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

82 lines
3.3 KiB
TypeScript
Raw Normal View History

feat(breadboard): Wokwi-style parts-on-breadboard — hole snapping + invisible seating wires Parts now plug INTO the breadboard instead of using it as a junction box: - Drag magnetism: while dragging, the part's anchor pin snaps to the nearest hole center (9 px range, 9.6 px grid) so parts land perfectly aligned, like Wokwi. - Seating: every pin within 4 px of a hole gets an invisible zero-length wire (Wire.bb) from pin to hole — the exact model Wokwi persists as ["r1:1","bb1:6t.b","",["$bb"]]. Electrically they are ordinary wires, so the netlist builder, digital trace and SPICE need zero changes; they are simply not rendered and not hit-testable. Seating re-computes on every move/rotation (updateComponent), and moving the breadboard carries its seated parts along. - Resistors auto-rotate to vertical when dragged over a breadboard (their 58.8 px pin span bridges the center trench rows b-f exactly). - Seat tolerance 4 px: absorbs the worst element pin-spacing residual (~1.6 px) while staying under half the hole pitch, so a pin is never ambiguous between holes. Wokwi interchange fixes that fell out of the diagram.json research: - import maps the top-level rotate attr onto properties.rotation (previously every rotated part imported flat) and export emits it back as rotate instead of leaking it into attrs; - $bb / empty-color connections import as bb seating wires and export back as ["$bb"] entries, so parts-on-breadboard projects round-trip; - wokwi-breadboard-half aliases to the full breadboard (hole names are a strict superset, so every connection stays valid). Breadboard elements now export their pure hole grids and import cleanly without a DOM (node tests); geometry + store seating covered by breadboard-snap.test.ts and breadboard-seating.test.ts.
2026-07-18 13:47:25 +07:00
// @vitest-environment jsdom
/**
* Store-level integration of parts-on-breadboard seating: moving a part so
* its pins land on holes creates invisible bb wires, moving it away removes
* them, and moving the breadboard carries seated parts along.
*/
import { describe, it, expect, beforeEach } from 'vitest';
import { useSimulatorStore } from '../store/useSimulatorStore';
import { BREADBOARD_PINS } from '../velxio-elements/breadboard-element';
const RES_PIN_INFO = [
{ name: '1', x: 0, y: 5.65, signals: [] },
{ name: '2', x: 58.8, y: 5.65, signals: [] },
];
const INSET = 6; // DynamicComponent wrapper border+padding
function mountFakeElement(id: string, pinInfo: unknown): void {
document.getElementById(id)?.remove();
const el = document.createElement('div');
el.id = id;
(el as unknown as { pinInfo: unknown }).pinInfo = pinInfo;
document.body.appendChild(el);
}
/** Component x/y that puts resistor pin 1 exactly on the given hole. */
function resistorPosFor(holeName: string, bbX: number, bbY: number) {
const hole = BREADBOARD_PINS.find((h) => h.name === holeName)!;
return {
x: bbX + INSET + hole.x - INSET - RES_PIN_INFO[0].x,
y: bbY + INSET + hole.y - INSET - RES_PIN_INFO[0].y,
};
}
describe('breadboard seating via updateComponent', () => {
beforeEach(() => {
const s = useSimulatorStore.getState();
s.setComponents([
{ id: 'bb1', metadataId: 'breadboard', x: 0, y: 0, properties: {} },
{ id: 'res1', metadataId: 'resistor', x: 900, y: 900, properties: {} },
] as never);
s.setWires([]);
mountFakeElement('res1', RES_PIN_INFO);
});
it('creates one invisible bb wire per pin when the part lands on holes', () => {
const pos = resistorPosFor('5t.a', 0, 0);
useSimulatorStore.getState().updateComponent('res1', pos);
const bbWires = useSimulatorStore.getState().wires.filter((w) => w.bb);
expect(bbWires).toHaveLength(2);
const holes = bbWires.map((w) => w.end.pinName).sort();
// Pin 1 on 5t.a; pin 2 is 58.8 px away = 6 pitches + 1.2 px residual,
// absorbed by the seat tolerance onto column 11.
expect(holes).toEqual(['11t.a', '5t.a']);
expect(bbWires.every((w) => w.end.componentId === 'bb1')).toBe(true);
});
it('removes the seating when the part moves off the board', () => {
useSimulatorStore.getState().updateComponent('res1', resistorPosFor('5t.a', 0, 0));
expect(useSimulatorStore.getState().wires.filter((w) => w.bb)).toHaveLength(2);
useSimulatorStore.getState().updateComponent('res1', { x: 1500, y: 1500 });
expect(useSimulatorStore.getState().wires.filter((w) => w.bb)).toHaveLength(0);
});
it('carries seated parts when the breadboard moves', () => {
useSimulatorStore.getState().updateComponent('res1', resistorPosFor('5t.a', 0, 0));
const before = useSimulatorStore.getState().components.find((c) => c.id === 'res1')!;
useSimulatorStore.getState().updateComponent('bb1', { x: 120, y: 40 });
const after = useSimulatorStore.getState().components.find((c) => c.id === 'res1')!;
expect(after.x).toBeCloseTo(before.x + 120, 5);
expect(after.y).toBeCloseTo(before.y + 40, 5);
const holes = useSimulatorStore.getState().wires
.filter((w) => w.bb)
.map((w) => w.end.pinName)
.sort();
expect(holes).toEqual(['11t.a', '5t.a']); // same holes, new location
});
});