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
91 changes: 91 additions & 0 deletions apps/server/src/plan/card-answer-projection.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import { describe, expect, it } from "bun:test";
import { ulid } from "@chopin/dialect";
import * as room from "./room";
import { questionnaire } from "./card-projections.test-fixtures";

describe("questionnaire answer projection", () => {
it("writes chosen option identifiers beside the answer summary", async () => {
let document = await room.create();
let card = questionnaire(ulid(), ulid(), ulid(), "Auth");
room.insertQuestionnaire(document, card);
let question = card.questions[0]!;

let mutation = room.projectAnswer(
document,
card.id,
{ [question.id]: "Choose this" },
undefined,
{ [question.id]: [question.options[0]!.id] },
);

expect(mutation?.source).toContain(
`<Answer value="Choose this" choices="${question.options[0]!.id}"`,
);
let custom = room.projectAnswer(document, card.id, { [question.id]: "Something else" });
expect(custom?.source).toContain('<Answer value="Something else"');
expect(custom?.source).not.toContain("choices=");
});

it("sets decided status without discarding the previous decision", async () => {
let document = await room.create();
let card = questionnaire(ulid(), ulid(), ulid(), "Auth");
let question = card.questions[0]!;
let previous = { choices: [question.options[0]!.id], by: "ana", at: "2026-09-23" };
room.insertQuestionnaire(document, {
...card,
status: "reopened",
questions: [{ ...question, previous }],
});
let mutation = room.projectAnswer(
document,
card.id,
{ [question.id]: "Choose this" },
{ by: "ben", at: "2026-09-25" },
{ [question.id]: [question.options[0]!.id] },
);
expect(mutation?.source).toContain('status="decided"');
expect(mutation?.source).toContain("<Previous");
expect(mutation?.source).toContain('by="ana"');
});

it("refuses a missing or ambiguous answer target", async () => {
let document = await room.create();
expect(() => room.projectAnswer(document, ulid(), {})).toThrow(/Card is not available/);
let card = questionnaire(ulid(), ulid(), ulid(), "First");
room.insertQuestionnaires(document, [
{ value: card },
{ value: questionnaire(card.id, ulid(), ulid(), "Second") },
]);
let before = room.project(document);
expect(() => room.projectAnswer(document, card.id, {})).toThrow(/Card is not available/);
expect(room.project(document)).toBe(before);
});

it("refuses an answer whose question differs from the card", async () => {
let document = await room.create();
let card = questionnaire(ulid(), ulid(), ulid(), "First");
room.insertQuestionnaire(document, card);
let before = room.project(document);
expect(() => room.projectAnswer(document, card.id, { [ulid()]: "Wrong card" }))
.toThrow(/Card questions do not match/);
expect(room.project(document)).toBe(before);
});

it("refuses duplicate question IDs inside one answer target", async () => {
let document = await room.create();
let card = questionnaire(ulid(), ulid(), ulid(), "First");
let question = card.questions[0]!;
room.insertQuestionnaire(document, {
...card,
questions: [question, {
...question,
header: "Second",
options: [{ ...question.options[0]!, id: ulid() }],
}],
});
let before = room.project(document);
expect(() => room.projectAnswer(document, card.id, { [question.id]: "Once" }))
.toThrow(/Card questions do not match/);
expect(room.project(document)).toBe(before);
});
});
80 changes: 80 additions & 0 deletions apps/server/src/plan/card-lifecycle-projection.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { describe, expect, it } from "bun:test";
import { ulid } from "@chopin/dialect";
import * as room from "./room";
import { questionnaire } from "./card-projections.test-fixtures";

