Skip to content

Commit 947bd37

Browse files
bloveclaude
andcommitted
test(examples/chat): stage pin specs step a stubbed animation frame instead of racing the real one
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
1 parent 76096bc commit 947bd37

1 file changed

Lines changed: 108 additions & 46 deletions

File tree

examples/chat/angular/src/app/stage/stage-mode.component.spec.ts

Lines changed: 108 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -36,57 +36,119 @@ describe('StageMode', () => {
3636
expect(window.__stageApplied?.t).toBe(25);
3737
});
3838

39-
/** The chat's scroll container, with jsdom's zero layout replaced by a tall transcript. */
40-
async function bootWithScroller(): Promise<HTMLElement> {
41-
// The chat shows its welcome screen until a message lands, so the scroll
42-
// container only exists once the first run has been applied.
43-
await fx.componentInstance.boot(new URLSearchParams('t=0'));
44-
fx.detectChanges();
45-
const scroller = (fx.nativeElement as HTMLElement).querySelector<HTMLElement>('chat .chat-scroll');
46-
if (!scroller) throw new Error('chat .chat-scroll did not render');
47-
// jsdom lays nothing out: stand in for a transcript taller than its box.
48-
Object.defineProperty(scroller, 'scrollHeight', { configurable: true, writable: true, value: 2400 });
49-
Object.defineProperty(scroller, 'scrollTop', { configurable: true, writable: true, value: 0 });
50-
return scroller;
51-
}
39+
describe('transcript pin', () => {
40+
// The pin is a pair of animation frames; the real ~16ms frame races the
41+
// controller's macrotask timing, so these specs step a stubbed frame
42+
// queue by hand and control exactly where each publish lands.
43+
type Frame = { id: number; cb: FrameRequestCallback };
44+
let queue: Frame[] = [];
45+
let cancelled: number[] = [];
46+
let nextId = 0;
47+
const realRaf = globalThis.requestAnimationFrame;
48+
const realCaf = globalThis.cancelAnimationFrame;
49+
const realWindowRaf = window.requestAnimationFrame;
50+
const realWindowCaf = window.cancelAnimationFrame;
51+
52+
beforeEach(() => {
53+
queue = [];
54+
cancelled = [];
55+
nextId = 0;
56+
const raf = (cb: FrameRequestCallback): number => {
57+
const id = ++nextId;
58+
queue.push({ id, cb });
59+
return id;
60+
};
61+
const caf = (id: number): void => {
62+
cancelled.push(id);
63+
queue = queue.filter((f) => f.id !== id);
64+
};
65+
globalThis.requestAnimationFrame = raf;
66+
globalThis.cancelAnimationFrame = caf;
67+
window.requestAnimationFrame = raf;
68+
window.cancelAnimationFrame = caf;
69+
});
5270

53-
const frame = () => new Promise<void>((r) => requestAnimationFrame(() => r()));
71+
afterEach(() => {
72+
globalThis.requestAnimationFrame = realRaf;
73+
globalThis.cancelAnimationFrame = realCaf;
74+
window.requestAnimationFrame = realWindowRaf;
75+
window.cancelAnimationFrame = realWindowCaf;
76+
});
5477

55-
/** Polls frame by frame so the test pins the BEHAVIOR, not how many frames deep it lands. */
56-
async function pinnedWithin(scroller: HTMLElement, frames: number): Promise<boolean> {
57-
for (let i = 0; i < frames; i++) {
58-
if (scroller.scrollTop === scroller.scrollHeight) return true;
59-
await frame();
78+
/** Runs exactly the callbacks queued at the time of the call, in order. */
79+
function step(): void {
80+
const batch = queue;
81+
queue = [];
82+
for (const { cb } of batch) cb(performance.now());
6083
}
61-
return scroller.scrollTop === scroller.scrollHeight;
62-
}
6384

64-
it('pins the transcript to its newest content once a seek settles', async () => {
65-
const scroller = await bootWithScroller();
66-
await fx.componentInstance.controller()?.seek(25);
67-
fx.detectChanges();
68-
expect(await pinnedWithin(scroller, 6)).toBe(true);
69-
expect(scroller.scrollTop).toBe(2400);
70-
});
85+
/** Steps until nothing is queued: settles whatever boot scheduled. */
86+
function flush(): void {
87+
for (let i = 0; i < 20 && queue.length > 0; i++) step();
88+
if (queue.length > 0) throw new Error('animation frames never settled');
89+
}
7190

72-
it('re-arms one more pin when a publish lands while a pair is in flight', async () => {
73-
const scroller = await bootWithScroller();
74-
const c = fx.componentInstance.controller();
75-
if (!c) throw new Error('no controller');
76-
// The first publish schedules the pair; the second lands while that pair
77-
// is still in flight, so it must re-arm one more pair rather than be dropped.
78-
await c.seek(25);
79-
fx.detectChanges();
80-
await c.seek(30);
81-
fx.detectChanges();
82-
// The content that grows AFTER the first pair's inner frame: the two-frame
83-
// pair alone would pin at 2400 and leave the new 1200px below the fold.
84-
await frame();
85-
await frame();
86-
expect(scroller.scrollTop).toBe(2400);
87-
Object.defineProperty(scroller, 'scrollHeight', { configurable: true, writable: true, value: 3600 });
88-
expect(await pinnedWithin(scroller, 6)).toBe(true);
89-
expect(scroller.scrollTop).toBe(3600);
91+
/** The chat's scroll container, with jsdom's zero layout replaced by a tall transcript. */
92+
async function bootWithScroller(): Promise<HTMLElement> {
93+
// The chat shows its welcome screen until a message lands, so the scroll
94+
// container only exists once the first run has been applied.
95+
await fx.componentInstance.boot(new URLSearchParams('t=0'));
96+
fx.detectChanges();
97+
// Boot publishes too: drain its pair so each spec starts with no pin in flight.
98+
flush();
99+
const scroller = (fx.nativeElement as HTMLElement).querySelector<HTMLElement>('chat .chat-scroll');
100+
if (!scroller) throw new Error('chat .chat-scroll did not render');
101+
// jsdom lays nothing out: stand in for a transcript taller than its box.
102+
Object.defineProperty(scroller, 'scrollHeight', { configurable: true, writable: true, value: 2400 });
103+
Object.defineProperty(scroller, 'scrollTop', { configurable: true, writable: true, value: 0 });
104+
return scroller;
105+
}
106+
107+
/** Applies a seek and lets its publish schedule (or re-arm) the pin; no frame runs. */
108+
async function publishAt(t: number): Promise<void> {
109+
const c = fx.componentInstance.controller();
110+
if (!c) throw new Error('no controller');
111+
await c.seek(t);
112+
fx.detectChanges();
113+
}
114+
115+
it('pins the transcript to its newest content once a seek settles', async () => {
116+
const scroller = await bootWithScroller();
117+
await publishAt(25);
118+
expect(queue.length).toBeGreaterThan(0);
119+
step(); // outer frame: views render
120+
expect(scroller.scrollTop).toBe(0);
121+
step(); // inner frame: layout settled, the write lands
122+
expect(scroller.scrollTop).toBe(2400);
123+
});
124+
125+
it('re-arms one more pin when a publish lands while a pair is in flight', async () => {
126+
const scroller = await bootWithScroller();
127+
await publishAt(25); // schedules the pair
128+
step(); // outer frame fires: the pair now owns everything before it
129+
await publishAt(30); // lands between outer and inner: must re-arm
130+
step(); // inner frame: writes the current height, then re-arms
131+
expect(scroller.scrollTop).toBe(2400);
132+
// Content that grows AFTER the first pair's inner frame: without the
133+
// re-arm nothing is queued and the new 1200px stay below the fold.
134+
Object.defineProperty(scroller, 'scrollHeight', { configurable: true, writable: true, value: 3600 });
135+
expect(queue.length).toBeGreaterThan(0);
136+
step();
137+
step();
138+
expect(scroller.scrollTop).toBe(3600);
139+
});
140+
141+
it('cancels an in-flight pin on destroy so no frame writes after teardown', async () => {
142+
const scroller = await bootWithScroller();
143+
await publishAt(25);
144+
const pending = queue.map((f) => f.id);
145+
expect(pending.length).toBeGreaterThan(0);
146+
fx.destroy();
147+
expect(cancelled.some((id) => pending.includes(id))).toBe(true);
148+
expect(queue.some((f) => cancelled.includes(f.id))).toBe(false);
149+
flush();
150+
expect(scroller.scrollTop).toBe(0);
151+
});
90152
});
91153

92154
it('posts ready and state through the bridge', async () => {

0 commit comments

Comments
 (0)