| 1 | import assert from "node:assert/strict"; |
| 2 | import React, { act } from "react"; |
| 3 | import { createRoot } from "react-dom/client"; |
| 4 | import { installDom, installBridgeApp } from "./composerInboxHarness"; |
| 5 | import { useInboxQueueEditor } from "../lib/useInboxQueueEditor"; |
| 6 | import { inboxQueueDrafts } from "../lib/inboxQueueDrafts"; |
| 7 | import { queueActionAnchor, queueMoveAnchor, type InboxQueueRequest } from "../lib/inboxQueueCommands"; |
| 8 | import { createInboxQueuePreview } from "../lib/inboxQueuePreview"; |
| 9 | import type { InboxTarget } from "../lib/pendingFollowup"; |
| 10 | |
| 11 | const dom = installDom(); |
| 12 | globalThis.sessionStorage = dom.window.sessionStorage; |
| 13 | const target: InboxTarget = { tabId: "tab-a", sessionPath: "session-a", generation: 7, selection: 3, remote: true }; |
| 14 | const fixture = createInboxQueuePreview(); |
| 15 | const requests: InboxQueueRequest[] = []; |
| 16 | let loseSaveReply = false; |
| 17 | installBridgeApp({ |
| 18 | CaptureInboxTarget: async () => target, |
| 19 | InboxQueueForTarget: async (captured: InboxTarget, request: InboxQueueRequest) => { |
| 20 | assert.deepEqual(captured, target); |
| 21 | requests.push(request); |
| 22 | const result = await fixture.command(captured, request); |
| 23 | if (request.kind === "edit" && loseSaveReply) throw new Error("reply lost after commit"); |
| 24 | return result; |
| 25 | }, |
| 26 | }); |
| 27 | let editor!: ReturnType<typeof useInboxQueueEditor>; |
| 28 | function Harness({ scope }: { scope: string }) { |
| 29 | editor = useInboxQueueEditor(scope, target.tabId, target.sessionPath, () => {}, () => {}); |
| 30 | return <p>{editor.drafts.edit?.value}</p>; |
| 31 | } |
| 32 | const root = createRoot(document.getElementById("root")!); |
| 33 | const render = (scope: string) => act(async () => { root.render(<Harness key={scope} scope={scope} />); }); |
| 34 | await render("queue-test-a"); |
| 35 | await act(async () => { await editor.edit("queue-layout"); }); |
| 36 | await act(async () => { editor.close(); }); |
| 37 | assert.equal(editor.drafts.edit, undefined, "opening and cancelling without changes does not create a retained draft"); |
| 38 | assert.equal(editor.open, false); |
| 39 | await act(async () => { await editor.edit("queue-save"); }); |
| 40 | const original = editor.drafts.edit!; |
| 41 | assert.ok(original.value.includes("\n完成后验证"), "read includes the full multiline body"); |
| 42 | assert.equal(original.references.length, 2); |
| 43 | const longText = " 保留空白\n" + "完整正文".repeat(200) + "\n结束 "; |
| 44 | await act(async () => { editor.update(longText); editor.close(); }); |
| 45 | await render("queue-test-b"); |
| 46 | assert.equal(editor.drafts.edit, undefined, "draft is scoped to its original session"); |
| 47 | await render("queue-test-a"); |
| 48 | assert.equal(editor.drafts.edit?.value, longText, "remount retains unsaved input"); |
| 49 | await act(async () => { await editor.mutate({ kind: "pause", paused: true }); await editor.save(); }); |
| 50 | assert.equal(editor.drafts.edit, undefined); |
| 51 | assert.equal(fixture.snapshot().paused, true, "saving does not resume the queue"); |
| 52 | assert.deepEqual(fixture.snapshot().items.map(item => item.id), ["queue-layout", "queue-save", "queue-tests"]); |
| 53 | assert.equal(fixture.snapshot().items[1].preview, longText); |
| 54 | |
| 55 | await act(async () => { await editor.edit("queue-save"); editor.update("my conflicting edits"); }); |
| 56 | await fixture.command(target, { kind: "edit", itemId: "queue-save", text: "another window", contentVersion: editor.drafts.edit!.contentVersion }); |
| 57 | await act(async () => { await editor.save(); }); |
| 58 | assert.equal(editor.drafts.edit?.value, "my conflicting edits"); |
| 59 | assert.equal(editor.drafts.edit?.reason, "content_changed"); |
| 60 | await act(async () => { await editor.reload(); }); |
| 61 | assert.equal(editor.drafts.edit?.value, "another window"); |
| 62 | assert.equal(editor.drafts.recovered.at(-1)?.value, "my conflicting edits"); |
| 63 | |
| 64 | await act(async () => { editor.update("save committed but reply lost"); }); |
| 65 | loseSaveReply = true; |
| 66 | const before = requests.length; |
| 67 | await act(async () => { await editor.save(); }); |
| 68 | assert.deepEqual(requests.slice(before).map(r => r.kind), ["edit", "snapshot"], "ambiguous save reconciles once without replay"); |
| 69 | assert.equal(editor.drafts.edit?.reason, "unconfirmed"); |
| 70 | assert.equal(editor.drafts.edit?.value, "save committed but reply lost"); |
| 71 | let restored: string | undefined; |
| 72 | await act(async () => { restored = editor.recover(); }); |
| 73 | assert.equal(restored, "save committed but reply lost"); |
| 74 | assert.equal(editor.drafts.edit, undefined); |
| 75 | assert.equal(editor.drafts.recovered.at(-1)?.value, restored); |
| 76 | |
| 77 | // Pointer and keyboard actions share exactly the same stable anchor contract. |
| 78 | assert.equal(queueMoveAnchor(["a", "b", "c"], "a", "b"), "c"); |
| 79 | assert.equal(queueActionAnchor(["a", "b", "c"], "a", "down"), "c"); |
| 80 | assert.equal(queueMoveAnchor(["a", "b", "c"], "a", "c"), null); |
| 81 | assert.equal(queueActionAnchor(["a", "b", "c"], "c", "first"), "a"); |
| 82 | assert.equal(queueMoveAnchor(["a", "b", "c"], "a", "missing"), undefined); |
| 83 | await act(async () => { inboxQueueDrafts.set("queue-test-a", { recovered: [] }); root.unmount(); }); |
| 84 | dom.window.close(); |
| 85 | console.log("PASS queue editor: full body, remount, pause, conflict, recovery and ambiguous save"); |
| 86 |