6.0 KiB · text History 99f6896
defmodule Mix.Tasks.Diogramos.AndroidFixtures do
  @moduledoc """
  Regenerates the golden fixtures the Android `ConnectorGeometry` test
  asserts against.

      mix diogramos.android_fixtures

  `ConnectorGeometry` exists twice — here in Elixir for the web, and again
  in Kotlin for the phone — because connector routing has to happen
  locally to stay smooth under a finger. Two implementations of the same
  maths drift silently: nothing errors, diagrams just render subtly wrong
  on one of them.

  So the Elixir module is the authority, and this task dumps its output
  for a spread of documents into a JSON file the Kotlin unit test reads.
  Change the routing rules on either side and that test fails until both
  agree. Re-run this task whenever the Elixir behaviour changes on
  purpose, and commit the result.
  """
  use Mix.Task

  alias Diogramos.Diagrams.ConnectorGeometry

  @shortdoc "Regenerates android/app/src/test/resources/connector_geometry.json"
  @output "android/app/src/test/resources/connector_geometry.json"

  @impl Mix.Task
  def run(_args) do
    documents = documents()
    cases = Enum.flat_map(documents, &cases_for/1)

    # Documents are referenced by name rather than inlined per case: the
    # matrix is a few hundred cases over a handful of documents, and
    # inlining makes the file ten times bigger for no added coverage.
    payload = %{
      "generated_by" => "mix diogramos.android_fixtures",
      "documents" => Map.new(documents),
      "cases" => cases
    }

    File.mkdir_p!(Path.dirname(@output))
    File.write!(@output, Jason.encode!(payload, pretty: true) <> "\n")

    Mix.shell().info("wrote #{length(cases)} cases to #{@output}")
  end

  ## Documents ------------------------------------------------------------

  defp documents do
    [
      {"rects-apart", doc_rects()},
      {"circle-and-rect", doc_circle()},
      {"fractional", doc_fractional()},
      {"overlapping", doc_overlapping()}
    ]
  end

  defp doc_rects do
    elements(%{
      "a" => %{"id" => "a", "type" => "rect", "x" => 0, "y" => 0, "w" => 100, "h" => 60},
      "b" => %{"id" => "b", "type" => "rect", "x" => 300, "y" => 200, "w" => 120, "h" => 80}
    })
  end

  defp doc_circle do
    elements(%{
      "a" => %{"id" => "a", "type" => "circle", "cx" => 50, "cy" => 50, "r" => 40},
      "b" => %{"id" => "b", "type" => "rounded", "x" => -250, "y" => -120, "w" => 90, "h" => 50}
    })
  end

  # Exercises the number formatter: halves, thirds and values that round
  # at the second decimal all reach the path string.
  defp doc_fractional do
    elements(%{
      "a" => %{"id" => "a", "type" => "text", "x" => 1.5, "y" => 2.25, "w" => 33, "h" => 17},
      "b" => %{"id" => "b", "type" => "rect", "x" => 101.333, "y" => 0.5, "w" => 45.5, "h" => 21}
    })
  end

  defp doc_overlapping do
    elements(%{
      "a" => %{"id" => "a", "type" => "rect", "x" => 0, "y" => 0, "w" => 100, "h" => 100},
      "b" => %{"id" => "b", "type" => "rect", "x" => 20, "y" => 20, "w" => 100, "h" => 100}
    })
  end

  defp elements(map), do: %{"elements" => map, "order" => Map.keys(map), "connectors" => %{}}

  ## Connector matrix -----------------------------------------------------

  @routings ~w(straight orthogonal curve)
  @anchors ~w(auto n e s w)

  defp cases_for({doc_name, document}) do
    anchor_pairs =
      for from <- @anchors, to <- @anchors, do: {%{"anchor" => from}, %{"anchor" => to}}

    point_pairs = [
      {%{"anchor" => "point", "x" => 12.5, "y" => -3}, %{"anchor" => "e"}},
      {%{"anchor" => "e"}, %{"anchor" => "point", "x" => 400, "y" => 333.75}}
    ]

    base =
      for routing <- @routings,
          {from_anchor, to_anchor} <- anchor_pairs ++ point_pairs do
        build_case(doc_name, document, routing, from_anchor, to_anchor, %{})
      end

    # Styling doesn't change the path, but it does change everything else
    # in the payload, and the marker/dash defaults are easy to get wrong.
    styled =
      for overrides <- [
            %{"start_gap" => 0, "end_gap" => 0},
            %{"start_gap" => 12, "end_gap" => 3.5},
            %{"dash" => "dotted"},
            %{"dash" => "dashed"},
            %{"dash" => "dash-dot"},
            %{"stroke_width" => 0.5},
            %{"marker_start" => "diamond", "marker_end" => "none"},
            %{"marker_start" => "circle", "marker_end" => "filled-arrow"},
            # Every key absent: the defaults are part of the contract.
            :bare
          ] do
        build_case(
          doc_name,
          document,
          "straight",
          %{"anchor" => "e"},
          %{"anchor" => "w"},
          overrides
        )
      end

    Enum.reject(base ++ styled, &is_nil/1)
  end

  defp build_case(doc_name, document, routing, from_anchor, to_anchor, overrides) do
    connector =
      case overrides do
        :bare ->
          %{
            "id" => "k",
            "from" => Map.put(from_anchor, "element", "a"),
            "to" => Map.put(to_anchor, "element", "b")
          }

        map ->
          Map.merge(
            %{
              "id" => "k",
              "from" => Map.put(from_anchor, "element", "a"),
              "to" => Map.put(to_anchor, "element", "b"),
              "routing" => routing,
              "dash" => "solid",
              "marker_start" => "none",
              "marker_end" => "arrow",
              "start_gap" => 6,
              "end_gap" => 6,
              "stroke_width" => 2,
              "color" => "base-content"
            },
            map
          )
      end

    case ConnectorGeometry.render(connector, document) do
      nil ->
        nil

      rendered ->
        %{
          "name" => "#{doc_name}/#{routing}/#{from_anchor["anchor"]}->#{to_anchor["anchor"]}",
          "document" => doc_name,
          "connector" => connector,
          "expected" => %{
            "d" => rendered.d,
            "stroke_width" => rendered.stroke_width,
            "dash_array" => rendered.dash_array,
            "marker_start" => rendered.marker_start,
            "marker_end" => rendered.marker_end
          }
        }
    end
  end
end