Commit 9de3d41
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
- specs/047-exam-attempt-persistence
- src
- tests
- e2e
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
70 | 70 | | |
71 | 71 | | |
72 | 72 | | |
73 | | - | |
74 | | - | |
75 | | - | |
76 | | - | |
77 | | - | |
78 | | - | |
79 | | - | |
| 73 | + | |
| 74 | + | |
| 75 | + | |
| 76 | + | |
| 77 | + | |
| 78 | + | |
| 79 | + | |
| 80 | + | |
| 81 | + | |
| 82 | + | |
80 | 83 | | |
81 | 84 | | |
82 | 85 | | |
| |||
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
0 commit comments