mirror of
https://github.com/tiennm99/rplace.git
synced 2026-10-13 16:15:33 +00:00
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)
114 lines
3.7 KiB
JavaScript
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);
|
|
}
|
|
});
|
|
});
|