Files
rplace/test/lib/canvas-storage.test.js
T
tiennm99 cfbac2a586 feat(canvas): 4096^2 canvas, 256-color palette (u8 byte-aligned), custom picker (#5)
Canvas:
- CANVAS_W/H = 4096, total 16,777,216 pixels
- BITS_PER_PIXEL = 8 (byte-aligned) — raw Redis bytes are palette indices
- Canvas-decoder becomes an identity wrap/copy
- Storage BITFIELD uses u8; offset = y*W + x
- Redis key versioned to rplace:canvas:v2 so old 32-color/2048^2 data is
  orphaned (operators can DEL the old key to reclaim memory)

Palette:
- 256 entries, generated deterministically:
  - 0..15  = 16-step grayscale ramp (pure black -> pure white)
  - 16..255 = 240 HSL wheel (4 lightness rings x 60 hues @ 82% saturation)
- nearestPaletteIndex(r,g,b) helper for custom-color snapping

UI:
- ColorPicker: 16-swatch favorites strip (grays + 8 accents) + current-color
  swatch + expand toggle for the full 16x16 grid + "Custom..." button that
  opens the native <input type="color"> and snaps to nearest palette entry
- Default selected color bumped to index 0 (black)

Tests + docs:
- canvas-decoder tests rewritten for identity u8 decode
- canvas-storage tests updated for u8 offsets
- image-to-palette tests anchored to PALETTE_BLACK=0 / PALETTE_WHITE=15 and
  COLORS_RGBA[i] probes (no more hardcoded old 32-color indices)
- integration test uses u8 BITFIELD and canvas-aware bounds
- README, system-architecture, deployment-guide updated (storage math,
  migration note for orphaned old key)
2026-04-18 13:47:01 +07:00

114 lines
3.7 KiB
JavaScript

import { describe, it, expect, vi, beforeEach } from 'vitest';
import { CANVAS_WIDTH, REDIS_CANVAS_KEY } from '../../src/lib/constants.js';
// Mock redis-client module
vi.mock('../../src/lib/redis-client.js', () => ({
redisRaw: vi.fn(),
redisRawBinary: vi.fn(),
}));
import { getFullCanvas, setPixels } from '../../src/lib/canvas-storage.js';
import { redisRaw, redisRawBinary } from '../../src/lib/redis-client.js';
const CANVAS_BYTES = CANVAS_WIDTH * CANVAS_WIDTH; // u8 = 1 byte per pixel
describe('setPixels', () => {
beforeEach(() => vi.clearAllMocks());
it('does nothing for empty array', async () => {
await setPixels({}, []);
expect(redisRaw).not.toHaveBeenCalled();
});
it('builds correct BITFIELD command for single pixel', async () => {
redisRaw.mockResolvedValue({ result: [0] });
await setPixels({}, [{ x: 10, y: 20, color: 5 }]);
const call = redisRaw.mock.calls[0][1];
expect(call[0]).toBe('BITFIELD');
expect(call[1]).toBe(REDIS_CANVAS_KEY);
expect(call[2]).toBe('SET');
expect(call[3]).toBe('u8');
const expectedOffset = 20 * CANVAS_WIDTH + 10;
expect(call[4]).toBe(`#${expectedOffset}`);
expect(call[5]).toBe('5');
});
it('builds correct BITFIELD command for multiple pixels', async () => {
redisRaw.mockResolvedValue({ result: [0, 0] });
await setPixels({}, [
{ x: 0, y: 0, color: 1 },
{ x: 1, y: 0, color: 255 },
]);
const call = redisRaw.mock.calls[0][1];
// BITFIELD key SET u8 #0 1 SET u8 #1 255
expect(call).toEqual([
'BITFIELD', REDIS_CANVAS_KEY,
'SET', 'u8', '#0', '1',
'SET', 'u8', '#1', '255',
]);
});
it('computes offset correctly for various positions', async () => {
redisRaw.mockResolvedValue({ result: [0] });
// Last pixel of the canvas.
const lastX = CANVAS_WIDTH - 1;
const lastY = CANVAS_WIDTH - 1;
await setPixels({}, [{ x: lastX, y: lastY, color: 0 }]);
const offset = lastY * CANVAS_WIDTH + lastX;
expect(redisRaw.mock.calls[0][1][4]).toBe(`#${offset}`);
});
});
describe('getFullCanvas', () => {
beforeEach(() => vi.clearAllMocks());
it('returns zero-filled buffer when Redis returns null', async () => {
redisRawBinary.mockResolvedValue(null);
const buf = await getFullCanvas({});
expect(buf.length).toBe(CANVAS_BYTES);
expect(buf.every((b) => b === 0)).toBe(true);
});
it('returns zero-filled buffer when Redis returns empty string', async () => {
redisRawBinary.mockResolvedValue('');
const buf = await getFullCanvas({});
expect(buf.length).toBe(CANVAS_BYTES);
expect(buf.every((b) => b === 0)).toBe(true);
});
it('decodes base64 response correctly', async () => {
// 3 bytes: [0x78, 0xAB, 0xFF]
const base64 = btoa(String.fromCharCode(0x78, 0xAB, 0xFF));
redisRawBinary.mockResolvedValue(base64);
const buf = await getFullCanvas({});
expect(buf[0]).toBe(0x78);
expect(buf[1]).toBe(0xAB);
expect(buf[2]).toBe(0xFF);
});
it('pads short responses to full canvas size', async () => {
const base64 = btoa(String.fromCharCode(0xFF));
redisRawBinary.mockResolvedValue(base64);
const buf = await getFullCanvas({});
expect(buf.length).toBe(CANVAS_BYTES);
expect(buf[0]).toBe(0xFF);
expect(buf[1]).toBe(0);
});
it('handles all byte values (0x00-0xFF) without corruption', async () => {
// This is the test that would have caught the binary encoding bug
const allBytes = new Array(256);
for (let i = 0; i < 256; i++) allBytes[i] = String.fromCharCode(i);
const base64 = btoa(allBytes.join(''));
redisRawBinary.mockResolvedValue(base64);
const buf = await getFullCanvas({});
for (let i = 0; i < 256; i++) {
expect(buf[i]).toBe(i);
}
});
});