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 { 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(null); React.useEffect(() => { const el = ref.current; if (el) registerRosterEl(agentName, el); return () => registerRosterEl(agentName, null); }, [agentName, registerRosterEl]); return ( ); } /** Reads hiddenRosterAgents from context and exposes it via a data attribute. */ function HiddenAgentsProbe() { const { hiddenRosterAgents } = useLozengeFly(); return (
); } function Wrapper({ pipeline, children, }: { pipeline: PipelineState; children: React.ReactNode; }) { return ( {children} ); } // ─── hiddenRosterAgents: no-duplicate guarantee ─────────────────────────────── describe("hiddenRosterAgents: assigned agents are absent from roster", () => { it("is empty when no agents are in the pipeline", () => { render( , ); 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( , ); 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( , ); 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( , ); let probe = screen.getByTestId("hidden-agents-probe"); expect(probe.dataset.hidden).toBe(""); await act(async () => { rerender( , ); }); 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( , ); // First update: assign coder-1 → fly-in animation #1 starts await act(async () => { rerender( , ); }); // 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( , ); }); // 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( , ); // First animation await act(async () => { rerender( , ); }); // Swap at 200ms await act(async () => { vi.advanceTimersByTime(200); }); await act(async () => { rerender( , ); }); 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( , ); // Rapid-fire: assign coder-1, then swap to coder-2 at 100ms const p1 = makeWith("coder-1"); await act(async () => { rerender( , ); }); await act(async () => { vi.advanceTimersByTime(100); }); const p2 = makeWith("coder-2"); await act(async () => { rerender( , ); }); // 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( , ); }); // 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"); }); });