From a3b0dc01616dbc5ed0376b4e49bc20915fbc11c8 Mon Sep 17 00:00:00 2001 From: Dave Date: Mon, 23 Feb 2026 22:28:13 +0000 Subject: [PATCH] story-kit: merge 109_story_add_test_coverage_for_lozengeflycontext_selectionscreen_and_chatheader_components --- frontend/src/components/ChatHeader.test.tsx | 192 ++++++++++++++ .../src/components/LozengeFlyContext.test.tsx | 246 ++++++++++++++++++ .../selection/SelectionScreen.test.tsx | 136 ++++++++++ 3 files changed, 574 insertions(+) create mode 100644 frontend/src/components/ChatHeader.test.tsx create mode 100644 frontend/src/components/selection/SelectionScreen.test.tsx diff --git a/frontend/src/components/ChatHeader.test.tsx b/frontend/src/components/ChatHeader.test.tsx new file mode 100644 index 0000000..454afc9 --- /dev/null +++ b/frontend/src/components/ChatHeader.test.tsx @@ -0,0 +1,192 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { ChatHeader } from "./ChatHeader"; + +interface ChatHeaderProps { + projectPath: string; + onCloseProject: () => void; + contextUsage: { used: number; total: number; percentage: number }; + onClearSession: () => void; + model: string; + availableModels: string[]; + claudeModels: string[]; + hasAnthropicKey: boolean; + onModelChange: (model: string) => void; + enableTools: boolean; + onToggleTools: (enabled: boolean) => void; +} + +function makeProps(overrides: Partial = {}): ChatHeaderProps { + return { + projectPath: "/test/project", + onCloseProject: vi.fn(), + contextUsage: { used: 1000, total: 10000, percentage: 10 }, + onClearSession: vi.fn(), + model: "claude-sonnet", + availableModels: ["llama3"], + claudeModels: ["claude-sonnet"], + hasAnthropicKey: true, + onModelChange: vi.fn(), + enableTools: true, + onToggleTools: vi.fn(), + ...overrides, + }; +} + +describe("ChatHeader", () => { + it("renders project path", () => { + render(); + expect(screen.getByText("/test/project")).toBeInTheDocument(); + }); + + it("calls onCloseProject when close button is clicked", () => { + const onCloseProject = vi.fn(); + render(); + fireEvent.click(screen.getByText("\u2715")); + expect(onCloseProject).toHaveBeenCalled(); + }); + + it("displays context percentage with green emoji when low", () => { + render( + , + ); + expect(screen.getByText(/10%/)).toBeInTheDocument(); + }); + + it("displays yellow emoji when context is 75-89%", () => { + render( + , + ); + expect(screen.getByText(/80%/)).toBeInTheDocument(); + }); + + it("displays red emoji when context is 90%+", () => { + render( + , + ); + expect(screen.getByText(/95%/)).toBeInTheDocument(); + }); + + it("calls onClearSession when New Session button is clicked", () => { + const onClearSession = vi.fn(); + render(); + fireEvent.click(screen.getByText(/New Session/)); + expect(onClearSession).toHaveBeenCalled(); + }); + + it("renders select dropdown when model options are available", () => { + render(); + const select = screen.getByRole("combobox"); + expect(select).toBeInTheDocument(); + }); + + it("renders text input when no model options are available", () => { + render( + , + ); + expect(screen.getByPlaceholderText("Model")).toBeInTheDocument(); + }); + + it("calls onModelChange when model is selected from dropdown", () => { + const onModelChange = vi.fn(); + render(); + const select = screen.getByRole("combobox"); + fireEvent.change(select, { target: { value: "llama3" } }); + expect(onModelChange).toHaveBeenCalledWith("llama3"); + }); + + it("calls onModelChange when text is typed in model input", () => { + const onModelChange = vi.fn(); + render( + , + ); + const input = screen.getByPlaceholderText("Model"); + fireEvent.change(input, { target: { value: "custom-model" } }); + expect(onModelChange).toHaveBeenCalledWith("custom-model"); + }); + + it("calls onToggleTools when checkbox is toggled", () => { + const onToggleTools = vi.fn(); + render(); + const checkbox = screen.getByRole("checkbox"); + fireEvent.click(checkbox); + expect(onToggleTools).toHaveBeenCalled(); + }); + + it("shows disabled placeholder when claudeModels is empty and no API key", () => { + render( + , + ); + expect( + screen.getByText("Add Anthropic API key to load models"), + ).toBeInTheDocument(); + }); + + // ── Close button hover/focus handlers ───────────────────────────────────── + + it("close button changes background on mouseOver and resets on mouseOut", () => { + render(); + const closeBtn = screen.getByText("\u2715"); + fireEvent.mouseOver(closeBtn); + expect(closeBtn.style.background).toBe("rgb(51, 51, 51)"); + fireEvent.mouseOut(closeBtn); + expect(closeBtn.style.background).toBe("transparent"); + }); + + it("close button changes background on focus and resets on blur", () => { + render(); + const closeBtn = screen.getByText("\u2715"); + fireEvent.focus(closeBtn); + expect(closeBtn.style.background).toBe("rgb(51, 51, 51)"); + fireEvent.blur(closeBtn); + expect(closeBtn.style.background).toBe("transparent"); + }); + + // ── New Session button hover/focus handlers ─────────────────────────────── + + it("New Session button changes style on mouseOver and resets on mouseOut", () => { + render(); + const sessionBtn = screen.getByText(/New Session/); + fireEvent.mouseOver(sessionBtn); + expect(sessionBtn.style.backgroundColor).toBe("rgb(63, 63, 63)"); + expect(sessionBtn.style.color).toBe("rgb(204, 204, 204)"); + fireEvent.mouseOut(sessionBtn); + expect(sessionBtn.style.backgroundColor).toBe("rgb(47, 47, 47)"); + expect(sessionBtn.style.color).toBe("rgb(136, 136, 136)"); + }); + + it("New Session button changes style on focus and resets on blur", () => { + render(); + const sessionBtn = screen.getByText(/New Session/); + fireEvent.focus(sessionBtn); + expect(sessionBtn.style.backgroundColor).toBe("rgb(63, 63, 63)"); + expect(sessionBtn.style.color).toBe("rgb(204, 204, 204)"); + fireEvent.blur(sessionBtn); + expect(sessionBtn.style.backgroundColor).toBe("rgb(47, 47, 47)"); + expect(sessionBtn.style.color).toBe("rgb(136, 136, 136)"); + }); +}); diff --git a/frontend/src/components/LozengeFlyContext.test.tsx b/frontend/src/components/LozengeFlyContext.test.tsx index d23cf43..f9c7ebc 100644 --- a/frontend/src/components/LozengeFlyContext.test.tsx +++ b/frontend/src/components/LozengeFlyContext.test.tsx @@ -710,3 +710,249 @@ describe("hiddenRosterAgents: fly-out keeps agent hidden until clone lands", () expect(probe.dataset.hidden).toBe(""); }); }); + +// ─── Agent swap (name change) triggers both fly-out and fly-in ──────────── + +describe("LozengeFlyProvider agent swap (name change)", () => { + beforeEach(() => { + vi.useFakeTimers(); + Element.prototype.getBoundingClientRect = vi.fn().mockReturnValue({ + left: 100, + top: 50, + right: 180, + bottom: 70, + width: 80, + height: 20, + x: 100, + y: 50, + toJSON: () => ({}), + }); + vi.spyOn(window, "requestAnimationFrame").mockImplementation((cb) => { + cb(0); + return 0; + }); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + it("detects agent name change as both fly-out (old) and fly-in (new)", async () => { + const withCoder1 = makePipeline({ + current: [ + { + story_id: "109_swap_test", + name: "Swap Test", + error: null, + agent: { agent_name: "coder-1", model: "sonnet", status: "running" }, + }, + ], + }); + const withCoder2 = makePipeline({ + current: [ + { + story_id: "109_swap_test", + name: "Swap Test", + error: null, + agent: { agent_name: "coder-2", model: "haiku", status: "running" }, + }, + ], + }); + + const { rerender } = render( + + + + + + , + ); + + // Advance past initial fly-in + await act(async () => { + vi.advanceTimersByTime(600); + }); + + // Swap agent: coder-1 → coder-2 + await act(async () => { + rerender( + + + + + + , + ); + }); + + // A fly-out clone for coder-1 should appear (old agent leaves) + const flyOut = document.body.querySelector( + '[data-testid^="flying-lozenge-fly-out"]', + ); + expect(flyOut).not.toBeNull(); + + // A fly-in clone for coder-2 should appear (new agent arrives) + const flyIn = document.body.querySelector( + '[data-testid^="flying-lozenge-fly-in"]', + ); + expect(flyIn).not.toBeNull(); + }); +}); + +// ─── Fly-out without a roster element (null rosterRect fallback) ────────── + +describe("LozengeFlyProvider fly-out without roster element", () => { + beforeEach(() => { + vi.useFakeTimers(); + Element.prototype.getBoundingClientRect = vi.fn().mockReturnValue({ + left: 200, + top: 100, + right: 280, + bottom: 120, + width: 80, + height: 20, + x: 200, + y: 100, + toJSON: () => ({}), + }); + vi.spyOn(window, "requestAnimationFrame").mockImplementation((cb) => { + cb(0); + return 0; + }); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + it("fly-out still works when no roster element is registered (uses fallback coords)", async () => { + const withAgent = makePipeline({ + current: [ + { + story_id: "109_no_roster_flyout", + name: "No Roster Flyout", + error: null, + agent: { + agent_name: "orphan-agent", + model: null, + status: "completed", + }, + }, + ], + }); + const noAgent = makePipeline({ + current: [ + { + story_id: "109_no_roster_flyout", + name: "No Roster Flyout", + error: null, + agent: null, + }, + ], + }); + + const { rerender } = render( + + {/* No RosterFixture for orphan-agent */} + + , + ); + + await act(async () => { + vi.advanceTimersByTime(600); + }); + + await act(async () => { + rerender( + + + , + ); + }); + + // Fly-out clone should still appear even without roster element + const clone = document.body.querySelector( + '[data-testid^="flying-lozenge-fly-out"]', + ); + expect(clone).not.toBeNull(); + }); +}); + +// ─── Flying clone renders in initial (non-flying) state ─────────────────── + +describe("FlyingLozengeClone initial non-flying render", () => { + beforeEach(() => { + vi.useFakeTimers(); + Element.prototype.getBoundingClientRect = vi.fn().mockReturnValue({ + left: 100, + top: 50, + right: 180, + bottom: 70, + width: 80, + height: 20, + x: 100, + y: 50, + toJSON: () => ({}), + }); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + it("clone has transition: none before rAF fires", async () => { + // Collect rAF callbacks instead of firing them immediately + const rafCallbacks: FrameRequestCallback[] = []; + vi.spyOn(window, "requestAnimationFrame").mockImplementation((cb) => { + rafCallbacks.push(cb); + return rafCallbacks.length; + }); + + const noPipeline = makePipeline(); + const withAgent = makePipeline({ + current: [ + { + story_id: "109_nontransition_test", + name: "Non-transition Test", + error: null, + agent: { agent_name: "coder-1", model: null, status: "running" }, + }, + ], + }); + + const { rerender } = render( + + + + , + ); + + // Trigger fly-in but don't flush rAF callbacks + await act(async () => { + rerender( + + + + , + ); + }); + + // Clone should exist in its initial (non-flying) state + const clone = document.body.querySelector( + '[data-testid^="flying-lozenge-fly-in"]', + ) as HTMLElement | null; + expect(clone).not.toBeNull(); + expect(clone?.style.transition).toBe("none"); + + // Now flush rAF callbacks to trigger the flying state + await act(async () => { + for (const cb of rafCallbacks) cb(0); + rafCallbacks.length = 0; + // Flush inner rAF callbacks too + for (const cb of rafCallbacks) cb(0); + }); + }); +}); diff --git a/frontend/src/components/selection/SelectionScreen.test.tsx b/frontend/src/components/selection/SelectionScreen.test.tsx new file mode 100644 index 0000000..515cfb8 --- /dev/null +++ b/frontend/src/components/selection/SelectionScreen.test.tsx @@ -0,0 +1,136 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import type { KeyboardEvent } from "react"; +import { describe, expect, it, vi } from "vitest"; +import type { SelectionScreenProps } from "./SelectionScreen"; +import { SelectionScreen } from "./SelectionScreen"; + +function makeProps( + overrides: Partial = {}, +): SelectionScreenProps { + return { + knownProjects: [], + onOpenProject: vi.fn(), + onForgetProject: vi.fn(), + pathInput: "", + homeDir: null, + onPathInputChange: vi.fn(), + onPathInputKeyDown: vi.fn() as ( + event: KeyboardEvent, + ) => void, + isOpening: false, + suggestionTail: "", + matchList: [], + selectedMatch: -1, + onSelectMatch: vi.fn(), + onAcceptMatch: vi.fn(), + onCloseSuggestions: vi.fn(), + completionError: null, + currentPartial: "", + ...overrides, + }; +} + +describe("SelectionScreen", () => { + it("renders the title and description", () => { + render(); + expect(screen.getByText("StorkIt")).toBeInTheDocument(); + expect( + screen.getByText("Paste or complete a project path to start."), + ).toBeInTheDocument(); + }); + + it("renders recent projects list when knownProjects is non-empty", () => { + render( + , + ); + expect(screen.getByText("Recent projects")).toBeInTheDocument(); + }); + + it("does not render recent projects list when knownProjects is empty", () => { + render(); + expect(screen.queryByText("Recent projects")).not.toBeInTheDocument(); + }); + + it("calls onOpenProject when Open Project button is clicked", () => { + const onOpenProject = vi.fn(); + render( + , + ); + fireEvent.click(screen.getByText("Open Project")); + expect(onOpenProject).toHaveBeenCalledWith("/my/path"); + }); + + it("shows Opening... text and disables buttons when isOpening is true", () => { + render(); + expect(screen.getByText("Opening...")).toBeInTheDocument(); + const buttons = screen.getAllByRole("button"); + for (const button of buttons) { + if ( + button.textContent === "Opening..." || + button.textContent === "New Project" + ) { + expect(button).toBeDisabled(); + } + } + }); + + it("displays completion error when completionError is provided", () => { + render( + , + ); + expect(screen.getByText("Path not found")).toBeInTheDocument(); + }); + + it("does not display error div when completionError is null", () => { + const { container } = render(); + const errorDiv = container.querySelector('[style*="color: red"]'); + expect(errorDiv).toBeNull(); + }); + + it("New Project button calls onPathInputChange with homeDir (trailing slash appended)", () => { + const onPathInputChange = vi.fn(); + const onCloseSuggestions = vi.fn(); + render( + , + ); + fireEvent.click(screen.getByText("New Project")); + expect(onPathInputChange).toHaveBeenCalledWith("/Users/test/"); + expect(onCloseSuggestions).toHaveBeenCalled(); + }); + + it("New Project button uses homeDir as-is when it already ends with /", () => { + const onPathInputChange = vi.fn(); + const onCloseSuggestions = vi.fn(); + render( + , + ); + fireEvent.click(screen.getByText("New Project")); + expect(onPathInputChange).toHaveBeenCalledWith("/Users/test/"); + expect(onCloseSuggestions).toHaveBeenCalled(); + }); + + it("New Project button uses empty string when homeDir is null", () => { + const onPathInputChange = vi.fn(); + render( + , + ); + fireEvent.click(screen.getByText("New Project")); + expect(onPathInputChange).toHaveBeenCalledWith(""); + }); +});