story-kit: merge 109_story_add_test_coverage_for_lozengeflycontext_selectionscreen_and_chatheader_components

This commit is contained in:
Dave
2026-02-23 22:28:13 +00:00
parent 965d07930e
commit a3b0dc0161
3 changed files with 574 additions and 0 deletions

View File

@@ -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> = {}): 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(<ChatHeader {...makeProps()} />);
expect(screen.getByText("/test/project")).toBeInTheDocument();
});
it("calls onCloseProject when close button is clicked", () => {
const onCloseProject = vi.fn();
render(<ChatHeader {...makeProps({ onCloseProject })} />);
fireEvent.click(screen.getByText("\u2715"));
expect(onCloseProject).toHaveBeenCalled();
});
it("displays context percentage with green emoji when low", () => {
render(
<ChatHeader
{...makeProps({
contextUsage: { used: 1000, total: 10000, percentage: 10 },
})}
/>,
);
expect(screen.getByText(/10%/)).toBeInTheDocument();
});
it("displays yellow emoji when context is 75-89%", () => {
render(
<ChatHeader
{...makeProps({
contextUsage: { used: 8000, total: 10000, percentage: 80 },
})}
/>,
);
expect(screen.getByText(/80%/)).toBeInTheDocument();
});
it("displays red emoji when context is 90%+", () => {
render(
<ChatHeader
{...makeProps({
contextUsage: { used: 9500, total: 10000, percentage: 95 },
})}
/>,
);
expect(screen.getByText(/95%/)).toBeInTheDocument();
});
it("calls onClearSession when New Session button is clicked", () => {
const onClearSession = vi.fn();
render(<ChatHeader {...makeProps({ onClearSession })} />);
fireEvent.click(screen.getByText(/New Session/));
expect(onClearSession).toHaveBeenCalled();
});
it("renders select dropdown when model options are available", () => {
render(<ChatHeader {...makeProps()} />);
const select = screen.getByRole("combobox");
expect(select).toBeInTheDocument();
});
it("renders text input when no model options are available", () => {
render(
<ChatHeader {...makeProps({ availableModels: [], claudeModels: [] })} />,
);
expect(screen.getByPlaceholderText("Model")).toBeInTheDocument();
});
it("calls onModelChange when model is selected from dropdown", () => {
const onModelChange = vi.fn();
render(<ChatHeader {...makeProps({ onModelChange })} />);
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(
<ChatHeader
{...makeProps({
availableModels: [],
claudeModels: [],
onModelChange,
})}
/>,
);
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(<ChatHeader {...makeProps({ onToggleTools })} />);
const checkbox = screen.getByRole("checkbox");
fireEvent.click(checkbox);
expect(onToggleTools).toHaveBeenCalled();
});
it("shows disabled placeholder when claudeModels is empty and no API key", () => {
render(
<ChatHeader
{...makeProps({
claudeModels: [],
hasAnthropicKey: false,
availableModels: ["llama3"],
})}
/>,
);
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(<ChatHeader {...makeProps()} />);
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(<ChatHeader {...makeProps()} />);
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(<ChatHeader {...makeProps()} />);
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(<ChatHeader {...makeProps()} />);
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)");
});
});

View File

@@ -710,3 +710,249 @@ describe("hiddenRosterAgents: fly-out keeps agent hidden until clone lands", ()
expect(probe.dataset.hidden).toBe(""); 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(
<LozengeFlyProvider pipeline={withCoder1}>
<RosterFixture agentName="coder-1" />
<RosterFixture agentName="coder-2" />
<HiddenAgentsProbe />
<StagePanel title="Current" items={withCoder1.current} />
</LozengeFlyProvider>,
);
// Advance past initial fly-in
await act(async () => {
vi.advanceTimersByTime(600);
});
// Swap agent: coder-1 → coder-2
await act(async () => {
rerender(
<LozengeFlyProvider pipeline={withCoder2}>
<RosterFixture agentName="coder-1" />
<RosterFixture agentName="coder-2" />
<HiddenAgentsProbe />
<StagePanel title="Current" items={withCoder2.current} />
</LozengeFlyProvider>,
);
});
// 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(
<LozengeFlyProvider pipeline={withAgent}>
{/* No RosterFixture for orphan-agent */}
<StagePanel title="Current" items={withAgent.current} />
</LozengeFlyProvider>,
);
await act(async () => {
vi.advanceTimersByTime(600);
});
await act(async () => {
rerender(
<LozengeFlyProvider pipeline={noAgent}>
<StagePanel title="Current" items={noAgent.current} />
</LozengeFlyProvider>,
);
});
// 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(
<LozengeFlyProvider pipeline={noPipeline}>
<RosterFixture agentName="coder-1" />
<StagePanel title="Current" items={[]} />
</LozengeFlyProvider>,
);
// Trigger fly-in but don't flush rAF callbacks
await act(async () => {
rerender(
<LozengeFlyProvider pipeline={withAgent}>
<RosterFixture agentName="coder-1" />
<StagePanel title="Current" items={withAgent.current} />
</LozengeFlyProvider>,
);
});
// 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);
});
});
});

View File

@@ -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> = {},
): SelectionScreenProps {
return {
knownProjects: [],
onOpenProject: vi.fn(),
onForgetProject: vi.fn(),
pathInput: "",
homeDir: null,
onPathInputChange: vi.fn(),
onPathInputKeyDown: vi.fn() as (
event: KeyboardEvent<HTMLInputElement>,
) => 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(<SelectionScreen {...makeProps()} />);
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(
<SelectionScreen
{...makeProps({ knownProjects: ["/Users/test/project"] })}
/>,
);
expect(screen.getByText("Recent projects")).toBeInTheDocument();
});
it("does not render recent projects list when knownProjects is empty", () => {
render(<SelectionScreen {...makeProps({ knownProjects: [] })} />);
expect(screen.queryByText("Recent projects")).not.toBeInTheDocument();
});
it("calls onOpenProject when Open Project button is clicked", () => {
const onOpenProject = vi.fn();
render(
<SelectionScreen
{...makeProps({ pathInput: "/my/path", onOpenProject })}
/>,
);
fireEvent.click(screen.getByText("Open Project"));
expect(onOpenProject).toHaveBeenCalledWith("/my/path");
});
it("shows Opening... text and disables buttons when isOpening is true", () => {
render(<SelectionScreen {...makeProps({ isOpening: true })} />);
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(
<SelectionScreen {...makeProps({ completionError: "Path not found" })} />,
);
expect(screen.getByText("Path not found")).toBeInTheDocument();
});
it("does not display error div when completionError is null", () => {
const { container } = render(<SelectionScreen {...makeProps()} />);
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(
<SelectionScreen
{...makeProps({
homeDir: "/Users/test",
onPathInputChange,
onCloseSuggestions,
})}
/>,
);
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(
<SelectionScreen
{...makeProps({
homeDir: "/Users/test/",
onPathInputChange,
onCloseSuggestions,
})}
/>,
);
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(
<SelectionScreen {...makeProps({ homeDir: null, onPathInputChange })} />,
);
fireEvent.click(screen.getByText("New Project"));
expect(onPathInputChange).toHaveBeenCalledWith("");
});
});