From d1337a6c853474fbd039a2e8c2a598bc037839b9 Mon Sep 17 00:00:00 2001 From: murashit Date: Sat, 18 Jul 2026 14:27:06 +0900 Subject: [PATCH] test(ui): cover turn diff and toolbar boundaries --- .../chat/panel/toolbar-actions.test.ts | 79 ++++++++++++ .../chat/panel/toolbar-hit-test.test.ts | 70 +++++++++++ tests/features/turn-diff/turn-diff.test.ts | 117 +++++++++++++++++- 3 files changed, 264 insertions(+), 2 deletions(-) create mode 100644 tests/features/chat/panel/toolbar-hit-test.test.ts diff --git a/tests/features/chat/panel/toolbar-actions.test.ts b/tests/features/chat/panel/toolbar-actions.test.ts index 0bf88ec8..a18ab4d8 100644 --- a/tests/features/chat/panel/toolbar-actions.test.ts +++ b/tests/features/chat/panel/toolbar-actions.test.ts @@ -42,6 +42,50 @@ describe("createToolbarPanelActions", () => { expect(stateStore.getState().ui.toolbarPanel).toBeNull(); }); + it("keeps the panel open during rename while clearing archive confirmation on an outside pointer", () => { + const stateStore = createChatStateStore(createChatState()); + const actions = createToolbarPanelActions({ + stateStore, + threadActions: { archiveThread: vi.fn() } as unknown as ThreadManagementActions, + }); + actions.toggleHistory(); + actions.startArchive("thread"); + + actions.closeOnOutsidePointer({ + hit: { insideToolbarPanel: false, insideArchiveConfirm: false }, + renameEditing: true, + }); + + expect(stateStore.getState().ui.toolbarPanel).toBe("history"); + expect(actions.archiveConfirmId()).toBeNull(); + }); + + it("keeps archive confirmation only for pointers inside its confirmation row", () => { + const stateStore = createChatStateStore(createChatState()); + const actions = createToolbarPanelActions({ + stateStore, + threadActions: { archiveThread: vi.fn() } as unknown as ThreadManagementActions, + }); + actions.toggleHistory(); + actions.startArchive("thread"); + + actions.closeOnOutsidePointer({ + hit: { insideToolbarPanel: true, insideArchiveConfirm: true }, + renameEditing: false, + }); + + expect(stateStore.getState().ui.toolbarPanel).toBe("history"); + expect(actions.archiveConfirmId()).toBe("thread"); + + actions.closeOnOutsidePointer({ + hit: { insideToolbarPanel: true, insideArchiveConfirm: false }, + renameEditing: false, + }); + + expect(stateStore.getState().ui.toolbarPanel).toBe("history"); + expect(actions.archiveConfirmId()).toBeNull(); + }); + it("keeps the Set goal action reachable while a restored thread needs hydration", () => { const stateStore = createChatStateStore(createChatState()); stateStore.dispatch({ type: "panel/restored-thread-applied", threadId: "restored", fallbackTitle: "Restored" }); @@ -63,4 +107,39 @@ describe("createToolbarPanelActions", () => { expect(startEditingCurrent).toHaveBeenCalledOnce(); }); + + it("gates side chat, compaction, and goal mutation at invocation time", () => { + let enabled = false; + const openSideChat = vi.fn(); + const compactActiveThread = vi.fn().mockResolvedValue(undefined); + const startEditingCurrent = vi.fn(); + const actions = createToolbarUiActions({ + connectionActions: {} as never, + reconnectPanel: vi.fn(), + threadActions: { compactActiveThread } as never, + goals: { startEditingCurrent } as never, + toolbarPanel: {} as never, + rename: {} as never, + navigation: {} as never, + openSideChat, + canStartSideChat: () => enabled, + canCompact: () => enabled, + canMutateGoal: () => enabled, + }); + + actions.chat.startSideChat?.(); + actions.chat.compactContext(); + actions.chat.setGoal(); + expect(openSideChat).not.toHaveBeenCalled(); + expect(compactActiveThread).not.toHaveBeenCalled(); + expect(startEditingCurrent).not.toHaveBeenCalled(); + + enabled = true; + actions.chat.startSideChat?.(); + actions.chat.compactContext(); + actions.chat.setGoal(); + expect(openSideChat).toHaveBeenCalledOnce(); + expect(compactActiveThread).toHaveBeenCalledOnce(); + expect(startEditingCurrent).toHaveBeenCalledOnce(); + }); }); diff --git a/tests/features/chat/panel/toolbar-hit-test.test.ts b/tests/features/chat/panel/toolbar-hit-test.test.ts new file mode 100644 index 00000000..3ee2d7b0 --- /dev/null +++ b/tests/features/chat/panel/toolbar-hit-test.test.ts @@ -0,0 +1,70 @@ +// @vitest-environment jsdom + +import { describe, expect, it } from "vitest"; + +import { toolbarOutsidePointerHit } from "../../../../src/features/chat/panel/toolbar-hit-test.dom"; + +function pointerAt(target: EventTarget | null): PointerEvent { + return { target } as PointerEvent; +} + +describe("toolbarOutsidePointerHit", () => { + it("classifies toolbar panels and archive confirmation descendants inside the panel root", () => { + const root = document.createElement("div"); + const panel = root.appendChild(document.createElement("div")); + panel.className = "codex-panel__toolbar-panel"; + const ordinaryTarget = panel.appendChild(document.createElement("button")); + const archiveConfirm = panel.appendChild(document.createElement("div")); + archiveConfirm.className = "codex-panel__archive-confirm"; + const confirmTarget = archiveConfirm.appendChild(document.createElement("button")); + + expect(toolbarOutsidePointerHit(pointerAt(ordinaryTarget), root, window)).toEqual({ + insideToolbarPanel: true, + insideArchiveConfirm: false, + }); + expect(toolbarOutsidePointerHit(pointerAt(confirmTarget), root, window)).toEqual({ + insideToolbarPanel: true, + insideArchiveConfirm: true, + }); + }); + + it("does not treat a matching toolbar class outside the supplied root as inside", () => { + const root = document.createElement("div"); + const externalPanel = document.createElement("div"); + externalPanel.className = "codex-panel__toolbar-primary"; + const target = externalPanel.appendChild(document.createElement("button")); + + expect(toolbarOutsidePointerHit(pointerAt(target), root, window)).toEqual({ + insideToolbarPanel: false, + insideArchiveConfirm: false, + }); + }); + + it("uses the view window when deciding whether the event target is a DOM element", () => { + const root = document.createElement("div"); + const panel = root.appendChild(document.createElement("div")); + panel.className = "codex-panel__toolbar-panel"; + const target = panel.appendChild(document.createElement("button")); + const iframe = document.body.appendChild(document.createElement("iframe")); + const foreignWindow = iframe.contentWindow; + + expect(foreignWindow).not.toBeNull(); + expect(toolbarOutsidePointerHit(pointerAt(target), root, foreignWindow)).toEqual({ + insideToolbarPanel: false, + insideArchiveConfirm: false, + }); + + iframe.remove(); + }); + + it.each([ + { name: "a missing root", root: null, viewWindow: window, target: document.body }, + { name: "a missing view window", root: document.createElement("div"), viewWindow: null, target: document.body }, + { name: "a non-element target", root: document.createElement("div"), viewWindow: window, target: document }, + ])("classifies $name as outside", ({ root, viewWindow, target }) => { + expect(toolbarOutsidePointerHit(pointerAt(target), root, viewWindow)).toEqual({ + insideToolbarPanel: false, + insideArchiveConfirm: false, + }); + }); +}); diff --git a/tests/features/turn-diff/turn-diff.test.ts b/tests/features/turn-diff/turn-diff.test.ts index 8f71a299..de902418 100644 --- a/tests/features/turn-diff/turn-diff.test.ts +++ b/tests/features/turn-diff/turn-diff.test.ts @@ -1,9 +1,9 @@ // @vitest-environment jsdom import type { WorkspaceLeaf } from "obsidian"; -import { describe, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; -import { persistedTurnDiffViewState } from "../../../src/features/turn-diff/model"; +import { isPersistedTurnDiffViewState, persistedTurnDiffViewState } from "../../../src/features/turn-diff/model"; import { renderTurnDiffView } from "../../../src/features/turn-diff/render.dom"; import { CodexTurnDiffView } from "../../../src/features/turn-diff/view.obsidian"; import { installObsidianDomShims } from "../../support/dom"; @@ -11,6 +11,10 @@ import { installObsidianDomShims } from "../../support/dom"; installObsidianDomShims(); describe("turn diff view decisions", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + it("renders the turn diff view with classified unified diff lines", () => { const parent = document.createElement("div"); const copyDiff = vi.fn(); @@ -145,6 +149,42 @@ describe("turn diff view decisions", () => { expect(persisted).not.toHaveProperty("diff"); }); + it.each([ + { + name: "a complete state with a vault path", + value: { threadId: "thread", turnId: "turn", cwd: "/vault/project", files: ["src/main.ts"] }, + valid: true, + }, + { + name: "a complete state without a working directory", + value: { threadId: "thread", turnId: "turn", cwd: null, files: [] }, + valid: true, + }, + { name: "null", value: null, valid: false }, + { + name: "a missing turn id", + value: { threadId: "thread", cwd: "/vault/project", files: ["src/main.ts"] }, + valid: false, + }, + { + name: "a non-array file list", + value: { threadId: "thread", turnId: "turn", cwd: "/vault/project", files: "src/main.ts" }, + valid: false, + }, + { + name: "a file list containing a non-string value", + value: { threadId: "thread", turnId: "turn", cwd: "/vault/project", files: ["src/main.ts", 42] }, + valid: false, + }, + { + name: "an undefined working directory", + value: { threadId: "thread", turnId: "turn", files: [] }, + valid: false, + }, + ])("classifies $name as persisted state: $valid", ({ value, valid }) => { + expect(isPersistedTurnDiffViewState(value)).toBe(valid); + }); + it("renders restored turn diff metadata without unavailable diff text", () => { const parent = document.createElement("div"); @@ -156,6 +196,79 @@ describe("turn diff view decisions", () => { expect(parent.querySelector(".codex-panel-turn-diff__diff")).toBeNull(); }); + it("restores only persisted metadata and clears an in-memory diff payload", async () => { + const containerEl = document.createElement("div"); + const view = new CodexTurnDiffView({ containerEl } as unknown as WorkspaceLeaf); + view.setDiffPayload({ + threadId: "live-thread", + turnId: "live-turn", + cwd: "/vault/project", + files: ["src/live.ts"], + diff: "@@\n-old\n+new", + }); + + await view.setState( + { + threadId: "restored-thread", + turnId: "restored-turn", + cwd: null, + files: ["src/restored.ts"], + }, + {} as never, + ); + + expect(view.getState()).toEqual({ + threadId: "restored-thread", + turnId: "restored-turn", + cwd: null, + files: ["src/restored.ts"], + }); + expect(view.contentEl.textContent).toContain("Turn diff is no longer available."); + expect(view.contentEl.textContent).not.toContain("old"); + expect(view.contentEl.querySelector(".codex-panel-turn-diff__copy")).toBeNull(); + }); + + it("rejects invalid restored metadata instead of showing a partial turn identity", async () => { + const containerEl = document.createElement("div"); + const view = new CodexTurnDiffView({ containerEl } as unknown as WorkspaceLeaf); + + await view.setState( + { + threadId: "thread", + turnId: "turn", + cwd: "/vault/project", + files: ["src/main.ts", 42], + }, + {} as never, + ); + + expect(view.getState()).toEqual({}); + expect(view.contentEl.textContent).toBe("No turn diff selected."); + }); + + it("copies the current in-memory diff from the view action", async () => { + const writeText = vi.fn().mockResolvedValue(undefined); + vi.stubGlobal("navigator", { + ...navigator, + clipboard: { writeText }, + }); + const containerEl = document.createElement("div"); + const view = new CodexTurnDiffView({ containerEl } as unknown as WorkspaceLeaf); + view.setDiffPayload({ + threadId: "thread", + turnId: "turn", + cwd: "/vault/project", + files: ["src/main.ts"], + diff: "@@\n-old\n+new", + }); + + view.contentEl.querySelector(".codex-panel-turn-diff__copy")?.click(); + + await vi.waitFor(() => { + expect(writeText).toHaveBeenCalledWith("@@\n-old\n+new"); + }); + }); + it("unmounts the turn diff Preact root when the view closes", async () => { const containerEl = document.createElement("div"); const view = new CodexTurnDiffView({ containerEl } as unknown as WorkspaceLeaf);