import { describe, it, expect } from 'vitest'; import { createRingBuffer } from '../src/session/ring-buffer.js'; import type { RingBuffer } from '../src/types.js'; /** * T6 / M2 tests — ring-buffer.ts * * Invariants under test (ARCHITECTURE §3.4 / types.ts impl anchor): * - capacity measured in REAL UTF-8 bytes (Buffer.byteLength), not string.length; * - over-capacity evicts the WHOLE oldest chunk (append boundary), never splitting a * multi-byte UTF-8 codepoint or an ANSI escape sequence mid-way; * - snapshot() prepends a soft-reset "\x1b[0m" then the current buffer. */ const SOFT_RESET = '\x1b[0m'; /** snapshot() minus the soft-reset prefix = the live buffer content. */ function body(rb: RingBuffer): string { const snap = rb.snapshot(); expect(snap.startsWith(SOFT_RESET)).toBe(true); return snap.slice(SOFT_RESET.length); } describe('createRingBuffer', () => { describe('snapshot()', () => { it('prepends a soft-reset escape even when empty', () => { const rb = createRingBuffer(1024); expect(rb.snapshot()).toBe(SOFT_RESET); }); it('prepends soft-reset then the current buffer', () => { const rb = createRingBuffer(1024); rb.append('hello'); expect(rb.snapshot()).toBe(SOFT_RESET + 'hello'); }); }); describe('tail()', () => { it('returns empty for an empty buffer or non-positive maxBytes', () => { const rb = createRingBuffer(1024); expect(rb.tail(100)).toBe(''); rb.append('hello'); expect(rb.tail(0)).toBe(''); }); it('returns the most recent chunks up to ~maxBytes, on chunk boundaries', () => { const rb = createRingBuffer(1024); rb.append('aaaa'); // 4 bytes rb.append('bbbb'); // 4 bytes rb.append('cccc'); // 4 bytes // maxBytes=5 → newest 'cccc' (4) then 'bbbb' (8 ≥ 5) → stop; never splits. expect(rb.tail(5)).toBe('bbbbcccc'); }); it('returns the whole buffer when maxBytes exceeds its size (no soft-reset)', () => { const rb = createRingBuffer(1024); rb.append('one'); rb.append('two'); expect(rb.tail(1000)).toBe('onetwo'); }); }); describe('within capacity', () => { it('replays everything appended, in order', () => { const rb = createRingBuffer(1024); rb.append('a'); rb.append('b'); rb.append('c'); expect(body(rb)).toBe('abc'); }); it('keeps full content when total exactly equals capacity', () => { const rb = createRingBuffer(6); // "abcdef" = 6 bytes rb.append('abc'); rb.append('def'); expect(body(rb)).toBe('abcdef'); }); }); describe('over capacity — evict oldest whole chunk', () => { it('drops the oldest chunk(s) when capacity is exceeded', () => { const rb = createRingBuffer(6); rb.append('aaa'); // 3 bytes rb.append('bbb'); // 3 bytes -> total 6, fits rb.append('ccc'); // 3 bytes -> total 9 > 6, evict oldest 'aaa' expect(body(rb)).toBe('bbbccc'); }); it('evicts multiple oldest chunks if needed to fit a large new chunk', () => { const rb = createRingBuffer(5); rb.append('11'); // 2 rb.append('22'); // 2 -> total 4 rb.append('33333'); // 5 bytes; must evict '11' and '22' to fit expect(body(rb)).toBe('33333'); }); it('evicts on chunk boundaries — never a partial chunk', () => { const rb = createRingBuffer(4); rb.append('xxx'); // 3 rb.append('yy'); // 2 -> total 5 > 4, evict whole 'xxx', leaving 'yy' // 'xxx' is removed entirely, not trimmed to fit exactly 4 bytes. expect(body(rb)).toBe('yy'); }); }); describe('multi-byte UTF-8 is never split mid-codepoint', () => { it('does not corrupt Chinese characters on eviction', () => { // '中' is 3 UTF-8 bytes. const oneChar = '中'; expect(Buffer.byteLength(oneChar, 'utf8')).toBe(3); // Capacity holds ~3 of these chars (9 bytes). Append many in separate chunks. const rb = createRingBuffer(9); for (let i = 0; i < 10; i++) { rb.append('中'); // each chunk = one 3-byte codepoint } const out = body(rb); // No replacement char (U+FFFD) => nothing was split into invalid UTF-8. expect(out).not.toContain('�'); // Every char is a clean '中'. expect([...out].every((c) => c === '中')).toBe(true); // Byte budget respected. expect(Buffer.byteLength(out, 'utf8')).toBeLessThanOrEqual(9); }); it('round-trips through Buffer without producing replacement chars', () => { const rb = createRingBuffer(12); // 4 chars * 3 bytes const chars = ['日', '本', '語', '中', '文', '字']; for (const c of chars) rb.append(c); const out = body(rb); // Re-encode/decode: if any codepoint were split, we'd see U+FFFD. const reDecoded = Buffer.from(out, 'utf8').toString('utf8'); expect(reDecoded).toBe(out); expect(out).not.toContain('�'); }); }); describe('ANSI escape sequences are never cut mid-sequence', () => { it('keeps escape sequences whole when each is its own chunk', () => { const rb = createRingBuffer(20); // Each chunk is a complete colored token. rb.append('\x1b[1;32mA\x1b[0m'); // green A + reset rb.append('\x1b[1;31mB\x1b[0m'); // red B + reset rb.append('\x1b[1;34mC\x1b[0m'); // blue C + reset const out = body(rb); // The oldest chunk(s) may be evicted, but whichever remain are intact: // every ESC '[' begins a complete sequence ending in 'm'. const escIndexes = [...out.matchAll(/\x1b/g)].map((m) => m.index!); for (const idx of escIndexes) { const seq = out.slice(idx); expect(seq).toMatch(/^\x1b\[[0-9;]*m/); } // No dangling ESC at the very end (would mean a cut sequence). expect(out.endsWith('\x1b')).toBe(false); }); it('does not slice a single ANSI token in half on eviction', () => { const rb = createRingBuffer(8); const token = '\x1b[1;32m'; // 7 bytes, a complete SGR sequence rb.append(token); rb.append(token); // total 14 > 8 -> evict whole first token, keep second intact const out = body(rb); expect(out).toBe(token); // exactly one whole token, not a fragment expect(Buffer.byteLength(out, 'utf8')).toBeLessThanOrEqual(8); }); }); describe('byte accounting (not string.length)', () => { it('uses real UTF-8 byte count for capacity, not UTF-16 code units', () => { // '😀' is 2 UTF-16 code units (string.length === 2) but 4 UTF-8 bytes. const emoji = '😀'; expect(emoji.length).toBe(2); expect(Buffer.byteLength(emoji, 'utf8')).toBe(4); // Capacity 8 bytes = exactly two emoji. const rb = createRingBuffer(8); rb.append(emoji); // 4 bytes rb.append(emoji); // 8 bytes total, fits rb.append(emoji); // 12 > 8 -> evict oldest, leaving two emoji (8 bytes) const out = body(rb); expect(Buffer.byteLength(out, 'utf8')).toBe(8); expect([...out].length).toBe(2); expect(out).not.toContain('�'); }); it('counts multi-byte content by bytes when deciding eviction', () => { // If accounting used string.length, '中中' (length 2) would seem to fit a // capacity-of-3 buffer; by bytes it's 6 and must not both stay. const rb = createRingBuffer(3); rb.append('中'); // 3 bytes, fits exactly rb.append('中'); // 6 > 3 -> evict the first, keep the second const out = body(rb); expect(out).toBe('中'); expect(Buffer.byteLength(out, 'utf8')).toBe(3); }); }); describe('edge cases', () => { it('ignores empty appends', () => { const rb = createRingBuffer(8); rb.append(''); rb.append('ab'); rb.append(''); expect(body(rb)).toBe('ab'); }); it('a single chunk larger than capacity is kept as the sole chunk (cannot split)', () => { // Eviction is on whole-chunk boundaries; a lone oversized chunk has nothing // older to drop, so it stays intact rather than being sliced (M2: never split). const rb = createRingBuffer(4); rb.append('abcdefgh'); // 8 bytes > 4 expect(body(rb)).toBe('abcdefgh'); }); }); });