From 2481de634b161caa2178bd74d137451a377291a4 Mon Sep 17 00:00:00 2001 From: Vaclav Vancura Date: Fri, 8 May 2026 11:24:41 +0200 Subject: [PATCH] fix(api): extend beginner trap detection and polish palette messages Add a Palette instanceof guard to BT.paletteSet so undefined/null inputs show a friendly canvas error instead of crashing on the next draw call. Rewrite five Palette error strings to plain language (slot-0 transparency rule, invalid size, invalid JSON shape, unknown color name). Rewrite the three Vector2i.div zero-divisor messages with the same pattern. Tests updated to cover the new guard and the rewritten message text. Co-Authored-By: Claude Sonnet 4.6 Signed-off-by: Vaclav Vancura --- src/BlitTech.test.ts | 18 ++++++++++++++++++ src/BlitTech.ts | 8 ++++++++ src/assets/Palette.test.ts | 24 +++++++++++++++++------- src/assets/Palette.ts | 10 ++++++---- src/utils/Vector2i.test.ts | 10 +++++++--- src/utils/Vector2i.ts | 6 +++--- 6 files changed, 59 insertions(+), 17 deletions(-) diff --git a/src/BlitTech.test.ts b/src/BlitTech.test.ts index 5438a62..941b3b1 100644 --- a/src/BlitTech.test.ts +++ b/src/BlitTech.test.ts @@ -282,6 +282,24 @@ describe('BT.paletteSet', () => { expect(spy).toHaveBeenCalledWith(palette); }); + + it('shows a beginner-friendly error when passed undefined', async () => { + await withErrorContainer(async () => { + BT.paletteSet(undefined as never); + + const text = document.getElementById(DEFAULT_CONTAINER_ID)?.textContent ?? ''; + expect(text).toContain('BT.paletteSet expects a Palette'); + }); + }); + + it('shows a beginner-friendly error when passed null', async () => { + await withErrorContainer(async () => { + BT.paletteSet(null as never); + + const text = document.getElementById(DEFAULT_CONTAINER_ID)?.textContent ?? ''; + expect(text).toContain('BT.paletteSet expects a Palette'); + }); + }); }); describe('BT.paletteGet', () => { diff --git a/src/BlitTech.ts b/src/BlitTech.ts index 70a59cc..9410654 100644 --- a/src/BlitTech.ts +++ b/src/BlitTech.ts @@ -496,6 +496,14 @@ export const BT = { * @param palette - Palette to make active. */ paletteSet: (palette: Palette): void => { + if (!(palette instanceof Palette)) { + showBeginnerRuntimeError( + 'BT.paletteSet expects a Palette. Did you forget to create one with BT.paletteCreate() or a preset like Palette.vga()?', + 'Palette Error', + ); + return; + } + BTAPI.instance.setPalette(palette); }, diff --git a/src/assets/Palette.test.ts b/src/assets/Palette.test.ts index e9b9507..dc20a19 100644 --- a/src/assets/Palette.test.ts +++ b/src/assets/Palette.test.ts @@ -25,7 +25,7 @@ describe('Palette', () => { }); it('rejects invalid palette sizes', () => { - expect(() => new Palette(3)).toThrow('Invalid palette size: 3. Must be 2, 4, 16, 32, 64, 128, or 256'); + expect(() => new Palette(3)).toThrow('A palette can hold 2, 4, 16, 32, 64, 128, or 256 colors. Got 3.'); }); /** Confirms that palette entry zero remains the reserved transparent slot. */ @@ -33,7 +33,9 @@ describe('Palette', () => { const palette = new Palette(16); expect(palette.get(0).equals(Color32.transparent())).toBe(true); - expect(() => palette.set(0, Color32.red())).toThrow('Palette index 0 is reserved for transparency'); + expect(() => palette.set(0, Color32.red())).toThrow( + 'Slot 0 is always see-through (transparent). Put solid colors in slot 1 or higher.', + ); palette.set(0, new Color32(12, 34, 56, 0)); @@ -79,7 +81,9 @@ describe('Palette', () => { expect(palette.getNamed('uiAccent')).toBe(3); expect(palette.getNamedColor('uiAccent').equals(color)).toBe(true); - expect(() => palette.getNamed('missing')).toThrow("Unknown palette color name: 'missing'"); + expect(() => palette.getNamed('missing')).toThrow( + "There's no color named 'missing' in this palette. Did you call palette.setNamed('missing', someIndex) first?", + ); }); it('finds exact matching colors and returns -1 when absent', () => { @@ -138,8 +142,12 @@ describe('Palette', () => { }); it('rejects invalid JSON payloads', () => { - expect(() => Palette.fromJSON({ colors: ['#000000ff'] })).toThrow('Invalid palette JSON'); - expect(() => Palette.fromJSON({ size: 16 })).toThrow('Invalid palette JSON'); + expect(() => Palette.fromJSON({ colors: ['#000000ff'] })).toThrow( + "This doesn't look like a valid palette file. It needs 'colors' and 'size' fields.", + ); + expect(() => Palette.fromJSON({ size: 16 })).toThrow( + "This doesn't look like a valid palette file. It needs 'colors' and 'size' fields.", + ); expect(() => Palette.fromJSON({ colors: ['#00000000'], size: 16 })).toThrow( 'Palette JSON color count 1 does not match size 16', ); @@ -179,7 +187,7 @@ describe('Palette', () => { 'Palette byte array length 48 does not match palette size 32', ); expect(() => Palette.fromUint8Array(new Uint8Array(9))).toThrow( - 'Invalid palette size: 3. Must be 2, 4, 16, 32, 64, 128, or 256', + 'A palette can hold 2, 4, 16, 32, 64, 128, or 256 colors. Got 3.', ); }); @@ -202,7 +210,9 @@ describe('Palette', () => { expect(target.getNamed('last')).toBe(15); expect(smallTarget.get(1).equals(new Color32(10, 20, 30, 255))).toBe(true); - expect(() => smallTarget.getNamed('last')).toThrow("Unknown palette color name: 'last'"); + expect(() => smallTarget.getNamed('last')).toThrow( + "There's no color named 'last' in this palette. Did you call palette.setNamed('last', someIndex) first?", + ); }); it('always produces a 256-entry float buffer for GPU upload', () => { diff --git a/src/assets/Palette.ts b/src/assets/Palette.ts index f03f113..0d7540b 100644 --- a/src/assets/Palette.ts +++ b/src/assets/Palette.ts @@ -48,7 +48,7 @@ function isValidPaletteSize(size: number): boolean { */ function validatePaletteSize(size: number): void { if (!isValidPaletteSize(size)) { - throw new Error(`Invalid palette size: ${size}. Must be 2, 4, 16, 32, 64, 128, or 256.`); + throw new Error(`A palette can hold 2, 4, 16, 32, 64, 128, or 256 colors. Got ${size}.`); } } @@ -177,7 +177,7 @@ export class Palette { const { colors, names, size } = json; if (!Array.isArray(colors) || typeof size !== 'number') { - throw new Error('Invalid palette JSON'); + throw new Error("This doesn't look like a valid palette file. It needs 'colors' and 'size' fields."); } const palette = new Palette(size); @@ -307,7 +307,7 @@ export class Palette { if (index === 0) { if (color.a !== 0) { - throw new Error('Palette index 0 is reserved for transparency'); + throw new Error('Slot 0 is always see-through (transparent). Put solid colors in slot 1 or higher.'); } this.colors[0] = Color32.transparent(); @@ -381,7 +381,9 @@ export class Palette { const index = this.namedIndices.get(name); if (index === undefined) { - throw new Error(`Unknown palette color name: '${name}'`); + throw new Error( + `There's no color named '${name}' in this palette. Did you call palette.setNamed('${name}', someIndex) first?`, + ); } return index; diff --git a/src/utils/Vector2i.test.ts b/src/utils/Vector2i.test.ts index 857dfad..4193729 100644 --- a/src/utils/Vector2i.test.ts +++ b/src/utils/Vector2i.test.ts @@ -389,7 +389,7 @@ describe('Vector2i', () => { it('should throw on zero divisor', () => { const v = new Vector2i(10, 20); - expect(() => v.div(0)).toThrow('Vector2i.div: scalar must not be zero'); + expect(() => v.div(0)).toThrow("Can't divide by zero. Use a non-zero number for Vector2i.div."); }); it('should handle division by negative scalar', () => { @@ -770,7 +770,9 @@ describe('Vector2i', () => { const v = new Vector2i(10, 20); const out = new Vector2i(); - expect(() => v.divTo(0, out)).toThrow('Vector2i.divTo: scalar must not be zero'); + expect(() => v.divTo(0, out)).toThrow( + "Can't divide by zero. Use a non-zero number for Vector2i.divTo.", + ); }); it('should return the output vector', () => { @@ -1075,7 +1077,9 @@ describe('Vector2i', () => { it('should throw on zero divisor', () => { const v = new Vector2i(10, 20); - expect(() => v.divInPlace(0)).toThrow('Vector2i.divInPlace: scalar must not be zero'); + expect(() => v.divInPlace(0)).toThrow( + "Can't divide by zero. Use a non-zero number for Vector2i.divInPlace.", + ); }); it('should return this for chaining', () => { diff --git a/src/utils/Vector2i.ts b/src/utils/Vector2i.ts index 67974e9..41bc8f3 100644 --- a/src/utils/Vector2i.ts +++ b/src/utils/Vector2i.ts @@ -338,7 +338,7 @@ export class Vector2i { */ div(scalar: number): Vector2i { if (scalar === 0) { - throw new Error('Vector2i.div: scalar must not be zero'); + throw new Error("Can't divide by zero. Use a non-zero number for Vector2i.div."); } return new Vector2i((this.x / scalar) | 0, (this.y / scalar) | 0); @@ -510,7 +510,7 @@ export class Vector2i { */ divTo(scalar: number, out: Vector2i): Vector2i { if (scalar === 0) { - throw new Error('Vector2i.divTo: scalar must not be zero'); + throw new Error("Can't divide by zero. Use a non-zero number for Vector2i.divTo."); } out.x = (this.x / scalar) | 0; @@ -724,7 +724,7 @@ export class Vector2i { */ divInPlace(scalar: number): this { if (scalar === 0) { - throw new Error('Vector2i.divInPlace: scalar must not be zero'); + throw new Error("Can't divide by zero. Use a non-zero number for Vector2i.divInPlace."); } this.x = (this.x / scalar) | 0; -- 2.51.2