Loading files
This file is pinned to the selected release. Download the original to open it in your design tool.
import { describe, it, expect } 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.
const intent = { ...chainGroup(12), 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.
let seen = 0;
let nudgedOnce = false;
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'];
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) {
expect(len).toBeGreaterThan(STUB - 0.001);
expect(len).toBeLessThanOrEqual(STUB + 8); // MAX_LABEL_NUDGE
}
if (lengths.some((len) => len > STUB + 0.001)) nudgedOnce = true;
}
// the search stops at the first nudging board; every board before it
// was checked for the attachment invariant too
expect(seen).toBeGreaterThan(0);
expect(nudgedOnce, 'no seeded intent produced a nudge').toBe(true);
}, 120000);
});
c62f54059This file is pinned to the selected release. Download the original to open it in your design tool.