diff --git a/frontend/src/__tests__/examples-led-resistor.test.ts b/frontend/src/__tests__/examples-led-resistor.test.ts new file mode 100644 index 00000000..da67b1f5 --- /dev/null +++ b/frontend/src/__tests__/examples-led-resistor.test.ts @@ -0,0 +1,93 @@ +/** + * Every gallery LED must have a resistor in its path. + * + * The electrical model is honest: a LED straight across a 3.3 V or 5 V pin + * asks for an absurd current, the runtime burns it out and it stays dark + * for the rest of the run — while the sketch happily prints "LED ON". That + * is the worst kind of broken example, because nothing looks like an + * error. Twenty-four of them shipped that way (mostly Pico, and every RGB + * example, which needs one resistor PER CHANNEL) before this guard existed. + * + * The check walks the wiring graph rather than the LED's immediate + * neighbour: a resistor protects from the anode side, from the cathode + * side, and from the far side of a transistor or relay contact. + */ +import { describe, it, expect } from 'vitest'; +import { exampleProjects } from '../data/examples'; + +const LED_TYPES = new Set(['wokwi-led', 'wokwi-rgb-led']); +/** Anything that limits current well enough to save the LED. */ +const LIMITER_TYPES = [ + 'wokwi-resistor', + 'wokwi-resistor-us', + 'wokwi-potentiometer', + 'wokwi-slide-potentiometer', + 'wokwi-ntc-temperature-sensor', + 'wokwi-photoresistor-sensor', +]; + +interface Comp { + type?: string; + id?: string; +} +interface Wire { + start?: { componentId?: string }; + end?: { componentId?: string }; +} + +function isLimiter(type: string | undefined): boolean { + return !!type && LIMITER_TYPES.some((t) => type.startsWith(t)); +} + +/** LEDs with no current limiter anywhere on their side of the circuit. */ +function unprotectedLeds(example: { + components?: Comp[]; + wires?: Wire[]; +}): string[] { + const comps = new Map(); + for (const c of example.components ?? []) { + if (c?.id) comps.set(c.id, c.type ?? ''); + } + const leds = [...comps.entries()] + .filter(([, type]) => LED_TYPES.has(type)) + .map(([id]) => id); + if (!leds.length) return []; + + const adj = new Map(); + for (const w of example.wires ?? []) { + const a = w?.start?.componentId; + const b = w?.end?.componentId; + if (!a || !b) continue; + adj.set(a, [...(adj.get(a) ?? []), b]); + adj.set(b, [...(adj.get(b) ?? []), a]); + } + + return leds.filter((led) => { + const seen = new Set([led]); + const queue = [...(adj.get(led) ?? [])]; + while (queue.length) { + const node = queue.shift()!; + if (seen.has(node)) continue; + seen.add(node); + const type = comps.get(node); + if (isLimiter(type)) return false; // protected + // An unknown id is the board itself (a supply rail) and another LED + // is not protection either: neither continues the search. + if (type === undefined || LED_TYPES.has(type)) continue; + queue.push(...(adj.get(node) ?? [])); + } + return true; + }); +} + +describe('gallery examples — LED current limiting', () => { + it('no example drives a LED straight off a pin', () => { + const offenders: string[] = []; + for (const ex of exampleProjects) { + for (const led of unprotectedLeds(ex as never)) { + offenders.push(`${ex.id}: ${led}`); + } + } + expect(offenders, 'LEDs without a series resistor').toEqual([]); + }); +});