diff --git a/src/config.test.ts b/src/config.test.ts index 5399e80..dc7a409 100644 --- a/src/config.test.ts +++ b/src/config.test.ts @@ -40,4 +40,24 @@ describe('loadConfig', () => { mockFetch({}, false); await expect(loadConfig('config.json')).rejects.toThrow(); }); + + it('ignores malformed renderDefaults fields, keeping defaults and dropping extras', async () => { + mockFetch({ streamUrl: 'x', renderDefaults: { lineWidth: 'thick', cutColor: '#abc', bogus: true } }); + const cfg = await loadConfig('config.json'); + expect(cfg.renderDefaults.cutColor).toBe('#abc'); // valid string kept + expect(cfg.renderDefaults.lineWidth).toBe(DEFAULT_RENDER.lineWidth); // invalid number → default + expect(cfg.renderDefaults.rapidColor).toBe(DEFAULT_RENDER.rapidColor); // missing → default + expect(cfg.renderDefaults).not.toHaveProperty('bogus'); // extra key dropped + }); + + it('rejects a calibration whose homography contains NaN', async () => { + const calibration = { + imagePoints: [[0, 0], [1, 0], [1, 1], [0, 1]], + machinePoints: [[0, 0], [1, 0], [1, 1], [0, 1]], + homography: [1, 0, 0, 0, 1, 0, 0, 0, NaN], + }; + mockFetch({ streamUrl: 'x', calibration }); + const cfg = await loadConfig('config.json'); + expect(cfg.calibration).toBeNull(); + }); }); diff --git a/src/config.ts b/src/config.ts index 7a34a82..d047a8a 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,9 +1,13 @@ -import type { AppConfig, Calibration, Mat3, Vec2 } from './types'; +import type { AppConfig, Calibration, Mat3, Vec2, RenderStyle } from './types'; -export const DEFAULT_RENDER = { cutColor: '#00e5ff', rapidColor: '#ff9800', lineWidth: 1.5 }; +export const DEFAULT_RENDER: RenderStyle = { cutColor: '#00e5ff', rapidColor: '#ff9800', lineWidth: 1.5 }; + +function isFiniteNumber(n: unknown): n is number { + return typeof n === 'number' && Number.isFinite(n); +} function isVec2Array(v: unknown): v is Vec2[] { - return Array.isArray(v) && v.every((p) => Array.isArray(p) && p.length === 2 && p.every((n) => typeof n === 'number')); + return Array.isArray(v) && v.every((p) => Array.isArray(p) && p.length === 2 && p.every(isFiniteNumber)); } function parseCalibration(c: unknown): Calibration | null { @@ -11,7 +15,7 @@ function parseCalibration(c: unknown): Calibration | null { const obj = c as Record; if (!isVec2Array(obj.imagePoints) || !isVec2Array(obj.machinePoints)) return null; if (!Array.isArray(obj.homography) || obj.homography.length !== 9) return null; - if (!obj.homography.every((n) => typeof n === 'number')) return null; + if (!obj.homography.every(isFiniteNumber)) return null; if (obj.imagePoints.length !== obj.machinePoints.length || obj.imagePoints.length < 4) return null; return { imagePoints: obj.imagePoints, @@ -20,6 +24,15 @@ function parseCalibration(c: unknown): Calibration | null { }; } +function parseRenderStyle(r: unknown): RenderStyle { + const obj = (r && typeof r === 'object' ? r : {}) as Record; + return { + cutColor: typeof obj.cutColor === 'string' ? obj.cutColor : DEFAULT_RENDER.cutColor, + rapidColor: typeof obj.rapidColor === 'string' ? obj.rapidColor : DEFAULT_RENDER.rapidColor, + lineWidth: isFiniteNumber(obj.lineWidth) ? obj.lineWidth : DEFAULT_RENDER.lineWidth, + }; +} + export async function loadConfig(url: string): Promise { const res = await fetch(url); if (!res.ok) throw new Error(`Failed to load ${url}: ${res.status}`); @@ -27,6 +40,6 @@ export async function loadConfig(url: string): Promise { return { streamUrl: typeof raw.streamUrl === 'string' ? raw.streamUrl : '', calibration: parseCalibration(raw.calibration), - renderDefaults: { ...DEFAULT_RENDER, ...(raw.renderDefaults as object | undefined) }, + renderDefaults: parseRenderStyle(raw.renderDefaults), }; }