test(ui): cover turn diff and toolbar boundaries

This commit is contained in:
murashit 2026-07-18 14:27:06 +09:00
parent eb6d4a3c2d
commit d1337a6c85
3 changed files with 264 additions and 2 deletions

View file

@ -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();
});
});

View file

@ -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,
});
});
});

View file

@ -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<HTMLButtonElement>(".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);