Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions NEWS.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
2 changes: 1 addition & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion packages/frontend/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@recapp/frontend",
"private": true,
"version": "1.7.0",
"version": "1.7.1",
"type": "module",
"scripts": {
"dev": "vite",
Expand Down
63 changes: 63 additions & 0 deletions packages/frontend/src/actors/CurrentQuizActor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -140,13 +144,36 @@ export class CurrentQuizActor extends StatefulActor<MessageType, Unit | boolean
run: undefined,
exportFile: undefined,
deleted: false,
pendingAnswer: undefined,
runReady: false,
hasInitialQuestions: false,
questionsSubscribed: false,
runFetchStarted: false,
};
}

/**
* Replay an answer that arrived while `run` was undefined (buffered by
* LogAnswer's else branch). Called from every path that (re)initialises
* `run` and from the question-list update. It is a no-op unless the run and
* the answered question are both present, so calling it eagerly at several
* sites is safe regardless of the order in which run/questions settle. The
* buffer is cleared before re-dispatching so the answer applies exactly once
* and cannot re-enter this flush loop.
*/
private flushPendingAnswer(): void {
const pending = this.state.pendingAnswer;
if (!pending) return;
if (!this.state.run) return;
// LogAnswer requires the Question to be loaded (it reads question.type);
// wait for the question set if it hasn't arrived yet.
if (!this.state.questions.some(q => q.uid === pending.questionId)) return;
this.updateState(s => {
s.pendingAnswer = undefined;
});
this.send(this.ref, CurrentQuizMessages.LogAnswer(pending));
}

private async handleRemoteUpdates(message: MessageType): Promise<Maybe<CurrentQuizMessage>> {
if (message.tag === "QuizUpdateMessage") {
if (message.quiz.uid !== this.quiz.orElse(toId("-"))) {
Expand Down Expand Up @@ -225,6 +252,10 @@ export class CurrentQuizActor extends StatefulActor<MessageType, Unit | boolean
d.listRes({ quizId, source: "client", returnedCount: after });
}

// Questions just arrived; replay an answer buffered before run/questions
// were ready (no-op unless run is set and the question is now present).
this.flushPendingAnswer();

return nothing();
} else if (message.tag === "QuestionDeletedMessage") {
this.updateState(draft => {
Expand Down Expand Up @@ -293,6 +324,7 @@ export class CurrentQuizActor extends StatefulActor<MessageType, Unit | boolean
});
draft.run = undefined;
draft.runFetchStarted = false;
draft.pendingAnswer = undefined;
});
return nothing();
} else if (message.tag === "StatisticsUpdateMessage") {
Expand Down Expand Up @@ -380,6 +412,7 @@ export class CurrentQuizActor extends StatefulActor<MessageType, Unit | boolean
draft.run = undefined;
draft.exportFile = undefined;
draft.deleted = false;
draft.pendingAnswer = undefined;
draft.runReady = false;
draft.hasInitialQuestions = false;
draft.questionsSubscribed = false;
Expand Down Expand Up @@ -470,6 +503,11 @@ export class CurrentQuizActor extends StatefulActor<MessageType, Unit | boolean
this.send(`${actorUris.QuestionActorPrefix}${quizId}`, QuestionActorMessages.GetAll());
}

// Replay an answer buffered while run was undefined (no-op unless
// the answered question is already loaded; the QuestionUpdate
// handler retries once questions arrive).
this.flushPendingAnswer();

return run;
},
Activate: async ({ userId, quizId }) => {
Expand Down Expand Up @@ -529,6 +567,9 @@ export class CurrentQuizActor extends StatefulActor<MessageType, Unit | boolean
}
});

// run just (re)initialised — replay a buffered answer if any.
this.flushPendingAnswer();

