import { describe, expect, it } from 'vitest'; import { RingBuffer } from '../src/core/ring-buffer.js'; // Modèle de référence naïf : garde tout le flux et recalcule la fenêtre à la demande class RefBuffer { private data = Buffer.alloc(0); constructor(readonly capacity: number) {} write(chunk: Buffer): void { this.data = Buffer.concat([this.data, chunk]); } get total(): number { return this.data.length; } get windowStart(): number { return Math.max(0, this.data.length - this.capacity); } tail(count: number): Buffer { const window = this.data.subarray(this.windowStart); return Buffer.from(window.subarray(Math.max(0, window.length - count))); } readFrom(offset: number): Buffer | null { if (offset > this.total) return null; if (offset === this.total) return Buffer.alloc(0); if (offset < this.windowStart) return null; return Buffer.from(this.data.subarray(offset)); } } function mulberry32(seed: number): () => number { let a = seed >>> 0; return () => { a = (a + 0x6d2b79f5) >>> 0; let t = a; t = Math.imul(t ^ (t >>> 15), t | 1); t ^= t + Math.imul(t ^ (t >>> 7), t | 61); return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }; } const buf = (s: string): Buffer => Buffer.from(s, 'ascii'); describe('RingBuffer — écriture simple', () => { it('écrit puis relit sans wrap', () => { const ring = new RingBuffer(16); ring.write(buf('hello')); expect(ring.totalOffset).toBe(5); expect(ring.windowStart).toBe(0); expect(ring.tail(5).toString()).toBe('hello'); expect(ring.tail(3).toString()).toBe('llo'); expect(ring.tail(100).toString()).toBe('hello'); // borné à la fenêtre expect(ring.tail(0).length).toBe(0); }); it('écritures successives sans wrap', () => { const ring = new RingBuffer(16); ring.write(buf('abc')); ring.write(buf('def')); expect(ring.totalOffset).toBe(6); expect(ring.tail(6).toString()).toBe('abcdef'); }); it('chunk vide : no-op', () => { const ring = new RingBuffer(8); ring.write(buf('ab')); ring.write(Buffer.alloc(0)); expect(ring.totalOffset).toBe(2); expect(ring.tail(8).toString()).toBe('ab'); }); it('buffer vierge : tail vide, readFrom(0) vide', () => { const ring = new RingBuffer(8); expect(ring.totalOffset).toBe(0); expect(ring.tail(8).length).toBe(0); expect(ring.readFrom(0)?.length).toBe(0); expect(ring.readFrom(1)).toBeNull(); }); }); describe('RingBuffer — wrap-around', () => { it('wrap simple : le chunk chevauche la frontière du buffer', () => { const ring = new RingBuffer(8); ring.write(buf('01234')); // pos 0..5 ring.write(buf('56789')); // 3 octets en queue, 2 au début expect(ring.totalOffset).toBe(10); expect(ring.windowStart).toBe(2); expect(ring.tail(8).toString()).toBe('23456789'); expect(ring.tail(4).toString()).toBe('6789'); }); it('wrap-around multiple : la fenêtre reste les N derniers octets', () => { const ring = new RingBuffer(8); let stream = ''; for (let i = 0; i < 10; i++) { const chunk = String.fromCharCode(97 + i).repeat(3); // 'aaa', 'bbb'… ring.write(buf(chunk)); stream += chunk; expect(ring.totalOffset).toBe(stream.length); expect(ring.tail(8).toString()).toBe(stream.slice(Math.max(0, stream.length - 8))); } expect(ring.windowStart).toBe(30 - 8); }); it('écriture qui finit exactement sur la frontière (tailSpace == chunk.length)', () => { const ring = new RingBuffer(8); ring.write(buf('abcd')); ring.write(buf('efgh')); // remplit exactement jusqu'à la frontière expect(ring.totalOffset).toBe(8); expect(ring.tail(8).toString()).toBe('abcdefgh'); ring.write(buf('ij')); // repart de la position 0 expect(ring.tail(8).toString()).toBe('cdefghij'); }); it('total multiple exact de la capacité (end === 0)', () => { const ring = new RingBuffer(8); ring.write(buf('abcdefgh')); ring.write(buf('ijklmnop')); expect(ring.totalOffset).toBe(16); expect(ring.tail(8).toString()).toBe('ijklmnop'); expect(ring.tail(3).toString()).toBe('nop'); }); }); describe('RingBuffer — chunk >= capacité', () => { it('chunk == capacité sur un ring vierge', () => { const ring = new RingBuffer(8); ring.write(buf('abcdefgh')); expect(ring.totalOffset).toBe(8); expect(ring.windowStart).toBe(0); expect(ring.tail(8).toString()).toBe('abcdefgh'); }); it('chunk == 2x capacité sur un ring vierge (total reste aligné)', () => { const ring = new RingBuffer(8); ring.write(buf('0123456789abcdef')); expect(ring.totalOffset).toBe(16); expect(ring.windowStart).toBe(8); expect(ring.tail(8).toString()).toBe('89abcdef'); expect(ring.readFrom(8)?.toString()).toBe('89abcdef'); expect(ring.readFrom(7)).toBeNull(); }); // Invariant : l'octet k du flux vit à la position k % capacity, même quand un // chunk dépasse la capacité (les octets sautés comptent dans l'offset). it('chunk > capacité non aligné : la fenêtre reste ordonnée', () => { const ring = new RingBuffer(8); ring.write(buf('0123456789')); // 10 octets, total final 10 % 8 != 0 expect(ring.totalOffset).toBe(10); expect(ring.tail(8).toString()).toBe('23456789'); }); it('chunk == capacité après un préfixe non aligné : fenêtre ordonnée', () => { const ring = new RingBuffer(8); ring.write(buf('abc')); ring.write(buf('ABCDEFGH')); expect(ring.tail(8).toString()).toBe('ABCDEFGH'); }); }); describe('RingBuffer — readFrom', () => { function filled(): RingBuffer { // total = 10, capacité 8 → fenêtre = offsets [2, 10) const ring = new RingBuffer(8); ring.write(buf('01234')); ring.write(buf('56789')); return ring; } it('offset dans la fenêtre', () => { const ring = filled(); expect(ring.readFrom(5)?.toString()).toBe('56789'); expect(ring.readFrom(9)?.toString()).toBe('9'); }); it('offset == windowStart : toute la fenêtre', () => { const ring = filled(); expect(ring.readFrom(ring.windowStart)?.toString()).toBe('23456789'); }); it('offset == total : buffer vide (rien de nouveau)', () => { const ring = filled(); const out = ring.readFrom(10); expect(out).not.toBeNull(); expect(out?.length).toBe(0); }); it('offset sorti de fenêtre ou au-delà du total → null', () => { const ring = filled(); expect(ring.readFrom(1)).toBeNull(); // windowStart - 1 expect(ring.readFrom(0)).toBeNull(); expect(ring.readFrom(-1)).toBeNull(); expect(ring.readFrom(11)).toBeNull(); // total + 1 }); }); describe('RingBuffer — totalOffset monotone', () => { it('croît exactement de la taille de chaque chunk, jamais ne décroît', () => { const ring = new RingBuffer(8); const rand = mulberry32(0x5eed); let expected = 0; for (let i = 0; i < 200; i++) { const size = Math.floor(rand() * 7); // 0..6 (< capacité) ring.write(Buffer.alloc(size, 65 + (i % 26))); expected += size; expect(ring.totalOffset).toBe(expected); expect(ring.windowStart).toBe(Math.max(0, expected - 8)); } }); }); describe('RingBuffer — équivalence avec le modèle de référence (property test)', () => { it('tail et readFrom identiques au modèle pour 500 écritures aléatoires (chunks < capacité)', () => { const capacity = 64; const ring = new RingBuffer(capacity); const ref = new RefBuffer(capacity); const rand = mulberry32(0xc0ffee); for (let i = 0; i < 500; i++) { const size = Math.floor(rand() * capacity); // 0..63 const chunk = Buffer.alloc(size); for (let j = 0; j < size; j++) chunk[j] = Math.floor(rand() * 256); ring.write(chunk); ref.write(chunk); expect(ring.totalOffset).toBe(ref.total); expect(ring.windowStart).toBe(ref.windowStart); for (const n of [0, 1, 7, capacity - 1, capacity, capacity + 1, 500]) { expect(ring.tail(n).equals(ref.tail(n)), `tail(${n}) après ${i + 1} écritures`).toBe(true); } const offsets = [ ref.windowStart - 1, ref.windowStart, ref.windowStart + Math.floor((ref.total - ref.windowStart) / 2), ref.total - 1, ref.total, ref.total + 1, ]; for (const off of offsets) { const a = ring.readFrom(off); const b = ref.readFrom(off); if (b === null) { expect(a, `readFrom(${off}) après ${i + 1} écritures`).toBeNull(); } else { expect(a?.equals(b), `readFrom(${off}) après ${i + 1} écritures`).toBe(true); } } } }); });