# `MobDev.Differential`
[🔗](https://github.com/genericjam/mob_dev/blob/master/lib/mob_dev/differential.ex#L1)

Drive `Mob.Differential.compare/3` against two live device nodes.

Mob's product claim is one design, both platforms. `mob` ships the pure
comparator; this module is the observer side of that: sample the semantic
tree from each device, feed the pair through the comparator, report.

The comparator itself runs **on one of the device nodes**, via `:rpc.call`,
not in this process. Two reasons:

* A device with a recent `mob` already has `Mob.Differential` loaded. The
  host (this process) may not: `mob_dev` pins the `mob` dep at a Hex
  version that need not match what the app was built against.
* Feeding the trees to a comparator on the device keeps the tree payloads
  from crossing the dist boundary twice.

The iOS node is chosen by convention: iOS shipped first in this project,
and the comparator is symmetric (either device would give the same answer).
If the iOS node's `mob` does not carry `Mob.Differential` yet, the call
falls over to Android; if neither side does, that is reported as
`{:error, :differential_unavailable}` rather than being hidden as a
divergence. Note the semantic implication: while both devices have the
comparator, the iOS device's `mob` version decides what "compare" means.
If a comparator refinement lands on one platform before the other, the
answer follows the iOS build, not the newer of the two.

## What this does *not* do yet

* No fixture management. Both apps are already running the screen you
  want to compare; orchestrating the same fixture across two devices is a
  separate change on top of this.

## Divergences emit to the defect bus

On a `{:divergence, ...}` result the run also emits a
`Mob.Defect.Capsule` onto `Mob.Defect.Bus` (kind: `:divergence`, owner:
`:mob`). This is MOB-159 phase 2 — the observer half of the closing
loop that phase 1 built in `mob`.

The capsule's fingerprint key is `%{fixture: fixture, reason: reason,
path: path}` — so runs that both omit `:fixture` group with each other
on `reason` + `path`, but a run that passes `:fixture` never groups
with a run that omits it (they hash a different key). Pass `:fixture`
as a caller-supplied identifier when you want the same divergence in
the same fixture to group across runs; omit it for ad-hoc runs where
grouping by `reason` + `path` alone is what you want.

`:ok` and the documented `{:error, _}` variants do not emit — nothing to
report on a clean run, and a harness gap (`:not_ready`,
`:differential_unavailable`, `:tree_error`, `:comparator_error`) is not
a framework defect. A novel result shape logs a warning and is likewise
not emitted (see the fallback clause of the `emit` helper); adding a new
taxonomy member is an explicit decision to compare or skip it, not
silent absorption.

The emit path is a no-op when the resolved `mob` version does not
export `Mob.Defect.emit_divergence/2` — the dep spec allows a 0.7.x
`mob` for consumers who have not moved to 0.8.1 yet, and the older
library has no defect bus to emit onto. Callers who require the emit
should pin `mob >= 0.8.1` in their own `mix.exs`.

## Usage

    MobDev.Differential.run(:"my_app_ios@127.0.0.1", :"my_app_android_emulator_5554@127.0.0.1", fixture: :counter_screen)
    #=> :ok | {:divergence, %{path: [...], reason: :type, ios: ..., android: ...}}
    #=> {:error, :not_ready | :differential_unavailable | {:tree_error | :comparator_error, node, term}}

# `node_name`

```elixir
@type node_name() :: node()
```

Node name reachable over Erlang distribution.

# `result`

```elixir
@type result() :: :ok | {:divergence, map()} | {:error, run_error()}
```

# `run_error`

```elixir
@type run_error() ::
  :not_ready
  | :differential_unavailable
  | {:tree_error, node_name(), term()}
  | {:comparator_error, node_name(), term()}
```

Reason a run could not produce a comparison result.

# `run`

```elixir
@spec run(node_name(), node_name(), keyword()) :: result()
```

Sample `Mob.Test.view_tree/1` on both nodes and compare.

Options:
  * `:frame_tolerance_dp` - forwarded to `Mob.Differential.compare/3`.
  * `:rpc_timeout` - per-RPC timeout in ms. Default `15000`.
    A run performs up to three sequential RPCs (iOS sample, Android
    sample, one comparator dispatch), so the worst-case wall clock is
    roughly `3 * rpc_timeout`.
  * `:rpc` - module implementing `:rpc.call/5`. For tests. Real callers
    should not pass this.
  * `:fixture` - a caller-supplied identifier for what is being compared
    (an atom, string, or module). Recorded on the emitted defect capsule's
    fingerprint key when a divergence is reported, so the same divergence
    in the same fixture groups across runs. Omit for ad-hoc runs where
    grouping across fixtures is acceptable.

Unknown keys raise `ArgumentError` rather than being silently ignored:
a run that thinks it tightened `frame_tolerance_dp` but actually used
the default is worse than a loud failure.

---

*Consult [api-reference.md](api-reference.md) for complete listing*
