import { afterEach, expect, spyOn, test } from "bun:test"; import { QuestionnaireNode } from "@chopin/dialect"; import { $nodesOfType } from "lexical"; import { toolbox } from "./heading-tool.test-bridge"; import * as room from "../plan/room"; import * as Service from "../plan/service"; import { openPlan } from "../testing/plan"; import type { ConversationPlan } from "@chopin/protocol"; const CARD = ` `; let contexts: Awaited>[] = []; afterEach(async () => { for (let context of contexts) await Service.close(context.plan); contexts = []; }); async function opened(source: string) { let context = await openPlan(source); contexts.push(context); return context; } function headingJob(plan: Awaited>) { plan.chat.job = { id: "heading:document:m0", kind: "heading", target: "document", trigger: "m0", status: "running", attempts: 0, at: "2026-09-25T10:00:00.000Z", }; } function headingTool(context: Awaited>, exclusive = Service.exclusive) { let found = toolbox({ plan: context.plan, server: context.server, room: "test", persist: () => Service.persist(context.plan), exclusive: action => exclusive(context.plan, action), async publish() {}, anchors() {}, changes() {}, }).find(item => item.name === "draft_heading"); if (!found?.handler) throw new Error("draft_heading is missing"); return { handler: found.handler }; } function cardKey(context: Awaited>): string { let key = ""; context.plan.document.editor.getEditorState().read(() => { key = $nodesOfType(QuestionnaireNode)[0]?.getKey() ?? ""; }); return key; } const INPUT = { revision: 0, title: "Authentication Strategy", goal: "Goal: Decide how people sign in.", }; test("draft_heading titles an empty document in one durable update", async () => { let context = await opened(""); headingJob(context.plan); let result = await headingTool(context).handler(INPUT, {} as never); expect(JSON.parse(String(result))).toEqual({ ok: true, revision: 1, title: INPUT.title, goal: INPUT.goal, }); expect(room.project(context.plan.document)).toBe(`# ${INPUT.title}\n\n${INPUT.goal}\n`); expect(context.plan.document.seq).toBe(1); expect(context.broadcasts.filter(frame => frame.kind === "plan:update")).toHaveLength(1); }); test("draft_heading commits one heading and paragraph before the existing card", async () => { let context = await opened(CARD); headingJob(context.plan); let before = room.project(context.plan.document); let key = cardKey(context); let threads = context.plan.threads; let result = await headingTool(context).handler(INPUT, {} as never); expect(JSON.parse(String(result))).toEqual({ ok: true, revision: 1, title: INPUT.title, goal: INPUT.goal, }); let expected = `# ${INPUT.title}\n\n${INPUT.goal}\n\n${before}`; expect(room.project(context.plan.document)).toBe(expected); expect(cardKey(context)).toBe(key); expect(context.plan.threads).not.toBe(threads); expect(context.plan.document.seq).toBe(1); expect(context.broadcasts.filter(frame => frame.kind === "plan:update")).toHaveLength(1); expect(JSON.parse(context.plan.chat.jobOutput!)).toEqual({ title: INPUT.title, goal: INPUT.goal, }); let loaded = (await context.storage.collaboration.load(context.channel.id, context.now))!; expect((await Service.readStored(loaded)).source).toBe(expected); }); test("draft_heading replaces only the empty H1 template before cards", async () => { let context = await opened(`# \n\n${CARD}`); headingJob(context.plan); let before = room.project(context.plan.document); expect(before.startsWith("#\n\n")).toBe(true); let key = cardKey(context); let result = await headingTool(context).handler(INPUT, {} as never); expect(JSON.parse(String(result)).ok).toBe(true); expect(room.project(context.plan.document)).toBe( `# ${INPUT.title}\n\n${INPUT.goal}\n\n${before.slice("#\n\n".length)}`, ); expect(cardKey(context)).toBe(key); }); test("draft_heading refuses human prose and leaves the document untouched", async () => { for (let source of ["Somebody wrote this.\n", "# Human heading\n\nA human goal.\n"]) { let context = await opened(source); headingJob(context.plan); let before = room.project(context.plan.document); let result = await headingTool(context).handler(INPUT, {} as never); expect(String(result)).toBe( "Error: The document already has prose. A background job only titles an empty document.", ); expect(room.project(context.plan.document)).toBe(before); expect(context.plan.document.seq).toBe(0); expect(context.plan.chat.jobOutput).toBeUndefined(); } }); test("draft_heading rejects missing, wrong, stale, and replaced job scope", async () => { let context = await opened(""); let missing = await headingTool(context).handler(INPUT, {} as never); expect(String(missing)).toContain("only available to a background Planner job"); headingJob(context.plan); context.plan.chat.job = { ...context.plan.chat.job!, target: "other" } as ConversationPlan.Job; expect(String(await headingTool(context).handler(INPUT, {} as never))).toContain( "heading job target is not the document", ); headingJob(context.plan); expect(String(await headingTool(context).handler({ ...INPUT, revision: 99 }, {} as never))) .toContain("stale; read_plan again"); let entered = Promise.withResolvers(); let release = Promise.withResolvers(); let delayed = headingTool(context, (_plan, action) => { entered.resolve(); return release.promise.then(action); }); let result = delayed.handler(INPUT, {} as never); await entered.promise; context.plan.chat.job = { ...context.plan.chat.job!, id: "heading:document:m1" }; release.resolve(); expect(String(await result)).toContain("background Planner job changed"); expect(room.project(context.plan.document)).toBe(""); expect(context.plan.chat.jobOutput).toBeUndefined(); }); test("draft_heading rejects lengths, CR titles, blank lines, and non-paragraph goals", async () => { let context = await opened(""); headingJob(context.plan); let errors = spyOn(console, "error").mockImplementation(() => {}); try { for ( let input of [ { ...INPUT, title: "x".repeat(81) }, { ...INPUT, title: "First\rSecond" }, { ...INPUT, title: "# Already a heading" }, { ...INPUT, extra: "surprise" }, { ...INPUT, goal: "x".repeat(401) }, { ...INPUT, goal: "First\r\n\r\nSecond" }, { ...INPUT, goal: "- a list item" }, { ...INPUT, goal: "```ts\ncode\n```" }, { ...INPUT, goal: "" }, ] ) { expect(String(await headingTool(context).handler(input, {} as never))).toStartWith("Error:"); expect(room.project(context.plan.document)).toBe(""); } } finally { errors.mockRestore(); } expect(context.plan.chat.jobOutput).toBeUndefined(); }); test("failed heading persistence leaves no live edit, publication, or job output", async () => { let context = await opened(CARD); headingJob(context.plan); let before = room.project(context.plan.document); let outlines = new Map(context.plan.outlines); let threads = context.plan.threads; let original = context.storage.collaboration.commit; context.storage.collaboration.commit = async () => { throw new Error("storage unavailable"); }; context.backend.fatal = () => {}; let errors = spyOn(console, "error").mockImplementation(() => {}); try { expect(String(await headingTool(context).handler(INPUT, {} as never))) .toBe("Error: storage unavailable"); } finally { errors.mockRestore(); context.storage.collaboration.commit = original; } expect(room.project(context.plan.document)).toBe(before); expect(context.plan.revision).toBe(0); expect(context.plan.document.seq).toBe(0); expect(context.plan.outlines).toEqual(outlines); expect(context.plan.threads).toBe(threads); expect(context.plan.chat.jobOutput).toBeUndefined(); expect(context.broadcasts.filter(frame => frame.kind === "plan:update")).toHaveLength(0); let loaded = (await context.storage.collaboration.load(context.channel.id, context.now))!; expect((await Service.readStored(loaded)).source).toBe(before); });