story-kit: merge 109_story_add_test_coverage_for_lozengeflycontext_selectionscreen_and_chatheader_components
This commit is contained in:
192
frontend/src/components/ChatHeader.test.tsx
Normal file
192
frontend/src/components/ChatHeader.test.tsx
Normal 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)");
|
||||
});
|
||||
});
|
||||
@@ -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(
|
||||
<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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
136
frontend/src/components/selection/SelectionScreen.test.tsx
Normal file
136
frontend/src/components/selection/SelectionScreen.test.tsx
Normal 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("");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user