2026-02-23 15:04:10 +00:00
|
|
|
|
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 {
|
|
|
|
|
|
upcoming: [],
|
|
|
|
|
|
current: [],
|
|
|
|
|
|
qa: [],
|
|
|
|
|
|
merge: [],
|
|
|
|
|
|
...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 }}
|
|
|
|
|
|
/>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function Wrapper({
|
|
|
|
|
|
pipeline,
|
|
|
|
|
|
children,
|
|
|
|
|
|
}: {
|
|
|
|
|
|
pipeline: PipelineState;
|
|
|
|
|
|
children: React.ReactNode;
|
|
|
|
|
|
}) {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<LozengeFlyProvider pipeline={pipeline}>{children}</LozengeFlyProvider>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ─── 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,
|
|
|
|
|
|
agent: { agent_name: "coder-1", model: "sonnet", status: "running" },
|
|
|
|
|
|
},
|
|
|
|
|
|
];
|
|
|
|
|
|
const pipeline = makePipeline({ current: items });
|
|
|
|
|
|
const { container } = render(
|
|
|
|
|
|
<Wrapper pipeline={pipeline}>
|
|
|
|
|
|
<StagePanel title="Current" items={items} />
|
|
|
|
|
|
</Wrapper>,
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
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,
|
|
|
|
|
|
agent: { agent_name: "coder-1", model: null, status: "running" },
|
|
|
|
|
|
},
|
|
|
|
|
|
],
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const { rerender } = render(
|
|
|
|
|
|
<Wrapper pipeline={noPipeline}>
|
|
|
|
|
|
<RosterFixture agentName="coder-1" />
|
|
|
|
|
|
<StagePanel title="Current" items={[]} />
|
|
|
|
|
|
</Wrapper>,
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
// Rerender with the agent assigned
|
|
|
|
|
|
await act(async () => {
|
|
|
|
|
|
rerender(
|
|
|
|
|
|
<Wrapper pipeline={withAgent}>
|
|
|
|
|
|
<RosterFixture agentName="coder-1" />
|
|
|
|
|
|
<StagePanel title="Current" items={withAgent.current} />
|
|
|
|
|
|
</Wrapper>,
|
|
|
|
|
|
);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
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,
|
|
|
|
|
|
agent: {
|
|
|
|
|
|
agent_name: "unknown-agent",
|
|
|
|
|
|
model: null,
|
|
|
|
|
|
status: "running",
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
],
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const { rerender } = render(
|
|
|
|
|
|
<Wrapper pipeline={noPipeline}>
|
|
|
|
|
|
{/* No RosterFixture for "unknown-agent" */}
|
|
|
|
|
|
<StagePanel title="Current" items={[]} />
|
|
|
|
|
|
</Wrapper>,
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
await act(async () => {
|
|
|
|
|
|
rerender(
|
|
|
|
|
|
<Wrapper pipeline={withAgent}>
|
|
|
|
|
|
<StagePanel title="Current" items={withAgent.current} />
|
|
|
|
|
|
</Wrapper>,
|
|
|
|
|
|
);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
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,
|
|
|
|
|
|
agent: { agent_name: "coder-1", model: "sonnet", status: "running" },
|
|
|
|
|
|
},
|
|
|
|
|
|
],
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const { rerender } = render(
|
|
|
|
|
|
<Wrapper pipeline={noPipeline}>
|
|
|
|
|
|
<RosterFixture agentName="coder-1" />
|
|
|
|
|
|
<StagePanel title="Current" items={[]} />
|
|
|
|
|
|
</Wrapper>,
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
await act(async () => {
|
|
|
|
|
|
rerender(
|
|
|
|
|
|
<Wrapper pipeline={withAgent}>
|
|
|
|
|
|
<RosterFixture agentName="coder-1" />
|
|
|
|
|
|
<StagePanel title="Current" items={withAgent.current} />
|
|
|
|
|
|
</Wrapper>,
|
|
|
|
|
|
);
|
|
|
|
|
|
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,
|
|
|
|
|
|
agent: { agent_name: "coder-1", model: null, status: "running" },
|
|
|
|
|
|
},
|
|
|
|
|
|
],
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const { rerender } = render(
|
|
|
|
|
|
<Wrapper pipeline={noPipeline}>
|
|
|
|
|
|
<RosterFixture agentName="coder-1" />
|
|
|
|
|
|
<StagePanel title="Current" items={[]} />
|
|
|
|
|
|
</Wrapper>,
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
await act(async () => {
|
|
|
|
|
|
rerender(
|
|
|
|
|
|
<Wrapper pipeline={withAgent}>
|
|
|
|
|
|
<RosterFixture agentName="coder-1" />
|
|
|
|
|
|
<StagePanel title="Current" items={withAgent.current} />
|
|
|
|
|
|
</Wrapper>,
|
|
|
|
|
|
);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// 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,
|
|
|
|
|
|
agent: { agent_name: "coder-1", model: null, status: "running" },
|
|
|
|
|
|
},
|
|
|
|
|
|
],
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const { rerender } = render(
|
|
|
|
|
|
<Wrapper pipeline={noPipeline}>
|
|
|
|
|
|
<RosterFixture agentName="coder-1" />
|
|
|
|
|
|
<StagePanel title="Current" items={[]} />
|
|
|
|
|
|
</Wrapper>,
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
await act(async () => {
|
|
|
|
|
|
rerender(
|
|
|
|
|
|
<Wrapper pipeline={withAgent}>
|
|
|
|
|
|
<RosterFixture agentName="coder-1" />
|
|
|
|
|
|
<StagePanel title="Current" items={withAgent.current} />
|
|
|
|
|
|
</Wrapper>,
|
|
|
|
|
|
);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// 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,
|
|
|
|
|
|
agent: { agent_name: "coder-1", model: "haiku", status: "completed" },
|
|
|
|
|
|
},
|
|
|
|
|
|
],
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const { rerender } = render(
|
|
|
|
|
|
<Wrapper pipeline={withAgent}>
|
|
|
|
|
|
<RosterFixture agentName="coder-1" />
|
|
|
|
|
|
<StagePanel title="Current" items={withAgent.current} />
|
|
|
|
|
|
</Wrapper>,
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
// 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,
|
|
|
|
|
|
agent: null,
|
|
|
|
|
|
},
|
|
|
|
|
|
],
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
await act(async () => {
|
|
|
|
|
|
rerender(
|
|
|
|
|
|
<Wrapper pipeline={noAgent}>
|
|
|
|
|
|
<RosterFixture agentName="coder-1" />
|
|
|
|
|
|
<StagePanel title="Current" items={noAgent.current} />
|
|
|
|
|
|
</Wrapper>,
|
|
|
|
|
|
);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// 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", () => {
|
2026-02-23 18:23:01 +00:00
|
|
|
|
it("running agent lozenge uses the green active color", () => {
|
2026-02-23 15:04:10 +00:00
|
|
|
|
const items = [
|
|
|
|
|
|
{
|
|
|
|
|
|
story_id: "74_running_color",
|
|
|
|
|
|
name: "Running",
|
|
|
|
|
|
error: null,
|
|
|
|
|
|
agent: { agent_name: "coder-1", model: null, status: "running" },
|
|
|
|
|
|
},
|
|
|
|
|
|
];
|
|
|
|
|
|
const { container } = render(
|
|
|
|
|
|
<Wrapper pipeline={makePipeline({ current: items })}>
|
|
|
|
|
|
<StagePanel title="Current" items={items} />
|
|
|
|
|
|
</Wrapper>,
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
const lozenge = container.querySelector(
|
|
|
|
|
|
'[data-testid="slot-lozenge-74_running_color"]',
|
|
|
|
|
|
) as HTMLElement;
|
|
|
|
|
|
expect(lozenge).toBeInTheDocument();
|
2026-02-23 18:23:01 +00:00
|
|
|
|
// Green: rgb(63, 185, 80) = #3fb950
|
|
|
|
|
|
expect(lozenge.style.color).toBe("rgb(63, 185, 80)");
|
2026-02-23 15:04:10 +00:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("pending agent lozenge uses the yellow pending color", () => {
|
|
|
|
|
|
const items = [
|
|
|
|
|
|
{
|
|
|
|
|
|
story_id: "74_pending_color",
|
|
|
|
|
|
name: "Pending",
|
|
|
|
|
|
error: null,
|
|
|
|
|
|
agent: { agent_name: "coder-1", model: null, status: "pending" },
|
|
|
|
|
|
},
|
|
|
|
|
|
];
|
|
|
|
|
|
const { container } = render(
|
|
|
|
|
|
<Wrapper pipeline={makePipeline({ current: items })}>
|
|
|
|
|
|
<StagePanel title="Current" items={items} />
|
|
|
|
|
|
</Wrapper>,
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
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,
|
|
|
|
|
|
agent: { agent_name: "coder-1", model: null, status: "running" },
|
|
|
|
|
|
},
|
|
|
|
|
|
];
|
|
|
|
|
|
const { container } = render(
|
|
|
|
|
|
<Wrapper pipeline={makePipeline({ current: items })}>
|
|
|
|
|
|
<StagePanel title="Current" items={items} />
|
|
|
|
|
|
</Wrapper>,
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
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");
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
2026-02-23 19:52:23 +00:00
|
|
|
|
|
|
|
|
|
|
// ─── hiddenRosterAgents: no-duplicate guarantee ───────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
/** 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(",")}
|
|
|
|
|
|
/>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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,
|
|
|
|
|
|
agent: { agent_name: "coder-1", model: null, status: "running" },
|
|
|
|
|
|
},
|
|
|
|
|
|
],
|
|
|
|
|
|
});
|
|
|
|
|
|
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,
|
|
|
|
|
|
agent: 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,
|
|
|
|
|
|
agent: { agent_name: "coder-1", model: null, status: "running" },
|
|
|
|
|
|
},
|
|
|
|
|
|
],
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
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");
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
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,
|
|
|
|
|
|
agent: { agent_name: "coder-1", model: null, status: "completed" },
|
|
|
|
|
|
},
|
|
|
|
|
|
],
|
|
|
|
|
|
});
|
|
|
|
|
|
const noAgent = makePipeline({
|
|
|
|
|
|
current: [
|
|
|
|
|
|
{
|
|
|
|
|
|
story_id: "85_flyout_hidden",
|
|
|
|
|
|
name: "Fly-out Hidden",
|
|
|
|
|
|
error: null,
|
|
|
|
|
|
agent: null,
|
|
|
|
|
|
},
|
|
|
|
|
|
],
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const { rerender } = render(
|
|
|
|
|
|
<LozengeFlyProvider pipeline={withAgent}>
|
|
|
|
|
|
<RosterFixture agentName="coder-1" />
|
|
|
|
|
|
<HiddenAgentsProbe />
|
|
|
|
|
|
<StagePanel title="Current" items={withAgent.current} />
|
|
|
|
|
|
</LozengeFlyProvider>,
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
// Advance past the initial fly-in
|
|
|
|
|
|
await act(async () => {
|
|
|
|
|
|
vi.advanceTimersByTime(600);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// Remove agent — fly-out starts
|
|
|
|
|
|
await act(async () => {
|
|
|
|
|
|
rerender(
|
|
|
|
|
|
<LozengeFlyProvider pipeline={noAgent}>
|
|
|
|
|
|
<RosterFixture agentName="coder-1" />
|
|
|
|
|
|
<HiddenAgentsProbe />
|
|
|
|
|
|
<StagePanel title="Current" items={noAgent.current} />
|
|
|
|
|
|
</LozengeFlyProvider>,
|
|
|
|
|
|
);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// 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,
|
|
|
|
|
|
agent: { agent_name: "coder-1", model: null, status: "completed" },
|
|
|
|
|
|
},
|
|
|
|
|
|
],
|
|
|
|
|
|
});
|
|
|
|
|
|
const noAgent = makePipeline({
|
|
|
|
|
|
current: [
|
|
|
|
|
|
{
|
|
|
|
|
|
story_id: "85_flyout_reveal",
|
|
|
|
|
|
name: "Fly-out Reveal",
|
|
|
|
|
|
error: null,
|
|
|
|
|
|
agent: null,
|
|
|
|
|
|
},
|
|
|
|
|
|
],
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const { rerender } = render(
|
|
|
|
|
|
<LozengeFlyProvider pipeline={withAgent}>
|
|
|
|
|
|
<RosterFixture agentName="coder-1" />
|
|
|
|
|
|
<HiddenAgentsProbe />
|
|
|
|
|
|
<StagePanel title="Current" items={withAgent.current} />
|
|
|
|
|
|
</LozengeFlyProvider>,
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
await act(async () => {
|
|
|
|
|
|
vi.advanceTimersByTime(600);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
await act(async () => {
|
|
|
|
|
|
rerender(
|
|
|
|
|
|
<LozengeFlyProvider pipeline={noAgent}>
|
|
|
|
|
|
<RosterFixture agentName="coder-1" />
|
|
|
|
|
|
<HiddenAgentsProbe />
|
|
|
|
|
|
<StagePanel title="Current" items={noAgent.current} />
|
|
|
|
|
|
</LozengeFlyProvider>,
|
|
|
|
|
|
);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// 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("");
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
2026-02-23 22:28:13 +00:00
|
|
|
|
|
|
|
|
|
|
// ─── 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);
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
2026-02-24 13:09:25 +00:00
|
|
|
|
|
|
|
|
|
|
// ─── 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,
|
|
|
|
|
|
agent: { agent_name: "coder-1", model: "sonnet", status: "running" },
|
|
|
|
|
|
},
|
|
|
|
|
|
],
|
|
|
|
|
|
});
|
|
|
|
|
|
const withCoder2 = makePipeline({
|
|
|
|
|
|
current: [
|
|
|
|
|
|
{
|
|
|
|
|
|
story_id: "137_rapid_swap",
|
|
|
|
|
|
name: "Rapid Swap",
|
|
|
|
|
|
error: null,
|
|
|
|
|
|
agent: { agent_name: "coder-2", model: "haiku", status: "running" },
|
|
|
|
|
|
},
|
|
|
|
|
|
],
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
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,
|
|
|
|
|
|
agent: { agent_name: "coder-1", model: null, status: "running" },
|
|
|
|
|
|
},
|
|
|
|
|
|
],
|
|
|
|
|
|
});
|
|
|
|
|
|
const withCoder2 = makePipeline({
|
|
|
|
|
|
current: [
|
|
|
|
|
|
{
|
|
|
|
|
|
story_id: "137_reveal_last",
|
|
|
|
|
|
name: "Reveal Last",
|
|
|
|
|
|
error: null,
|
|
|
|
|
|
agent: { agent_name: "coder-2", model: null, status: "running" },
|
|
|
|
|
|
},
|
|
|
|
|
|
],
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
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,
|
|
|
|
|
|
agent: { agent_name: agentName, model: null, status: "running" },
|
|
|
|
|
|
},
|
|
|
|
|
|
],
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
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");
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|