@@ -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