From 99ed66898333c10fa62720ceceefb67db44cdd5a Mon Sep 17 00:00:00 2001 From: GenericJam Date: Thu, 18 Jun 2026 19:20:59 -0600 Subject: [PATCH 1/4] =?UTF-8?q?feat(test):=20Mob.ScreenCase=20=E2=80=94=20?= =?UTF-8?q?blessed=20in-BEAM=20screen=20testing=20(prototype)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tier-1 screen unit testing, the Phoenix.LiveViewTest analog. `use Mob.ScreenCase` gives mount_screen/3, render_event/3, render_info/2, assigns/1, plus tree queries (tree/find/find_all/text/flatten) whose vocabulary mirrors Mob.Test (the device driver), so the same assertions read identically in-BEAM or, later, on device. Key difference from LiveView: render returns a typed view tree (%{type, props, children}), so assertions query real data, not HTML strings. assert_renderable/2 adds a tier-2 contract check: every node type must be renderable, derived at compile time from priv/tags/{ios,android}.txt (the same authoritative source the ~MOB sigil validates against) plus :native_view, with an :extra opt for plugin/custom types. Catches 'emitted a node the native layer cannot draw' at mix test time, no device. 15 tests; credo --strict clean. Prototype for review. Co-Authored-By: Claude Opus 4.8 --- lib/mob/screen_case.ex | 229 ++++++++++++++++++++++++++++++++++ test/mob/screen_case_test.exs | 136 ++++++++++++++++++++ 2 files changed, 365 insertions(+) create mode 100644 lib/mob/screen_case.ex create mode 100644 test/mob/screen_case_test.exs diff --git a/lib/mob/screen_case.ex b/lib/mob/screen_case.ex new file mode 100644 index 00000000..a855360a --- /dev/null +++ b/lib/mob/screen_case.ex @@ -0,0 +1,229 @@ +defmodule Mob.ScreenCase do + @moduledoc """ + The blessed way to unit-test a `Mob.Screen` in the BEAM, no device or + emulator required. The screen-level analog of `Phoenix.LiveViewTest`. + + A `Mob.Screen` is a GenServer-shaped module: `mount/3` builds state, + `handle_event/3` and `handle_info/2` mutate it, and `render/1` turns the + assigns into a **view tree** (plain data: `%{type:, props:, children:}`). That + last part is the key difference from LiveView: the screen produces a typed + data structure, not an HTML string, so assertions are tree queries against + real data instead of brittle string matching. + + This module drives those callbacks directly (the same thing the on-device + runtime does) and gives you query helpers whose vocabulary matches `Mob.Test` + (the device-side driver): `assigns/1`, `tree/1`, `find/3`, `flatten/1`. So a + test reads the same whether it runs here in milliseconds or, later, against a + real device. + + defmodule MyApp.CounterScreenTest do + use Mob.ScreenCase + + test "increment bumps the count and the rendered text" do + view = mount_screen(MyApp.CounterScreen) + assert assigns(view).count == 0 + + view = render_event(view, "increment") + assert assigns(view).count == 1 + assert text(view) =~ "Count: 1" + assert find(view, :button, tag: "increment") + + # cheap native-contract check: every node the screen emits is a + # type the Compose / SwiftUI layer actually renders. + assert_renderable(view) + end + end + + ## What this does and does not catch + + This is tier 1 of the testing pyramid: it exercises **logic, state, and the + shape of the view tree**, fast and deterministically. `assert_renderable/2` + adds a tier-2 **contract** check (does the tree only use renderable node + types). Neither runs the native layer, so they cannot catch a node that + renders wrong or behaves wrong on a real iOS/Android build. That needs a + device test driven through `Mob.Test`. Weight your suite heavily toward this + module, with a thin band of device tests for the things only hardware proves. + """ + + use ExUnit.CaseTemplate + + using do + quote do + import Mob.ScreenCase + end + end + + defmodule View do + @moduledoc "A mounted screen under test: the screen module plus its current socket." + @enforce_keys [:module, :socket] + defstruct [:module, :socket] + end + + # Renderable node types, derived at compile time from the same authoritative + # source the ~MOB sigil validates against (priv/tags/{ios,android}.txt, one + # PascalCase tag per line, converted to the snake_case `:type` atom the same + # way the sigil does). Plus `:native_view`, the runtime-only escape hatch that + # plugin / custom components serialize to and which has no template tag. + @renderable_types ( + read = fn name -> + path = Application.app_dir(:mob, "priv/tags/#{name}") + + case File.read(path) do + {:ok, body} -> + body + |> String.split("\n", trim: true) + |> Enum.reject(&(&1 == "" or String.starts_with?(&1, "#"))) + |> Enum.map(&(&1 |> Macro.underscore() |> String.to_atom())) + + _ -> + [] + end + end + + (read.("ios.txt") ++ read.("android.txt") ++ [:native_view]) + |> MapSet.new() + ) + + @doc """ + The set of node types the native layer can render: the core component tags + plus `:native_view`. The contract surface `assert_renderable/2` checks against. + """ + @spec renderable_types() :: MapSet.t(atom()) + def renderable_types, do: @renderable_types + + # ── Driving a screen ─────────────────────────────────────────────────────── + + @doc """ + Mount a screen and return a `View` handle. Calls `Mob.Socket.new/1` then the + screen's `mount/3`, asserting it returns `{:ok, socket}`. + """ + @spec mount_screen(module(), map(), map()) :: View.t() + def mount_screen(module, params \\ %{}, session \\ %{}) when is_atom(module) do + socket = Mob.Socket.new(module) + + case module.mount(params, session, socket) do + {:ok, %Mob.Socket{} = socket} -> + %View{module: module, socket: socket} + + other -> + raise ArgumentError, + "#{inspect(module)}.mount/3 must return {:ok, socket}, got: #{inspect(other)}" + end + end + + @doc "Dispatch a `handle_event/3` (the explicit-event style) and return the updated `View`." + @spec render_event(View.t(), String.t(), map()) :: View.t() + def render_event(%View{module: module, socket: socket} = view, event, params \\ %{}) + when is_binary(event) do + {:noreply, socket} = module.handle_event(event, params, socket) + %{view | socket: socket} + end + + @doc """ + Deliver a message to the screen's `handle_info/2` and return the updated + `View`. This is how taps reach a screen on device (a `Button`'s `on_tap` + sends a message), so it is the in-BEAM equivalent of a tap. + """ + @spec render_info(View.t(), term()) :: View.t() + def render_info(%View{module: module, socket: socket} = view, message) do + {:noreply, socket} = module.handle_info(message, socket) + %{view | socket: socket} + end + + @doc "The screen's current assigns. Mirrors `Mob.Test.assigns/1`." + @spec assigns(View.t()) :: map() + def assigns(%View{socket: socket}), do: socket.assigns + + # ── Querying the rendered tree ─────────────────────────────────────────────── + + @doc "Render the screen to its current view tree. Mirrors `Mob.Test.tree/1`." + @spec tree(View.t() | map()) :: map() + def tree(%View{module: module, socket: socket}), do: module.render(socket.assigns) + def tree(%{type: _} = node), do: node + + @doc "Every node in the tree, depth-first. Mirrors `Mob.Test.flatten_tree/1`." + @spec flatten(View.t() | map()) :: [map()] + def flatten(view_or_tree), do: do_flatten(tree(view_or_tree)) + + defp do_flatten(%{type: _} = node) do + children = Map.get(node, :children, []) || [] + [node | Enum.flat_map(List.wrap(children), &do_flatten/1)] + end + + defp do_flatten(_), do: [] + + @doc """ + All nodes of `type` whose props are a superset of `props`. Mirrors + `Mob.Test.find/2`, but matches on the typed tree rather than a substring. + + find_all(view, :button, tag: "increment") + """ + @spec find_all(View.t() | map(), atom(), keyword()) :: [map()] + def find_all(view_or_tree, type, props \\ []) when is_atom(type) do + want = Map.new(props) + + view_or_tree + |> flatten() + |> Enum.filter(fn node -> + node.type == type and props_match?(Map.get(node, :props, %{}), want) + end) + end + + @doc "The first node matching `find_all/3`, or `nil`." + @spec find(View.t() | map(), atom(), keyword()) :: map() | nil + def find(view_or_tree, type, props \\ []) do + view_or_tree |> find_all(type, props) |> List.first() + end + + @doc "Concatenated text of every `:text` node in the tree, joined by spaces." + @spec text(View.t() | map()) :: String.t() + def text(view_or_tree) do + view_or_tree + |> find_all(:text) + |> Enum.map(&(&1.props[:text] || "")) + |> Enum.join(" ") + end + + defp props_match?(have, want) do + Enum.all?(want, fn {k, v} -> Map.get(have, k) == v end) + end + + # ── The native contract check (tier 2) ────────────────────────────────────── + + @doc """ + Assert every node in the tree is a type the native layer can render. Returns + the tree on success so it composes; flunks (with the offending types) if a + node uses a type that has no Compose / SwiftUI renderer. + + This catches the "you emitted a node the native side can't draw" class of bug + at `mix test` time, no device needed. Pass extra types a plugin or your own + app registers via `:extra`: + + assert_renderable(view, extra: [:gauge]) + """ + @spec assert_renderable(View.t() | map(), keyword()) :: map() + def assert_renderable(view_or_tree, opts \\ []) do + tree = tree(view_or_tree) + allowed = MapSet.union(@renderable_types, MapSet.new(Keyword.get(opts, :extra, []))) + + offenders = + tree + |> do_flatten() + |> Enum.map(& &1.type) + |> Enum.uniq() + |> Enum.reject(&MapSet.member?(allowed, &1)) + + if offenders == [] do + tree + else + ExUnit.Assertions.flunk(""" + view tree uses node type(s) the native layer cannot render: #{inspect(offenders)} + + Renderable types come from mob's priv/tags/{ios,android}.txt (plus :native_view). + If one of these is a plugin or custom component, pass it via + `assert_renderable(view, extra: #{inspect(offenders)})`. Otherwise it is + likely a typo or a component with no registered native renderer. + """) + end + end +end diff --git a/test/mob/screen_case_test.exs b/test/mob/screen_case_test.exs new file mode 100644 index 00000000..1a32665c --- /dev/null +++ b/test/mob/screen_case_test.exs @@ -0,0 +1,136 @@ +defmodule Mob.ScreenCaseTest do + use Mob.ScreenCase, async: true + + # A realistic fixture: core node types only, an explicit event, and a + # tap-via-message path with a catch-all (the shape real screens use). + defmodule CounterScreen do + use Mob.Screen + + def mount(params, _session, socket) do + {:ok, Mob.Socket.assign(socket, :count, Map.get(params, :start, 0))} + end + + def render(assigns) do + %{ + type: :column, + props: %{}, + children: [ + %{type: :text, props: %{text: "Count: #{assigns.count}"}, children: []}, + %{type: :button, props: %{tag: "increment", label: "Add one"}, children: []} + ] + } + end + + def handle_event("increment", _params, socket) do + {:noreply, Mob.Socket.assign(socket, :count, socket.assigns.count + 1)} + end + + def handle_info({:tap, :inc}, socket) do + {:noreply, Mob.Socket.assign(socket, :count, socket.assigns.count + 1)} + end + + def handle_info(_message, socket), do: {:noreply, socket} + end + + # Renders a node type the native layer has no renderer for. + defmodule BadScreen do + use Mob.Screen + + def mount(_params, _session, socket), do: {:ok, socket} + + def render(_assigns) do + %{type: :column, props: %{}, children: [%{type: :hologram, props: %{}, children: []}]} + end + end + + describe "mount_screen/3 + assigns/1" do + test "mounts with initial assigns" do + assert assigns(mount_screen(CounterScreen)).count == 0 + end + + test "passes params through to mount/3" do + assert assigns(mount_screen(CounterScreen, %{start: 5})).count == 5 + end + end + + describe "render_event/3" do + test "dispatches handle_event and updates state + rendered text" do + view = CounterScreen |> mount_screen() |> render_event("increment") + assert assigns(view).count == 1 + assert text(view) =~ "Count: 1" + end + + test "is chainable" do + view = + CounterScreen |> mount_screen() |> render_event("increment") |> render_event("increment") + + assert assigns(view).count == 2 + end + end + + describe "render_info/2 (the tap path)" do + test "delivers a message that handle_info acts on" do + view = CounterScreen |> mount_screen() |> render_info({:tap, :inc}) + assert assigns(view).count == 1 + end + + test "an unhandled message hits the catch-all and noops" do + view = CounterScreen |> mount_screen() |> render_info(:whatever) + assert assigns(view).count == 0 + end + end + + describe "tree queries" do + setup do + {:ok, view: mount_screen(CounterScreen)} + end + + test "find/3 matches by type and a prop subset", %{view: view} do + assert %{type: :button, props: %{label: "Add one"}} = find(view, :button, tag: "increment") + assert find(view, :button, tag: "nope") == nil + end + + test "find_all/3 returns every match", %{view: view} do + assert length(find_all(view, :text)) == 1 + end + + test "flatten/1 walks the whole tree depth-first", %{view: view} do + assert Enum.map(flatten(view), & &1.type) == [:column, :text, :button] + end + + test "text/1 concatenates :text nodes", %{view: view} do + assert text(view) == "Count: 0" + end + + test "query helpers also accept a raw tree, not just a View", %{view: view} do + raw = tree(view) + assert find(raw, :button, tag: "increment") + assert text(raw) == "Count: 0" + end + end + + describe "assert_renderable/2" do + test "passes for a tree of core node types" do + assert %{type: :column} = assert_renderable(mount_screen(CounterScreen)) + end + + test "flunks on a type with no native renderer" do + view = mount_screen(BadScreen) + + assert_raise ExUnit.AssertionError, ~r/hologram/, fn -> + assert_renderable(view) + end + end + + test ":extra allows a plugin/custom type through" do + assert assert_renderable(mount_screen(BadScreen), extra: [:hologram]) + end + + test "renderable_types includes core tags and the native_view escape hatch" do + types = renderable_types() + assert MapSet.member?(types, :column) + assert MapSet.member?(types, :text) + assert MapSet.member?(types, :native_view) + end + end +end From cf68769283abb93ccf8ac226b4d6048d9275af39 Mon Sep 17 00:00:00 2001 From: GenericJam Date: Thu, 18 Jun 2026 19:41:20 -0600 Subject: [PATCH 2/4] Mob.ScreenCase: boot Mob.State in setup (found via the mob.new scaffold) Scaffolding a real test from mob.new surfaced this: the generated home screen reads the theme from Mob.State in mount/3, and Mob.State is DETS-backed, so every screen that touches it crashed in mix test with a :dets argument error. A blessed screen Case should own that runtime, like ConnCase owns the Ecto sandbox. The setup starts Mob.State per test against a throwaway MOB_DATA_DIR so screen tests just work and never pollute the app's real dev state. Idempotent (reuses an already-running Mob.State). Existing 15 tests still pass. Co-Authored-By: Claude Opus 4.8 --- lib/mob/screen_case.ex | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/lib/mob/screen_case.ex b/lib/mob/screen_case.ex index a855360a..4b3cd004 100644 --- a/lib/mob/screen_case.ex +++ b/lib/mob/screen_case.ex @@ -53,6 +53,23 @@ defmodule Mob.ScreenCase do end end + # Many screens read or write `Mob.State` in `mount/3` (the home screen reads + # the theme, for one), and it is DETS-backed, so without it open every such + # screen crashes in `mix test` with a `:dets` argument error. Start it per + # test against a throwaway data dir, the same way ConnCase starts the Ecto + # sandbox, so screen tests just work and never touch the app's real dev state. + setup do + if Process.whereis(Mob.State) == nil do + tmp = Path.join(System.tmp_dir!(), "mob_screen_case_#{System.unique_integer([:positive])}") + File.mkdir_p!(tmp) + System.put_env("MOB_DATA_DIR", tmp) + ExUnit.Callbacks.start_supervised!(Mob.State) + ExUnit.Callbacks.on_exit(fn -> File.rm_rf(tmp) end) + end + + :ok + end + defmodule View do @moduledoc "A mounted screen under test: the screen module plus its current socket." @enforce_keys [:module, :socket] From d6ae2f661bf755e9974249d90fe0dd1ac78c4b1d Mon Sep 17 00:00:00 2001 From: GenericJam Date: Thu, 18 Jun 2026 20:09:18 -0600 Subject: [PATCH 3/4] Mob.ScreenCase: navigated_to/1 + a device (@tag :on_device) backend navigated_to/1: assert what an event navigated to. In-BEAM it reads the nav action Mob.Socket.push_screen/3 records on the socket (e.g. {:push, Dest, params}); on device it returns the live screen via Mob.Test. Device backend: the View now carries a :source (:beam | :device). device_view/1 wraps a running node, and tree/1, assigns/1, navigated_to/1 dispatch to Mob.Test over Erlang-distribution RPC. The query + assertion layer (find/text/flatten/ assert_renderable) is tree-based, so the SAME assertions run in-BEAM or against hardware; only driving differs (render_event/render_info in-BEAM, Mob.Test.tap/ navigate on device). Device example is @tag :on_device (mob's auto-excluded tag). 18 tests pass + 1 excluded device example; the mob.new scaffold still passes against the updated helper. credo clean. Co-Authored-By: Claude Opus 4.8 --- lib/mob/screen_case.ex | 77 +++++++++++++++++++++++++++++------ test/mob/screen_case_test.exs | 49 ++++++++++++++++++++++ 2 files changed, 114 insertions(+), 12 deletions(-) diff --git a/lib/mob/screen_case.ex b/lib/mob/screen_case.ex index 4b3cd004..a92df535 100644 --- a/lib/mob/screen_case.ex +++ b/lib/mob/screen_case.ex @@ -71,9 +71,21 @@ defmodule Mob.ScreenCase do end defmodule View do - @moduledoc "A mounted screen under test: the screen module plus its current socket." - @enforce_keys [:module, :socket] - defstruct [:module, :socket] + @moduledoc """ + A screen under test. Two backends, same query/assertion surface: + + * `source: :beam` — `module` + `socket`, driven in-process (the default, + built by `mount_screen/3`). + * `source: :device` — a `node` running the app, read over `Mob.Test`'s + Erlang-distribution RPC (built by `device_view/1`, gated behind + `@tag :on_device`). + + `tree/1`, `assigns/1`, `find/3`, `text/1`, `assert_renderable/2` and + `navigated_to/1` work against either, so an assertion reads the same whether + it ran here in milliseconds or against real hardware. + """ + @enforce_keys [:source] + defstruct [:source, :module, :socket, :node] end # Renderable node types, derived at compile time from the same authoritative @@ -120,7 +132,7 @@ defmodule Mob.ScreenCase do case module.mount(params, session, socket) do {:ok, %Mob.Socket{} = socket} -> - %View{module: module, socket: socket} + %View{source: :beam, module: module, socket: socket} other -> raise ArgumentError, @@ -128,9 +140,35 @@ defmodule Mob.ScreenCase do end end - @doc "Dispatch a `handle_event/3` (the explicit-event style) and return the updated `View`." + @doc """ + Wrap a running device `node` as a `View` so the query/assertion helpers read + it over `Mob.Test`'s Erlang-distribution RPC. The device-backed counterpart to + `mount_screen/3`. Use behind `@tag :on_device`; get the `node` from + `mix mob.connect` / `Mob.Test`. Driving (navigate, tap) stays on `Mob.Test`; + this is for asserting against the live screen with the same helpers. + + @tag :on_device + test "the live home screen is renderable" do + node = :"my_app_android@127.0.0.1" + Mob.Test.navigate(node, MyApp.HomeScreen) + view = device_view(node) + assert_renderable(view) + assert navigated_to(view) == MyApp.HomeScreen + end + """ + @spec device_view(node()) :: View.t() + def device_view(node) when is_atom(node), do: %View{source: :device, node: node} + + @doc """ + Dispatch a `handle_event/3` (the explicit-event style) and return the updated + `View`. In-BEAM only; on a device, drive with `Mob.Test.tap/2`. + """ @spec render_event(View.t(), String.t(), map()) :: View.t() - def render_event(%View{module: module, socket: socket} = view, event, params \\ %{}) + def render_event( + %View{source: :beam, module: module, socket: socket} = view, + event, + params \\ %{} + ) when is_binary(event) do {:noreply, socket} = module.handle_event(event, params, socket) %{view | socket: socket} @@ -139,23 +177,38 @@ defmodule Mob.ScreenCase do @doc """ Deliver a message to the screen's `handle_info/2` and return the updated `View`. This is how taps reach a screen on device (a `Button`'s `on_tap` - sends a message), so it is the in-BEAM equivalent of a tap. + sends a message), so it is the in-BEAM equivalent of a tap. In-BEAM only. """ @spec render_info(View.t(), term()) :: View.t() - def render_info(%View{module: module, socket: socket} = view, message) do + def render_info(%View{source: :beam, module: module, socket: socket} = view, message) do {:noreply, socket} = module.handle_info(message, socket) %{view | socket: socket} end - @doc "The screen's current assigns. Mirrors `Mob.Test.assigns/1`." + @doc "The screen's current assigns. Mirrors `Mob.Test.assigns/1` (and uses it on device)." @spec assigns(View.t()) :: map() - def assigns(%View{socket: socket}), do: socket.assigns + def assigns(%View{source: :beam, socket: socket}), do: socket.assigns + def assigns(%View{source: :device, node: node}), do: Mob.Test.assigns(node) + + @doc """ + The screen the last event navigated to, or `nil` if none. + + * in-BEAM: the navigation action recorded on the socket by + `Mob.Socket.push_screen/3` and friends, e.g. `{:push, Dest, params}`. + * on device: the screen currently showing (`Mob.Test.screen/1`). + """ + @spec navigated_to(View.t()) :: term() | nil + def navigated_to(%View{source: :beam, socket: socket}), do: Map.get(socket.__mob__, :nav_action) + def navigated_to(%View{source: :device, node: node}), do: Mob.Test.screen(node) # ── Querying the rendered tree ─────────────────────────────────────────────── - @doc "Render the screen to its current view tree. Mirrors `Mob.Test.tree/1`." + @doc "The current view tree, from in-BEAM render or the device. Mirrors `Mob.Test.tree/1`." @spec tree(View.t() | map()) :: map() - def tree(%View{module: module, socket: socket}), do: module.render(socket.assigns) + def tree(%View{source: :beam, module: module, socket: socket}), + do: module.render(socket.assigns) + + def tree(%View{source: :device, node: node}), do: Mob.Test.view_tree(node) def tree(%{type: _} = node), do: node @doc "Every node in the tree, depth-first. Mirrors `Mob.Test.flatten_tree/1`." diff --git a/test/mob/screen_case_test.exs b/test/mob/screen_case_test.exs index 1a32665c..f900e544 100644 --- a/test/mob/screen_case_test.exs +++ b/test/mob/screen_case_test.exs @@ -43,6 +43,24 @@ defmodule Mob.ScreenCaseTest do end end + # Pushes another screen, both from an explicit event and from a tap message. + defmodule NavScreen do + use Mob.Screen + + def mount(_params, _session, socket), do: {:ok, socket} + def render(_assigns), do: %{type: :column, props: %{}, children: []} + + def handle_event("go", _params, socket) do + {:noreply, Mob.Socket.push_screen(socket, CounterScreen)} + end + + def handle_info({:tap, :go}, socket) do + {:noreply, Mob.Socket.push_screen(socket, CounterScreen)} + end + + def handle_info(_message, socket), do: {:noreply, socket} + end + describe "mount_screen/3 + assigns/1" do test "mounts with initial assigns" do assert assigns(mount_screen(CounterScreen)).count == 0 @@ -133,4 +151,35 @@ defmodule Mob.ScreenCaseTest do assert MapSet.member?(types, :native_view) end end + + describe "navigated_to/1" do + test "nil before any navigation" do + assert navigated_to(mount_screen(CounterScreen)) == nil + end + + test "records a push from an explicit event" do + view = NavScreen |> mount_screen() |> render_event("go") + assert navigated_to(view) == {:push, CounterScreen, %{}} + end + + test "records a push from a tap (handle_info)" do + view = NavScreen |> mount_screen() |> render_info({:tap, :go}) + assert navigated_to(view) == {:push, CounterScreen, %{}} + end + end + + # The same assertion helpers, pointed at a live device over Mob.Test instead + # of an in-process socket. Excluded by default (needs hardware + a connected + # node); shown here as the worked example of the device backend. + describe "device backend (@tag :on_device)" do + @tag :on_device + test "the same assertions run against a live device node" do + node = :"mob_screen_case_demo@127.0.0.1" + Mob.Test.navigate(node, CounterScreen) + + view = device_view(node) + assert_renderable(view) + assert navigated_to(view) == CounterScreen + end + end end From 4c10df7ab5810488b138b68c53d9dca1b1763d9f Mon Sep 17 00:00:00 2001 From: GenericJam Date: Fri, 19 Jun 2026 00:49:31 -0600 Subject: [PATCH 4/4] Fix Mob.ScreenCase device backend: correct tree/1 routing, pin it, normalize navigated_to - tree/1 :device clause called Mob.Test.view_tree/1 (native accessibility tree, %{type,label,value,frame}) when the query helpers expect the logical render tree (%{type,props,children}). Route to Mob.Test.tree/1 instead. - Add a node-less unit test pinning the :device dispatch: against a down node Mob.Test.tree/1 raises BadMapError (it does rpc(...).tree) while view_tree/1 returns the tuple, so asserting the raise proves the routing without a device. - navigated_to/1 returned the raw nav tuple ({:push, Dest, params}) on :beam but a bare module on :device, breaking the same-assertion promise. Normalize destination-bearing actions ({:push,_,_}/{:reset,_,_}/{:pop_to,_}) to the destination module; leave destinationless actions unchanged. Update docs/tests. Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/mob/screen_case.ex | 24 ++++++++++++++++++++---- test/mob/screen_case_test.exs | 24 ++++++++++++++++++++---- 2 files changed, 40 insertions(+), 8 deletions(-) diff --git a/lib/mob/screen_case.ex b/lib/mob/screen_case.ex index a92df535..c4f80064 100644 --- a/lib/mob/screen_case.ex +++ b/lib/mob/screen_case.ex @@ -193,12 +193,28 @@ defmodule Mob.ScreenCase do @doc """ The screen the last event navigated to, or `nil` if none. - * in-BEAM: the navigation action recorded on the socket by - `Mob.Socket.push_screen/3` and friends, e.g. `{:push, Dest, params}`. + Returns the destination **module** on both backends, so the same assertion + reads identically whether the test ran in-BEAM or against a device: + + assert navigated_to(view) == MyApp.CounterScreen + + * in-BEAM: the destination of the nav action recorded on the socket by + `Mob.Socket.push_screen/3` and friends. Destination-bearing actions + (`{:push, Dest, _}`, `{:reset, Dest, _}`, `{:pop_to, Dest}`) return + `Dest`; destinationless ones (`{:pop}`, `{:pop_to_root}`, + `{:switch_tab, tab}`) return the raw action unchanged. * on device: the screen currently showing (`Mob.Test.screen/1`). """ @spec navigated_to(View.t()) :: term() | nil - def navigated_to(%View{source: :beam, socket: socket}), do: Map.get(socket.__mob__, :nav_action) + def navigated_to(%View{source: :beam, socket: socket}) do + case Map.get(socket.__mob__, :nav_action) do + {:push, dest, _params} -> dest + {:reset, dest, _params} -> dest + {:pop_to, dest} -> dest + other -> other + end + end + def navigated_to(%View{source: :device, node: node}), do: Mob.Test.screen(node) # ── Querying the rendered tree ─────────────────────────────────────────────── @@ -208,7 +224,7 @@ defmodule Mob.ScreenCase do def tree(%View{source: :beam, module: module, socket: socket}), do: module.render(socket.assigns) - def tree(%View{source: :device, node: node}), do: Mob.Test.view_tree(node) + def tree(%View{source: :device, node: node}), do: Mob.Test.tree(node) def tree(%{type: _} = node), do: node @doc "Every node in the tree, depth-first. Mirrors `Mob.Test.flatten_tree/1`." diff --git a/test/mob/screen_case_test.exs b/test/mob/screen_case_test.exs index f900e544..28e89817 100644 --- a/test/mob/screen_case_test.exs +++ b/test/mob/screen_case_test.exs @@ -157,14 +157,30 @@ defmodule Mob.ScreenCaseTest do assert navigated_to(mount_screen(CounterScreen)) == nil end - test "records a push from an explicit event" do + test "records a push from an explicit event as the destination module" do view = NavScreen |> mount_screen() |> render_event("go") - assert navigated_to(view) == {:push, CounterScreen, %{}} + assert navigated_to(view) == CounterScreen end - test "records a push from a tap (handle_info)" do + test "records a push from a tap (handle_info) as the destination module" do view = NavScreen |> mount_screen() |> render_info({:tap, :go}) - assert navigated_to(view) == {:push, CounterScreen, %{}} + assert navigated_to(view) == CounterScreen + end + end + + # tree/1's :device clause must route to Mob.Test.tree/1 (the logical render + # tree, shape %{type, props, children}) — NOT Mob.Test.view_tree/1, which is + # the native accessibility tree (shape %{type, label, value, frame}) the query + # helpers can't read. The @tag :on_device test below is excluded by default, + # so this regression shipped undetected once; this pins the dispatch with no + # device by exploiting the two functions' divergent behavior against a down + # node: Mob.Test.tree/1 does `rpc(node, :inspect).tree` and so raises + # BadMapError on the `{:badrpc, :nodedown}` it gets back, whereas + # Mob.Test.view_tree/1 returns that tuple without raising. + describe "tree/1 :device dispatch" do + test "routes to Mob.Test.tree/1, not Mob.Test.view_tree/1" do + view = device_view(:"nonexistent_node@127.0.0.1") + assert_raise BadMapError, fn -> tree(view) end end end