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'], + }, + }, +});