test(sim): Phase 1d-tests J + C — vitest.config.ts + components-metadata integrity

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) <noreply@anthropic.com>
This commit is contained in:
davidmonterocrespo24 2026-05-15 22:58:12 +02:00
parent 37f35488c7
commit 8bde313a91
4 changed files with 263 additions and 10 deletions

View File

@ -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<string, unknown>;
thumbnail: string;
}
interface MetadataFile {
version: string;
components: MetadataEntry[];
}
const metadata = metadataJson as unknown as MetadataFile;
const entries: MetadataEntry[] = metadata.components;
const idsInMetadata: Set<string> = 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<string, Set<string>> {
// id → set of example IDs that reference it
const refs = new Map<string, Set<string>>();
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 ?? '<no-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<string>();
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('<svg')) {
bad.push(e.id);
}
}
expect(bad, 'entries with missing or non-SVG thumbnail').toEqual([]);
});
it('properties is always an array; defaultValues always an object', () => {
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
});
});

View File

@ -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();

View File

@ -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).
}))

45
frontend/vitest.config.ts Normal file
View File

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