Files
Michał Pierzchała 766d42e124 fix(ios): report an unobserved text commit instead of a partial success (#1924)
* fix(ios): report an unobserved text commit instead of a partial success

awaitSynthesizedFirstResponderCommit returned Void, so its three exits were
indistinguishable to the caller: the expected text committed, the app
transformed the input, or the 3s deadline expired with a strict prefix still
outstanding. The caller returned dispatched-with-no-failure in all three, and
`type` answered ok with textEntryRoute synthesized-first-responder over a field
holding part of the requested text.

The wait now returns a SynthesizedTextCommitOutcome and an expired deadline
becomes TEXT_INPUT_COMMIT_NOT_OBSERVED, whose hint points at fill rather than a
type retry — type appends, so retrying it concatenates onto whatever committed.

The tail is still not re-synthesized: #1676 rejected that because a stalled
prefix cannot be told apart from a suffix still queued, so repair double-posts.
Reporting is what the runner does instead.

typeIntoCurrentTarget loses its `dispatched` flag, which was exactly
`failure == nil` and could not express the new state — characters posted, commit
unconfirmed, command must refuse. Failure is now the single discriminator.

The decision moves behind an injected clock/observer so the deadline branch runs
in the macOS host lane on every PR instead of needing a simulator.

Refs #1874, #1844

* fix(ios): close false-failure windows in the commit wait

Adversarial review found two deterministic false failures in the wait added by
the previous commit, plus a message that asserted a field state never read.

The deadline was checked before observing, so a commit landing during the final
poll sleep was condemned as never observed — under exactly the loaded-host
timing the wait exists for. The check now runs after an observation, so the last
thing before condemning is a read.

`treatingPlaceholderAsEmpty` maps a value equal to the field's placeholder to
"", a prefix of every expected value. `type "0.00"` into a field placeheld
"0.00" committed instantly, read as pending for the full 3s, and failed. The
observation now settles on an exact raw match; the normalized read still drives
the prefix walk.

The outcome-to-failure mapping moves to textEntryFailure(forCommitOutcome:) so
the branch the command refuses on is pinned by a test rather than living only in
a ternary. `.unobservable` staying a success is what keeps `type "...\n"`
working, and it now has an assertion.

Message and hint no longer claim the field holds a partial value: under both
fixed windows it may hold all of it. The docs sentence no longer implies every
text-entry route verifies its result — the replacement and keyboard-visible
routes have no resolvable element to observe and are unchanged.

Refs #1874, #1844

* test(ios): pin the placeholder fix at the boundary it actually lives on

Review [P1]: testValueEqualToThePlaceholder… injected an observe closure that
already returned "0.00", so it never supplied the normalized "" that causes the
failure. The raw-value short-circuit lived in the production observe closure,
which that test bypassed entirely — reverting the fix left it green.

The raw-exact/normalized-prefix choice moves into commitObservation, and the
test drives it with (raw: "0.00", normalized: "", expected: "0.00"). Reverting
commitObservation to always return the normalized reading now fails the
exact-match assertion.

normalizedValue is a closure rather than a value so an exact match still costs
one accessibility read instead of two, on a path that polls every 20ms for up to
three seconds; a second test pins that laziness.

The old test is deleted rather than kept: its remaining assertion (an exact
match settles without polling) is already covered by
testSynthesizedCommitStopsAtTheFirstSettledObservation.

Refs #1874

* fix(ios): never treat placeholder equality as commit evidence

Review [P1]: an empty text field renders its placeholder AS its accessibility
value, which is why editableTextValue(treatingPlaceholderAsEmpty:) classifies
that value as empty. The previous revision's raw-exact short-circuit therefore
matched BEFORE anything committed whenever the requested text was the
placeholder: `type "0.00"` into a field placeheld "0.00" settled on the first
read and returned ok with zero characters delivered — reintroducing the
success-misdescribes-the-device failure this PR exists to remove.

The state is structurally indeterminate. element.value is identical whether the
placeholder is rendering or the committed text happens to equal it, and
placeholderValue does not disambiguate, so no read resolves it and waiting the
deadline out discovers nothing. placeholderMakesCommitUnobservable detects it up
front and reports the commit unobserved, which the caller refuses on.

commitObservation is deleted rather than narrowed: the raw match was only ever
consulted in this exact case, and in this exact case it is not evidence.

The failure message drops its deadline reference — this refusal never waits.

Refs #1874

* fix(ios): scope the placeholder refusal to an empty baseline

Review [P1]: the guard took only the placeholder and the expected text, so it
refused any append whose result happened to equal the placeholder. Value "0" +
`type ".00"` against placeholder "0.00" was refused before a single read, even
though the non-empty pre-dispatch value proves the placeholder is not what is
rendering and a later "0.00" is genuine commit evidence.

The baseline is what decides it, so it is now an input. placeholderCommitEvidence
returns three states rather than a boolean:

  normalRead         expected differs from the placeholder; the placeholder never
                     enters into the observation
  indistinguishable  expected IS the placeholder and the field was empty, so the
                     placeholder was what rendered and no read can resolve it
  rawValueIsEvidence expected IS the placeholder but the field held content, so a
                     raw match is real

Only .indistinguishable refuses, and it still refuses before the wait, since no
read resolves it. .rawValueIsEvidence reaches the observation and settles on the
raw match, which the normalized read would otherwise hide.

commitObservation returns for that third state, now scoped by evidence rather
than applied unconditionally as in the revision that made raw equality a
false success. Both readings stay closures, so normalRead — the ordinary case —
never pays for the raw read.

Refs #1874

* fix(ios): keep placeholder-equal commits conservative
2026-08-21 15:12:43 +02:00
..