From eb740ba4bbf6ac7f8a7f85b3968402be72a3e49e Mon Sep 17 00:00:00 2001 From: Vaclav Vancura Date: Fri, 8 May 2026 13:03:42 +0200 Subject: [PATCH] fix(api): unify palette and sprite error wording across the engine MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add five runtime message helpers to errorMessages.ts — noActivePaletteError, paletteIndexNegativeError, paletteIndexOutOfRangeError, spriteColorNotInPaletteError, spriteNotIndexizedError — and wire them up across all call sites. - Replace seven duplicate "no active palette" strings with a single noActivePaletteError() call in BTAPI, Renderer, and BlitTech - Rewrite palette index errors to plain-language "color number" wording (BTAPI assertPaletteIndex, Palette assertIndexInRange) - Rewrite sprite "color not in palette" error in indexize/reindexize to name the hex value and suggest next steps - Replace "has not been indexized" with spriteNotIndexizedError(), surfacing loadIndexed as the easy path first - Soften paletteCycle speed and paletteFade duration validation strings; drop [BT] prefix from all thrown Error messages in the engine - Wrap BT.effectAdd, effectRemove, effectClear in executeDrawCall so calling them before bootstrap shows the friendly canvas error instead of an uncaught exception Co-Authored-By: Claude Sonnet 4.6 Signed-off-by: Vaclav Vancura --- src/BlitTech.test.ts | 84 ++++++++++++++++++++++++++++- src/BlitTech.ts | 15 ++++-- src/assets/Palette.test.ts | 6 +-- src/assets/Palette.ts | 3 +- src/assets/SpriteSheet.ts | 29 +++++----- src/core/BTAPI.test.ts | 12 ++--- src/core/BTAPI.ts | 47 +++++++++-------- src/render/Renderer.test.ts | 2 +- src/render/Renderer.ts | 3 +- src/utils/FrameCapture.ts | 2 +- src/utils/errorMessages.test.ts | 93 +++++++++++++++++++++++++++++++++ src/utils/errorMessages.ts | 75 +++++++++++++++++++++++++- 12 files changed, 309 insertions(+), 62 deletions(-) diff --git a/src/BlitTech.test.ts b/src/BlitTech.test.ts index 941b3b1..5621612 100644 --- a/src/BlitTech.test.ts +++ b/src/BlitTech.test.ts @@ -318,7 +318,7 @@ describe('BT.paletteGet', () => { it('throws when no palette is set', () => { vi.spyOn(BTAPI.instance, 'getPalette').mockReturnValue(null); - expect(() => BT.paletteGet()).toThrow('No active palette. Call BT.paletteSet() first.'); + expect(() => BT.paletteGet()).toThrow('No palette set yet. Call BT.paletteSet'); }); }); @@ -1206,3 +1206,85 @@ describe('BT.drawSprite', () => { }); // #endregion + +// #region BT.effectAdd / BT.effectRemove / BT.effectClear + +describe('BT.effectAdd / BT.effectRemove / BT.effectClear', () => { + function makeStubEffect() { + return { + tier: 'pixel' as const, + init: vi.fn(), + updateUniforms: vi.fn(), + encodePass: vi.fn(), + dispose: vi.fn(), + }; + } + + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it('effectAdd shows engine-not-ready when called before bootstrap', async () => { + await withErrorContainer(async () => { + vi.spyOn(BTAPI.instance, 'getRenderer').mockReturnValue(null); + + BT.effectAdd(makeStubEffect()); + + const text = document.getElementById(DEFAULT_CONTAINER_ID)?.textContent ?? ''; + expect(text).toContain("The engine isn't ready yet."); + }); + }); + + it('effectRemove shows engine-not-ready when called before bootstrap', async () => { + await withErrorContainer(async () => { + vi.spyOn(BTAPI.instance, 'getRenderer').mockReturnValue(null); + + BT.effectRemove(makeStubEffect()); + + const text = document.getElementById(DEFAULT_CONTAINER_ID)?.textContent ?? ''; + expect(text).toContain("The engine isn't ready yet."); + }); + }); + + it('effectClear shows engine-not-ready when called before bootstrap', async () => { + await withErrorContainer(async () => { + vi.spyOn(BTAPI.instance, 'getRenderer').mockReturnValue(null); + + BT.effectClear(); + + const text = document.getElementById(DEFAULT_CONTAINER_ID)?.textContent ?? ''; + expect(text).toContain("The engine isn't ready yet."); + }); + }); + + it('effectAdd delegates to BTAPI.instance.effectAdd when renderer is ready', () => { + vi.spyOn(BTAPI.instance, 'getRenderer').mockReturnValue({} as never); + const spy = vi.spyOn(BTAPI.instance, 'effectAdd').mockReturnValue(undefined); + const effect = makeStubEffect(); + + BT.effectAdd(effect); + + expect(spy).toHaveBeenCalledWith(effect); + }); + + it('effectRemove delegates to BTAPI.instance.effectRemove when renderer is ready', () => { + vi.spyOn(BTAPI.instance, 'getRenderer').mockReturnValue({} as never); + const spy = vi.spyOn(BTAPI.instance, 'effectRemove').mockReturnValue(undefined); + const effect = makeStubEffect(); + + BT.effectRemove(effect); + + expect(spy).toHaveBeenCalledWith(effect); + }); + + it('effectClear delegates to BTAPI.instance.effectClear when renderer is ready', () => { + vi.spyOn(BTAPI.instance, 'getRenderer').mockReturnValue({} as never); + const spy = vi.spyOn(BTAPI.instance, 'effectClear').mockReturnValue(undefined); + + BT.effectClear(); + + expect(spy).toHaveBeenCalled(); + }); +}); + +// #endregion diff --git a/src/BlitTech.ts b/src/BlitTech.ts index 9410654..1ce9817 100644 --- a/src/BlitTech.ts +++ b/src/BlitTech.ts @@ -47,6 +47,7 @@ import { clampCameraToWorld } from './utils/CameraUtils'; import { Color32 } from './utils/Color32'; import type { EasingFunction } from './utils/Easing'; import { applyEasing } from './utils/Easing'; +import { noActivePaletteError } from './utils/errorMessages'; import { Rect2i } from './utils/Rect2i'; import { Vector2i } from './utils/Vector2i'; @@ -517,7 +518,7 @@ export const BT = { const palette = BTAPI.instance.getPalette(); if (!palette) { - throw new Error('No active palette. Call BT.paletteSet() first.'); + throw new Error(noActivePaletteError()); } return palette; @@ -642,7 +643,9 @@ export const BT = { * @throws If a `'display'` effect is added without `canvasDisplaySize`. */ effectAdd: (effect: Effect): void => { - BTAPI.instance.effectAdd(effect); + executeDrawCall('effectAdd', () => { + BTAPI.instance.effectAdd(effect); + }); }, /** @@ -655,7 +658,9 @@ export const BT = { * @throws If the engine has not been initialized. */ effectRemove: (effect: Effect): void => { - BTAPI.instance.effectRemove(effect); + executeDrawCall('effectRemove', () => { + BTAPI.instance.effectRemove(effect); + }); }, /** @@ -664,7 +669,9 @@ export const BT = { * @throws If the engine has not been initialized. */ effectClear: (): void => { - BTAPI.instance.effectClear(); + executeDrawCall('effectClear', () => { + BTAPI.instance.effectClear(); + }); }, /** diff --git a/src/assets/Palette.test.ts b/src/assets/Palette.test.ts index dc20a19..5f9d938 100644 --- a/src/assets/Palette.test.ts +++ b/src/assets/Palette.test.ts @@ -55,8 +55,8 @@ describe('Palette', () => { expect(palette.get(5).equals(color)).toBe(true); expect(palette.get(5)).not.toBe(color); - expect(() => palette.get(16)).toThrow('Palette index 16 out of range (palette size: 16)'); - expect(() => palette.set(-1, color)).toThrow('Palette index -1 out of range (palette size: 16)'); + expect(() => palette.get(16)).toThrow('The color number 16 is too big'); + expect(() => palette.set(-1, color)).toThrow('The color number -1 is too big'); }); it('get() returns a defensive copy — mutating the result does not change the stored entry', () => { @@ -162,7 +162,7 @@ describe('Palette', () => { expect(() => Palette.fromJSON({ colors: invalidColors, size: 16 })).toThrow('Palette JSON color 2 is missing'); expect(() => Palette.fromJSON({ colors: new Array(16).fill('#00000000'), names: { bad: 16 }, size: 16 }), - ).toThrow('Palette index 16 out of range (palette size: 16)'); + ).toThrow('The color number 16 is too big'); }); it('roundtrips through raw RGB bytes', () => { diff --git a/src/assets/Palette.ts b/src/assets/Palette.ts index 0d7540b..44e59c0 100644 --- a/src/assets/Palette.ts +++ b/src/assets/Palette.ts @@ -7,6 +7,7 @@ */ import { Color32 } from '../utils/Color32'; +import { paletteIndexOutOfRangeError } from '../utils/errorMessages'; import { C64_HEX, CGA_HEX, GAMEBOY_HEX, NES_HEX, PICO8_HEX, VGA_HEX } from './palettes/presetData'; /** Supported palette sizes exposed by the public API. */ @@ -589,7 +590,7 @@ export class Palette { */ private assertIndexInRange(index: number): void { if (!Number.isInteger(index) || index < 0 || index >= this.size) { - throw new Error(`Palette index ${index} out of range (palette size: ${this.size})`); + throw new Error(paletteIndexOutOfRangeError(index, this.size)); } } diff --git a/src/assets/SpriteSheet.ts b/src/assets/SpriteSheet.ts index d2d4529..0b4107b 100644 --- a/src/assets/SpriteSheet.ts +++ b/src/assets/SpriteSheet.ts @@ -1,4 +1,5 @@ import { Color32 } from '../utils/Color32'; +import { spriteColorNotInPaletteError } from '../utils/errorMessages'; import { Rect2i } from '../utils/Rect2i'; import { Vector2i } from '../utils/Vector2i'; import { AssetLoader } from './AssetLoader'; @@ -71,7 +72,7 @@ export class SpriteSheet { } else if (size) { this.size = size; } else { - throw new Error('[SpriteSheet] Either an image or explicit size must be provided.'); + throw new Error('Either an image or explicit size must be provided.'); } } @@ -240,7 +241,7 @@ export class SpriteSheet { if (collected.length > 0) { if (startSlot < 1) { throw new RangeError( - `[SpriteSheet] loadColorsIntoPalette: startSlot ${startSlot} is invalid (slot 0 is reserved for transparency).`, + `loadColorsIntoPalette: startSlot ${startSlot} is invalid (slot 0 is reserved for transparency).`, ); } @@ -248,7 +249,7 @@ export class SpriteSheet { if (endSlot >= palette.size) { throw new RangeError( - `[SpriteSheet] loadColorsIntoPalette: ${collected.length} colors do not fit in palette size ${palette.size} starting at slot ${startSlot}.`, + `loadColorsIntoPalette: ${collected.length} colors do not fit in palette size ${palette.size} starting at slot ${startSlot}.`, ); } } @@ -281,7 +282,7 @@ export class SpriteSheet { if (indexedPixels.length !== expectedLength) { throw new RangeError( - `[SpriteSheet] indexedPixels length ${indexedPixels.length} does not match ${width}x${height} (expected ${expectedLength}).`, + `indexedPixels length ${indexedPixels.length} does not match ${width}x${height} (expected ${expectedLength}).`, ); } @@ -312,7 +313,7 @@ export class SpriteSheet { */ indexize(palette: Palette): void { if (!this.image) { - throw new Error('[SpriteSheet] indexize: not available for sheets created from raw indexed data.'); + throw new Error('indexize: not available for sheets created from raw indexed data.'); } const w = this.size.x; @@ -356,10 +357,7 @@ export class SpriteSheet { const x = i % w; const y = Math.floor(i / w); const src = this.image.src ? `'${this.image.src}'` : '(unnamed)'; - throw new Error( - `[SpriteSheet] ${src} pixel at (${x}, ${y}) has color ${hex} which is not in the active palette.` + - ` Add this color to the palette before indexizing.`, - ); + throw new Error(spriteColorNotInPaletteError(x, y, src, hex)); } // eslint-disable-next-line security/detect-object-injection @@ -418,11 +416,11 @@ export class SpriteSheet { */ reindexize(palette: Palette): void { if (!this.image) { - throw new Error('[SpriteSheet] reindexize: not available for sheets created from raw indexed data.'); + throw new Error('reindexize: not available for sheets created from raw indexed data.'); } if (this.rgbaPixels === null) { - throw new Error('[SpriteSheet] reindexize: indexize() must be called before reindexize().'); + throw new Error('reindexize: indexize() must be called before reindexize().'); } const w = this.size.x; @@ -457,10 +455,7 @@ export class SpriteSheet { const x = i % w; const y = Math.floor(i / w); const src = this.image.src ? `'${this.image.src}'` : '(unnamed)'; - throw new Error( - `[SpriteSheet] ${src} pixel at (${x}, ${y}) has color ${hex} which is not in the active palette.` + - ` Add this color to the palette before reindexizing.`, - ); + throw new Error(spriteColorNotInPaletteError(x, y, src, hex)); } // eslint-disable-next-line security/detect-object-injection @@ -497,7 +492,7 @@ export class SpriteSheet { */ getImage(): HTMLImageElement { if (!this.image) { - throw new Error('[SpriteSheet] getImage: not available for sheets created from raw indexed data.'); + throw new Error('getImage: not available for sheets created from raw indexed data.'); } return this.image; @@ -611,7 +606,7 @@ export class SpriteSheet { */ private createTexture(device: GPUDevice): void { if (!this.image) { - throw new Error('[SpriteSheet] createTexture: no source image available.'); + throw new Error('createTexture: no source image available.'); } this.texture = device.createTexture({ diff --git a/src/core/BTAPI.test.ts b/src/core/BTAPI.test.ts index dc2848a..ce73ca2 100644 --- a/src/core/BTAPI.test.ts +++ b/src/core/BTAPI.test.ts @@ -225,7 +225,7 @@ describe('BTAPI', () => { it('drawSprite should throw when sprite sheet is not indexized', () => { const mockSheet = { isIndexized: () => false } as unknown as SpriteSheet; expect(() => BTAPI.instance.drawSprite(mockSheet, new Rect2i(0, 0, 16, 16), new Vector2i(0, 0))).toThrow( - '[BT] drawSprite: sprite sheet has not been indexized.', + "This sprite sheet hasn't been prepared yet.", ); }); @@ -251,7 +251,7 @@ describe('BTAPI', () => { const mockSheet = { isIndexized: () => false } as unknown as SpriteSheet; const mockFont = { getSpriteSheet: () => mockSheet } as unknown as BitmapFont; expect(() => BTAPI.instance.drawBitmapText(mockFont, new Vector2i(0, 0), 'hi')).toThrow( - '[BT] drawBitmapText: font sprite sheet has not been indexized.', + "This sprite sheet hasn't been prepared yet.", ); }); @@ -516,9 +516,7 @@ describe('BTAPI', () => { describe('assertPaletteIndex', () => { it('throws when index is negative (no palette set)', () => { - expect(() => BTAPI.instance.drawPixel(new Vector2i(0, 0), -1)).toThrow( - 'is not a valid non-negative integer', - ); + expect(() => BTAPI.instance.drawPixel(new Vector2i(0, 0), -1)).toThrow('0 or higher'); }); it('throws when index is out of range for the active palette', () => { @@ -526,9 +524,7 @@ describe('BTAPI', () => { BTAPI.instance.setPalette(palette); - expect(() => BTAPI.instance.drawPixel(new Vector2i(0, 0), 20)).toThrow( - 'Palette index 20 out of range for palette of size 16.', - ); + expect(() => BTAPI.instance.drawPixel(new Vector2i(0, 0), 20)).toThrow('The color number 20 is too big'); }); }); diff --git a/src/core/BTAPI.ts b/src/core/BTAPI.ts index 3bcb879..b3b649e 100644 --- a/src/core/BTAPI.ts +++ b/src/core/BTAPI.ts @@ -17,6 +17,12 @@ import type { Effect } from '../render/effects/Effect'; import { Renderer } from '../render/Renderer'; import type { Color32 } from '../utils/Color32'; import type { EasingFunction } from '../utils/Easing'; +import { + noActivePaletteError, + paletteIndexNegativeError, + paletteIndexOutOfRangeError, + spriteNotIndexizedError, +} from '../utils/errorMessages'; import type { Rect2i } from '../utils/Rect2i'; import { Vector2i } from '../utils/Vector2i'; import type { FrameDropCallback, FrameDropEvent } from './GameLoop'; @@ -566,7 +572,7 @@ export class BTAPI { */ public drawSprite(spriteSheet: SpriteSheet, srcRect: Rect2i, destPos: Vector2i, paletteOffset: number = 0): void { this.assertPaletteIndex(paletteOffset); - this.requireIndexizedSheet(spriteSheet, 'drawSprite'); + this.requireIndexizedSheet(spriteSheet); this.renderer?.drawSprite(spriteSheet, srcRect, destPos, paletteOffset); } @@ -582,7 +588,7 @@ export class BTAPI { */ public drawBitmapText(font: BitmapFont, pos: Vector2i, text: string, paletteOffset: number = 0): void { this.assertPaletteIndex(paletteOffset); - this.requireIndexizedSheet(font.getSpriteSheet(), 'drawBitmapText', 'font sprite sheet'); + this.requireIndexizedSheet(font.getSpriteSheet()); this.renderer?.drawBitmapText(font, pos, text, paletteOffset); } @@ -600,7 +606,7 @@ export class BTAPI { */ public spritesRefresh(): void { if (!this.palette) { - throw new Error('[BT] spritesRefresh: no active palette. Call BT.paletteSet() first.'); + throw new Error(noActivePaletteError()); } let refreshed = 0; @@ -636,7 +642,7 @@ export class BTAPI { */ public captureFrame(): Promise { if (!this.renderer) { - return Promise.reject(new Error("[BT] Can't capture frame: renderer not initialized")); + return Promise.reject(new Error("Can't capture frame: renderer not initialized")); } return this.renderer.captureFrame(); @@ -688,11 +694,11 @@ export class BTAPI { */ public paletteCycle(start: number, end: number, speed: number): void { if (!Number.isFinite(speed)) { - throw new Error(`[BT] paletteCycle: speed must be finite, got ${speed}.`); + throw new Error(`paletteCycle: 'speed' should be a number (got ${speed}).`); } if (!Number.isInteger(start) || !Number.isInteger(end) || start >= end) { - throw new Error(`[BT] paletteCycle: start must be an integer less than end, got [${start}, ${end}].`); + throw new Error(`paletteCycle: start must be an integer less than end, got [${start}, ${end}].`); } this.paletteEffects.add(new CycleEffect(start, end, speed)); @@ -709,7 +715,7 @@ export class BTAPI { */ public paletteFade(target: Palette, durationMs: number, easing?: EasingFunction): void { if (!this.palette) { - throw new Error('[BT] Cannot fade palette: no active palette set.'); + throw new Error(noActivePaletteError()); } this.assertFiniteDuration('paletteFade', durationMs); @@ -733,7 +739,7 @@ export class BTAPI { easing?: EasingFunction, ): void { if (!this.palette) { - throw new Error('[BT] Cannot fade palette range: no active palette set.'); + throw new Error(noActivePaletteError()); } this.assertFiniteDuration('paletteFadeRange', durationMs); @@ -750,7 +756,7 @@ export class BTAPI { */ public paletteFlash(color: Color32, durationMs: number): void { if (!this.palette) { - throw new Error('[BT] Cannot flash palette: no active palette set.'); + throw new Error(noActivePaletteError()); } this.assertFiniteDuration('paletteFlash', durationMs); @@ -767,7 +773,7 @@ export class BTAPI { */ public paletteSwap(indexA: number, indexB: number): void { if (!this.palette) { - throw new Error('[BT] Cannot swap palette entries: no active palette set.'); + throw new Error(noActivePaletteError()); } paletteSwap(this.palette, indexA, indexB); @@ -797,7 +803,7 @@ export class BTAPI { */ public effectAdd(effect: Effect): void { if (!this.renderer) { - throw new Error('[BT] Cannot add effect: renderer not initialized.'); + throw new Error('Cannot add effect: renderer not initialized.'); } this.renderer.addEffect(effect); @@ -815,7 +821,7 @@ export class BTAPI { */ public effectRemove(effect: Effect): void { if (!this.renderer) { - throw new Error('[BT] Cannot remove effect: renderer not initialized.'); + throw new Error('Cannot remove effect: renderer not initialized.'); } this.renderer.removeEffect(effect); @@ -828,7 +834,7 @@ export class BTAPI { */ public effectClear(): void { if (!this.renderer) { - throw new Error('[BT] Cannot clear effects: renderer not initialized.'); + throw new Error('Cannot clear effects: renderer not initialized.'); } this.renderer.clearEffects(); @@ -906,16 +912,11 @@ export class BTAPI { * Validates that a sprite sheet has been indexized and registers it for refresh tracking. * * @param sheet - Sprite sheet to validate. - * @param method - Calling method name for the error message. - * @param label - Human-readable name for the sheet used in the error message (default `'sprite sheet'`). * @throws If the sprite sheet has not been indexized. */ - private requireIndexizedSheet(sheet: SpriteSheet, method: string, label: string = 'sprite sheet'): void { + private requireIndexizedSheet(sheet: SpriteSheet): void { if (!sheet.isIndexized()) { - throw new Error( - `[BT] ${method}: ${label} has not been indexized.` + - ' Call spriteSheet.indexize(palette) after setting a palette.', - ); + throw new Error(spriteNotIndexizedError()); } this.spriteSheets.add(sheet); @@ -930,7 +931,7 @@ export class BTAPI { */ private assertFiniteDuration(method: string, durationMs: number): void { if (!Number.isFinite(durationMs) || durationMs < 0) { - throw new Error(`[BT] ${method}: durationMs must be a finite non-negative number, got ${durationMs}.`); + throw new Error(`${method}: the time should be a positive number of milliseconds (got ${durationMs}).`); } } @@ -947,11 +948,11 @@ export class BTAPI { */ private assertPaletteIndex(index: number): void { if (!Number.isInteger(index) || index < 0) { - throw new Error(`Palette index ${index} is not a valid non-negative integer.`); + throw new Error(paletteIndexNegativeError(index)); } if (this.palette && index >= this.palette.size) { - throw new Error(`Palette index ${index} out of range for palette of size ${this.palette.size}.`); + throw new Error(paletteIndexOutOfRangeError(index, this.palette.size)); } } diff --git a/src/render/Renderer.test.ts b/src/render/Renderer.test.ts index f395f54..cc88cbb 100644 --- a/src/render/Renderer.test.ts +++ b/src/render/Renderer.test.ts @@ -111,7 +111,7 @@ describe('pre-initialization methods', () => { expect(() => { renderer.beginFrame(); - }).toThrow('Cannot begin frame: no active palette. Call setPalette() first.'); + }).toThrow('No palette set yet. Call BT.paletteSet'); }); it('beginFrame succeeds with active palette', () => { diff --git a/src/render/Renderer.ts b/src/render/Renderer.ts index 338c2e5..f70bf73 100644 --- a/src/render/Renderer.ts +++ b/src/render/Renderer.ts @@ -2,6 +2,7 @@ import type { BitmapFont } from '../assets/BitmapFont'; import type { Palette } from '../assets/Palette'; import type { SpriteSheet } from '../assets/SpriteSheet'; import { Color32 } from '../utils/Color32'; +import { noActivePaletteError } from '../utils/errorMessages'; import { FrameCapture } from '../utils/FrameCapture'; import type { Rect2i } from '../utils/Rect2i'; import { Vector2i } from '../utils/Vector2i'; @@ -293,7 +294,7 @@ export class Renderer { */ beginFrame(): void { if (!this.palette) { - throw new Error('Cannot begin frame: no active palette. Call setPalette() first.'); + throw new Error(noActivePaletteError()); } this.primitives.reset(); diff --git a/src/utils/FrameCapture.ts b/src/utils/FrameCapture.ts index 93d5ee8..ce914c5 100644 --- a/src/utils/FrameCapture.ts +++ b/src/utils/FrameCapture.ts @@ -94,7 +94,7 @@ export async function pixelBufferToPNG( const ctx = offscreen.getContext('2d'); if (!ctx) { - throw new Error('[FrameCapture] Failed to create 2D context on OffscreenCanvas'); + throw new Error('Failed to create 2D context on OffscreenCanvas'); } ctx.putImageData(imageData, 0, 0); diff --git a/src/utils/errorMessages.test.ts b/src/utils/errorMessages.test.ts index 38f61c7..54f5be9 100644 --- a/src/utils/errorMessages.test.ts +++ b/src/utils/errorMessages.test.ts @@ -3,6 +3,11 @@ import { describe, expect, it } from 'vitest'; import { CANVAS_NOT_FOUND_MESSAGE, INIT_FAILED_MESSAGE, + noActivePaletteError, + paletteIndexNegativeError, + paletteIndexOutOfRangeError, + spriteColorNotInPaletteError, + spriteNotIndexizedError, WEBGPU_ADAPTER_MESSAGE, WEBGPU_DEVICE_MESSAGE, } from './errorMessages'; @@ -55,3 +60,91 @@ describe('errorMessages', () => { }); }); }); + +describe('runtime error message helpers', () => { + describe('noActivePaletteError', () => { + it('returns a non-empty string', () => { + expect(noActivePaletteError().length).toBeGreaterThan(0); + }); + + it('mentions BT.paletteSet', () => { + expect(noActivePaletteError()).toContain('BT.paletteSet'); + }); + + it('is consistent across calls', () => { + expect(noActivePaletteError()).toBe(noActivePaletteError()); + }); + }); + + describe('paletteIndexNegativeError', () => { + it('includes the supplied index', () => { + expect(paletteIndexNegativeError(-3)).toContain('-3'); + }); + + it('mentions 0 or higher', () => { + expect(paletteIndexNegativeError(-3)).toContain('0 or higher'); + }); + + it('produces different messages for different indices', () => { + expect(paletteIndexNegativeError(-1)).not.toBe(paletteIndexNegativeError(-5)); + }); + }); + + describe('paletteIndexOutOfRangeError', () => { + it('includes the supplied index', () => { + expect(paletteIndexOutOfRangeError(999, 256)).toContain('999'); + }); + + it('includes the palette size', () => { + expect(paletteIndexOutOfRangeError(999, 256)).toContain('256'); + }); + + it('states the valid upper bound', () => { + expect(paletteIndexOutOfRangeError(999, 256)).toContain('255'); + }); + + it('produces different messages for different sizes', () => { + expect(paletteIndexOutOfRangeError(20, 16)).not.toBe(paletteIndexOutOfRangeError(20, 32)); + }); + }); + + describe('spriteColorNotInPaletteError', () => { + it('includes the pixel coordinates', () => { + const msg = spriteColorNotInPaletteError(10, 5, "'sheet.png'", '#ff0000'); + expect(msg).toContain('(10, 5)'); + }); + + it('includes the source image name', () => { + const msg = spriteColorNotInPaletteError(10, 5, "'sheet.png'", '#ff0000'); + expect(msg).toContain("'sheet.png'"); + }); + + it('includes the hex color', () => { + const msg = spriteColorNotInPaletteError(10, 5, "'sheet.png'", '#ff0000'); + expect(msg).toContain('#ff0000'); + }); + + it('suggests adding the color or editing the image', () => { + const msg = spriteColorNotInPaletteError(10, 5, "'sheet.png'", '#ff0000'); + expect(msg).toContain('add'); + }); + }); + + describe('spriteNotIndexizedError', () => { + it('returns a non-empty string', () => { + expect(spriteNotIndexizedError().length).toBeGreaterThan(0); + }); + + it('mentions SpriteSheet.loadIndexed', () => { + expect(spriteNotIndexizedError()).toContain('SpriteSheet.loadIndexed'); + }); + + it('mentions indexize as the manual path', () => { + expect(spriteNotIndexizedError()).toContain('indexize'); + }); + + it('is consistent across calls', () => { + expect(spriteNotIndexizedError()).toBe(spriteNotIndexizedError()); + }); + }); +}); diff --git a/src/utils/errorMessages.ts b/src/utils/errorMessages.ts index 3c23d87..c7f027e 100644 --- a/src/utils/errorMessages.ts +++ b/src/utils/errorMessages.ts @@ -1,9 +1,10 @@ /** - * Shared user-facing error message strings for the Blit-Tech bootstrap path. + * Shared user-facing error message strings for the Blit-Tech bootstrap and runtime paths. * * Both the production bootstrap (Bootstrap.ts) and the error-preview demo * (BootstrapHelpers.ts) import from here, guaranteeing they can never drift - * apart. + * apart. Runtime message helpers (palette, sprite) are also centralized here + * so every site uses identical wording. */ /** @@ -42,3 +43,73 @@ export const WEBGPU_ADAPTER_MESSAGE = */ export const WEBGPU_DEVICE_MESSAGE = "Couldn't connect to the graphics card. Try closing other tabs or restarting the browser."; + +// #region Runtime — Palette + +/** + * Returns the "no active palette" error message used whenever a palette must + * be set before an operation can proceed. + * + * @returns User-facing error string. + */ +export function noActivePaletteError(): string { + return 'No palette set yet. Call BT.paletteSet(somePalette) before drawing or running palette effects.'; +} + +/** + * Returns the error message for a palette index that is negative or not a + * whole number. + * + * @param index - The invalid index value that was supplied. + * @returns User-facing error string. + */ +export function paletteIndexNegativeError(index: number): string { + return `The color number must be a whole number that's 0 or higher (got ${index}).`; +} + +/** + * Returns the error message for a palette index that exceeds the palette size. + * + * @param index - The out-of-range index that was supplied. + * @param size - The number of colors in the active palette. + * @returns User-facing error string. + */ +export function paletteIndexOutOfRangeError(index: number, size: number): string { + return `The color number ${index} is too big for this palette. The palette has ${size} colors, so use a number from 0 to ${size - 1}.`; +} + +// #endregion + +// #region Runtime — Sprites + +/** + * Returns the error message for a sprite pixel whose color is absent from the + * active palette. + * + * @param x - Pixel x coordinate within the source image. + * @param y - Pixel y coordinate within the source image. + * @param src - Source image label (e.g. `'sheet.png'` or `(unnamed)`). + * @param hex - The color that was not found, as a lowercase hex string. + * @returns User-facing error string. + */ +export function spriteColorNotInPaletteError(x: number, y: number, src: string, hex: string): string { + return ( + `The pixel at (${x}, ${y}) in ${src} has the color ${hex}, but that color isn't in your palette.` + + ` Either add ${hex} to the palette, or change that pixel in the image.` + ); +} + +/** + * Returns the error message shown when a sprite sheet has not been indexized + * before use. + * + * @returns User-facing error string. + */ +export function spriteNotIndexizedError(): string { + return ( + "This sprite sheet hasn't been prepared yet. Use SpriteSheet.loadIndexed(...) for one-step setup," + + ' or call sheet.indexize(palette) after BT.paletteSet.' + ); +} + +// #endregion -- 2.51.2