test(examples): audit every shipped component value against physical ranges

The night-light LDR shipped a 0.001 ohm dark resistance for months and
nothing complained: the netlist built, ngspice converged, the part
rendered, and the example just quietly read 4095 forever. A wrong value
is invisible, so check them all.

Parses every resistor / pot / photoresistor / capacitor / inductor value
in the gallery with the real parser and asserts it lands in a physically
sane range. The failure message names both SPICE suffix traps, because
they are the ones that bite: M is milli (mega is "Meg"), and F is femto
and is deliberately not stripped, so "10uF" would parse as 10 fF rather
than the 10 uF intended.

Verified the check actually bites: putting "1M" back makes it fail with
`dark = "1M" -> 0.001 — LDR dark resistance (ohm) out of range`. A second
case asserts the audit visits a meaningful number of components, so the
test can't silently degrade into a no-op.

Swept the rest of the catalogue while here (all example files, the pro
overlay, and the 49 100-days diagrams): no other value hits either trap.
This commit is contained in:
David Montero Crespo 2026-08-17 03:47:39 +02:00
parent 9b0b4bc038
commit 979a95fef9
1 changed files with 116 additions and 0 deletions

View File

@ -0,0 +1,116 @@
/**
* Audit every component value shipped in the gallery examples.
*
* Component values go through `parseValueWithUnits`, which follows the SPICE
* suffix convention. Two of its rules routinely trip up whoever authors an
* example, because both produce a *silently* wrong circuit rather than an
* error:
*
* - `M` is MILLI, not mega. `"1M"` is 0.001, and mega is `"1Meg"`.
* 100d-auto-night-light shipped `dark: "1M"` for a photoresistor: the
* 0.001 ohm "dark resistance" shorted the ADC node to VCC, so the sketch
* read 4095 forever and its lux control did nothing.
* - `F` is FEMTO, and is deliberately NOT stripped as a unit letter, so
* `"10uF"` parses as `10 * 1e-15` — nine orders of magnitude off the
* 10 microfarads the author meant. Write `"10u"`.
*
* A wrong value here is invisible: the netlist builds, ngspice converges, the
* part renders, and the example just quietly misbehaves. So assert the parsed
* numbers land inside physically sane ranges for their component class.
*/
import { describe, it, expect } from 'vitest';
import { exampleProjects } from '../data/examples';
import { parseValueWithUnits } from '../simulation/spice/valueParser';
/** Physically plausible ranges, per (metadataId prefix, property). */
const RANGES: Array<{
match: (metaId: string) => boolean;
prop: string;
min: number;
max: number;
what: string;
}> = [
// A resistor below an ohm is a wire; above 100 M it's an open.
{ match: (m) => m === 'resistor' || m.startsWith('resistor-'), prop: 'value', min: 1, max: 1e8, what: 'resistance (ohm)' },
{ match: (m) => m === 'potentiometer', prop: 'value', min: 10, max: 1e7, what: 'pot resistance (ohm)' },
// LDR dark resistance: real parts sit between ~10k and ~20M.
{ match: (m) => m === 'photoresistor' || m === 'photoresistor-sensor', prop: 'dark', min: 1e3, max: 1e8, what: 'LDR dark resistance (ohm)' },
{ match: (m) => m === 'photoresistor' || m === 'photoresistor-sensor', prop: 'pullup', min: 100, max: 1e7, what: 'LDR pull-down (ohm)' },
// Capacitors: 1 pF .. 1 F covers everything from RF trimmers to supercaps.
{ match: (m) => m === 'capacitor' || m === 'capacitor-electrolytic' || m.startsWith('capacitor-'), prop: 'value', min: 1e-12, max: 1, what: 'capacitance (F)' },
// Inductors: 1 nH .. 10 H.
{ match: (m) => m === 'inductor' || m.startsWith('inductor-'), prop: 'value', min: 1e-9, max: 10, what: 'inductance (H)' },
];
interface Offender {
example: string;
componentId: string;
metadataId: string;
prop: string;
raw: unknown;
parsed: number;
what: string;
}
function audit(): Offender[] {
const out: Offender[] = [];
for (const ex of exampleProjects) {
for (const comp of ex.components ?? []) {
// Example components carry the branded tag (`wokwi-resistor`); the
// mapper sees the stripped metadata id. Compare on both.
const raw = String((comp as { type?: string }).type ?? '');
const metaId = raw.replace(/^(wokwi|velxio|chip)-/, '');
const props = (comp as { properties?: Record<string, unknown> }).properties ?? {};
for (const rule of RANGES) {
if (!rule.match(metaId)) continue;
if (!(rule.prop in props)) continue;
const rawVal = props[rule.prop];
const parsed = parseValueWithUnits(rawVal, NaN);
if (!Number.isFinite(parsed) || parsed < rule.min || parsed > rule.max) {
out.push({
example: ex.id,
componentId: String((comp as { id?: string }).id ?? '?'),
metadataId: metaId,
prop: rule.prop,
raw: rawVal,
parsed,
what: rule.what,
});
}
}
}
}
return out;
}
describe('gallery example component values', () => {
it('parse into physically sane ranges', () => {
const offenders = audit();
const report = offenders
.map(
(o) =>
`${o.example} / ${o.componentId} (${o.metadataId}).${o.prop} = ` +
`${JSON.stringify(o.raw)} -> ${o.parsed} — ${o.what} out of range. ` +
`Remember: SPICE M = milli (use "Meg"), F = femto (drop the F).`,
)
.join('\n');
expect(report).toBe('');
});
it('covers a meaningful number of components (guard against a silent no-op)', () => {
let checked = 0;
for (const ex of exampleProjects) {
for (const comp of ex.components ?? []) {
const metaId = String((comp as { type?: string }).type ?? '').replace(
/^(wokwi|velxio|chip)-/,
'',
);
const props = (comp as { properties?: Record<string, unknown> }).properties ?? {};
for (const rule of RANGES) {
if (rule.match(metaId) && rule.prop in props) checked++;
}
}
}
expect(checked).toBeGreaterThan(50);
});
});