From 799998fad2a80dd96e02422659aceb6c801877ce Mon Sep 17 00:00:00 2001 From: sjat Date: Mon, 8 Jun 2026 22:28:07 +0200 Subject: [PATCH] feat: config loader with calibration validation Co-Authored-By: Claude Opus 4.8 (1M context) --- src/config.test.ts | 43 +++++++++++++++++++++++++++++++++++++++++++ src/config.ts | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 75 insertions(+) create mode 100644 src/config.test.ts create mode 100644 src/config.ts diff --git a/src/config.test.ts b/src/config.test.ts new file mode 100644 index 0000000..5399e80 --- /dev/null +++ b/src/config.test.ts @@ -0,0 +1,43 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { loadConfig, DEFAULT_RENDER } from './config'; + +function mockFetch(body: unknown, ok = true) { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ + ok, + json: () => Promise.resolve(body), + })); +} + +afterEach(() => vi.unstubAllGlobals()); + +describe('loadConfig', () => { + it('loads stream URL and fills render defaults', async () => { + mockFetch({ streamUrl: 'http://cam/stream' }); + const cfg = await loadConfig('config.json'); + expect(cfg.streamUrl).toBe('http://cam/stream'); + expect(cfg.renderDefaults).toEqual(DEFAULT_RENDER); + expect(cfg.calibration).toBeNull(); + }); + + it('keeps a valid calibration', 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, 1], + }; + mockFetch({ streamUrl: 'x', calibration }); + const cfg = await loadConfig('config.json'); + expect(cfg.calibration).toEqual(calibration); + }); + + it('drops a malformed calibration to null', async () => { + mockFetch({ streamUrl: 'x', calibration: { homography: [1, 2, 3] } }); + const cfg = await loadConfig('config.json'); + expect(cfg.calibration).toBeNull(); + }); + + it('throws when the request fails', async () => { + mockFetch({}, false); + await expect(loadConfig('config.json')).rejects.toThrow(); + }); +}); diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..7a34a82 --- /dev/null +++ b/src/config.ts @@ -0,0 +1,32 @@ +import type { AppConfig, Calibration, Mat3, Vec2 } from './types'; + +export const DEFAULT_RENDER = { cutColor: '#00e5ff', rapidColor: '#ff9800', lineWidth: 1.5 }; + +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')); +} + +function parseCalibration(c: unknown): Calibration | null { + if (!c || typeof c !== 'object') return 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.imagePoints.length !== obj.machinePoints.length || obj.imagePoints.length < 4) return null; + return { + imagePoints: obj.imagePoints, + machinePoints: obj.machinePoints, + homography: obj.homography as Mat3, + }; +} + +export async function loadConfig(url: string): Promise { + const res = await fetch(url); + if (!res.ok) throw new Error(`Failed to load ${url}: ${res.status}`); + const raw = (await res.json()) as Record; + return { + streamUrl: typeof raw.streamUrl === 'string' ? raw.streamUrl : '', + calibration: parseCalibration(raw.calibration), + renderDefaults: { ...DEFAULT_RENDER, ...(raw.renderDefaults as object | undefined) }, + }; +}