velxio/frontend/src/__tests__/components-metadata-integri...

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
});
});