From c11d20f691bc815c952e79b668ad1394b5c0e00f Mon Sep 17 00:00:00 2001 From: Sebastian Hanss Date: Tue, 18 Aug 2026 19:10:35 +0200 Subject: [PATCH 1/3] fix(quiz): stop silently dropping answers selected before run init An answer selected while CurrentQuizActor.state.run was undefined was silently discarded: LogAnswer's body is wrapped in `if (this.state.run)` with no else, and the student answer UI was clickable during the run (re)initialisation window (e.g. the startup WS reconnect clears `run` and the async GetRun re-fetch stalls on the ts-actors 5s ask-timeout). - LogAnswer: buffer the pending {questionId, answer} in state.pendingAnswer instead of no-oping when run is undefined; new flushPendingAnswer() replays it once run + questions are ready (from GetRun, StartQuiz, SetQuiz-same, QuestionUpdate). Buffer cleared on Reset/SetQuiz-different/QuizRunDeleted. - QuizPage: gate the student answer UI (RunningQuizTab) on run present + hasInitialQuestions so a question isn't answerable before the run exists. Scoped to that branch (not page-wide) so teacher tabs still render without a run while editing; keyed on `run` rather than the unreliable runReady flag. - Add CurrentQuizActor unit test covering buffer + replay. --- .../frontend/src/actors/CurrentQuizActor.ts | 63 ++++++++ packages/frontend/src/pages/QuizPage.tsx | 37 +++-- .../test/actors/CurrentQuizActor.test.ts | 137 ++++++++++++++++++ 3 files changed, 221 insertions(+), 16 deletions(-) create mode 100644 packages/frontend/test/actors/CurrentQuizActor.test.ts diff --git a/packages/frontend/src/actors/CurrentQuizActor.ts b/packages/frontend/src/actors/CurrentQuizActor.ts index f17546ba..9af4520e 100644 --- a/packages/frontend/src/actors/CurrentQuizActor.ts +++ b/packages/frontend/src/actors/CurrentQuizActor.ts @@ -107,6 +107,10 @@ export type CurrentQuizState = { result?: QuizRun; exportFile?: string; deleted: boolean; + // An answer selected before `run` finished (re)initialising. LogAnswer + // buffers it here instead of silently dropping it, and it is replayed once + // `run` and the question set are ready. See flushPendingAnswer(). + pendingAnswer?: { questionId: Id; answer: string | boolean[] }; runReady: boolean; hasInitialQuestions: boolean; questionsSubscribed: boolean; @@ -140,6 +144,7 @@ export class CurrentQuizActor extends StatefulActor q.uid === pending.questionId)) return; + this.updateState(s => { + s.pendingAnswer = undefined; + }); + this.send(this.ref, CurrentQuizMessages.LogAnswer(pending)); + } + private async handleRemoteUpdates(message: MessageType): Promise> { if (message.tag === "QuizUpdateMessage") { if (message.quiz.uid !== this.quiz.orElse(toId("-"))) { @@ -225,6 +252,10 @@ export class CurrentQuizActor extends StatefulActor { @@ -293,6 +324,7 @@ export class CurrentQuizActor extends StatefulActor { @@ -529,6 +567,9 @@ export class CurrentQuizActor extends StatefulActor { @@ -626,6 +667,24 @@ export class CurrentQuizActor extends StatefulActor { + draft.pendingAnswer = { questionId, answer }; + }); + d.runState({ + source: "LogAnswer", + beforeCounter: null, + afterCounter: null, + blocked: true, + reason: "run-undefined-buffered", + }); } return unit(); }, @@ -909,6 +968,9 @@ export class CurrentQuizActor extends StatefulActor { console.log("TL", isUserInTeachersList, quizData.quiz.previewers); - const runReady = !!quizData.runReady; const isQuizStateStarted = quizData.quiz.state === "STARTED"; // if (!isQuizStateStarted) { // return
The quiz hasn’t started yet.
; // } - // if (!runReady || !hasInitialQuestions) { - // // Lightweight “syncing” UI — keeps users from seeing “0” briefly - // return ( - //
- // Fetching questions... - //
- // ); - // } + // Gate for the student answer UI (RunningQuizTab): the run must exist + // AND the first questions must have arrived. Without this, a radio is + // clickable while `run` is still undefined (e.g. during a startup or + // mid-quiz WS reconnect that clears `run` and re-fetches it async), + // and CurrentQuizActor.LogAnswer would silently drop the selection. + // Scoped to the RunningQuizTab branch below (not page-wide) so it never + // hides the teacher tabs, which render without a `run` while editing. + const answerReady = !!quizData.run && hasInitialQuestions; const run = quizData.run; - const qData = quizData.questions;runReady; + const qData = quizData.questions; const questions = run?.questions.map(id => qData.find(q => q.uid === id)) ?? []; const currentQuestion = questions[run?.counter ?? 0]; const questionId = currentQuestion?.uid ?? toId(""); @@ -535,12 +534,18 @@ export const QuizPage: React.FC = () => { tabClassName={quizData.isPresentationModeActive ? "d-none" : ""} > {disableForStudent && quizData.quiz.state === "STARTED" ? ( - setShowMDModal(true)} - quizState={quizData} - logQuestion={logQuestion} - /> + answerReady ? ( + setShowMDModal(true)} + quizState={quizData} + logQuestion={logQuestion} + /> + ) : ( + // Lightweight “syncing” UI while the run (re)initialises — + // keeps the question from being answerable too early. +
Fetching questions...
+ ) ) : ( {}, warn: () => {}, error: () => {}, debug: () => {} }; + +const QUIZ_ID = "quiz-1"; +const QUESTION_ID = "q1"; + +// A single-choice question with a known-correct answer, so LogAnswer's +// correctness path runs without throwing. +const question = { + uid: toId(QUESTION_ID), + type: "SINGLE", + approved: true, + answers: [ + { text: "A", correct: true }, + { text: "B", correct: false }, + ], +} as unknown as Question; + +const quiz = { + uid: toId(QUIZ_ID), + state: "STARTED", + groups: [{ name: "DEFAULT", questions: [toId(QUESTION_ID)] }], + students: [], + shuffleQuestions: false, +} as unknown as Quiz; + +const user = { uid: toId("student-1"), role: "STUDENT" } as unknown as User; + +const makeRun = (): QuizRun => + ({ + uid: toId("run-1"), + studentId: toId("student-1"), + quizId: toId(QUIZ_ID), + questions: [toId(QUESTION_ID)], + counter: 0, + answers: [], + correct: [], + wrong: [], + }) as unknown as QuizRun; + +describe("CurrentQuizActor — LogAnswer buffering before run init", () => { + let system: ActorSystem; + let ref: Awaited>; + // Prefixes we repoint at test stubs; restored afterEach. + let saved: Record = {}; + + const state = () => getActorState(system, "CurrentQuiz"); + const actor = () => { + const r = system.getActorRef(`actors://test/CurrentQuiz`) as any; + if (r instanceof Error) throw r; + return r.actor as CurrentQuizActor & { quiz: unknown; user: unknown; state: CurrentQuizState }; + }; + + beforeEach(async () => { + saved = { + QuizRunActorPrefix: actorUris["QuizRunActorPrefix"], + QuestionActorPrefix: actorUris["QuestionActorPrefix"], + StatsActorPrefix: actorUris["StatsActorPrefix"], + }; + system = await ActorSystem.create({ systemName: "test", logger: silentLogger as any }); + + // Point the per-quiz actor prefixes at stubs living in this test system. + actorUris["QuizRunActorPrefix"] = "actors://test/QRun_" as any; + actorUris["QuestionActorPrefix"] = "actors://test/Quest_" as any; + actorUris["StatsActorPrefix"] = "actors://test/Stats_" as any; + + // GetForUser (ask) resolves to a fresh run at counter 0; Update (send) is ignored. + await createStub(system, `QRun_${QUIZ_ID}`, () => makeRun()); + await createStub(system, `Quest_${QUIZ_ID}`, () => undefined); + await createStub(system, `Stats_${QUIZ_ID}`, () => undefined); + + ref = await system.createActor(CurrentQuizActor, { name: "CurrentQuiz" }); + + // Wire the actor's identity and a minimal loaded state. + const a = actor(); + a.quiz = maybe(toId(QUIZ_ID)); + a.user = maybe(user); + a.state = { + ...a.state, + quiz, + questions: [question], + run: undefined, + }; + }); + + afterEach(async () => { + actorUris["QuizRunActorPrefix"] = saved.QuizRunActorPrefix as any; + actorUris["QuestionActorPrefix"] = saved.QuestionActorPrefix as any; + actorUris["StatsActorPrefix"] = saved.StatsActorPrefix as any; + await system.shutdown(); + }); + + it("buffers the answer (does not silently drop it) when run is undefined", async () => { + expect(state().run).toBeUndefined(); + + await system.ask( + ref, + CurrentQuizMessages.LogAnswer({ questionId: toId(QUESTION_ID), answer: [true, false] }) + ); + + const s = state(); + // Nothing was recorded against a (non-existent) run … + expect(s.run).toBeUndefined(); + // … but the answer is retained for later instead of being lost. + expect(s.pendingAnswer).toEqual({ questionId: toId(QUESTION_ID), answer: [true, false] }); + }); + + it("applies the buffered answer once run initialises via GetRun", async () => { + // 1) Answer selected while run is still undefined → buffered. + await system.ask( + ref, + CurrentQuizMessages.LogAnswer({ questionId: toId(QUESTION_ID), answer: [true, false] }) + ); + expect(state().pendingAnswer).toBeDefined(); + + // 2) Run initialises. GetRun sets state.run and flushes the buffer, which + // re-dispatches LogAnswer; that send is async, so allow it to drain. + await system.ask(ref, CurrentQuizMessages.GetRun()); + await new Promise(r => setTimeout(r, 50)); + + const s = state(); + expect(s.run).toBeDefined(); + // The previously-buffered answer has now been logged exactly once. + expect(s.pendingAnswer).toBeUndefined(); + expect(s.run!.counter).toBe(1); + expect(s.run!.answers).toHaveLength(1); + expect(s.run!.answers[0]).toEqual([true, false]); + }); +}); From ffd3b0e2cc1d52ec0d322a95bdba2a94f20a02e3 Mon Sep 17 00:00:00 2001 From: Sebastian Hanss Date: Tue, 18 Aug 2026 20:27:07 +0200 Subject: [PATCH 2/3] version bump 1.7.0 -> 1.7.1 --- package-lock.json | 2 +- packages/frontend/package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index 263ec150..bbb6bbf1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20209,7 +20209,7 @@ }, "packages/frontend": { "name": "@recapp/frontend", - "version": "1.7.0", + "version": "1.7.1", "dependencies": { "@lingui/cli": "^5.3.1", "@lingui/macro": "^4.14.1", diff --git a/packages/frontend/package.json b/packages/frontend/package.json index bf060839..569be0b9 100644 --- a/packages/frontend/package.json +++ b/packages/frontend/package.json @@ -1,7 +1,7 @@ { "name": "@recapp/frontend", "private": true, - "version": "1.7.0", + "version": "1.7.1", "type": "module", "scripts": { "dev": "vite", From 348f229ad80afdf6e8188189ec8f5a962710ac51 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Han=C3=9F?= Date: Thu, 20 Aug 2026 15:44:28 +0200 Subject: [PATCH 3/3] new entry to NEWS.md --- NEWS.md | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/NEWS.md b/NEWS.md index 082c8715..d50142da 100644 --- a/NEWS.md +++ b/NEWS.md @@ -1,3 +1,28 @@ +## [Release] v1.7.1 – 2026-08-20 + +Patch release fixing a quiz-answer race: an answer selected during the brief +run-(re)initialisation window could be silently dropped. No operator action — +frontend-only change, deploy stack and config unchanged since v1.7.0. + +Versions: frontend `1.7.0 → 1.7.1`, backend `1.1.0`, models `1.1.0`. + +### Highlights + +- **Quiz reliability** + - Fixed answers being silently discarded when selected before the quiz run + finished initialising (e.g. during a startup WebSocket reconnect, when the + `GetRun` re-fetch stalls on the actor ask-timeout). Such answers are now + buffered and replayed once the run and its questions are ready, instead of + being no-oped + - The student answer UI is now gated on the run being present, so a question + can't be answered before its run exists; teacher tabs still render normally + while editing + +- **Testing** + - Added a `CurrentQuizActor` unit test covering the buffer-and-replay path + +--- + ## [Release] v1.7.0 – 2026-08-14 Quiz-reliability hardening, a deployment-stack move to **Caddy**, tighter session