# Testing

Mob supports two levels of testing: unit tests for screen logic (no device required) and live inspection of a running app via Erlang distribution.

## Unit testing screens with Mob.ScreenCase

`Mob.ScreenCase` is the blessed way to unit-test a screen in the BEAM — the
screen-level analog of `Phoenix.LiveViewTest`. It drives `mount/3`,
`handle_event/3`, and `handle_info/2` directly and gives you query helpers
whose vocabulary matches `Mob.Test` (`assigns/1`, `tree/1`, `find/3`,
`text/1`), so a test reads the same whether it runs in-BEAM in milliseconds or
against a real device:

```elixir
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

    # A tap arrives as a message — render_info is the in-BEAM equivalent:
    view = render_info(view, {:tap, :increment})
    assert assigns(view).count == 1
    assert text(view) =~ "Count: 1"

    # cheap native-contract check: every node the screen emits is a
    # type the Compose / SwiftUI layer actually renders.
    assert_renderable(view)
  end

  test "save navigates to the detail screen" do
    view = mount_screen(MyApp.HomeScreen)
    view = render_info(view, {:tap, :open_detail})
    assert navigated_to(view) == MyApp.DetailScreen
  end
end
```

`device_view/1` wraps a running device node in the same `View` handle, so the
query helpers (`assigns/1`, `tree/1`, `assert_renderable/2`, `navigated_to/1`)
work against live hardware behind a `@tag :on_device`. See `Mob.ScreenCase`
for the full API.

### Testing layouts by size class

`mount_screen/4` mounts in `{:compact, :regular}` (a portrait phone) unless you
pass `size_class:`, and `change_size_class/2` changes it the way a rotation or
an iPad resize does on device — the assign first, then
`handle_info({:mob_size_class_changed, new}, socket)`:

```elixir
test "a regular-width window shows both panes" do
  view = mount_screen(MyApp.MailScreen, %{}, %{}, size_class: {:regular, :regular})
  assert find(view, :row, id: "two_pane")

  view = change_size_class(view, {:compact, :regular})
  assert find(view, :column, id: "one_pane")
end
```

