Skip to content

Commit 9de3d41

Browse files
cucumberfalseMikhail Orlovclaude
authored
feat(exam): start screen, attempt persistence, leave guard (#212)
* feat(exam): start screen, attempt persistence, leave guard Implement ТЗ-P1 slice 2 (FR-B1 + FR-A4 + FR-A5): the exam attempt is no longer a silent data-loss path. - FR-B1: ExamView is a phase model idle → resumePrompt → active → finished. The start screen shows the format from data.examFormat (no hardcoded 40/45/85); the question set, startedAt, and timer only begin on «Начать». - FR-A4: the active attempt persists to a dedicated localStorage key cabadrive.exam-attempt.v1 ({version, questionIds, answers, startedAt, deadline}) on start and every answer/skip. The deadline is absolute, so the timer recomputes remaining from deadline - now (removes the fragile finish-inside-setState pattern, ТЗ-11). A valid unexpired saved attempt offers «Продолжить попытку (осталось MM:SS)» / «Отменить»; decline and finish clear the key; broken/expired/foreign attempts are discarded. - FR-A5: App owns a single examAttemptActive flag; top-nav goes through guardedSelectView, which opens the reused ConfirmDialog when leaving an active exam (the persisted attempt is kept for resume). beforeunload is armed only while active. New isolated module src/examAttemptStorage.ts (pure parseExamAttempt + remainingSeconds + thin StorageLike wrappers) with try/catch degradation; the progress store contract (cabadrive.progress.v1) is untouched. Tests: tests/exam-attempt.test.mjs (17 unit); migrated 3 existing exam e2e to click «Начать»; added 6 e2e (start screen, AC-2 reload-resume, leave guard, decline, beforeunload registration, seeded broken/expired negative). Unit 531 → 548; e2e 120 → 132 (66×2). Docs: learning-and-exam-flows.md. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * docs(exam): record Cycle PR set for PR #212 (evidence-only) Evidence-only: adds the Cycle PR set entry (PR #212, branch, state) to tasks.md. No behaviour, code, test, or doc-flow change; implementation content head remains 1a3a532. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * docs(exam): record final Architect and Analyst validation evidence Evidence-only: appends final Architect validation note + deviation disposition (tasks.md) and final Analyst validation note (feature-request.md) for the effective content head 1a3a532. No code/test/behavior change. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(exam): clear leave-guard flag and reject terminal saved attempt Address 3 Codex P2 findings on PR #212 (findings #1/#3 share one root cause). - Finding A (src/App.tsx, ExamView mount effect): when mount resolves to no resumable attempt (absent/broken/expired/terminal), notify the parent via onAttemptActiveChange(false) so App's examAttemptActive is cleared. Previously an attempt that expired between App's lazy seed and ExamView mount left beforeunload armed and guardedSelectView showing "Прервать экзамен?" on a clean start screen — contrary to FR-A5. The flag is now true only while the attempt is genuinely live (active / valid resumePrompt). - Finding B (src/examAttemptStorage.ts parseExamAttempt): reject a terminal snapshot (answers.length >= questionIds.length). If the final answer was persisted but the key was not cleared before the tab died, resuming restored position = answers.length, making current undefined and crashing the active render on current.id. Terminal snapshots are now discarded (key cleared, clean start screen). Tests: +1 unit (terminal-snapshot rejection, both == and > boundary) and +1 e2e (attempt expires before the exam tab opens → guard/beforeunload cleared). Unit 548 -> 549; e2e 132 -> 134 (67x2). Evidence-Log counts refreshed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(exam): validate answer sequence, clear attempt on reset, recheck deadline on resume Address 3 more Codex P2 findings on PR #212. - Finding C (src/examAttemptStorage.ts parseExamAttempt): also require each saved answer to line up with its question in sequence — exam-mode and answers[i].questionId === questionIds[i]. position is derived from answers.length, so a mismatched/stale answer would skip a real question on resume and let finishExam record a foreign answer into progress. Reject (null) otherwise. - Finding D (src/App.tsx): resetting or importing/undoing progress replaces cabadrive.progress.v1 but previously left the new cabadrive.exam-attempt.v1 key and examAttemptActive flag intact, so a reload could still offer to resume the deleted attempt and the guard/beforeunload stayed armed. confirmReset, confirmImport and restoreFromUndo now call discardActiveExamAttempt (clear key + flag false + remount ExamView via key={examResetNonce}) so a mounted attempt cannot survive the reset or re-persist on the next answer. - Finding E (src/App.tsx resume()): recheck deadline > Date.now() before entering the active phase; if the resume prompt sat open past the deadline, discard the attempt (clear key, flag false, back to start screen) instead of the timer immediately grading a finished attempt from partial saved answers. Proactive audit: swept every point where the two persisted keys could drift or a degenerate snapshot could be acted on (mount / resume / record / finish / reset-import-undo) — no further gaps; noted in tasks.md Dead Ends. Tests: +1 unit (answer-sequence rejection) and +2 e2e (reset-during-exam clears key+guard; resume-after-deadline discards without grading). Unit 549 -> 550 (module file 18 -> 19 test()); e2e 134 -> 138 (69x2). Evidence-Log refreshed. progressStore* schema untouched. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * docs(exam): record final Architect+Analyst validation for bf028a7 Evidence-only: final Architect and Analyst validation notes + Codex finding dispositions (C/D/E + process) recorded for effective content head bf028a7; T017/T018 checkboxes reconciled to the same validated SHA. No code/test/behavior change. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * docs(exam): add SHA-agnostic merge-guard evidence-only certification Evidence-only: adds a merge-guard block certifying that every commit after effective content head bf028a7 is a validation evidence-only commit (specs/047 notes only), so the bf028a7 Architect/Analyst validation stays current for the PR head. Addresses Codex process finding. No code/test/behavior change. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(exam): warn honestly when attempt persistence is unavailable Address Codex P2 Finding F (FR-A5 gap under the localStorage-unavailable path). When localStorage is missing or setItem throws (private/sandbox/quota), saveExamAttempt returns false, but start/record still marked the attempt active and the leave-guard dialog promised "Попытка сохранена — продолжите позже". If the user then confirmed a top-nav leave, ExamView unmounted and the only (in-memory) copy of their answers was lost — the guard made a promise the persistence layer never kept. - persist() now returns the actual save result; ExamView reports (active, persisted) to App via onAttemptStateChange (renamed from onAttemptActiveChange). A resumable attempt read back from storage is persisted; a fresh/continued attempt is persisted only if the write succeeded. - App tracks examAttemptPersisted and shows honest guard copy: when persistence is not working, the dialog warns "Этот браузер не сохраняет прогресс экзамена: при уходе текущая попытка будет потеряна." The normal wording is unchanged. The guard is still shown (warning the user is the point) and the exam still runs fully in memory without crashing. Scope: src/App.tsx only (saveExamAttempt already returned a boolean — progressStore* schema untouched, examAttemptStorage.ts unchanged). Tests: +1 e2e (localStorage double whose setItem throws → exam runs, guard armed, honest "will be lost" wording) plus a positive-path assertion that the working case still says "Попытка сохранена". e2e 138 -> 140 (70x2); unit stays 550. Evidence-Log refreshed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * docs(exam): final Architect+Analyst validation for 6e4aca1 Evidence-only: records final Architect and Analyst validation at effective content head 6e4aca1 (Finding F disposition + honest-guard re-validation), syncs T017/T018 to the same SHA, and updates the merge-guard block. Both role validations current for the PR head. No code/test/behavior change. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(exam): discard unsaved attempt when leaving via guard Address Codex Finding G (follow-on from F). When the active attempt is not persisted (localStorage unavailable / a failed write), confirming «Выйти» in the leave guard unmounts ExamView — the in-memory attempt is already lost — but the confirm-leave path only switched views and left examAttemptActive / examAttemptPersisted as they were. Consequences: (1) an older snapshot from a previous successful save could offer a stale/misleading resume on return, and (2) beforeunload kept firing until the exam tab was reopened. Fix (confirm-leave handler in App only, reusing discardActiveExamAttempt): - Persisted attempt (examAttemptPersisted === true): unchanged — do not clear; it resumes on return (FR-A5 design). - Unpersisted attempt (false): call discardActiveExamAttempt() (clear cabadrive.exam-attempt.v1 + examAttemptActive false + examAttemptPersisted false) before switching views, so no stale resume is offered and beforeunload disarms. The in-memory attempt is already gone on unmount, so discarding is correct. Tests: +2 e2e — unsaved leave via guard → clean start on return + beforeunload defaultPrevented false; older-successful-snapshot then failed re-save then leave → stale snapshot dropped, no resume offered. Persisted-leave test stays green. e2e 140 -> 144 (72x2); unit stays 550. Evidence-Log refreshed. progressStore* schema untouched. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * docs(exam): final validation for bcec92e with merge-gate markers Evidence-only: records final Architect+Analyst validation at effective content head bcec92e (Finding G disposition + honest/clean unsaved-leave re-validation), adds the exact `Final Architect/Analyst validation completed at:` merge-gate markers in chronological order, and updates the merge-guard block. No code/test/behavior change. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(exam): clear stale snapshot when a save fails Address Codex Finding H (the reload/close path, deeper than G). If setItem starts failing AFTER a snapshot was already stored (e.g. quota exhaustion mid-attempt), saveExamAttempt returned false but the OLDER JSON stayed under cabadrive.exam-attempt.v1. G only cleaned up the top-nav "leave via guard" path, so a plain reload/close still offered to resume from the STALE snapshot — silently dropping every answer made after the failed write. Fix (module-level, the robust place): in saveExamAttempt's write-failure branch, best-effort clearExamAttempt (removeItem inside its own try/catch, never throws) and return false as before. Invariant: cabadrive.exam-attempt.v1 reflects the current attempt or is absent — never a stale prior state. parseExamAttempt / read / clear semantics unchanged. The three storage-failure paths now agree: F (honest guard copy), G (discard on guard-leave), H (module-level cleanup). Tests: +1 unit (store a snapshot, flip setItem to throw → saveExamAttempt false AND key cleared) and +1 e2e (start save succeeds, a later save fails, plain reload → clean start, no stale-answers resume). Unit 550 -> 551 (module file 19 -> 20 test()); e2e 144 -> 146 (73x2). Evidence-Log refreshed. progressStore* schema untouched. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * docs(exam): final validation for be44583 with merge-gate markers Evidence-only: records final Architect+Analyst validation at effective content head be44583 (Finding H disposition — clear stale snapshot on failed write), with exact chronological merge-gate markers and the updated merge-guard block. No code/test/behavior change. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(exam): disarm leave guard when a saved attempt expires while away Address Codex Finding I (a new FR-A5 class: expiry-while-away, not storage failure). When a persisted attempt is left via the guard (kept for resume) and the user then stays on a non-exam view past the deadline, examAttemptActive never flipped — the only expiry checks lived inside ExamView, which is unmounted while away. So the App-level beforeunload stayed registered (and guardedSelectView would still guard) even though the stored attempt was expired and no longer resumable; closing/reloading from a non-exam view after the deadline still showed the native warning until the exam tab was reopened. Fix (App only): - App-level expiry timer: while examAttemptActive && view !== "exam", schedule a timeout at the stored deadline (read via readExamAttempt); on fire, call discardActiveExamAttempt() so the guard + beforeunload disarm exactly at expiry. Missing/already-past deadline disarms immediately; the timeout clears on cleanup / returning to the exam view (ExamView owns expiry while mounted) / when the flag clears. - beforeunload fire-time re-check (belt-and-suspenders): away from the exam tab, only warn if a still-stored, unexpired snapshot is present; otherwise don't prevent and best-effort clear the key. On the exam tab the attempt is genuinely running (incl. in-memory private mode) — always warn. - discardActiveExamAttempt converted to a stable useCallback. The mounted-ExamView expiry path is unchanged; a genuinely active, unexpired attempt still warns (no regression); F/G/H behaviors preserved; progressStore* schema untouched. Tests: +1 e2e (page.clock) — leave a persisted attempt via the guard to Mistakes, assert beforeunload still warns before the deadline, advance past the deadline, assert beforeunload no longer prevents unload and returning to Exam shows a clean start (key cleared). e2e 146 -> 148 (74x2); unit stays 551. Evidence-Log refreshed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * docs(exam): final validation for 9711abe with merge-gate markers Evidence-only: records final Architect+Analyst validation at effective content head 9711abe (Finding I disposition — disarm leave guard and beforeunload when a saved attempt expires while away), with exact chronological merge-gate markers and the updated merge-guard block. No code/test/behavior change. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(exam): reject truncated saved exam attempts Address Codex Finding J. parseExamAttempt validated that questionIds is a non-empty set of resolvable IDs, but never checked that the number of saved questions matches the expected exam length. A well-formed but truncated/stale snapshot (e.g. questionIds of length 1-2, from a corrupted key or an old format) was treated as resumable; resume() then graded against that shortened questions.length, producing a bogus short exam and storing an INCORRECT completed-attempt total instead of discarding the broken attempt. Fix: add a questionCount field to the parseExamAttempt / readExamAttempt options and reject when questionIds.length !== questionCount (truncated or oversized). Also sanity-guard answers.length > questionCount (with the existing terminal answers.length >= questionIds.length check this pins answers to a valid in-progress range). All existing checks (version, resolvable IDs, answer-sequence match, deadline, terminal-rejection) are kept. The expected count is threaded in from data.examFormat.questionCount at all four App call sites (ExamView savedAttempt init, App examAttemptActive lazy-init, beforeunload re-check, expiry timer). A legitimate in-progress attempt always persists the full set from start() (length == questionCount), so only genuinely truncated/corrupt snapshots are rejected (AC-2 resume stays green). Tests: +2 unit (truncated < and oversized > questionCount → null; full-length in-progress accepted; answers > questionCount → null) and +1 e2e (seed a 1-questionId truncated key → clean start, key cleared, zero completed attempts). Unit 551 -> 553 (module file 20 -> 22 test()); e2e 148 -> 150 (75x2). Evidence-Log refreshed. progressStore* schema untouched. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * docs(exam): final validation for 19ef642 + canonical evidence headings Evidence-only: records final Architect+Analyst validation at effective content head 19ef642 (Finding J disposition — reject truncated/oversized saved attempts), with exact chronological merge-gate markers, updated merge-guard block, and process memory realigned to the canonical section headings parsed by scripts/finalize-pr.mjs. No code/test/behavior change. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(exam): auto-expire the resume prompt at the deadline Address Codex Finding K. When the user reloads into the resume prompt (phase === "resumePrompt") and leaves it open past the saved deadline, nothing transitioned it: the remaining time is computed only at initial render, no interval runs in this phase, and the App-level away-expiry effect (Finding I) is disabled while view === "exam". So the prompt kept advertising a resumable attempt and the leave guard + beforeunload stayed armed until the user clicked «Продолжить» (which discards an expired one via Finding E's recheck). Left sitting, it was stale. Fix (ExamView only): while phase === "resumePrompt", schedule a setTimeout(savedAttempt.deadline - Date.now()) (fires immediately if already past) that discards the attempt (clearExamAttempt + onAttemptStateChange(false, false) + clear savedAttempt + phase → idle), auto-transitioning to a clean start screen without user action and disarming the guard/beforeunload. The timeout clears on cleanup / phase change. No double-fire: the active-phase timer is a different phase, and the App away-expiry effect only runs when view !== "exam" — resumePrompt is on the exam view, so this fills exactly the uncovered case. The resume() recheck (Finding E) and the active-phase timer are unchanged; no regression to F/G/H/I/J. Tests: +1 e2e (page.clock runFor past the deadline with the prompt open → auto-clean-start, key cleared, guard/beforeunload disarmed). The Finding E test now uses setFixedTime (jump Date past the deadline without advancing the timer queue, so the auto-expire has not fired and «Продолжить» still exercises resume()'s recheck); AC-2 normal resume stays green. e2e 150 -> 152 (76x2); unit stays 553. Evidence-Log refreshed. examAttemptStorage.ts / progressStore* untouched. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * docs(exam): final validation for 01bacf1 with merge-gate markers Evidence-only: records final Architect+Analyst validation at effective content head 01bacf1 (Finding K disposition — auto-expire the resume prompt at the deadline), with exact chronological merge-gate markers, updated merge-guard block, and the reconciled Final Validation Evidence section. No code/test/behavior change. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(exam): reject answers submitted after the deadline Address Codex Finding L (exam integrity). When the exam tab is suspended/throttled, or the user answers in the gap before the next 1s interval tick, record() appended + persisted the answer from stale React state; the absolute deadline was only enforced later by the interval-driven effect. So an answer or skip submitted AFTER the deadline could still be counted in the final score. Fix (ExamView.record() only; skipCurrent routes through record): re-check the wall clock against the same absolute deadline before accepting the input. If Date.now() >= deadline, do NOT append the late answer/skip — instead finish() with the EXISTING (in-time) answers, the same terminal path the interval uses, so the late input is rejected and the attempt is graded on what was legitimately submitted before the deadline. In-time behavior (append + persist-on-answer) is unchanged, and the interval-driven finish becomes a backstop. finish() stays idempotent via finishGuard, so there is no double-finish. Tests: +1 e2e (page.clock) — one in-time skip, then setFixedTime past the 45-min deadline (without advancing the timer queue, so the interval finish has not fired) and attempt a late skip → the exam grades on the single in-time answer (progress records exactly 1 answer, 1 completed attempt, key cleared); the late skip is dropped. Normal in-time play, timeout, and AC-2 resume stay green. e2e 152 -> 154 (77x2); unit stays 553. Evidence-Log refreshed. examAttemptStorage.ts / progressStore* untouched. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * docs(exam): final validation for 4f8492c with merge-gate markers Evidence-only: records final Architect+Analyst validation at effective content head 4f8492c (Finding L disposition — reject answers submitted after the deadline), with exact chronological merge-gate markers and the updated merge-guard block. No code/test/behavior change. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * docs(exam): refresh stale process-head summaries to 4f8492c Evidence-only: makes tasks.md internally consistent — Cycle PR set summary, T018 note, and all active head references now name the current effective content head 4f8492c (superseded heads marked as history). No code/test/behavior change; effective content head unchanged. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Mikhail Orlov <mikhail.orlov@my.games> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent ae5f980 commit 9de3d41

10 files changed

Lines changed: 4084 additions & 53 deletions

File tree

docs_project/screens/learning-and-exam-flows.md

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -70,13 +70,16 @@ The app header exposes three progress-safety icon actions next to the title:
7070

7171
## Exam Simulation Flow
7272

73-
1. Start exam with parameters from `content/config/caba-exam-format.json`.
74-
2. Use the exam-wide timer only; do not show learning per-ticket timer controls during an active attempt.
75-
3. Hide translation/explanation during active attempt.
76-
4. Do not show difficulty rationale, dimensions, or study hints during active attempt; current active exam UI also omits compact difficulty chips.
77-
5. Record timing and selected answers.
78-
6. Complete exam and show score.
79-
7. Generate weak-topic and mistake review recommendations.
73+
1. Open the exam tab to a start screen that lists the format from `content/config/caba-exam-format.json` (question count, time limit, passing score, skip rule, status). No exam question set is selected, no `startedAt` is fixed, and no timer runs until the user presses «Начать».
74+
2. On «Начать», fix the question set, record `startedAt`, and derive an absolute `deadline = startedAt + timeLimitMinutes * 60_000`. The exam-wide timer recomputes remaining time from `deadline - now`; do not show learning per-ticket timer controls during an active attempt.
75+
3. Persist the active attempt to a dedicated localStorage key `cabadrive.exam-attempt.v1` (`{ version, questionIds, answers, startedAt, deadline }`) on start and on every answer/skip. This is separate from the progress store (`cabadrive.progress.v1`, completed attempts only) and degrades silently (try/catch) when localStorage is unavailable — the exam still runs in memory.
76+
4. On returning to the exam tab or after a reload, a valid unexpired saved attempt offers «Продолжить попытку (осталось MM:SS)» / «Отменить», where the remaining time is recomputed from the absolute `deadline`. «Продолжить» restores the question set, answers, position, and countdown; «Отменить» and finishing both clear the key. A broken, expired (`deadline <= now`), or unresolvable saved attempt is discarded and the key cleared, showing a clean start screen.
77+
5. While an attempt is active, leaving the exam tab for another top-nav view asks for confirmation via `ConfirmDialog` (the persisted attempt is kept for later resume); closing/reloading the browser tab arms a best-effort `beforeunload` warning (unreliable on mobile — the persisted attempt is the real guarantee).
78+
6. Hide translation/explanation during active attempt.
79+
7. Do not show difficulty rationale, dimensions, or study hints during active attempt; current active exam UI also omits compact difficulty chips.
80+
8. Record timing and selected answers.
81+
9. Complete exam and show score.
82+
10. Generate weak-topic and mistake review recommendations.
8083

8184
## Mistake Review Flow
8285

specs/047-exam-attempt-persistence/feature-request.md

Lines changed: 417 additions & 0 deletions
Large diffs are not rendered by default.

specs/047-exam-attempt-persistence/plan.md

Lines changed: 280 additions & 0 deletions
Large diffs are not rendered by default.

specs/047-exam-attempt-persistence/spec.md

Lines changed: 294 additions & 0 deletions
Large diffs are not rendered by default.

specs/047-exam-attempt-persistence/tasks.md

Lines changed: 1570 additions & 0 deletions
Large diffs are not rendered by default.

0 commit comments

Comments
 (0)