import { describe, expect, it, vi } from 'vitest';
import { SmoothTextStream } from './streamSmoother';
function createManualFrameScheduler(onFrame?: () => void) {
let nextId = 1;
const queue: Array<{ id: number; callback: () => void; cancelled: boolean }> = [];
return {
scheduleFrame(callback: () => void): number {
const id = nextId;
nextId += 1;
queue.push({ id, callback, cancelled: false });
return id;
},
cancelFrame(id: number) {
const item = queue.find((entry) => entry.id === id);
if (item) item.cancelled = true;
},
runNext() {
const item = queue.shift();
if (item && !item.cancelled) {
onFrame?.();
item.callback();
}
},
drain(limit = 80) {
let count = 0;
while (queue.length > 0 && count < limit) {
this.runNext();
count += 1;
}
return count;
},
get size() {
return queue.filter((item) => !item.cancelled).length;
},
};
}
describe('SmoothTextStream', () => {
it('renders a large chunk over many bounded frame updates', () => {
let now = 0;
const scheduler = createManualFrameScheduler(() => {
now += 33;
});
const emitted: string[] = [];
const text = 'abcdefghijklmnopqrstuvwxyz '.repeat(8);
const stream = new SmoothTextStream({
emit: (content) => emitted.push(content),
scheduleFrame: (callback) => scheduler.scheduleFrame(callback),
cancelFrame: (handle) => scheduler.cancelFrame(handle),
now: () => now,
frameMs: 33,
minCharsPerFrame: 3,
maxCharsPerFrame: 18,
});
stream.append(text);
expect(emitted.length).toBe(1);
expect(emitted[0].length).toBeGreaterThan(0);
expect(emitted[0].length).toBeLessThan(text.length);
expect(stream.getSnapshot().targetLength).toBe(text.length);
scheduler.runNext();
scheduler.runNext();
expect(emitted.length).toBeGreaterThanOrEqual(2);
expect(emitted[0].length).toBeGreaterThan(0);
expect(emitted[0].length).toBeLessThan(text.length);
for (let index = 1; index < emitted.length; index += 1) {
const delta = emitted[index].length - emitted[index - 1].length;
expect(delta).toBeGreaterThan(0);
expect(delta).toBeLessThanOrEqual(18);
}
scheduler.drain();
expect(emitted[emitted.length - 1]).toBe(text);
expect(stream.getSnapshot().renderedLength).toBe(text.length);
});
it('updates the moving average rate when chunk cadence changes', () => {
let now = 0;
const scheduler = createManualFrameScheduler(() => {
now += 33;
});
const stream = new SmoothTextStream({
emit: () => {},
scheduleFrame: (callback) => scheduler.scheduleFrame(callback),
cancelFrame: (handle) => scheduler.cancelFrame(handle),
now: () => now,
});
stream.append('abcd');
now += 40;
stream.append('x'.repeat(80));
const snapshot = stream.getSnapshot();
expect(snapshot.averageCharsPerSecond).toBeGreaterThan(400);
expect(snapshot.pendingChars).toBeGreaterThan(0);
expect(snapshot.pendingChars).toBeLessThan(84);
});
it('prefers whitespace and punctuation boundaries without exceeding the frame cap', () => {
let now = 0;
const scheduler = createManualFrameScheduler(() => {
now += 33;
});
const emitted: string[] = [];
const stream = new SmoothTextStream({
emit: (content) => emitted.push(content),
scheduleFrame: (callback) => scheduler.scheduleFrame(callback),
cancelFrame: (handle) => scheduler.cancelFrame(handle),
now: () => now,
frameMs: 33,
minCharsPerFrame: 6,
maxCharsPerFrame: 12,
});
stream.append('hello world, next sentence.');
scheduler.runNext();
expect(emitted[0].length).toBeLessThanOrEqual(12);
expect(/[\s,]$/.test(emitted[0])).toBe(true);
});
it('flushes all buffered content and finalizes immediately', () => {
let now = 0;
const scheduler = createManualFrameScheduler(() => {
now += 33;
});
const emitted: string[] = [];
let finalized = 0;
const stream = new SmoothTextStream({
emit: (content) => emitted.push(content),
finalize: () => {
finalized += 1;
},
scheduleFrame: (callback) => scheduler.scheduleFrame(callback),
cancelFrame: (handle) => scheduler.cancelFrame(handle),
now: () => now,
});
stream.append('streaming output');
stream.flush(true);
expect(emitted.at(-1)).toBe('streaming output');
expect(finalized).toBe(1);
expect(stream.getSnapshot().targetLength).toBe(0);
expect(stream.getSnapshot().renderedLength).toBe(0);
expect(scheduler.size).toBe(0);
});
it('falls back when requestAnimationFrame does not run promptly', () => {
vi.useFakeTimers();
const requestAnimationFrameSpy = vi.fn(() => 1);
const cancelAnimationFrameSpy = vi.fn();
vi.stubGlobal('window', {
requestAnimationFrame: requestAnimationFrameSpy,
cancelAnimationFrame: cancelAnimationFrameSpy,
setTimeout: globalThis.setTimeout,
});
const emitted: string[] = [];
try {
const stream = new SmoothTextStream({
emit: (content) => emitted.push(content),
fallbackFrameMs: 10,
});
stream.append('abcdefghijklmnopqrstuvwxyz '.repeat(4));
expect(emitted.length).toBe(1);
vi.advanceTimersByTime(10);
expect(cancelAnimationFrameSpy).toHaveBeenCalledWith(1);
expect(emitted.length).toBeGreaterThan(1);
} finally {
vi.unstubAllGlobals();
vi.useRealTimers();
}
});
});