describe("card lifecycle projection", () => {
it("marks a card discarded without removing its answer or provenance", async () => {
let document = await room.create();
let card = questionnaire(ulid(), ulid(), ulid(), "Auth");
let question = card.questions[0]!;
room.insertQuestionnaire(document, card);
room.projectAnswer(document, card.id, { [question.id]: "Choose this" }, {
by: "ana",
at: "2026-09-23T17:30:00.000Z",
}, { [question.id]: [question.options[0]!.id] });

let mutation = room.projectCard(document, card.id, { status: "discarded" });

expect(mutation?.source).toContain('status="discarded"');
expect(mutation?.source).toContain('by="ana"');
expect(mutation?.source).toContain('<Answer value="Choose this"');
expect(mutation?.source).toContain(`choices="${question.options[0]!.id}"`);
expect(mutation?.source.match(/<Questionnaire/g)).toHaveLength(1);
document.doc.destroy();
});

it("refuses missing and ambiguous card targets without changing source", async () => {
let document = await room.create();
let card = questionnaire(ulid(), ulid(), ulid(), "Auth");
room.insertQuestionnaires(document, [
{ value: card },
{ value: questionnaire(card.id, ulid(), ulid(), "Other") },
]);
let before = room.project(document);

expect(() => room.projectCard(document, ulid(), { status: "discarded" }))
.toThrow(/Card is not available/);
expect(() => room.projectCard(document, card.id, { status: "discarded" }))
.toThrow(/Card is not available/);
expect(room.project(document)).toBe(before);
document.doc.destroy();
});

it("can clear an answer and show its previous choice when reopened", async () => {
let document = await room.create();
let card = questionnaire(ulid(), ulid(), ulid(), "Auth");
let question = card.questions[0]!;
let option = question.options[0]!.id;
room.insertQuestionnaire(document, card);
room.projectAnswer(document, card.id, { [question.id]: "Choose this" }, {
by: "ana",
at: "2026-09-23T17:30:00.000Z",
}, { [question.id]: [option] });

let mutation = room.projectCard(document, card.id, {
status: "reopened",
clearAnswers: true,
previous: {
[question.id]: { choices: [option], by: "ana", at: "2026-09-23T17:30:00.000Z" },
},
});

expect(mutation?.source).toContain('status="reopened"');
expect(mutation?.source).toContain(`<Previous choices="${option}" by="ana"`);
expect(mutation?.source).not.toContain("<Answer");
expect(mutation?.source).not.toContain('<Questionnaire id="' + card.id + '" by=');
expect(() =>
room.projectCard(document, card.id, {
status: "reopened",
previous: { missing: null },
})
).toThrow(/Card questions do not match/);
let cleared = room.projectCard(document, card.id, {
status: "open",
previous: { [question.id]: null },
});
expect(cleared?.source).not.toContain("<Previous");
document.doc.destroy();
});
});
51 changes: 51 additions & 0 deletions apps/server/src/plan/card-option-projection.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { describe, expect, it } from "bun:test";
import { ulid } from "@chopin/dialect";
import * as room from "./room";
import { questionnaire } from "./card-projections.test-fixtures";

describe("questionnaire option projection", () => {
it("updates the matching card without changing its prompt or neighboring card", async () => {
let document = await room.create();
let first = questionnaire(ulid(), ulid(), ulid(), "First");
let second = questionnaire(ulid(), ulid(), ulid(), "Second");
let addedId = ulid();
room.insertQuestionnaires(document, [{ value: first }, { value: second }]);
let before = room.project(document);

let mutation = room.projectOptions(document, first.id, {
id: first.questions[0]!.id,
header: "First",
question: "What should First be?",
multiple: false,
options: [
{ id: first.questions[0]!.options[0]!.id, label: "Choose this", description: "" },
{ id: addedId, label: "GitHub Apps", description: "" },
],
});

expect(mutation?.source).toContain('label="GitHub Apps"');
expect(mutation?.source).toContain('prompt="What should First be?"');
expect(mutation?.source.match(/label="GitHub Apps"/g)).toHaveLength(1);
expect(room.project(document)).toContain(`id="${second.id}"`);
expect(room.project(document)).toContain('prompt="What should Second be?"');
expect(before).not.toContain("GitHub Apps");
});

it("refuses a missing card or mismatched question without changing the document", async () => {
let document = await room.create();
let card = questionnaire(ulid(), ulid(), ulid(), "First");
room.insertQuestionnaire(document, card);
let before = room.project(document);
let question = {
id: card.questions[0]!.id,
header: "First",
question: "What should First be?",
multiple: false,
options: [{ id: ulid(), label: "GitHub Apps", description: "" }],
};

expect(() => room.projectOptions(document, ulid(), question)).toThrow();
expect(() => room.projectOptions(document, card.id, { ...question, id: ulid() })).toThrow();
expect(room.project(document)).toBe(before);
});
});
12 changes: 12 additions & 0 deletions apps/server/src/plan/card-projections.test-fixtures.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
export function questionnaire(id: string, question: string, option: string, header: string) {
return {
id,
questions: [{
id: question,
header,
prompt: `What should ${header} be?`,
multiple: false,
options: [{ id: option, label: "Choose this" }],
}],
};
}
152 changes: 152 additions & 0 deletions apps/server/src/plan/card-projections.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
import { $nodesOfType } from "lexical";
import { QuestionnaireNode } from "@chopin/dialect";
import type { Previous } from "@chopin/dialect";
import type * as Question from "@chopin/question";
import type { Document, Mutation } from "./room";

