story-kit: merge 137_bug_lozengeflycontext_animation_queue_race_condition_on_rapid_updates
This commit is contained in:
@@ -956,3 +956,289 @@ describe("FlyingLozengeClone initial non-flying render", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ─── 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");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user