See [Size class](screen_lifecycle.md#size-class).

### Testing async loading

A screen that loads with `Mob.Socket.start_async/3` mounts in its loading
state. `render_async/2` waits for the screen's tasks, delivers each result to
`handle_async/3` (including tasks that `handle_async/3` itself starts), and
returns the updated view. It flunks if they haven't finished within the timeout
(1000 ms by default):

```elixir
test "the profile replaces the skeleton once it loads" do
  view = mount_screen(MyApp.ProfileScreen, %{id: 1})
  assert assigns(view).profile == :loading

  view = render_async(view)
  assert text(view) =~ "Ada"
end
```

See [`handle_async/3`](screen_lifecycle.md#handle_async-3).

## Unit testing with a real screen process

When you want the actual GenServer semantics (messages through a mailbox,
navigation applied by the router), `Mob.Screen.start_link/2` starts real
screen processes in `:no_render` mode — it runs all Elixir callbacks but skips
NIF calls. The returned pid is the navigation owner, which starts one process
per live screen behind it. Use it in `ExUnit` tests:

```elixir
defmodule MyApp.CounterScreenTest do
  use ExUnit.Case

  test "increments count on tap" do
    {:ok, pid} = Mob.Screen.start_link(MyApp.CounterScreen, %{})

    # Read initial state
    socket = Mob.Screen.get_socket(pid)
    assert socket.assigns.count == 0

    # Dispatch an event (needs a handle_event("increment", ...) clause)
    :ok = Mob.Screen.dispatch(pid, "increment", %{})

    # Verify updated state
    socket = Mob.Screen.get_socket(pid)
    assert socket.assigns.count == 1
  end

  test "navigates to detail" do
    {:ok, pid} = Mob.Screen.start_link(MyApp.HomeScreen, %{})

    # dispatch/3 is synchronous — navigation is applied before it returns
    :ok = Mob.Screen.dispatch(pid, "open_detail", %{})

    assert Mob.Screen.get_current_module(pid) == MyApp.DetailScreen
  end
end
```

Key functions for test-mode screens:
- `Mob.Screen.get_socket/1` — returns the current `Mob.Socket.t()`
- `Mob.Screen.dispatch/3` — sends an event, blocks until processed
- `Mob.Screen.get_current_module/1` — returns the current screen module (after navigation)
- `Mob.Screen.get_nav_history/1` — returns the navigation stack as `[{module, socket}]`

## Testing handle_info

Send messages directly to the screen process:

```elixir
test "handles location update" do
  {:ok, pid} = Mob.Screen.start_link(MyApp.MapScreen, %{})

  send(pid, {:location, %{lat: 43.6532, lon: -79.3832, accuracy: 10.0, altitude: 80.0}})

  # handle_info is async, but get_socket/1 calls into the screen process, so
  # its reply queues behind the message you just sent — no explicit sync needed.
  socket = Mob.Screen.get_socket(pid)
  assert socket.assigns.location.lat == 43.6532
end
```

For a pure-logic test with no processes at all, `Mob.ScreenCase.render_info/2`
(above) drives the same callback synchronously.

## Live inspection with Mob.Test

After `mix mob.connect`, `Mob.Test` gives you a remote view into the running app.

### Inspection

```elixir
node = :"my_app_ios@127.0.0.1"

Mob.Test.screen(node)    #=> MyApp.HomeScreen
Mob.Test.assigns(node)   #=> %{count: 3, safe_area: %{top: 62.0, ...}, size_class: {:compact, :regular}}
Mob.Test.tree(node)      #=> %{type: :column, props: %{...}, children: [...]}
Mob.Test.find(node, "Increment")
#=> [{[0, 1], %{"type" => "button", "props" => %{"text" => "Increment", ...}}}]
Mob.Test.inspect(node)   # full snapshot: screen + assigns + nav_history + tree
```

### Taps

```elixir
Mob.Test.tap(node, :increment)
```

The tag atom comes from `on_tap: {self(), :increment}` in the screen's `render/1`. Fire-and-forget — does not block. Follow with `settle/2` (below) before reading the native side.

### Press and hold

Two levels, for `on_press_in` / `on_press_out` (hold-to-talk and the like):

```elixir
# By tag, any platform: sends {:press_in, :mic}, waits, sends {:press_out, :mic}.
Mob.Test.hold(node, :mic, 1_500)
Mob.Test.press_in(node, :mic)   # or the halves, with checks in between
Mob.Test.press_out(node, :mic)

# A real held touch (Android): the app's window gets ACTION_DOWN, then nothing
# until you lift. Compose detectors (detectTapGestures' onPress /
# tryAwaitRelease, long-press timeouts) see a finger that is genuinely down.
:ok = Mob.Test.press_down_xy(node, 40.0, 700.0)
Mob.Test.assigns(node).listening        #=> true, while the finger is still down
:ok = Mob.Test.press_move_xy(node, 40.0, 600.0)   # optional: slide it
:ok = Mob.Test.press_up_xy(node, 40.0, 600.0)

:ok = Mob.Test.hold_xy(node, 40.0, 700.0, 1_500)  # down, 1.5 s, up
```

Find coordinates with `Mob.Test.element_frames/1` (nodes with an `:id`). A
held press auto-cancels after `max_hold_ms:` (default 30 s), so a crashed test
can't leave a finger on the glass. `adb shell input swipe x y x y 3000` is
not a substitute: on some devices it never reaches Compose as a held press.
iOS has no in-process touch injection that reaches SwiftUI, so the `_xy`
functions return `{:error, :not_supported}` there; use the by-tag functions, or
a real touch through the simulator (mobile-mcp / agent-device).

### Navigation

Navigation functions are **synchronous** — they block until the navigation and re-render are complete, so it is safe to read state immediately after:

```elixir
Mob.Test.pop(node)                               # pop to previous screen
Mob.Test.navigate(node, MyApp.DetailScreen, %{id: 42})
Mob.Test.navigate(node, :detail, %{id: 42})      # by registered name
Mob.Test.pop_to(node, MyApp.HomeScreen)          # pop back to a specific screen
Mob.Test.pop_to_root(node)                       # pop all the way back
Mob.Test.reset_to(node, MyApp.HomeScreen)        # replace the active stack
Mob.Test.reset_to(node, MyApp.LoginScreen, %{}, scope: :all) # discard every stack

# System back gesture (fire-and-forget — same as hardware back / edge-pan)
Mob.Test.back(node)
```

### List interaction

```elixir
Mob.Test.select(node, :my_list, 0)   # select first row
```

The list ID comes from the `:id` prop on the `type: :list` node. Delivers `{:select, :my_list, 0}` to `handle_info/2`.

### Simulating device API results

Use `send_message/2` to deliver any term to `handle_info/2` — useful for simulating async device results without triggering real hardware:

```elixir
# Permissions
Mob.Test.send_message(node, {:permission, :camera, :granted})
Mob.Test.send_message(node, {:permission, :notifications, :denied})

# Camera / Photos / Files
Mob.Test.send_message(node, {:camera, :photo, %{path: "/tmp/photo.jpg", width: 1920, height: 1080}})
Mob.Test.send_message(node, {:camera, :cancelled})
Mob.Test.send_message(node, {:photos, :picked, [%{path: "/tmp/photo.jpg", width: 800, height: 600}]})
Mob.Test.send_message(node, {:files, :picked, [%{path: "/tmp/doc.pdf", name: "doc.pdf", size: 4096}]})

# Location / Motion
Mob.Test.send_message(node, {:location, %{lat: 43.6532, lon: -79.3832, accuracy: 10.0, altitude: 80.0}})
Mob.Test.send_message(node, {:motion, %{ax: 0.1, ay: 9.8, az: 0.0, gx: 0.0, gy: 0.0, gz: 0.0}})

# Notifications / Push
Mob.Test.send_message(node, {:notification, %{id: "n1", title: "Hi", body: "Hello", data: %{}, source: :push, presentation: :tap, action: "default"}})
Mob.Test.send_message(node, {:push_token, :ios, "abc123"})

# Deep links (Mob.Link). This goes to the screen showing; to exercise the real
# path (a registered process, the cold-launch hold), open the URL on the device:
# adb shell am start -a android.intent.action.VIEW -d 'myapp://thread?id=42'
Mob.Test.send_message(node, {:link, %{url: "myapp://thread?id=42", source: :running}})

# Biometric / Scanner
Mob.Test.send_message(node, {:biometric, :success})
Mob.Test.send_message(node, {:scan, :result, %{type: :qr, value: "https://example.com"}})

# Audio recording
Mob.Test.send_message(node, {:audio, :recorded, %{path: "/tmp/rec.aac", duration: 3.2}})
Mob.Test.send_message(node, {:audio, :error, :permission_denied})

# Audio playback
Mob.Test.send_message(node, {:audio, :playback_finished, %{path: "/tmp/clip.m4a"}})
Mob.Test.send_message(node, {:audio, :playback_error, %{reason: :file_not_found}})

# WebView
Mob.Test.send_message(node, {:webview, :message, %{"event" => "clicked", "id" => 42}})
Mob.Test.send_message(node, {:webview, :blocked, "https://blocked.example.com"})
Mob.Test.send_message(node, {:webview, :eval_result, "Page Title"})

# Alert / action sheet
Mob.Test.send_message(node, {:alert, :confirmed_delete})
Mob.Test.send_message(node, {:alert, :dismiss})

# Custom
Mob.Test.send_message(node, {:my_event, %{key: "value"}})
```

`send_message/2` is fire-and-forget. Use `Mob.Test.settle/2` as a sync point if
you need to wait before reading state:

```elixir
Mob.Test.send_message(node, {:permission, :camera, :granted})
Mob.Test.settle(node)
Mob.Test.assigns(node)
```

### Settling: waiting for a frame to land

`settle/2` blocks until the app has finished processing and the current frame
is committed. Three processes are involved since the screen-process
architecture: the navigation owner (registered as `:mob_screen`) forwards the
event, the screen process builds the tree, and `Mob.Sender` commits it —
so a bare `:sys.get_state(:mob_screen)` is no longer a sufficient sync point.
Use `settle/2` after any fire-and-forget call (`tap/2`, `back/1`,
`send_message/2`) before reading the *native* side (`view_tree/1`,
`screenshot/2`, `tap_id/2`, `element_frames/2`); `tree/1` and `assigns/1`
re-render in-process and don't need it.

```elixir
Mob.Test.tap(node, :save)
Mob.Test.settle(node)
{:ok, png} = Mob.Test.screenshot(node)
```

### Coordinate taps report observed effect

`Mob.Test.tap_xy/3` — and `tap_id/2`, which inherits its contract — returns
`:ok` only when the app **reacted**: an event reached the BEAM within 300 ms of
the tap. Anything else is an honest error tuple (`{:error, :no_view_at_point}`,
`{:error, :no_element_at_point}`, `{:error, :no_effect}`), never a phantom
success. Read `Mob.Test.tap_xy/3`'s platform notes before treating a
non-`:ok` as a failure — some targets (a SwiftUI `Box` with `on_tap:`, any
coordinate on a physical iOS device) legitimately report `{:error, :no_effect}`
and should be driven with `tap/2` by tag instead.

### Sampling rendered colors

`Mob.Test.sample_color/2` reads the pixels the app actually drew for an
element's frame (or an explicit `{x, y, w, h}` rect) and reduces them to
`%{average:, dominant:, dominant_share:, distinct:, pixels:}`, all
`0xAARRGGBB`. It exists because the view tree cannot answer color questions on
iOS 26 SwiftUI — use it to assert a theme token actually painted, or to catch
a regression where two different tokens render identically. iOS-only,
debug-build only.

```elixir
{:ok, %{dominant: color, dominant_share: share}} = Mob.Test.sample_color(node, "my-card")
assert share > 0.8            # a flat fill, so :dominant is the background
assert color == 0xFF2196F3    # the ARGB the theme resolves :primary to
```

### Native UI interaction

`Mob.Test.tap_native/1` locates an element via the iOS accessibility tree and sends a real touch event. **iOS only.** Requires `idb` — install it with `brew install facebook/fb/idb-companion`.

```elixir
Mob.Test.tap_native("Increment")   # by visible text
Mob.Test.tap_native(:increment)    # by accessibility_id (= tag atom name)

Mob.Test.locate("Increment")
#=> {:ok, %{x: 0.0, y: 412.0, width: 402.0, height: 44.0}}
```

Use `tap_native/1` when you need to test the native gesture path end-to-end. Prefer `tap/2` for testing Elixir logic — it's faster, works on both platforms, and doesn't require `idb`.

## Hot code push in development

During development, push a single module without restarting:

```bash
# After editing MyApp.SomeScreen:
mix compile && nl(MyApp.SomeScreen)
#=> {:ok, [{:"my_app_ios@127.0.0.1", :loaded, MyApp.SomeScreen}]}
```

`nl/1` is a built-in IEx helper that loads new bytecode on all connected nodes. The running screen process picks up the new code on the next `handle_*` call.

## Integration test patterns

For tests that require a running app, use `@tag :integration` and exclude them in CI:

```elixir
@tag :integration
test "app shows home screen after launch" do
  node = :"my_app_ios@127.0.0.1"
  assert Mob.Test.screen(node) == MyApp.HomeScreen
end
```

Run only unit tests (skipping integration):

```bash
mix test --exclude integration
```

Run only integration tests:

```bash
mix test --only integration
```
