279 lines
11 KiB
TypeScript
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');
|
|
});
|
|
});
|