206 lines
8.0 KiB
TypeScript
206 lines
8.0 KiB
TypeScript
|
|
/**
|
||
|
|
* 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 { 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,
|
||
|
|
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
|
||
|
|
});
|
||
|
|
});
|