mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-15 16:39:39 +02:00
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>
446 lines
13 KiB
TypeScript
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]);
|
|
});
|
|
});
|