velxio/frontend/src/__tests__/pin-inspector-layout.test.ts

279 lines
11 KiB
TypeScript

/**
* pinInspectorLayout — the Part Inspector's spatial pin geometry.
*
* The coordinate tables here are copied VERBATIM from the real elements (the
* file/line each block cites), not invented: the point of the suite is that
* the classifier reads actual shipped parts sensibly. If a part's pinInfo
* changes upstream, refresh the copy.
*/
import { describe, expect, it } from 'vitest';
import {
layoutInspectorPins,
signalKindOf,
type InspectorPinInput,
} from '../utils/pinInspectorLayout';
// components/velxio-components/Esp32Element.ts:31-68 (PINS_ESP32), board
// natural size 141x265 (BOARD_CONFIGS.esp32). Two vertical columns: left
// x=6, right x=134. Silk aliases (16/RX2, 17/TX2) share coordinates.
const ESP32_PINS: InspectorPinInput[] = [
{ name: 'EN', x: 6, y: 29 },
{ name: 'VN', x: 6, y: 42 },
{ name: 'VP', x: 6, y: 54 },
{ name: '34', x: 6, y: 67 },
{ name: '35', x: 6, y: 80 },
{ name: '32', x: 6, y: 93 },
{ name: '33', x: 6, y: 105 },
{ name: '25', x: 6, y: 118 },
{ name: '26', x: 6, y: 131 },
{ name: '27', x: 6, y: 143 },
{ name: '14', x: 6, y: 156 },
{ name: '12', x: 6, y: 169 },
{ name: '13', x: 6, y: 181 },
{ name: 'GND', x: 6, y: 194 },
{ name: 'VIN', x: 6, y: 207 },
{ name: '3V3', x: 134, y: 207 },
{ name: 'GND2', x: 134, y: 194 },
{ name: '15', x: 134, y: 181 },
{ name: '2', x: 134, y: 169 },
{ name: '4', x: 134, y: 156 },
{ name: 'RX2', x: 134, y: 143 },
{ name: 'TX2', x: 134, y: 131 },
{ name: '5', x: 134, y: 118 },
{ name: '18', x: 134, y: 105 },
{ name: '19', x: 134, y: 93 },
{ name: '21', x: 134, y: 80 },
{ name: 'RX0', x: 134, y: 67 },
{ name: 'TX0', x: 134, y: 54 },
{ name: '22', x: 134, y: 42 },
{ name: '23', x: 134, y: 29 },
];
const ESP32_SIZE = { width: 141, height: 265 };
// @wokwi/elements led-element.js:28-34 (unflipped), element box 40x50.
const LED_PINS: InspectorPinInput[] = [
{ name: 'A', x: 25, y: 42 },
{ name: 'C', x: 15, y: 42 },
];
const LED_SIZE = { width: 40, height: 50 };
describe('layoutInspectorPins — the ESP32 board (the 30-pin scroll case)', () => {
const r = layoutInspectorPins(ESP32_PINS, ESP32_SIZE);
it('classifies every pin left or right, none interior', () => {
for (const p of r.pins) {
expect(['left', 'right']).toContain(p.edge);
}
expect(r.pins.filter((p) => p.edge === 'left')).toHaveLength(15);
expect(r.pins.filter((p) => p.edge === 'right')).toHaveLength(15);
});
it('keeps a minimum spacing between labels on the same side', () => {
for (const edge of ['left', 'right'] as const) {
const ys = r.pins
.filter((p) => p.edge === edge)
.map((p) => p.labelY)
.sort((a, b) => a - b);
for (let i = 1; i < ys.length; i++) {
expect(ys[i] - ys[i - 1]).toBeGreaterThanOrEqual(16 - 1e-6);
}
}
});
it('preserves the dot y-order in the label order per side', () => {
for (const edge of ['left', 'right'] as const) {
const group = r.pins.filter((p) => p.edge === edge).sort((a, b) => a.dotY - b.dotY);
for (let i = 1; i < group.length; i++) {
expect(group[i].labelY).toBeGreaterThan(group[i - 1].labelY);
}
}
});
it('reserves side gutters and no top/bottom gutters', () => {
expect(r.padLeft).toBeGreaterThan(0);
expect(r.padRight).toBeGreaterThan(0);
expect(r.padTop).toBe(0);
expect(r.padBottom).toBe(0);
});
it('scales the art into the default box', () => {
expect(r.artHeight).toBeLessThanOrEqual(260);
expect(r.scale).toBeCloseTo(260 / 265, 5);
});
});
describe('layoutInspectorPins — small parts', () => {
it('puts the LED legs on the bottom edge', () => {
const r = layoutInspectorPins(LED_PINS, LED_SIZE);
for (const p of r.pins) expect(p.edge).toBe('bottom');
expect(r.padBottom).toBeGreaterThan(0);
expect(r.padLeft).toBe(0);
});
it('caps upscaling at 2x so a tiny part is not a blur', () => {
const r = layoutInspectorPins(LED_PINS, { width: 20, height: 10 });
expect(r.scale).toBe(2);
expect(r.artWidth).toBe(40);
});
it('handles zero pins and one pin', () => {
const none = layoutInspectorPins([], LED_SIZE);
expect(none.pins).toHaveLength(0);
expect(none.padLeft + none.padRight + none.padTop + none.padBottom).toBe(0);
const one = layoutInspectorPins([{ name: 'OUT', x: 2, y: 25 }], { width: 50, height: 50 });
expect(one.pins[0].edge).toBe('left');
expect(one.pins[0].needsLeader).toBe(false);
});
});
describe('layoutInspectorPins — a crowded header fits its edge', () => {
it('packs 8 top pins inside the art width (they are drawn as vertical text)', () => {
// The SSD1306's 8-pin header measured 240px wide in the dialog. With
// horizontal labels this demanded ~288px and the labels escaped the
// dialog; vertical labels use the side spacing instead.
const pins: InspectorPinInput[] = Array.from({ length: 8 }, (_, i) => ({
name: ['DATA', 'CLK', 'DC', 'RST', 'CS', '3V3', 'VIN', 'GND'][i],
x: 14 + i * 13,
y: 2,
}));
const r = layoutInspectorPins(pins, { width: 128, height: 119 });
for (const p of r.pins) expect(p.edge).toBe('top');
const xs = r.pins.map((p) => p.labelX).sort((a, b) => a - b);
expect(xs[0]).toBeGreaterThanOrEqual(0);
expect(xs[xs.length - 1]).toBeLessThanOrEqual(r.artWidth);
for (let i = 1; i < xs.length; i++) {
expect(xs[i] - xs[i - 1]).toBeGreaterThanOrEqual(16 - 1e-6);
}
});
});
describe('layoutInspectorPins — inset columns (the ReSpeaker Lite case)', () => {
// pro/.../SeeedElements.ts ReSpeakerLiteElement.pinInfo, body 178x437.
// The XIAO socket is TWO INSET COLUMNS (x=50.5 / 126.5 on a 178-wide body)
// plus two breakout pad columns at x=10. A nearest-edge rule called most of
// these "interior" and printed no label, so the part looked like it was
// missing pins. Every pin must get a label.
const RESPEAKER: InspectorPinInput[] = [
...['D0', 'D1', 'D2', 'D3', 'D4', 'D5', 'D6'].map((name, i) => ({
name,
x: 50.5,
y: [43, 55, 68, 81, 93, 106, 119][i],
})),
...['D7', 'D8', 'D9', 'D10', '3V3', 'GND', '5V'].map((name, i) => ({
name,
x: 126.5,
y: [43, 55, 68, 81, 93, 106, 119][i],
})),
...['BTN1', 'BTN2', 'BTN3'].map((name, i) => ({ name, x: 10, y: 214 + i * 15 })),
...['IO1', 'IO2', 'IO3', 'IO4'].map((name, i) => ({ name, x: 10, y: 289 + i * 13 })),
];
const r = layoutInspectorPins(RESPEAKER, { width: 178, height: 437 });
it('labels every single pin — none silently dropped', () => {
expect(r.pins).toHaveLength(RESPEAKER.length);
const named = new Set(r.pins.map((p) => p.name));
for (const p of RESPEAKER) expect(named.has(p.name)).toBe(true);
});
it('keeps each inset column on the side of the body it sits on', () => {
const sideOf = (n: string) => r.pins.find((p) => p.name === n)!.edge;
for (const n of ['D0', 'D3', 'D6']) expect(sideOf(n)).toBe('left');
for (const n of ['D7', '3V3', '5V']) expect(sideOf(n)).toBe('right');
for (const n of ['BTN1', 'IO4']) expect(sideOf(n)).toBe('left');
});
it('draws a leader from every inset dot to its gutter label', () => {
for (const n of ['D0', 'D7', '3V3']) {
expect(r.pins.find((p) => p.name === n)!.needsLeader).toBe(true);
}
});
it('never puts a column end-pin in the top or bottom gutter', () => {
expect(r.pins.some((p) => p.edge === 'top' || p.edge === 'bottom')).toBe(false);
});
it('grows the box so a stack taller than the body is not clipped', () => {
// 13 labels on the left at >=16px need more room than the scaled body,
// so the first and last used to be cut off by the preview's clip.
const top = -r.padTop;
const bottom = r.artHeight + r.padBottom;
for (const p of r.pins) {
expect(p.labelY).toBeGreaterThanOrEqual(top);
expect(p.labelY).toBeLessThanOrEqual(bottom);
}
});
});
describe('layoutInspectorPins — the Arduino Uno (two opposite headers)', () => {
// node_modules/@wokwi/elements arduino-uno-element pinInfo, board 274x208.
// 18 pins along the top header (y=9) and 13 along the bottom (y=192), with
// SIX x values shared between the two. Reading those pairs as columns threw
// most of the board into the side gutters with leaders fanning across the
// art; a column has to be a run of pads, not two opposite ends.
const UNO: InspectorPinInput[] = [
{ name: 'A5.2', x: 87, y: 9 },
{ name: 'A4.2', x: 97, y: 9 },
{ name: 'AREF', x: 106, y: 9 },
{ name: 'GND.1', x: 115.5, y: 9 },
{ name: '13', x: 125, y: 9 },
{ name: '12', x: 134.5, y: 9 },
{ name: '11', x: 144, y: 9 },
{ name: '10', x: 153.5, y: 9 },
{ name: '9', x: 163, y: 9 },
{ name: '8', x: 173, y: 9 },
{ name: '7', x: 189, y: 9 },
{ name: '6', x: 198.5, y: 9 },
{ name: '5', x: 208, y: 9 },
{ name: '4', x: 217.5, y: 9 },
{ name: '3', x: 227, y: 9 },
{ name: '2', x: 236.5, y: 9 },
{ name: '1', x: 246, y: 9 },
{ name: '0', x: 255.5, y: 9 },
{ name: 'IOREF', x: 131, y: 191.5 },
{ name: 'RESET', x: 140.5, y: 191.5 },
{ name: '3.3V', x: 150, y: 191.5 },
{ name: '5V', x: 160, y: 191.5 },
{ name: 'GND.2', x: 169.5, y: 191.5 },
{ name: 'GND.3', x: 179, y: 191.5 },
{ name: 'VIN', x: 188.5, y: 191.5 },
{ name: 'A0', x: 208, y: 191.5 },
{ name: 'A1', x: 217.5, y: 191.5 },
{ name: 'A2', x: 227, y: 191.5 },
{ name: 'A3', x: 236.5, y: 191.5 },
{ name: 'A4', x: 246, y: 191.5 },
{ name: 'A5', x: 255.5, y: 191.5 }
];
const r = layoutInspectorPins(UNO, { width: 274, height: 208 });
it('puts every pin on the header it belongs to', () => {
expect(r.pins).toHaveLength(31);
expect(r.pins.filter((p) => p.edge === 'top')).toHaveLength(18);
expect(r.pins.filter((p) => p.edge === 'bottom')).toHaveLength(13);
});
it('uses no side gutters at all', () => {
expect(r.pins.some((p) => p.edge === 'left' || p.edge === 'right')).toBe(false);
});
it('keeps the shared-x pairs apart — one per header', () => {
for (const name of ['5', '4', '3', '2', '1', '0']) {
const p = r.pins.find((q) => q.name === name);
if (p) expect(p.edge).toBe('top');
}
});
});
describe('signalKindOf', () => {
it('maps the wokwi signal shapes', () => {
expect(signalKindOf({ name: 'SDA', x: 0, y: 0, signals: [{ type: 'i2c', signal: 'SDA' }] })).toBe('i2c');
expect(signalKindOf({ name: 'GND', x: 0, y: 0, signals: [{ type: 'power', signal: 'GND' }] })).toBe('power-gnd');
expect(signalKindOf({ name: 'VCC', x: 0, y: 0, signals: [{ type: 'power', signal: 'VCC' }] })).toBe('power-vcc');
expect(signalKindOf({ name: 'D1', x: 0, y: 0 })).toBe('other');
expect(signalKindOf({ name: 'D1', x: 0, y: 0, signals: [] })).toBe('other');
});
});