Initial QuestionGraph library and documentation
This commit is contained in:
@@ -0,0 +1,232 @@
|
||||
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");
|
||||
});
|
||||
Reference in New Issue
Block a user