13.5 KiB · text History 7f6cefe
defmodule Mix.Tasks.Diogramos.AndroidFixtures do
  @moduledoc """
  Regenerates the golden fixtures the Android unit tests assert against.

      mix diogramos.android_fixtures

  Two modules exist twice — `ConnectorGeometry` and the op application in
  `Document` — because both have to run on the device: connectors
  re-route on every frame of a drag, and an edit applies optimistically
  before the server has replied. Two implementations of the same rules
  drift silently: nothing errors, the phone just draws or stores
  something the server wouldn't.

  So the Elixir modules are the authority, and this task dumps their
  output for a spread of inputs into JSON the Kotlin tests read. Change
  the behaviour on either side and those tests fail 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, Document}

  @shortdoc "Regenerates the Android golden fixtures"
  @geometry_output "android/app/src/test/resources/connector_geometry.json"
  @ops_output "android/app/src/test/resources/document_ops.json"

  @impl Mix.Task
  def run(_args) do
    write_geometry()
    write_ops()
  end

  defp write_geometry 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
    }

    write!(@geometry_output, payload)
    Mix.shell().info("wrote #{length(cases)} geometry cases to #{@geometry_output}")
  end

  defp write_ops do
    cases = op_cases()

    payload = %{
      "generated_by" => "mix diogramos.android_fixtures",
      "cases" => cases
    }

    write!(@ops_output, payload)
    Mix.shell().info("wrote #{length(cases)} op cases to #{@ops_output}")
  end

  defp write!(path, payload) do
    File.mkdir_p!(Path.dirname(path))
    File.write!(path, Jason.encode!(payload, pretty: true) <> "\n")
  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

  ## Op application -------------------------------------------------------

  # A document with one of everything, so ops land on real neighbours
  # rather than on an empty map.
  defp ops_base do
    %{
      "elements" => %{
        "a" => %{
          "id" => "a",
          "type" => "rect",
          "x" => 0,
          "y" => 0,
          "w" => 100,
          "h" => 60,
          "label" => "A",
          "style" => %{"fill" => "primary", "stroke" => "base-content", "stroke_width" => 2}
        },
        "b" => %{"id" => "b", "type" => "circle", "cx" => 200, "cy" => 100, "r" => 30},
        "c" => %{"id" => "c", "type" => "text", "x" => 10, "y" => 200, "w" => 80, "h" => 20}
      },
      "order" => ["a", "b", "c"],
      "connectors" => %{
        "k1" => %{
          "id" => "k1",
          "from" => %{"element" => "a", "anchor" => "e"},
          "to" => %{"element" => "b", "anchor" => "w"},
          "routing" => "orthogonal",
          "dash" => "solid",
          "marker_start" => "none",
          "marker_end" => "arrow",
          "start_gap" => 6,
          "end_gap" => 6,
          "stroke_width" => 2,
          "color" => "base-content",
          "label" => ""
        },
        "k2" => %{
          "id" => "k2",
          "from" => %{"element" => "b", "anchor" => "s"},
          "to" => %{"element" => "c", "anchor" => "n"},
          "routing" => "curve",
          "dash" => "solid",
          "marker_start" => "none",
          "marker_end" => "arrow",
          "start_gap" => 6,
          "end_gap" => 6,
          "stroke_width" => 2,
          "color" => "base-content",
          "label" => ""
        }
      }
    }
  end

  defp op_cases do
    ops = [
      # Inserts, including the defaults apply_op fills in.
      {"insert rect",
       %{
         "type" => "insert_element",
         "element" => %{"id" => "new", "type" => "rect", "x" => 5, "y" => 5, "w" => 10, "h" => 10}
       }},
      {"insert circle",
       %{
         "type" => "insert_element",
         "element" => %{"id" => "new", "type" => "circle", "cx" => 1, "cy" => 2, "r" => 3}
       }},
      {"insert duplicate id",
       %{
         "type" => "insert_element",
         "element" => %{"id" => "a", "type" => "rect", "x" => 0, "y" => 0, "w" => 1, "h" => 1}
       }},
      {"insert invalid geometry",
       %{
         "type" => "insert_element",
         "element" => %{"id" => "bad", "type" => "rect", "x" => 0, "y" => 0, "w" => -5, "h" => 1}
       }},
      {"insert unknown type",
       %{
         "type" => "insert_element",
         "element" => %{
           "id" => "bad",
           "type" => "hexagon",
           "x" => 0,
           "y" => 0,
           "w" => 1,
           "h" => 1
         }
       }},

      # Updates: partial patches, deep merges, and the immutability rules.
      {"move element", %{"type" => "update_element", "id" => "a", "patch" => %{"x" => 40.5}}},
      {"resize element",
       %{"type" => "update_element", "id" => "a", "patch" => %{"w" => 150, "h" => 90}}},
      {"relabel element",
       %{"type" => "update_element", "id" => "a", "patch" => %{"label" => "Renamed"}}},
      {"patch one style key",
       %{"type" => "update_element", "id" => "a", "patch" => %{"style" => %{"fill" => "success"}}}},
      {"patch style on an element with none",
       %{"type" => "update_element", "id" => "b", "patch" => %{"style" => %{"fill" => "error"}}}},
      {"move circle",
       %{"type" => "update_element", "id" => "b", "patch" => %{"cx" => 250, "cy" => 150}}},
      {"update missing element",
       %{"type" => "update_element", "id" => "ghost", "patch" => %{"x" => 1}}},
      {"update with invalid color",
       %{
         "type" => "update_element",
         "id" => "a",
         "patch" => %{"style" => %{"fill" => "chartreuse"}}
       }},
      {"update to invalid geometry",
       %{"type" => "update_element", "id" => "a", "patch" => %{"w" => 0}}},
      {"update cannot change id",
       %{"type" => "update_element", "id" => "a", "patch" => %{"id" => "other"}}},
      {"update cannot change type",
       %{"type" => "update_element", "id" => "a", "patch" => %{"type" => "circle"}}},

      # Deletes, including the connector cascade.
      {"delete element with connectors", %{"type" => "delete_element", "id" => "b"}},
      {"delete element without connectors", %{"type" => "delete_element", "id" => "unconnected"}},
      {"delete missing element", %{"type" => "delete_element", "id" => "ghost"}},

      # Connectors.
      {"insert connector",
       %{
         "type" => "insert_connector",
         "connector" => %{
           "id" => "k3",
           "from" => %{"element" => "a"},
           "to" => %{"element" => "c"}
         }
       }},
      {"insert connector with a missing endpoint",
       %{
         "type" => "insert_connector",
         "connector" => %{
           "id" => "k4",
           "from" => %{"element" => "a"},
           "to" => %{"element" => "ghost"}
         }
       }},
      {"insert duplicate connector",
       %{
         "type" => "insert_connector",
         "connector" => %{
           "id" => "k1",
           "from" => %{"element" => "a"},
           "to" => %{"element" => "c"}
         }
       }},
      {"update connector routing",
       %{"type" => "update_connector", "id" => "k1", "patch" => %{"routing" => "straight"}}},
      {"update connector anchor",
       %{
         "type" => "update_connector",
         "id" => "k1",
         "patch" => %{"from" => %{"anchor" => "n"}}
       }},
      {"update connector invalid routing",
       %{"type" => "update_connector", "id" => "k1", "patch" => %{"routing" => "zigzag"}}},
      {"delete connector", %{"type" => "delete_connector", "id" => "k1"}},
      {"delete missing connector", %{"type" => "delete_connector", "id" => "ghost"}},

      # Z order.
      {"reorder", %{"type" => "set_z_order", "order" => ["c", "a", "b"]}},
      {"reorder with a missing id", %{"type" => "set_z_order", "order" => ["a", "b"]}},
      {"reorder with an unknown id", %{"type" => "set_z_order", "order" => ["a", "b", "c", "x"]}},

      # Not an op at all.
      {"unknown op", %{"type" => "explode"}},
      {"op with no type", %{"id" => "a"}}
    ]

    base = ops_base()

    Enum.map(ops, fn {name, op} ->
      document =
        if name == "delete element without connectors" do
          put_in(base, ["elements", "unconnected"], %{
            "id" => "unconnected",
            "type" => "rect",
            "x" => 500,
            "y" => 500,
            "w" => 10,
            "h" => 10
          })
          |> Map.update!("order", &(&1 ++ ["unconnected"]))
        else
          base
        end

      case Document.apply_op(document, op) do
        {:ok, result} ->
          %{"name" => name, "document" => document, "op" => op, "ok" => true, "result" => result}

        {:error, reason} ->
          %{
            "name" => name,
            "document" => document,
            "op" => op,
            "ok" => false,
            "error" => to_string(reason)
          }
      end
    end)
  end
end