import { afterEach, describe, expect, test } from "bun:test";
import { $createParagraphNode, $getRoot } from "lexical";
import * as room from "../plan/room";
import * as Service from "../plan/service";
import { openPlan } from "../testing/plan";
import { normalizeRecord } from "./service";
import { proseOperation, validateProse } from "./write-prose";
const CARD = "01K0N4TR8K7JGM4R1J7PW4R8YJ";
const QUESTION = "01K0N4V4E7Y6P4MJ5WD8XZF3B2";
const OPTION = "01K0N4W3B7P27CBAEC7A8C8WEA";
const WIDGET = `
`;
let opened: Service.Plan[] = [];
afterEach(async () => {
for (let plan of opened) await Service.close(plan);
opened = [];
});
async function decided(source = `Context.\n\n${WIDGET}`, fields: {
status?: "answered" | "open";
origin?: "conversation" | "planner";
proseAt?: number;
orphaned?: boolean;
} = {}) {
let { plan } = await openPlan(source);
opened.push(plan);
let hashes = room.digests(plan.document);
let prose = fields.proseAt !== undefined
? [room.anchorAt(plan.document, fields.proseAt, hashes[fields.proseAt]!)]
: fields.orphaned
? [{ ...room.anchorAt(plan.document, 0, hashes[0]!), orphaned: true }]
: undefined;
plan.records.set(
CARD,
normalizeRecord({
id: CARD,
definition: {
questions: [{
id: QUESTION,
header: "Auth",
question: "Which authentication approach?",
multiple: false,
options: [{ id: OPTION, label: "GitHub Apps", description: "" }],
}],
},
status: fields.status ?? "answered",
origin: fields.origin ?? "conversation",
threadId: "t1",
owner: "mina",
decidedAt: 1_758_645_000,
choices: [OPTION],
...(prose ? { prose } : {}),
}),
);
return plan;
}
describe("validateProse", () => {
test("accepts one ordinary paragraph with inline formatting", () => {
expect(validateProse(" We chose **GitHub Apps** with `org` access. ")).toEqual({
ok: true,
text: "We chose **GitHub Apps** with `org` access.",
});
});
test("refuses empty, oversized, multiblock, and non-prose constructs", () => {
for (
let value of [
"",
"x".repeat(601),
"One.\n\nTwo.",
"# Heading",
"- List",
"Hi",
"Text ",
"",
]
) {
expect(validateProse(value).ok).toBe(false);
}
});
});
describe("proseOperation", () => {
test("inserts before the first decision in canonical coordinates", async () => {
let plan = await decided(WIDGET);
expect(room.questionnaireIndex(plan.document, CARD)).toBe(0);
expect(proseOperation(plan, CARD, "We chose GitHub Apps.")).toEqual({
ok: true,
mode: "insert",
index: 0,
steps: [
[{ op: "insert", index: 0, source: "We chose GitHub Apps.\n" }],
[{ op: "move", index: 0, to: 1 }],
],
});
});
test("ignores leading and inter-card caret paragraphs when addressing cards", async () => {
let plan = await decided();
plan.document.editor.update(() => {
let children = $getRoot().getChildren();
children[0]!.insertBefore($createParagraphNode());
children.at(-1)!.insertBefore($createParagraphNode());
}, { discrete: true });
await room.settle();
expect(room.digests(plan.document)).toHaveLength(2);
expect(room.questionnaireIndex(plan.document, CARD)).toBe(1);
expect(proseOperation(plan, CARD, "Decision prose.")).toEqual({
ok: true,
mode: "insert",
index: 1,
steps: [[{ op: "insert", index: 0, source: "Decision prose.\n" }]],
});
});
test("replaces only the paragraph named by a live anchor beside identical text", async () => {
let plan = await decided(`Same.\n\nSame.\n\n${WIDGET}`, { proseAt: 1 });
expect(proseOperation(plan, CARD, "New prose.")).toEqual({
ok: true,
mode: "replace",
index: 1,
steps: [[{ op: "replace", index: 1, source: "New prose.\n" }]],
});
});
test("inserts when prose is orphaned, but refuses an unresolved live anchor", async () => {
let orphan = await decided(undefined, { orphaned: true });
expect(proseOperation(orphan, CARD, "New prose.")).toMatchObject({
ok: true,
mode: "insert",
});
let live = await decided(`Old prose.\n\n${WIDGET}`, { proseAt: 0 });
live.records.get(CARD)!.prose![0]!.epoch = "different-epoch";
expect(proseOperation(live, CARD, "New prose.").ok).toBe(false);
});
test("refuses open and Planner-owned cards", async () => {
let open = await decided(undefined, { status: "open" });
let planner = await decided(undefined, { origin: "planner" });
expect(proseOperation(open, CARD, "Text.").ok).toBe(false);
expect(proseOperation(planner, CARD, "Text.").ok).toBe(false);
});
});