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}},
# What the editor actually stores: the JS that creates a connector
# writes `_other` into each anchor naming the opposite endpoint
# (canvas_live/edit.ex), and that is what makes "auto" pick the
# nearest edge instead of always leaving east. Without these cases
# the matrix only ever exercised the fallback.
{%{"anchor" => "auto", "_other" => "b"}, %{"anchor" => "auto", "_other" => "a"}},
{%{"anchor" => "auto", "_other" => "b"}, %{"anchor" => "w"}},
{%{"anchor" => "n"}, %{"anchor" => "auto", "_other" => "a"}},
# A hint pointing at an element that no longer exists falls back.
{%{"anchor" => "auto", "_other" => "ghost"}, %{"anchor" => "auto", "_other" => "a"}}
]
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"}}},
# The server accepts an unrecognised anchor string -- valid_anchor?/1
# falls through to the catch-all map clause. Pinned so the port
# doesn't get stricter than the thing it mirrors.
{"update connector unknown anchor",
%{
"type" => "update_connector",
"id" => "k1",
"patch" => %{"from" => %{"anchor" => "northwest"}}
}},
{"insert connector with an unknown anchor",
%{
"type" => "insert_connector",
"connector" => %{
"id" => "k5",
"from" => %{"element" => "a", "anchor" => "sideways"},
"to" => %{"element" => "c"}
}
}},
{"insert connector with a point anchor missing coordinates",
%{
"type" => "insert_connector",
"connector" => %{
"id" => "k6",
"from" => %{"element" => "a", "anchor" => "point"},
"to" => %{"element" => "c"}
}
}},
{"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
neiam
/diogramos
diogramos
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
Log in to mark this repository.
15.1 KiB · text
History
10ab3fb