return unit();
},
LogAnswer: async ({ questionId, answer }) => {
Expand Down Expand Up @@ -626,6 +667,24 @@ export class CurrentQuizActor extends StatefulActor<MessageType, Unit | boolean
`${actorUris.StatsActorPrefix}${this.quiz.orElse(toId("-"))}`,
StatisticsActorMessages.Update(stat)
);
} else {
// `run` is undefined — the answer was selected during the run
// (re)initialisation window (e.g. a WS reconnect cleared `run`
// and the async re-fetch hasn't completed, or a reset was
// dequeued ahead of this LogAnswer). Buffer it instead of
// silently dropping; flushPendingAnswer() replays it once the
// run and question set are ready. Last-write-wins: only one
// question is answerable at a time, so a single slot suffices.
this.updateState(draft => {
draft.pendingAnswer = { questionId, answer };
});
d.runState({
source: "LogAnswer",
beforeCounter: null,
afterCounter: null,
blocked: true,
reason: "run-undefined-buffered",
});
}
return unit();
},
Expand Down Expand Up @@ -909,6 +968,9 @@ export class CurrentQuizActor extends StatefulActor<MessageType, Unit | boolean
this.send(this.ref, CurrentQuizMessages.StartQuiz());
}
}
// Reconnect re-runs SetQuiz for the same quiz and repopulates
// run here; replay a buffered answer if one is waiting.
this.flushPendingAnswer();
return unit();
}
this.state = { ...this.state, comments: [], questions: [], deleted: false };
Expand Down Expand Up @@ -947,6 +1009,7 @@ export class CurrentQuizActor extends StatefulActor<MessageType, Unit | boolean
draft.quizStats = undefined;
draft.quiz = quizData;
draft.deleted = !quizData || keys(quizData).length === 0;
draft.pendingAnswer = undefined;
draft.runFetchStarted = false;
draft.runReady = false;
draft.questionsSubscribed = false;
Expand Down
37 changes: 21 additions & 16 deletions packages/frontend/src/pages/QuizPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -288,24 +288,23 @@ export const QuizPage: React.FC = () => {

console.log("TL", isUserInTeachersList, quizData.quiz.previewers);

const runReady = !!quizData.runReady;
const isQuizStateStarted = quizData.quiz.state === "STARTED";

// if (!isQuizStateStarted) {
// return <div className="text-sm opacity-70">The quiz hasn’t started yet.</div>;
// }

// if (!runReady || !hasInitialQuestions) {
// // Lightweight “syncing” UI — keeps users from seeing “0” briefly
// return (
// <div className="text-sm opacity-70">
// Fetching questions...
// </div>
// );
// }
// 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("");
Expand Down Expand Up @@ -535,12 +534,18 @@ export const QuizPage: React.FC = () => {
tabClassName={quizData.isPresentationModeActive ? "d-none" : ""}
>
{disableForStudent && quizData.quiz.state === "STARTED" ? (
<RunningQuizTab
isUserInTeachersList={isUserInTeachersList}
onClickAddComment={() => setShowMDModal(true)}
quizState={quizData}
logQuestion={logQuestion}
/>
answerReady ? (
<RunningQuizTab
isUserInTeachersList={isUserInTeachersList}
onClickAddComment={() => setShowMDModal(true)}
quizState={quizData}
logQuestion={logQuestion}
/>
) : (
// Lightweight “syncing” UI while the run (re)initialises —
// keeps the question from being answerable too early.
<div className="text-sm opacity-70">Fetching questions...</div>
)
) : (
<QuestionsTab
isUserInTeachersList={isUserInTeachersList}
Expand Down
137 changes: 137 additions & 0 deletions packages/frontend/test/actors/CurrentQuizActor.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { ActorSystem } from "ts-actors";
import { maybe } from "tsmonads";
import { toId } from "@recapp/models";
import type { Id, Quiz, QuizRun, Question, User } from "@recapp/models";
import { CurrentQuizActor, CurrentQuizMessages, CurrentQuizState } from "../../src/actors/CurrentQuizActor";
import { actorUris } from "../../src/actorUris";
import { createStub, getActorState } from "./stubs";

const silentLogger = { info: () => {}, 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<ReturnType<typeof system.createActor>>;
// Prefixes we repoint at test stubs; restored afterEach.
let saved: Record<string, string | undefined> = {};

const state = () => getActorState<CurrentQuizState>(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]);
});
});