Files
huskies/frontend/src/components/LozengeFlyContext.hidden.test.tsx
T
2026-04-29 17:20:32 +00:00

468 lines
12 KiB
TypeScript

import { act, render, screen } from "@testing-library/react";
import * as React from "react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { PipelineState } from "../api/client";
import { LozengeFlyProvider, useLozengeFly } from "./LozengeFlyContext";
import { StagePanel } from "./StagePanel";
// ─── Helpers ──────────────────────────────────────────────────────────────────
function makePipeline(overrides: Partial<PipelineState> = {}): PipelineState {
return {
backlog: [],
current: [],
qa: [],
merge: [],
done: [],
deterministic_merges_in_flight: [],
...overrides,
};
}
/** A minimal roster element fixture that registers itself with the context. */
function RosterFixture({ agentName }: { agentName: string }) {
const { registerRosterEl } = useLozengeFly();
const ref = React.useRef<HTMLSpanElement>(null);
React.useEffect(() => {
const el = ref.current;
if (el) registerRosterEl(agentName, el);
return () => registerRosterEl(agentName, null);
}, [agentName, registerRosterEl]);
return (
<span
ref={ref}
data-testid={`roster-${agentName}`}
style={{ position: "fixed", top: 10, left: 20, width: 80, height: 20 }}
/>
);
}
/** Reads hiddenRosterAgents from context and exposes it via a data attribute. */
function HiddenAgentsProbe() {
const { hiddenRosterAgents } = useLozengeFly();
return (
<div
data-testid="hidden-agents-probe"
data-hidden={[...hiddenRosterAgents].join(",")}
/>
);
}
function Wrapper({
pipeline,
children,
}: {
pipeline: PipelineState;
children: React.ReactNode;
}) {
return (
<LozengeFlyProvider pipeline={pipeline}>{children}</LozengeFlyProvider>
);
}
// ─── hiddenRosterAgents: no-duplicate guarantee ───────────────────────────────
describe("hiddenRosterAgents: assigned agents are absent from roster", () => {
it("is empty when no agents are in the pipeline", () => {
render(
<LozengeFlyProvider pipeline={makePipeline()}>
<HiddenAgentsProbe />
</LozengeFlyProvider>,
);
const probe = screen.getByTestId("hidden-agents-probe");
expect(probe.dataset.hidden).toBe("");
});
it("includes agent name when agent is assigned to a current story", () => {
const pipeline = makePipeline({
current: [
{
story_id: "85_assign_test",
name: "Assign Test",
error: null,
merge_failure: null,
agent: { agent_name: "coder-1", model: null, status: "running" },
review_hold: null,
qa: null,
depends_on: null,
},
],
});
render(
<LozengeFlyProvider pipeline={pipeline}>
<HiddenAgentsProbe />
</LozengeFlyProvider>,
);
const probe = screen.getByTestId("hidden-agents-probe");
expect(probe.dataset.hidden).toContain("coder-1");
});
it("excludes agent name when it has no assignment in the pipeline", () => {
const pipeline = makePipeline({
current: [
{
story_id: "85_no_agent",
name: "No Agent",
error: null,
merge_failure: null,
agent: null,
review_hold: null,
qa: null,
depends_on: null,
},
],
});
render(
<LozengeFlyProvider pipeline={pipeline}>
<HiddenAgentsProbe />
</LozengeFlyProvider>,
);
const probe = screen.getByTestId("hidden-agents-probe");
expect(probe.dataset.hidden).toBe("");
});
it("updates to include agent when pipeline transitions from no-agent to assigned", async () => {
const noPipeline = makePipeline();
const withAgent = makePipeline({
current: [
{
story_id: "85_transition_test",
name: "Transition",
error: null,
merge_failure: null,
agent: { agent_name: "coder-1", model: null, status: "running" },
review_hold: null,
qa: null,
depends_on: null,
},
],
});
const { rerender } = render(
<LozengeFlyProvider pipeline={noPipeline}>
<HiddenAgentsProbe />
</LozengeFlyProvider>,
);
let probe = screen.getByTestId("hidden-agents-probe");
expect(probe.dataset.hidden).toBe("");
await act(async () => {
rerender(
<LozengeFlyProvider pipeline={withAgent}>
<HiddenAgentsProbe />
</LozengeFlyProvider>,
);
});
probe = screen.getByTestId("hidden-agents-probe");
expect(probe.dataset.hidden).toContain("coder-1");
});
});
// ─── Bug 137: Race condition on rapid pipeline updates ────────────────────
describe("Bug 137: no animation actions lost during rapid pipeline updates", () => {
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("rapid agent swap: first timeout does not prematurely reveal slot lozenge", async () => {
const empty = makePipeline();
const withCoder1 = makePipeline({
current: [
{
story_id: "137_rapid_swap",
name: "Rapid Swap",
error: null,
merge_failure: null,
agent: { agent_name: "coder-1", model: "sonnet", status: "running" },
review_hold: null,
qa: null,
depends_on: null,
},
],
});
const withCoder2 = makePipeline({
current: [
{
story_id: "137_rapid_swap",
name: "Rapid Swap",
error: null,
merge_failure: null,
agent: { agent_name: "coder-2", model: "haiku", status: "running" },
review_hold: null,
qa: null,
depends_on: null,
},
],
});
const { rerender } = render(
<Wrapper pipeline={empty}>
<RosterFixture agentName="coder-1" />
<RosterFixture agentName="coder-2" />
<StagePanel title="Current" items={[]} />
</Wrapper>,
);
// First update: assign coder-1 → fly-in animation #1 starts
await act(async () => {
rerender(
<Wrapper pipeline={withCoder1}>
<RosterFixture agentName="coder-1" />
<RosterFixture agentName="coder-2" />
<StagePanel title="Current" items={withCoder1.current} />
</Wrapper>,
);
});
// Slot should be hidden (fly-in in progress)
const lozenge = screen.getByTestId("slot-lozenge-137_rapid_swap");
expect(lozenge.style.opacity).toBe("0");
// Rapid swap at 200ms: coder-1 → coder-2 (before first animation's 500ms timeout)
await act(async () => {
vi.advanceTimersByTime(200);
});
await act(async () => {
rerender(
<Wrapper pipeline={withCoder2}>
<RosterFixture agentName="coder-1" />
<RosterFixture agentName="coder-2" />
<StagePanel title="Current" items={withCoder2.current} />
</Wrapper>,
);
});
// Slot should still be hidden (new fly-in for coder-2 is in progress)
expect(lozenge.style.opacity).toBe("0");
// At 300ms after first animation started (500ms total from start),
// the FIRST animation's timeout fires. It must NOT reveal the slot.
await act(async () => {
vi.advanceTimersByTime(300);
});
// BUG: Without fix, the first timeout clears pendingFlyIns for this story,
// revealing the slot while coder-2's fly-in is still in progress.
expect(lozenge.style.opacity).toBe("0");
});
it("slot lozenge reveals correctly after the LAST animation completes", async () => {
const empty = makePipeline();
const withCoder1 = makePipeline({
current: [
{
story_id: "137_reveal_last",
name: "Reveal Last",
error: null,
merge_failure: null,
agent: { agent_name: "coder-1", model: null, status: "running" },
review_hold: null,
qa: null,
depends_on: null,
},
],
});
const withCoder2 = makePipeline({
current: [
{
story_id: "137_reveal_last",
name: "Reveal Last",
error: null,
merge_failure: null,
agent: { agent_name: "coder-2", model: null, status: "running" },
review_hold: null,
qa: null,
depends_on: null,
},
],
});
const { rerender } = render(
<Wrapper pipeline={empty}>
<RosterFixture agentName="coder-1" />
<RosterFixture agentName="coder-2" />
<StagePanel title="Current" items={[]} />
</Wrapper>,
);
// First animation
await act(async () => {
rerender(
<Wrapper pipeline={withCoder1}>
<RosterFixture agentName="coder-1" />
<RosterFixture agentName="coder-2" />
<StagePanel title="Current" items={withCoder1.current} />
</Wrapper>,
);
});
// Swap at 200ms
await act(async () => {
vi.advanceTimersByTime(200);
});
await act(async () => {
rerender(
<Wrapper pipeline={withCoder2}>
<RosterFixture agentName="coder-1" />
<RosterFixture agentName="coder-2" />
<StagePanel title="Current" items={withCoder2.current} />
</Wrapper>,
);
});
const lozenge = screen.getByTestId("slot-lozenge-137_reveal_last");
// After the second animation's full 500ms, slot should reveal
await act(async () => {
vi.advanceTimersByTime(600);
});
expect(lozenge.style.opacity).toBe("1");
});
});
describe("Bug 137: animations remain functional through sustained agent activity", () => {
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("fly-in still works after multiple rapid swaps have completed", async () => {
const empty = makePipeline();
const makeWith = (agentName: string) =>
makePipeline({
current: [
{
story_id: "137_sustained",
name: "Sustained",
error: null,
merge_failure: null,
agent: { agent_name: agentName, model: null, status: "running" },
review_hold: null,
qa: null,
depends_on: null,
},
],
});
const { rerender } = render(
<Wrapper pipeline={empty}>
<RosterFixture agentName="coder-1" />
<RosterFixture agentName="coder-2" />
<RosterFixture agentName="coder-3" />
<StagePanel title="Current" items={[]} />
</Wrapper>,
);
// Rapid-fire: assign coder-1, then swap to coder-2 at 100ms
const p1 = makeWith("coder-1");
await act(async () => {
rerender(
<Wrapper pipeline={p1}>
<RosterFixture agentName="coder-1" />
<RosterFixture agentName="coder-2" />
<RosterFixture agentName="coder-3" />
<StagePanel title="Current" items={p1.current} />
</Wrapper>,
);
});
await act(async () => {
vi.advanceTimersByTime(100);
});
const p2 = makeWith("coder-2");
await act(async () => {
rerender(
<Wrapper pipeline={p2}>
<RosterFixture agentName="coder-1" />
<RosterFixture agentName="coder-2" />
<RosterFixture agentName="coder-3" />
<StagePanel title="Current" items={p2.current} />
</Wrapper>,
);
});
// Let all animations complete
await act(async () => {
vi.advanceTimersByTime(1000);
});
const lozenge = screen.getByTestId("slot-lozenge-137_sustained");
expect(lozenge.style.opacity).toBe("1");
// Now assign coder-3 — a fresh fly-in should still work
const p3 = makeWith("coder-3");
await act(async () => {
rerender(
<Wrapper pipeline={p3}>
<RosterFixture agentName="coder-1" />
<RosterFixture agentName="coder-2" />
<RosterFixture agentName="coder-3" />
<StagePanel title="Current" items={p3.current} />
</Wrapper>,
);
});
// Slot should be hidden again for the new fly-in
expect(lozenge.style.opacity).toBe("0");
// A flying clone should exist
const clone = document.body.querySelector(
'[data-testid^="flying-lozenge-fly-in"]',
);
expect(clone).not.toBeNull();
// After animation completes, slot reveals
await act(async () => {
vi.advanceTimersByTime(600);
});
expect(lozenge.style.opacity).toBe("1");
});
});