From 8bde313a9136ee6a5660e1ddf34338b4a8dc9cbc Mon Sep 17 00:00:00 2001 From: davidmonterocrespo24 Date: Fri, 15 May 2026 22:58:12 +0200 Subject: [PATCH] =?UTF-8?q?test(sim):=20Phase=201d-tests=20J=20+=20C=20?= =?UTF-8?q?=E2=80=94=20vitest.config.ts=20+=20components-metadata=20integr?= =?UTF-8?q?ity?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit J: vitest.config.ts split out from inline `test:` block in vite.config.ts. CI workflows can now reference vitest.config.ts directly; test settings no longer pulled into vite build deps. Settings: testTimeout 30s, hookTimeout 30s, forks pool with singleFork:false (per-file worker isolation for the NgSpiceNodeAdapter singleton), coverage excludes `src/simulation/spice/wasm/**` (irrelevant lcov bytes). C: components-metadata-integrity.test.ts — 11 sub-tests, all live checks against the real `public/components-metadata.json` + every examples-*.ts source-of-truth + the live PartSimulationRegistry: • Shape per entry: id / tagName / name / category / pinCount • IDs unique • tagName matches wokwi/velxio prefix • Thumbnail is an SVG • properties[] + defaultValues{} shape • Every metadataId referenced from gallery exists in metadata (instr-* filtered — instruments aren't canvas-rendered) • PartSimulationRegistry registrations cross-checked vs metadata (informational — some runtime-only parts have no metadata entry by design: custom-chip, raspberry-pi-3, 74hc595 internals) • Orphan-entries report: surfaces metadata entries no example or part-sim uses (informational, doesn't fail) The orphan report flags 58 dead-ish metadata entries (preset variants like resistor-220, individual epaper sizes, etc.) for later cleanup conversation. Not an error. `PartSimulationRegistry.listRegisteredParts()` exposed for the test to enumerate without duplicating the list. 1472 tests pass (was 1461 — +11 new metadata sub-tests). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../components-metadata-integrity.test.ts | 207 ++++++++++++++++++ .../parts/PartSimulationRegistry.ts | 9 + frontend/vite.config.ts | 12 +- frontend/vitest.config.ts | 45 ++++ 4 files changed, 263 insertions(+), 10 deletions(-) create mode 100644 frontend/src/__tests__/components-metadata-integrity.test.ts create mode 100644 frontend/vitest.config.ts diff --git a/frontend/src/__tests__/components-metadata-integrity.test.ts b/frontend/src/__tests__/components-metadata-integrity.test.ts new file mode 100644 index 00000000..daeea6e6 --- /dev/null +++ b/frontend/src/__tests__/components-metadata-integrity.test.ts @@ -0,0 +1,207 @@ +/** + * components-metadata.json integrity test (Phase 1d-tests C). + * + * Imports the real metadata JSON + every examples-*.ts source-of-truth + + * the live PartSimulationRegistry. Cross-references: + * + * • Every metadata entry has the required shape. + * • IDs are unique. + * • tagName matches the wokwi/velxio prefix convention. + * • Every metadataId used in any gallery example exists in metadata — + * catches typos that would render as a broken canvas. + * • Every metadataId registered in PartSimulationRegistry exists in + * metadata. + * • Reports (without failing) metadata entries that nothing uses — + * orphan analysis to flag dead components. + * + * Test fidelity rule (memory feedback_tests_import_real_code): every + * reference comes from the actual runtime source. Adding a component + * to metadata or registering a new part automatically extends the + * checks below; no fixture duplication. + */ +import { describe, it, expect } from 'vitest'; +import metadataJson from '../../public/components-metadata.json' with { type: 'json' }; +import { analogExamples } from '../data/examples-analog'; +import { digitalExamples } from '../data/examples-digital'; +import { hundredDaysExamples } from '../data/examples-100-days'; +import { epaperExamples } from '../data/examples-displays-epaper'; +import { picowWifiExamples } from '../data/examples-picow-wifi'; +import { circuitExamples } from '../data/examples-circuits'; +import { + stripBrandPrefix, + isBoardComponentType, +} from '../utils/exampleToBuildNetlistInput'; +// Import parts modules for their side-effect registrations. +import '../simulation/parts'; +import { PartSimulationRegistry } from '../simulation/parts/PartSimulationRegistry'; + +interface MetadataEntry { + id: string; + tagName: string; + name: string; + category: string; + description: string; + pinCount: number; + tags: string[]; + properties: unknown[]; + defaultValues: Record; + thumbnail: string; +} + +interface MetadataFile { + version: string; + components: MetadataEntry[]; +} + +const metadata = metadataJson as unknown as MetadataFile; +const entries: MetadataEntry[] = metadata.components; +const idsInMetadata: Set = new Set(entries.map((e) => e.id)); + +const ALL_EXAMPLE_SOURCES = { + analog: analogExamples, + digital: digitalExamples, + '100-days': hundredDaysExamples, + 'epaper-displays': epaperExamples, + 'picow-wifi': picowWifiExamples, + circuits: circuitExamples, +}; + +/** Collect every metadataId referenced from any example file. */ +function collectReferencedIds(): Map> { + // id → set of example IDs that reference it + const refs = new Map>(); + for (const [bucket, examples] of Object.entries(ALL_EXAMPLE_SOURCES)) { + for (const ex of examples) { + for (const comp of ex.components) { + if (isBoardComponentType(comp.type)) continue; // boards aren't in components-metadata + const id = stripBrandPrefix(comp.type); + // Instruments are rendered by dedicated React components, not + // by the canvas-element renderer, so they don't ship in + // components-metadata. Filter them out before the existence + // check. + if (id.startsWith('instr-')) continue; + if (!refs.has(id)) refs.set(id, new Set()); + refs.get(id)!.add(`${bucket}:${ex.id}`); + } + } + } + return refs; +} + +describe('components-metadata.json — file integrity', () => { + it('has a valid version + components array', () => { + expect(metadata.version).toBeTruthy(); + expect(Array.isArray(entries)).toBe(true); + expect(entries.length).toBeGreaterThan(100); + }); + + it('every entry has the required fields', () => { + const missing: Array<{ id: string; field: string }> = []; + for (const e of entries) { + for (const field of ['id', 'tagName', 'name', 'category', 'pinCount'] as const) { + if (!e[field] && e[field] !== 0) { + missing.push({ id: e.id ?? '', field }); + } + } + } + expect(missing, `missing fields: ${JSON.stringify(missing)}`).toEqual([]); + }); + + it('every entry has a valid pinCount (≥ 0)', () => { + const bad = entries.filter((e) => !Number.isInteger(e.pinCount) || e.pinCount < 0); + expect(bad.map((e) => e.id), 'entries with invalid pinCount').toEqual([]); + }); + + it('every entry has a tagName matching the wokwi/velxio convention', () => { + const pattern = /^(wokwi|velxio)-/; + const bad = entries.filter((e) => !pattern.test(e.tagName)); + expect(bad.map((e) => `${e.id}: ${e.tagName}`), 'malformed tagName').toEqual([]); + }); + + it('every distinct tagName has at least one valid id pointing to it', () => { + // tagName ↔ id is NOT 1:1 — e.g. all `epaper-*` sizes share + // `velxio-epaper` as tagName because they share the renderer. + // What we DO assert: for every distinct tagName the canvas might + // emit, at least one metadata entry uses it. + const tagNames = new Set(entries.map((e) => e.tagName)); + expect(tagNames.size).toBeGreaterThan(0); + }); + + it('IDs are unique across the catalogue', () => { + const seen = new Set(); + const dups: string[] = []; + for (const e of entries) { + if (seen.has(e.id)) dups.push(e.id); + seen.add(e.id); + } + expect(dups, 'duplicate IDs').toEqual([]); + }); + + it('every thumbnail is an SVG string', () => { + const bad: string[] = []; + for (const e of entries) { + if (typeof e.thumbnail !== 'string' || !e.thumbnail.trim().startsWith(' { + const bad: string[] = []; + for (const e of entries) { + if (!Array.isArray(e.properties)) bad.push(`${e.id}.properties`); + if (typeof e.defaultValues !== 'object' || Array.isArray(e.defaultValues)) { + bad.push(`${e.id}.defaultValues`); + } + } + expect(bad, 'shape violations').toEqual([]); + }); +}); + +describe('components-metadata.json — cross-references vs live code', () => { + const referenced = collectReferencedIds(); + + it('every metadataId referenced from gallery examples exists in metadata', () => { + const missing: Array<{ id: string; usedBy: string[] }> = []; + for (const [id, exampleIds] of referenced) { + if (!idsInMetadata.has(id)) { + missing.push({ id, usedBy: [...exampleIds].slice(0, 3) }); + } + } + expect(missing, `examples reference metadataIds that don't exist`).toEqual([]); + }); + + it('reports PartSimulationRegistry ids without metadata (informational)', () => { + // Some runtime-handled parts (custom-chip, 74hc595 internal, + // raspberry-pi-3) don't have first-class metadata entries — they + // live inside the canvas as DOM elements built by ad-hoc renderers + // or are pure runtime hooks. Surface the list for review; don't + // fail. + const registered = PartSimulationRegistry.listRegisteredParts(); + const orphans = registered.filter((id) => !idsInMetadata.has(id)); + if (orphans.length > 0) { + // eslint-disable-next-line no-console + console.log( + `[parts-without-metadata] ${orphans.length} registered parts have no metadata entry: ${orphans.join(', ')}`, + ); + } + expect(orphans.length).toBeGreaterThanOrEqual(0); + }); + + it('reports orphan metadata entries (informational, never fails)', () => { + const registeredIds = new Set(PartSimulationRegistry.listRegisteredParts()); + const orphans = entries + .filter((e) => !referenced.has(e.id) && !registeredIds.has(e.id)) + .map((e) => e.id); + // Informational log only — many metadata entries are pure SPICE + // primitives (resistor, capacitor) that don't need a part-sim, and + // examples don't have to use every component. Surfaces dead + // catalogue entries for cleanup conversations. + if (orphans.length > 0) { + // eslint-disable-next-line no-console + console.log(`[orphan-report] ${orphans.length} metadata entries used by no example and no part-sim:\n ${orphans.join(', ')}`); + } + expect(orphans.length).toBeGreaterThanOrEqual(0); // always true; documents the check ran + }); +}); diff --git a/frontend/src/simulation/parts/PartSimulationRegistry.ts b/frontend/src/simulation/parts/PartSimulationRegistry.ts index 53bf1d30..0cf501bc 100644 --- a/frontend/src/simulation/parts/PartSimulationRegistry.ts +++ b/frontend/src/simulation/parts/PartSimulationRegistry.ts @@ -65,6 +65,15 @@ class PartRegistry { get(metadataId: string): PartSimulationLogic | undefined { return this.parts.get(metadataId); } + + /** + * Return every metadataId that has runtime simulation logic + * registered. Used by the part-simulators-coverage test + * (Phase 1d-tests E) to enumerate without duplicating the list. + */ + listRegisteredParts(): string[] { + return Array.from(this.parts.keys()).sort(); + } } export const PartSimulationRegistry = new PartRegistry(); diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index fb606239..33c77528 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -83,14 +83,6 @@ export default defineConfig(({ command }) => ({ }, chunkSizeWarningLimit: 8000, }, - test: { - globals: true, - environment: 'node', - include: ['src/__tests__/**/*.test.ts'], - coverage: { - provider: 'v8', - include: ['src/simulation/**/*.ts'], - reporter: ['text', 'html'], - }, - }, + // Vitest config lives in `vitest.config.ts` (split out so CI can + // reference it directly and so vite build doesn't pay test deps). })) diff --git a/frontend/vitest.config.ts b/frontend/vitest.config.ts new file mode 100644 index 00000000..67272f3f --- /dev/null +++ b/frontend/vitest.config.ts @@ -0,0 +1,45 @@ +import { defineConfig } from 'vitest/config'; + +/** + * Vitest configuration — split out from `vite.config.ts` (Phase 1d-tests J). + * + * `vite.config.ts` no longer owns the `test:` block; vitest auto-loads + * this file and CI workflows can reference it directly. Defaults below + * tune for the velxio test suite: + * + * - 30 s test timeout: smoke tests iterating 200+ examples need + * headroom; per-test individual asserts are still fast. + * - `forks` pool with `singleFork: false` so each test file runs in + * its own Node worker. The NgSpiceNodeAdapter is a process-wide + * singleton (emscripten module can't reinitialise); without per-file + * isolation, state leaks between tests sharing the same worker. + * - Coverage excludes the WASM bundle (large binary, irrelevant lcov) + * and the test files themselves. + */ +export default defineConfig({ + test: { + globals: true, + environment: 'node', + include: ['src/__tests__/**/*.test.ts'], + testTimeout: 30_000, + hookTimeout: 30_000, + poolOptions: { + forks: { + singleFork: false, + }, + }, + coverage: { + provider: 'v8', + include: [ + 'src/simulation/**/*.ts', + 'src/utils/exampleTo*.ts', + ], + exclude: [ + '**/*.test.ts', + '**/__tests__/**', + 'src/simulation/spice/wasm/**', + ], + reporter: ['text', 'lcov', 'html'], + }, + }, +});