mirror of
https://github.com/tiennm99/loto.git
synced 2026-10-15 20:16:01 +00:00
chore(plans): add review reports and dual-platform fix cycle journal
This commit is contained in:
1 parent
57ec6bfbcf
commit
0a16cef4ae
13 files changed
+2506
No files matched your search
@@ -0,0 +1,93 @@
|
||||
# Review follow-ups: multi-tab contract, backup rules, settings-loaded race
|
||||
|
||||
Status: done (2026-08-31) | Branch: main | Mode: auto (user delegated decisions)
|
||||
|
||||
Completion: Phases 1-3 done. Pre-commit review surfaced H-1/M-1..M-4
|
||||
regressions in the fixes themselves; all fixed same cycle (reports
|
||||
`fullstack-developer-260831-2314-web-multitab.md`,
|
||||
`fullstack-developer-260831-2334-android-regression-fixes.md`,
|
||||
`code-reviewer-260831-2314-precommit-review.md`). Final: web 144/144
|
||||
tests, eslint/svelte-check clean, both builds + verify-pwa pass;
|
||||
android 129/129 tests, lint clean. Deferred (unchanged): M6
|
||||
recomposition perf (needs profiled baseline); reviewer lows —
|
||||
web loadMaster deck validation parity, unlockAudio player-gesture,
|
||||
loading-flag timeouts, verify-pwa chunk heuristic, empty onCleared,
|
||||
Robolectric-less VoicePlayerHolder glue test.
|
||||
Source evidence: `plans/reports/code-reviewer-260831-2213-web-review.md` (M5),
|
||||
`plans/reports/code-reviewer-260831-2213-android-review.md` (M4 residual, L9),
|
||||
`plans/reports/fullstack-developer-260831-2226-android-fixes.md` (M4 gap detail).
|
||||
|
||||
## Outcome
|
||||
|
||||
Close the last three review items left pending as "product decisions", using
|
||||
the decisions delegated to the assistant:
|
||||
|
||||
1. **Web M5** — two tabs opened simultaneously freeze each other. Decision:
|
||||
newest claim wins, deterministic tie-break.
|
||||
2. **Android L9** — decision: exclude in-progress round state
|
||||
(`loto_game_state` DataStore) from Android backup/device-transfer; keep
|
||||
settings backed up. Rationale: a fairground round is device-local and
|
||||
transient; restoring it on another device mid-round is confusing, settings
|
||||
are worth keeping.
|
||||
3. **Android M4 residual** — master restore landing before settings resolves
|
||||
replays history under default PLAYER mode. Decision: thread a
|
||||
settings-loaded signal and gate master replay on it.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Master-panel recomposition perf (M6): deferred until a profiled baseline on
|
||||
the API 24 QA device exists.
|
||||
- Any redesign of the multi-tab UX beyond fixing the double-freeze.
|
||||
|
||||
## Phases
|
||||
|
||||
### Phase 1 — web: claim contract (owner: web agent)
|
||||
|
||||
Files: `web/src/lib/active-tab.svelte.js`, `web/src/lib/active-tab.test.js`,
|
||||
`web/src/routes/+layout.svelte` (only if wiring changes).
|
||||
|
||||
- Add a claim timestamp: record `myClaimTs` when this tab claims (mount and
|
||||
`claimActiveTab()`). Include `ts` + `id` in the broadcast.
|
||||
- On peer claim: ignore if `ts < myClaimTs` (this tab is newer); freeze if
|
||||
newer; tie-break equal `ts` deterministically on `id` so exactly one tab
|
||||
wins.
|
||||
- Keep the H2 reclaim re-hydration flow intact.
|
||||
|
||||
Acceptance: simulated simultaneous mount (equal + unequal ts) leaves exactly
|
||||
one tab active; existing reclaim/handover tests still pass; 138+ vitest suite
|
||||
green, eslint + svelte-check (checkJs on) clean.
|
||||
|
||||
### Phase 2 — android: backup rules + settings-loaded signal (owner: android agent)
|
||||
|
||||
Files: `android/app/src/main/AndroidManifest.xml`, new
|
||||
`android/app/src/main/res/xml/data_extraction_rules.xml` (+
|
||||
`backup_rules.xml` for API < 31), `settings/SettingsRepository.kt`,
|
||||
`LotoApplication.kt`, `state/PlayerBoardViewModel.kt` (+ MasterStore only if
|
||||
needed), tests under `app/src/test`.
|
||||
|
||||
- Backup: `android:dataExtractionRules` + `android:fullBackupContent`
|
||||
excluding the game-state DataStore file(s) (verify actual on-disk name);
|
||||
settings DataStore stays included.
|
||||
- Settings-loaded signal: expose loaded-state from `SettingsRepository`
|
||||
(e.g. nullable initial value or `loaded: StateFlow<Boolean>`); ViewModel
|
||||
must not consume master emissions (must not advance the auto-cross cursor)
|
||||
until settings have resolved — closes the reverse interleaving documented
|
||||
in the prior fix report §M4.
|
||||
|
||||
Acceptance: new SlowDataStore-based test proving master-restore-before-
|
||||
settings no longer mis-consumes history under default mode (fails without
|
||||
fix); backup XML referenced from manifest and lint-clean; full
|
||||
`:app:testDebugUnitTest` + `:app:lintDebug` green.
|
||||
|
||||
### Phase 3 — verify + review (owner: controller)
|
||||
|
||||
- `tester` agent: full web + android suites, builds.
|
||||
- `code-reviewer` agent: diff review vs acceptance criteria, contract/blast-
|
||||
radius check.
|
||||
- Finalize: plan sync-back, docs impact check, offer commit.
|
||||
|
||||
## Risk / rollback
|
||||
|
||||
All changes additive and localized; rollback = git checkout of touched files.
|
||||
No schema/data migrations. Backup-rule change affects only future device
|
||||
backups.
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
---
|
||||
title: "Dual-platform review-fix cycle: web PWA + Android state races"
|
||||
date: 2026-08-31
|
||||
summary: "Review agents found dead PWA + state races on both platforms; three fix waves, pre-commit review caught regressions in our own fixes, all verified green"
|
||||
---
|
||||
|
||||
# Dual-platform review-fix cycle: web PWA + Android state races
|
||||
|
||||
## What happened
|
||||
|
||||
Full review-fix cycle over `web/` (SvelteKit) and `android/` (Kotlin/Compose), driven by parallel review and fix subagents.
|
||||
|
||||
Review pass found: web PWA entirely dead (SW never registered — adapter-static prerenders after the PWA plugin's closeBundle; audio precache also missing the `/loto` base, 92 would-be 404s), frozen-tab reclaim rolling back the live round via a stale save effect, and on Android a restore/DataStore race that silently discarded a fresh card plus confetti rendered invisibly behind the kinh Dialog window.
|
||||
|
||||
Fix wave 1 closed all criticals/highs + clear-cut mediums (SW registration + `verify-pwa-build.mjs` CI guard, shared `base-path.js` resolver, hydration-gated saves, loading-gated actions with a delay-injecting `SlowDataStore` fake, confetti embedded in the dialog window). Wave 2: `registerType: "prompt"` reload banner (docs already promised a toast; config contradicted them), `checkJs: true` with 56 type-only fixes, Android L2–L6/L7/L10. Wave 3 (delegated decisions): newest-claim-wins multi-tab contract, `dataExtractionRules` excluding only `datastore/loto_game_state.preferences_pb`, settings-loaded signal closing the M4 reverse-ordering replay race.
|
||||
|
||||
## Key lesson
|
||||
|
||||
Independent pre-commit review of the accumulated diff caught real regressions **introduced by our own fixes**: `voicePlayer.release()` on `isFinishing` left the `by lazy` app-scoped ExoPlayer permanently dead after relaunch into a cached process (fixed with a recreatable `VoicePlayerHolder`), and the one-way claim protocol could leave two tabs active (fixed with winner-echo + missing-`ts` = `Infinity`). Also folded settings-loaded into the exposed `loading` state after the reviewer showed `generate()`/`clearMarks()` dodged the gate.
|
||||
|
||||
## Final state
|
||||
|
||||
Web 144/144 vitest, eslint + svelte-check (checkJs on) clean, both build profiles + PWA verification pass. Android 129/129 unit tests, lint clean. Uncommitted on `main`. Deferred: master-panel recomposition perf (needs profiled baseline), reviewer lows (loadMaster deck-validation parity, player-gesture audio unlock, loading timeouts, verify-pwa chunk heuristic).
|
||||
|
||||
## Next steps
|
||||
|
||||
- Commit (user decision pending).
|
||||
- Profile master panel on the API 24 QA device before touching recomposition.
|
||||
- Consider Robolectric for the VoicePlayerHolder/Activity glue, currently untestable on JVM.
|
||||
|
||||
> Historical work record — not durable authority. Prefer docs/specs/ADRs for current decisions.
|
||||
@@ -0,0 +1,395 @@
|
||||
# Android native app — code review (2026-08-31)
|
||||
|
||||
Scope: `android/app/src/**`, `android/*.gradle.kts`, `gradle.properties`,
|
||||
`gradle/libs.versions.toml`, `AndroidManifest.xml`, `res/**`,
|
||||
`.github/workflows/android-release.yml`. Read-only review; no source changed.
|
||||
|
||||
Baseline: ~2.9k LOC main + ~1.5k LOC unit tests + 4 instrumented tests.
|
||||
Overall the port is disciplined — pure game logic is isolated and well
|
||||
covered, DataStore validation is per-field, and the web-parity notes are
|
||||
specific rather than decorative. Findings below are the gaps.
|
||||
|
||||
---
|
||||
|
||||
## Critical
|
||||
|
||||
None. No trust-boundary, data-loss-at-rest, or crash-on-launch defect found.
|
||||
The app takes no network input, declares no `INTERNET` permission, exports
|
||||
only the LAUNCHER activity, and the signing config reads env vars only.
|
||||
|
||||
---
|
||||
|
||||
## High
|
||||
|
||||
### H1 — Restore races a user action and silently clobbers a fresh card/round
|
||||
|
||||
`android/app/src/main/java/com/miti99/loto/state/PlayerBoardViewModel.kt:76-101`
|
||||
`android/app/src/main/java/com/miti99/loto/state/MasterStore.kt:25-29`
|
||||
|
||||
`restore()` suspends on `dataStore.data.first()` while the UI is already
|
||||
composed and interactive. During that window `uiState.grid == null`, so
|
||||
`PlayerBoardScreen.kt:70-73` takes the **no-confirm** branch:
|
||||
`if (state.grid != null) confirmGenerate = true else viewModel.generate()`.
|
||||
A single tap on "Tạo bảng mới" during the load therefore generates + persists
|
||||
a new card; `restore()` then resumes and unconditionally overwrites
|
||||
`grid`/`crossed`/`manualUnticks` with the stale saved round, without
|
||||
re-persisting. Result: the user's new card vanishes, and in-memory state now
|
||||
disagrees with what is on disk until the next `persist()`.
|
||||
|
||||
Identical shape on the master side: `MasterPanelScreen.kt:86` calls
|
||||
`viewModel.newGame()` with no confirmation while `hasGame == false` (which is
|
||||
exactly the pre-restore state), and `MasterStore.restore()` then replaces the
|
||||
deck with the persisted one.
|
||||
|
||||
Fix: make restore a no-op once the user has acted, and/or gate the buttons on
|
||||
a load-complete flag.
|
||||
|
||||
```kotlin
|
||||
// PlayerBoardViewModel
|
||||
private var restored = false
|
||||
private suspend fun restore() {
|
||||
val saved = repository.loadPlayerState()
|
||||
if (grid != null) { restored = true; return } // user already generated
|
||||
...
|
||||
}
|
||||
// MasterStore
|
||||
suspend fun restore() {
|
||||
if (deck.remaining.isNotEmpty() || deck.called.isNotEmpty()) return
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
Cleaner alternative: add `val loading: Boolean` to `PlayerUiState` /
|
||||
`MasterRoundState` exposure and render the empty state as non-interactive
|
||||
until both restores settle.
|
||||
|
||||
### H2 — Tier-2 confetti is emitted inside the scrolling column, behind the dialog
|
||||
|
||||
`android/app/src/main/java/com/miti99/loto/ui/player/PlayerBoardScreen.kt:154-159`
|
||||
`android/app/src/main/java/com/miti99/loto/ui/player/Confetti.kt:615-624`
|
||||
|
||||
`ConfettiOverlay` uses `Modifier.fillMaxSize()`, but it is emitted as a
|
||||
sibling in `LotoAppRoot`'s `Column(Modifier.verticalScroll(...))`
|
||||
(`LotoAppRoot.kt:64-100`). Under an unbounded max height, Compose's fill
|
||||
modifier falls back to `constraints.minHeight`, so the Box wraps its content
|
||||
instead of filling the screen — `size.height` used for `translationY`
|
||||
(`Confetti.kt:640`) is the height of a single emoji, not the viewport.
|
||||
|
||||
Worse, `KinhDialog` is a real `Dialog` in its own window with the default dim
|
||||
scrim, so the confetti is drawn *behind* the dim layer in the activity
|
||||
window. The tier-2 celebration is effectively invisible and also injects
|
||||
layout into the scroll content.
|
||||
|
||||
Fix: host the confetti in the same window as the dialog, or in a `Popup`:
|
||||
|
||||
```kotlin
|
||||
if (state.showCongrats) {
|
||||
KinhDialog(congratsRow = state.congratsRow, onDismiss = { viewModel.dismissCongrats() })
|
||||
if (state.celebrationTier >= 2 && !reducedMotion) {
|
||||
Popup(properties = PopupProperties(clippingEnabled = false)) {
|
||||
Box(Modifier.fillMaxSize()) { ConfettiOverlay() } // bounded by the popup window
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
(Or move `ConfettiOverlay()` inside `KinhDialog`'s `Dialog { }` content with
|
||||
`usePlatformDefaultWidth = false` + `decorFitsSystemWindows = false`.)
|
||||
|
||||
---
|
||||
|
||||
## Medium
|
||||
|
||||
### M1 — RGB channel truncation drifts the empty-cell colour on every drag
|
||||
|
||||
`android/app/src/main/java/com/miti99/loto/ui/settings/EmptyCellColorPicker.kt:369-403`
|
||||
|
||||
```kotlin
|
||||
val r = (color.red * 255).toInt() // truncation, not rounding
|
||||
```
|
||||
|
||||
`Color.red/green/blue` are Float. `toInt()` truncates, so a float round-trip
|
||||
that lands at `126.9999` yields 126 for a stored `0x7F`. Because dragging the
|
||||
R slider rebuilds the hex from the *derived* `g` and `b`
|
||||
(`draft = hex(it, g, b)`), any truncation error on the untouched channels is
|
||||
committed too — repeated drags walk the colour downward.
|
||||
|
||||
Fix: `val r = (color.red * 255).roundToInt()` (same for g/b).
|
||||
|
||||
### M2 — Every draw force-scrolls the page to the master hero, even in "both" mode
|
||||
|
||||
`android/app/src/main/java/com/miti99/loto/ui/master/MasterPanelScreen.kt:73-75`
|
||||
|
||||
```kotlin
|
||||
LaunchedEffect(tickKey) { if (tickKey > 0 && lastCalled != null) heroRequester.bringIntoView() }
|
||||
```
|
||||
|
||||
In `AppMode.BOTH` with auto-call at 1–10 s, the shared scroll is yanked away
|
||||
from the player card on every draw — the player physically cannot keep their
|
||||
board on screen. Suggest gating on mode:
|
||||
|
||||
```kotlin
|
||||
LaunchedEffect(tickKey) {
|
||||
if (tickKey > 0 && lastCalled != null && settings.mode == AppMode.MASTER) {
|
||||
heroRequester.bringIntoView()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Confirm against the web behaviour before changing (see Unresolved Q1).
|
||||
|
||||
### M3 — Toast copy and cell accessibility text are hardcoded Vietnamese in non-UI layers
|
||||
|
||||
`android/app/src/main/java/com/miti99/loto/state/PlayerBoardViewModel.kt:282` — `showToast("Chờ $waitNum")`
|
||||
`android/app/src/main/java/com/miti99/loto/ui/player/PlayerCell.kt:563-569` — `"Số $num", ", đã đánh dấu", ", đang chờ"`
|
||||
|
||||
`res/values/strings.xml` already ships `toast_waiting` (`Chờ %1$d`),
|
||||
`toast_dismiss`, `player_board_label`, `master_board_label` and `kinh_close`
|
||||
— **all five are unreferenced** (verified by grep). The ViewModel emits
|
||||
presentation copy instead, which also makes the string un-translatable and
|
||||
couples the VM to display text.
|
||||
|
||||
Fix: put a `waitingNumber: Int?` in `PlayerUiState` and format with
|
||||
`stringResource(R.string.toast_waiting, n)` at the call site; move the cell
|
||||
`stateDescription` fragments into `strings.xml`. Delete whichever resources
|
||||
stay genuinely unused (lint `UnusedResources` will otherwise keep flagging
|
||||
them; `isShrinkResources` hides it only in release).
|
||||
|
||||
### M4 — Cold-start ordering can leave `settings.mode` at its default during the first master emission
|
||||
|
||||
`android/app/src/main/java/com/miti99/loto/LotoApplication.kt:58-61, 76-83`
|
||||
`android/app/src/main/java/com/miti99/loto/state/PlayerBoardViewModel.kt:103-135`
|
||||
|
||||
`settingsState` is `stateIn(..., SharingStarted.Eagerly, defaults)` — its
|
||||
initial value is `mode = PLAYER` until the settings DataStore read lands.
|
||||
`onMasterState` reads `settings.value.mode` synchronously. Two independent
|
||||
DataStore reads (settings vs. game state) race, so on a cold start with a
|
||||
persisted `mode = BOTH`, the first master emission can be evaluated as
|
||||
`PLAYER`: `PlayerAutoCross` then advances `lastHandledIndex` to
|
||||
`called.size` with `changed = false` and no replay ever happens for that
|
||||
history (`masterStore.state` will not re-emit until the next draw).
|
||||
|
||||
In practice the persisted `crossed` matrix already reflects the earlier
|
||||
auto-crosses, so this is usually invisible — but it is load-bearing on the
|
||||
"restore replays idempotently" claim in the code comment
|
||||
(`PlayerBoardViewModel.kt:65-67`), which does not hold under this ordering.
|
||||
|
||||
Fix: make the ordering explicit rather than accidental — e.g. `settings.first
|
||||
{ it !== defaults }`-style gating, a `Settings?` (null = not loaded) flow, or
|
||||
`combine(masterStore.state, settings)` so a mode arrival re-triggers the
|
||||
replay pass.
|
||||
|
||||
Also: `LotoApplication.onCreate` serialises two unrelated jobs in one
|
||||
coroutine (`masterStore.restore()` then `settingsState.collect`). Split them
|
||||
into two `launch` blocks so a slow/failed restore cannot stall voice-id sync.
|
||||
|
||||
### M5 — Card generation runs on the main thread with a 200-attempt rejection loop
|
||||
|
||||
`android/app/src/main/java/com/miti99/loto/game/CardGenerator.kt:147-155`
|
||||
called synchronously from `PlayerBoardViewModel.generate()` (`:140`).
|
||||
|
||||
`pickFilledCols` can run `pickFilledColsOnce` up to 201 times; each pass
|
||||
enumerates up to `C(9,5) = 126` combinations per row across 9 rows, with a
|
||||
recursive `combinations()` that allocates a fresh `List` per node. Typical
|
||||
runtime is fine, but the worst case is an unbounded-ish main-thread stall on
|
||||
a low-end device. The column-quota invariant is correct (verified: `forced.size
|
||||
<= 5` and `candidates.size > need` hold for every `rowsLeft`), so this is a
|
||||
latency risk, not a correctness one.
|
||||
|
||||
Fix: `viewModelScope.launch(Dispatchers.Default) { val g = CardGenerator.generateGrid(); withContext(Dispatchers.Main) { ... } }`,
|
||||
or memoise `combinations()` per `(candidates.size, need)` shape.
|
||||
|
||||
### M6 — Master panel recomposes ~200 non-lazy nodes on every draw
|
||||
|
||||
`android/app/src/main/java/com/miti99/loto/ui/master/MasterPanelScreen.kt:187-209, 270-325`
|
||||
|
||||
The called-history `FlowRow` (up to 90 tokens) plus the 11×9 `MasterBoard`
|
||||
(99 cells, each a Box + Box + up to 2 Texts) are plain `Column`/`Row`
|
||||
inside a `verticalScroll` — everything is composed and measured. Each draw
|
||||
mutates `master` and recomposes the whole subtree. Combined with the
|
||||
per-frame `withFrameNanos` loop in `AutoCountdown.kt:143-153`, auto-call at
|
||||
1 s/number keeps the main thread busy.
|
||||
|
||||
Not a blocker for a fairground app on modern hardware, but worth measuring
|
||||
on the API 24 target listed in `plans/todo.md`. Cheap mitigations: hoist
|
||||
`MasterBoardLayout.BOARD` rows into `key(num)` blocks, or pass `isCalled` /
|
||||
`isLast` as lambdas so the token colour changes without recomposing the cell.
|
||||
|
||||
---
|
||||
|
||||
## Low
|
||||
|
||||
### L1 — `ExoPlayer` is never released
|
||||
|
||||
`LotoApplication.kt:72-74`, `VoicePlayer.kt:98-100`. `VoicePlayerApi.release()`
|
||||
has no call site anywhere in `main/`. The player is app-scoped over an
|
||||
Application context, so no Activity leak — but audio renderers/codecs stay
|
||||
resident for the process lifetime. Either wire it to a
|
||||
`ProcessLifecycleOwner` `ON_STOP`+timeout, or drop the unused `release()` from
|
||||
the interface so it does not read as a contract that is being honoured.
|
||||
|
||||
### L2 — Shared voice player + per-ViewModel `onCleared` cancel
|
||||
|
||||
`MasterPanelViewModel.kt:105-107`, `PlayerBoardViewModel.kt:238-240`. Both
|
||||
VMs `cancel()` the *same* singleton on clear, and neither calls
|
||||
`super.onCleared()`. Harmless today (both clear together), fragile if
|
||||
scoping ever changes.
|
||||
|
||||
### L3 — Restore payload is range-validated but not shape-validated
|
||||
|
||||
`GameStateRepository.kt:107-113`. `parseNumberList` accepts duplicates and
|
||||
does not check `called ∪ remaining == 1..90` or disjointness. A
|
||||
partially-written/tampered `loto_game_state` file can restore a deck with a
|
||||
repeated number, which `PlayerAutoCross` will then cross twice on different
|
||||
cells. Local-only file, low impact; a `toSet().size == size` check plus a
|
||||
disjointness assert would close it cheaply.
|
||||
|
||||
### L4 — `write()` swallows every `IOException` with no signal
|
||||
|
||||
`SettingsRepository.kt:107-113`, `GameStateRepository.kt:85-90`. Documented as
|
||||
intentional ("web parity"), and correct for this product, but a persistently
|
||||
failing store now degrades to "settings silently never save" with zero
|
||||
telemetry. Consider at least a `Log.w` so device QA can spot it.
|
||||
|
||||
### L5 — `MasterPanelViewModel` leaves `autoRunning = true` after a manual draw exhausts the deck
|
||||
|
||||
`MasterPanelViewModel.kt:65-72, 85-91`. `_autoRunning` only flips false at the
|
||||
*next* tick. Between exhaustion and that tick the UI shows "Dừng" while
|
||||
`MasterPanelScreen.kt:88` has already hidden the button (guarded on
|
||||
`remaining.isNotEmpty()`), so it is invisible — but `toggleAuto()` can no
|
||||
longer reset it either. Add the `remaining.isEmpty()` check inside
|
||||
`drawNext()`.
|
||||
|
||||
### L6 — `manualUnticks` survives a master "Ván mới" outside `BOTH` mode
|
||||
|
||||
`PlayerBoardViewModel.kt:107-119`. The reset branch is gated on
|
||||
`mode == AppMode.BOTH`, so unticks recorded in a previous round persist. A
|
||||
later switch to BOTH + "Xoá đánh dấu" replays with stale suppressions. Narrow
|
||||
edge; clear `manualUnticks` on any shrink regardless of mode.
|
||||
|
||||
### L7 — `Color.parseColor` is deprecated
|
||||
|
||||
`ColorParsing.kt:9-13`. Works on all supported API levels; `androidx.core`
|
||||
ships `String.toColorInt()` which is the non-deprecated equivalent and keeps
|
||||
the same `IllegalArgumentException` contract.
|
||||
|
||||
### L8 — `launchMode="singleTask"` on the LAUNCHER activity
|
||||
|
||||
`AndroidManifest.xml:44`. No deep links or external `startActivity` targets
|
||||
exist, so nothing needs it, and `singleTask` changes task-reparenting and
|
||||
`onNewIntent` semantics. `standard` is the safer default unless the wrapper
|
||||
era needed it for a reason worth recording.
|
||||
|
||||
### L9 — `allowBackup="true"` with no backup rules
|
||||
|
||||
`AndroidManifest.xml:36`. Both DataStores (settings + round state) are
|
||||
included in cloud backup / D2D transfer by default. Nothing sensitive is
|
||||
stored, so this is a data-hygiene note rather than a security finding — but
|
||||
if a restored `loto_game_state` from another device is undesirable, exclude
|
||||
it via `android:dataExtractionRules`.
|
||||
|
||||
### L10 — CI leaves the decoded keystore in the workspace
|
||||
|
||||
`.github/workflows/android-release.yml:66-70`. `keystore.p12` is written to
|
||||
`$GITHUB_WORKSPACE` and never removed. The runner is ephemeral and the
|
||||
release upload globs only `*.apk`/`*.aab`, so there is no actual exposure —
|
||||
but an `if: always()` cleanup step is one line and removes the class of
|
||||
mistake where a future step archives the workspace.
|
||||
|
||||
### L11 — `resourceConfigurations` is deprecated in AGP 8.13
|
||||
|
||||
`app/build.gradle.kts:22`. Replaced by `androidResources.localeFilters`.
|
||||
Functional today; will warn/break on an AGP major bump.
|
||||
|
||||
---
|
||||
|
||||
## Edge cases checked and found sound
|
||||
|
||||
Recorded so a future audit does not re-litigate them:
|
||||
|
||||
- `CardGenerator` column-quota invariant holds for every `rowsLeft`
|
||||
(`forced.size <= 5`, `candidates.size > need`), so
|
||||
`picked.removeFirstOrNull() ?: 0` never silently drops a number.
|
||||
- `PlayerAutoCross` cursor advances strictly in non-`BOTH` modes, so a
|
||||
`player → both` toggle does not dump back-history (`PlayerAutoCross.kt:40-43`).
|
||||
- `MasterStore.publishAndSave` launches saves on `Dispatchers.Main.immediate`
|
||||
and each coroutine reaches `DataStore.edit` in dispatch order, so snapshots
|
||||
cannot be persisted out of order.
|
||||
- `parseCrossed` returns null for a 0-length string, so an empty `crossed`
|
||||
matrix falls back to all-false rather than corrupting the grid.
|
||||
- `AutoCountdown` deliberately uses `withFrameNanos` instead of a Compose
|
||||
animation so the digit still ticks under "remove animations"
|
||||
(`AutoCountdown.kt:139-153`) — correct, and the comment explains why.
|
||||
- `KeepScreenOn` releases the flag in `onDispose` and is keyed on `remaining`,
|
||||
matching the `plans/todo.md` QA items.
|
||||
- Manifest surface is minimal: `VIBRATE` only, `ACCESS_NETWORK_STATE`
|
||||
stripped, one exported activity with a LAUNCHER filter.
|
||||
|
||||
## Test-quality note
|
||||
|
||||
`InMemoryDataStore` (`app/src/test/java/com/miti99/loto/InMemoryDataStore.kt`)
|
||||
is a `MutableStateFlow`, so `dataStore.data.first()` resolves *synchronously*
|
||||
in unit tests. That is why H1 and M4 — both of which are purely about the
|
||||
async gap between construction and the first DataStore emission — are
|
||||
invisible to the existing suite despite `PlayerBoardViewModelTest` and
|
||||
`GameStateRepositoryTest` looking thorough. The Windows rename limitation
|
||||
that motivated the fake is real; the fix is a delay-injecting fake, not a
|
||||
file-backed one:
|
||||
|
||||
```kotlin
|
||||
class SlowDataStore(private val gate: CompletableDeferred<Unit>) : DataStore<Preferences> {
|
||||
override val data = flow { gate.await(); emitAll(state) }
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
Then assert that `generate()` before `gate.complete(Unit)` wins.
|
||||
|
||||
---
|
||||
|
||||
## Recommended actions (priority order)
|
||||
|
||||
1. Guard `PlayerBoardViewModel.restore()` and `MasterStore.restore()` against
|
||||
a concurrent user action, or disable the action buttons until restore
|
||||
settles (H1).
|
||||
2. Re-host `ConfettiOverlay` in a `Popup`/dialog window so tier-2
|
||||
celebrations are actually visible and stop injecting scroll layout (H2).
|
||||
3. `roundToInt()` in `EmptyCellColorPicker` (M1) — one-line, prevents
|
||||
user-visible colour drift.
|
||||
4. Decide and document the `bringIntoView` policy for `BOTH` mode (M2).
|
||||
5. Move the toast/`stateDescription` copy into `strings.xml` and delete or
|
||||
wire the five orphaned resources (M3).
|
||||
6. Make the settings-vs-master-restore ordering explicit and split
|
||||
`LotoApplication.onCreate` into two launches (M4).
|
||||
7. Move `generateGrid()` off the main thread (M5).
|
||||
8. Add a delay-injecting DataStore fake and regression tests for 1 and 6.
|
||||
9. Sweep the Low list opportunistically; L1/L5/L6 are each a few lines.
|
||||
|
||||
## Metrics
|
||||
|
||||
- Type coverage: 100% Kotlin, no `Any`/unchecked casts outside the one
|
||||
documented `@Suppress("UNCHECKED_CAST")` in `LotoViewModelFactory.kt:13`
|
||||
(standard `ViewModelProvider.Factory` idiom).
|
||||
- Unit tests: 13 files / ~1.5k LOC over `game`, `audio`, `settings`, `state`,
|
||||
`ui`. Instrumented: 4 files. Pure game logic is genuinely covered (real
|
||||
assertions on invariants, not smoke calls). Gap is the async-boundary
|
||||
behaviour described above.
|
||||
- Lint/typecheck/build: **not run** — no Android SDK configured in this
|
||||
review environment (`local.properties` was out of scope by instruction).
|
||||
Findings are static-analysis + code-reading only.
|
||||
|
||||
## Unresolved questions
|
||||
|
||||
1. **M2** — does `web/src/lib/MasterPanel.svelte` scroll the hero into view
|
||||
in `both` mode, or only in master-only mode? The web app is the stated
|
||||
behavioural spec, so the Kotlin side should match whatever it does. I did
|
||||
not read `web/` (out of the delegated file scope).
|
||||
2. **H1** — is the pre-restore no-confirm branch intentional (an empty board
|
||||
genuinely has nothing to confirm), or was the `grid == null` check meant
|
||||
to be a "not loaded yet" check? The fix differs: guard the restore vs.
|
||||
gate the button.
|
||||
3. **L8** — was `launchMode="singleTask"` carried over deliberately from the
|
||||
Capacitor wrapper manifest, or copied without a reason?
|
||||
4. **M6** — has the master panel been profiled on the API 24 device named in
|
||||
`plans/todo.md`, or is that QA item still open? The `todo.md` list shows
|
||||
all device-QA boxes unchecked.
|
||||
@@ -0,0 +1,387 @@
|
||||
# Code Review — `web/` (SvelteKit lô tô app)
|
||||
|
||||
Date: 2026-08-31 · Reviewer: code-reviewer · Mode: report-only (no source edits)
|
||||
|
||||
## Scope
|
||||
|
||||
- Files: all of `web/src` (23 files, ~4.7k LOC incl. tests), `package.json`,
|
||||
`svelte.config.js`, `vite.config.js`, `firebase.json`, `eslint.config.mjs`,
|
||||
`jsconfig.json`, `static/manifest.webmanifest`, `scripts/generate-audio.py`,
|
||||
`.github/workflows/ci.yml`, `README.md`, `docs/`.
|
||||
- Verification run locally: `npm ci`, `npx vitest run` (121 pass / 8 files),
|
||||
`npx eslint .` (0 problems), `BUILD_PROFILE=gh npx vite build` + inspection of
|
||||
`build/sw.js`, `build/index.html`, `.svelte-kit/output/prerendered/pages/index.html`.
|
||||
|
||||
## Overall Assessment
|
||||
|
||||
Game logic is solid and matches `docs/project-overview-pdr.md` (5-per-row and
|
||||
5-per-column invariants, ascending column order, unbiased Fisher–Yates, soft
|
||||
"no 3 consecutive" via rejection sampling). Storage parsing has size caps and
|
||||
per-key validators; there are no XSS sinks (`@html`/`innerHTML` absent), no
|
||||
client-side Firebase SDK, no secrets, and CI uses `pull_request` (not
|
||||
`pull_request_target`) with a SHA-pinned deploy action.
|
||||
|
||||
The real risk sits at the edges the unit tests don't reach: the PWA layer is
|
||||
built but never wired up, and the multi-tab coordinator can roll back a live
|
||||
round. Component/effect code (PlayerBoard, MasterPanel) has zero test coverage
|
||||
and carries several timing/UX defects.
|
||||
|
||||
---
|
||||
|
||||
## Critical
|
||||
|
||||
### C1. Service worker is generated but never registered — offline mode does not exist
|
||||
`web/vite.config.js:39-81` (SvelteKitPWA config), `web/src/routes/+layout.svelte:19-23`
|
||||
|
||||
Empirically verified on a real build (`BUILD_PROFILE=gh npx vite build`):
|
||||
|
||||
- `build/sw.js`, `build/workbox-*.js`, `build/registerSW.js` are emitted.
|
||||
- `build/index.html` contains **zero** occurrences of `registerSW`, `sw.js`, or
|
||||
`serviceWorker` (`grep -oi 'sw\.js\|registerSW\|serviceWorker' build/index.html`
|
||||
→ empty). Same for `.svelte-kit/output/prerendered/pages/index.html`.
|
||||
- The only `serviceWorker` reference in `_app/immutable/**` is SvelteKit's own
|
||||
`updated.check()` helper, not vite-pwa's registration.
|
||||
|
||||
Cause: with `adapter-static`, page HTML is produced during `adapt()`, which runs
|
||||
after the PWA plugin's `closeBundle`; `@vite-pwa/sveltekit` therefore never
|
||||
injects the tag, and nothing in `src/` imports `virtual:pwa-register`.
|
||||
|
||||
Impact: no precache, no runtime audio cache, no offline start. `README.md:27`
|
||||
("works offline after first load"), `docs/development-roadmap.md:21` ("Works
|
||||
offline at fairground (no signal venues)") and `docs/system-architecture.md:173-178`
|
||||
are all false for both the GitHub Pages and Firebase deployments. This is the
|
||||
app's stated core venue requirement and it fails silently — no test or CI step
|
||||
catches it. (Android is unaffected: the APK mounts the assets locally.)
|
||||
|
||||
Fix (in `+layout.svelte`, alongside the existing `onMount`):
|
||||
|
||||
```js
|
||||
import { base } from "$app/paths";
|
||||
onMount(() => {
|
||||
if (import.meta.env.PROD && "serviceWorker" in navigator) {
|
||||
navigator.serviceWorker.register(`${base}/sw.js`, { scope: `${base}/` });
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
or use the plugin's own entry point (`const { registerSW } = await import("virtual:pwa-register"); registerSW({ immediate: true })`).
|
||||
Then re-verify with the greps above, and add a build-time assertion (a tiny node
|
||||
script in CI: fail if `build/index.html` lacks a SW registration) so this cannot
|
||||
silently regress again.
|
||||
|
||||
---
|
||||
|
||||
## High
|
||||
|
||||
### H1. Audio precache URLs ignore the SvelteKit base path — SW install will 404 on GitHub Pages
|
||||
`web/vite.config.js:21-26`, base resolution at `web/svelte.config.js:17-25`
|
||||
|
||||
`additionalManifestEntries` are passed verbatim to workbox-build; the plugin only
|
||||
rewrites entries it generates from globs. Verified in the `gh` build:
|
||||
|
||||
```
|
||||
$ grep -o '"[^"]*audio[^"]*mp3"' build/sw.js | head -1
|
||||
"/audio/hoai-my/1.mp3" # absolute, origin-rooted
|
||||
$ grep -oE '"[^"]*\.(js|css)"' build/sw.js | head -2
|
||||
"registerSW.js" "_app/immutable/nodes/2.QP53jZoe.js" # relative → correct
|
||||
$ grep -c '/loto/' build/sw.js
|
||||
0
|
||||
```
|
||||
|
||||
On `tiennm99.github.io/loto/` those 92 entries resolve to
|
||||
`https://tiennm99.github.io/audio/hoai-my/N.mp3` → 404. Workbox rejects the
|
||||
`install` event when any precache request fails, so once C1 is fixed the SW will
|
||||
install-fail on every load of the Pages deployment and never activate — offline
|
||||
stays broken there while appearing fixed on Firebase (base `""`).
|
||||
|
||||
Fix: share one base resolver between the two configs and prefix the entries.
|
||||
|
||||
```js
|
||||
// web/base-path.js (imported by svelte.config.js and vite.config.js)
|
||||
export function resolveBase() { /* moved verbatim from svelte.config.js */ }
|
||||
```
|
||||
```js
|
||||
const base = resolveBase();
|
||||
const defaultVoicePrecacheEntries = clipNames.map((n) => ({
|
||||
url: `${base}/audio/${defaultVoiceId}/${n}.mp3`,
|
||||
revision: `audio-v1-${defaultVoiceId}-${n}`,
|
||||
}));
|
||||
```
|
||||
|
||||
Verify: `BUILD_PROFILE=gh npm run build:gh && grep -o '"[^"]*audio[^"]*mp3"' build/sw.js | head -1`
|
||||
must print `"/loto/audio/hoai-my/1.mp3"`.
|
||||
|
||||
### H2. Reclaiming a frozen tab overwrites the live round with stale state (data loss)
|
||||
`web/src/lib/active-tab.svelte.js:58-61`, `web/src/lib/MasterPanel.svelte:69-74`,
|
||||
`web/src/routes/+layout.svelte:26-44`
|
||||
|
||||
Sequence: tab A hosts a round → user opens tab B → B broadcasts `claim` → A sets
|
||||
`activeTab.inactive = true` → the layout `{#if}` unmounts all children of A, but
|
||||
the `masterState` module singleton in A keeps its snapshot. B draws 20 more
|
||||
numbers and persists them. User returns to A and taps "Nhấn để tiếp tục tại đây"
|
||||
→ `claimActiveTab()` only flips the flag → children remount → `MasterPanel`'s
|
||||
save effect (`MasterPanel.svelte:69-74`) runs unconditionally on mount and calls
|
||||
`saveMaster()` with A's stale `called`/`remaining` → `loto_master` in
|
||||
localStorage is rolled back and B's 20 draws are gone.
|
||||
|
||||
There is no re-hydration on unfreeze and no `storage`-event listener anywhere.
|
||||
|
||||
Fix: re-hydrate before handing control back.
|
||||
|
||||
```js
|
||||
// active-tab.svelte.js — keep the store dumb, or do it in +layout.svelte
|
||||
export function claimActiveTab() {
|
||||
loadSettings();
|
||||
loadMaster(); // re-read authoritative state written by the peer
|
||||
activeTab.inactive = false;
|
||||
if (bc) bc.postMessage({ type: "claim", id: TAB_ID });
|
||||
}
|
||||
```
|
||||
|
||||
Defence in depth: gate the `saveMaster()` effect on a "hydrated" flag so a mount
|
||||
never writes before a load has happened in this tab's current active session.
|
||||
|
||||
---
|
||||
|
||||
## Medium
|
||||
|
||||
### M1. Second "Chờ" toast within 5 s is invisible but still swallows taps
|
||||
`web/src/lib/PlayerBoard.svelte:117-123` and `:493-512`, `web/src/app.css:194-207`
|
||||
|
||||
`showToast()` sets `toast = null` then `toast = msg` in the same synchronous
|
||||
tick, so the `{#if toast}` block never tears down — the DOM node is reused and
|
||||
the `animate-toast` 5 s animation is **not** restarted. Consequences when a
|
||||
second row enters "Chờ" 3 s after the first:
|
||||
|
||||
1. The chip shows the new text but keeps the old animation's progress; with
|
||||
`forwards` fill it reaches `opacity: 0` at t=5 s while the JS timer
|
||||
(`:120`) keeps the node mounted until t=8 s.
|
||||
2. That node is `pointer-events-auto` and sits centred over the card, so for
|
||||
~3 s an invisible ~150×60 px button eats taps on the middle cells — the user
|
||||
taps a number and nothing happens.
|
||||
|
||||
Fix: force a remount and let one owner control the lifetime.
|
||||
|
||||
```svelte
|
||||
{#key toastId}
|
||||
<div role="status" aria-live="polite" …>…</div>
|
||||
{/key}
|
||||
```
|
||||
with `let toastId = $state(0)` incremented in `showToast`, or drop the CSS
|
||||
`forwards` fade and remove the node from JS only.
|
||||
|
||||
### M2. "Kinh!" is cut off by "Chờ" in the same effect pass
|
||||
`web/src/lib/PlayerBoard.svelte:191` (pass 1 `playBingo()`), `:203` (pass 2
|
||||
`playWaiting()`), `web/src/lib/voice.js:107,123` (every entry point starts with
|
||||
`cancelPlayback()`)
|
||||
|
||||
A single draw that completes one row and puts another row one number away runs
|
||||
both passes in one effect run: `playBingo()` starts, then `playWaiting()`
|
||||
cancels it a fraction of a millisecond later. The win announcement — the whole
|
||||
point of the feature — is never heard in exactly the most exciting case.
|
||||
|
||||
Fix: give the two events a priority or a queue. Cheapest correct version: in
|
||||
pass 2, skip `playWaiting()` when pass 1 fired this run
|
||||
(`let announcedBingo = false` local flag), or add a small FIFO in `voice.js`
|
||||
(`enqueue(clip)`) instead of unconditional `cancelPlayback()`.
|
||||
|
||||
### M3. Theme flash on every cold load
|
||||
`web/src/routes/+layout.svelte:19-23`, `web/src/app.html` (no inline script)
|
||||
|
||||
`loadSettings()` runs in `onMount`, so the prerendered shell always paints with
|
||||
`:root` light tokens (`app.css:14-25`) before `documentElement.classList.toggle("dark")`
|
||||
is applied. Dark-theme users get a full-page white flash on every launch,
|
||||
including the Android WebView.
|
||||
|
||||
Fix: add a blocking inline script in `app.html` head that reads
|
||||
`localStorage.getItem("loto_settings")`, applies the `dark` class and
|
||||
`--empty-cell-bg`, before the first paint. Keep `loadSettings()` as the
|
||||
authoritative pass.
|
||||
|
||||
### M4. Both modal dialogs have no focus management
|
||||
`web/src/lib/PlayerBoard.svelte:566-618` (bingo), `web/src/lib/SettingsButton.svelte:175-190` (settings)
|
||||
|
||||
Both use `role="dialog" aria-modal="true"` but never move focus into the dialog,
|
||||
never trap Tab, never restore focus on close, and leave background content
|
||||
tabbable and scrollable. Screen-reader users get no announcement (focus stays on
|
||||
the trigger, outside the dialog), and keyboard users tab straight into the
|
||||
board behind the overlay. `aria-modal` claims a containment that does not exist.
|
||||
|
||||
Fix: on open, focus the dialog container (`tabindex="-1"` + `element.focus()`);
|
||||
cycle Tab between the first/last focusable descendants; restore
|
||||
`document.activeElement` on close; add `overflow: hidden` on `<body>` while open
|
||||
(or `inert` on the page wrapper). Escape handling already exists.
|
||||
|
||||
### M5. Two tabs opened at the same time freeze each other
|
||||
`web/src/lib/active-tab.svelte.js:35-51`
|
||||
|
||||
Both tabs `postMessage({type:"claim"})` on mount; each receives the other's claim
|
||||
and sets `inactive = true`. Result: both windows show the "đang chạy ở tab khác"
|
||||
banner and the app is unusable until the user manually clicks one. Not
|
||||
hypothetical — restoring a browser session with two pinned tabs does exactly this.
|
||||
|
||||
Fix: include a monotonic claim timestamp and ignore a peer claim that is older
|
||||
than this tab's own claim (`if (e.data.ts < myClaimTs) return;`), tie-break on id.
|
||||
|
||||
### M6. Auto-call voice is silent on iOS Safari (and 92 `Audio` objects are retained)
|
||||
`web/src/lib/voice.js:27-35`, driven from `web/src/lib/MasterPanel.svelte:97-103`
|
||||
|
||||
`getAudio()` constructs a **new** `Audio` per clip URL, lazily, at play time.
|
||||
Under auto-call, every `play()` happens inside a `setInterval` callback, i.e.
|
||||
outside a user gesture, on an element that was never unlocked. iOS Safari
|
||||
rejects it; `playClip` swallows the rejection via `a.play().catch(done)`
|
||||
(`voice.js:93`) so the host gets silence with no diagnostic. Secondary cost: up
|
||||
to 92 `preload="auto"` elements per voice held in the `cache` Map.
|
||||
|
||||
Fix: use a single reusable `HTMLAudioElement` whose `src` is swapped per clip and
|
||||
which is `play()`/`pause()`-unlocked during the first user gesture (the
|
||||
"Bắt đầu"/"Xổ số" click). Same for `clearAudioCache`. Keep the token/cancel
|
||||
bookkeeping as-is.
|
||||
|
||||
### M7. Lint and type checking never run in CI; JSDoc types are decorative
|
||||
`.github/workflows/ci.yml:42-50`, `web/jsconfig.json:1-6`, `web/package.json:11-16`
|
||||
|
||||
The `test` job runs `npm test` only. `npm run lint` exists but is not wired into
|
||||
any job (the Android job does run `:app:lint`). `jsconfig.json` sets
|
||||
`"checkJs": false` and there is no `svelte-check` dependency or script, so none
|
||||
of the extensive `@type`/`@param` annotations are ever verified — they can drift
|
||||
arbitrarily and CI stays green.
|
||||
|
||||
Fix: add `- run: npm run lint` to the `test` job, add
|
||||
`svelte-check --tsconfig ./jsconfig.json` as a `check` script (with
|
||||
`"checkJs": true`), and run it in the same job. Expect an initial batch of
|
||||
findings; fix or explicitly ignore them once.
|
||||
|
||||
### M8. Riskiest code has no tests
|
||||
`web/src/lib/*.test.js` (8 files, all module-level)
|
||||
|
||||
Coverage is good for pure/logic modules but there is not a single component or
|
||||
effect test. Every defect above (C1, H2, M1, M2, M5) lives in component effects
|
||||
or build config — exactly the untested surface. The passing 121 tests give a
|
||||
false sense of safety.
|
||||
|
||||
Fix: add `@testing-library/svelte`/`vitest-browser-svelte` cases for at least:
|
||||
master draw → player auto-cross in `both` mode, "Ván mới" clearing player marks,
|
||||
consecutive-toast behaviour, and the bingo+waiting audio ordering.
|
||||
|
||||
### M9. Docs describe an update UX that does not exist
|
||||
`web/docs/codebase-summary.md:64`, `web/docs/development-roadmap.md:21`,
|
||||
`web/vite.config.js:40-42`
|
||||
|
||||
Docs say "Auto-update w/ reload toast"; there is no reload-prompt component
|
||||
anywhere in `src/`. `registerType: "autoUpdate"` makes vite-plugin-pwa emit
|
||||
`skipWaiting`/`clientsClaim` and reload on controller change — the exact
|
||||
mid-game swap the comment at `vite.config.js:40-41` says must not happen.
|
||||
Once C1 is fixed, a deploy during a fairground round can reload the page and
|
||||
drop in-memory state (`autoRunning`, `showCongrats`, countdown).
|
||||
|
||||
Fix: pick one and make code, comment, and docs agree — either implement the
|
||||
prompt (`registerType: "prompt"` + a small "Có bản mới, tải lại?" banner) or
|
||||
document that auto-reload is accepted and delete the contradicting comment.
|
||||
|
||||
---
|
||||
|
||||
## Low
|
||||
|
||||
- **L1** `web/static/manifest.webmanifest:2` — `"id": "/loto/"` is hardcoded, but
|
||||
the same file is deployed to the Firebase root (base `""`), where that path
|
||||
does not exist. Use `"id": "."` to match `start_url`/`scope`, or template the
|
||||
file per build profile.
|
||||
- **L2** `web/src/lib/PlayerBoard.svelte:197-207` — when several rows enter "Chờ"
|
||||
on the same draw, each iteration overwrites the previous toast and cancels the
|
||||
previous clip; only the last row is ever announced. Aggregate
|
||||
(`Chờ 12, 47`) or queue.
|
||||
- **L3** `web/src/lib/settings-store.svelte.js:204-216` — `resetSettings()` can
|
||||
change `settings.voice` without calling `clearAudioCache()` (unlike
|
||||
`SettingsButton.svelte:116-120`), leaving the old voice's elements cached.
|
||||
- **L4** `web/src/lib/game-logic.js:192-217` and
|
||||
`web/src/lib/master-store.svelte.js:34-56` — validators check shape and range
|
||||
but not game semantics: a grid may contain duplicates/values outside a
|
||||
column's range, and `called`/`remaining` may overlap, contain duplicates, or
|
||||
not sum to 90. Self-inflicted only (localStorage), but a duplicate in `called`
|
||||
silently collapses in the `callOrder` Map (`MasterPanel.svelte:77-79`). Add
|
||||
`called.length + remaining.length === 90` and a disjointness check; fall back
|
||||
to defaults otherwise.
|
||||
- **L5** `web/src/routes/+layout.svelte:27-41` — `<button>` wrapping `<p>`
|
||||
elements is an invalid content model (phrasing content only). Use a
|
||||
`<div role="button" tabindex="0">` or move the text out and keep a labelled
|
||||
button.
|
||||
- **L6** `web/src/lib/AutoCountdown.svelte:41-48` — the rAF loop runs at display
|
||||
refresh rate for the whole round even when `reduceMotion` is true and
|
||||
`dashOffset` is pinned to 0 (`:63-65`); only the integer second changes. Gate
|
||||
the loop on `!reduceMotion` and drive the digit from a 1 Hz timer.
|
||||
- **L7** `web/vite.config.js:23-25` — `revision: "audio-v1-…"` is a manual
|
||||
version string; regenerating clips without editing it serves stale audio from
|
||||
precache forever. Derive it from a file hash or the manifest mtime.
|
||||
- **L8** `web/firebase.json` — no `headers` block. Add explicit `Cache-Control`
|
||||
(`no-cache` for `index.html`/`sw.js`, `immutable` for `_app/immutable/**`)
|
||||
rather than relying on Hosting defaults, especially once the SW ships.
|
||||
- **L9** `web/scripts/generate-audio.py:44-49` — `voice_id()` slugs from the last
|
||||
hyphen segment only; two voices whose names slug identically would silently
|
||||
overwrite each other's folder. Assert uniqueness before writing.
|
||||
- **L10** `web/src/lib/PlayerBoard.svelte:176` — the modal says "Hàng {1..9}" but
|
||||
the card is presented as three labelled 3-row sections
|
||||
(`:374-379`); users must count across sections to find the row. Consider
|
||||
"Hàng 1 — TN1 (2014-2017)".
|
||||
|
||||
---
|
||||
|
||||
## Edge Cases Checked and Found Sound
|
||||
|
||||
Recorded so they are not re-litigated:
|
||||
|
||||
- `pickFilledColsOnce` (`game-logic.js:80-121`) cannot produce `need < 0` (that
|
||||
would recurse `combinations` with a negative `k`): more than 5 columns with
|
||||
`quota === rowsLeft` would imply `sum(quota) > 5·rowsLeft`, contradicting the
|
||||
invariant that each row removes exactly 5. Column quotas hold on the fallback
|
||||
path too, so `picked.shift() ?? 0` (`:160`) never silently blanks a cell.
|
||||
- The `crossed`/`lastHandledIndex` self-writing effect
|
||||
(`PlayerBoard.svelte:245-258`) converges: `applyMasterCalls` short-circuits at
|
||||
`lastHandledIndex >= called.length` (`player-auto-cross.js:33-35`).
|
||||
- Store hydration order is safe either way: `MasterPanel` cannot mount before
|
||||
`loadSettings()` (default `mode` is `"player"`), and `PlayerBoard`'s replay
|
||||
from cursor 0 is idempotent via `findUncrossedCell`.
|
||||
- Unticking a called number does not get re-crossed on the next effect run
|
||||
(cursor already at `called.length`).
|
||||
- Freezing an inactive tab unmounts the page, which clears the auto-call
|
||||
interval, releases the wake lock, and cancels audio.
|
||||
- Shuffle bias, column ranges, ascending-within-column, and 5/5 quotas match
|
||||
`docs/project-overview-pdr.md:16-27` exactly.
|
||||
|
||||
## Recommended Actions (priority order)
|
||||
|
||||
1. Register the service worker and add a CI assertion that the built HTML
|
||||
contains the registration (C1).
|
||||
2. Prefix `additionalManifestEntries` with the resolved base path; verify
|
||||
`build/sw.js` for the `gh` profile (H1).
|
||||
3. Re-hydrate stores in `claimActiveTab()` and gate `saveMaster()` on hydration (H2).
|
||||
4. Fix the toast remount/pointer-events defect (M1) and the bingo/waiting audio
|
||||
collision (M2).
|
||||
5. Add the inline pre-paint theme script (M3).
|
||||
6. Add focus management to both dialogs (M4).
|
||||
7. Wire `npm run lint` + `svelte-check` into CI and turn on `checkJs` (M7).
|
||||
8. Resolve the double-claim freeze (M5) and iOS audio unlock (M6).
|
||||
9. Add component/effect tests covering the paths above (M8); reconcile the
|
||||
update-UX docs (M9).
|
||||
|
||||
## Metrics
|
||||
|
||||
- Unit tests: 121 passed / 8 files (`vitest run`), all module-level; 0 component tests.
|
||||
- Lint: `eslint .` → 0 problems.
|
||||
- Type coverage: not enforced — `checkJs: false`, no `svelte-check` in the repo.
|
||||
- Build: `BUILD_PROFILE=gh vite build` succeeds; precache manifest 115 entries
|
||||
(92 of them unreachable on that base path, see H1).
|
||||
|
||||
## Unresolved Questions
|
||||
|
||||
1. Was the PWA ever verified in a deployed browser (Application → Service Workers)?
|
||||
If it worked previously, something in the `@vite-pwa/sveltekit@1` /
|
||||
`@sveltejs/kit@2` / `vite@8` upgrade path dropped the injection, and the fix
|
||||
should be pinned by a regression check rather than a one-off patch.
|
||||
2. Multi-tab policy: is the intended contract "last claimant wins and adopts the
|
||||
stored round" (implies re-hydrate, H2) or "each tab owns its own session"?
|
||||
The fix differs.
|
||||
3. `manifest.webmanifest` ships identically to two origins with different base
|
||||
paths — is the GitHub Pages deployment still a supported target, or is
|
||||
Firebase now canonical? That decides L1 and the H1 fix's urgency.
|
||||
4. Update policy for mid-round deploys (M9): silent auto-reload, or prompt?
|
||||
@@ -0,0 +1,283 @@
|
||||
# Pre-commit review — accumulated review-fix cycle (web + android + CI)
|
||||
|
||||
Reviewer: code-reviewer | Date: 2026-08-31 | Mode: static reading only (no builds/tests run)
|
||||
Scope: full uncommitted working tree (42 modified + 14 new files), plan
|
||||
`plans/260831-2314-review-followups/plan.md` and the six fix reports.
|
||||
|
||||
## Verdict
|
||||
|
||||
Plan acceptance criteria are substantially met, but three of them are met only
|
||||
under an ordering assumption or only on the path that was tested. One **High**
|
||||
regression unrelated to this plan (introduced by the earlier L1/L2 audio fix) is
|
||||
a launch-blocker for the announcer.
|
||||
|
||||
| Criterion | Verdict |
|
||||
|---|---|
|
||||
| Multi-tab newest-claim-wins, exactly one winner | Partially met — symmetric cases correct; a losing claimer is never told it lost (M-1) |
|
||||
| Game-state-only backup exclusion, settings kept | Met — path verified against DataStore's default location |
|
||||
| Settings-loaded gate never advances the cursor early | Met for `onMasterState`; not for `generate()`/`clearMarks()` (M-3) |
|
||||
| Gate cannot deadlock the ViewModel | Met for the `IOException` path; not for a non-IO failure (M-4) |
|
||||
|
||||
---
|
||||
|
||||
## High
|
||||
|
||||
### H-1 — `voicePlayer.release()` permanently kills audio after "Thoát" + relaunch in the same process
|
||||
|
||||
`android/app/src/main/java/com/miti99/loto/MainActivity.kt:58-59`
|
||||
`android/app/src/main/java/com/miti99/loto/LotoApplication.kt:85-87`
|
||||
|
||||
`onDestroy()` releases the **app-scoped** ExoPlayer when `isFinishing`. The
|
||||
"Thoát" dialog calls `finish()` (MainActivity.kt:104), but `finish()` does not
|
||||
end the process — Android keeps it cached. On relaunch from the launcher/Recents
|
||||
the same `LotoApplication` instance is reused, and `voicePlayer` is a `by lazy`
|
||||
singleton that is never rebuilt, so `LotoViewModelFactory` hands both ViewModels
|
||||
the already-released `ExoVoicePlayer` (`VoicePlayer.kt:98-100` → `player.release()`
|
||||
is terminal, per the interface's own doc: "the instance is unusable afterwards").
|
||||
Every subsequent `speak()` either throws or silently drops — the announcer is dead
|
||||
until the OS reclaims the process. This is a new failure mode: before this change
|
||||
the VMs only called `cancel()`.
|
||||
|
||||
Fix (pick one):
|
||||
|
||||
```kotlin
|
||||
// LotoApplication.kt — make it recreatable instead of a one-shot lazy
|
||||
private var _voicePlayer: VoicePlayerApi? = null
|
||||
val voicePlayer: VoicePlayerApi
|
||||
get() = _voicePlayer ?: ExoVoicePlayer(this)
|
||||
.also { it.voiceId = VoiceCatalog.defaultVoiceId(voices); _voicePlayer = it }
|
||||
fun releaseVoicePlayer() { _voicePlayer?.release(); _voicePlayer = null }
|
||||
```
|
||||
|
||||
then call `releaseVoicePlayer()` from `MainActivity.onDestroy()`. Alternatively
|
||||
drop `release()` entirely and call `voicePlayer.cancel()` there — the player is a
|
||||
tiny resource and the process teardown reclaims it.
|
||||
|
||||
Note the release path is untested: no unit test covers `onDestroy`, and
|
||||
`FakeVoicePlayer` has no "released" state to assert against.
|
||||
|
||||
---
|
||||
|
||||
## Medium
|
||||
|
||||
### M-1 — Newest-claim-wins can now leave **two** tabs active (a losing claimer is never corrected)
|
||||
|
||||
`web/src/lib/active-tab.svelte.js:57-66`
|
||||
|
||||
The protocol is one-way: a tab that receives a *losing* peer claim just ignores
|
||||
it. The peer therefore never learns it lost and stays active locally
|
||||
(`claimActiveTab()` sets `inactive = false` before broadcasting, line 83). The
|
||||
old bug (both tabs frozen) has been traded for both tabs *active* — double
|
||||
auto-call intervals, double `localStorage` writers, overlapping audio, i.e. the
|
||||
exact thing the module exists to prevent.
|
||||
|
||||
Reachable paths:
|
||||
|
||||
1. The repo's own new test demonstrates it —
|
||||
`web/src/lib/active-tab.test.js` "reclaim updates myClaimTs so a stale peer
|
||||
claim can't re-freeze the reclaimed tab": after `tabB.claimActiveTab()` with a
|
||||
stale ts, `tabA.inactive === false` **and** `tabB.inactive === false`. The test
|
||||
only asserts A.
|
||||
2. Wall-clock stepping backwards (NTP correction / user changing device time on a
|
||||
fairground tablet) makes any new claim "older" than the incumbent's.
|
||||
3. Equal-millisecond opens where delivery is asymmetric: a tab created *after*
|
||||
the peer's `postMessage` never receives that claim (real `BroadcastChannel`
|
||||
does not replay), so only one side evaluates the tie-break; if the surviving
|
||||
evaluator holds the greater id it ignores the peer and both stay active.
|
||||
4. Mixed-version tabs (now more likely, since `registerType: "prompt"` keeps old
|
||||
tabs alive): a pre-fix tab broadcasts `{type, id}` with no `ts`, so
|
||||
`peerTs > myClaimTs` and `peerTs === myClaimTs` are both `false` → the new tab
|
||||
never freezes.
|
||||
|
||||
Fix — make the winner echo, and treat a missing `ts` as winning:
|
||||
|
||||
```js
|
||||
bc.onmessage = (e) => {
|
||||
if (e.data?.type !== "claim" || e.data.id === TAB_ID) return;
|
||||
const peerTs = typeof e.data.ts === "number" ? e.data.ts : Infinity; // legacy peer wins
|
||||
const peerWins =
|
||||
peerTs > myClaimTs || (peerTs === myClaimTs && e.data.id > TAB_ID);
|
||||
if (peerWins) {
|
||||
activeTab.inactive = true;
|
||||
} else if (!activeTab.inactive && bc) {
|
||||
// Tell the loser it lost — same (ts,id), no refresh, so this terminates:
|
||||
// the peer freezes and a frozen tab never echoes.
|
||||
bc.postMessage({ type: "claim", id: TAB_ID, ts: myClaimTs });
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
Add a test for the asymmetric case (only one channel open at post time) asserting
|
||||
exactly one `inactive === false`.
|
||||
|
||||
### M-2 — The settings-loaded gate relies on undocumented emission ordering between two independent collectors
|
||||
|
||||
`android/app/src/main/java/com/miti99/loto/LotoApplication.kt:58-74`
|
||||
`android/app/src/main/java/com/miti99/loto/state/PlayerBoardViewModel.kt:126-127, 192`
|
||||
|
||||
`settingsState` and `settingsLoaded` are two separate `stateIn` collectors over
|
||||
two separate `settingsRepository.settingsFlow` chains. `onMasterState` reads
|
||||
`settings.value` directly (line 192), not the combine snapshot. Nothing guarantees
|
||||
that the `settingsState` collector applies the loaded value *before* the
|
||||
`settingsLoaded` collector flips to `true` — both are resumed as independent tasks
|
||||
on `Dispatchers.Main.immediate`. If `loaded` wins the race, the gate opens while
|
||||
`settings.value` still returns the PLAYER default and the M4 bug reappears exactly
|
||||
as before, silently. It currently works only because `settingsState` happens to be
|
||||
subscribed first (`onCreate` line 100, and factory arg order).
|
||||
|
||||
Fix — single source of truth, so "loaded" and "value" cannot disagree:
|
||||
|
||||
```kotlin
|
||||
// LotoApplication
|
||||
val settingsOrNull: StateFlow<Settings?> by lazy {
|
||||
settingsRepository.settingsFlow.stateIn(appScope, SharingStarted.Eagerly, null)
|
||||
}
|
||||
```
|
||||
|
||||
and in `PlayerBoardViewModel` take `settings: StateFlow<Settings?>` (or keep both
|
||||
but pass the settings snapshot *into* `onMasterState(master, settings)` from the
|
||||
combine transform, and derive `loaded` as `snapshot != null`). Reading
|
||||
`settings.value` inside a gated handler is the defect shape to remove.
|
||||
|
||||
### M-3 — The gate covers `onMasterState` only; `generate()` / `clearMarks()` still consume the master history under the placeholder mode
|
||||
|
||||
`android/app/src/main/java/com/miti99/loto/state/PlayerBoardViewModel.kt:224-237, 258-271`
|
||||
`android/app/src/main/java/com/miti99/loto/ui/player/PlayerBoardScreen.kt:78`
|
||||
|
||||
Both methods read `settings.value.mode` unguarded and, in the non-BOTH branch, do
|
||||
`lastHandledIndex = masterStore.state.value.called.size`. The generate button is
|
||||
gated on `state.loading` (player restore only), not on settings having resolved.
|
||||
So a tap in the pre-settings window — the most likely moment to tap, since the
|
||||
empty board's only action is "Tạo bảng mới" — with a persisted `mode = BOTH` and a
|
||||
restored master history advances the cursor to `called.size` with no crossing.
|
||||
`onMasterState` later runs under the real BOTH mode but `cursor >= called.size`
|
||||
returns `changed = false` (`PlayerAutoCross.kt:34-36`), so the replay is
|
||||
permanently lost. This is the same class of bug M4 was filed for; the new test
|
||||
`master restore landing with full history before settings resolves...` avoids it
|
||||
only because it calls `generate()` *before* `masterStore.restore()`.
|
||||
|
||||
Fix: fold the settings gate into the UI loading flag, e.g. publish
|
||||
`loading = !restoreDone || !settingsLoaded.value` (collect `settingsLoaded` in the
|
||||
existing `init` combine and republish), so both buttons are disabled until the
|
||||
mode is real. Add a test that restores the master history *before* `generate()`
|
||||
while settings is still gated.
|
||||
|
||||
### M-4 — `settingsLoaded` can stick at `false` forever, permanently disabling auto-cross
|
||||
|
||||
`android/app/src/main/java/com/miti99/loto/settings/SettingsRepository.kt:76`
|
||||
|
||||
`loaded` is derived from `settingsFlow`, whose `catch` re-throws anything that is
|
||||
not an `IOException` (line 52-60). A non-IO failure therefore cancels the
|
||||
`settingsLoaded` collector (`SupervisorJob` keeps the app alive), leaving
|
||||
`loaded == false` for the process lifetime — `onMasterState` is then never invoked
|
||||
and the player board silently stops following the master. The plan explicitly
|
||||
required the gate not to deadlock the ViewModel on a settings-load failure.
|
||||
|
||||
Fix (one line, fail-open):
|
||||
|
||||
```kotlin
|
||||
val loaded: Flow<Boolean> =
|
||||
settingsFlow.map { true }.onStart { emit(false) }.catch { emit(true) }
|
||||
```
|
||||
|
||||
Fail-open is correct here: a broken settings store should degrade to defaults, not
|
||||
to a dead board. Add a `ThrowingDataStore`-with-`RuntimeException` test.
|
||||
|
||||
---
|
||||
|
||||
## Low
|
||||
|
||||
- **L-a `generate()` is now asynchronous** (`PlayerBoardViewModel.kt:210-216`):
|
||||
two fast taps enqueue two generations (the confirm dialog is skipped while
|
||||
`grid` is still `null`), and the H1 guard `saved != null && grid == null`
|
||||
(line 137) is weaker than its comment claims, because `grid` is only assigned at
|
||||
the end of `applyGeneratedGrid`. Consider a `generating` flag that both
|
||||
short-circuits a second `generate()` and participates in the restore guard.
|
||||
- **L-b iOS unlock is master-only** (`web/src/lib/voice.js:56-72`,
|
||||
`MasterPanel.svelte:156, 170`): `unlockAudio()` is called only from the master's
|
||||
draw/auto buttons. In player-only mode with `voiceEnabledPlayer` on, the shared
|
||||
element is never primed inside a gesture, so Chờ/Kinh may stay silent on iOS.
|
||||
Call `unlockAudio()` from the player cell-tap handler too. Also: the unlock's
|
||||
`play().then(() => a.pause())` would cut a live clip if `unlockAudio()` were ever
|
||||
called while something is playing — safe today only because the `src` swap in
|
||||
`playClip` happens synchronously in the same click task.
|
||||
- **L-c web/android validation parity**: Android now rejects a restored round that
|
||||
is not a complete 90-number partition (`GameStateRepository.kt:70-73`), while
|
||||
`web/src/lib/master-store.svelte.js:56-62` still accepts any two in-range arrays
|
||||
(duplicates, overlap, short deck). Either port the check or record the
|
||||
divergence — a shared localStorage origin can produce a double-crossed cell.
|
||||
- **L-d dead overrides**: `PlayerBoardViewModel.onCleared()` (line 316-323) and
|
||||
`MasterPanelViewModel.onCleared()` now only call `super`. Delete the overrides and
|
||||
move the rationale to the class doc; an empty override invites someone to
|
||||
"fix" it later.
|
||||
- **L-e loading flags never time out**: `MasterStore.loading` /
|
||||
`PlayerUiState.loading` gate the only two primary buttons and only clear when the
|
||||
DataStore read resolves. An `IOException` resolves (handled), but a hung read
|
||||
leaves the app unusable with no escape hatch. Optional: `withTimeoutOrNull` around
|
||||
the restore read.
|
||||
- **L-f brittle CI gate**: `web/scripts/verify-pwa-build.mjs:52-56` requires one
|
||||
built chunk to contain both `"serviceWorker"` and `"sw.js"`. A future Rollup
|
||||
chunking change can split them and fail CI with no real regression. Prefer
|
||||
asserting on the presence of the `virtual:pwa-register` output chunk plus a
|
||||
`sw.js` reference anywhere under `_app`.
|
||||
- **L-g backup exclusion misses the DataStore temp sibling**:
|
||||
`android/app/src/main/res/xml/{data_extraction_rules,backup_rules}.xml` exclude
|
||||
`datastore/loto_game_state.preferences_pb` only. DataStore writes through
|
||||
`<name>.preferences_pb.tmp`; a backup taken mid-write can capture it. Add the
|
||||
`.tmp` path (or exclude by directory if you ever move the round store to its own
|
||||
subdirectory).
|
||||
|
||||
---
|
||||
|
||||
## Verified as intentional / no action
|
||||
|
||||
- `android:launchMode="singleTask"` removal is finding L8 from
|
||||
`plans/reports/code-reviewer-260831-2213-android-review.md:276` — deliberate, and
|
||||
the launcher intent already brings the existing task to front, so no behavioral
|
||||
regression.
|
||||
- Backup path is correct: `preferencesDataStore(name = "loto_game_state")`
|
||||
(`LotoApplication.kt:33-36`) → `files/datastore/loto_game_state.preferences_pb`;
|
||||
`loto_settings` is not excluded, as the decision requires. `allowBackup="true"`
|
||||
unchanged.
|
||||
- Storage keys/file names unchanged on both platforms (`loto_settings`,
|
||||
`loto_master`, `loto_game_state`); `masterState.hydrated` is *not* serialized
|
||||
(`master-store.svelte.js:72-84`), so no stored-shape change.
|
||||
- Removed strings (`player_board_label`, `toast_dismiss`, `kinh_close`,
|
||||
`master_board_label`) have no remaining references.
|
||||
- H1 loading-gate behavior survives the M4 gate: `restore()` still publishes
|
||||
`loading = false` on both branches (`PlayerBoardViewModel.kt:153`), the early-return
|
||||
branch of `publish()` now carries `loading` through (line 404), and `prevCalledLen`
|
||||
stays `0` while the gate is closed, so no false "Ván mới" shrink is detected when
|
||||
it opens.
|
||||
- Web reclaim/hydration flow is coherent: `reclaimTab()` re-reads storage *before*
|
||||
flipping `inactive` (`+layout.svelte:64-68`), children are destroyed while frozen
|
||||
so `PlayerBoard` re-initializes from storage, and `MasterPanel`'s save effect is
|
||||
gated on `masterState.hydrated`.
|
||||
- Update banner vs other-tab overlay cannot collide: they live in mutually
|
||||
exclusive branches of `{#if activeTab.inactive}`; a prompt raised while frozen
|
||||
simply appears after reclaim.
|
||||
- `PlayerAutoCross.applyMasterCalls` already resets a stale cursor
|
||||
(`PlayerAutoCross.kt:33`), so the L6 change (clearing `manualUnticks` on a shrink
|
||||
in every mode without resetting `lastHandledIndex` outside BOTH) does not strand
|
||||
the cursor.
|
||||
- New tests are behavioral, not phantom: `SlowDataStore`/`ThrowingDataStore` drive
|
||||
real suspension/failure paths, and the M4-residual and H1 tests would fail without
|
||||
their respective fixes.
|
||||
- CI additions (`npm run lint`, `npm run check`, `verify:pwa` with `BUILD_PROFILE`)
|
||||
and the keystore cleanup step (`if: always()`) are contract-safe additions.
|
||||
|
||||
## Unresolved questions
|
||||
|
||||
1. H-1 — is exiting via "Thoát" and immediately reopening in-scope for QA? If the
|
||||
answer is "the process always dies", H-1 drops to Low; nothing in the code
|
||||
guarantees that.
|
||||
2. M-1 — is "two active tabs" acceptable in any scenario, or should a losing
|
||||
claimer always freeze itself? The echo fix assumes the latter.
|
||||
3. Is `unlockAudio()`'s src-less priming `play()` actually sufficient to unlock iOS
|
||||
Safari? It has not been verified on a device, and the comment asserts it as
|
||||
fact. A silent one-sample data-URI source would be the safer primer.
|
||||
4. Scope: the diff carries a large amount of work beyond this plan's three items
|
||||
(PWA prompt + registration, voice single-element rewrite, `checkJs`, focus trap,
|
||||
pre-paint theme script). Confirm this is intended to land as one commit, or split
|
||||
the PWA/audio work out — it has a different blast radius than the three plan items
|
||||
and (H-1, L-b) is where the residual risk sits.
|
||||
@@ -0,0 +1,200 @@
|
||||
# Android review fixes (2026-08-31)
|
||||
|
||||
Source review: `plans/reports/code-reviewer-260831-2213-android-review.md`.
|
||||
Scope: `android/` only (per delegation; `web/` read-only for M2 parity check;
|
||||
`.github/workflows/` and `android/local.properties` untouched).
|
||||
|
||||
## Fixed
|
||||
|
||||
### H1 — restore/user-action race
|
||||
|
||||
- `state/PlayerBoardViewModel.kt`: `restore()` now only applies the saved
|
||||
round `if (saved != null && grid == null)` — a `generate()` that lands
|
||||
while the DataStore read is still in flight can no longer be clobbered.
|
||||
Added `loading: Boolean` to `PlayerUiState` (`true` until restore
|
||||
resolves); `PlayerBoardScreen.kt`'s "Tạo bảng mới" button is now
|
||||
`enabled = !state.loading`, closing the no-confirm branch at its root
|
||||
(previously `grid == null` meant both "no card" and "not loaded yet").
|
||||
- `state/MasterStore.kt`: added `loading: StateFlow<Boolean>` and a
|
||||
`mutatedBeforeRestore` guard set by `startNewGame()`/`drawNext()`;
|
||||
`restore()` skips applying the saved snapshot once either has run.
|
||||
`state/MasterPanelViewModel.kt` exposes `loading`;
|
||||
`ui/master/MasterPanelScreen.kt`'s "Ván mới" button is now
|
||||
`enabled = !loading`, same shape as the player fix.
|
||||
- Regression tests: added `app/src/test/java/com/miti99/loto/SlowDataStore.kt`
|
||||
(delays a wrapped `DataStore`'s first `.data` emission until a
|
||||
`CompletableDeferred` gate completes — `InMemoryDataStore` alone resolves
|
||||
synchronously so the race was invisible to the existing suite, per the
|
||||
report's test-quality note). Added
|
||||
`restore does not clobber a card generated while the load is still in
|
||||
flight` (PlayerBoardViewModelTest) and
|
||||
`restore does not clobber a new game started while the load is still in
|
||||
flight` (new `MasterStoreTest.kt`) — both fail without the fix, pass with it.
|
||||
|
||||
### H2 — tier-2 confetti invisible
|
||||
|
||||
- `ui/player/KinhDialog.kt`: `Dialog` now uses
|
||||
`DialogProperties(usePlatformDefaultWidth = false)` so the dialog window
|
||||
spans the full screen, and `ConfettiOverlay()` is composed *inside* that
|
||||
window (drawn after/above the card `Column` in a `Box`), gated by a new
|
||||
`showConfetti: Boolean` param. `PlayerBoardScreen.kt` no longer emits
|
||||
`ConfettiOverlay()` as a scroll-column sibling. This is option (b) from
|
||||
the report (embed in the dialog's own window) rather than a separate
|
||||
`Popup`, since it guarantees same-window z-order over the scrim without
|
||||
relying on cross-window layering behavior.
|
||||
|
||||
### M1 — RGB truncation
|
||||
|
||||
- `ui/settings/EmptyCellColorPicker.kt`: `r`/`g`/`b` now use `roundToInt()`
|
||||
instead of `toInt()`.
|
||||
|
||||
### M2 — force-scroll to hero in `both` mode
|
||||
|
||||
- Checked `web/src/lib/MasterPanel.svelte:86-158` (read-only): `handleDrawNext()`
|
||||
sets `scrollOnNextDraw = true` unconditionally, called by both the manual
|
||||
draw button and the auto-call `setInterval`, with no `settings.mode` gate.
|
||||
`MasterPanel.svelte` is rendered the same way in `master` and `both` mode
|
||||
(`+page.svelte:37`). **Web already force-scrolls on every draw in `both`
|
||||
mode** — Android's `LaunchedEffect(tickKey) { ... heroRequester.bringIntoView() }`
|
||||
in `MasterPanelScreen.kt` already matches. Left as-is; added a comment
|
||||
recording the parity check so this isn't re-litigated as a bug.
|
||||
|
||||
### M3 — hardcoded toast copy / unused strings
|
||||
|
||||
- `PlayerUiState.toast: String?` → `waitingNumber: Int?`. The ViewModel now
|
||||
stores the raw number; `PlayerBoardScreen.kt` formats it with
|
||||
`stringResource(R.string.toast_waiting, waitNum)`.
|
||||
- Deleted 4 of the 5 unreferenced resources from `strings.xml`:
|
||||
`toast_dismiss`, `player_board_label`, `master_board_label`, `kinh_close`.
|
||||
`toast_waiting` is now wired (not deleted).
|
||||
- Did **not** move `PlayerCell.kt`'s `stateDescription` fragments into
|
||||
`strings.xml` — the task scope named only the toast + the five unused
|
||||
resources; that's a separate, additive accessibility cleanup the report
|
||||
suggests but didn't require. Noted below as deferred.
|
||||
|
||||
### M4 — cold-start settings/master ordering
|
||||
|
||||
- `LotoApplication.kt`: split `onCreate`'s single coroutine into two
|
||||
`appScope.launch` blocks (`masterStore.restore()` and
|
||||
`settingsState.collect { ... }`) so a slow round-state restore can't stall
|
||||
voice-id sync.
|
||||
- `PlayerBoardViewModel.kt`: `init` now does
|
||||
`combine(masterStore.state, settings) { master, _ -> master }.collect { onMasterState(it) }`
|
||||
instead of a plain `masterStore.state.collect`, so a settings arrival
|
||||
re-runs the replay against the current master snapshot.
|
||||
- **Scope note (see Unresolved below)**: `PlayerAutoCross.applyMasterCalls`
|
||||
advances the cursor to `called.size` on every non-`BOTH` pass, even a
|
||||
no-op one. `combine()` closes the ordering where settings resolves to its
|
||||
real value no later than `masterStore.restore()`'s first real emission
|
||||
(verified by test). It does **not** close the reverse ordering — master's
|
||||
restore landing with full history before settings ever resolves — because
|
||||
by the time settings arrives, the cursor has already consumed that
|
||||
history under the wrong mode. Fully closing that needs a "settings
|
||||
loaded" signal threaded through `SettingsRepository`/`LotoApplication`,
|
||||
which is a larger change than this narrow, "usually invisible" (report's
|
||||
words) edge case warrants; documented as a deferred follow-up rather than
|
||||
silently left unfixed.
|
||||
|
||||
### M5 — `generateGrid()` off the main thread
|
||||
|
||||
- `PlayerBoardViewModel.generate()`: the pure `CardGenerator.generateGrid()`
|
||||
call now runs on an injectable `computeDispatcher` (defaults to
|
||||
`Dispatchers.Default`), then `withContext(Dispatchers.Main.immediate)`
|
||||
applies the result via a new private `applyGeneratedGrid()`. Only the
|
||||
computation is offloaded; all `ViewModel` field mutations still happen on
|
||||
Main, so there's no new cross-thread mutable-state hazard.
|
||||
- Added the `computeDispatcher` constructor param (defaulted) specifically
|
||||
so unit tests can pass the same `StandardTestDispatcher` used for Main —
|
||||
otherwise `generate()` would race a real background thread against
|
||||
`runCurrent()`/`advanceUntilIdle()`, making the suite flaky.
|
||||
`LotoViewModelFactory.kt` needed no change (named-arg construction, new
|
||||
param is defaulted).
|
||||
|
||||
### M6 — master-panel recomposition
|
||||
|
||||
- **Deferred, not applied.** The report frames this as "not a blocker...
|
||||
worth measuring," conditional on API 24 device profiling that
|
||||
`plans/todo.md` shows as still unchecked. The suggested mitigations
|
||||
(`key(num)` blocks or lambda-based cell props) touch the hot
|
||||
`MasterBoard`/cell-render path with no compose UI test harness in this
|
||||
project to catch a visual regression — the risk/verification ratio didn't
|
||||
clear the bar for a code-review-driven change without a measured
|
||||
baseline. Flagging back to the user rather than guessing at scope.
|
||||
|
||||
### L1 — ExoPlayer never released
|
||||
|
||||
- `MainActivity.kt`: `onDestroy()` now calls
|
||||
`(application as LotoApplication).voicePlayer.release()` when
|
||||
`isFinishing` is true. Guarded by `isFinishing` (not fired on a
|
||||
rotation/config-change recreate) since `ExoPlayer.release()` is terminal
|
||||
— the single Activity finishing via the exit-confirm dialog's `finish()`
|
||||
is the one path where no further `speak()` call can follow.
|
||||
|
||||
### Manifest/CI hygiene (clear-cut only)
|
||||
|
||||
- `AndroidManifest.xml` (L8): dropped `android:launchMode="singleTask"`
|
||||
(default `standard`) — no deep links or external `startActivity` targets.
|
||||
- `app/build.gradle.kts` (L11): moved the Vietnamese-only trim from the
|
||||
deprecated `defaultConfig.resourceConfigurations` to
|
||||
`androidResources.localeFilters` (AGP 8.13).
|
||||
- **L10 (CI keystore cleanup) not applied**: `.github/workflows/android-release.yml`
|
||||
is outside `android/`, and the file-ownership instruction for this task is
|
||||
explicit ("Files you may modify: anything under .../android"). Flagged,
|
||||
not fixed — a one-line `if: always()` rm step for whoever owns that file.
|
||||
- **L9 (`allowBackup`/`dataExtractionRules`) not applied**: the report
|
||||
itself frames this as conditional ("if a restored `loto_game_state` from
|
||||
another device is undesirable") — that's a product decision about
|
||||
cross-device transfer behavior, not a clear-cut technical fix. Left as-is
|
||||
pending a decision.
|
||||
- **L7 (`Color.parseColor` → `toColorInt()`) not applied**: not in the
|
||||
task's enumerated item list (task named Manifest/CI hygiene specifically);
|
||||
`ColorParsing.kt` is core app code, out of the explicitly requested scope.
|
||||
|
||||
## Verification
|
||||
|
||||
- `JAVA_HOME="C:/Program Files/Eclipse Adoptium/jdk-21.0.10.7-hotspot" ./gradlew :app:testDebugUnitTest`
|
||||
→ **BUILD SUCCESSFUL**. `PlayerBoardViewModelTest`: 17/17 (14 original +
|
||||
3 new). `MasterStoreTest` (new): 2/2. All other existing suites
|
||||
unaffected (no other test files touched).
|
||||
- `./gradlew :app:lintDebug` → **BUILD SUCCESSFUL**, no findings in any file
|
||||
touched by this change (checked by grepping the HTML report for each
|
||||
modified filename).
|
||||
- No gradle daemon left running beyond the two builds above (Gradle's
|
||||
default idle-timeout will reap it; not force-killed since this repo's
|
||||
standard flow doesn't request that and the daemon is reused by whoever
|
||||
runs the next Gradle command in this workspace).
|
||||
|
||||
## Deferred / out of scope
|
||||
|
||||
- M6 (master-panel recomposition) — needs a profiled baseline first.
|
||||
- L9 (dataExtractionRules) — needs a product decision on cross-device
|
||||
round-state transfer.
|
||||
- L10 (CI keystore cleanup) — file outside `android/`, blocked by
|
||||
file-ownership scope.
|
||||
- L7 (`Color.parseColor` deprecation) — not in the delegated item list.
|
||||
- `PlayerCell.kt`'s hardcoded `stateDescription` Vietnamese fragments — the
|
||||
report bundles this with M3 but the task scoped M3 to the toast + the
|
||||
five unused resources; left untouched.
|
||||
- L2–L6 (shared voice player double-cancel, restore shape validation,
|
||||
swallowed `IOException` with no log signal, stale `autoRunning` after
|
||||
manual-draw exhaustion, `manualUnticks` surviving a non-BOTH reset) — not
|
||||
named in the task's item list; not touched.
|
||||
|
||||
## Unresolved questions
|
||||
|
||||
1. M4: is the residual race (master's restore landing with full history
|
||||
*before* settings ever resolves) worth a "settings loaded" signal, or is
|
||||
the report's own "usually invisible" framing enough to leave it as
|
||||
documented risk? I implemented the report's suggested `combine()` fix,
|
||||
which closes the more common ordering, but flagging the gap rather than
|
||||
silently claiming full closure.
|
||||
2. L9: should `loto_game_state` be excluded from Android backup/device
|
||||
transfer via `dataExtractionRules`, or is cross-device restore of an
|
||||
in-progress round acceptable/desired? Needs a product call, not a code
|
||||
fix.
|
||||
3. M6: is there an actual profiled slowdown on the API 24 device from
|
||||
`plans/todo.md`, or should this stay deferred until QA reports one?
|
||||
|
||||
Status: DONE_WITH_CONCERNS
|
||||
Summary: Fixed H1, H2, M1, M3, M4 (partial, documented gap), M5, L1, and the two clear-cut manifest/gradle hygiene items (L8, L11); confirmed M2 already matches web and left as-is; deferred M6, L2-L7, L9, L10 with reasons. All unit tests (including 5 new race-regression tests) and lint pass.
|
||||
Concerns/Blockers: M4's fix does not close every interleaving (see report); M6/L9/L10 deferred rather than guessed at; none are build-blocking.
|
||||
@@ -0,0 +1,236 @@
|
||||
# Fix Report — code review findings, `web/` (SvelteKit lô tô app)
|
||||
|
||||
Date: 2026-08-31 · Source review: `plans/reports/code-reviewer-260831-2213-web-review.md`
|
||||
|
||||
## Summary
|
||||
|
||||
Fixed C1, H1, H2, and mediums M1/M2/M3/M4/M6/M7 (partial — see checkJs below).
|
||||
Skipped M5, M9, and the rest of Low as out of scope (product decisions or not
|
||||
requested). 133/133 tests pass (121 original + 12 new), lint clean, svelte-check
|
||||
clean, both `build`/`build:gh` profiles verified against real output.
|
||||
|
||||
## C1 — Service worker never registered
|
||||
|
||||
**Fix**: `src/routes/+layout.svelte` — added `registerServiceWorker()`, called
|
||||
from the existing `onMount`. Guarded on `import.meta.env.PROD` and
|
||||
`"serviceWorker" in navigator`; dynamically imports `virtual:pwa-register` (so
|
||||
the module is never evaluated during SSR/prerender) and calls
|
||||
`registerSW({ immediate: true })`. This reuses `@vite-pwa/sveltekit`'s own
|
||||
base/scope resolution (`__SW__`/`__SCOPE__` placeholders), so it stays correct
|
||||
for both the Firebase (`base ""`) and GitHub Pages (`base "/loto"`) builds
|
||||
without duplicating that logic.
|
||||
|
||||
**Verified against real builds** (`BUILD_PROFILE=gh npx vite build` and
|
||||
`npx vite build`): the compiled `_app/immutable/nodes/0.*.js` (the root
|
||||
layout) contains the call site `import('../chunks/*.js')` →
|
||||
`registerSW({immediate:!0})`; the resolved chunk constructs
|
||||
`new Workbox('./sw.js', {scope: './', type: 'classic'})`. `build/index.html`
|
||||
itself still shows no literal `sw.js`/`registerSW` string — expected, since
|
||||
registration now lives in a JS chunk, not injected markup; grepping
|
||||
`index.html` was never going to prove this either way (that's exactly C1's
|
||||
root cause). Confirmed the actual reachability by inspecting the compiled
|
||||
chunk content directly instead.
|
||||
|
||||
**Regression guard**: added `scripts/verify-pwa-build.mjs` (new
|
||||
`npm run verify:pwa`, wired into the CI `build` job for both matrix legs)
|
||||
that fails the build if no client JS chunk under `build/_app` contains both
|
||||
`"serviceWorker"` and `"sw.js"`, so this can't silently regress again.
|
||||
|
||||
## H1 — Audio precache URLs ignored the base path
|
||||
|
||||
**Fix**: extracted `resolveBase()` out of `svelte.config.js` into a new
|
||||
`web/base-path.js`, imported by both `svelte.config.js` and `vite.config.js`.
|
||||
`vite.config.js`'s `defaultVoicePrecacheEntries` now prefix every URL with the
|
||||
same resolved base SvelteKit itself uses, so the two configs can never drift
|
||||
apart again.
|
||||
|
||||
**Verified**: `BUILD_PROFILE=gh npx vite build` →
|
||||
`grep -o '/loto/' build/sw.js | wc -l` = 92 (all default-voice clips),
|
||||
`grep -o '"/audio' build/sw.js | wc -l` = 0 (no more origin-rooted entries).
|
||||
Root/Firebase build (`base ""`) still produces `"/audio/..."` entries
|
||||
correctly (empty base + `/audio/...` = same as before).
|
||||
|
||||
**Regression guard**: `scripts/verify-pwa-build.mjs` also asserts every
|
||||
precached `*.mp3` URL in `build/sw.js` starts with `${resolveBase()}/audio/`
|
||||
for the build's actual `BUILD_PROFILE`.
|
||||
|
||||
## H2 — Reclaiming a frozen tab rolled back the live round
|
||||
|
||||
**Fix**, two parts:
|
||||
1. `src/routes/+layout.svelte` — the "Nhấn để tiếp tục tại đây" button now
|
||||
calls a new `reclaimTab()` (`loadSettings(); loadMaster(); claimActiveTab();`)
|
||||
instead of `claimActiveTab` directly, so this tab re-reads
|
||||
localStorage — including whatever the peer tab persisted while this tab
|
||||
was frozen — BEFORE `activeTab.inactive` flips back to `false` and
|
||||
children remount.
|
||||
2. Defense in depth: `master-store.svelte.js` — `masterState` gained a
|
||||
`hydrated` boolean, set `true` at the end of `loadMaster()` (via
|
||||
`finally`, so it's set on every path: success, corrupt JSON, missing
|
||||
data). `MasterPanel.svelte`'s save effect now only calls `saveMaster()`
|
||||
when `masterState.hydrated` is true, so a mount can never persist stale
|
||||
in-memory state before a load has actually happened in this tab's
|
||||
session.
|
||||
|
||||
**Tests added** (`master-store.test.js`, new `describe("hydrated flag …")`):
|
||||
flag starts false and flips true after load (including the "nothing to
|
||||
load" and "corrupt JSON" paths), and a scenario test simulating the exact
|
||||
reclaim sequence (draw 2, persist, peer persists a 3rd draw, reload →
|
||||
in-memory state matches the peer's newer data, not the stale copy).
|
||||
|
||||
## M1 — Invisible-but-clickable toast
|
||||
|
||||
**Fix**: `PlayerBoard.svelte` — added `toastId` state, incremented in
|
||||
`showToast()`; wrapped the toast markup in `{#key toastId}` so every
|
||||
`showToast()` call forces a full DOM remount instead of reusing the node,
|
||||
which restarts the 5s CSS fade animation and guarantees the
|
||||
`pointer-events-auto` button is torn down when its own timer expires, even
|
||||
if a second "Chờ" arrived mid-animation.
|
||||
|
||||
## M2 — "Kinh!" cut off by "Chờ" in the same effect pass
|
||||
|
||||
**Fix**: `PlayerBoard.svelte`'s bingo/waiting `$effect` — added a local
|
||||
`announcedBingo` flag set when pass 1 calls `playBingo()`; pass 2 now skips
|
||||
the `playWaiting()` call (not the toast/state bookkeeping) when
|
||||
`announcedBingo` is true, so a draw that completes one row and puts another
|
||||
one number away no longer has the win clip silently cancelled a
|
||||
millisecond after it starts.
|
||||
|
||||
## M3 — Theme flash on cold load
|
||||
|
||||
**Fix**: `src/app.html` — added a blocking inline `<script>` in `<head>`,
|
||||
before `%sveltekit.head%` (so it runs before app.css's injected `<link>`),
|
||||
that reads `localStorage.getItem("loto_settings")`, applies `.dark` and
|
||||
`--empty-cell-bg` with the same validation semantics as
|
||||
`settings-store.svelte.js` (defensive try/catch, hex6 regex), falling back
|
||||
to light/default on any parse failure. `loadSettings()` in `+layout.svelte`
|
||||
remains the authoritative pass.
|
||||
|
||||
## M4 — No focus management in the two `aria-modal` dialogs
|
||||
|
||||
**Fix**: new `src/lib/focus-trap.js` — a Svelte action (`use:focusTrap`)
|
||||
that focuses the dialog container on mount (`tabindex="-1"` added if
|
||||
missing), traps Tab/Shift+Tab among focusable descendants, restores focus to
|
||||
whatever was focused before the dialog opened, and locks `document.body`
|
||||
scroll for as long as any trap is active (reference-counted so the bingo
|
||||
modal and settings sheet can't fight over the lock if both are somehow
|
||||
open). Wired into `PlayerBoard.svelte`'s bingo dialog and
|
||||
`SettingsButton.svelte`'s settings dialog. Existing window-level Escape
|
||||
handling in both components is untouched.
|
||||
|
||||
**Tests added**: `focus-trap.test.js` (6 cases) — focuses container on
|
||||
mount, Tab wraps last→first, Shift+Tab wraps first→last, body scroll
|
||||
lock/unlock, focus restoration on destroy, and reference-counted lock across
|
||||
two stacked traps.
|
||||
|
||||
## M6 — iOS auto-call voice silent (+ 92 retained `Audio` elements)
|
||||
|
||||
**Fix**: rewrote `voice.js`'s element management — one reusable
|
||||
`HTMLAudioElement` (`.src` swapped per clip) instead of a `Map` growing to
|
||||
one element per clip URL. New `unlockAudio()` export, called synchronously
|
||||
from `MasterPanel.svelte`'s `toggleAuto()` ("Bắt đầu") and
|
||||
`handleDrawNext()` ("Xổ số") — both real click handlers — primes the shared
|
||||
element inside a genuine user gesture so it stays unlocked for later
|
||||
`play()` calls from `setInterval` (auto-call), which iOS Safari does not
|
||||
treat as a gesture. `clearAudioCache()` kept as an export (now just
|
||||
`cancelPlayback()`) so callers that change `settings.voice` don't need to
|
||||
change.
|
||||
|
||||
**Tests**: rewrote `voice.test.js` to match the new architecture (the old
|
||||
tests asserted "one Audio instance per URL", now structurally false by
|
||||
design) using `vi.resetModules()` + dynamic re-import per test for clean
|
||||
module-singleton isolation, preserving every original assertion's intent,
|
||||
plus two new cases for `unlockAudio()` (idempotent priming, later playback
|
||||
reuses the primed element).
|
||||
|
||||
## M7 — Lint/typecheck never ran in CI
|
||||
|
||||
**Fix**: `.github/workflows/ci.yml` `test` job now runs `npm run lint` and
|
||||
`npm run check` before `npm test`. Added `svelte-check` as a devDependency
|
||||
and a `check` script (`svelte-check --tsconfig ./jsconfig.json`).
|
||||
|
||||
**checkJs — left off, per task's explicit escape hatch.** Tried enabling it:
|
||||
`checkJs: true` produced 58 errors across 10 files. The large majority
|
||||
cascade from one pre-existing, unrelated root cause — `DEFAULT_SETTINGS` is
|
||||
`Object.freeze`d, so TS infers each field's literal type (`"#7030A0"`,
|
||||
`false`, `5`, …) instead of its declared JSDoc type, and every subsequent
|
||||
`settings.foo = <widened value>` assignment across `settings-store.svelte.js`,
|
||||
`SettingsButton.svelte`, and their test files fails. The rest are similarly
|
||||
pre-existing and unrelated to this task's fixes: `PlayerBoard.svelte`'s
|
||||
nullable-grid call sites, `active-tab.test.js`'s Vitest cache-busting
|
||||
`?query` dynamic imports, `overlay-history.test.js` implicit-any locals. None
|
||||
of these were introduced by my changes — verified by re-running
|
||||
`svelte-check` with `checkJs` back to `false`: 0 errors, 0 warnings. Fixing
|
||||
the settings-store typing alone would mean re-typing every settings field
|
||||
and touching ~5 files well outside this task's file list; that's the "large
|
||||
refactor" the task told me to leave for later rather than attempt here.
|
||||
`svelte-check` still runs in CI (and locally passes clean with `checkJs:
|
||||
false`, catching real Svelte-specific issues like unused CSS/a11y/reactivity
|
||||
mistakes independent of TS types) — only the JS-type-checking half of M7 is
|
||||
deferred. `jsconfig.json` is unchanged (`checkJs: false`).
|
||||
|
||||
I did fix the two type errors my own new `focus-trap.js` produced under
|
||||
`checkJs: true` (an untyped `filter()` losing its `HTMLElement` narrowing) —
|
||||
cheap, and keeps that file honest even though `checkJs` ends up off.
|
||||
|
||||
## Skipped (per task instructions — product decisions / not requested)
|
||||
|
||||
- **M5** — two tabs opened simultaneously freeze each other (mutual claim).
|
||||
Explicitly excluded ("multi-tab contract" needs a product decision per the
|
||||
review's Unresolved Question #2).
|
||||
- **M9** — docs/comment say auto-reload "must not happen"
|
||||
(`vite.config.js:40-41`'s comment), but `registerType: "autoUpdate"`
|
||||
auto-reloads on SW update. This was silently inert before C1 (nothing ever
|
||||
registered the SW, so it never fired); now that C1 is fixed, a deploy
|
||||
during a live round CAN trigger `window.location.reload()` mid-game — the
|
||||
exact scenario the comment warns against. Left `registerType: "autoUpdate"`
|
||||
unchanged since the review flags the resolution (implement a reload-prompt
|
||||
UI vs. accept auto-reload) as needing a product decision (Unresolved
|
||||
Question #4). **Flagging this explicitly since C1 makes the risk real
|
||||
where it was previously theoretical** — recommend deciding this soon.
|
||||
- **L1–L10** — not in the requested fix list; untouched.
|
||||
- **M8** (add broad component/effect test coverage) — not in the requested
|
||||
fix list either; added the specific unit tests called out above
|
||||
(hydration flag, focus-trap, voice unlock) that directly cover the fixed
|
||||
logic, but did not add `@testing-library/svelte`/component-mount
|
||||
infrastructure for M1/M2's Svelte-effect-level behavior — that's the
|
||||
larger, separately-scoped ask M8 describes, and doing it well means adding
|
||||
a new test-rendering dependency and harness, not a few lines.
|
||||
|
||||
## Files changed
|
||||
|
||||
- `web/src/routes/+layout.svelte` — SW registration, reclaim re-hydration.
|
||||
- `web/src/lib/master-store.svelte.js` — `hydrated` flag.
|
||||
- `web/src/lib/MasterPanel.svelte` — hydration-gated save effect, `unlockAudio()` wiring.
|
||||
- `web/src/lib/PlayerBoard.svelte` — toast remount key, bingo/waiting priority, focus trap wiring.
|
||||
- `web/src/lib/SettingsButton.svelte` — focus trap wiring.
|
||||
- `web/src/lib/voice.js` — single shared `<audio>` element + `unlockAudio()`.
|
||||
- `web/src/app.html` — pre-paint theme script.
|
||||
- `web/vite.config.js`, `web/svelte.config.js` — shared base resolver.
|
||||
- `web/base-path.js` (new) — shared `resolveBase()`.
|
||||
- `web/src/lib/focus-trap.js` (new) — reusable dialog focus-trap action.
|
||||
- `web/scripts/verify-pwa-build.mjs` (new) — C1/H1 build-time regression guard.
|
||||
- `web/package.json`, `web/package-lock.json` — `check`/`verify:pwa` scripts, `svelte-check` devDependency.
|
||||
- `.github/workflows/ci.yml` — lint/check/verify:pwa steps.
|
||||
- Tests: `web/src/lib/master-store.test.js`, `web/src/lib/voice.test.js` (rewritten), `web/src/lib/focus-trap.test.js` (new).
|
||||
|
||||
## Verification
|
||||
|
||||
- `npx vitest run` → 133/133 pass (9 files; was 121/8).
|
||||
- `npx eslint .` → 0 problems.
|
||||
- `npx svelte-check --tsconfig ./jsconfig.json` → 0 errors, 0 warnings (418 files).
|
||||
- `BUILD_PROFILE=gh npx vite build` then `BUILD_PROFILE=gh node scripts/verify-pwa-build.mjs` → pass (base `/loto`, 92 precached audio entries all prefixed, SW registration reachable in compiled JS).
|
||||
- `npx vite build` (root/Firebase profile) then `node scripts/verify-pwa-build.mjs` → pass (base `""`).
|
||||
- Manually inspected the compiled `_app/immutable/nodes/0.*.js` and its dynamically-imported chunk to confirm `registerSW({immediate:true})` → `Workbox('./sw.js', {scope: './'})` is really reachable at runtime, not just present in dead code.
|
||||
|
||||
## Unresolved questions
|
||||
|
||||
1. M9 is now a live risk, not a theoretical one (see above) — worth deciding
|
||||
soon whether to accept mid-round auto-reload or add a "Có bản mới, tải
|
||||
lại?" prompt banner.
|
||||
2. `registerType: "autoUpdate"` was left as-is since changing it is the M9
|
||||
product decision; flagging in case the intent was actually to fix C1 *and*
|
||||
settle M9 together.
|
||||
|
||||
Status: DONE_WITH_CONCERNS
|
||||
Summary: Fixed C1/H1/H2 and mediums M1/M2/M3/M4/M6/M7(CI half); checkJs left off after confirming the 58 errors are a pre-existing, unrelated settings-store typing refactor outside this task's scope; M5/M9/Lows skipped as instructed.
|
||||
Concerns/Blockers: M9 (mid-round auto-reload) is now a real risk now that C1 registers the SW — was previously inert. Recommend a follow-up product decision soon.
|
||||
@@ -0,0 +1,199 @@
|
||||
# Android follow-up fixes (2026-08-31)
|
||||
|
||||
Source: `plans/reports/code-reviewer-260831-2213-android-review.md` (L2–L7,
|
||||
L10) + the review's M3 note on `PlayerCell.kt`'s hardcoded `stateDescription`.
|
||||
Prior pass: `plans/reports/fullstack-developer-260831-2226-android-fixes.md`
|
||||
(H1/H2/M1/M3-partial/M4-partial/M5/L1/L8/L11; explicitly deferred L2–L7, L9,
|
||||
L10, and the `PlayerCell.kt` cleanup — this task closes that deferred list
|
||||
except L9, which stays a product decision, and M6, out of scope per the task).
|
||||
|
||||
Scope: `android/` + `.github/workflows/android-release.yml` (L10 only). `web/`
|
||||
untouched. `android/local.properties` not read.
|
||||
|
||||
## Fixed
|
||||
|
||||
### L2 — shared voice player double-cancel
|
||||
|
||||
`state/MasterPanelViewModel.kt`, `state/PlayerBoardViewModel.kt`:
|
||||
`onCleared()` no longer calls `voicePlayer.cancel()`, only `super.onCleared()`.
|
||||
Root cause check (not just the described symptom): both ViewModels are
|
||||
activity-scoped (`LotoViewModelFactory` via `by viewModels { factory }` in
|
||||
`MainActivity`), so `onCleared()` only fires together, on a real finish — and
|
||||
`MainActivity.onDestroy()` already calls the shared `voicePlayer.release()`
|
||||
unconditionally on that same `isFinishing` path (the prior pass's L1 fix).
|
||||
`release()` supersedes `cancel()` (it stops and tears the player down), so the
|
||||
per-VM cancel calls were dead weight duplicating L1's fix, plus the fragility
|
||||
the review flagged (two independent owners unilaterally acting on a shared
|
||||
singleton). Removing them and adding the missing `super.onCleared()` closes
|
||||
both parts of the finding without behavior change on any currently-possible
|
||||
scoping.
|
||||
Not unit-tested: `onCleared()` is `protected` on `ViewModel` and neither test
|
||||
class subclasses it, so it isn't reachable from a JVM unit test without a
|
||||
lifecycle test harness this project doesn't have (same gap as L1, which also
|
||||
shipped without a dedicated test).
|
||||
|
||||
### L3 — restore payload shape validation
|
||||
|
||||
`state/GameStateRepository.kt`:
|
||||
- `parseNumberList` (backs `master_called`, `master_remaining`,
|
||||
`player_manual_unticks`) now rejects duplicates (`toSet().size != size`),
|
||||
not just out-of-range values.
|
||||
- `parseGrid` now rejects a duplicate non-zero cell value (a real card never
|
||||
repeats a number).
|
||||
- `loadMasterState` adds `isCompleteDeckPartition`: `called` and `remaining`
|
||||
must be disjoint and together cover exactly the 90-number deck. Combined
|
||||
size ≠ 90 or an overlap (detected via combined-set size) both fail closed to
|
||||
"no saved round" — consistent with the class's existing "malformed payload
|
||||
shape-validates to empty" contract, so nothing new crashes or propagates.
|
||||
|
||||
Tests added (`state/GameStateRepositoryTest.kt`): duplicate grid cell,
|
||||
duplicate manual unticks, three `isCompleteDeckPartition` violations
|
||||
(duplicate-within-field, called/remaining overlap, doesn't sum to 90). Updated
|
||||
`master state survives a repository recreation` to use a full valid 90-number
|
||||
partition instead of 6 arbitrary numbers — it would otherwise now fail the new
|
||||
invariant it wasn't designed to satisfy.
|
||||
|
||||
### L4 — swallowed IOException with no log signal
|
||||
|
||||
`state/GameStateRepository.kt`, `settings/SettingsRepository.kt`: every
|
||||
`catch (_: IOException)` now does `Log.w(TAG, "...", e)` before falling back
|
||||
(read → empty/defaults, write → silently keeps in-memory state, unchanged
|
||||
behavior otherwise).
|
||||
`app/build.gradle.kts`: added
|
||||
`testOptions { unitTests { isReturnDefaultValues = true } }` — the stock
|
||||
`android.jar` stub throws on any unmocked call (no Robolectric in this
|
||||
project), so an untouched `Log.w` call would crash any unit test that
|
||||
exercises the catch block. This is scoped to unit tests only, no production
|
||||
behavior change.
|
||||
Added `ThrowingDataStore` (`app/src/test/java/com/miti99/loto/`) — a
|
||||
`DataStore<Preferences>` fake that throws `IOException` from both `data` and
|
||||
`updateData`, per the task's note that file-backed DataStore can't be
|
||||
exercised on Windows. Tests added in `GameStateRepositoryTest` and
|
||||
`SettingsRepositoryTest` assert the read/write paths degrade instead of
|
||||
throwing. (Log content itself isn't asserted — no mocking framework is set up
|
||||
for `android.util.Log`; behavior is verified instead, matching this project's
|
||||
existing test style.)
|
||||
|
||||
### L5 — stale autoRunning after a draw exhausts the deck
|
||||
|
||||
`state/MasterPanelViewModel.kt`: `drawNext()` now checks
|
||||
`masterStore.state.value.remaining.isEmpty()` right after drawing and flips
|
||||
`_autoRunning.value = false` immediately, instead of relying solely on the
|
||||
ticker loop's next-tick top-of-loop check. Verified the `StateFlow` update
|
||||
propagates through `combine(...).distinctUntilChanged().collectLatest{...}`
|
||||
and cancels the in-flight `while(true)` loop within the same virtual-time
|
||||
`advanceTimeBy` call (no extra tick needed) — confirmed by the updated test.
|
||||
Also correct for a manual "Xổ số" draw that happens to be the one exhausting
|
||||
the deck while auto-call was separately left on.
|
||||
Left the ticker loop's own top-of-loop check in place as a harmless second
|
||||
line of defense.
|
||||
|
||||
Tests: renamed/strengthened `auto-call stops when the deck is exhausted` →
|
||||
`auto-call stops immediately when a draw exhausts the deck (L5)`, asserting
|
||||
`autoRunning` is false right after the exhausting draw (previously only
|
||||
asserted after a second tick, which is exactly the stale window L5 describes).
|
||||
Added `manual drawNext exhausting the deck leaves autoRunning false (L5)` for
|
||||
the literal "manual draw" wording.
|
||||
|
||||
### L6 — manualUnticks surviving a non-BOTH reset
|
||||
|
||||
`state/PlayerBoardViewModel.kt`: `onMasterState()`'s master-"Ván mới"
|
||||
detection (`prev > 0 && len < prev`) now clears `manualUnticks` — and persists
|
||||
that if it had entries — in every mode, not only inside the `BOTH`-gated
|
||||
branch. `onCellClick` has no mode gate (any number in the master's `called[]`
|
||||
can be manually unticked regardless of the currently displayed mode), so a
|
||||
reset must invalidate that suppression state everywhere, not just when `BOTH`
|
||||
happens to be selected at reset time. The `crossed`/`lastHandledIndex`/row-
|
||||
tracker reset stays `BOTH`-only, unchanged — L6 only concerns
|
||||
`manualUnticks`.
|
||||
|
||||
Test added: `manual unticks are cleared and persisted on a master Ván mới
|
||||
outside both mode (L6)` — crosses/unticks a called number in `PLAYER` mode,
|
||||
resets the master round, and reads `GameStateRepository.loadPlayerState()`
|
||||
directly (sidesteps the unrelated, not-in-scope question of whether
|
||||
`lastHandledIndex` also needs resetting on a non-`BOTH` shrink — L6 is
|
||||
specifically about `manualUnticks`).
|
||||
|
||||
### L7 — deprecated Color.parseColor
|
||||
|
||||
`ui/ColorParsing.kt`: `android.graphics.Color.parseColor(this)` →
|
||||
`this.toColorInt()` (androidx-core, already a dependency via
|
||||
`androidx.core.ktx`). Same `IllegalArgumentException` catch/fallback
|
||||
unchanged. Not unit-tested (no test existed before either — parsing depends
|
||||
on the unmocked `android.graphics.Color` stub, which is untestable in a pure
|
||||
JVM unit test regardless of which API calls it; the settings-layer regex is
|
||||
what's actually covered, in `SettingsRepositoryTest`).
|
||||
|
||||
### PlayerCell.kt stateDescription → strings.xml
|
||||
|
||||
`res/values/strings.xml`: added `player_cell_number` ("Số %1$d"),
|
||||
`player_cell_crossed_suffix` (", đã đánh dấu"),
|
||||
`player_cell_waiting_suffix` (", đang chờ").
|
||||
`ui/player/PlayerCell.kt`: resolves all three via `stringResource(...)`
|
||||
unconditionally (composable-safe — no conditional `stringResource` calls) and
|
||||
builds the same `stateDescription` string as before from those values. No
|
||||
change in the rendered accessibility text.
|
||||
|
||||
### L10 — CI keystore cleanup
|
||||
|
||||
`.github/workflows/android-release.yml`: added a
|
||||
`Remove decoded keystore` step (`if: always()`, `rm -f
|
||||
"$GITHUB_WORKSPACE/keystore.p12"`) between the signed-build step and the
|
||||
GitHub Release upload step, so the decoded `keystore.p12` is removed from the
|
||||
runner workspace whether or not the build succeeds.
|
||||
|
||||
## Deferred / out of scope (unchanged from prior pass or explicitly excluded)
|
||||
|
||||
- L9 (`dataExtractionRules`) — product decision, not touched (task's NOT-IN-SCOPE list).
|
||||
- M4's residual settings-loaded-signal race, M6 recomposition perf — explicitly out of scope per the task.
|
||||
|
||||
## Verification
|
||||
|
||||
- `JAVA_HOME="C:/Program Files/Eclipse Adoptium/jdk-21.0.10.7-hotspot" ./gradlew :app:testDebugUnitTest`
|
||||
→ **BUILD SUCCESSFUL**. 118 tests across 13 suites, 0 failures/errors
|
||||
(includes all newly added L3/L4/L5/L6 regression tests).
|
||||
- `JAVA_HOME="C:/Program Files/Eclipse Adoptium/jdk-21.0.10.7-hotspot" ./gradlew :app:lintDebug`
|
||||
→ **BUILD SUCCESSFUL**, 17 warnings — all pre-existing (AGP/Gradle
|
||||
dependency version notices, one unrelated string typo, two plurals
|
||||
candidates, one manifest `enableOnBackInvokedCallback` API-level notice).
|
||||
Verified none reference any file touched by this change and no
|
||||
`UnusedResources` finding for the three new strings.
|
||||
- No gradle daemon force-killed; left to Gradle's own idle-timeout per the
|
||||
process-management rule.
|
||||
|
||||
## Files modified
|
||||
|
||||
- `android/app/src/main/java/com/miti99/loto/state/MasterPanelViewModel.kt`
|
||||
- `android/app/src/main/java/com/miti99/loto/state/PlayerBoardViewModel.kt`
|
||||
- `android/app/src/main/java/com/miti99/loto/state/GameStateRepository.kt`
|
||||
- `android/app/src/main/java/com/miti99/loto/settings/SettingsRepository.kt`
|
||||
- `android/app/src/main/java/com/miti99/loto/ui/ColorParsing.kt`
|
||||
- `android/app/src/main/java/com/miti99/loto/ui/player/PlayerCell.kt`
|
||||
- `android/app/src/main/res/values/strings.xml`
|
||||
- `android/app/build.gradle.kts`
|
||||
- `android/app/src/test/java/com/miti99/loto/ThrowingDataStore.kt` (new)
|
||||
- `android/app/src/test/java/com/miti99/loto/state/GameStateRepositoryTest.kt`
|
||||
- `android/app/src/test/java/com/miti99/loto/settings/SettingsRepositoryTest.kt`
|
||||
- `android/app/src/test/java/com/miti99/loto/state/MasterPanelViewModelTest.kt`
|
||||
- `android/app/src/test/java/com/miti99/loto/state/PlayerBoardViewModelTest.kt`
|
||||
- `.github/workflows/android-release.yml`
|
||||
|
||||
## Unresolved questions
|
||||
|
||||
1. L2: is it worth adding a lifecycle-test harness (e.g. pulling in
|
||||
`androidx.lifecycle:lifecycle-viewmodel-testing` or Robolectric) so
|
||||
`onCleared()` behavior is directly testable, or does the code-level
|
||||
ownership argument above (both VMs share the exact same clearing
|
||||
condition as `MainActivity`'s existing guarded `release()`) suffice
|
||||
without one? Same gap already existed for L1.
|
||||
2. L3: `isCompleteDeckPartition` treats *any* deviation from a full 1..90
|
||||
partition as corrupt (falls back to "no saved round"). This matches the
|
||||
review's literal ask, but is strict — e.g. it would also reject a
|
||||
hypothetical future variable-length deck. Flagging in case that's ever a
|
||||
real product direction, not just a theoretical one.
|
||||
|
||||
Status: DONE
|
||||
Summary: Implemented and tested L2, L3, L4, L5, L6, L7, L10, and the
|
||||
PlayerCell.kt strings.xml cleanup; all 118 unit tests and lint pass clean with
|
||||
no new findings.
|
||||
Concerns/Blockers: none blocking; two unresolved questions above are informational only.
|
||||
@@ -0,0 +1,186 @@
|
||||
# Fix Report — M9 (update-prompt UX) + checkJs, `web/` (SvelteKit lô tô app)
|
||||
|
||||
Date: 2026-08-31 · Source: `plans/reports/code-reviewer-260831-2213-web-review.md` (M9, M7)
|
||||
and `plans/reports/fullstack-developer-260831-2226-web-review-fixes.md` (checkJs left off)
|
||||
|
||||
## Summary
|
||||
|
||||
Both items done in full, no escape hatch needed. `registerType` is now
|
||||
`"prompt"` with a real reload-prompt banner; `checkJs: true` with all 56
|
||||
pre-existing errors fixed via type-only changes (no runtime behavior
|
||||
changed). 138/138 tests pass (133 + 5 new), lint clean, svelte-check clean,
|
||||
both build profiles + `verify-pwa-build.mjs` pass.
|
||||
|
||||
## Item 1 — M9: mid-round auto-reload
|
||||
|
||||
**`vite.config.js`**: `registerType: "autoUpdate"` → `"prompt"`. Comment
|
||||
above it rewritten to describe the new flow instead of just warning against
|
||||
the old one.
|
||||
|
||||
**`src/lib/update-prompt.svelte.js`** (new): small `.svelte.js` store module,
|
||||
same shape as `active-tab.svelte.js`/`master-store.svelte.js`:
|
||||
- `updatePrompt = $state({ visible: false })`
|
||||
- `setUpdateSW(fn)` — stores the function `registerSW()` returns
|
||||
- `showUpdatePrompt()` — wired to `onNeedRefresh`, flips `visible = true`
|
||||
- `applyUpdate()` — hides the banner, calls `updateSW(true)` (reload)
|
||||
- `dismissUpdate()` — hides the banner, does NOT call `updateSW` (old
|
||||
version keeps running this visit)
|
||||
- `_resetUpdatePromptForTest()` — test-only reset, matches
|
||||
`_resetOverlayHistoryForTest()`'s convention
|
||||
|
||||
**`src/routes/+layout.svelte`**: `registerServiceWorker()` now calls
|
||||
`registerSW({ onNeedRefresh: showUpdatePrompt })` (was
|
||||
`registerSW({ immediate: true })`) and stores the returned `updateSW` via
|
||||
`setUpdateSW()`. Template: banner renders as a sibling of `{@render
|
||||
children()}` inside the active-tab branch only (hidden while the "other tab
|
||||
active" full-screen overlay is showing, so the two never fight over
|
||||
z-index). Markup: `fixed`, bottom-4/corner-anchored, `max-w-xs` on ≥sm,
|
||||
`z-40` (below the app's `z-50` dialogs, so a modal correctly covers it) —
|
||||
deliberately NOT full-screen and NOT near the board or MasterPanel's
|
||||
`heroEl` "Số vừa xổ" element, both of which live in normal document flow
|
||||
mid-page. Vietnamese copy: "Có bản mới. Tải lại?" + a rose-600 "Tải lại"
|
||||
button (matches the bingo modal's primary-button styling) + a plain-text
|
||||
"Để sau" dismiss button. Both light and dark variants via the existing
|
||||
`bg-white dark:bg-slate-800` / `border-slate-200 dark:border-slate-600`
|
||||
pattern used elsewhere (`SettingsButton.svelte`'s sticky header/footer).
|
||||
|
||||
**Verified against real builds**: compiled `_app/immutable/nodes/0.*.js` +
|
||||
its `virtual:pwa-register` chunk contain `onNeedRefresh` and no longer
|
||||
contain `immediate:!0`. `build/sw.js`'s only `self.skipWaiting()` call is
|
||||
gated behind `self.addEventListener("message", i => i.data?.type ===
|
||||
"SKIP_WAITING" && self.skipWaiting())` — i.e. the new SW never activates
|
||||
itself; it only does so on an explicit `postMessage` that workbox-window's
|
||||
`updateSW(true)` sends, which only fires from the banner's "Tải lại" tap.
|
||||
|
||||
**`scripts/verify-pwa-build.mjs`**: unchanged — its C1/H1 assertions only
|
||||
check for `"serviceWorker"` + `"sw.js"` reachability and the base-prefixed
|
||||
audio precache list, neither of which depends on `registerType`. Re-ran it
|
||||
against both profiles; both pass.
|
||||
|
||||
**Test added**: `src/lib/update-prompt.test.js` (5 cases) — starts hidden;
|
||||
`showUpdatePrompt` reveals it; `dismissUpdate` hides without calling
|
||||
`updateSW`; `applyUpdate` hides and calls `updateSW(true)` exactly once;
|
||||
`applyUpdate` before `registerSW` resolves is a safe no-op.
|
||||
|
||||
## Item 2 — `checkJs: true`
|
||||
|
||||
`jsconfig.json`: `checkJs: false` → `true`. Fixed all 56 errors
|
||||
(`npx svelte-kit sync && npx svelte-check --tsconfig ./jsconfig.json` → the
|
||||
list matched the prior report's count almost exactly — 56 not 58, small
|
||||
line-number drift from the Item-1 edits). All fixes are type-only; no
|
||||
runtime behavior changed anywhere.
|
||||
|
||||
**Root cause 1 — `Object.freeze(DEFAULT_SETTINGS)` literal narrowing**
|
||||
(~25 of the 56 errors, across `settings-store.svelte.js`,
|
||||
`settings-store.test.js`, `SettingsButton.svelte`, `voice.test.js`): fresh
|
||||
object-literal fields like `autoCallEnabled: false` were inferred as the
|
||||
literal type `false`, not `boolean`, and that leaked into
|
||||
`settings = $state({ ...DEFAULT_SETTINGS })`. **Fix**: added an explicit
|
||||
`@typedef {Object} Settings` (one property per field, general types) in
|
||||
`settings-store.svelte.js` and cast the object literal passed to
|
||||
`Object.freeze()` — `Object.freeze(/** @type {Settings} */ ({ ... }))` —
|
||||
so every field keeps its widened type instead of narrowing to the default's
|
||||
literal value. Removed the two now-redundant per-field inline casts
|
||||
(`theme`, `mode`) since the typedef covers them. Zero other files touched
|
||||
for this cause — it single-handedly cleared the settings-store,
|
||||
SettingsButton, and voice/settings test errors.
|
||||
|
||||
**Root cause 2 — `voice.js`'s `new Promise` executor**: `playClip()` had no
|
||||
return-type annotation, so TS couldn't infer `T` for `new Promise<T>` and
|
||||
fell back to a `resolve` that requires an argument (can't be called as
|
||||
`resolve()`). **Fix**: added `@returns {Promise<void>}` to `playClip`'s
|
||||
JSDoc — `void` gets TS's special "callable with zero args" treatment for
|
||||
`resolve`. No behavior change (the function always resolved with no value).
|
||||
|
||||
**Root cause 3 — `PlayerBoard.svelte` nullable-grid narrowing lost inside
|
||||
closures**: `grid` is `$state(number[][] | null)`; three call sites read it
|
||||
inside a `.map()`/`.some()` callback after an outer null check, and TS
|
||||
doesn't carry narrowing of a mutable `let` binding into a nested function
|
||||
expression. **Fix**: capture `grid` into a local `const g = grid` right
|
||||
after each null check, then reference `g` (not `grid`) inside the closures
|
||||
— `rowCompleteness` and `waitingRows` changed from `$derived(expr)` to
|
||||
`$derived.by(() => { const g = grid; return … })` (same dependency-tracking
|
||||
semantics, since the same reactive reads happen either way); the
|
||||
bingo/waiting `$effect` got a `const g = grid;` right after its early
|
||||
return and had its `grid` references swapped to `g`. Purely a type-narrowing
|
||||
refactor — no logic changed.
|
||||
|
||||
**Remaining one-off fixes** (implicit-`any`s, one unresolvable-module class,
|
||||
all pre-existing per the prior report):
|
||||
- `SettingsButton.svelte`'s `{#snippet switchRow(label, isOn, onToggle)}` —
|
||||
added inline JSDoc casts on the three params (`string`, `boolean`,
|
||||
`() => void`), same style already used elsewhere in the file for event
|
||||
handler params.
|
||||
- `settings-store.test.js`'s `mockMatchMedia()` stub — added
|
||||
`(e: MediaQueryListEvent) => void` JSDoc to the `fn` params of
|
||||
`addEventListener`/`removeEventListener`.
|
||||
- `overlay-history.test.js`'s `back`/`pushState` — typed as
|
||||
`ReturnType<typeof vi.spyOn>` instead of left to bare `let` inference.
|
||||
- `active-tab.test.js`'s `FakeBC.onmessage` — added
|
||||
`{((e: {data: any}) => void) | null}` JSDoc to the constructor
|
||||
assignment.
|
||||
- `active-tab.test.js`'s six `import("./active-tab.svelte.js?<tag>")` —
|
||||
these are real relative imports at runtime (Vitest's cache-busting query
|
||||
suffix loads a fresh module instance per test for isolation) but not a
|
||||
specifier TS can resolve. Tried an ambient wildcard module declaration
|
||||
(`declare module "./active-tab.svelte.js?*" { export * from "..." }`)
|
||||
first; it resolved but produced a wrapped `{ default: typeof import(...) }`
|
||||
shape instead of the real named exports (wildcard ambient modules don't
|
||||
behave like `export *` re-exports for query-string specifiers). Dropped
|
||||
that file and used `@ts-expect-error` with an explanatory comment on each
|
||||
import line instead — simpler, guaranteed correct, and each error was a
|
||||
genuine one-to-one match for `@ts-expect-error` to consume.
|
||||
- `+layout.svelte`'s `import("virtual:pwa-register")` — no `app.d.ts`
|
||||
existed in this project (SvelteKit's usual scaffold file was never
|
||||
created). Added `src/app.d.ts` with the standard SvelteKit template plus
|
||||
`/// <reference types="vite-plugin-pwa/client" />`, which supplies
|
||||
`vite-plugin-pwa`'s own ambient types for the virtual module (already a
|
||||
transitive dependency; no new package added).
|
||||
|
||||
**Verified `checkJs` didn't just move the goalposts**: after all fixes,
|
||||
`npx svelte-kit sync && npx svelte-check --tsconfig ./jsconfig.json` →
|
||||
`0 ERRORS, 0 WARNINGS` (429 files). No runtime test assertions changed —
|
||||
`vitest run` stayed at the same pass/fail shape (133 → 138, the +5 being the
|
||||
new Item-1 test file only).
|
||||
|
||||
## Files changed
|
||||
|
||||
- `web/vite.config.js` — `registerType: "prompt"`, comment rewritten.
|
||||
- `web/src/routes/+layout.svelte` — `onNeedRefresh` wiring, update banner.
|
||||
- `web/src/lib/update-prompt.svelte.js` (new) — update-prompt store.
|
||||
- `web/src/lib/update-prompt.test.js` (new) — 5 tests.
|
||||
- `web/jsconfig.json` — `checkJs: true`.
|
||||
- `web/src/lib/settings-store.svelte.js` — `Settings` typedef, cast on
|
||||
`DEFAULT_SETTINGS`'s object literal.
|
||||
- `web/src/lib/voice.js` — `@returns {Promise<void>}` on `playClip`.
|
||||
- `web/src/lib/PlayerBoard.svelte` — local `const g = grid` narrowing at 3
|
||||
call sites (2 `$derived` → `$derived.by`, 1 `$effect`).
|
||||
- `web/src/lib/SettingsButton.svelte` — JSDoc param casts on the
|
||||
`switchRow` snippet.
|
||||
- `web/src/lib/settings-store.test.js` — JSDoc param casts on
|
||||
`mockMatchMedia`'s stub.
|
||||
- `web/src/lib/overlay-history.test.js` — typed `back`/`pushState`.
|
||||
- `web/src/lib/active-tab.test.js` — typed `FakeBC.onmessage`,
|
||||
`@ts-expect-error` on the 6 query-string dynamic imports.
|
||||
- `web/src/app.d.ts` (new) — standard SvelteKit ambient types file +
|
||||
`vite-plugin-pwa/client` reference.
|
||||
|
||||
## Verification
|
||||
|
||||
- `npx vitest run` → 138/138 pass (10 files; was 133/9).
|
||||
- `npx eslint .` → 0 problems.
|
||||
- `npx svelte-kit sync && npx svelte-check --tsconfig ./jsconfig.json` →
|
||||
0 errors, 0 warnings, `checkJs: true` (429 files).
|
||||
- `BUILD_PROFILE=gh npx vite build` + `BUILD_PROFILE=gh node
|
||||
scripts/verify-pwa-build.mjs` → pass (base `/loto`, 92 audio entries,
|
||||
registerSW/sw.js reachable).
|
||||
- `npx vite build` + `node scripts/verify-pwa-build.mjs` → pass (base `""`).
|
||||
- Manually inspected compiled `_app/immutable/nodes/0.*.js` + its
|
||||
`virtual:pwa-register` chunk: `onNeedRefresh` present, `immediate:!0`
|
||||
gone. Manually inspected `build/sw.js`: `self.skipWaiting()` only fires
|
||||
on an incoming `SKIP_WAITING` postMessage, never unconditionally.
|
||||
|
||||
## Unresolved questions
|
||||
|
||||
None — both items were resolvable in full within the stated scope; no
|
||||
escape hatch was needed for `checkJs`.
|
||||
@@ -0,0 +1,127 @@
|
||||
# Fix report: Android backup rules (L9) + settings-loaded signal (M4 residual)
|
||||
|
||||
Phase 2 of `plans/260831-2314-review-followups/plan.md`. Scope: two decisions
|
||||
delegated by the user — exclude game-state DataStore from backup/transfer,
|
||||
keep settings backed up; close the M4 residual race (master restore landing
|
||||
before settings resolves) with an explicit settings-loaded signal.
|
||||
|
||||
## Item A — backup rules
|
||||
|
||||
Verified on-disk DataStore names from `LotoApplication.kt`'s
|
||||
`preferencesDataStore(name = ...)` delegates: `loto_settings` and
|
||||
`loto_game_state`. `androidx.datastore.preferences.preferencesDataStore`'s
|
||||
default file location is `files/datastore/<name>.preferences_pb`, so the
|
||||
excluded path is `datastore/loto_game_state.preferences_pb`.
|
||||
|
||||
- New `android/app/src/main/res/xml/data_extraction_rules.xml` — API 31+,
|
||||
excludes that path from both `<cloud-backup>` and `<device-transfer>`.
|
||||
- New `android/app/src/main/res/xml/backup_rules.xml` — `full-backup-content`
|
||||
counterpart for API 24-30 (minSdk 24, so this path is live).
|
||||
- `AndroidManifest.xml`: added `android:dataExtractionRules` and
|
||||
`android:fullBackupContent` to `<application>`; `android:allowBackup="true"`
|
||||
left unchanged. Settings DataStore is not mentioned anywhere in either XML,
|
||||
so it stays backed up/transferred by default.
|
||||
|
||||
## Item B — M4 residual: settings-loaded signal
|
||||
|
||||
Root cause (confirmed by reading `PlayerAutoCross.applyMasterCalls`): the
|
||||
cursor advances to `called.size` on every non-`BOTH` pass even when nothing
|
||||
changes. If `masterStore.restore()` lands with the full history before the
|
||||
settings DataStore read resolves, the prior `combine()` fix still evaluates
|
||||
that first master emission with `settings.value.mode` at its `PLAYER`
|
||||
default, consumes the cursor to `called.size`, and the later real-mode
|
||||
arrival finds `cursor >= called.size` — replay lost, permanently, since
|
||||
`masterStore.state` doesn't re-emit until the next draw.
|
||||
|
||||
Fix — a loaded signal threaded end to end:
|
||||
|
||||
- `settings/SettingsRepository.kt`: new `val loaded: Flow<Boolean> =
|
||||
settingsFlow.map { true }.onStart { emit(false) }` — cold, derived purely
|
||||
from `settingsFlow`, mirrors the existing `settingsFlow`/`settingsState`
|
||||
pairing rather than adding a scope to the repository.
|
||||
- `LotoApplication.kt`: new `val settingsLoaded: StateFlow<Boolean> =
|
||||
settingsRepository.loaded.stateIn(appScope, SharingStarted.Eagerly, false)`,
|
||||
next to `settingsState`.
|
||||
- `state/PlayerBoardViewModel.kt`: new constructor param `settingsLoaded:
|
||||
StateFlow<Boolean> = MutableStateFlow(true)` (defaults to "already loaded"
|
||||
so every existing call site, including all prior tests, is unaffected).
|
||||
`init`'s collector changed from
|
||||
`combine(masterStore.state, settings) { master, _ -> master }.collect { onMasterState(it) }`
|
||||
to `combine(masterStore.state, settings, settingsLoaded) { master, _, loaded -> master to loaded }.collect { (master, loaded) -> if (loaded) onMasterState(master) }`.
|
||||
While `!loaded`, `onMasterState` is skipped entirely — `lastHandledIndex`
|
||||
stays untouched — so once `settingsLoaded` flips true, `combine`'s cached
|
||||
master snapshot is re-delivered and replayed under the real mode.
|
||||
- `state/LotoViewModelFactory.kt`: passes `settingsLoaded = app.settingsLoaded`
|
||||
to the production `PlayerBoardViewModel`. This file isn't in the plan's
|
||||
Phase 2 file-ownership list but is load-bearing — without it the fix would
|
||||
only exist in test wiring, not in the shipped app. The direct task
|
||||
instructions ("Files you may modify: anything under android/") permit it.
|
||||
- `state/MasterPanelViewModel.kt` / `state/MasterStore.kt`: unchanged. Neither
|
||||
consumes master emissions through the mode-gated cursor the task scoped
|
||||
this fix to; `MasterPanelViewModel` reads `settings.value` synchronously
|
||||
per-draw with no persistent cursor to corrupt.
|
||||
- H1/loading behavior (restore vs. in-flight user action, from the prior fix
|
||||
pass) is untouched — `restore()`/`publish(loading = ...)` logic wasn't
|
||||
touched, and the H1 regression tests in `PlayerBoardViewModelTest` still
|
||||
pass unmodified.
|
||||
|
||||
## Tests
|
||||
|
||||
- `settings/SettingsRepositoryTest.kt`: new `loaded stays false until the
|
||||
first DataStore read resolves` — `SlowDataStore` + `CompletableDeferred`
|
||||
gate, collects `loaded` into a list via `backgroundScope`, asserts
|
||||
`[false]` before the gate opens and `[false, true]` after.
|
||||
- `state/PlayerBoardViewModelTest.kt`: new `master restore landing with full
|
||||
history before settings resolves still replays under the real mode (M4
|
||||
residual)` — builds a real `SettingsRepository` over a gated
|
||||
`SlowDataStore` (mode persisted as `BOTH` ahead of time on the ungated
|
||||
backing store), constructs the `ViewModel` with `settingsFlow`/`loaded`
|
||||
`stateIn`'d the same way `LotoApplication` does, resolves `masterStore`
|
||||
with the full 90-number history *before* completing the settings gate,
|
||||
asserts nothing crossed yet, then completes the gate and asserts the full
|
||||
board replays (`rowComplete` all true).
|
||||
- **Verified fail-without-fix**: temporarily reverted the `init` collector to
|
||||
the pre-fix `combine(...) { master, _, _ -> master }.collect { onMasterState(it) }`
|
||||
(ignoring `loaded`) and reran `:app:testDebugUnitTest --tests
|
||||
"...PlayerBoardViewModelTest"` — exactly the new regression test failed (18
|
||||
others passed), confirming it actually exercises the closed gap. Reverted
|
||||
back to the fix immediately after.
|
||||
- Fixed a pre-existing opt-in warning while touching `SettingsRepositoryTest.kt`
|
||||
(`@OptIn(ExperimentalCoroutinesApi::class)` on the class, matching the
|
||||
pattern already used in `PlayerBoardViewModelTest.kt`) since the new test
|
||||
needed `runCurrent`.
|
||||
|
||||
## Verification
|
||||
|
||||
```
|
||||
JAVA_HOME="C:/Program Files/Eclipse Adoptium/jdk-21.0.10.7-hotspot" ./gradlew :app:testDebugUnitTest :app:lintDebug
|
||||
```
|
||||
`BUILD SUCCESSFUL`, all unit tests pass (including the two new ones), lint
|
||||
clean. Not committed, per instructions.
|
||||
|
||||
## Files changed
|
||||
|
||||
- `android/app/src/main/AndroidManifest.xml`
|
||||
- `android/app/src/main/res/xml/data_extraction_rules.xml` (new)
|
||||
- `android/app/src/main/res/xml/backup_rules.xml` (new)
|
||||
- `android/app/src/main/java/com/miti99/loto/settings/SettingsRepository.kt`
|
||||
- `android/app/src/main/java/com/miti99/loto/LotoApplication.kt`
|
||||
- `android/app/src/main/java/com/miti99/loto/state/PlayerBoardViewModel.kt`
|
||||
- `android/app/src/main/java/com/miti99/loto/state/LotoViewModelFactory.kt`
|
||||
- `android/app/src/test/java/com/miti99/loto/settings/SettingsRepositoryTest.kt`
|
||||
- `android/app/src/test/java/com/miti99/loto/state/PlayerBoardViewModelTest.kt`
|
||||
|
||||
Note: `git status` also shows other android files modified/new
|
||||
(`build.gradle.kts`, `MainActivity.kt`, `MasterStore.kt`,
|
||||
`GameStateRepository.kt`, `MasterPanelViewModel.kt`, `ColorParsing.kt`, UI
|
||||
files, `MasterStoreTest.kt`, `ThrowingDataStore.kt`, etc.) — these are
|
||||
pre-existing uncommitted changes from the earlier `fullstack-developer-260831-2226`
|
||||
fix pass, not touched by this task.
|
||||
|
||||
## Unresolved questions
|
||||
|
||||
None.
|
||||
|
||||
Status: DONE
|
||||
Summary: Added API-31+ and pre-31 backup-exclusion rules for the game-state DataStore only (settings stay backed up), and closed the M4 residual race with a `SettingsRepository.loaded`/`LotoApplication.settingsLoaded` signal that gates `PlayerBoardViewModel`'s master-emission consumption until settings have really resolved; new regression test verified to fail without the fix. `:app:testDebugUnitTest` and `:app:lintDebug` both green.
|
||||
Concerns/Blockers: none.
|
||||
@@ -0,0 +1,115 @@
|
||||
# Web M5 fix: simultaneous-tab double-freeze
|
||||
|
||||
Plan: `plans/260831-2314-review-followups/plan.md` Phase 1.
|
||||
Source: `plans/reports/code-reviewer-260831-2213-web-review.md` M5.
|
||||
|
||||
## Change
|
||||
|
||||
`web/src/lib/active-tab.svelte.js`:
|
||||
- Added module-scoped `myClaimTs` (last `Date.now()` this tab claimed) and a
|
||||
`postClaim()` helper that sets it and broadcasts `{type:"claim", id, ts}`.
|
||||
Called from both `watchActiveTab()` (mount) and `claimActiveTab()`
|
||||
(reclaim) — same call sites as the old unconditional `postMessage`.
|
||||
- `onmessage` now applies newest-claim-wins: ignore `peerTs < myClaimTs`;
|
||||
freeze if `peerTs > myClaimTs`; on exact tie, freeze only if peer `id` is
|
||||
lexicographically greater than `TAB_ID` (deterministic, exactly one tab
|
||||
wins). Reclaim's fresh `myClaimTs` means a stale/late peer claim can no
|
||||
longer re-freeze the reclaiming tab.
|
||||
- `+layout.svelte` untouched — `watchActiveTab()`/`claimActiveTab()` keep
|
||||
their signatures, reclaim re-hydration flow unchanged.
|
||||
|
||||
`web/src/lib/active-tab.test.js`:
|
||||
- `FakeBC.postMessage` now defers delivery via `queueMicrotask` and
|
||||
re-reads the peer list at delivery time instead of snapshotting it at
|
||||
call time. This matches real `BroadcastChannel`'s queued-task delivery
|
||||
and — critically — is what makes a true "both tabs mount in the same
|
||||
tick" test possible: with the old synchronous/snapshot delivery, a tab
|
||||
created after another tab's `postMessage` call could never receive that
|
||||
earlier message, making one side of any tie-break untestable (and, per
|
||||
spec, unfaithful to the real API). Added `deliverBcMessages()` helper
|
||||
(`await Promise.resolve()`) to flush it in tests.
|
||||
- `beforeEach` now stubs `Date.now` with a strictly-increasing counter by
|
||||
default (removes flakiness/ambiguity in the pre-existing tests, which
|
||||
don't care about specific ts values) and restores mocks in `afterEach`.
|
||||
- Added three tests: unequal-ts simultaneous mount (older freezes, newer
|
||||
stays active), equal-ts tie-break (both tabs receive each other's claim
|
||||
via the same-tick delivery fix above; exactly one — the lexicographically
|
||||
greater id — stays active), and reclaim-updates-myClaimTs (a stale,
|
||||
older-ts claim from a peer after reclaim doesn't re-freeze).
|
||||
- Existing 4 tests updated only to `await deliverBcMessages()` after
|
||||
actions that need cross-tab delivery; assertions/intent unchanged.
|
||||
|
||||
## Verification (from `web/`)
|
||||
|
||||
- `npx vitest run` → 141/141 passed (10 files), incl. 7/7 in
|
||||
`active-tab.test.js`.
|
||||
- `npx eslint .` → 0 problems.
|
||||
- `npx svelte-check --tsconfig ./jsconfig.json` → 0 errors, 0 warnings
|
||||
(429 files, `checkJs: true`).
|
||||
|
||||
Builds skipped per instruction (unaffected — no import/signature changes).
|
||||
|
||||
## Unresolved questions
|
||||
|
||||
None — decision and algorithm were fully specified; no ambiguity hit
|
||||
during implementation.
|
||||
|
||||
Status: DONE
|
||||
Summary: Implemented newest-claim-wins with id tie-break in `active-tab.svelte.js`; extended the test file (and made its `BroadcastChannel` fake deliver messages the way the real API does) to cover unequal-ts, equal-ts tie-break, and reclaim-clears-staleness; all gates green.
|
||||
Concerns/Blockers: none
|
||||
|
||||
## M-1 follow-up — losing claimer never told it lost (2026-08-31)
|
||||
|
||||
Source: `plans/reports/code-reviewer-260831-2314-precommit-review.md` M-1.
|
||||
|
||||
### Change
|
||||
|
||||
`web/src/lib/active-tab.svelte.js` `onmessage` handler:
|
||||
- `peerTs` now falls back to `Infinity` when `e.data.ts` is not a number
|
||||
(legacy pre-fix peer broadcasting `{type, id}` only) — a no-ts claim
|
||||
always wins, matching the old always-freeze behavior instead of being
|
||||
silently ignored (`peerTs > myClaimTs` / `=== ` were both `false` against
|
||||
`undefined`).
|
||||
- Added an `else` branch: when this tab does **not** lose (`!peerWins`) and
|
||||
is currently active, it echoes its own unchanged `{type:"claim", id:
|
||||
TAB_ID, ts: myClaimTs}` back on the channel. The peer that sent the
|
||||
losing claim receives this echo, re-evaluates, strictly loses (same
|
||||
`myClaimTs` it already lost against, or a higher `ts`/`id` under the
|
||||
`Infinity` legacy path), and freezes. A frozen tab only ever hits the
|
||||
`peerWins` branch, so it never echoes — the exchange terminates after one
|
||||
echo round by construction, no counter/dedup needed.
|
||||
|
||||
No public API change — `watchActiveTab()`/`claimActiveTab()` signatures
|
||||
and `activeTab` shape untouched.
|
||||
|
||||
`web/src/lib/active-tab.test.js`:
|
||||
- Updated "reclaim updates myClaimTs..." — a stale claim from B now also
|
||||
asserts `tabB.activeTab.inactive === true` after an extra
|
||||
`deliverBcMessages()` flush (previously only checked A, per M-1 finding
|
||||
#1).
|
||||
- Added "a losing claimer is echoed at and freezes instead of leaving both
|
||||
tabs active" — reproduces the asymmetric-delivery race (tabA's initial
|
||||
claim is flushed with zero peers before tabB even mounts, so tabB never
|
||||
receives it, matching real `BroadcastChannel` non-replay), equal ts, id
|
||||
tie-break; asserts exactly one tab ends up active.
|
||||
- Added "the echo does not loop" — spies on `FakeBC.prototype.postMessage`,
|
||||
asserts message count settles at exactly 3 (two initial claims + one
|
||||
echo) and stays there across further flushes.
|
||||
- Added "a peer claim with no ts (legacy tab) always wins" — posts a raw
|
||||
`{type:"claim", id}` (no `ts`) from a second `BroadcastChannel` instance
|
||||
directly and asserts the tab freezes.
|
||||
|
||||
### Verification (from `web/`)
|
||||
|
||||
- `npx vitest run` → 144/144 passed (10 files), incl. 10/10 in
|
||||
`active-tab.test.js`.
|
||||
- `npx eslint .` → 0 problems.
|
||||
- `npx svelte-check --tsconfig ./jsconfig.json` → 0 errors, 0 warnings
|
||||
(429 files).
|
||||
|
||||
Files touched: `web/src/lib/active-tab.svelte.js`,
|
||||
`web/src/lib/active-tab.test.js` only, per assignment. Not committed.
|
||||
|
||||
Status: DONE
|
||||
Summary: Fixed M-1 by echoing a winning claim back at a losing peer (so it freezes instead of silently ignoring the loss) and treating a missing peer `ts` as `Infinity` (legacy peer always wins); added echo, no-loop, and legacy-no-ts test coverage; all gates green.
|
||||
Concerns/Blockers: none
|
||||
@@ -0,0 +1,158 @@
|
||||
# Android regression fixes — H1, M2, M3, M4, backup .tmp, generate() double-tap guard
|
||||
|
||||
Source: `plans/reports/code-reviewer-260831-2314-precommit-review.md`
|
||||
Scope: `android/` only. No commit made.
|
||||
|
||||
## H-1 — voicePlayer.release() kills audio on relaunch-in-process
|
||||
|
||||
Chose the **recreatable holder**, not the `cancel()` fallback: `cancel()` would
|
||||
fully abandon L1's intent (never releasing the ExoPlayer on explicit exit,
|
||||
reverting to pre-L1 behavior). The holder keeps L1's actual release on
|
||||
"Thoát" while surviving a relaunch into the same cached process.
|
||||
|
||||
- New `android/app/src/main/java/com/miti99/loto/audio/VoicePlayerHolder.kt`:
|
||||
nullable-instance holder, `value` rebuilds via a factory after `release()`.
|
||||
- `LotoApplication.kt`: `voicePlayer` now backed by a private
|
||||
`VoicePlayerHolder`; added `releaseVoicePlayer()`.
|
||||
- `MainActivity.kt`: `onDestroy()` calls `releaseVoicePlayer()` instead of
|
||||
`voicePlayer.release()`.
|
||||
- Testability: `LotoApplication`/`ExoVoicePlayer` need a real `Context`
|
||||
(no Robolectric in this project — `testOptions.unitTests` only sets
|
||||
`isReturnDefaultValues`), so the recreate-after-release contract is
|
||||
extracted into `VoicePlayerHolder` and unit-tested in isolation
|
||||
(`VoicePlayerHolderTest.kt`, 4 tests) against `FakeVoicePlayer`. The
|
||||
`LotoApplication`/`MainActivity` wiring itself stays unverified by a JVM
|
||||
test, same residual gap the reviewer already flagged — out of scope to
|
||||
close without adding Robolectric.
|
||||
|
||||
## M-2 — settings/loaded sibling-collector race
|
||||
|
||||
Replaced the two independent `stateIn` collectors PlayerBoardViewModel
|
||||
depended on (`settingsState: StateFlow<Settings>` + a separate loaded
|
||||
boolean) with one: `LotoApplication.settingsOrNull: StateFlow<Settings?>`
|
||||
(null = not loaded), the sole collector over `settingsRepository.settingsFlow`.
|
||||
`settingsState` (used by `MainActivity`, `MasterPanelViewModel`,
|
||||
`SettingsViewModel`, `SettingsSheetTest`) is now *derived* from
|
||||
`settingsOrNull` rather than an independent collection, removing the
|
||||
top-level race for everyone, not just the reported call site.
|
||||
`PlayerBoardViewModel` now takes `settingsOrNull` directly and reads
|
||||
`settingsOrNull.value` for both "is it loaded" and "what is the mode" —
|
||||
by construction the same snapshot, so they can never disagree.
|
||||
`LotoViewModelFactory` updated to wire `app.settingsOrNull` (+
|
||||
`app.settingsRepository.defaults` as the pre-load fallback for the rare
|
||||
`onCellClick`-on-a-restored-card-before-settings-load path).
|
||||
|
||||
## M-3 — generate()/clearMarks() bypass the settings gate
|
||||
|
||||
- `PlayerUiState.loading` now folds in settings-loaded, not just restore:
|
||||
`publishLoading() = !restoreDone || settingsOrNull.value == null`,
|
||||
called from `restore()` and from the `init` combine's collector (which now
|
||||
combines `masterStore.state` with `settingsOrNull` directly — settings
|
||||
changes still re-trigger `onMasterState`, same as before). The existing UI
|
||||
gate (`state.loading` disables both buttons) now also covers the
|
||||
settings-unresolved window.
|
||||
- Defense in depth, mirroring `restore()`'s own H1 guard: `generate()` and
|
||||
`clearMarks()` early-return (no-op) while `settingsOrNull.value == null`,
|
||||
so a caller that acts before the UI gate resolves can't corrupt the
|
||||
auto-cross cursor under a placeholder mode.
|
||||
- Updated the M4-residual test in `PlayerBoardViewModelTest.kt` to actually
|
||||
exercise the ordering the review said it dodged: `masterStore.restore()`
|
||||
now resolves *before* `generate()` is tapped (previously the reverse),
|
||||
asserting the tap while gated is a no-op and the full replay lands once
|
||||
settings resolve and the user taps again. The other ordering test
|
||||
("a settings arrival that lands before masterStore's restore...") is
|
||||
unchanged/kept.
|
||||
- New: `clearMarks() is also a no-op while settings have not resolved yet`
|
||||
and `loading stays true while settings have not resolved... (M3)`.
|
||||
|
||||
## M-4 — non-IO settings read failure deadlocks the gate
|
||||
|
||||
`SettingsRepository.settingsFlow`'s `catch` now falls back to
|
||||
`emptyPreferences()` (→ defaults) for **any** read failure, not only
|
||||
`IOException` — the fail-open fix applies at the source now that
|
||||
`LotoApplication.settingsOrNull` is the sole downstream collector (a fix
|
||||
scoped only to the separate `loaded: Flow<Boolean>`, as the review's
|
||||
snippet suggested, would no longer reach the actual production consumer
|
||||
after the M2 unification). `loaded` is kept as a plain derived signal for
|
||||
tests/other consumers; it no longer needs its own `.catch` since upstream
|
||||
never throws.
|
||||
`ThrowingDataStore` gained an optional `exception: Throwable` constructor
|
||||
param (defaults to `IOException`, unchanged for existing callers) so tests
|
||||
can exercise a non-IO failure. Added
|
||||
`non-IO failure on read still falls back to defaults...` and
|
||||
`loaded still flips true after a non-IO read failure` to
|
||||
`SettingsRepositoryTest.kt`.
|
||||
|
||||
## Low — backup `.tmp` sibling
|
||||
|
||||
Added `datastore/loto_game_state.preferences_pb.tmp` exclusion alongside the
|
||||
existing `.preferences_pb` one in both `data_extraction_rules.xml` and
|
||||
`backup_rules.xml`.
|
||||
|
||||
## Low — generate() double-tap guard
|
||||
|
||||
Added `private var generating` to `PlayerBoardViewModel`: `generate()` no-ops
|
||||
while a previous call's off-main compute hasn't resolved yet; also folded
|
||||
into `restore()`'s clobber guard (`!generating`) per the review's "participates
|
||||
in the restore guard" suggestion, since `grid` alone can't detect an
|
||||
in-flight generation. Test: `a second rapid generate() tap while the first
|
||||
is still computing is a no-op (L-a)`, asserting `FakeVoicePlayer.cancel()`
|
||||
(new `cancelCount` counter) fires only once.
|
||||
|
||||
## Files Modified
|
||||
|
||||
- `android/app/src/main/java/com/miti99/loto/audio/VoicePlayerHolder.kt` (new)
|
||||
- `android/app/src/main/java/com/miti99/loto/LotoApplication.kt`
|
||||
- `android/app/src/main/java/com/miti99/loto/MainActivity.kt`
|
||||
- `android/app/src/main/java/com/miti99/loto/settings/SettingsRepository.kt`
|
||||
- `android/app/src/main/java/com/miti99/loto/state/LotoViewModelFactory.kt`
|
||||
- `android/app/src/main/java/com/miti99/loto/state/PlayerBoardViewModel.kt`
|
||||
- `android/app/src/main/res/xml/data_extraction_rules.xml`
|
||||
- `android/app/src/main/res/xml/backup_rules.xml`
|
||||
- `android/app/src/test/java/com/miti99/loto/audio/VoicePlayerHolderTest.kt` (new)
|
||||
- `android/app/src/test/java/com/miti99/loto/audio/FakeVoicePlayer.kt`
|
||||
- `android/app/src/test/java/com/miti99/loto/ThrowingDataStore.kt`
|
||||
- `android/app/src/test/java/com/miti99/loto/settings/SettingsRepositoryTest.kt`
|
||||
- `android/app/src/test/java/com/miti99/loto/state/PlayerBoardViewModelTest.kt`
|
||||
|
||||
Untouched by me but pre-existing modified/untracked in the working tree
|
||||
(part of the earlier fix cycle under review, not this task): `build.gradle.kts`,
|
||||
`AndroidManifest.xml`, `GameStateRepository.kt`, `GameStateRepositoryTest.kt`,
|
||||
`MasterPanelViewModel.kt`, `MasterPanelViewModelTest.kt`, `MasterStore.kt`,
|
||||
`MasterStoreTest.kt`, `SlowDataStore.kt`, various `ui/*` files, `strings.xml`.
|
||||
|
||||
## Tests Status
|
||||
|
||||
- `JAVA_HOME=jdk-21 ./gradlew :app:testDebugUnitTest` — **BUILD SUCCESSFUL**,
|
||||
129 tests, 0 failures, 0 errors (was 120; +9 new: 4 `VoicePlayerHolderTest`,
|
||||
3 `PlayerBoardViewModelTest`, 2 `SettingsRepositoryTest`).
|
||||
- `JAVA_HOME=jdk-21 ./gradlew :app:lintDebug` — **BUILD SUCCESSFUL**, 0
|
||||
errors, 17 warnings (all pre-existing: dependency-version notices, one
|
||||
Vietnamese string typo heuristic, two plurals-candidate heuristics,
|
||||
`enableOnBackInvokedCallback` API-33 note — none touch the changed files).
|
||||
|
||||
## Issues Encountered
|
||||
|
||||
None. No file-ownership conflicts (single-agent task). `local.properties`
|
||||
was not read.
|
||||
|
||||
## Unresolved Questions
|
||||
|
||||
1. H-1: the `LotoApplication`/`MainActivity` glue around `VoicePlayerHolder`
|
||||
(real `finish()` + relaunch through a real `Application`/`Context`) is
|
||||
still not exercised by any automated test — this project has no
|
||||
Robolectric dependency, and adding one was out of scope. The holder's
|
||||
own recreate-after-release contract is unit-tested; the wiring is
|
||||
equivalent to what the reviewer already called out as untested.
|
||||
2. `fallbackSettings` for `PlayerBoardViewModel` (used only if `onCellClick`
|
||||
fires on a restored card before settings ever resolve) is wired to the
|
||||
real manifest-derived defaults in production
|
||||
(`app.settingsRepository.defaults`) and to a placeholder
|
||||
`Settings(voice = "")` in the constructor's own default parameter (never
|
||||
hit by existing tests, all of which pass a non-null-typed settings flow).
|
||||
Flagging this only so the placeholder default doesn't surprise a future
|
||||
reader who instantiates `PlayerBoardViewModel` with a null-capable flow
|
||||
without supplying `fallbackSettings`.
|
||||
|
||||
Status: DONE
|
||||
Summary: Fixed H1 (recreatable voice player holder), M2 (single settingsOrNull source of truth), M3 (settings-loaded folded into loading + generate()/clearMarks() guards, test reordered to hit the previously-dodged case), M4 (fail-open on any settingsFlow read failure), plus the backup .tmp exclusion and a generate() double-tap/in-flight guard; all 129 unit tests and lint pass clean.
|
||||
@@ -0,0 +1,96 @@
|
||||
# Full Verification Report — Uncommitted Changes
|
||||
**Date:** 2026-08-31 23:24
|
||||
**Scope:** All uncommitted changes in web/ and android/
|
||||
|
||||
---
|
||||
|
||||
## Web Verification Results
|
||||
|
||||
### 1. Unit Tests (vitest)
|
||||
- **Command:** `npx vitest run`
|
||||
- **Status:** ✓ PASS
|
||||
- **Result:** 141 tests passed, 0 failed
|
||||
- Test files: 10
|
||||
- Duration: 2.53s
|
||||
|
||||
### 2. Linting (eslint)
|
||||
- **Command:** `npx eslint .`
|
||||
- **Status:** ✓ PASS
|
||||
- **Errors:** 0
|
||||
|
||||
### 3. Type Checking (svelte-check)
|
||||
- **Command:** `npx svelte-check --tsconfig ./jsconfig.json`
|
||||
- **Status:** ✓ PASS
|
||||
- **Checked:** 429 files
|
||||
- **Errors:** 0
|
||||
- **Warnings:** 0
|
||||
|
||||
### 4. Build (BUILD_PROFILE=gh)
|
||||
- **Command:** `BUILD_PROFILE=gh npx vite build`
|
||||
- **Status:** ✓ PASS
|
||||
- **Duration:** 2.20s
|
||||
- **PWA Build Check:** ✓ PASS (base "/loto", 13 client JS files, 92 precached audio entries)
|
||||
|
||||
### 5. Build (default)
|
||||
- **Command:** `npx vite build`
|
||||
- **Status:** ✓ PASS
|
||||
- **Duration:** 2.18s
|
||||
- **PWA Build Check:** ✓ PASS (base "", 13 client JS files, 92 precached audio entries)
|
||||
|
||||
---
|
||||
|
||||
## Android Verification Results
|
||||
|
||||
### 1. Unit Tests (testDebugUnitTest)
|
||||
- **Command:** `JAVA_HOME="C:/Program Files/Eclipse Adoptium/jdk-21.0.10.7-hotspot" ./gradlew :app:testDebugUnitTest`
|
||||
- **Status:** ✓ PASS
|
||||
- **Result:** 120 tests passed, 0 failed
|
||||
- Packages: 6
|
||||
- Test classes: 12
|
||||
- Duration: 4.629s
|
||||
- Success rate: 100%
|
||||
|
||||
**Test Breakdown by Package:**
|
||||
- com.miti99.loto.audio: 13 tests (0.032s)
|
||||
- com.miti99.loto.game: 39 tests (4.240s)
|
||||
- com.miti99.loto.settings: 19 tests (0.125s)
|
||||
- com.miti99.loto.state: 41 tests (0.231s)
|
||||
- com.miti99.loto.ui: 3 tests (0s)
|
||||
- com.miti99.loto.ui.master: 5 tests (0.001s)
|
||||
|
||||
### 2. Lint Check (lintDebug)
|
||||
- **Command:** `JAVA_HOME="C:/Program Files/Eclipse Adoptium/jdk-21.0.10.7-hotspot" ./gradlew :app:lintDebug`
|
||||
- **Status:** ✓ PASS
|
||||
- **Build Result:** BUILD SUCCESSFUL in 7s
|
||||
- **Errors:** 0
|
||||
- **Warnings:** 14 (all non-blocking)
|
||||
|
||||
**Warning Summary:**
|
||||
- 1 API level compatibility warning (enableOnBackInvokedCallback @ API 33+)
|
||||
- 10 dependency version upgrade suggestions (AGP, androidx libs, Kotlin)
|
||||
- 2 plurals candidate warnings (Vietnamese string formatting)
|
||||
- 1 typo detection (cosmetic)
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
| Component | Status | Details |
|
||||
|-----------|--------|---------|
|
||||
| **Web Tests** | ✅ PASS | 141/141 tests |
|
||||
| **Web Lint** | ✅ PASS | 0 errors |
|
||||
| **Web Types** | ✅ PASS | 0 errors (429 files checked) |
|
||||
| **Web Build (gh)** | ✅ PASS | PWA verified |
|
||||
| **Web Build (default)** | ✅ PASS | PWA verified |
|
||||
| **Android Tests** | ✅ PASS | 120/120 tests |
|
||||
| **Android Lint** | ✅ PASS | 0 errors (14 warnings only) |
|
||||
|
||||
**Total:** 261 unit tests passing (141 web + 120 android)
|
||||
**Build Status:** Both web and android build successfully
|
||||
**Code Quality:** No errors, no blocking issues
|
||||
|
||||
---
|
||||
|
||||
Status: DONE
|
||||
Summary: All verification checks passed — 141 web tests, 120 android tests, builds succeed, no errors detected.
|
||||
Concerns/Blockers: None
|
||||
Reference in new issue
Block a user