255 lines · 9.3 KB · TypeScript
RawDownload
import "../index.css"; import { EnvironmentId, ProjectId, ThreadId } from "@t3tools/contracts";import { useCallback, useRef, useState } from "react";import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";import { page, userEvent } from "vite-plus/test/browser";import { cleanup, render } from "vitest-browser-react"; import { AppAtomRegistryProvider } from "../rpc/atomRegistry";import { DEFAULT_INTERACTION_MODE } from "../types";import type { SidebarThreadSummary } from "../types";import { SidebarThreadRow } from "./Sidebar"; // Double-click-to-rename is a desktop affordance; force the non-mobile path so// the rename input is reachable regardless of the test browser viewport.vi.mock("~/hooks/useMediaQuery", () => ({  useIsMobile: () => false,  useMediaQuery: () => false,})); const THREAD_ID = ThreadId.make("thread-1");const ENVIRONMENT_ID = EnvironmentId.make("environment-local");const PROJECT_ID = ProjectId.make("project-1");const INITIAL_TITLE = "Original title"; const ROW_TESTID = `thread-row-${THREAD_ID}`;const TITLE_TESTID = `thread-title-${THREAD_ID}`; // Spies live at module scope so their call history survives the row's// re-renders; reset between tests.const spies = {  handleThreadClick: vi.fn(),  startThreadRename: vi.fn(),  navigateToThread: vi.fn(),  handleMultiSelectContextMenu: vi.fn(async () => {}),  handleThreadContextMenu: vi.fn(async () => {}),  clearSelection: vi.fn(),  commitRename: vi.fn(),  attemptArchiveThread: vi.fn(async () => {}),  openPrLink: vi.fn(),}; function buildThread(title: string): SidebarThreadSummary {  return {    id: THREAD_ID,    environmentId: ENVIRONMENT_ID,    projectId: PROJECT_ID,    title,    interactionMode: DEFAULT_INTERACTION_MODE,    session: null,    createdAt: "2024-01-01T00:00:00.000Z",    archivedAt: null,    updatedAt: undefined,    latestTurn: null,    branch: null,    worktreePath: null,    latestUserMessageAt: null,    hasPendingApprovals: false,    hasPendingUserInput: false,    hasActionableProposedPlan: false,  };} // Mirrors the real parent (`SidebarProjectItem`): holds the rename state, wires// `startThreadRename`, and commits by clearing the rename state and persisting// the new title back onto the thread so the row re-renders with it.function Harness() {  const [title, setTitle] = useState(INITIAL_TITLE);  const [renamingThreadKey, setRenamingThreadKey] = useState<string | null>(null);  const [renamingTitle, setRenamingTitle] = useState("");  const [confirmingArchiveThreadKey, setConfirmingArchiveThreadKey] = useState<string | null>(null);  const renamingInputRef = useRef<HTMLInputElement | null>(null);  const renamingCommittedRef = useRef(false);  const confirmArchiveButtonRefs = useRef(new Map<string, HTMLButtonElement>());   const startThreadRename = useCallback((threadKey: string, nextTitle: string) => {    spies.startThreadRename(threadKey, nextTitle);    setRenamingThreadKey(threadKey);    setRenamingTitle(nextTitle);    renamingCommittedRef.current = false;  }, []);   const commitRename = useCallback(    async (threadRef: unknown, newTitle: string, originalTitle: string) => {      spies.commitRename(threadRef, newTitle, originalTitle);      const trimmed = newTitle.trim();      if (trimmed.length > 0) {        setTitle(trimmed);      }      setRenamingThreadKey(null);      renamingInputRef.current = null;    },    [],  );   const cancelRename = useCallback(() => {    setRenamingThreadKey(null);    renamingInputRef.current = null;  }, []);   return (    <AppAtomRegistryProvider>      <ul>        <SidebarThreadRow          thread={buildThread(title)}          projectCwd={null}          orderedProjectThreadKeys={[]}          isActive={false}          jumpLabel={null}          appSettingsConfirmThreadArchive={false}          renamingThreadKey={renamingThreadKey}          renamingTitle={renamingTitle}          setRenamingTitle={setRenamingTitle}          startThreadRename={startThreadRename}          renamingInputRef={renamingInputRef}          renamingCommittedRef={renamingCommittedRef}          confirmingArchiveThreadKey={confirmingArchiveThreadKey}          setConfirmingArchiveThreadKey={setConfirmingArchiveThreadKey}          confirmArchiveButtonRefs={confirmArchiveButtonRefs}          handleThreadClick={spies.handleThreadClick}          navigateToThread={spies.navigateToThread}          handleMultiSelectContextMenu={spies.handleMultiSelectContextMenu}          handleThreadContextMenu={spies.handleThreadContextMenu}          clearSelection={spies.clearSelection}          commitRename={commitRename}          cancelRename={cancelRename}          attemptArchiveThread={spies.attemptArchiveThread}          openPrLink={spies.openPrLink}        />      </ul>    </AppAtomRegistryProvider>  );} describe("SidebarThreadRow double-click rename", () => {  beforeEach(() => {    for (const spy of Object.values(spies)) spy.mockClear();  });   afterEach(() => {    cleanup();  });   it("double-clicking a row starts the inline rename, focused with text selected", async () => {    render(<Harness />);     await expect.element(page.getByTestId(TITLE_TESTID)).toBeVisible();     await userEvent.dblClick(page.getByTestId(ROW_TESTID));     const input = page.getByRole("textbox");    await expect.element(input).toBeVisible();     const element = input.element() as HTMLInputElement;    expect(element.value).toBe(INITIAL_TITLE);    // The existing rename-input ref focuses + selects the whole title.    expect(document.activeElement).toBe(element);    expect(element.selectionStart).toBe(0);    expect(element.selectionEnd).toBe(INITIAL_TITLE.length);  });   it("Enter commits the rename and the new title persists on the row", async () => {    render(<Harness />);     await userEvent.dblClick(page.getByTestId(ROW_TESTID));    const input = page.getByRole("textbox");    await expect.element(input).toBeVisible();     await userEvent.fill(input, "Renamed thread");    await userEvent.keyboard("{Enter}");     // commitRename was invoked with (threadRef, newTitle, originalTitle).    expect(spies.commitRename).toHaveBeenCalledTimes(1);    expect(spies.commitRename).toHaveBeenCalledWith(      expect.anything(),      "Renamed thread",      INITIAL_TITLE,    );     // Input is gone and the row now shows the persisted title.    const title = page.getByTestId(TITLE_TESTID);    await expect.element(title).toBeVisible();    await expect.element(title).toHaveTextContent("Renamed thread");  });   it("Escape cancels the rename without committing", async () => {    render(<Harness />);     await userEvent.dblClick(page.getByTestId(ROW_TESTID));    await expect.element(page.getByRole("textbox")).toBeVisible();     await userEvent.keyboard("{Escape}");     expect(spies.commitRename).not.toHaveBeenCalled();    const title = page.getByTestId(TITLE_TESTID);    await expect.element(title).toBeVisible();    await expect.element(title).toHaveTextContent(INITIAL_TITLE);  });   it("double-clicking inside the rename input keeps the edit (does not reset to the title)", async () => {    render(<Harness />);     await userEvent.dblClick(page.getByTestId(ROW_TESTID));    const input = page.getByRole("textbox");    await expect.element(input).toBeVisible();     await userEvent.fill(input, "Edited but not committed");    // Double-clicking inside the input (e.g. to select a word) must not bubble    // to the row and restart the rename, which would wipe the edit.    await userEvent.dblClick(input);     expect((input.element() as HTMLInputElement).value).toBe("Edited but not committed");    expect(spies.commitRename).not.toHaveBeenCalled();  });   it("double-clicking the row chrome while already renaming does not restart/reset it", async () => {    render(<Harness />);     await userEvent.dblClick(page.getByTestId(ROW_TESTID));    const input = page.getByRole("textbox");    await expect.element(input).toBeVisible();    await userEvent.fill(input, "Edited");    expect(spies.startThreadRename).toHaveBeenCalledTimes(1);     // Double-click the row element itself (chrome, not the input).    const rowEl = page.getByTestId(ROW_TESTID).element();    rowEl.dispatchEvent(new MouseEvent("dblclick", { bubbles: true, cancelable: true, detail: 2 }));     // Guard short-circuits: rename is not restarted and the edit is preserved.    expect(spies.startThreadRename).toHaveBeenCalledTimes(1);    expect((input.element() as HTMLInputElement).value).toBe("Edited");  });   it("modifier double-click is multi-select intent and does not start a rename", async () => {    render(<Harness />);     await userEvent.keyboard("{Shift>}");    await userEvent.dblClick(page.getByTestId(ROW_TESTID));    await userEvent.keyboard("{/Shift}");     await expect.element(page.getByTestId(TITLE_TESTID)).toBeVisible();    expect(page.getByRole("textbox").elements()).toHaveLength(0);  });   it("single click routes through the navigation handler and does not start a rename", async () => {    render(<Harness />);     await userEvent.click(page.getByTestId(ROW_TESTID));     expect(spies.handleThreadClick).toHaveBeenCalledTimes(1);    // No rename input: the title span is still shown.    await expect.element(page.getByTestId(TITLE_TESTID)).toBeVisible();    expect(page.getByRole("textbox").elements()).toHaveLength(0);  });});