From 82f3e61c103da6e8a9065697c1eeebc4afc8dc97 Mon Sep 17 00:00:00 2001 From: murashit Date: Fri, 29 May 2026 17:53:55 +0900 Subject: [PATCH] Add archive and fork action regression tests --- tests/features/chat/thread-actions.test.ts | 107 +++++++++++++++++++++ tests/main.test.ts | 5 +- 2 files changed, 111 insertions(+), 1 deletion(-) create mode 100644 tests/features/chat/thread-actions.test.ts diff --git a/tests/features/chat/thread-actions.test.ts b/tests/features/chat/thread-actions.test.ts new file mode 100644 index 00000000..c1fe7ba9 --- /dev/null +++ b/tests/features/chat/thread-actions.test.ts @@ -0,0 +1,107 @@ +import { describe, expect, it, vi } from "vitest"; + +import type { AppServerClient } from "../../../src/app-server/client"; +import { createChatState, createChatStateStore } from "../../../src/features/chat/chat-state"; +import { ChatThreadActionController, type ChatThreadActionControllerHost } from "../../../src/features/chat/thread-actions"; +import type { DisplayItem } from "../../../src/features/chat/display/types"; +import { DEFAULT_SETTINGS } from "../../../src/settings/model"; + +describe("ChatThreadActionController", () => { + it("forks from a selected turn by dropping later turns on the fork", async () => { + const client = clientMock(); + const host = hostMock({ client, displayItems: turnItems() }); + const controller = new ChatThreadActionController(host); + + await controller.forkThreadFromTurn("source", "turn-1", false); + + expect(client.forkThread).toHaveBeenCalledWith("source", "/vault"); + expect(client.rollbackThread).toHaveBeenCalledWith("forked", 2); + expect(host.openThreadInNewView).toHaveBeenCalledWith("forked"); + expect(client.archiveThread).not.toHaveBeenCalled(); + expect(host.closePanel).not.toHaveBeenCalled(); + }); + + it("does not archive the source when fork and archive cannot open the fork panel", async () => { + const client = clientMock(); + const host = hostMock({ client, displayItems: turnItems() }); + host.openThreadInNewView.mockRejectedValue(new Error("no panel")); + const controller = new ChatThreadActionController(host); + + await controller.forkThreadFromTurn("source", "turn-2", true); + + expect(client.forkThread).toHaveBeenCalledWith("source", "/vault"); + expect(client.rollbackThread).toHaveBeenCalledWith("forked", 1); + expect(client.archiveThread).not.toHaveBeenCalled(); + expect(host.closePanel).not.toHaveBeenCalled(); + }); + + it("does not close the source panel when fork and archive fails to archive", async () => { + const client = clientMock(); + client.archiveThread.mockRejectedValue(new Error("archive failed")); + const host = hostMock({ client, displayItems: turnItems() }); + const controller = new ChatThreadActionController(host); + + await controller.forkThreadFromTurn("source", "turn-3", true); + + expect(client.rollbackThread).not.toHaveBeenCalled(); + expect(client.archiveThread).toHaveBeenCalledWith("source"); + expect(host.closePanel).not.toHaveBeenCalled(); + expect(host.addSystemMessage).toHaveBeenCalledWith("archive failed"); + }); + + it("closes the source panel after fork and archive succeeds", async () => { + const client = clientMock(); + const host = hostMock({ client, displayItems: turnItems() }); + const controller = new ChatThreadActionController(host); + + await controller.forkThreadFromTurn("source", "turn-3", true); + + expect(client.archiveThread).toHaveBeenCalledWith("source"); + expect(host.closePanel).toHaveBeenCalledOnce(); + }); +}); + +function turnItems(): DisplayItem[] { + return [ + { id: "u1", kind: "message", role: "user", text: "one", turnId: "turn-1", markdown: true }, + { id: "a1", kind: "message", role: "assistant", text: "one answer", turnId: "turn-1", markdown: true }, + { id: "u2", kind: "message", role: "user", text: "two", turnId: "turn-2", markdown: true }, + { id: "a2", kind: "message", role: "assistant", text: "two answer", turnId: "turn-2", markdown: true }, + { id: "u3", kind: "message", role: "user", text: "three", turnId: "turn-3", markdown: true }, + { id: "a3", kind: "message", role: "assistant", text: "three answer", turnId: "turn-3", markdown: true }, + ]; +} + +function clientMock() { + return { + forkThread: vi.fn().mockResolvedValue({ thread: { id: "forked" } }), + rollbackThread: vi.fn().mockResolvedValue({ thread: { id: "forked" } }), + archiveThread: vi.fn().mockResolvedValue({}), + readThread: vi.fn(), + setThreadName: vi.fn(), + }; +} + +function hostMock({ client, displayItems }: { client: ReturnType; displayItems: DisplayItem[] }) { + const state = createChatState(); + const stateStore = createChatStateStore({ ...state, displayItems }); + return { + stateStore, + vaultPath: "/vault", + settings: () => DEFAULT_SETTINGS, + archiveAdapter: vi.fn(), + ensureConnected: vi.fn().mockResolvedValue(undefined), + currentClient: () => client as unknown as AppServerClient, + history: {} as never, + addSystemMessage: vi.fn(), + setStatus: vi.fn(), + setComposerText: vi.fn(), + openThreadInNewView: vi.fn().mockResolvedValue(undefined), + notifyThreadArchived: vi.fn(), + notifyThreadRenamed: vi.fn(), + notifyActiveThreadIdentityChanged: vi.fn(), + refreshThreads: vi.fn().mockResolvedValue(undefined), + refreshSharedThreadListFromOpenSurface: vi.fn(), + closePanel: vi.fn(), + } satisfies ChatThreadActionControllerHost; +} diff --git a/tests/main.test.ts b/tests/main.test.ts index a458b6f6..54206f1b 100644 --- a/tests/main.test.ts +++ b/tests/main.test.ts @@ -277,22 +277,25 @@ describe("CodexPanelPlugin boot restored panel loading", () => { it("closes matching chat panels only when archive notification requests it", async () => { const { CodexChatView } = await import("../src/features/chat/view"); + const restoredMatchingLeaf = leaf({ state: { threadId: "thread-1", threadTitle: "Restored" } }); const matchingLeaf = leaf(); matchingLeaf.view = chatView(CodexChatView, matchingLeaf); vi.spyOn(matchingLeaf.view as CodexChatView, "openPanelSnapshot").mockReturnValue(panelSnapshot({ threadId: "thread-1" })); const otherLeaf = leaf(); otherLeaf.view = chatView(CodexChatView, otherLeaf); vi.spyOn(otherLeaf.view as CodexChatView, "openPanelSnapshot").mockReturnValue(panelSnapshot({ threadId: "thread-2" })); - const plugin = await pluginWithLeaves([matchingLeaf, otherLeaf]); + const plugin = await pluginWithLeaves([restoredMatchingLeaf, matchingLeaf, otherLeaf]); vi.spyOn(plugin, "refreshSharedThreadListFromOpenSurface").mockImplementation(() => undefined); plugin.notifyThreadArchived("thread-1"); + expect(restoredMatchingLeaf.detach).not.toHaveBeenCalled(); expect(matchingLeaf.detach).not.toHaveBeenCalled(); expect(otherLeaf.detach).not.toHaveBeenCalled(); plugin.notifyThreadArchived("thread-1", { closeOpenPanels: true }); + expect(restoredMatchingLeaf.detach).toHaveBeenCalledOnce(); expect(matchingLeaf.detach).toHaveBeenCalledOnce(); expect(otherLeaf.detach).not.toHaveBeenCalled(); });