managarten/packages/spiral-db/src/encoding.test.ts
Till JS d4d08cc68b fix(spiral-db): add test suite and fix critical bugs
Add comprehensive test suite (174 tests) covering encoding, schema,
image, database CRUD, and PNG round-trip. Fix critical bugs:

- PNG compression: replace non-functional zlibCompress with pako.deflate
- PNG import: add CRC validation, support all filter types (Sub/Up/Avg/Paeth)
- Input validation: validate records against schema before insert
- Index overflow: dynamic dataStartRing prevents index/data ring overlap
- Image expansion: expand before writes instead of after to prevent OOB
- update() read bug: search index from end to find latest entry, not deleted one
- String encoding: enforce 511-byte max length
- Index ring count: use 6 bits (2 pixels) instead of 3 bits for >7 ring support

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 09:52:18 +01:00

446 lines
13 KiB
TypeScript

/**
* Encoding/Decoding Tests
*/
import { describe, it, expect } from 'vitest';
import {
bitsToColor,
colorToBits,
colorToRGB,
rgbToColor,
createBitStream,
writeBits,
readBits,
peekBits,
hasMoreBits,
bitsToPixels,
pixelsToBits,
encodeInt,
decodeInt,
encodeBool,
decodeBool,
encodeString,
decodeString,
encodeTimestamp,
decodeTimestamp,
encodeIntArray,
decodeIntArray,
encodeStringArray,
decodeStringArray,
} from './encoding.js';
import type { ColorIndex } from './types.js';
// =============================================================================
// COLOR ↔ BITS
// =============================================================================
describe('Color ↔ Bits Conversion', () => {
it('should convert all 8 colors to bits and back', () => {
for (let i = 0; i < 8; i++) {
const color = i as ColorIndex;
const bits = colorToBits(color);
const back = bitsToColor(bits[0], bits[1], bits[2]);
expect(back).toBe(color);
}
});
it('should map black to [0,0,0] and white to [1,1,1]', () => {
expect(colorToBits(0)).toEqual([0, 0, 0]);
expect(colorToBits(7)).toEqual([1, 1, 1]);
});
it('should map red (4) to [1,0,0]', () => {
expect(colorToBits(4)).toEqual([1, 0, 0]);
});
});
describe('Color ↔ RGB Conversion', () => {
it('should convert all 8 colors to RGB', () => {
expect(colorToRGB(0)).toEqual({ r: 0, g: 0, b: 0 }); // black
expect(colorToRGB(1)).toEqual({ r: 0, g: 0, b: 255 }); // blue
expect(colorToRGB(2)).toEqual({ r: 0, g: 255, b: 0 }); // green
expect(colorToRGB(4)).toEqual({ r: 255, g: 0, b: 0 }); // red
expect(colorToRGB(7)).toEqual({ r: 255, g: 255, b: 255 }); // white
});
it('should round-trip exact RGB values', () => {
for (let i = 0; i < 8; i++) {
const color = i as ColorIndex;
const rgb = colorToRGB(color);
const back = rgbToColor(rgb.r, rgb.g, rgb.b);
expect(back).toBe(color);
}
});
it('should threshold at 128 for non-exact values', () => {
expect(rgbToColor(127, 127, 127)).toBe(0); // all below → black
expect(rgbToColor(128, 128, 128)).toBe(7); // all above → white
expect(rgbToColor(200, 50, 50)).toBe(4); // red-ish → red
expect(rgbToColor(50, 200, 50)).toBe(2); // green-ish → green
});
it('should handle boundary values (0 and 255)', () => {
expect(rgbToColor(0, 0, 0)).toBe(0);
expect(rgbToColor(255, 255, 255)).toBe(7);
});
});
// =============================================================================
// BIT STREAM
// =============================================================================
describe('BitStream', () => {
it('should create empty stream', () => {
const stream = createBitStream();
expect(stream.bits).toEqual([]);
expect(stream.position).toBe(0);
});
it('should write and read bits', () => {
const stream = createBitStream();
writeBits(stream, 0b101, 3);
stream.position = 0;
expect(readBits(stream, 3)).toBe(0b101);
});
it('should write multiple values', () => {
const stream = createBitStream();
writeBits(stream, 5, 4); // 0101
writeBits(stream, 3, 3); // 011
stream.position = 0;
expect(readBits(stream, 4)).toBe(5);
expect(readBits(stream, 3)).toBe(3);
});
it('should throw on read past end', () => {
const stream = createBitStream();
writeBits(stream, 1, 1);
stream.position = 0;
readBits(stream, 1);
expect(() => readBits(stream, 1)).toThrow('Unexpected end of bit stream');
});
it('should peek without consuming', () => {
const stream = createBitStream();
writeBits(stream, 0b110, 3);
stream.position = 0;
expect(peekBits(stream, 3)).toBe(0b110);
expect(stream.position).toBe(0); // not consumed
expect(readBits(stream, 3)).toBe(0b110);
expect(stream.position).toBe(3); // now consumed
});
it('should check hasMoreBits', () => {
const stream = createBitStream();
writeBits(stream, 0b11, 2);
stream.position = 0;
expect(hasMoreBits(stream, 2)).toBe(true);
expect(hasMoreBits(stream, 3)).toBe(false);
readBits(stream, 1);
expect(hasMoreBits(stream, 1)).toBe(true);
expect(hasMoreBits(stream, 2)).toBe(false);
});
it('should handle writing 0 bits correctly', () => {
const stream = createBitStream();
writeBits(stream, 0, 8);
stream.position = 0;
expect(readBits(stream, 8)).toBe(0);
});
it('should handle max value for bit width', () => {
const stream = createBitStream();
writeBits(stream, 0xfff, 12); // max 12-bit value
stream.position = 0;
expect(readBits(stream, 12)).toBe(4095);
});
});
// =============================================================================
// BITS ↔ PIXELS
// =============================================================================
describe('Bits ↔ Pixels', () => {
it('should convert bits to pixels (3 bits per pixel)', () => {
const pixels = bitsToPixels([1, 0, 1, 0, 1, 0]);
expect(pixels).toEqual([5, 2]); // 101 = 5 (magenta), 010 = 2 (green)
});
it('should pad to 3-bit boundary', () => {
const pixels = bitsToPixels([1, 0]); // only 2 bits → padded to 100
expect(pixels).toHaveLength(1);
expect(pixels[0]).toBe(4); // 100 = red
});
it('should round-trip pixels ↔ bits', () => {
const original: ColorIndex[] = [0, 3, 5, 7, 1, 6];
const bits = pixelsToBits(original);
const back = bitsToPixels(bits);
expect(back).toEqual(original);
});
it('should handle empty input', () => {
expect(bitsToPixels([])).toEqual([]);
expect(pixelsToBits([])).toEqual([]);
});
});
// =============================================================================
// VALUE ENCODING: INT
// =============================================================================
describe('Int Encoding', () => {
it('should encode and decode 0', () => {
const stream = createBitStream();
encodeInt(stream, 0, 12);
stream.position = 0;
expect(decodeInt(stream, 12)).toBe(0);
});
it('should encode and decode max value', () => {
const stream = createBitStream();
encodeInt(stream, 4095, 12);
stream.position = 0;
expect(decodeInt(stream, 12)).toBe(4095);
});
it('should reject negative integers', () => {
const stream = createBitStream();
expect(() => encodeInt(stream, -1, 12)).toThrow('Negative integers not supported');
});
it('should reject values too large for bit width', () => {
const stream = createBitStream();
expect(() => encodeInt(stream, 4096, 12)).toThrow('too large for 12 bits');
});
it('should handle various bit widths', () => {
for (const bits of [1, 3, 8, 12, 24]) {
const maxVal = 2 ** bits - 1;
const stream = createBitStream();
encodeInt(stream, maxVal, bits);
stream.position = 0;
expect(decodeInt(stream, bits)).toBe(maxVal);
}
});
});
// =============================================================================
// VALUE ENCODING: BOOL
// =============================================================================
describe('Bool Encoding', () => {
it('should encode and decode true', () => {
const stream = createBitStream();
encodeBool(stream, true);
stream.position = 0;
expect(decodeBool(stream)).toBe(true);
});
it('should encode and decode false', () => {
const stream = createBitStream();
encodeBool(stream, false);
stream.position = 0;
expect(decodeBool(stream)).toBe(false);
});
it('should use exactly 1 bit', () => {
const stream = createBitStream();
encodeBool(stream, true);
expect(stream.bits.length).toBe(1);
});
});
// =============================================================================
// VALUE ENCODING: STRING
// =============================================================================
describe('String Encoding', () => {
it('should encode and decode simple ASCII', () => {
const stream = createBitStream();
encodeString(stream, 'Hello');
stream.position = 0;
expect(decodeString(stream)).toBe('Hello');
});
it('should encode and decode empty string', () => {
const stream = createBitStream();
encodeString(stream, '');
stream.position = 0;
expect(decodeString(stream)).toBe('');
});
it('should encode and decode UTF-8 (emoji, umlauts)', () => {
const stream = createBitStream();
encodeString(stream, 'Hëllo 🌍');
stream.position = 0;
expect(decodeString(stream)).toBe('Hëllo 🌍');
});
it('should encode and decode with compression (long strings)', () => {
const longString = 'a'.repeat(100);
const stream = createBitStream();
encodeString(stream, longString, true);
stream.position = 0;
expect(decodeString(stream)).toBe(longString);
});
it('should skip compression for short strings even when enabled', () => {
const stream = createBitStream();
encodeString(stream, 'short', true); // < 20 bytes, won't compress
stream.position = 0;
expect(decodeString(stream)).toBe('short');
});
it('should handle max-length string (511 bytes UTF-8)', () => {
const str = 'x'.repeat(511);
const stream = createBitStream();
encodeString(stream, str);
stream.position = 0;
expect(decodeString(stream)).toBe(str);
});
it('should handle string with only whitespace', () => {
const stream = createBitStream();
encodeString(stream, ' \t\n');
stream.position = 0;
expect(decodeString(stream)).toBe(' \t\n');
});
it('should reject string exceeding 511 bytes', () => {
const stream = createBitStream();
const tooLong = 'x'.repeat(512); // 512 bytes > 511 max
expect(() => encodeString(stream, tooLong)).toThrow('String too long');
});
it('should accept string of exactly 511 bytes', () => {
const stream = createBitStream();
const maxStr = 'x'.repeat(511);
expect(() => encodeString(stream, maxStr)).not.toThrow();
});
});
// =============================================================================
// VALUE ENCODING: TIMESTAMP
// =============================================================================
describe('Timestamp Encoding', () => {
it('should encode and decode a date', () => {
const date = new Date('2025-06-15');
const stream = createBitStream();
encodeTimestamp(stream, date);
stream.position = 0;
const decoded = decodeTimestamp(stream);
expect(decoded).not.toBeNull();
// Compare as days since epoch (precision is days)
const expectedDays = Math.floor(date.getTime() / (1000 * 60 * 60 * 24));
const decodedDays = Math.floor(decoded!.getTime() / (1000 * 60 * 60 * 24));
expect(decodedDays).toBe(expectedDays);
});
it('should encode null as 0 and decode back to null', () => {
const stream = createBitStream();
encodeTimestamp(stream, null);
stream.position = 0;
expect(decodeTimestamp(stream)).toBeNull();
});
it('should use exactly 24 bits', () => {
const stream = createBitStream();
encodeTimestamp(stream, new Date());
expect(stream.bits.length).toBe(24);
});
it('should handle epoch date (1970-01-01) — decodes to null since days=0', () => {
const stream = createBitStream();
encodeTimestamp(stream, new Date(0));
stream.position = 0;
// days since epoch = 0 → decoded as null (ambiguity!)
expect(decodeTimestamp(stream)).toBeNull();
});
});
// =============================================================================
// VALUE ENCODING: ARRAYS
// =============================================================================
describe('Int Array Encoding', () => {
it('should encode and decode int array', () => {
const stream = createBitStream();
encodeIntArray(stream, [1, 2, 3], 12);
stream.position = 0;
expect(decodeIntArray(stream, 12)).toEqual([1, 2, 3]);
});
it('should handle empty array', () => {
const stream = createBitStream();
encodeIntArray(stream, [], 12);
stream.position = 0;
expect(decodeIntArray(stream, 12)).toEqual([]);
});
it('should handle single-element array', () => {
const stream = createBitStream();
encodeIntArray(stream, [42], 8);
stream.position = 0;
expect(decodeIntArray(stream, 8)).toEqual([42]);
});
it('should handle max array count (255)', () => {
const arr = Array.from({ length: 255 }, (_, i) => i % 256);
const stream = createBitStream();
encodeIntArray(stream, arr, 8);
stream.position = 0;
expect(decodeIntArray(stream, 8)).toEqual(arr);
});
});
describe('String Array Encoding', () => {
it('should encode and decode string array', () => {
const stream = createBitStream();
encodeStringArray(stream, ['hello', 'world']);
stream.position = 0;
expect(decodeStringArray(stream)).toEqual(['hello', 'world']);
});
it('should handle empty string array', () => {
const stream = createBitStream();
encodeStringArray(stream, []);
stream.position = 0;
expect(decodeStringArray(stream)).toEqual([]);
});
it('should handle array with empty strings', () => {
const stream = createBitStream();
encodeStringArray(stream, ['', '', '']);
stream.position = 0;
expect(decodeStringArray(stream)).toEqual(['', '', '']);
});
});
// =============================================================================
// MULTI-FIELD ROUND-TRIP
// =============================================================================
describe('Multi-field Round-trip', () => {
it('should encode and decode multiple fields in sequence', () => {
const stream = createBitStream();
encodeInt(stream, 42, 12);
encodeInt(stream, 3, 3);
encodeBool(stream, true);
encodeTimestamp(stream, new Date('2025-01-01'));
encodeString(stream, 'Test todo');
encodeIntArray(stream, [1, 2], 12);
stream.position = 0;
expect(decodeInt(stream, 12)).toBe(42);
expect(decodeInt(stream, 3)).toBe(3);
expect(decodeBool(stream)).toBe(true);
const ts = decodeTimestamp(stream);
expect(ts).not.toBeNull();
expect(decodeString(stream)).toBe('Test todo');
expect(decodeIntArray(stream, 12)).toEqual([1, 2]);
});
});