mirror of
https://github.com/tiennm99/rplace.git
synced 2026-10-11 03:13:48 +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)
175 lines
5.6 KiB
JavaScript
175 lines
5.6 KiB
JavaScript
/**
|
|
* Integration tests: BITFIELD write → GETRANGE read round-trip with real Redis.
|
|
* Uses Testcontainers to spin up a Redis Docker container.
|
|
* Verifies the exact command sequences our setPixels/getFullCanvas use.
|
|
*/
|
|
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
|
import { GenericContainer } from 'testcontainers';
|
|
import Redis from 'ioredis';
|
|
import { decodeCanvas } from '../../src/lib/canvas-decoder.js';
|
|
import { CANVAS_WIDTH, CANVAS_HEIGHT, REDIS_CANVAS_KEY } from '../../src/lib/constants.js';
|
|
|
|
const TOTAL_PIXELS = CANVAS_WIDTH * CANVAS_HEIGHT;
|
|
const CANVAS_BYTES = TOTAL_PIXELS; // 1 byte per pixel (u8)
|
|
|
|
let container;
|
|
let redis;
|
|
|
|
beforeAll(async () => {
|
|
container = await new GenericContainer('redis:7-alpine')
|
|
.withExposedPorts(6379)
|
|
.start();
|
|
redis = new Redis({
|
|
host: container.getHost(),
|
|
port: container.getMappedPort(6379),
|
|
});
|
|
}, 60000);
|
|
|
|
afterAll(async () => {
|
|
await redis?.quit();
|
|
await container?.stop();
|
|
});
|
|
|
|
/**
|
|
* Replicate setPixels: build the same BITFIELD command our worker sends.
|
|
*/
|
|
async function writePixels(pixels) {
|
|
const args = [];
|
|
for (const { x, y, color } of pixels) {
|
|
const offset = y * CANVAS_WIDTH + x;
|
|
args.push('SET', 'u8', `#${offset}`, String(color));
|
|
}
|
|
return redis.call('BITFIELD', REDIS_CANVAS_KEY, ...args);
|
|
}
|
|
|
|
/**
|
|
* Replicate getFullCanvas: GETRANGE → raw bytes → decode.
|
|
* Uses Buffer (binary-safe, unlike Upstash REST JSON transport).
|
|
*/
|
|
async function readCanvasBytes() {
|
|
const buf = await redis.getrangeBuffer(REDIS_CANVAS_KEY, 0, CANVAS_BYTES - 1);
|
|
if (!buf || buf.length === 0) {
|
|
return new Uint8Array(CANVAS_BYTES);
|
|
}
|
|
const bytes = new Uint8Array(buf.length);
|
|
for (let i = 0; i < buf.length; i++) {
|
|
bytes[i] = buf[i];
|
|
}
|
|
if (bytes.length < CANVAS_BYTES) {
|
|
const padded = new Uint8Array(CANVAS_BYTES);
|
|
padded.set(bytes);
|
|
return padded;
|
|
}
|
|
return bytes;
|
|
}
|
|
|
|
describe('Redis BITFIELD canvas round-trip', () => {
|
|
it('stores and reads back a single pixel', async () => {
|
|
await writePixels([{ x: 0, y: 0, color: 15 }]);
|
|
const bytes = await readCanvasBytes();
|
|
const indices = decodeCanvas(bytes.buffer);
|
|
expect(indices[0]).toBe(15);
|
|
});
|
|
|
|
it('round-trips 256 color values across the palette', async () => {
|
|
const pixels = [];
|
|
for (let i = 0; i < 256; i++) {
|
|
pixels.push({ x: i, y: 1, color: i });
|
|
}
|
|
await writePixels(pixels);
|
|
const bytes = await readCanvasBytes();
|
|
const indices = decodeCanvas(bytes.buffer);
|
|
|
|
for (let i = 0; i < 256; i++) {
|
|
expect(indices[1 * CANVAS_WIDTH + i]).toBe(i);
|
|
}
|
|
});
|
|
|
|
it('handles pixels at various canvas positions', async () => {
|
|
const testCases = [
|
|
{ x: 0, y: 0, color: 1 },
|
|
{ x: CANVAS_WIDTH - 1, y: 0, color: 255 },
|
|
{ x: 0, y: CANVAS_HEIGHT - 1, color: 128 },
|
|
{ x: CANVAS_WIDTH - 1, y: CANVAS_HEIGHT - 1, color: 42 },
|
|
{ x: CANVAS_WIDTH / 2, y: CANVAS_HEIGHT / 2, color: 200 },
|
|
];
|
|
await writePixels(testCases);
|
|
const bytes = await readCanvasBytes();
|
|
const indices = decodeCanvas(bytes.buffer);
|
|
|
|
for (const { x, y, color } of testCases) {
|
|
expect(indices[y * CANVAS_WIDTH + x]).toBe(color);
|
|
}
|
|
});
|
|
|
|
it('overwrites existing pixels correctly', async () => {
|
|
await writePixels([{ x: 500, y: 500, color: 10 }]);
|
|
let bytes = await readCanvasBytes();
|
|
let indices = decodeCanvas(bytes.buffer);
|
|
expect(indices[500 * CANVAS_WIDTH + 500]).toBe(10);
|
|
|
|
await writePixels([{ x: 500, y: 500, color: 250 }]);
|
|
bytes = await readCanvasBytes();
|
|
indices = decodeCanvas(bytes.buffer);
|
|
expect(indices[500 * CANVAS_WIDTH + 500]).toBe(250);
|
|
});
|
|
|
|
it('batch writes are atomic (all pixels in one BITFIELD)', async () => {
|
|
const batchSize = 500;
|
|
const pixels = [];
|
|
for (let i = 0; i < batchSize; i++) {
|
|
pixels.push({ x: i, y: 2, color: i % 256 });
|
|
}
|
|
await writePixels(pixels);
|
|
const bytes = await readCanvasBytes();
|
|
const indices = decodeCanvas(bytes.buffer);
|
|
|
|
for (let i = 0; i < batchSize; i++) {
|
|
expect(indices[2 * CANVAS_WIDTH + i]).toBe(i % 256);
|
|
}
|
|
});
|
|
|
|
it('adjacent pixels do not corrupt each other (byte boundary)', async () => {
|
|
// With u8 each pixel is its own byte; verify no bleed between neighbors.
|
|
const pixels = [];
|
|
for (let i = 0; i < 16; i++) pixels.push({ x: i, y: 3, color: 255 }); // all bits
|
|
for (let i = 16; i < 32; i++) pixels.push({ x: i, y: 3, color: 0 }); // zero
|
|
await writePixels(pixels);
|
|
const bytes = await readCanvasBytes();
|
|
const indices = decodeCanvas(bytes.buffer);
|
|
|
|
for (let i = 0; i < 16; i++) expect(indices[3 * CANVAS_WIDTH + i]).toBe(255);
|
|
for (let i = 16; i < 32; i++) expect(indices[3 * CANVAS_WIDTH + i]).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe('Redis rate limiter cooldown (SET NX EX)', () => {
|
|
const COOLDOWN_SEC = 1;
|
|
const key = 'rplace:cooldown:test-user';
|
|
|
|
/** Mirrors checkRateLimit: SET key "1" NX EX <cooldown>. */
|
|
async function tryAcquire() {
|
|
const res = await redis.set(key, '1', 'EX', COOLDOWN_SEC, 'NX');
|
|
return res === 'OK';
|
|
}
|
|
|
|
it('allows first request for a fresh user', async () => {
|
|
await redis.del(key);
|
|
expect(await tryAcquire()).toBe(true);
|
|
});
|
|
|
|
it('rejects second request within cooldown window', async () => {
|
|
await redis.del(key);
|
|
expect(await tryAcquire()).toBe(true);
|
|
expect(await tryAcquire()).toBe(false);
|
|
});
|
|
|
|
it('allows again after cooldown expires', async () => {
|
|
await redis.del(key);
|
|
expect(await tryAcquire()).toBe(true);
|
|
// Wait slightly longer than cooldown for the EX key to expire.
|
|
await new Promise((r) => setTimeout(r, (COOLDOWN_SEC * 1000) + 100));
|
|
expect(await tryAcquire()).toBe(true);
|
|
}, 5000);
|
|
});
|