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';
|
2026-07-20 03:01:41 +07:00
|
|
|
import { computeSeating, resolveSeatPosition, seatOnDrop } from '../utils/breadboardSnap';
|
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
|
|
|
|
|
|
|
|
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
|
|
|
|
|
});
|
|
|
|
|
});
|
feat(breadboard): hover-gated labels + full-footprint seating solver
Three changes, all driven by a real project where a 4-digit 7-segment clock
was unreadable and half its parts were not actually seated.
Labels on hover only
--------------------
Eight vertical resistors at 19 px pitch rendered eight 93 px "Resistor 220 Ω"
labels on top of each other, hiding the parts and the breadboard holes; the
SPICE overlay added ~40 more `0uV` pills. Both are now revealed on hover:
hovering a part also lights up the voltages of every wire touching it.
The label is hidden with OPACITY and stays in flow. pinPositionCalculator
derives the rotation pivot from wrapper.offsetHeight, so taking it out of
flow would move the pins of every rotated component in every saved project.
Seat-on-drop
------------
The drag-time magnet only aligned the anchor pin and assumed the rest
followed, which is how parts ended up HALF-seated: some pins in holes, the
rest dead in the air. It looks mounted in a screenshot and silently breaks
the circuit. On release we now re-solve properly — nearest position where
EVERY pin is in a free hole, sliding past occupied columns — via the new
solvePlacement/seatOnDrop. Geometry comes from the element's own pinInfo,
so there is no part whitelist.
Sub-pitch translation
---------------------
solvePlacement first assigned pins to holes at half-pitch, then translates
by the centroid of the residuals before judging fit. Pinning the anchor dead
centre refused every off-lattice footprint: a diode spans 7.5 pitches, so
one leg landed 4.8 px out. Shifted 2.4 px, BOTH legs sit inside tolerance —
what bending the leads does on a real board. Measured over the catalog this
takes seatable parts from 87 to 125 of 152; diodes, transistors, regulators,
optocouplers and flip-flops are rescued with no artwork change.
Staying under SEAT_TOLERANCE (< half pitch) keeps each pin's nearest hole
unambiguous, so computeSeating resolves the same holes and the netlist is
unaffected by the small offset.
Also: refuse a placement that would put two of a part's own pins in one
strip. A column strip — and far worse, a power rail — is a single net, so
such a seating shorts the part to itself. Without it a 7-segment happily
lays its pins across a rail. And deduplicate pin names before solving:
calculatePinPosition resolves by name and returns the first match, so a
board carrying GND x5 collided with itself and was refused outright.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 10:28:40 +07:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Drop-time auto-seating (seatOnDrop). This is the generic path: pin
|
|
|
|
|
* geometry comes from the element's `pinInfo`, so no part is special-cased.
|
|
|
|
|
*/
|
|
|
|
|
describe('seatOnDrop', () => {
|
|
|
|
|
/** Real wokwi 1-digit 7segment pinInfo (pins='top'), mm*3.78 -> CSS px. */
|
|
|
|
|
const SEG7_PIN_INFO = [
|
|
|
|
|
{ name: 'COM.1', x: 23.72, y: 71.82, signals: [] },
|
|
|
|
|
{ name: 'COM.2', x: 23.72, y: 3.78, signals: [] },
|
|
|
|
|
{ name: 'A', x: 33.32, y: 3.78, signals: [] },
|
|
|
|
|
{ name: 'B', x: 42.92, y: 3.78, signals: [] },
|
|
|
|
|
{ name: 'C', x: 33.32, y: 71.82, signals: [] },
|
|
|
|
|
{ name: 'D', x: 14.12, y: 71.82, signals: [] },
|
|
|
|
|
{ name: 'E', x: 4.52, y: 71.82, signals: [] },
|
|
|
|
|
{ name: 'F', x: 14.12, y: 3.78, signals: [] },
|
|
|
|
|
{ name: 'G', x: 4.52, y: 3.78, signals: [] },
|
|
|
|
|
{ name: 'DP', x: 42.92, y: 71.82, signals: [] },
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
const bb = { id: 'bb1', metadataId: 'breadboard', x: 0, y: 0, properties: {} };
|
|
|
|
|
|
|
|
|
|
/** Every pin of `comp` that is within seat tolerance of a hole. */
|
|
|
|
|
const seatedCount = (comp: never) =>
|
|
|
|
|
(computeSeating(comp, [bb, comp] as never) ?? []).length;
|
|
|
|
|
|
|
|
|
|
beforeEach(() => {
|
|
|
|
|
mountFakeElement('seg1', SEG7_PIN_INFO);
|
|
|
|
|
mountFakeElement('res1', RES_PIN_INFO);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('fully seats a 7-segment dropped a few px off — never half-seated', () => {
|
|
|
|
|
// The exact bug from the reported project: dropped slightly high, the
|
|
|
|
|
// top pin row grazes bank-a and the bottom row lands on nothing.
|
|
|
|
|
const comp = { id: 'seg1', metadataId: '7segment', x: 40, y: 30, properties: {} };
|
|
|
|
|
const placed = seatOnDrop(comp as never, 43, 27, [bb, comp] as never);
|
|
|
|
|
|
|
|
|
|
expect(placed).not.toBeNull();
|
|
|
|
|
expect(placed!.holes).toHaveLength(SEG7_PIN_INFO.length);
|
|
|
|
|
const seated = { ...comp, x: placed!.x, y: placed!.y };
|
|
|
|
|
expect(seatedCount(seated as never)).toBe(SEG7_PIN_INFO.length);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('straddles the trench: top pin row in bank-t, bottom row in bank-b', () => {
|
|
|
|
|
const comp = { id: 'seg1', metadataId: '7segment', x: 40, y: 30, properties: {} };
|
|
|
|
|
const placed = seatOnDrop(comp as never, 43, 27, [bb, comp] as never)!;
|
|
|
|
|
const holeOf = (pin: string) => placed.holes.find((h) => h.pinName === pin)!.holeName;
|
|
|
|
|
// COM.2 is a top-row pin, COM.1 the bottom-row one directly below it.
|
|
|
|
|
expect(holeOf('COM.2')).toMatch(/t\.[a-e]$/);
|
|
|
|
|
expect(holeOf('COM.1')).toMatch(/b\.[f-j]$/);
|
|
|
|
|
// Same column — the part is rigid.
|
|
|
|
|
expect(holeOf('COM.2').split('t.')[0]).toBe(holeOf('COM.1').split('b.')[0]);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('never assigns two pins to the same hole', () => {
|
|
|
|
|
const comp = { id: 'seg1', metadataId: '7segment', x: 40, y: 30, properties: {} };
|
|
|
|
|
const placed = seatOnDrop(comp as never, 43, 27, [bb, comp] as never)!;
|
|
|
|
|
const names = placed.holes.map((h) => h.holeName);
|
|
|
|
|
expect(new Set(names).size).toBe(names.length);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('slides clear of a part already occupying the target holes', () => {
|
|
|
|
|
const seg = { id: 'seg1', metadataId: '7segment', x: 40, y: 30, properties: {} };
|
|
|
|
|
const first = seatOnDrop(seg as never, 43, 27, [bb, seg] as never)!;
|
|
|
|
|
const seated = { ...seg, x: first.x, y: first.y };
|
|
|
|
|
|
|
|
|
|
// Drop a resistor right on top of the seated display.
|
|
|
|
|
const res = { id: 'res1', metadataId: 'resistor', x: first.x, y: first.y, properties: {} };
|
|
|
|
|
const placed = seatOnDrop(res as never, first.x, first.y, [bb, seated, res] as never);
|
|
|
|
|
|
|
|
|
|
expect(placed).not.toBeNull();
|
|
|
|
|
const taken = new Set(first.holes.map((h) => h.holeName));
|
|
|
|
|
for (const h of placed!.holes) expect(taken.has(h.holeName)).toBe(false);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('leaves a part dropped away from any breadboard alone', () => {
|
|
|
|
|
const comp = { id: 'res1', metadataId: 'resistor', x: 5000, y: 5000, properties: {} };
|
|
|
|
|
expect(seatOnDrop(comp as never, 5000, 5000, [bb, comp] as never)).toBeNull();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('works off pinInfo alone — an unknown part type seats just the same', () => {
|
|
|
|
|
// No whitelist: a made-up component with plausible 2-pin geometry.
|
|
|
|
|
mountFakeElement('mystery1', [
|
|
|
|
|
{ name: 'P1', x: 0, y: 0, signals: [] },
|
|
|
|
|
{ name: 'P2', x: 9.6 * 3, y: 0, signals: [] },
|
|
|
|
|
]);
|
|
|
|
|
const comp = { id: 'mystery1', metadataId: 'totally-unknown-part', x: 40, y: 30, properties: {} };
|
|
|
|
|
const placed = seatOnDrop(comp as never, 42, 31, [bb, comp] as never);
|
|
|
|
|
expect(placed).not.toBeNull();
|
|
|
|
|
expect(placed!.holes).toHaveLength(2);
|
|
|
|
|
});
|
|
|
|
|
});
|
2026-07-20 03:01:41 +07:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Agent-path seating correction: resolveSeatPosition lands a named pin exactly
|
|
|
|
|
* on a named hole by pure translation. jsdom has no layout (offsetWidth = 0),
|
|
|
|
|
* so this covers the translation + wiring at rotation 0; the rotation-pivot
|
|
|
|
|
* case — the reason the resolver exists — is verified in the browser via
|
|
|
|
|
* Playwright, where the real wrapper (with its text label) exists.
|
|
|
|
|
*/
|
|
|
|
|
describe('resolveSeatPosition', () => {
|
|
|
|
|
const bbAt = (x: number, y: number) => ({ id: 'bb1', metadataId: 'breadboard', x, y, properties: {} });
|
|
|
|
|
// Anchor target in breadboard-element space (what the solver sends). Here we
|
|
|
|
|
// use a hole centre directly, since a 0-rotation resistor is on-lattice.
|
|
|
|
|
const holeElem = (name: string) => {
|
|
|
|
|
const h = BREADBOARD_PINS.find((p) => p.name === name)!;
|
|
|
|
|
return { x: h.x, y: h.y };
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
beforeEach(() => mountFakeElement('res1', RES_PIN_INFO));
|
|
|
|
|
|
|
|
|
|
it('lands the anchor pin on its solver target from a wrong position', () => {
|
|
|
|
|
// Simulate the backend delivering an approximate x/y: drop the resistor
|
|
|
|
|
// 27 px off from where pin 1 should sit on hole 10t.b.
|
|
|
|
|
const bb = bbAt(0, 0);
|
|
|
|
|
const a = holeElem('10t.b');
|
|
|
|
|
const targetWorld = { x: bb.x + INSET + a.x, y: bb.y + INSET + a.y };
|
|
|
|
|
const comp = { id: 'res1', metadataId: 'resistor', x: targetWorld.x - 27, y: targetWorld.y + 13, properties: {} };
|
|
|
|
|
|
|
|
|
|
const pos = resolveSeatPosition(comp as never, 'bb1', '1', a.x, a.y, [bb, comp] as never)!;
|
|
|
|
|
expect(pos).not.toBeNull();
|
|
|
|
|
// Pin 1 (offset 0,5.65 at rotation 0) now sits on the anchor target.
|
|
|
|
|
expect(pos.x + INSET + RES_PIN_INFO[0].x).toBeCloseTo(targetWorld.x, 6);
|
|
|
|
|
expect(pos.y + INSET + RES_PIN_INFO[0].y).toBeCloseTo(targetWorld.y, 6);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('honours a sub-pitch anchor offset instead of snapping to a hole centre', () => {
|
|
|
|
|
// The solver shifts off-lattice parts; the anchor target is deliberately
|
|
|
|
|
// 2.4 px off a hole. The resolver must reproduce that, not re-centre it.
|
|
|
|
|
const bb = bbAt(0, 0);
|
|
|
|
|
const a = holeElem('10t.b');
|
|
|
|
|
const shifted = { x: a.x + 2.4, y: a.y };
|
|
|
|
|
const comp = { id: 'res1', metadataId: 'resistor', x: 500, y: 500, properties: {} };
|
|
|
|
|
const pos = resolveSeatPosition(comp as never, 'bb1', '1', shifted.x, shifted.y, [bb, comp] as never)!;
|
|
|
|
|
expect(pos.x + INSET + RES_PIN_INFO[0].x).toBeCloseTo(bb.x + INSET + shifted.x, 6);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('is a no-op when the part is already at its solver position', () => {
|
|
|
|
|
const bb = bbAt(50, 60);
|
|
|
|
|
const a = holeElem('20t.c');
|
|
|
|
|
const comp = {
|
|
|
|
|
id: 'res1', metadataId: 'resistor',
|
|
|
|
|
x: bb.x + a.x - RES_PIN_INFO[0].x,
|
|
|
|
|
y: bb.y + a.y - RES_PIN_INFO[0].y,
|
|
|
|
|
properties: {},
|
|
|
|
|
};
|
|
|
|
|
const pos = resolveSeatPosition(comp as never, 'bb1', '1', a.x, a.y, [bb, comp] as never)!;
|
|
|
|
|
expect(pos.x).toBeCloseTo(comp.x, 6);
|
|
|
|
|
expect(pos.y).toBeCloseTo(comp.y, 6);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('returns null for a non-breadboard target', () => {
|
|
|
|
|
const bb = bbAt(0, 0);
|
|
|
|
|
const comp = { id: 'res1', metadataId: 'resistor', x: 0, y: 0, properties: {} };
|
|
|
|
|
expect(resolveSeatPosition(comp as never, 'res1', '1', 0, 0, [bb, comp] as never)).toBeNull();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('correction then updateComponent produces the invisible bb wires', () => {
|
|
|
|
|
// End-to-end of the agent path (minus the real rotation pivot): place off,
|
|
|
|
|
// correct, apply — the store should then seat both pins.
|
|
|
|
|
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([]);
|
|
|
|
|
const a = holeElem('5t.a');
|
|
|
|
|
const pos = resolveSeatPosition(
|
|
|
|
|
useSimulatorStore.getState().components.find((c) => c.id === 'res1')! as never,
|
|
|
|
|
'bb1', '1', a.x, a.y,
|
|
|
|
|
useSimulatorStore.getState().components as never,
|
|
|
|
|
)!;
|
|
|
|
|
useSimulatorStore.getState().updateComponent('res1', pos);
|
|
|
|
|
const bbWires = useSimulatorStore.getState().wires.filter((w) => w.bb);
|
|
|
|
|
expect(bbWires).toHaveLength(2);
|
|
|
|
|
expect(bbWires.map((w) => w.end.pinName).sort()).toEqual(['11t.a', '5t.a']);
|
|
|
|
|
});
|
|
|
|
|
});
|