@@ -6,22 +6,6 @@ function makeText(length: number): string {
66 return "x" . repeat ( length ) ;
77}
88
9- /**
10- * Realistic whitespace-bearing text for tests that exercise word-paced reveal
11- * cadence. Uses fixed 5-char "words" + 1 space = 6 chars per atom — short
12- * enough to fit comfortably under WORD_PACE_MAX_CHARS=12 so the cap doesn't
13- * dominate behavior.
14- */
15- function makeWords ( length : number ) : string {
16- const words : string [ ] = [ ] ;
17- let total = 0 ;
18- while ( total < length ) {
19- words . push ( "abcde" ) ;
20- total += 6 ; // 5 chars + 1 space
21- }
22- return words . join ( " " ) . slice ( 0 , length ) ;
23- }
24-
259describe ( "SmoothTextEngine" , ( ) => {
2610 it ( "reveals text steadily and reaches full length" , ( ) => {
2711 const engine = new SmoothTextEngine ( ) ;
@@ -129,17 +113,17 @@ describe("SmoothTextEngine", () => {
129113
130114 it ( "does not force reveal when budget is below one char" , ( ) => {
131115 const engine = new SmoothTextEngine ( ) ;
132- // For a 1-char string with no whitespace, the next reveal atom is the
133- // entire string (cost=1). With ~74 cps adaptive rate at 4ms per tick:
134- // ~0.30 budget per tick. The engine waits until floor(charBudget) > = 1
135- // before revealing — frame-rate invariance means partial budget rolls over .
116+ // With a 1-char backlog, adaptive rate is at floor (~24 cps).
117+ // At 4ms per tick: 24 * 0.004 = 0.096 budget per tick.
118+ // The required-char gate is min(MIN_FRAME_CHARS, backlog) = min(2, 1) = 1
119+ // for this 1-char stream, so it reveals once budget reaches 1.0 .
136120 engine . update ( "x" , true , false ) ;
137121
138- // First tick at 4ms should not reveal (budget ~0.30 < 1 ).
122+ // First tick at 4ms should not reveal (budget ~0.10 ).
139123 const afterFirstTick = engine . tick ( 4 ) ;
140124 expect ( afterFirstTick ) . toBe ( 0 ) ;
141125
142- // Several more small ticks should still not reveal (budget < 1) .
126+ // Several more small ticks should still not reveal.
143127 engine . tick ( 4 ) ;
144128 engine . tick ( 4 ) ;
145129 expect ( engine . visibleLength ) . toBe ( 0 ) ;
@@ -153,14 +137,12 @@ describe("SmoothTextEngine", () => {
153137
154138 it ( "targets the live model rate when provided" , ( ) => {
155139 // With a model rate of 200 cps the engine should reveal materially faster
156- // than at the BASE rate of 72 cps for the same backlog. Uses realistic
157- // word-bearing text so the rate differential maps onto distinct word
158- // counts revealed in the same wall-time window.
140+ // than at the BASE rate of 72 cps for the same backlog.
159141 const baseEngine = new SmoothTextEngine ( ) ;
160142 const modelAwareEngine = new SmoothTextEngine ( ) ;
161143
162- baseEngine . update ( makeWords ( 50 ) , true , false ) ;
163- modelAwareEngine . update ( makeWords ( 50 ) , true , false , 200 ) ;
144+ baseEngine . update ( makeText ( 50 ) , true , false ) ;
145+ modelAwareEngine . update ( makeText ( 50 ) , true , false , 200 ) ;
164146
165147 for ( let i = 0 ; i < 10 ; i ++ ) {
166148 baseEngine . tick ( 16 ) ;
@@ -170,93 +152,6 @@ describe("SmoothTextEngine", () => {
170152 expect ( modelAwareEngine . visibleLength ) . toBeGreaterThan ( baseEngine . visibleLength ) ;
171153 } ) ;
172154
173- it ( "reveals at most one atom per tick even with huge budget" , ( ) => {
174- // Time-smoothing: even when budget covers many atoms (catch-up burst,
175- // very high adaptive rate), reveals must be spread across ticks so the
176- // user sees one word per animation frame. Multi-atom reveals would
177- // bypass the temporal cadence and read as bursty.
178- const engine = new SmoothTextEngine ( ) ;
179- // 5-char words + space = 6-char atoms. 100 chars = ~17 atoms.
180- engine . update ( makeWords ( 100 ) , true , false , 1000 ) ; // very high model rate
181-
182- // Even one tick at the dt clamp ceiling shouldn't reveal more than the
183- // largest possible atom (WORD_PACE_MAX_CHARS=12).
184- const before = engine . visibleLength ;
185- engine . tick ( 33 ) ;
186- const revealed = engine . visibleLength - before ;
187-
188- // ≤ 12 chars (one atom max). With 6-char atoms it's exactly 6.
189- expect ( revealed ) . toBeLessThanOrEqual ( STREAM_SMOOTHING . WORD_PACE_MAX_CHARS ) ;
190- } ) ;
191-
192- it ( "clamps dt so a long pause doesn't burst on resume" , ( ) => {
193- // RAF gaps (tab visibility, debugger pauses) can produce multi-second
194- // dt values. Without clamping, budget = adaptiveRate * dt would balloon
195- // and feed downstream into multi-atom reveals (or in earlier engine
196- // designs, a 10s pause would dump the entire backlog in one frame).
197- const engine = new SmoothTextEngine ( ) ;
198- engine . update ( makeWords ( 200 ) , true , false , 200 ) ;
199-
200- const before = engine . visibleLength ;
201- engine . tick ( 10_000 ) ; // 10-second "pause"
202- const revealed = engine . visibleLength - before ;
203-
204- // Same single-atom cap as a normal tick — the clamp ensures budget
205- // accumulated from a 10s gap is no larger than from a 33ms gap.
206- expect ( revealed ) . toBeLessThanOrEqual ( STREAM_SMOOTHING . WORD_PACE_MAX_CHARS ) ;
207- } ) ;
208-
209- it ( "treats Unicode whitespace as word boundaries" , ( ) => {
210- // Non-English content uses NBSP \u00A0, ideographic space \u3000, etc.
211- // The boundary scanner must recognize them or the entire stream is treated
212- // as one no-whitespace run capped at WORD_PACE_MAX_CHARS chunks. Each of
213- // these strings has a single Unicode whitespace separator at index 5.
214- const cases = [
215- "Hello\u00a0world" , // NBSP
216- "Hello\u2003world" , // em space
217- "Hello\u2009world" , // thin space
218- "Hello\u3000world" , // ideographic space
219- "Hello\u2028world" , // line separator
220- ] ;
221-
222- for ( const text of cases ) {
223- const engine = new SmoothTextEngine ( ) ;
224- engine . update ( text , true , false ) ;
225- // Tick until "Hello<sep>" is revealed (cost = 6) — boundary scan must
226- // land at index 6, not at the WORD_PACE_MAX_CHARS cap of 12.
227- let observed = engine . visibleLength ;
228- for ( let i = 0 ; i < 50 && engine . visibleLength < 6 ; i ++ ) {
229- engine . tick ( 16 ) ;
230- observed = engine . visibleLength ;
231- if ( observed >= 6 && observed < text . length ) break ;
232- }
233- expect ( observed ) . toBe ( 6 ) ;
234- }
235- } ) ;
236-
237- it ( "reveals only at whitespace boundaries" , ( ) => {
238- // Word-paced reveal: visibleLength must always land just after a
239- // whitespace character (or at 0 / fullLength). Prevents mid-word reveals
240- // that the eye registers as character-by-character chop.
241- const engine = new SmoothTextEngine ( ) ;
242- const text = "Hello world. How are you doing today?" ;
243- engine . update ( text , true , false ) ;
244-
245- const seenLengths = new Set < number > ( [ engine . visibleLength ] ) ;
246- for ( let i = 0 ; i < 200 && ! engine . isCaughtUp ; i ++ ) {
247- engine . tick ( 16 ) ;
248- seenLengths . add ( engine . visibleLength ) ;
249- }
250-
251- expect ( engine . isCaughtUp ) . toBe ( true ) ;
252- for ( const len of seenLengths ) {
253- if ( len === 0 || len === text . length ) continue ;
254- // The character immediately before the reveal cursor must be whitespace.
255- const charBefore = text [ len - 1 ] ;
256- expect ( / \s / . test ( charBefore ) ) . toBe ( true ) ;
257- }
258- } ) ;
259-
260155 it ( "soft-catches-up large lag without a hard snap" , ( ) => {
261156 const engine = new SmoothTextEngine ( ) ;
262157
0 commit comments