import { test } from "node:test"; import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; import { QuestionnaireEngine, type Answers, type QuestionnaireDefinition, type QuestionDefinition } from "../src/lib/questionnaire/questionnaire-engine"; import { compileExpression, evaluateExpression } from "../src/lib/questionnaire/dsl"; import { parseResumeDraft } from "../src/lib/questionnaire/resume-draft"; import { SaveEventRecorder, type SaveEvent } from "../src/lib/persistence/save-events"; const claim = JSON.parse(readFileSync(new URL("../src/demo/claim-questionnaire.json", import.meta.url), "utf8")); const example: QuestionnaireDefinition = JSON.parse(readFileSync(new URL("../src/demo/example-questionnaire.json", import.meta.url), "utf8")); const today = { today: "2026-09-12" }; const basic = (fields: QuestionDefinition[]): QuestionnaireDefinition => ({ schemaVersion: 1, id: "test", version: "1", title: "Test", pages: [{ id: "first", title: "First", fields }] }); const bool = (id: string): QuestionDefinition => ({ id, type: "boolean", label: id }); const text = (id: string): QuestionDefinition => ({ id, type: "text", label: id }); const evaluate = (source: string) => evaluateExpression(compileExpression(source), { functions: {} }); test("migrated claim and documented example compile", () => { assert.equal(new QuestionnaireEngine(claim).definition.pages.length, 9); assert.equal(new QuestionnaireEngine(example).definition.pages.length, 2); }); test("schema rejects misspelled properties with useful paths", () => { const definition = basic([text("name")]) as any; definition.pages[0].fields[0].requried = true; assert.throws(() => new QuestionnaireEngine(definition), /\/pages\/0\/fields\/0.*requried/); }); test("schema enforces type-specific options, widgets and requiredness", () => { for (const field of [ { ...text("x"), constraints: { maxFiles: 1 } }, { ...text("x"), required: true, requiredWhen: "true" }, { id: "x", type: "select", label: "X" }, { ...text("x"), ui: { widget: "checkbox" } } ]) assert.throws(() => new QuestionnaireEngine(basic([field as QuestionDefinition])), /Invalid questionnaire/); }); test("duplicate IDs, duplicate options and inconsistent bounds fail compilation", () => { assert.throws(() => new QuestionnaireEngine(basic([text("x"), text("x")])), /Duplicate field/); assert.throws(() => new QuestionnaireEngine(basic([{ id: "x", type: "select", label: "X", options: [{ value: "a", label: "A" }, { value: "a", label: "B" }] }])), /unique/); assert.throws(() => new QuestionnaireEngine(basic([{ id: "x", type: "number", label: "X", constraints: { min: 5, max: 2 } }])), /must not exceed/); }); test("references, helper arity, argument types, conditions and value scope are checked", () => { for (const [when, message] of [ ["answer('missing') === true", /Unknown field/], ["derived('missing')", /Unknown derived/], ["today(1) === 'x'", /expects 0 arguments/], ["length(42) > 1", /argument 1/], ["123", /boolean/], ["value === true", /only available/] ] as const) assert.throws(() => new QuestionnaireEngine(basic([{ ...text("x"), when }])), message); }); test("dependency checks include page, group, effective-answer and derived cycles", () => { const self = basic([{ ...bool("x"), when: "answer('x') === true" }]); assert.throws(() => new QuestionnaireEngine(self), /Circular dependency/); const page = basic([bool("x")]); page.pages[0].when = "answer('x') === true"; assert.throws(() => new QuestionnaireEngine(page), /Circular dependency/); const derived = basic([text("x")]); derived.derived = { a: "derived('b')", b: "derived('a')" }; assert.throws(() => new QuestionnaireEngine(derived), /Circular dependency/); const group = basic([text("x")]); group.pages[0].fields = [{ type: "group", when: "answer('x') !== null", fields: [text("x")] }]; assert.throws(() => new QuestionnaireEngine(group), /Circular dependency/); }); test("ordered pages skip conditions and progress reflects the current journey", () => { const engine = new QuestionnaireEngine(example); const declined = engine.createSession({ attending: false }, today); assert.deepEqual(declined.buildJourney(), ["registration"]); assert.equal(declined.getPageState("registration").progress, 100); const attending = engine.createSession({ attending: true }, today); assert.deepEqual(attending.buildJourney(), ["registration", "preferences"]); assert.equal(attending.getPageState("preferences").previous, "registration"); }); test("page and nested group applicability masks answers while drafts retain them", () => { const engine = new QuestionnaireEngine(example); const draft = { attending: false, supportRequired: true, supportDetails: "Ramp" }; const hidden = engine.createSession(draft, today); assert.equal(hidden.getAnswer("supportRequired"), null); assert.equal(hidden.getDerived("needsSupport"), false); assert.equal(hidden.getOutcomes().arrangeSupport, false); assert.equal(hidden.answers.supportDetails, "Ramp"); assert.ok(!Object.hasOwn(hidden.getEffectiveAnswers(), "supportDetails")); const restored = engine.createSession({ ...draft, attending: true }, today); assert.equal(restored.getAnswer("supportDetails"), "Ramp"); assert.equal(engine.createSession({ ...draft, attending: true, supportRequired: false }, today).getAnswer("supportDetails"), null); }); test("switching injury to illness removes stale injury evidence and outcomes", () => { const engine = new QuestionnaireEngine(claim); const answers = { claimType: "ILLNESS", injuryCause: "MOTOR_VEHICLE", motorVehicleAccident: true, workRelated: true, workersCompClaim: true }; const session = engine.createSession(answers, today); assert.equal(session.isQuestionVisible("policeReport"), false); assert.equal(session.isQuestionVisible("workersCompDocuments"), false); assert.equal(session.getOutcomes().potentialWorkersComp, false); assert.equal(session.getOutcomes().potentialThirdPartyRecovery, false); assert.equal(session.answers.injuryCause, "MOTOR_VEHICLE"); }); test("required false is a valid boolean answer; mustBeTrue requires acceptance", () => { const engine = new QuestionnaireEngine(basic([{ ...bool("answer"), required: true }, { ...bool("consent"), constraints: { mustBeTrue: true } }])); const session = engine.createSession({ answer: false, consent: false }, today); assert.equal(session.validateQuestion("answer").length, 0); assert.equal(session.validateQuestion("consent").length, 1); assert.equal(engine.createSession({}, today).validateQuestion("consent").length, 1); assert.equal(session.isQuestionRequired("consent"), true); }); test("whitespace, invalid dates, nonfinite numbers and invalid options are handled centrally", () => { const engine = new QuestionnaireEngine(basic([ { ...text("name"), required: true }, { id: "date", type: "date", label: "Date" }, { id: "number", type: "number", label: "Number" }, { id: "choices", type: "multiselect", label: "Choices", options: [{ value: "a", label: "A" }] } ])); const session = engine.createSession({ name: " ", date: "2026-02-30", number: Infinity, choices: ["a", "a"] }, today); assert.equal(session.validateJourney().length, 4); assert.equal(session.getAnswer("date"), null); assert.equal(session.getAnswer("name"), null); assert.equal(engine.createSession({ name: [], number: "" }, today).validateJourney().length, 2); }); test("constraints enforce text, numbers and file limits", () => { const engine = new QuestionnaireEngine(basic([ { ...text("name"), constraints: { minLength: 3, maxLength: 5 } }, { id: "hours", type: "number", label: "Hours", constraints: { min: 0, max: 10, integer: true } }, { id: "files", type: "files", label: "Files", constraints: { maxFiles: 1, maxFileSizeMb: 1, allowedExtensions: ["pdf"] } } ])); const session = engine.createSession({ name: " a ", hours: 12.5, files: [{ name: "bad.exe", size: 2 * 1024 * 1024, type: "application/octet-stream" }, { name: "ok.pdf", size: 10, type: "application/pdf" }] }, today); assert.equal(session.validateQuestion("name").length, 1); assert.equal(session.validateQuestion("hours").length, 2); assert.equal(session.validateQuestion("files").length, 3); }); test("forward routes use first matching case and required fallback", () => { const definition = basic([bool("skip")]); definition.pages.push({ id: "second", title: "Second", fields: [text("secondAnswer")] }, { id: "third", title: "Third", fields: [text("thirdAnswer")] }); definition.pages[0].route = { cases: [{ when: "answer('skip') === true", to: "third" }, { when: "true", to: "second" }], otherwise: "END" }; const engine = new QuestionnaireEngine(definition); assert.deepEqual(engine.createSession({ skip: true }, today).buildJourney(), ["first", "third"]); assert.deepEqual(engine.createSession({ skip: false }, today).buildJourney(), ["first", "second", "third"]); definition.pages[0].route.otherwise = "first"; assert.throws(() => new QuestionnaireEngine(definition), /later page/); }); test("routes cannot depend on the applicability of later answers", () => { const definition = basic([text("firstAnswer")]); definition.pages.push({ id: "second", title: "Second", fields: [bool("later")] }); definition.pages[0].route = { cases: [{ when: "answer('later') === true", to: "END" }], otherwise: "second" }; assert.throws(() => new QuestionnaireEngine(definition), /Circular dependency/); }); test("null semantics, precedence, short circuiting and parser bounds", () => { assert.equal(evaluate("null >= 0"), false); assert.equal(evaluate("null + 1"), null); assert.equal(evaluate("1 + 2 * 3"), 7); assert.equal(evaluate("false && (1 / 0 > 0)"), false); assert.equal(evaluate("null ?? 7"), 7); assert.throws(() => evaluate("1 / 0"), /zero/); assert.throws(() => compileExpression("(".repeat(65) + "1" + ")".repeat(65)), /nesting/); assert.throws(() => compileExpression("1".repeat(4097)), /4096/); assert.throws(() => compileExpression("answer('x').constructor"), /Unexpected character/); }); test("session date is injectable; month arithmetic clamps and invalid calendar dates fail", () => { const session = new QuestionnaireEngine(example).createSession({}, today); assert.equal(session.evaluate("today()"), "2026-09-12"); assert.equal(session.evaluate("addMonths('2024-01-31', 1)"), "2024-02-29"); assert.equal(session.evaluate("addYears('2024-02-29', 1)"), "2025-02-28"); assert.throws(() => session.evaluate("age('2026-02-30', today())"), /Expected ISO/); }); test("snapshots protect caches from subsequent mutation", () => { const engine = new QuestionnaireEngine(example); const answers = { attending: true, supportRequired: true }; const session = engine.createSession(answers, today); answers.attending = false; assert.equal(session.getDerived("needsSupport"), true); assert.throws(() => { session.answers.attending = false; }, TypeError); assert.throws(() => { engine.definition.pages[0].title = "mutated"; }, TypeError); }); test("draft resume keeps inactive values and redirects unreachable or invalid positions", () => { const draft = parseResumeDraft(JSON.stringify({ answers: { fullName: "Alex", attending: false, supportDetails: "Ramp" }, resume: { nodeId: "preferences" } }), example); assert.equal(draft.nodeId, "registration"); assert.equal(draft.answers.supportDetails, "Ramp"); assert.throws(() => parseResumeDraft(JSON.stringify({ answers: { attending: "yes" } }), example), /valid boolean/); }); test("draft saves clear inactive answers and submit only effective answers", async () => { const engine = new QuestionnaireEngine(example); const events: SaveEvent[] = []; const recorder = new SaveEventRecorder(engine, event => events.push(event), { attending: true, supportRequired: true, supportDetails: "Ramp" }); const session = engine.createSession({ fullName: "Alex", attending: false, supportRequired: true, supportDetails: "Ramp" }, today); await recorder.save(session, ["attending"], "blur", "registration"); const patch = events.at(-1)!.data! as { changes: Answers }; assert.deepEqual(patch.changes, { attending: false, supportRequired: null, supportDetails: null }); assert.deepEqual(session.getEffectiveAnswers(), { fullName: "Alex", attending: false }); }); test("employment changes clear occupation from live answers and draft patches", async () => { const engine = new QuestionnaireEngine(claim); const initial = { employmentStatus: "FULL_TIME", occupation: "Engineer" }; const updated = engine.createSession({ ...initial, employmentStatus: "UNEMPLOYED" }, today).getAnswersWithInactiveCleared(); assert.deepEqual(updated, { employmentStatus: "UNEMPLOYED", occupation: null }); const events: SaveEvent[] = []; const recorder = new SaveEventRecorder(engine, event => events.push(event), initial); await recorder.save(engine.createSession(updated, today), ["employmentStatus"], "Question blur", "claimantDetails"); assert.deepEqual((events.at(-1)!.data! as { changes: Answers }).changes, { ...updated, unableToWork: null }); const restored = engine.createSession({ ...updated, employmentStatus: "FULL_TIME" }, today); assert.equal(restored.answers.occupation, null); assert.ok(restored.validateQuestion("occupation").some(issue => issue.severity === "ERROR")); await recorder.save(restored, ["employmentStatus"], "Question blur", "claimantDetails"); assert.deepEqual((events.at(-1)!.data! as { changes: Answers }).changes, { employmentStatus: "FULL_TIME" }); }); test("newly hidden occupation is explicitly reset even without a previously saved value", async () => { const engine = new QuestionnaireEngine(claim); for (const occupation of [undefined, null]) { const events: SaveEvent[] = []; const initial: Answers = { employmentStatus: "FULL_TIME" }; if (occupation === null) initial.occupation = null; const recorder = new SaveEventRecorder(engine, event => events.push(event), initial); const session = engine.createSession({ employmentStatus: "UNEMPLOYED", occupation: null }, today); await recorder.save(session, ["employmentStatus"], "Question blur", "claimantDetails"); const changes = (events.at(-1)!.data! as { changes: Answers }).changes; assert.equal(changes.employmentStatus, "UNEMPLOYED"); assert.equal(changes.occupation, null); } }); test("invalid file selections do not simulate upload or patch requests", async () => { const engine = new QuestionnaireEngine(basic([{ id: "files", type: "files", label: "Files", constraints: { maxFiles: 1 } }])); const files = [{ name: "a.pdf", size: 1, type: "application/pdf" }, { name: "b.pdf", size: 1, type: "application/pdf" }]; const events: SaveEvent[] = []; await new SaveEventRecorder(engine, event => events.push(event)).filesSelected("files", files, "first", engine.createSession({ files }, today)); assert.equal(events.length, 1); assert.equal(events[0].status, "Blocked"); assert.equal(events[0].data, undefined); }); for (const claimType of ["INJURY", "ILLNESS", "TPD", "INCOME_PROTECTION"]) test(`complete ${claimType} journey validates`, () => { const session = new QuestionnaireEngine(claim).createSession({ dateOfBirth: "1980-01-01", employmentStatus: "FULL_TIME", occupation: "Software developer", claimType, incidentDate: "2026-08-01", diagnosisDate: "2026-08-01", conditionDescription: "A sufficiently detailed description of the condition for testing.", bodyAreas: ["BACK"], injuryCause: "FALL", workRelated: false, thirdPartyInvolved: false, conditionCategory: ["MUSCULOSKELETAL"], receivedMedicalTreatment: false, unableToWork: false, modifiedDuties: false, previousClaim: false, medicalDocuments: [{ uploadId: "upload-1", name: "report.pdf", size: 100, type: "application/pdf" }], informationCorrect: true, medicalConsent: true }, today); assert.deepEqual(session.validateJourney(), []); assert.equal(session.getNextNode("declaration"), "END"); });