export type CardChange = {
status: "open" | "decided" | "reopened" | "discarded";
clearAnswers?: boolean;
previous?: { [question: string]: Previous | null };
};

export class QuestionnaireProjectionError extends Error {}

export function createCardProjections(
mutate: (target: Document, change: () => boolean) => Mutation | undefined,
) {
function projectAnswer(
target: Document,
id: string,
answers: Record<string, string>,
settled?: { by: string; at: string },
chosen?: Record<string, string[]>,
): Mutation | undefined {
return mutate(target, () => {
let matches = $nodesOfType(QuestionnaireNode).filter(node => node.getId() === id);
if (matches.length !== 1) throw new QuestionnaireProjectionError("Card is not available");
let node = matches[0]!;
let value = node.getQuestionnaire();
let known = new Set(value.questions.map(question => question.id));
if (
known.size !== value.questions.length
|| known.size !== Object.keys(answers).length
|| Object.keys(answers).some(question => !known.has(question))
|| Object.keys(chosen ?? {}).some(question => !known.has(question))
) throw new QuestionnaireProjectionError("Card questions do not match its record");
node.setQuestionnaire({
...value,
// Resolution belongs to the questionnaire, not each answer.
...(settled ? { by: settled.by, at: settled.at, status: "decided" as const } : {}),
questions: value.questions.map(question => {
let answer = answers[question.id];
if (answer === undefined) return question;
let projected = { ...question, answer };
let ids = chosen?.[question.id];
if (ids?.length) projected.choices = ids;
else delete projected.choices;
return projected;
}),
});
return true;
});
}

function projectCard(
target: Document,
id: string,
change: CardChange,
): Mutation | undefined {
return mutate(target, () => {
let matches = $nodesOfType(QuestionnaireNode).filter(node => node.getId() === id);
if (matches.length !== 1) throw new QuestionnaireProjectionError("Card is not available");
let node = matches[0]!;
let value = node.getQuestionnaire();
let known = new Set(value.questions.map(question => question.id));
if (
known.size !== value.questions.length
|| Object.keys(change.previous ?? {}).some(question => !known.has(question))
) throw new QuestionnaireProjectionError("Card questions do not match its record");
let { by: _by, at: _at, ...unsettled } = value;
node.setQuestionnaire({
...(change.clearAnswers ? unsettled : value),
status: change.status,
questions: value.questions.map(question => {
let next = { ...question };
if (change.clearAnswers) {
delete next.answer;
delete next.choices;
}
let previous = change.previous?.[question.id];
if (previous === null) delete next.previous;
else if (previous) next.previous = previous;
return next;
}),
});
return true;
});
}

function hasQuestionnaire(target: Document, id: string, questionId: string): boolean {
return target.editor.getEditorState().read(() => {
let matches = $nodesOfType(QuestionnaireNode).filter(node => node.getId() === id);
if (matches.length !== 1) return false;
let questions = matches[0]!.getQuestionnaire().questions;
return questions.length === 1 && questions[0]?.id === questionId;
});
}

function projectOptions(
target: Document,
id: string,
question: Question.Item,
): Mutation | undefined {
return mutate(target, () => {
let matches = $nodesOfType(QuestionnaireNode).filter(node => node.getId() === id);
if (matches.length !== 1) throw new QuestionnaireProjectionError("Card is not available");
let node = matches[0]!;
let value = node.getQuestionnaire();
if (value.questions.filter(item => item.id === question.id).length !== 1) {
throw new QuestionnaireProjectionError("Card question does not match its record");
}
node.setQuestionnaire({
...value,
questions: value.questions.map(item =>
item.id !== question.id ? item : {
...item,
options: question.options.map(option => ({
id: option.id,
label: option.label,
...(option.description ? { description: option.description } : {}),
})),
}
),
});
return true;
});
}

function projectPrompt(
target: Document,
id: string,
questionId: string,
prompt: string,
): Mutation | undefined {
return mutate(target, () => {
let matches = $nodesOfType(QuestionnaireNode).filter(node => node.getId() === id);
if (matches.length !== 1) throw new QuestionnaireProjectionError("Card is not available");
let node = matches[0]!;
let value = node.getQuestionnaire();
if (value.questions.length !== 1 || value.questions[0]?.id !== questionId) {
throw new QuestionnaireProjectionError("Card question does not match its record");
}
node.setQuestionnaire({
...value,
questions: [{ ...value.questions[0]!, prompt }],
});
return true;
});
}

return { projectAnswer, projectCard, hasQuestionnaire, projectOptions, projectPrompt };
}
Loading
Loading