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:
parent
37f35488c7
commit
8bde313a91
|
|
@ -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
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
}))
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
},
|
||||
},
|
||||
});
|
||||
Loading…
Reference in New Issue