Skip to content

fix(android): make the sync chip's state decision resolvable without an Application#859

Open
vishk23 wants to merge 1 commit into
ryanbr:mainfrom
vishk23:fix/sync-chip-state-jvm-test
Open

fix(android): make the sync chip's state decision resolvable without an Application#859
vishk23 wants to merge 1 commit into
ryanbr:mainfrom
vishk23:fix/sync-chip-state-jvm-test

Conversation

@vishk23

@vishk23 vishk23 commented Jul 27, 2026

Copy link
Copy Markdown

Follow-up to the note I left in review on #738 — kept separate so an unrelated UI test fix doesn't ride along with a sleep-staging change.

The failure

SyncChipStateTest.lastSyncedAt_takesPriorityOverHistorySyncExperimental fails on main (verified at cca23d6 on a clean checkout). It is the only failure in the suite — 3060 tests, 1 failure.

java.lang.IllegalStateException: NoopApplication is not attached
  at com.noop.NoopApplication$Companion.localizedString(NoopApplication.kt:134)
  at com.noop.ui.UiStringsKt.uiString(UiStrings.kt:16)
  at com.noop.ui.TodayScoringKt.shortSyncAgo(TodayScoring.kt:412)
  at com.noop.ui.SyncChipState$Companion.resolve(TodayScoring.kt:399)
  at com.noop.ui.SyncChipStateTest.lastSyncedAt_takesPriorityOverHistorySyncExperimental(SyncChipStateTest.kt:59)

Repro:

cd android && ./gradlew testFullDebugUnitTest --tests '*SyncChipStateTest*'

Root cause

SyncChipState.resolve is documented Pure + unit-tested, but it reached for two ambient dependencies: the process-wide Application's resources (via shortSyncAgouiStringNoopApplication.localizedString) and the system clock. These are plain JVM tests with no Robolectric, so no Application is ever attached and the resource lookup throws.

What made it look arbitrary: only the < 60s branch of shortSyncAgo needs a translated word — every other bucket is digits plus a unit letter. So five of the six cases passed, and the one with a 5-second-old sync did not. It is also why lastSyncedAt_isSyncedWithAgeText survives: it uses 65 seconds and lands in the Nm branch.

The fix

Hoist both dependencies to parameters. SyncStatusChip — a composable that already depends on the clock and the string catalog — resolves them and passes them down, so the decision itself becomes genuinely pure. This is the same injected-clock shape as recordingStateFor directly above it, which already takes its own nowSec.

No behaviour change. The clock is still read at composition time, exactly as shortSyncAgo did on every recomposition, and the chip renders identical text in every bucket.

The tests become deterministic (a fixed nowSec instead of System.currentTimeMillis()) and gain one case for the sub-minute branch — previously impossible to reach from a unit test, and now the regression guard against the lookup migrating back inside.

On the parity contract

The Swift twin is deliberately unchanged, calling that out per CLAUDE.md. SyncChipState.shortAgo resolves its own Date() and String(localized:) and is fine doing so — XCTest runs against a real bundle, and SyncChipStateTests passes. The two signatures now differ on purpose; the four cases and the priority order they encode do not. There is a KDoc note saying so, so it doesn't get "restored" later.

Verification

before after
tests 3060 3061
failures 1 0
test classes 381 381

Measured on this machine by running the full suite against a pristine upstream/main tree, then against the patch. The +1 is the new sub-minute case; no test was dropped.

i18n-coverage and source-hygiene (the two workflows that actually run) are both green locally:

OK no new hardcoded literals (215 pre-existing, tracked in the baseline)
OK no new un-extracted literals (64 pre-existing, tracked in the baseline)
OK no NEW detached doc comments (1387 files, 25 baselined site(s) remaining)

The l10n_today_screen_sync_chip_now_c9bc849a catalog entry is still referenced, just from TodayScreen.kt now.

Why CI didn't catch it

android.yml is disabled by default, so nothing runs the Android unit tests on a PR. Worth a separate conversation about whether the testFullDebugUnitTest job is cheap enough to enable on its own (it is pure JVM — ~2 min here, no emulator, no SDK-heavy steps) even while the full compile job stays off.

…an Application

`SyncChipStateTest.lastSyncedAt_takesPriorityOverHistorySyncExperimental` fails on
main — the only failure in the suite (3060 tests, 1 failure):

    java.lang.IllegalStateException: NoopApplication is not attached
      at com.noop.NoopApplication$Companion.localizedString(NoopApplication.kt:134)
      at com.noop.ui.UiStringsKt.uiString(UiStrings.kt:16)
      at com.noop.ui.TodayScoringKt.shortSyncAgo(TodayScoring.kt:412)
      at com.noop.ui.SyncChipState$Companion.resolve(TodayScoring.kt:399)

`SyncChipState.resolve` is documented "Pure + unit-tested" but reached for two
ambient dependencies: the process-wide Application's resources (for the translated
"now" word) and the system clock. These are plain JVM tests with no Robolectric, so
no Application is ever attached and the resource lookup throws.

Only the `< 60s` branch of `shortSyncAgo` needs a word — every other bucket is
digits plus a unit letter — which is why five of the six cases passed and the sixth,
the one with a 5-second-old sync, did not. Same reason `lastSyncedAt_isSyncedWith-
AgeText` survives at 65 seconds.

Hoist both dependencies to parameters. `SyncStatusChip` — a composable that already
depends on the clock and the string catalog — resolves them and hands them down, so
the decision itself becomes a genuinely pure function. Same injected-clock style as
`recordingStateFor` directly above it, which already takes its own `nowSec`.

No behaviour change: the clock is still read at composition time, exactly as
`shortSyncAgo` did on every recomposition, and the chip renders identical text.

Tests are now deterministic (fixed `nowSec` rather than `System.currentTimeMillis()`)
and gain a case for the sub-minute branch, which was previously impossible to reach
from a unit test — it is the regression guard against the lookup moving back inside.

The Swift twin is deliberately unchanged. Its `shortAgo` resolves its own clock and
`String(localized:)` and is fine doing so: XCTest runs against a real bundle, and
`SyncChipStateTests` passes. The two signatures now differ on purpose; the priority
order they encode does not. Noted in the KDoc so it does not get "fixed" back.

Verified locally: 3061 tests, 0 failures (was 3060 / 1), 381 test classes both
before and after. i18n-coverage and source-hygiene gates both green. android.yml is
disabled by default, which is why CI never caught this.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant