From 979a95fef9a6d96394cee550c72357f2061efb52 Mon Sep 17 00:00:00 2001 From: David Montero Crespo Date: Mon, 17 Aug 2026 03:47:39 +0200 Subject: [PATCH] test(examples): audit every shipped component value against physical ranges MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../example-component-values.test.ts | 116 ++++++++++++++++++ 1 file changed, 116 insertions(+) create mode 100644 frontend/src/__tests__/example-component-values.test.ts diff --git a/frontend/src/__tests__/example-component-values.test.ts b/frontend/src/__tests__/example-component-values.test.ts new file mode 100644 index 00000000..0504b253 --- /dev/null +++ b/frontend/src/__tests__/example-component-values.test.ts @@ -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 }).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 }).properties ?? {}; + for (const rule of RANGES) { + if (rule.match(metaId) && rule.prop in props) checked++; + } + } + } + expect(checked).toBeGreaterThan(50); + }); +});