Files
rplace/test/lib/canvas-decoder.test.js
T
tiennm99 a823f8527d fix: address ultrareview findings across backend and frontend
Backend:
- rate-limiter: retryAfter now in seconds; ms-precision lu preserves
  fractional regen residue across calls (C1, C2)
- redis-client: throw on Upstash 200-with-error envelope; redisRaw
  returns body.result (NH1)
- constants: MAX_BATCH_SIZE = MAX_CREDITS = 256 (was 512 vs 256)
- worker: content-length cap, gzip + s-maxage=10 on /api/canvas,
  broadcast via executionCtx.waitUntil with r.ok check (NC2, H4, H5)
- canvas-storage: warn on truncated Upstash read instead of silent
  zero-pad (NH2)
- get-user-id: SHA-256 (16 hex chars) replaces 32-bit string hash;
  missing cf-connecting-ip routes to anon:dev with warn (H1, H2);
  function is now async
- canvas-room: log unclean WS closes and errors; defensive close on
  unexpected client message (NH4, N5)

Frontend:
- pixel buffer capped at MAX_BATCH_SIZE with toast (NC2)
- Submit error UX: toast for 429/413/400/5xx/network; honor
  retryAfter (NC1)
- committedColors allocated upfront so WS updates during initial
  fetch no longer null-deref (NC3)
- handleWheel always renders even when zoom is clamped (C1, C2)
- canvas-decoder throws on truncated input instead of reading past
  end with || 0 (C3)
- WS reconnect refetches canvas to recover missed pixels (C4)
- pixel-buffer Map cache for O(1) getColorAt/pixelCount (NH1)
- cancel in-progress stroke on mode switch (NH3)
- canvas load error overlay with Retry button (NH5)
- DPR-aware canvas sizing (H1)
- onMount cleanup is now sync (no leaked resize listener) (H2)

Tests:
- update for async getUserId, decoder bounds check, redis error
  format; add Upstash error-envelope coverage
2026-04-17 10:14:45 +07:00

122 lines
4.1 KiB
JavaScript

import { describe, it, expect } from 'vitest';
import { decodeCanvas, indicesToRgba } from '../../src/lib/canvas-decoder.js';
import { CANVAS_WIDTH, CANVAS_HEIGHT, BITS_PER_PIXEL, COLORS, COLORS_RGBA } from '../../src/lib/constants.js';
const TOTAL_PIXELS = CANVAS_WIDTH * CANVAS_HEIGHT;
const EXPECTED_BYTES = Math.ceil((TOTAL_PIXELS * BITS_PER_PIXEL) / 8);
/** Encode color indices into 5-bit packed bytes (test helper, mirrors BITFIELD storage).
* Returns a full-canvas-sized buffer (zero-padded tail) so decodeCanvas accepts it. */
function encodeIndicesPadded(indices) {
const bytes = new Uint8Array(EXPECTED_BYTES);
for (let i = 0; i < indices.length; i++) {
const bitPos = i * 5;
const byteIndex = bitPos >> 3;
const bitOffset = bitPos & 7;
bytes[byteIndex] |= (indices[i] << (11 - bitOffset)) >> 8;
if (bitOffset > 3) {
bytes[byteIndex + 1] |= (indices[i] << (11 - bitOffset)) & 0xff;
} else {
bytes[byteIndex] |= (indices[i] << (3 - bitOffset));
}
}
return bytes;
}
describe('decodeCanvas', () => {
it('decodes a full zero-filled buffer as all zeros', () => {
const buffer = new ArrayBuffer(EXPECTED_BYTES);
const indices = decodeCanvas(buffer);
expect(indices.length).toBe(TOTAL_PIXELS);
expect(indices.every((v) => v === 0)).toBe(true);
});
it('throws on truncated buffer', () => {
expect(() => decodeCanvas(new ArrayBuffer(0))).toThrow(/truncated/);
expect(() => decodeCanvas(new ArrayBuffer(EXPECTED_BYTES - 1))).toThrow(/truncated/);
});
it('decodes a single pixel', () => {
// Color 15 at pixel 0: binary 01111 in first 5 bits → byte 0 = 0111_1000 = 0x78
const bytes = new Uint8Array(EXPECTED_BYTES);
bytes[0] = 0x78;
const indices = decodeCanvas(bytes.buffer);
expect(indices[0]).toBe(15);
});
it('round-trips all 32 color values', () => {
const input = new Uint8Array(32);
for (let i = 0; i < 32; i++) input[i] = i;
const encoded = encodeIndicesPadded(input);
const decoded = decodeCanvas(encoded.buffer);
for (let i = 0; i < 32; i++) {
expect(decoded[i]).toBe(i);
}
});
it('round-trips repeated color patterns', () => {
const input = new Uint8Array(100);
for (let i = 0; i < 100; i++) input[i] = i % 32;
const encoded = encodeIndicesPadded(input);
const decoded = decodeCanvas(encoded.buffer);
for (let i = 0; i < 100; i++) {
expect(decoded[i]).toBe(i % 32);
}
});
it('handles max color value (31) at various offsets', () => {
const input = new Uint8Array(8).fill(31);
const encoded = encodeIndicesPadded(input);
const decoded = decodeCanvas(encoded.buffer);
for (let i = 0; i < 8; i++) {
expect(decoded[i]).toBe(31);
}
});
});
describe('indicesToRgba', () => {
it('produces correct RGBA for all 32 colors', () => {
const indices = new Uint8Array(32);
for (let i = 0; i < 32; i++) indices[i] = i;
const rgba = indicesToRgba(indices);
expect(rgba.length).toBe(32 * 4);
for (let i = 0; i < 32; i++) {
const [r, g, b, a] = COLORS_RGBA[i];
expect(rgba[i * 4]).toBe(r);
expect(rgba[i * 4 + 1]).toBe(g);
expect(rgba[i * 4 + 2]).toBe(b);
expect(rgba[i * 4 + 3]).toBe(a);
}
});
it('always sets alpha to 255', () => {
const indices = new Uint8Array([0, 15, 27, 31]);
const rgba = indicesToRgba(indices);
for (let i = 0; i < 4; i++) {
expect(rgba[i * 4 + 3]).toBe(255);
}
});
it('returns Uint8ClampedArray', () => {
const rgba = indicesToRgba(new Uint8Array([0]));
expect(rgba).toBeInstanceOf(Uint8ClampedArray);
});
});
describe('COLORS_RGBA consistency', () => {
it('has 32 entries matching COLORS hex values', () => {
expect(COLORS_RGBA.length).toBe(32);
expect(COLORS.length).toBe(32);
for (let i = 0; i < 32; i++) {
const hex = COLORS[i];
const n = parseInt(hex.slice(1), 16);
expect(COLORS_RGBA[i][0]).toBe((n >> 16) & 0xff);
expect(COLORS_RGBA[i][1]).toBe((n >> 8) & 0xff);
expect(COLORS_RGBA[i][2]).toBe(n & 0xff);
expect(COLORS_RGBA[i][3]).toBe(255);
}
});
});