diff --git a/frontend/src/components/LozengeFlyContext.agentlozenge.test.tsx b/frontend/src/components/LozengeFlyContext.agentlozenge.test.tsx new file mode 100644 index 00000000..50316cc8 --- /dev/null +++ b/frontend/src/components/LozengeFlyContext.agentlozenge.test.tsx @@ -0,0 +1,148 @@ +import { render } from "@testing-library/react"; +import * as React from "react"; +import { describe, expect, it } from "vitest"; +import type { PipelineState } from "../api/client"; +import { LozengeFlyProvider } from "./LozengeFlyContext"; +import { StagePanel } from "./StagePanel"; + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +function makePipeline(overrides: Partial = {}): PipelineState { + return { + backlog: [], + current: [], + qa: [], + merge: [], + done: [], + ...overrides, + }; +} + +function Wrapper({ + pipeline, + children, +}: { + pipeline: PipelineState; + children: React.ReactNode; +}) { + return ( + {children} + ); +} + +// ─── Agent lozenge fixed intrinsic width ────────────────────────────────────── + +describe("AgentLozenge fixed intrinsic width", () => { + it("has align-self: flex-start so it never stretches inside a flex column", () => { + const items = [ + { + story_id: "74_width_test", + name: "Width Test", + error: null, + merge_failure: null, + agent: { agent_name: "coder-1", model: "sonnet", status: "running" }, + review_hold: null, + qa: null, + depends_on: null, + }, + ]; + const pipeline = makePipeline({ current: items }); + const { container } = render( + + + , + ); + + const lozenge = container.querySelector( + '[data-testid="slot-lozenge-74_width_test"]', + ) as HTMLElement; + expect(lozenge).toBeInTheDocument(); + expect(lozenge.style.alignSelf).toBe("flex-start"); + }); +}); + +// ─── Idle vs active visual distinction ──────────────────────────────────────── + +describe("AgentLozenge idle vs active appearance", () => { + it("running agent lozenge uses the green active color", () => { + const items = [ + { + story_id: "74_running_color", + name: "Running", + error: null, + merge_failure: null, + agent: { agent_name: "coder-1", model: null, status: "running" }, + review_hold: null, + qa: null, + depends_on: null, + }, + ]; + const { container } = render( + + + , + ); + + const lozenge = container.querySelector( + '[data-testid="slot-lozenge-74_running_color"]', + ) as HTMLElement; + expect(lozenge).toBeInTheDocument(); + // Green: rgb(63, 185, 80) = #3fb950 + expect(lozenge.style.color).toBe("rgb(63, 185, 80)"); + }); + + it("pending agent lozenge uses the yellow pending color", () => { + const items = [ + { + story_id: "74_pending_color", + name: "Pending", + error: null, + merge_failure: null, + agent: { agent_name: "coder-1", model: null, status: "pending" }, + review_hold: null, + qa: null, + depends_on: null, + }, + ]; + const { container } = render( + + + , + ); + + const lozenge = container.querySelector( + '[data-testid="slot-lozenge-74_pending_color"]', + ) as HTMLElement; + expect(lozenge).toBeInTheDocument(); + // Yellow: rgb(227, 179, 65) = #e3b341 + expect(lozenge.style.color).toBe("rgb(227, 179, 65)"); + }); + + it("running lozenge has a pulsing dot child element", () => { + const items = [ + { + story_id: "74_pulse_dot", + name: "Pulse", + error: null, + merge_failure: null, + agent: { agent_name: "coder-1", model: null, status: "running" }, + review_hold: null, + qa: null, + depends_on: null, + }, + ]; + const { container } = render( + + + , + ); + + const lozenge = container.querySelector( + '[data-testid="slot-lozenge-74_pulse_dot"]', + ) as HTMLElement; + // The pulse dot is a child span with animation: pulse + const dot = lozenge.querySelector("span"); + expect(dot).not.toBeNull(); + expect(dot?.style.animation).toContain("pulse"); + }); +}); diff --git a/frontend/src/components/LozengeFlyContext.flyin.test.tsx b/frontend/src/components/LozengeFlyContext.flyin.test.tsx new file mode 100644 index 00000000..c730ef4b --- /dev/null +++ b/frontend/src/components/LozengeFlyContext.flyin.test.tsx @@ -0,0 +1,403 @@ +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: [], + ...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 ( + + ); +} + +function Wrapper({ + pipeline, + children, +}: { + pipeline: PipelineState; + children: React.ReactNode; +}) { + return ( + {children} + ); +} + +// ─── Fly-in: slot lozenge visibility ───────────────────────────────────────── + +describe("LozengeFlyProvider fly-in visibility", () => { + beforeEach(() => { + 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.restoreAllMocks(); + }); + + it("slot lozenge starts hidden when a matching roster element exists", async () => { + const noPipeline = makePipeline(); + const withAgent = makePipeline({ + current: [ + { + story_id: "74_hidden_test", + name: "Hidden Test", + 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( + + + + , + ); + + // Rerender with the agent assigned + await act(async () => { + rerender( + + + + , + ); + }); + + const lozenge = screen.getByTestId("slot-lozenge-74_hidden_test"); + // Hidden while fly-in is in progress + expect(lozenge.style.opacity).toBe("0"); + }); + + it("slot lozenge is visible when no roster element is registered", async () => { + const noPipeline = makePipeline(); + const withAgent = makePipeline({ + current: [ + { + story_id: "74_no_roster", + name: "No Roster", + error: null, + merge_failure: null, + agent: { + agent_name: "unknown-agent", + model: null, + status: "running", + }, + review_hold: null, + qa: null, + depends_on: null, + }, + ], + }); + + const { rerender } = render( + + {/* No RosterFixture for "unknown-agent" */} + + , + ); + + await act(async () => { + rerender( + + + , + ); + }); + + const lozenge = screen.getByTestId("slot-lozenge-74_no_roster"); + // Immediately visible because no fly-in animation is possible + expect(lozenge.style.opacity).toBe("1"); + }); +}); + +// ─── Fly-in: flying clone in document.body portal ──────────────────────────── + +describe("LozengeFlyProvider fly-in clone", () => { + 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("renders a fixed-position clone in document.body when fly-in triggers", async () => { + const noPipeline = makePipeline(); + const withAgent = makePipeline({ + current: [ + { + story_id: "74_portal_test", + name: "Portal Test", + error: null, + merge_failure: null, + agent: { agent_name: "coder-1", model: "sonnet", status: "running" }, + review_hold: null, + qa: null, + depends_on: null, + }, + ], + }); + + const { rerender } = render( + + + + , + ); + + await act(async () => { + rerender( + + + + , + ); + vi.runAllTimers(); + }); + + // Clone is in document.body (portal), not inside the component container + const clone = document.body.querySelector( + '[data-testid^="flying-lozenge-fly-in"]', + ) as HTMLElement | null; + expect(clone).not.toBeNull(); + expect(clone?.style.position).toBe("fixed"); + expect(Number(clone?.style.zIndex)).toBeGreaterThanOrEqual(9999); + expect(clone?.style.pointerEvents).toBe("none"); + }); + + it("clone is removed from document.body after 500 ms", async () => { + const noPipeline = makePipeline(); + const withAgent = makePipeline({ + current: [ + { + story_id: "74_clone_remove", + name: "Clone Remove", + 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( + + + + , + ); + + await act(async () => { + rerender( + + + + , + ); + }); + + // Clone should exist before timeout + const cloneBefore = document.body.querySelector( + '[data-testid^="flying-lozenge-fly-in"]', + ); + expect(cloneBefore).not.toBeNull(); + + // Advance past the 500ms cleanup timeout + await act(async () => { + vi.advanceTimersByTime(600); + }); + + const cloneAfter = document.body.querySelector( + '[data-testid^="flying-lozenge-fly-in"]', + ); + expect(cloneAfter).toBeNull(); + }); + + it("slot lozenge becomes visible (opacity 1) after 500 ms timeout", async () => { + const noPipeline = makePipeline(); + const withAgent = makePipeline({ + current: [ + { + story_id: "74_reveal_test", + name: "Reveal Test", + 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( + + + + , + ); + + await act(async () => { + rerender( + + + + , + ); + }); + + // Initially hidden + const lozenge = screen.getByTestId("slot-lozenge-74_reveal_test"); + expect(lozenge.style.opacity).toBe("0"); + + // After 500ms the slot becomes visible + await act(async () => { + vi.advanceTimersByTime(600); + }); + + expect(lozenge.style.opacity).toBe("1"); + }); +}); + +// ─── 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, + merge_failure: null, + agent: { agent_name: "coder-1", model: null, status: "running" }, + review_hold: null, + qa: null, + depends_on: null, + }, + ], + }); + + 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/LozengeFlyContext.flyout.test.tsx b/frontend/src/components/LozengeFlyContext.flyout.test.tsx new file mode 100644 index 00000000..59326fc4 --- /dev/null +++ b/frontend/src/components/LozengeFlyContext.flyout.test.tsx @@ -0,0 +1,482 @@ +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: [], + ...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} + ); +} + +// ─── Fly-out animation ──────────────────────────────────────────────────────── + +describe("LozengeFlyProvider fly-out", () => { + 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("creates a fly-out clone in document.body when agent is removed", async () => { + const withAgent = makePipeline({ + current: [ + { + story_id: "74_fly_out_test", + name: "Fly Out Test", + error: null, + merge_failure: null, + agent: { agent_name: "coder-1", model: "haiku", status: "completed" }, + review_hold: null, + qa: null, + depends_on: null, + }, + ], + }); + + const { rerender } = render( + + + + , + ); + + // Advance past initial fly-in animation to get a clean state + await act(async () => { + vi.advanceTimersByTime(600); + }); + + // Remove the agent from the pipeline + const noAgent = makePipeline({ + current: [ + { + story_id: "74_fly_out_test", + name: "Fly Out Test", + error: null, + merge_failure: null, + agent: null, + review_hold: null, + qa: null, + depends_on: null, + }, + ], + }); + + await act(async () => { + rerender( + + + + , + ); + }); + + // A fly-out clone should now be in document.body + const clone = document.body.querySelector( + '[data-testid^="flying-lozenge-fly-out"]', + ); + expect(clone).not.toBeNull(); + }); +}); + +// ─── 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, + 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: "109_swap_test", + name: "Swap Test", + 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( + + + + + + , + ); + + // 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, + merge_failure: null, + agent: { + agent_name: "orphan-agent", + model: null, + status: "completed", + }, + review_hold: null, + qa: null, + depends_on: null, + }, + ], + }); + const noAgent = makePipeline({ + current: [ + { + story_id: "109_no_roster_flyout", + name: "No Roster Flyout", + error: null, + merge_failure: null, + agent: null, + review_hold: null, + qa: null, + depends_on: 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(); + }); +}); + +// ─── hiddenRosterAgents: fly-out keeps agent hidden until clone lands ───── + +describe("hiddenRosterAgents: fly-out keeps agent hidden until clone lands", () => { + 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("agent stays hidden in roster during fly-out (0–499 ms)", async () => { + const withAgent = makePipeline({ + current: [ + { + story_id: "85_flyout_hidden", + name: "Fly-out Hidden", + error: null, + merge_failure: null, + agent: { agent_name: "coder-1", model: null, status: "completed" }, + review_hold: null, + qa: null, + depends_on: null, + }, + ], + }); + const noAgent = makePipeline({ + current: [ + { + story_id: "85_flyout_hidden", + name: "Fly-out Hidden", + error: null, + merge_failure: null, + agent: null, + review_hold: null, + qa: null, + depends_on: null, + }, + ], + }); + + const { rerender } = render( + + + + + , + ); + + // Advance past the initial fly-in + await act(async () => { + vi.advanceTimersByTime(600); + }); + + // Remove agent — fly-out starts + await act(async () => { + rerender( + + + + + , + ); + }); + + // Agent should still be hidden (fly-out clone is in flight) + const probe = screen.getByTestId("hidden-agents-probe"); + expect(probe.dataset.hidden).toContain("coder-1"); + }); + + it("agent reappears in roster after fly-out clone lands (500 ms)", async () => { + const withAgent = makePipeline({ + current: [ + { + story_id: "85_flyout_reveal", + name: "Fly-out Reveal", + error: null, + merge_failure: null, + agent: { agent_name: "coder-1", model: null, status: "completed" }, + review_hold: null, + qa: null, + depends_on: null, + }, + ], + }); + const noAgent = makePipeline({ + current: [ + { + story_id: "85_flyout_reveal", + name: "Fly-out Reveal", + error: null, + merge_failure: null, + agent: null, + review_hold: null, + qa: null, + depends_on: null, + }, + ], + }); + + const { rerender } = render( + + + + + , + ); + + await act(async () => { + vi.advanceTimersByTime(600); + }); + + await act(async () => { + rerender( + + + + + , + ); + }); + + // Advance past fly-out animation + await act(async () => { + vi.advanceTimersByTime(600); + }); + + // Agent should now be visible in roster + const probe = screen.getByTestId("hidden-agents-probe"); + expect(probe.dataset.hidden).toBe(""); + }); +}); diff --git a/frontend/src/components/LozengeFlyContext.hidden.test.tsx b/frontend/src/components/LozengeFlyContext.hidden.test.tsx new file mode 100644 index 00000000..34466dd5 --- /dev/null +++ b/frontend/src/components/LozengeFlyContext.hidden.test.tsx @@ -0,0 +1,466 @@ +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: [], + ...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"); + }); +}); diff --git a/frontend/src/components/LozengeFlyContext.test.tsx b/frontend/src/components/LozengeFlyContext.test.tsx deleted file mode 100644 index fba8f748..00000000 --- a/frontend/src/components/LozengeFlyContext.test.tsx +++ /dev/null @@ -1,1357 +0,0 @@ -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: [], - ...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 ( - - ); -} - -function Wrapper({ - pipeline, - children, -}: { - pipeline: PipelineState; - children: React.ReactNode; -}) { - return ( - {children} - ); -} - -// ─── Agent lozenge fixed intrinsic width ────────────────────────────────────── - -describe("AgentLozenge fixed intrinsic width", () => { - it("has align-self: flex-start so it never stretches inside a flex column", () => { - const items = [ - { - story_id: "74_width_test", - name: "Width Test", - error: null, - merge_failure: null, - agent: { agent_name: "coder-1", model: "sonnet", status: "running" }, - review_hold: null, - qa: null, - depends_on: null, - }, - ]; - const pipeline = makePipeline({ current: items }); - const { container } = render( - - - , - ); - - const lozenge = container.querySelector( - '[data-testid="slot-lozenge-74_width_test"]', - ) as HTMLElement; - expect(lozenge).toBeInTheDocument(); - expect(lozenge.style.alignSelf).toBe("flex-start"); - }); -}); - -// ─── Fly-in: slot lozenge visibility ───────────────────────────────────────── - -describe("LozengeFlyProvider fly-in visibility", () => { - beforeEach(() => { - 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.restoreAllMocks(); - }); - - it("slot lozenge starts hidden when a matching roster element exists", async () => { - const noPipeline = makePipeline(); - const withAgent = makePipeline({ - current: [ - { - story_id: "74_hidden_test", - name: "Hidden Test", - 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( - - - - , - ); - - // Rerender with the agent assigned - await act(async () => { - rerender( - - - - , - ); - }); - - const lozenge = screen.getByTestId("slot-lozenge-74_hidden_test"); - // Hidden while fly-in is in progress - expect(lozenge.style.opacity).toBe("0"); - }); - - it("slot lozenge is visible when no roster element is registered", async () => { - const noPipeline = makePipeline(); - const withAgent = makePipeline({ - current: [ - { - story_id: "74_no_roster", - name: "No Roster", - error: null, - merge_failure: null, - agent: { - agent_name: "unknown-agent", - model: null, - status: "running", - }, - review_hold: null, - qa: null, - depends_on: null, - }, - ], - }); - - const { rerender } = render( - - {/* No RosterFixture for "unknown-agent" */} - - , - ); - - await act(async () => { - rerender( - - - , - ); - }); - - const lozenge = screen.getByTestId("slot-lozenge-74_no_roster"); - // Immediately visible because no fly-in animation is possible - expect(lozenge.style.opacity).toBe("1"); - }); -}); - -// ─── Fly-in: flying clone in document.body portal ──────────────────────────── - -describe("LozengeFlyProvider fly-in clone", () => { - 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("renders a fixed-position clone in document.body when fly-in triggers", async () => { - const noPipeline = makePipeline(); - const withAgent = makePipeline({ - current: [ - { - story_id: "74_portal_test", - name: "Portal Test", - error: null, - merge_failure: null, - agent: { agent_name: "coder-1", model: "sonnet", status: "running" }, - review_hold: null, - qa: null, - depends_on: null, - }, - ], - }); - - const { rerender } = render( - - - - , - ); - - await act(async () => { - rerender( - - - - , - ); - vi.runAllTimers(); - }); - - // Clone is in document.body (portal), not inside the component container - const clone = document.body.querySelector( - '[data-testid^="flying-lozenge-fly-in"]', - ) as HTMLElement | null; - expect(clone).not.toBeNull(); - expect(clone?.style.position).toBe("fixed"); - expect(Number(clone?.style.zIndex)).toBeGreaterThanOrEqual(9999); - expect(clone?.style.pointerEvents).toBe("none"); - }); - - it("clone is removed from document.body after 500 ms", async () => { - const noPipeline = makePipeline(); - const withAgent = makePipeline({ - current: [ - { - story_id: "74_clone_remove", - name: "Clone Remove", - 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( - - - - , - ); - - await act(async () => { - rerender( - - - - , - ); - }); - - // Clone should exist before timeout - const cloneBefore = document.body.querySelector( - '[data-testid^="flying-lozenge-fly-in"]', - ); - expect(cloneBefore).not.toBeNull(); - - // Advance past the 500ms cleanup timeout - await act(async () => { - vi.advanceTimersByTime(600); - }); - - const cloneAfter = document.body.querySelector( - '[data-testid^="flying-lozenge-fly-in"]', - ); - expect(cloneAfter).toBeNull(); - }); - - it("slot lozenge becomes visible (opacity 1) after 500 ms timeout", async () => { - const noPipeline = makePipeline(); - const withAgent = makePipeline({ - current: [ - { - story_id: "74_reveal_test", - name: "Reveal Test", - 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( - - - - , - ); - - await act(async () => { - rerender( - - - - , - ); - }); - - // Initially hidden - const lozenge = screen.getByTestId("slot-lozenge-74_reveal_test"); - expect(lozenge.style.opacity).toBe("0"); - - // After 500ms the slot becomes visible - await act(async () => { - vi.advanceTimersByTime(600); - }); - - expect(lozenge.style.opacity).toBe("1"); - }); -}); - -// ─── Fly-out animation ──────────────────────────────────────────────────────── - -describe("LozengeFlyProvider fly-out", () => { - 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("creates a fly-out clone in document.body when agent is removed", async () => { - const withAgent = makePipeline({ - current: [ - { - story_id: "74_fly_out_test", - name: "Fly Out Test", - error: null, - merge_failure: null, - agent: { agent_name: "coder-1", model: "haiku", status: "completed" }, - review_hold: null, - qa: null, - depends_on: null, - }, - ], - }); - - const { rerender } = render( - - - - , - ); - - // Advance past initial fly-in animation to get a clean state - await act(async () => { - vi.advanceTimersByTime(600); - }); - - // Remove the agent from the pipeline - const noAgent = makePipeline({ - current: [ - { - story_id: "74_fly_out_test", - name: "Fly Out Test", - error: null, - merge_failure: null, - agent: null, - review_hold: null, - qa: null, - depends_on: null, - }, - ], - }); - - await act(async () => { - rerender( - - - - , - ); - }); - - // A fly-out clone should now be in document.body - const clone = document.body.querySelector( - '[data-testid^="flying-lozenge-fly-out"]', - ); - expect(clone).not.toBeNull(); - }); -}); - -// ─── Idle vs active visual distinction ──────────────────────────────────────── - -describe("AgentLozenge idle vs active appearance", () => { - it("running agent lozenge uses the green active color", () => { - const items = [ - { - story_id: "74_running_color", - name: "Running", - error: null, - merge_failure: null, - agent: { agent_name: "coder-1", model: null, status: "running" }, - review_hold: null, - qa: null, - depends_on: null, - }, - ]; - const { container } = render( - - - , - ); - - const lozenge = container.querySelector( - '[data-testid="slot-lozenge-74_running_color"]', - ) as HTMLElement; - expect(lozenge).toBeInTheDocument(); - // Green: rgb(63, 185, 80) = #3fb950 - expect(lozenge.style.color).toBe("rgb(63, 185, 80)"); - }); - - it("pending agent lozenge uses the yellow pending color", () => { - const items = [ - { - story_id: "74_pending_color", - name: "Pending", - error: null, - merge_failure: null, - agent: { agent_name: "coder-1", model: null, status: "pending" }, - review_hold: null, - qa: null, - depends_on: null, - }, - ]; - const { container } = render( - - - , - ); - - const lozenge = container.querySelector( - '[data-testid="slot-lozenge-74_pending_color"]', - ) as HTMLElement; - expect(lozenge).toBeInTheDocument(); - // Yellow: rgb(227, 179, 65) = #e3b341 - expect(lozenge.style.color).toBe("rgb(227, 179, 65)"); - }); - - it("running lozenge has a pulsing dot child element", () => { - const items = [ - { - story_id: "74_pulse_dot", - name: "Pulse", - error: null, - merge_failure: null, - agent: { agent_name: "coder-1", model: null, status: "running" }, - review_hold: null, - qa: null, - depends_on: null, - }, - ]; - const { container } = render( - - - , - ); - - const lozenge = container.querySelector( - '[data-testid="slot-lozenge-74_pulse_dot"]', - ) as HTMLElement; - // The pulse dot is a child span with animation: pulse - const dot = lozenge.querySelector("span"); - expect(dot).not.toBeNull(); - expect(dot?.style.animation).toContain("pulse"); - }); -}); - -// ─── hiddenRosterAgents: no-duplicate guarantee ─────────────────────────────── - -/** Reads hiddenRosterAgents from context and exposes it via a data attribute. */ -function HiddenAgentsProbe() { - const { hiddenRosterAgents } = useLozengeFly(); - return ( -
- ); -} - -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"); - }); -}); - -describe("hiddenRosterAgents: fly-out keeps agent hidden until clone lands", () => { - 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("agent stays hidden in roster during fly-out (0–499 ms)", async () => { - const withAgent = makePipeline({ - current: [ - { - story_id: "85_flyout_hidden", - name: "Fly-out Hidden", - error: null, - merge_failure: null, - agent: { agent_name: "coder-1", model: null, status: "completed" }, - review_hold: null, - qa: null, - depends_on: null, - }, - ], - }); - const noAgent = makePipeline({ - current: [ - { - story_id: "85_flyout_hidden", - name: "Fly-out Hidden", - error: null, - merge_failure: null, - agent: null, - review_hold: null, - qa: null, - depends_on: null, - }, - ], - }); - - const { rerender } = render( - - - - - , - ); - - // Advance past the initial fly-in - await act(async () => { - vi.advanceTimersByTime(600); - }); - - // Remove agent — fly-out starts - await act(async () => { - rerender( - - - - - , - ); - }); - - // Agent should still be hidden (fly-out clone is in flight) - const probe = screen.getByTestId("hidden-agents-probe"); - expect(probe.dataset.hidden).toContain("coder-1"); - }); - - it("agent reappears in roster after fly-out clone lands (500 ms)", async () => { - const withAgent = makePipeline({ - current: [ - { - story_id: "85_flyout_reveal", - name: "Fly-out Reveal", - error: null, - merge_failure: null, - agent: { agent_name: "coder-1", model: null, status: "completed" }, - review_hold: null, - qa: null, - depends_on: null, - }, - ], - }); - const noAgent = makePipeline({ - current: [ - { - story_id: "85_flyout_reveal", - name: "Fly-out Reveal", - error: null, - merge_failure: null, - agent: null, - review_hold: null, - qa: null, - depends_on: null, - }, - ], - }); - - const { rerender } = render( - - - - - , - ); - - await act(async () => { - vi.advanceTimersByTime(600); - }); - - await act(async () => { - rerender( - - - - - , - ); - }); - - // Advance past fly-out animation - await act(async () => { - vi.advanceTimersByTime(600); - }); - - // Agent should now be visible in roster - const probe = screen.getByTestId("hidden-agents-probe"); - 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, - 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: "109_swap_test", - name: "Swap Test", - 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( - - - - - - , - ); - - // 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, - merge_failure: null, - agent: { - agent_name: "orphan-agent", - model: null, - status: "completed", - }, - review_hold: null, - qa: null, - depends_on: null, - }, - ], - }); - const noAgent = makePipeline({ - current: [ - { - story_id: "109_no_roster_flyout", - name: "No Roster Flyout", - error: null, - merge_failure: null, - agent: null, - review_hold: null, - qa: null, - depends_on: 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, - merge_failure: null, - agent: { agent_name: "coder-1", model: null, status: "running" }, - review_hold: null, - qa: null, - depends_on: null, - }, - ], - }); - - 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); - }); - }); -}); - -// ─── 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"); - }); -});