velxio/frontend/src/__tests__/breadboard-snap.test.ts

369 lines
15 KiB
TypeScript

/**
* Parts-on-breadboard geometry: hole grids, nearest-hole lookup and the
* wire flag plumbing that keeps seating wires invisible.
*/
import { describe, it, expect } from 'vitest';
import {
breadboardHoles,
nearestHole,
solvePlacement,
SEAT_TOLERANCE,
} from '../utils/breadboardSnap';
import { BREADBOARD_PINS } from '../velxio-elements/breadboard-element';
import { BREADBOARD_MINI_PINS } from '../velxio-elements/breadboard-mini-element';
const WRAPPER_INSET = 6;
describe('breadboardHoles', () => {
it('exposes the full-size grid (830 holes) and the mini grid (170)', () => {
expect(breadboardHoles('breadboard')).toHaveLength(830);
expect(breadboardHoles('breadboard-mini')).toHaveLength(170);
expect(breadboardHoles('led')).toBeNull();
});
it('uses the wokwi 9.6 px pitch on both axes', () => {
const holes = BREADBOARD_PINS;
const c1a = holes.find((h) => h.name === '1t.a')!;
const c2a = holes.find((h) => h.name === '2t.a')!;
const c1b = holes.find((h) => h.name === '1t.b')!;
expect(c2a.x - c1a.x).toBeCloseTo(9.6, 5);
expect(c1b.y - c1a.y).toBeCloseTo(9.6, 5);
});
it('spans exactly 6 pitches from row b to row f (resistor trench bridge)', () => {
const b = BREADBOARD_PINS.find((h) => h.name === '10t.b')!;
const f = BREADBOARD_PINS.find((h) => h.name === '10b.f')!;
expect(f.y - b.y).toBeCloseTo(6 * 9.6, 5);
expect(f.x).toBeCloseTo(b.x, 5);
});
it('mini board has no rails', () => {
expect(BREADBOARD_MINI_PINS.some((h) => h.name.includes('p.'))).toBe(false);
});
});
describe('nearestHole', () => {
const bb = { id: 'bb1', metadataId: 'breadboard', x: 100, y: 200 };
const hole = BREADBOARD_PINS.find((h) => h.name === '5t.c')!;
const world = { x: 100 + WRAPPER_INSET + hole.x, y: 200 + WRAPPER_INSET + hole.y };
it('finds the exact hole under a world point (wrapper inset accounted)', () => {
const found = nearestHole(bb, world, SEAT_TOLERANCE);
expect(found?.name).toBe('5t.c');
expect(found?.dist).toBeCloseTo(0, 5);
});
it('absorbs the resistor 1.2 px pitch residual but rejects beyond tolerance', () => {
expect(nearestHole(bb, { x: world.x + 1.2, y: world.y }, SEAT_TOLERANCE)?.name).toBe('5t.c');
// Center of the e-f trench: 14.4 px from the nearest rows — no hole.
const rowE = BREADBOARD_PINS.find((h) => h.name === '5t.e')!;
const trench = { x: 100 + WRAPPER_INSET + rowE.x, y: 200 + WRAPPER_INSET + rowE.y + 14.4 };
expect(nearestHole(bb, trench, SEAT_TOLERANCE)).toBeNull();
});
it('tolerance stays below half the hole pitch (no ambiguous seating)', () => {
expect(SEAT_TOLERANCE).toBeLessThan(9.6 / 2);
});
it('refuses rotated breadboards', () => {
const rotated = { ...bb, properties: { rotation: 90 } };
expect(nearestHole(rotated, world, SEAT_TOLERANCE)).toBeNull();
});
});
describe('solvePlacement', () => {
const bb = { id: 'bb1', metadataId: 'breadboard', x: 100, y: 200 };
const ox = 100 + WRAPPER_INSET;
const oy = 200 + WRAPPER_INSET;
const hole = (name: string) => BREADBOARD_PINS.find((h) => h.name === name)!;
/** Pin offsets for a vertical resistor bridging the trench (rows b -> f). */
const resistorPins = () => {
const b = hole('10t.b');
const f = hole('10b.f');
return [
{ name: '1', dx: 0, dy: 0 },
{ name: '2', dx: f.x - b.x, dy: f.y - b.y },
];
};
/** Position that puts pin 1 exactly on `name`. */
const posFor = (name: string) => ({ x: ox + hole(name).x, y: oy + hole(name).y });
it('leaves an already-correct part exactly where it is', () => {
const want = posFor('10t.b');
const got = solvePlacement(resistorPins(), bb, new Set(), want.x, want.y)!;
expect(got.moved).toBeCloseTo(0, 5);
expect(got.holes.map((h) => h.holeName)).toEqual(['10t.b', '10b.f']);
});
it('pulls a part dropped slightly off back onto the holes', () => {
const want = posFor('10t.b');
const got = solvePlacement(resistorPins(), bb, new Set(), want.x + 3, want.y - 2)!;
expect(got.holes.map((h) => h.holeName)).toEqual(['10t.b', '10b.f']);
expect(got.x).toBeCloseTo(want.x, 5);
expect(got.y).toBeCloseTo(want.y, 5);
});
it('slides to the next free column when the target is occupied', () => {
const want = posFor('10t.b');
const got = solvePlacement(resistorPins(), bb, new Set(['10t.b']), want.x, want.y)!;
expect(got.holes[0].holeName).not.toBe('10t.b');
// Nearest free column, not a jump across the board.
expect(got.moved).toBeLessThanOrEqual(9.6 * 2);
});
it('rejects a placement whose SECOND pin would collide', () => {
const want = posFor('10t.b');
const got = solvePlacement(resistorPins(), bb, new Set(['10b.f']), want.x, want.y)!;
expect(got.holes.map((h) => h.holeName)).not.toContain('10b.f');
});
it('returns null when every hole in range is taken', () => {
const all = new Set(BREADBOARD_PINS.map((h) => h.name));
const want = posFor('10t.b');
expect(solvePlacement(resistorPins(), bb, all, want.x, want.y)).toBeNull();
});
it('never returns a half-seated placement (the 7-segment bug)', () => {
// The real invariant: a returned placement always assigns EVERY pin a
// hole. Never a partial seating, whatever the geometry. Swept across
// spans that are on-pitch, off-pitch and half-pitch.
const want = posFor('10t.b');
for (let extra = 0; extra <= 9.6; extra += 0.4) {
const pins = [
{ name: 'top', dx: 0, dy: 0 },
{ name: 'bottom', dx: 0, dy: 6 * 9.6 + extra },
];
const got = solvePlacement(pins, bb, new Set(), want.x, want.y);
if (got) expect(got.holes).toHaveLength(pins.length);
}
});
it('refuses to drag a part more than the search radius', () => {
const want = posFor('10t.b');
// Occupy a wide band around the drop so nothing fits within 6 pitches.
const taken = new Set(
BREADBOARD_PINS.filter((h) => Math.abs(h.x - hole('10t.b').x) < 9.6 * 8).map((h) => h.name),
);
expect(solvePlacement(resistorPins(), bb, taken, want.x, want.y)).toBeNull();
});
});
describe('solvePlacement — sub-pitch translation (off-lattice footprints)', () => {
const bb = { id: 'bb1', metadataId: 'breadboard', x: 100, y: 200 };
const ox = 100 + WRAPPER_INSET;
const oy = 200 + WRAPPER_INSET;
const hole = (name: string) => BREADBOARD_PINS.find((h) => h.name === name)!;
const posFor = (name: string) => ({ x: ox + hole(name).x, y: oy + hole(name).y });
/** Nearest-hole distance for every pin of a placement, for assertions. */
const residuals = (pins: { name: string; dx: number; dy: number }[], p: { x: number; y: number }) =>
pins.map((pin) => {
const px = p.x + pin.dx;
const py = p.y + pin.dy;
let best = Infinity;
for (const h of BREADBOARD_PINS) best = Math.min(best, Math.hypot(ox + h.x - px, oy + h.y - py));
return best;
});
it('seats a diode: 7.5-pitch span splits the error between both legs', () => {
// DiodeElements.ts diodePinInfo(): A at x=0, C at x=72 = 7.5 * 9.6.
// Anchor-exact placement leaves C 4.8 px out; the fine translation puts
// both legs 2.4 px off centre instead, which is inside tolerance.
const pins = [
{ name: 'A', dx: 0, dy: 0 },
{ name: 'C', dx: 72, dy: 0 },
];
const want = posFor('10t.b');
const got = solvePlacement(pins, bb, new Set(), want.x, want.y);
expect(got).not.toBeNull();
expect(got!.holes).toHaveLength(2);
for (const r of residuals(pins, got!)) {
expect(r).toBeLessThanOrEqual(SEAT_TOLERANCE);
expect(r).toBeGreaterThan(0.5); // genuinely off-centre, not a lucky exact fit
}
});
it('keeps every pin inside tolerance, so hole resolution stays unambiguous', () => {
// SEAT_TOLERANCE < half pitch is what guarantees computeSeating later
// picks the SAME holes the solver assigned — i.e. the netlist is
// unaffected by the part rendering a couple of px off centre.
const pins = [
{ name: 'A', dx: 0, dy: 0 },
{ name: 'C', dx: 72, dy: 0 },
];
const want = posFor('10t.b');
const got = solvePlacement(pins, bb, new Set(), want.x, want.y)!;
for (const r of residuals(pins, got)) expect(r).toBeLessThan(9.6 / 2);
});
it('still refuses a DIP-14 on the wrong pitch — 8 px cannot be rescued', () => {
// LogicICElements.ts dip14Pins(): y = 12 + i*8. Seven pins at 8 px drift
// 1.6 px per step against the 9.6 grid; by pin 7 that is 4.8 px, and no
// single translation can absorb a spread that large.
const pins = Array.from({ length: 7 }, (_, i) => ({ name: `p${i + 1}`, dx: 0, dy: i * 8 }));
const want = posFor('10t.a');
expect(solvePlacement(pins, bb, new Set(), want.x, want.y)).toBeNull();
});
it('makes EVERY two-pin footprint seatable, at any span', () => {
// Guarantee of the centroid rule: with two pins the worst span error
// against the lattice is half a pitch (4.8 px), which splits into 2.4 px
// per pin — always inside SEAT_TOLERANCE. This is what rescues the whole
// 72 px family (diodes, transistors, regulators) with no artwork change.
const want = posFor('10t.b');
for (let span = 9.6; span <= 96; span += 0.4) {
const pins = [
{ name: 'a', dx: 0, dy: 0 },
{ name: 'b', dx: span, dy: 0 },
];
const got = solvePlacement(pins, bb, new Set(), want.x, want.y);
expect(got, `span ${span.toFixed(1)} px should seat`).not.toBeNull();
for (const r of residuals(pins, got!)) expect(r).toBeLessThanOrEqual(SEAT_TOLERANCE);
}
});
it('an exactly-on-grid part is still placed dead centre, not nudged', () => {
const pins = [
{ name: '1', dx: 0, dy: 0 },
{ name: '2', dx: 9.6 * 4, dy: 0 },
];
const want = posFor('10t.b');
const got = solvePlacement(pins, bb, new Set(), want.x, want.y)!;
expect(got.moved).toBeCloseTo(0, 6);
for (const r of residuals(pins, got)) expect(r).toBeCloseTo(0, 6);
});
});
describe('solvePlacement — real catalog footprints', () => {
// Coordinates copied verbatim from the element sources, so this locks in
// the measured coverage boundary. If artwork changes, these move with it.
const bb = { id: 'bb1', metadataId: 'breadboard', x: 100, y: 200 };
const ox = 100 + WRAPPER_INSET;
const oy = 200 + WRAPPER_INSET;
const hole = (name: string) => BREADBOARD_PINS.find((h) => h.name === name)!;
const at = (name: string) => ({ x: ox + hole(name).x, y: oy + hole(name).y });
const seats = (pins: { name: string; dx: number; dy: number }[], anchor = '10t.a') => {
const want = at(anchor);
return solvePlacement(pins, bb, new Set(), want.x, want.y);
};
it('SEATS a diode — DiodeElements.ts, A/C 72 px apart', () => {
expect(
seats([
{ name: 'A', dx: 0, dy: 16 },
{ name: 'C', dx: 72, dy: 16 },
]),
).not.toBeNull();
});
it('SEATS a TO-92 transistor — TransistorElements.ts C/B/E', () => {
const got = seats([
{ name: 'C', dx: 60, dy: 0 },
{ name: 'B', dx: 0, dy: 36 },
{ name: 'E', dx: 60, dy: 72 },
]);
expect(got).not.toBeNull();
expect(got!.holes).toHaveLength(3);
// Distinct holes — a transistor shorting two of its own legs is useless.
expect(new Set(got!.holes.map((h) => h.holeName)).size).toBe(3);
});
it('SEATS the 74HC595 — IC74HC595.ts, already on an exact 9.6 pitch', () => {
const xs = [8.1, 17.7, 27.3, 36.9, 46.5, 56.1, 65.7, 75.3];
const pins = [
...xs.map((x, i) => ({ name: `b${i}`, dx: x, dy: 51.3 })),
...xs.map((x, i) => ({ name: `t${i}`, dx: x, dy: 3 })),
];
const got = seats(pins);
expect(got).not.toBeNull();
expect(got!.holes).toHaveLength(16);
});
it('REFUSES the 74HC00 family — LogicICElements.ts dip14Pins() 8 px pitch', () => {
// The one defect no solver can absorb: 1.6 px drift per pin compounds
// to 4.8 px across the package. Needs the artwork fixed against the
// 74HC595 template above.
const DIP14_W = 80;
const pins = Array.from({ length: 14 }, (_, i) => ({
name: `p${i + 1}`,
dx: i < 7 ? 0 : DIP14_W,
dy: i < 7 ? 12 + i * 8 : 12 + (13 - i) * 8,
}));
expect(seats(pins)).toBeNull();
});
});
describe('solvePlacement — never shorts a part to itself', () => {
const bb = { id: 'bb1', metadataId: 'breadboard', x: 100, y: 200 };
const ox = 100 + WRAPPER_INSET;
const oy = 200 + WRAPPER_INSET;
const hole = (name: string) => BREADBOARD_PINS.find((h) => h.name === name)!;
const at = (name: string) => ({ x: ox + hole(name).x, y: oy + hole(name).y });
const groupOf = (h: string) => {
const m = /^(\d+)([tb])\.[a-j]$/.exec(h);
if (m) return `col${m[1]}${m[2]}`;
const r = /^([tb][pn])\.\d+$/.exec(h);
return r ? `rail${r[1]}` : h;
};
it('refuses a footprint whose pins would share one column strip', () => {
// Two pins 9.6 px apart vertically inside a bank land in the same 5-hole
// column, which is a single net.
const pins = [
{ name: 'a', dx: 0, dy: 0 },
{ name: 'b', dx: 0, dy: 9.6 },
];
const want = at('20t.a');
const got = solvePlacement(pins, bb, new Set(), want.x, want.y);
if (got) {
const gs = got.holes.map((h) => groupOf(h.holeName));
expect(new Set(gs).size).toBe(gs.length);
}
});
it('never lays a multi-pin part across a power rail', () => {
// A rail is one net for the WHOLE board — the worst possible short.
const pins = Array.from({ length: 5 }, (_, i) => ({ name: `p${i}`, dx: i * 9.6, dy: 0 }));
const want = at('20t.a');
const got = solvePlacement(pins, bb, new Set(), want.x, want.y);
if (got) {
const rails = got.holes.filter((h) => /^[tb][pn]\./.test(h.holeName));
expect(rails.length).toBeLessThanOrEqual(1);
}
});
it('every seated real footprint uses one distinct strip per pin', () => {
const cases: Record<string, { name: string; dx: number; dy: number }[]> = {
diode: [
{ name: 'A', dx: 0, dy: 16 },
{ name: 'C', dx: 72, dy: 16 },
],
to92: [
{ name: 'C', dx: 60, dy: 0 },
{ name: 'B', dx: 0, dy: 36 },
{ name: 'E', dx: 60, dy: 72 },
],
// neopixel: 20 x 10.5 px rectangle — the audit found this one seats
// only by shorting itself, at every anchor and rotation.
neopixel: [
{ name: 'VDD', dx: 0, dy: 0 },
{ name: 'DIN', dx: 0, dy: 10.5 },
{ name: 'DOUT', dx: 20, dy: 0 },
{ name: 'VSS', dx: 20, dy: 10.5 },
],
};
for (const [label, pins] of Object.entries(cases)) {
const want = at('20t.a');
const got = solvePlacement(pins, bb, new Set(), want.x, want.y);
if (!got) continue; // refusing is a valid answer
const gs = got.holes.map((h) => groupOf(h.holeName));
expect(new Set(gs).size, `${label} shorts itself`).toBe(gs.length);
}
});
});