import test from "node:test"; import assert from "node:assert/strict"; import { QuestionnaireEngine } from "../src/lib/questionnaire/questionnaire-engine"; import { SaveEventRecorder, type SaveEvent } from "../src/lib/persistence/save-events"; import type { SaveRequest } from "../src/lib/persistence/types"; import example from "../src/demo/example-questionnaire.json"; import { serializeAnswers } from "../src/lib/persistence/typed-answers"; import { parseResumeDraft } from "../src/lib/questionnaire/resume-draft"; test("typed changes and resume drafts round-trip dates, falsy values, arrays and uploads", async () => { const definition = { schemaVersion: 1, id: "typed", version: "1", title: "Typed answers", pages: [{ id: "first", title: "First", fields: [ { id: "dateOfBirth", type: "date", label: "Date of birth" }, { id: "consent", type: "boolean", label: "Consent" }, { id: "count", type: "number", label: "Count" }, { id: "notes", type: "text", label: "Notes" }, { id: "choices", type: "multiselect", label: "Choices", options: [{ value: "a", label: "A" }] }, { id: "attachment", type: "files", label: "Attachment" }, ] }], }; const engine = new QuestionnaireEngine(definition); const answers = { dateOfBirth: "1992-03-04", consent: false, count: 0, notes: null, choices: ["a"], attachment: [{ uploadId: "upload-1", name: "proof.pdf", size: 10, type: "application/pdf" }], }; const typed = serializeAnswers(engine, answers); assert.deepEqual(typed.dateOfBirth, { type: "date", value: "1992-03-04" }); assert.deepEqual(parseResumeDraft(JSON.stringify({ answers: typed }), engine.definition).answers, answers); const calls: SaveRequest[] = []; const recorder = new SaveEventRecorder(engine, () => {}, {}, 0, undefined, request => { calls.push(request); return { revision: request.revision + 1 }; }); await recorder.save(engine.createSession(answers), Object.keys(answers), "Continue", "first"); assert.deepEqual(calls[0].changes, typed); assert.throws(() => parseResumeDraft(JSON.stringify({ answers: { dateOfBirth: { type: "text", value: "1992-03-04" } } }), engine.definition), /expected type date/); assert.throws(() => parseResumeDraft(JSON.stringify({ answers: { dateOfBirth: { type: "date" } } }), engine.definition), /needs a value/); assert.throws(() => parseResumeDraft(JSON.stringify({ answers: { consent: { type: "boolean", value: "yes" } } }), engine.definition), /valid boolean/); }); test("file saves reject missing upload IDs and preserve real host references", async () => { const fileEngine = new QuestionnaireEngine({ schemaVersion: 1, id: "uploads", version: "1", title: "Uploads", pages: [{ id: "files", title: "Files", fields: [{ type: "files", label: "Attachment", id: "attachment" }] }] }); const calls: SaveRequest[] = []; const recorder = new SaveEventRecorder(fileEngine, () => {}, {}, 0, undefined, request => { calls.push(request); return { revision: 1 }; }); const metadata = { name: "evidence.pdf", size: 10, type: "application/pdf" }; assert.equal(await recorder.filesSelected("attachment", [metadata], "files", fileEngine.createSession({ attachment: [metadata] })), false); assert.equal(calls.length, 0); const uploaded = { ...metadata, uploadId: "host-upload-123" }; const session = fileEngine.createSession({ attachment: [uploaded] }); assert.equal(await recorder.filesSelected("attachment", [uploaded], "files", session), true); assert.equal(calls[0].kind, "changes"); assert.deepEqual(calls[0].changes?.attachment, { type: "files", value: [uploaded] }); assert.deepEqual(session.getEffectiveAnswers().attachment, [uploaded]); }); test("failed changes retry and queued saves use the acknowledged revision", async () => { const engine = new QuestionnaireEngine(example); const calls: SaveRequest[] = []; const events: SaveEvent[] = []; let fail = true; const recorder = new SaveEventRecorder(engine, event => events.push(event), {}, 4, undefined, async request => { calls.push(request); if (fail) { fail = false; throw new Error("Offline"); } return { revision: request.revision + 1 }; }); const session = engine.createSession({ fullName: "Alex" }); assert.equal(await recorder.save(session, ["fullName"], "blur", "registration"), false); const retry = recorder.save(session, ["fullName"], "Continue", "registration"); const next = recorder.savePosition("registration", "attending"); assert.equal(await retry, true); await next; assert.deepEqual(calls[0].changes, calls[1].changes); assert.deepEqual(calls.map(call => call.revision), [4, 4, 5]); assert.deepEqual(events.map(event => event.status), ["Blocked", "Saved", "Saved"]); }); test("save promises wait for the host and duplicate queued answers are deduplicated", async () => { const engine = new QuestionnaireEngine(example); let finish!: () => void; const gate = new Promise(resolve => { finish = resolve; }); let calls = 0; const events: SaveEvent[] = []; const recorder = new SaveEventRecorder(engine, event => events.push(event), {}, 0, undefined, async () => { calls++; await gate; return { revision: 1 }; }); const session = engine.createSession({ fullName: "Alex" }); const first = recorder.save(session, ["fullName"], "blur", "registration"); const second = recorder.save(session, ["fullName"], "blur", "registration"); await Promise.resolve(); assert.equal(events.length, 0); finish(); assert.equal(await first, true); assert.equal(await second, true); assert.equal(calls, 1); }); test("in-memory changes never claim server persistence or advance revisions", async () => { const engine = new QuestionnaireEngine(example); const events: SaveEvent[] = []; const recorder = new SaveEventRecorder(engine, event => events.push(event)); await recorder.save(engine.createSession({ fullName: "Alex" }), ["fullName"], "blur", "registration"); await recorder.savePosition("registration", "attending"); assert.ok(events.every(event => event.status === "Changed" && event.data?.revision === 0)); }); test("answer and focus requests have separate payloads and share acknowledged revisions", async () => { const engine = new QuestionnaireEngine(example); const calls: SaveRequest[] = []; const events: SaveEvent[] = []; const recorder = new SaveEventRecorder(engine, event => events.push(event), {}, 4, undefined, request => { calls.push(request); return { revision: request.revision + 1 }; }); await recorder.savePosition("registration", "fullName"); await recorder.save(engine.createSession({ fullName: "Alex" }), ["fullName"], "Question blur", "registration", false, "fullName"); await recorder.savePosition("registration", "attending"); await recorder.savePosition("registration", "attending"); assert.deepEqual(calls, [ { kind: "focus", resume: { nodeId: "registration", questionId: "fullName" }, revision: 4, trigger: "Question focus" }, { kind: "changes", changes: { fullName: { type: "text", value: "Alex" } }, revision: 5, trigger: "Question blur" }, { kind: "focus", resume: { nodeId: "registration", questionId: "attending" }, revision: 6, trigger: "Question focus" }, ]); assert.deepEqual(events.map(event => event.kind), ["focus", "changes", "focus"]); }); test("failed focus after an answer save retries only the unacknowledged position", async () => { const engine = new QuestionnaireEngine(example); const calls: SaveRequest[] = []; const events: SaveEvent[] = []; let failFocus = true; const recorder = new SaveEventRecorder(engine, event => events.push(event), {}, 0, undefined, request => { calls.push(request); if (request.kind === "focus" && failFocus) { failFocus = false; throw new Error("Offline"); } return { revision: request.revision + 1 }; }); const session = engine.createSession({ fullName: "Alex" }); assert.equal(await recorder.save(session, ["fullName"], "Continue", "registration", false, "attending"), false); assert.equal(await recorder.save(session, ["fullName"], "Continue", "registration", false, "attending"), true); assert.deepEqual(calls.map(call => [call.kind, call.revision]), [["changes", 0], ["focus", 1], ["focus", 1]]); assert.deepEqual(events.map(event => [event.kind, event.status]), [["changes", "Saved"], ["focus", "Blocked"], ["focus", "Saved"]]); });