Loading files
This file is pinned to the selected release. Download the original to open it in your design tool.
29e44291cThis file is pinned to the selected release. Download the original to open it in your design tool.
import { describe, it, expect, vi } from 'vitest';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { mkdtemp, mkdir, cp, rm, readFile, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { SymbolSource } from '../src/kicad/draft/symsource.js';
import { validateIntent, type SchematicIntent } from '../src/kicad/draft/ir.js';
import { draftSchematicPlacement } from '../src/kicad/draft/engine.js';
import { draftSchematic } from '../src/kicad/draft/draft.js';
import { checkLegibility } from '../src/kicad/legibility.js';
/**
* Sheet-level layout passes: shelf-wrap, stacked-pin collapse, power-value
* placement, and label nudging. These run on the placement model rather than
* the emitted bytes, so a failure names the geometry that moved instead of
* pointing at a diff. The byte contract itself lives in draft-reference-boards.test.ts.
*/
const HERE = path.dirname(fileURLToPath(import.meta.url));
const SYMLIB = path.join(HERE, 'fixtures', 'symlib');
const CONTROL = path.join(HERE, '..', 'manual-tests', 'reference-boards');
const U = 1.27;
/** Default stub length, grid units (engine STUB). */
const STUB = 2;
/** Place an intent without touching the schematic on disk. */
async function place(intent: SchematicIntent, docsDir: string | null = null) {
const repo = await mkdtemp(path.join(tmpdir(), 'copperhead-layout-'));
try {
const symsource = new SymbolSource(repo, [SYMLIB]);
const v = await validateIntent(intent, symsource, docsDir);
expect(v.ok, v.findings.map((f) => f.detail).join('; ')).toBe(true);
const { model, report } = draftSchematicPlacement(v.validated!, 'board', '2020-01-01');
return { model, report, symbols: v.validated!.symbols };
} finally {
await rm(repo, { recursive: true, force: true });
}
}
/** N single-MCU subsystems in declared order: the ribbon shelf-wrap reflows. */
function ribbon(n: number): SchematicIntent {
const parts = [];
const nets = [];
const noConnect: string[] = [];
for (let i = 1; i <= n; i++) {
const group = `G${String(i).padStart(2, '0')}`;
parts.push({ ref: `U${i}`, libId: 'CopperMCU:MCU8', value: 'MCU8', group });
parts.push({ ref: `R${i}`, libId: 'Device:R', value: '10k', group });
nets.push({ name: `SIG${i}`, pins: [`U${i}.4`, `R${i}.1`] });
nets.push({ name: `OUT${i}`, pins: [`U${i}.5`, `R${i}.2`] });
for (const p of ['3', '6', '7', '8']) noConnect.push(`U${i}.${p}`);
}
nets.push({ name: 'VCC', pins: Array.from({ length: n }, (_, i) => `U${i + 1}.1`) });
nets.push({ name: 'GND', pins: Array.from({ length: n }, (_, i) => `U${i + 1}.2`) });
return { version: 1, parts, nets, noConnect };
}
/** Group boxes bucketed into rows by top edge, each row left-to-right. */
function rowsOf(rects: { x1: number; y1: number; name?: string }[]): string[][] {
const rows = new Map<number, { x: number; name: string }[]>();
for (const r of rects) {
const key = Math.round(r.y1);
rows.set(key, [...(rows.get(key) ?? []), { x: r.x1, name: r.name ?? '' }]);
}
return [...rows.entries()]
.sort((a, b) => a[0] - b[0])
.map(([, row]) => row.sort((a, b) => a.x - b.x).map((g) => g.name));
}
describe('shelf-wrap: the group ribbon reflows into rows (design D12)', () => {
it('wraps a long ribbon into rows, preserves reading order, and fits a smaller sheet', async () => {
const { model, report } = await place(ribbon(8));
// how many rows depends on how wide a group draws (the engine reserves
// text at paper width, so a group carries its rail names and its fields
// in its footprint); what must hold is that it wrapped, and that the
// rows read in declared order, left to right then top to bottom
const wrapNote = report.notes.find((n) => /^groups wrapped onto \d+ rows to fit the sheet$/.test(n));
expect(wrapNote, report.notes.join('; ')).toBeDefined();
const rows = rowsOf(model.rectangles);
expect(rows.length).toBe(Number(/\d+/.exec(wrapNote!)![0]));
expect(rows.length).toBeGreaterThanOrEqual(2);
expect(rows.flat()).toEqual(['G01', 'G02', 'G03', 'G04', 'G05', 'G06', 'G07', 'G08']);
// the point of wrapping: eight groups in a single row is a ~9:1 ribbon that
// forces a huge sheet; wrapped, the same content fits A4
expect(report.paper).toBe('A4');
const totalGroupWidth = model.rectangles.reduce((s, r) => s + (r.x2 - r.x1), 0);
expect(totalGroupWidth).toBeGreaterThan(297);
// rows are rows: no group box overlaps another
for (let i = 0; i < model.rectangles.length; i++) {
for (let j = i + 1; j < model.rectangles.length; j++) {
const a = model.rectangles[i]!;
const b = model.rectangles[j]!;
const overlaps = a.x1 < b.x2 && a.x2 > b.x1 && a.y1 < b.y2 && a.y2 > b.y1;
expect(overlaps, `${a.name} overlaps ${b.name}`).toBe(false);
}
}
});
it('leaves a layout that already fits one row on one row, unwrapped and unannotated', async () => {
const { model, report } = await place(ribbon(2));
expect(report.notes).toEqual([]);
expect(rowsOf(model.rectangles)).toEqual([['G01', 'G02']]);
});
it('wraps to the hinted paper width and keeps the hint', async () => {
const { model, report } = await place({ ...ribbon(6), hints: { paper: 'A3' } });
expect(report.paper).toBe('A3');
// A3 is wider than the content-derived A4, so the same six groups need
// fewer rows than they did without the hint
expect(rowsOf(model.rectangles).length).toBeLessThanOrEqual(2);
});
it('notes an unknown paper hint and derives the sheet from content instead', async () => {
const { report } = await place({ ...ribbon(2), hints: { paper: 'B7' } });
expect(report.notes).toContain('paper hint "B7" is not a standard size; deriving paper from content');
expect(report.paper).toBe('A5');
});
it('drafts an intent with nothing to place instead of throwing', async () => {
const { model, report } = await place({ version: 1, parts: [], nets: [] });
expect(model.rectangles).toEqual([]);
expect(model.symbols).toEqual([]);
expect(report.paper).toBe('A5');
});
it('a wrapped sheet still passes the legibility gate', async () => {
const repo = await mkdtemp(path.join(tmpdir(), 'copperhead-wrap-'));
try {
const intent = ribbon(8);
await mkdir(path.join(repo, 'docs'), { recursive: true });
await writeFile(
path.join(repo, 'docs', 'SUBSYSTEMS.md'),
['# Subsystems', ...[...new Set(intent.parts.map((p) => p.group))].map((g) => `\n## ${g}\n\nBlock ${g}.`)].join('\n'),
'utf8',
);
await writeFile(
path.join(repo, 'docs', 'BOM.md'),
['# BOM', '', '| Refdes | Value | Footprint | MPN | Rationale |', '| --- | --- | --- | --- | --- |',
...intent.parts.map((p) => `| ${p.ref} | ${p.value} | | UNVERIFIED | fixture |`)].join('\n'),
'utf8',
);
await writeFile(path.join(repo, 'schematic.intent.json'), JSON.stringify(intent, null, 2), 'utf8');
const res = await draftSchematic({
repoRoot: repo,
schematic: 'board.kicad_sch',
docsDir: 'docs',
symbolDirs: [SYMLIB],
});
expect(res.ok, res.ok ? '' : res.message).toBe(true);
if (!res.ok) return;
const leg = await checkLegibility(res.schematicPath, { docsDir: path.join(repo, 'docs') });
expect(leg.findings.filter((f) => f.severity === 'error')).toEqual([]);
} finally {
await rm(repo, { recursive: true, force: true });
}
}, 30000);
});
/** N MCUs chained U1.5 -> U2.4, U2.5 -> U3.4, ... in ONE group: each signal
* edge pushes layer depth forward, so the group tiles as one column per part —
* a horizontal ribbon that outgrows every sheet long before the netlist does. */
function chainGroup(n: number, group = 'MAIN', netName: (i: number) => string = (i) => `SIG${i}`): SchematicIntent {
const parts = [];
const nets = [];
const noConnect: string[] = [];
for (let i = 1; i <= n; i++) {
parts.push({ ref: `U${i}`, libId: 'CopperMCU:MCU8', value: 'MCU8', group });
for (const p of ['3', '6', '7', '8']) noConnect.push(`U${i}.${p}`);
if (i > 1) nets.push({ name: netName(i), pins: [`U${i - 1}.5`, `U${i}.4`] });
}
noConnect.push('U1.4', `U${n}.5`);
nets.push({ name: 'VCC', pins: Array.from({ length: n }, (_, i) => `U${i + 1}.1`) });
nets.push({ name: 'GND', pins: Array.from({ length: n }, (_, i) => `U${i + 1}.2`) });
return { version: 1, parts, nets, noConnect };
}
describe('column banding: a group wider than every sheet wraps into bands (#219)', () => {
const PAPER_DIMS: Record<string, { w: number; h: number }> = {
A5: { w: 210, h: 148 }, A4: { w: 297, h: 210 }, A3: { w: 420, h: 297 },
A2: { w: 594, h: 420 }, A1: { w: 841, h: 594 }, A0: { w: 1189, h: 841 },
};
const FRAME = 10;
it('bands an oversized single group instead of overflowing the frame, and keeps the paper small', async () => {
// 40 chained parts tile naturally to ~1610 mm — wider than A0's usable
// frame. Before banding this drew an A0 whose content ran 430 mm past the
// right edge: a sheet that would not plot (issue #219, 367 findings).
const { model, report } = await place(chainGroup(40));
// 5 bands, not 4: the band budget reserves the group's facing-label
// extents so edge labels stay inside the frame (#220 phase 1).
expect(report.notes).toContain('group "MAIN" was wider than the sheet; its columns wrapped onto 5 bands');
expect(report.paper).toBe('A3');
expect(report.mergedNets).toEqual([]);
const paper = PAPER_DIMS[model.paper]!;
for (const r of model.rectangles) {
expect(r.x1, `${r.name} left of frame`).toBeGreaterThanOrEqual(FRAME);
expect(r.y1, `${r.name} above frame`).toBeGreaterThanOrEqual(FRAME);
expect(r.x2, `${r.name} past right frame edge`).toBeLessThanOrEqual(paper.w - FRAME);
expect(r.y2, `${r.name} past bottom frame edge`).toBeLessThanOrEqual(paper.h - FRAME);
}
for (const s of model.symbols) {
expect(s.at.x, `${s.ref} outside the frame`).toBeGreaterThanOrEqual(FRAME);
expect(s.at.x, `${s.ref} outside the frame`).toBeLessThanOrEqual(paper.w - FRAME);
}
});
it('never bands a layout that already fits a sheet', async () => {
const { report } = await place(chainGroup(4));
expect(report.notes).toEqual([]);
});
it('bands the oversized group while small groups still shelf-wrap around it', async () => {
const wide = chainGroup(30);
const intent: SchematicIntent = {
version: 1,
parts: [
...wide.parts,
{ ref: 'U101', libId: 'CopperMCU:MCU8', value: 'MCU8', group: 'AUX1' },
{ ref: 'U102', libId: 'CopperMCU:MCU8', value: 'MCU8', group: 'AUX2' },
],
nets: [
...wide.nets,
{ name: 'AUX', pins: ['U101.5', 'U102.4'] },
],
noConnect: [
...(wide.noConnect ?? []),
...['1', '2', '3', '4', '6', '7', '8'].map((p) => `U101.${p}`),
...['1', '2', '3', '5', '6', '7', '8'].map((p) => `U102.${p}`),
].filter((e) => !['U101.5', 'U102.4'].includes(e)),
};
const { model, report } = await place(intent);
expect(report.notes.some((n) => /group "MAIN" was wider than the sheet; its columns wrapped onto \d+ bands/.test(n))).toBe(true);
expect(report.notes.some((n) => /group "AUX\d" was wider/.test(n))).toBe(false);
const paper = PAPER_DIMS[model.paper]!;
for (const r of model.rectangles) {
expect(r.x2, `${r.name} past right frame edge`).toBeLessThanOrEqual(paper.w - FRAME);
expect(r.y2, `${r.name} past bottom frame edge`).toBeLessThanOrEqual(paper.h - FRAME);
}
for (let i = 0; i < model.rectangles.length; i++) {
for (let j = i + 1; j < model.rectangles.length; j++) {
const a = model.rectangles[i]!;
const b = model.rectangles[j]!;
const overlaps = a.x1 < b.x2 && a.x2 > b.x1 && a.y1 < b.y2 && a.y2 > b.y1;
expect(overlaps, `${a.name} overlaps ${b.name}`).toBe(false);
}
}
});
it('a banded sheet passes the legibility gate with zero out-of-frame findings', async () => {
const repo = await mkdtemp(path.join(tmpdir(), 'copperhead-band-'));
try {
const intent = chainGroup(40);
await mkdir(path.join(repo, 'docs'), { recursive: true });
await writeFile(
path.join(repo, 'docs', 'SUBSYSTEMS.md'),
['# Subsystems', ...[...new Set(intent.parts.map((p) => p.group))].map((g) => `\n## ${g}\n\nBlock ${g}.`)].join('\n'),
'utf8',
);
await writeFile(
path.join(repo, 'docs', 'BOM.md'),
['# BOM', '', '| Refdes | Value | Footprint | MPN | Rationale |', '| --- | --- | --- | --- | --- |',
...intent.parts.map((p) => `| ${p.ref} | ${p.value} | | UNVERIFIED | fixture |`)].join('\n'),
'utf8',
);
await writeFile(path.join(repo, 'schematic.intent.json'), JSON.stringify(intent, null, 2), 'utf8');
const res = await draftSchematic({
repoRoot: repo,
schematic: 'board.kicad_sch',
docsDir: 'docs',
symbolDirs: [SYMLIB],
});
expect(res.ok, res.ok ? '' : res.message).toBe(true);
if (!res.ok) return;
const leg = await checkLegibility(res.schematicPath, { docsDir: path.join(repo, 'docs') });
// the family cap shows at most 10 findings per family; assert on the
// suppressed ledger too so 367-finding regressions cannot hide behind it
expect(leg.findings.filter((f) => f.kind === 'out-of-frame')).toEqual([]);
expect(leg.suppressed.filter((s) => s.family === 'out-of-frame')).toEqual([]);
expect(leg.findings.filter((f) => f.severity === 'error')).toEqual([]);
} finally {
await rm(repo, { recursive: true, force: true });
}
}, 30000);
it('long edge-facing net names stay inside the frame on a banded sheet (#220 phase 1)', async () => {
// stickhub's survivor after banding: the leftmost column's left-facing
// label text was not counted anywhere, so on a sheet banded near the full
// usable width it overhung the left frame edge. Long anonymous-style
// names make the extent big enough to reproduce that here.
const repo = await mkdtemp(path.join(tmpdir(), 'copperhead-band-edge-'));
try {
const intent = chainGroup(40, 'MAIN', (i) => `Net-(D${i}-PADA)`);
await mkdir(path.join(repo, 'docs'), { recursive: true });
await writeFile(
path.join(repo, 'docs', 'SUBSYSTEMS.md'),
['# Subsystems', ...[...new Set(intent.parts.map((p) => p.group))].map((g) => `\n## ${g}\n\nBlock ${g}.`)].join('\n'),
'utf8',
);
await writeFile(
path.join(repo, 'docs', 'BOM.md'),
['# BOM', '', '| Refdes | Value | Footprint | MPN | Rationale |', '| --- | --- | --- | --- | --- |',
...intent.parts.map((p) => `| ${p.ref} | ${p.value} | | UNVERIFIED | fixture |`)].join('\n'),
'utf8',
);
await writeFile(path.join(repo, 'schematic.intent.json'), JSON.stringify(intent, null, 2), 'utf8');
const res = await draftSchematic({
repoRoot: repo,
schematic: 'board.kicad_sch',
docsDir: 'docs',
symbolDirs: [SYMLIB],
});
expect(res.ok, res.ok ? '' : res.message).toBe(true);
if (!res.ok) return;
const leg = await checkLegibility(res.schematicPath, { docsDir: path.join(repo, 'docs') });
expect(leg.findings.filter((f) => f.kind === 'out-of-frame')).toEqual([]);
expect(leg.suppressed.filter((s) => s.family === 'out-of-frame')).toEqual([]);
} finally {
await rm(repo, { recursive: true, force: true });
}
}, 30000);
});
describe('passive banks chain on one trunk (#233, #220 phase 3)', () => {
/** An IC with three decoupling caps on VCC/GND: the bank idiom's minimum. */
const banked: SchematicIntent = {
version: 1,
parts: [
{ ref: 'U1', libId: 'CopperMCU:MCU8', value: 'MCU8', group: 'MAIN' },
{ ref: 'C1', libId: 'Device:C', value: '100n', group: 'MAIN' },
{ ref: 'C2', libId: 'Device:C', value: '100n', group: 'MAIN' },
{ ref: 'C3', libId: 'Device:C', value: '100n', group: 'MAIN' },
],
nets: [
{ name: 'VCC', pins: ['U1.1', 'C1.1', 'C2.1', 'C3.1'] },
{ name: 'GND', pins: ['U1.2', 'C1.2', 'C2.2', 'C3.2'] },
{ name: 'SIG', pins: ['U1.4', 'U1.5'] },
],
noConnect: ['U1.3', 'U1.6', 'U1.7', 'U1.8'],
};
it('a three-cap bank carries one rail symbol and one ground symbol, not three of each', async () => {
const { model, report } = await place(banked);
expect(report.mergedNets).toEqual([]);
const pwr = model.symbols.filter((s) => s.libId.startsWith('copperhead_power:') && s.value !== 'PWR_FLAG');
// U1's own VCC/GND pins keep their symbols; the bank shares ONE per side
expect(pwr.filter((s) => s.value === 'VCC').length).toBe(2);
expect(pwr.filter((s) => s.value === 'GND').length).toBe(2);
// interior stub ends meet the trunk with junction dots
expect(model.junctions.length).toBeGreaterThanOrEqual(2);
// the trunk joins consecutive stub ends: every cap pin reaches the rail
const capXs = model.symbols.filter((s) => /^C\d$/.test(s.ref)).map((s) => s.at.x).sort((a, b) => a - b);
// the trunk is the horizontal VCC line with 2+ collinear segments (U1's
// own horizontal stub is a lone segment on its own row)
const horiz = model.wires.filter((w) => w.net === 'VCC' && w.y1 === w.y2);
const trunkYval = horiz.map((w) => w.y1).find((y, _, ys) => ys.filter((v) => v === y).length >= 2)!;
const trunk = horiz.filter((w) => w.y1 === trunkYval);
expect(trunk.length).toBeGreaterThanOrEqual(2);
expect(Math.min(...trunk.map((w) => Math.min(w.x1, w.x2)))).toBeCloseTo(capXs[0]!, 5);
expect(Math.max(...trunk.map((w) => Math.max(w.x1, w.x2)))).toBeCloseTo(capXs[2]!, 5);
});
/** Every wired-net label must sit on a wire of ITS OWN net: KiCad attaches a
* local label only where it lies on a wire, so a label anchored anywhere else
* leaves the net carrying whatever name KiCad invents for it. */
function assertLabelsOnTheirOwnWires(model: {
labels: { name: string; x: number; y: number }[];
wires: { x1: number; y1: number; x2: number; y2: number; net: string }[];
}): void {
const E = 0.005;
const on = (x: number, y: number, w: { x1: number; y1: number; x2: number; y2: number }): boolean =>
x >= Math.min(w.x1, w.x2) - E &&
x <= Math.max(w.x1, w.x2) + E &&
y >= Math.min(w.y1, w.y2) - E &&
y <= Math.max(w.y1, w.y2) + E &&
(Math.abs(w.x1 - w.x2) < E ? Math.abs(x - w.x1) < E : Math.abs(y - w.y1) < E);
for (const l of model.labels) {
const own = model.wires.filter((w) => w.net === l.name);
if (!own.length) continue; // a labelled stub the router never wired
expect(own.some((w) => on(l.x, l.y, w)), `label ${l.name} at (${l.x}, ${l.y}) sits on no wire of its own net`).toBe(
true,
);
}
}
it('every wired-net label sits on a wire of its own net', async () => {
// The de-collision passes may move a wired label anywhere along its run,
// and one of them walks the run's interior points. Walking the run's
// POINTS rather than its SEGMENTS interpolates between corners that share
// no wire, which anchors the label in open sheet and silently drops the
// net's name; this asserts the property those passes must preserve.
for (const intent of [banked, chainGroup(6), ribbon(4)]) {
const { model } = await place(intent);
assertLabelsOnTheirOwnWires(model);
}
});
it('a hinted sheet too small for the content overflows loudly, budgets and all', async () => {
// The last resort: the hint pins one candidate, the budgeted retries all
// fail, and the engine places on it anyway rather than silently choosing
// another sheet — with a note naming the overflow, which is the only
// honest output when the person asked for a sheet the drawing cannot fit.
// 24 stages: since rows tightened (AC-16.55) twelve fit A5 once banded
const intent = { ...chainGroup(24), hints: { paper: 'A5' } } as SchematicIntent;
const { report } = await place(intent);
expect(report.paper).toBe('A5');
expect(
report.notes.some((n) => /does not fit the hinted sheet \(A5\).*will overflow the frame/.test(n)),
report.notes.join('; '),
).toBe(true);
});
it('a two-pin part that is not a capacitor joins the decoupling row', async () => {
// The reduction is structural, not name-based: what makes a part a
// decoupling element is a rail on one pin, ground on the other, and an
// owner IC on the same rail — never its lib_id. Real boards carry their
// caps under embedded, renamed ids no name test matches, and a rail-clamp
// part drawn beside them is how a hand-drawn sheet shows it too.
const intent: SchematicIntent = {
version: 1,
parts: [
{ ref: 'U1', libId: 'CopperMCU:MCU8', value: 'MCU8', group: 'MAIN' },
{ ref: 'C1', libId: 'Device:C', value: '100n', group: 'MAIN' },
{ ref: 'C2', libId: 'Device:C', value: '100n', group: 'MAIN' },
{ ref: 'R9', libId: 'Device:R', value: '10k', group: 'MAIN' },
],
nets: [
{ name: 'VCC', pins: ['U1.1', 'C1.1', 'C2.1', 'R9.1'] },
{ name: 'GND', pins: ['U1.2', 'C1.2', 'C2.2', 'R9.2'] },
{ name: 'SIG', pins: ['U1.4', 'U1.5'] },
],
noConnect: ['U1.3', 'U1.6', 'U1.7', 'U1.8'],
};
const { model } = await place(intent);
const at = (ref: string) => model.symbols.find((s) => s.ref === ref)!.at;
expect(at('R9').y).toBeCloseTo(at('C1').y, 5);
expect(at('C2').y).toBeCloseTo(at('C1').y, 5);
// and the row sits under the circuit, not in it
expect(at('C1').y).toBeGreaterThan(at('U1').y);
});
it('a measurement node named like a supply is not drafted as a rail', async () => {
// The name fallback fires only where no pin's electrical type attests
// anything, and it is narrow in one direction on purpose: a rail read as a
// signal draws labels, while a signal read as a rail makes the sheet assert
// a supply the design does not have. VBUS_DET is a divider tap on a real
// board; VDD_3V3 beside it is the rail that feeds it.
const intent: SchematicIntent = {
version: 1,
parts: [
{ ref: 'R1', libId: 'Device:R', value: '100k', group: 'MAIN' },
{ ref: 'R2', libId: 'Device:R', value: '100k', group: 'MAIN' },
],
nets: [
{ name: 'VDD_3V3', pins: ['R1.1', 'R2.1'] },
{ name: 'VBUS_DET', pins: ['R1.2', 'R2.2'] },
],
};
const { model, report } = await place(intent);
const classOf = (n: string) => report.netClasses.find((c) => c.name === n);
expect(classOf('VDD_3V3')).toEqual({ name: 'VDD_3V3', class: 'rail', overridden: false, basis: 'name' });
expect(classOf('VBUS_DET')).toEqual({ name: 'VBUS_DET', class: 'signal', overridden: false, basis: 'name' });
// the rail gets power symbols and no label; the tap gets labels and no bar
expect(model.symbols.some((s) => s.value === 'VDD_3V3' && s.libId.startsWith('copperhead_power:'))).toBe(true);
expect(model.symbols.some((s) => s.value === 'VBUS_DET' && s.libId.startsWith('copperhead_power:'))).toBe(false);
});
it('a supply-named net with only passive pins still drafts as power, not labels', async () => {
// stickhub carries GND on embedded symbols whose pins are all `passive`:
// 80 pins, no etype evidence anywhere, and it drafted as 80 labels with
// zero ground bars. Only the name says it is a supply.
const intent: SchematicIntent = {
version: 1,
parts: [
{ ref: 'R1', libId: 'Device:R', value: '10k', group: 'MAIN' },
{ ref: 'R2', libId: 'Device:R', value: '10k', group: 'MAIN' },
],
nets: [
{ name: '5V0', pins: ['R1.1', 'R2.1'] },
{ name: 'GNDD', pins: ['R1.2', 'R2.2'] },
],
};
const { model } = await place(intent);
expect(model.labels.filter((l) => l.name === '5V0' || l.name === 'GNDD')).toEqual([]);
expect(model.symbols.some((s) => s.value === '5V0' && s.libId.startsWith('copperhead_power:'))).toBe(true);
expect(model.symbols.some((s) => s.value === 'GNDD' && s.libId.startsWith('copperhead_power:'))).toBe(true);
});
});
describe('compaction: a mostly-empty sheet reflows onto a smaller one (#220 phase 4)', () => {
/** N parts sharing only power rails: every one lands at the same layer
* depth, so the group naturally stacks into ONE full-height column, the
* shape that made interf_u "fit" A1 while the person drew it on A3. */
function stack(n: number): SchematicIntent {
const parts = [];
const noConnect: string[] = [];
for (let i = 1; i <= n; i++) {
parts.push({ ref: `U${i}`, libId: 'CopperMCU:MCU8', value: 'MCU8', group: 'MAIN' });
for (const p of ['3', '4', '5', '6', '7', '8']) noConnect.push(`U${i}.${p}`);
}
const nets = [
{ name: 'VCC', pins: Array.from({ length: n }, (_, i) => `U${i + 1}.1`) },
{ name: 'GND', pins: Array.from({ length: n }, (_, i) => `U${i + 1}.2`) },
];
return { version: 1, parts, nets, noConnect };
}
it('splits the tall column and takes the smallest sheet that holds it', async () => {
const { model, report } = await place(stack(16));
// The in-group balance already re-rows the 16-cell strip into columns
// no taller than the group's square side, so the natural layout lands
// on a small sheet by itself; the paper pass then either compacts it
// further or says why it could not. Either way the choice is explained.
expect(report.notes.some((n) => /^sheet (not )?compacted:/.test(n)), report.notes.join('; ')).toBe(true);
// the single 16-cell column would span well past A3's usable height, so
// an unbalanced, uncompacted fit needs a large sheet; the reflowed block
// must land on something smaller than A2 and stay inside the frame
expect(['A5', 'A4', 'A3']).toContain(report.paper);
expect(report.mergedNets).toEqual([]);
const PAPER_DIMS: Record<string, { w: number; h: number }> = {
A5: { w: 210, h: 148 }, A4: { w: 297, h: 210 }, A3: { w: 420, h: 297 },
};
const paper = PAPER_DIMS[model.paper]!;
for (const s of model.symbols) {
expect(s.at.x, `${s.ref} outside the frame`).toBeGreaterThanOrEqual(10);
expect(s.at.x, `${s.ref} outside the frame`).toBeLessThanOrEqual(paper.w - 10);
expect(s.at.y, `${s.ref} outside the frame`).toBeGreaterThanOrEqual(10);
expect(s.at.y, `${s.ref} outside the frame`).toBeLessThanOrEqual(paper.h - 10);
}
});
it('leaves a well-filled sheet exactly as it was', async () => {
// the 4-part chain fits its natural sheet with healthy utilization: no
// compaction note, and the layout must not move
const { report } = await place(chainGroup(4));
expect(report.notes.some((n) => /^sheet compacted:/.test(n))).toBe(false);
});
});
describe('stacked pins are one point on the sheet', () => {
/** PWRIC repeats its GND pin (2 and 4) at one coordinate, thermal-pad style. */
const stacked: SchematicIntent = {
version: 1,
parts: [
{ ref: 'U1', libId: 'CopperStack:PWRIC', value: 'PWRIC', group: 'Reg' },
{ ref: 'J1', libId: 'CopperConn:Conn_01x03', value: 'Conn_01x03', group: 'Reg' },
],
nets: [
{ name: 'VIN', pins: ['U1.1', 'J1.1'] },
{ name: 'GND', pins: ['U1.2', 'U1.4', 'J1.2'] },
{ name: 'VOUT', pins: ['U1.3', 'J1.3'] },
],
};
it('draws one stub, one power symbol, and one value at a repeated pin coordinate', async () => {
const { model } = await place(stacked);
const pwr = model.symbols.filter((s) => s.ref.startsWith('#PWR'));
// three nets x two ENDPOINT POINTS each: U1's two GND pins share a point
// and collapse, so GND gets two symbols and not three
expect(pwr).toHaveLength(6);
expect(pwr.filter((s) => s.value === 'GND')).toHaveLength(2);
const at = (s: (typeof pwr)[number]) => `${s.at.x},${s.at.y}`;
expect(new Set(pwr.map(at)).size).toBe(pwr.length);
// and one stub per symbol: no wire drawn twice on top of itself
const wireKeys = model.wires.map((w) => `${w.x1},${w.y1},${w.x2},${w.y2}`);
expect(new Set(wireKeys).size).toBe(wireKeys.length);
expect(model.wires).toHaveLength(6);
});
it('keeps the collapsed point connected to both stacked pins', async () => {
const { model, symbols } = await place(stacked);
const u1 = model.symbols.find((s) => s.ref === 'U1')!;
const pins = symbols.get('U1')!.pins;
const pin2 = pins.find((p) => p.number === '2')!;
const pin4 = pins.find((p) => p.number === '4')!;
const pointOf = (p: typeof pin2) => ({ x: u1.at.x + p.x, y: u1.at.y - p.y });
expect(pointOf(pin2)).toEqual(pointOf(pin4));
// the surviving stub leaves that shared point, so both pins stay on GND
const shared = pointOf(pin2);
// wire coordinates are file-rounded; the pin point is a raw subtraction
const near = (a: number, b: number) => Math.abs(a - b) < 0.005;
const stubs = model.wires.filter((w) => near(w.x1, shared.x) && near(w.y1, shared.y));
expect(stubs).toHaveLength(1);
expect(stubs[0]!.net).toBe('GND');
});
});
describe('power-symbol value text lands away from the part it serves', () => {
it('follows the stub outward rather than the net class', async () => {
// PWRIC carries GND and VOUT on DOWNWARD pins: the rail bar draws its text
// above by class, which would throw "VOUT" back across its own stub and
// onto the symbol body. The text follows the stub instead.
const { model } = await place({
version: 1,
parts: [
{ ref: 'U1', libId: 'CopperStack:PWRIC', value: 'PWRIC', group: 'Reg' },
{ ref: 'J1', libId: 'CopperConn:Conn_01x03', value: 'Conn_01x03', group: 'Reg' },
],
nets: [
{ name: 'VIN', pins: ['U1.1', 'J1.1'] },
{ name: 'GND', pins: ['U1.2', 'J1.2'] },
{ name: 'VOUT', pins: ['U1.3', 'J1.3'] },
],
});
const pwr = model.symbols.filter((s) => s.ref.startsWith('#PWR'));
expect(pwr.length).toBeGreaterThan(0);
for (const s of pwr) {
const stub = model.wires.find((w) => w.x2 === s.at.x && w.y2 === s.at.y);
expect(stub, `${s.ref} (${s.value}) has no stub`).toBeDefined();
if (!stub) continue;
if (stub.y1 === stub.y2) continue; // horizontal stub: text sits by class
// vertical stub: the text continues past the bar, never back toward the pin
expect(Math.sign(s.valueAt.y - s.at.y), `${s.ref} (${s.value}) text faces its pin`).toBe(
Math.sign(stub.y2 - stub.y1),
);
}
// the downward rail is the case that used to fail
const vout = pwr.find((s) => s.value === 'VOUT' && model.wires.some((w) => w.x2 === s.at.x && w.y2 === s.at.y && w.y2 > w.y1));
expect(vout, 'no downward VOUT stub in the fixture').toBeDefined();
expect(vout!.valueAt.y).toBeGreaterThan(vout!.at.y);
});
});
describe('label nudging keeps a stub label attached and clear', () => {
/** Small IC-with-passives intents, seeded so the search is deterministic. */
function seeded(seed: number): SchematicIntent {
let st = seed >>> 0;
const rnd = (): number => {
st = (st * 1664525 + 1013904223) >>> 0;
return st / 2 ** 32;
};
const nR = 2 + Math.floor(rnd() * 4);
const nC = 1 + Math.floor(rnd() * 3);
const parts = [
{ ref: 'U1', libId: 'CopperMCU:MCU8', value: 'MCU8', group: 'MAIN' },
{ ref: 'U2', libId: 'CopperMCU:MCU8', value: 'MCU8', group: 'AUX' },
];
for (let i = 1; i <= nR; i++) parts.push({ ref: `R${i}`, libId: 'Device:R', value: '10k', group: 'MAIN' });
for (let i = 1; i <= nC; i++) parts.push({ ref: `C${i}`, libId: 'Device:C', value: '100n', group: 'MAIN' });
const names = ['COMP', 'COMP_Z', 'FB', 'RT', 'BOOT', 'SW', 'EN', 'VSENSE', 'LED_A', 'DRIVE'];
const nets: { name: string; pins: string[] }[] = [];
const io = ['U1.4', 'U1.5', 'U1.6', 'U1.7', 'U1.8'];
const pool: string[] = [];
for (let i = 1; i <= nR; i++) pool.push(`R${i}.1`, `R${i}.2`);
for (let i = 1; i <= nC; i++) pool.push(`C${i}.1`, `C${i}.2`);
const gnd = ['U1.2', 'U2.2'];
const k = 3 + Math.floor(rnd() * 3);
for (let n = 0; n < k && pool.length >= 2; n++) {
const size = 2 + Math.floor(rnd() * 2);
const pins: string[] = [];
if (rnd() < 0.6 && io.length) pins.push(io.splice(Math.floor(rnd() * io.length), 1)[0]!);
while (pins.length < size && pool.length) pins.push(pool.splice(Math.floor(rnd() * pool.length), 1)[0]!);
if (pins.length >= 2) nets.push({ name: names[n % names.length]!, pins });
}
gnd.push(...pool);
nets.push({ name: 'GND', pins: gnd }, { name: 'VCC', pins: ['U1.1', 'U2.1'] });
return { version: 1, parts, nets, noConnect: ['U1.3', ...io, 'U2.3', 'U2.4', 'U2.5', 'U2.6', 'U2.7', 'U2.8'] };
}
it('rides a colliding label outward along its own stub', async () => {
// Nets draft in name order, so an earlier net's label cannot see the
// trunk a later net is about to run through it. The nudge pass fixes
// that after routing. Which small intent provokes the collision depends
// on every placement rule before the pass, so the test searches seeded
// intents for the first that nudges and checks the invariants on it;
// a placement change that leaves no nudge in a few hundred small boards
// would be a change to look at, and fails here. The nudge is read from
// the draft trace (a ride along the stub, or a turn of the label): stub
// lengths alone cannot tell it from the wire pass's own electrical rungs.
let seen = 0;
let nudgedOnce = false;
const traced: string[] = [];
const traceWas = process.env['COPPERHEAD_DRAFT_TRACE'];
process.env['COPPERHEAD_DRAFT_TRACE'] = '1';
const quiet = vi.spyOn(console, 'error').mockImplementation((...args: unknown[]) => {
traced.push(args.join(' '));
});
try {
for (let seed = 1; seed <= 400 && !nudgedOnce; seed++) {
const intent = seeded(seed);
const repo = await mkdtemp(path.join(tmpdir(), 'copperhead-nudge-'));
let model: Awaited<ReturnType<typeof place>>['model'];
let symbols: Awaited<ReturnType<typeof place>>['symbols'];
traced.length = 0;
try {
const symsource = new SymbolSource(repo, [SYMLIB]);
const v = await validateIntent(intent, symsource, null);
if (!v.ok) continue;
({ model, symbols } = { ...draftSchematicPlacement(v.validated!, 'board', '2020-01-01'), symbols: v.validated!.symbols });
} finally {
await rm(repo, { recursive: true, force: true });
}
seen++;
const pinPoints = new Set<string>();
for (const s of model.symbols) {
for (const p of symbols.get(s.ref)?.pins ?? []) pinPoints.add(`${s.at.x + p.x},${s.at.y - p.y}`);
}
const stubOf = (l: { x: number; y: number }) =>
model.wires.find((w) => w.x2 === l.x && w.y2 === l.y && pinPoints.has(`${w.x1},${w.y1}`));
const stubLabels = model.labels.map((l) => ({ l, w: stubOf(l) })).filter((e) => e.w !== undefined);
// every stub label is still an endpoint of the stub that starts at its
// pin: nudging extends the wire, it never detaches the label from it
const lengths = stubLabels.map((e) => Math.hypot(e.w!.x2 - e.w!.x1, e.w!.y2 - e.w!.y1) / U);
for (const len of lengths) {
// a stub may retreat to one unit (the last rung of the nudge), never
// shorter; and never past the nudge budget
expect(len).toBeGreaterThan(1 - 0.001);
expect(len).toBeLessThanOrEqual(STUB + 8); // MAX_LABEL_NUDGE
}
if (traced.some((l) => /^\[draft\] label .+: (rides its stub|turns to rotation)/.test(l))) nudgedOnce = true;
}
} finally {
quiet.mockRestore();
if (traceWas === undefined) delete process.env['COPPERHEAD_DRAFT_TRACE'];
else process.env['COPPERHEAD_DRAFT_TRACE'] = traceWas;
}
// every board up to the first that nudges was checked for the attachment
// invariant above; the nudge itself is the sentinel
expect(seen).toBeGreaterThan(0);
expect(nudgedOnce, 'no seeded intent produced a nudge').toBe(true);
}, 120000);
});