murashit_codex-panel/tests/features/threads-view/shell.test.tsx
2026-07-21 14:33:04 +09:00

302 lines
12 KiB
TypeScript

// @vitest-environment jsdom
import { describe, expect, it, vi } from "vitest";
import type { Thread } from "../../../src/domain/threads/model";
import { renderThreadsViewShell } from "../../../src/features/threads-view/shell.dom";
import { type ThreadsRowModel, type ThreadsViewPanelActivity, threadRows } from "../../../src/features/threads-view/state";
import { changeInputValue, installObsidianDomShims } from "../../support/dom";
installObsidianDomShims();
function expectPresent<T>(value: T | null | undefined): T {
if (value === null || value === undefined) throw new Error("Expected value to be present");
return value;
}
function panelActivity(overrides: Partial<ThreadsViewPanelActivity> = {}): ThreadsViewPanelActivity {
return {
threadId: "thread",
selected: false,
pending: false,
running: false,
...overrides,
};
}
function threadFixture(overrides: Partial<Thread> = {}): Thread {
return {
id: "thread",
preview: "",
createdAt: 1,
updatedAt: 1,
name: null,
archived: false,
provenance: { kind: "interactive" },
...overrides,
};
}
function rowFixture(overrides: Partial<ThreadsRowModel> = {}): ThreadsRowModel {
const threadId = overrides.threadId ?? "thread";
const title = overrides.title ?? "Thread";
return {
threadId,
title,
live: null,
selected: false,
rename: { active: false, draft: title, generating: false, saving: false, autoNameDisabled: true },
archiveConfirm: { active: false, defaultSaveMarkdown: false },
...overrides,
};
}
function threadsViewActions() {
return {
refresh: vi.fn(),
loadMore: vi.fn(),
openNewPanel: vi.fn(),
openThread: vi.fn(),
startRename: vi.fn(),
updateRename: vi.fn(),
saveRename: vi.fn(),
cancelRename: vi.fn(),
cancelAutoName: vi.fn(),
autoNameThread: vi.fn(),
startArchive: vi.fn(),
archiveThread: vi.fn(),
};
}
describe("threads view renderer decisions", () => {
it("renders an initial load failure as status instead of a navigation row or empty state", () => {
const parent = document.createElement("div");
renderThreadsViewShell(
parent,
{ status: { kind: "error", message: "Could not load threads." }, loading: false, rows: [] },
threadsViewActions(),
);
const status = expectPresent(parent.querySelector<HTMLElement>(".codex-panel-threads__status"));
expect(status.textContent).toBe("Could not load threads.");
expect(status.getAttribute("role")).toBe("status");
expect(status.classList.contains("codex-panel-ui__nav-item")).toBe(false);
expect(parent.querySelector(".codex-panel-threads__empty")).toBeNull();
});
it("reserves the empty state for a successfully loaded empty list", () => {
const parent = document.createElement("div");
renderThreadsViewShell(parent, { status: null, loading: false, rows: [] }, threadsViewActions());
expect(parent.querySelector(".codex-panel-threads__empty")?.textContent).toBe("No threads");
expect(parent.querySelector(".codex-panel-threads__status")).toBeNull();
});
it("prioritizes open panel live state per thread", () => {
expect(
threadRows(
[threadFixture({ id: "thread" })],
[
panelActivity({ threadId: "thread" }),
panelActivity({ threadId: "thread", running: true }),
panelActivity({ threadId: "thread", pending: true }),
],
new Map(),
)[0]?.live,
).toMatchObject({ status: "pending" });
expect(threadRows([threadFixture({ id: "thread" })], [panelActivity({ threadId: "thread" })], new Map())[0]?.live).toMatchObject({
status: "open",
});
expect(
threadRows([threadFixture({ id: "thread" })], [panelActivity({ threadId: null, running: true })], new Map())[0]?.live,
).toBeNull();
});
it("marks a row selected when one open panel for the thread was last focused", () => {
const rows = threadRows(
[
threadFixture({ id: "closed", preview: "Closed thread" }),
threadFixture({ id: "focused", preview: "Focused thread", updatedAt: 2 }),
],
[panelActivity({ threadId: "focused", selected: true })],
new Map(),
);
expect(rows.find((row) => row.threadId === "focused")?.selected).toBe(true);
expect(rows.find((row) => row.threadId === "closed")?.selected).toBe(false);
});
it("renders thread rows with live state and routes open actions", () => {
const parent = document.createElement("div");
const actions = threadsViewActions();
const rows = threadRows(
[threadFixture({ id: "closed", preview: "Closed thread" }), threadFixture({ id: "open", preview: "Open thread", updatedAt: 2 })],
[panelActivity({ threadId: "open", pending: true, selected: true })],
new Map(),
);
renderThreadsViewShell(parent, { status: null, loading: false, rows }, actions);
const main = expectPresent(parent.querySelector<HTMLElement>(".codex-panel-threads__row--pending"));
expect(main.textContent).toContain("Open thread");
const toolbarButtons = [...parent.querySelectorAll<HTMLElement>(".codex-panel-threads__toolbar-button")];
expect(toolbarButtons.map((button) => button.getAttribute("aria-label"))).toEqual(["Open new panel", "Refresh threads"]);
const refresh = expectPresent(parent.querySelector<HTMLElement>('[aria-label="Refresh threads"]'));
refresh.click();
expect(actions.refresh).toHaveBeenCalledOnce();
const openNewPanel = expectPresent(parent.querySelector<HTMLElement>('[aria-label="Open new panel"]'));
openNewPanel.click();
expect(actions.openNewPanel).toHaveBeenCalledOnce();
expect(parent.querySelector<HTMLButtonElement>('[aria-label="Rename thread"]')).not.toBeNull();
main.click();
expect(actions.openThread).toHaveBeenCalledWith("open");
});
it("renders threads view archive confirmation with the default action on the right", () => {
const parent = document.createElement("div");
const actions = threadsViewActions();
const row = rowFixture({
archiveConfirm: { active: true, defaultSaveMarkdown: false },
});
renderThreadsViewShell(parent, { status: null, loading: false, rows: [row] }, actions);
const confirm = expectPresent(parent.querySelector<HTMLElement>(".codex-panel-threads__archive-confirm"));
const archiveButtons = [
...confirm.querySelectorAll<HTMLButtonElement>(".codex-panel-threads__archive-alternate, .codex-panel-threads__archive-default"),
];
expect(archiveButtons.map((button) => button.getAttribute("aria-label"))).toEqual([
"Save and archive thread",
"Archive thread without saving",
]);
expect(parent.querySelector<HTMLButtonElement>('[aria-label="Rename thread"]')).toBeNull();
archiveButtons[0]?.click();
expect(actions.archiveThread).toHaveBeenCalledWith("thread", true);
archiveButtons[1]?.click();
expect(actions.archiveThread).toHaveBeenCalledWith("thread", false);
});
it("starts threads view archive confirmation before archiving", () => {
const parent = document.createElement("div");
const actions = threadsViewActions();
const row = rowFixture();
renderThreadsViewShell(parent, { status: null, loading: false, rows: [row] }, actions);
parent.querySelector<HTMLButtonElement>('[aria-label="Archive thread"]')?.click();
expect(actions.startArchive).toHaveBeenCalledWith("thread");
expect(actions.archiveThread).not.toHaveBeenCalled();
});
it("renders rename rows and saves entered values", () => {
const parent = document.createElement("div");
document.body.append(parent);
const actions = threadsViewActions();
const row = rowFixture({
title: "Old name",
rename: { active: true, draft: "Old name", generating: false, saving: false, autoNameDisabled: false },
});
renderThreadsViewShell(parent, { status: null, loading: false, rows: [row] }, actions);
const input = expectPresent(parent.querySelector<HTMLInputElement>(".codex-panel-threads__rename-input"));
expect(document.activeElement).toBe(input);
expect([input.selectionStart, input.selectionEnd]).toEqual([0, input.value.length]);
changeInputValue(input, "New name");
expect(actions.updateRename).toHaveBeenCalledWith("thread", "New name");
renderThreadsViewShell(
parent,
{
status: null,
loading: false,
rows: [{ ...row, rename: { active: true, draft: "New name", generating: false, saving: false, autoNameDisabled: false } }],
},
actions,
);
const renamedInput = expectPresent(parent.querySelector<HTMLInputElement>(".codex-panel-threads__rename-input"));
renamedInput.dispatchEvent(new FocusEvent("blur"));
expect(actions.saveRename).toHaveBeenCalledWith("thread", "New name");
actions.saveRename.mockClear();
renamedInput.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true, isComposing: true }));
expect(actions.saveRename).not.toHaveBeenCalled();
renamedInput.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
expect(actions.saveRename).toHaveBeenCalledWith("thread", "New name");
renamedInput.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
expect(actions.cancelRename).toHaveBeenCalledWith("thread");
expectPresent(parent.querySelector<HTMLElement>(".codex-panel-threads__row-main")).click();
expect(actions.openThread).not.toHaveBeenCalled();
parent.remove();
});
it("renders threads view rename actions inline with auto-name", () => {
const parent = document.createElement("div");
const actions = threadsViewActions();
const row = rowFixture({
title: "Old name",
rename: { active: true, draft: "Old name", generating: false, saving: false, autoNameDisabled: false },
});
renderThreadsViewShell(parent, { status: null, loading: false, rows: [row] }, actions);
expect(parent.querySelector<HTMLElement>(".codex-panel-threads__rename-form")).toBeTruthy();
parent.querySelector<HTMLButtonElement>('[aria-label="Auto-name thread"]')?.click();
expect(actions.autoNameThread).toHaveBeenCalledWith("thread");
});
it("renders threads view rename auto-name loading state", () => {
const parent = document.createElement("div");
const actions = threadsViewActions();
const row = rowFixture({
title: "Old name",
rename: { active: true, draft: "Old name", generating: true, saving: false, autoNameDisabled: false },
});
renderThreadsViewShell(parent, { status: null, loading: false, rows: [row] }, actions);
expect(parent.querySelector<HTMLInputElement>(".codex-panel-threads__rename-input")?.disabled).toBe(true);
const cancelAutoName = expectPresent(parent.querySelector<HTMLButtonElement>('[aria-label="Cancel auto-name"]'));
expect(cancelAutoName.disabled).toBe(false);
cancelAutoName.click();
expect(actions.cancelAutoName).toHaveBeenCalledWith("thread");
});
it("locks threads view rename controls while saving", () => {
const parent = document.createElement("div");
const actions = threadsViewActions();
const row = rowFixture({
title: "Old name",
rename: { active: true, draft: "Old name", generating: false, saving: true, autoNameDisabled: false },
});
renderThreadsViewShell(parent, { status: null, loading: false, rows: [row] }, actions);
const input = expectPresent(parent.querySelector<HTMLInputElement>(".codex-panel-threads__rename-input"));
expect(input.disabled).toBe(true);
input.dispatchEvent(new FocusEvent("blur"));
input.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
const autoName = expectPresent(parent.querySelector<HTMLButtonElement>('[aria-label="Auto-name thread"]'));
expect(autoName.disabled).toBe(true);
autoName.click();
expect(actions.saveRename).not.toHaveBeenCalled();
expect(actions.cancelRename).not.toHaveBeenCalled();
expect(actions.autoNameThread).not.toHaveBeenCalled();
});
it("disables history expansion during any shared thread fetch", () => {
const parent = document.createElement("div");
const actions = threadsViewActions();
renderThreadsViewShell(parent, { status: null, loading: false, fetching: true, hasMore: true, rows: [rowFixture()] }, actions);
const loadMore = expectPresent(parent.querySelector<HTMLButtonElement>(".codex-panel-threads__load-more"));
expect(loadMore.textContent).toBe("Load more threads");
expect(loadMore.disabled).toBe(true);
loadMore.click();
expect(actions.loadMore).not.toHaveBeenCalled();
});
});