Mob.ScreenCase: blessed in-BEAM screen testing (+ contract check, device backend) - #44
Merged
Conversation
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 <noreply@anthropic.com>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 <noreply@anthropic.com>
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 <noreply@anthropic.com>…rmalize 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) <noreply@anthropic.com>Uh oh!
There was an error while loading. Please reload this page.
GenericJam added a commit
that referenced
this pull request
Jun 19, 2026
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
GenericJam added a commit
that referenced
this pull request
Aug 30, 2026
…nt screen_pid/1 The testing guide never mentioned Mob.ScreenCase (#44), the blessed in-BEAM unit-test path — it now leads the guide. The old sync-point advice referenced Mob.Test.screen_pid/1, which does not exist, and :sys.get_state on :mob_screen, which stopped being sufficient when rendering moved to Mob.Sender (MOB-110) and :mob_screen became the navigation owner (MOB-112); both are replaced with Mob.Test.settle/2 and an explanation of the three processes it drains. Unit-test examples updated for the process model (dispatch is synchronous; get_socket is the natural sync point after send). Mob.Test's moduledoc claimed tap/2 goes through handle_event/3; it sends {:tap, tag} to handle_info/2 like a real native tap. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
GenericJam added a commit
that referenced
this pull request
Aug 30, 2026
…eature coverage (#105) * docs(navigation): multi-stack state, honest tab/drawer claims, back semantics navigation.md still described tab_bar/drawer as rendering native chrome (UITabBarController / NavigationBar) — since 0.7.33 the runtime backs the declaration with real per-stack state but draws no chrome; switching is programmatic. New 'Tabs and multi-stack state' section documents lazy materialization, parking, independent histories, back-at-secondary-root, the orphan stack, and the MOB-115/116/117 gaps. Directional-reset docs gain the ArgumentError validation and transition-survives-coalescing behavior (#100/#103). screen_lifecycle.md gains crash/restart semantics (per-screen isolation, restart cap, re-mount + load_state), per-screen self() and message delivery, terminate/2 reality (pop stops the leaving screen only), and multi-stack system-back. Mob.App.tab_bar/1 and drawer/1 docstrings no longer claim chrome that is not drawn. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs(testing): document Mob.ScreenCase and settle/2; remove nonexistent screen_pid/1 The testing guide never mentioned Mob.ScreenCase (#44), the blessed in-BEAM unit-test path — it now leads the guide. The old sync-point advice referenced Mob.Test.screen_pid/1, which does not exist, and :sys.get_state on :mob_screen, which stopped being sufficient when rendering moved to Mob.Sender (MOB-110) and :mob_screen became the navigation owner (MOB-112); both are replaced with Mob.Test.settle/2 and an explanation of the three processes it drains. Unit-test examples updated for the process model (dispatch is synchronous; get_socket is the natural sync point after send). Mob.Test's moduledoc claimed tap/2 goes through handle_event/3; it sends {:tap, tag} to handle_info/2 like a real native tap. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs(components,theming): Sheet section, handle-pool limits, font tokens components.md had no coverage of Mob.UI.sheet/2 (0.7.29) or intrinsic content detents (0.7.32) beyond the surface-matrix row — new ':sheet' section documents presence-is-presentation, detents including [:content] / [{:content, max_height: n}], exactly-once {:dismiss, tag}, and the iOS scrim limitation. New 'Handle limits' section covers the 256-handle tap pool and the 256-slot native component pool with {:error, :component_slots_exhausted} (0.7.28 behavior). :text gains the font prop (named font tokens, 0.7.25). theming.md never mentioned fonts — adds the fonts:/font_fallback: token type with a pointer to Styling → Custom fonts. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs: fix tap examples to handle_info/2 and stale module/function names README and several guides showed UI taps handled by handle_event("tap", %{"tag" => ...}) — a real tap delivers {:tap, tag} to handle_info/2, so those example screens would never respond on a device. README's diagram and testing snippet updated for the per-screen process model and Mob.ScreenCase. getting_started referenced Mob.Nav.push/2, which does not exist (Mob.Socket.push_screen is the API). event_audit's list-select re-emitter is Mob.Screen.Server since MOB-113. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs(agentic): split into single-agent and agent-team halves; new practices Part 1 (Working with one agent) keeps the existing content in order and adds: verify effects not exit codes (assert the app answers after a deploy), the honesty contract (success = a handler ran — assert on state change after a tap, settle-window caveat), match the evidence to the question (frames for layout, screenshots-with-tolerance for appearance, recordings for motion), lifecycle-event simulation recipes (simctl push .apns, adb broadcast / cmd notification post), and environment discipline (complete .tool-versions incl. zig/JDK, the MOB_DIR/MOB_DEV_DIR/MOB_NEW_DIR override chain). Part 2 (Working with agent teams) is new: one driver per device with lease discipline (humans outrank agents), unique node names per session (mob.connect --name), per-task git worktrees, the mob.push/mob.watch fan-out hazard (they reach every live node, no device scoping — fleets deploy per device or push over their own dist connection), and durable artifacts as the handoff medium between context windows. The standard agent loop gains a settle/2 step before native-side reads. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs(mix): group new modules in hexdocs sidebar Mob.Router joins Navigation; Mob.Screen.Server, Mob.Listener and Mob.Sender get a Runtime Processes group; Mob.ScreenCase joins Testing & Debugging. All five were shipping ungrouped. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs: document #80's honest tap returns and pixel sampling Written minutes before #80 merged, the honesty-contract and evidence-matching sections claimed no pixel-sampling API existed and leaned solely on state-change assertions. Now that tap_xy/3 reports observed effect, both guides document the contract: :ok only when an event reached the BEAM within 300ms, else {:error, :no_view_at_point | :no_element_at_point | :no_effect} — plus the platform limits that make :no_effect legitimate (SwiftUI on_tap containers, physical-device injection) and the serial-harness assumption the 300ms window shares with state-change checks. Evidence matching now splits exact-color decisions (sample_color/2: real pixels, dominant/average as 0xAARRGGBB, iOS debug-build only) from holistic visual parity (screenshots with tolerance). testing.md gains matching sections. No change needed for #77: push_notifications.md already described tap-to-open from a killed app, which that fix made true. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This was referenced Aug 31, 2026
GenericJam added a commit
that referenced
this pull request
Sep 1, 2026
…nt screen_pid/1 The testing guide never mentioned Mob.ScreenCase (#44), the blessed in-BEAM unit-test path — it now leads the guide. The old sync-point advice referenced Mob.Test.screen_pid/1, which does not exist, and :sys.get_state on :mob_screen, which stopped being sufficient when rendering moved to Mob.Sender (MOB-110) and :mob_screen became the navigation owner (MOB-112); both are replaced with Mob.Test.settle/2 and an explanation of the three processes it drains. Unit-test examples updated for the process model (dispatch is synchronous; get_socket is the natural sync point after send). Mob.Test's moduledoc claimed tap/2 goes through handle_event/3; it sends {:tap, tag} to handle_info/2 like a real native tap. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for freeto join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
Mob.ScreenCase— the blessed way to unit-test aMob.Screenin the BEAM, no device or emulator required. The screen-level analog ofPhoenix.LiveViewTest, but better positioned becauserender/1returns a typed view tree (%{type:, props:, children:}), so assertions are tree queries against real data instead of brittle HTML-string matching.The three tiers
mount_screen/3,render_event/3,render_info/2,assigns/1, plus tree queries (tree,find,find_all,text,flatten) whose vocabulary mirrorsMob.Test(the device driver).assert_renderable/2verifies every node type is renderable, derived at compile time frompriv/tags/{ios,android}.txt(the same authoritative source the~MOBsigil validates against) plus:native_view, with an:extraopt for plugin/custom types. Catches "emitted a node the native layer can't draw" atmix testtime.Viewcarries a:source(:beam | :device).device_view/1wraps a running node;tree/1,assigns/1,navigated_to/1dispatch toMob.Testover Erlang-distribution RPC. The query + assertion layer is tree-based, so the same assertions run in-BEAM or against hardware; only driving differs. Device examples are@tag :on_device(already auto-excluded).Plus
navigated_to/1: in-BEAM it reads the nav actionMob.Socket.push_screen/3records; on device it returns the live screen.It also starts
Mob.Stateper test against a throwaway data dir (likeConnCasestarts the Ecto sandbox), since screens commonly read it inmount/3.Tests
18 pass, 1 excluded (the device example).
credo --strictclean. Companionmob_newPR scaffolds one of these frommix mob.new.Caveat
The device backend reuses the existing, working
Mob.TestRPC surface and compiles, but the on-hardware run hasn't been exercised in CI (no connected node). The in-BEAM tiers are fully verified.🤖 Generated with Claude Code