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, cp, rm, readFile, writeFile } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { draftSchematic, draftSchematicToText } from '../src/kicad/draft/draft.js';
import { SymbolSource } from '../src/kicad/draft/symsource.js';
import { uuidv5, renameSymbolBlock } from '../src/kicad/emit.js';
import { checkLegibility } from '../src/kicad/legibility.js';
import { listSymbols, pinNets } from '../src/kicad/sexp.js';
import { verifySchematicSymbols } from '../src/kicad/symlib.js';
import { runErc } from '../src/kicad/cli.js';
const HERE = path.dirname(fileURLToPath(import.meta.url));
const SYMLIB = path.join(HERE, 'fixtures', 'symlib');
const DRAFT_FIXTURE = path.join(HERE, 'fixtures', 'draft');
async function draftRepo(): Promise<{ repo: string; symDirs: string[]; cleanup: () => Promise<void> }> {
const repo = await mkdtemp(path.join(tmpdir(), 'copperhead-engine-'));
await cp(path.join(DRAFT_FIXTURE, 'schematic.intent.json'), path.join(repo, 'schematic.intent.json'));
await cp(path.join(DRAFT_FIXTURE, 'docs'), path.join(repo, 'docs'), { recursive: true });
return { repo, symDirs: [SYMLIB], cleanup: () => rm(repo, { recursive: true, force: true }) };
}
const draftOpts = (repo: string, symDirs: string[]) => ({
repoRoot: repo,
schematic: 'board.kicad_sch',
intentPath: 'schematic.intent.json',
docsDir: path.join(repo, 'docs'),
symbolDirs: symDirs,
});
async function mutateIntent(repo: string, fn: (intent: any) => void): Promise<void> {
const p = path.join(repo, 'schematic.intent.json');
const intent = JSON.parse(await readFile(p, 'utf8'));
fn(intent);
await writeFile(p, JSON.stringify(intent, null, 2), 'utf8');
}
describe('uuidv5 and emitter primitives', () => {
it('uuidv5 is stable and RFC-shaped', () => {
const a = uuidv5('sheet/R1/pin/2');
expect(a).toBe(uuidv5('sheet/R1/pin/2'));
expect(a).not.toBe(uuidv5('sheet/R1/pin/3'));
expect(a).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-5[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/);
});
it('renameSymbolBlock changes only the identifier', () => {
const block = '(symbol "R"\n\t(pin passive line (at 0 3.81 270))\n)';
const renamed = renameSymbolBlock(block, 'Device:R');
expect(renamed).toBe('(symbol "Device:R"\n\t(pin passive line (at 0 3.81 270))\n)');
});
it('renameSymbolBlock renames unit children with the parent (derived symbols)', () => {
// KiCad's schematic loader requires child sub-symbol names to carry the
// parent's prefix: a derived symbol emitted with its base's children
// (AMS1117-3.3 wrapping AP1117-15_0_1) fails to load outright.
const block = '(symbol "Base"\n\t(symbol "Base_0_1"\n\t\t(rectangle)\n\t)\n\t(symbol "Base_1_1"\n\t\t(pin)\n\t)\n)';
const renamed = renameSymbolBlock(block, 'Lib:Derived');
expect(renamed).toContain('(symbol "Lib:Derived"');
expect(renamed).toContain('(symbol "Derived_0_1"');
expect(renamed).toContain('(symbol "Derived_1_1"');
expect(renamed).not.toContain('Base');
});
});
describe('drafting engine: the reference IR end to end', () => {
it('drafts byte-identically, passes the legibility gate, and round-trips connectivity (AC-16.1, AC-16.3)', async () => {
const { repo, symDirs, cleanup } = await draftRepo();
try {
const res = await draftSchematic(draftOpts(repo, symDirs));
expect(res.ok).toBe(true);
if (!res.ok) return;
// byte-identical re-draft (AC-16.1 / AC-16.4)
const first = await readFile(res.schematicPath, 'utf8');
const again = await draftSchematic(draftOpts(repo, symDirs));
expect(again.ok).toBe(true);
expect(await readFile(res.schematicPath, 'utf8')).toBe(first);
// gate by construction (AC-16.3): zero error-severity legibility findings
const leg = await checkLegibility(res.schematicPath, { docsDir: path.join(repo, 'docs') });
expect(leg.findings.filter((f) => f.severity === 'error')).toEqual([]);
// pins on-grid by construction (AC-16.2) is the off-grid family; assert explicitly
expect(leg.findings.some((f) => f.kind === 'off-grid')).toBe(false);
// parse round-trip (AC-16.13): the inferred netlist matches the IR
const syms = await listSymbols(res.schematicPath);
expect(syms.map((s) => s.ref)).toEqual(['C1', 'J1', 'R1', 'R2', 'U1']);
const nets = new Map((await pinNets(res.schematicPath)).map((p) => [`${p.ref}.${p.pinNumber}`, p.net]));
expect(nets.get('J1.1')).toBe('VCC');
expect(nets.get('J1.2')).toBe('GND');
expect(nets.get('J1.3')).toBe('SIG_IN');
expect(nets.get('R1.1')).toBe('SIG_IN');
expect(nets.get('R1.2')).toBe('DIV');
expect(nets.get('R2.1')).toBe('DIV');
expect(nets.get('U1.3')).toBe('DIV');
expect(nets.get('C1.1')).toBe('VCC');
expect(nets.get('C1.2')).toBe('GND');
// power nets are never routed as spanning wires (AC-16.8): every VCC/GND
// wire is a short per-pin stub
const text = await readFile(res.schematicPath, 'utf8');
expect(res.report.pwrFlags).toEqual(['GND', 'VCC']);
expect(res.report.noConnects).toBe(5);
expect(text).toContain('(no_connect');
// DIV falls back to per-stub labels: every collision-free trunk for it
// contacts a foreign connection point (the median crosses R2's body, the
// right-of-everything branch runs through R2's GND stub end, the
// left-of-everything branch through R1's SIG_IN stub end). The old wired
// routing shipped exactly that contact — the pinned golden had DIV
// shorted to GND at R2.2's stub end (I22, #204) — so labelled is the
// correct drawing here, not a regression.
expect(res.report.netClasses.find((n) => n.name === 'DIV')?.class).toBe('signal');
expect(text.match(/\(label "DIV"/g) ?? []).toHaveLength(3);
} finally {
await cleanup();
}
}, 60000);
it('passes ERC clean on the drafted output (gate-resolvability, AC-16.9/AC-16.10/AC-16.12)', async () => {
const { repo, symDirs, cleanup } = await draftRepo();
try {
const res = await draftSchematic(draftOpts(repo, symDirs));
expect(res.ok).toBe(true);
if (!res.ok) return;
const erc = await runErc(res.schematicPath);
expect(erc.violations).toEqual([]);
expect(erc.ok).toBe(true);
} finally {
await cleanup();
}
}, 60000);
it('draft is deterministic across processes: no clock in the output', async () => {
const { repo, symDirs, cleanup } = await draftRepo();
try {
const res = await draftSchematic(draftOpts(repo, symDirs));
expect(res.ok).toBe(true);
if (!res.ok) return;
const text = await readFile(res.schematicPath, 'utf8');
// The title-block date is the pinned epoch stamp exactly — asserting the
// absence of the wall-clock year would prove nothing in most years and
// false-fail whenever a fixture legitimately contains the current one.
expect(text).toContain('(date "2020-01-01")');
expect(text.match(/\(date "[^"]*"\)/g)).toEqual(['(date "2020-01-01")']);
} finally {
await cleanup();
}
});
});
describe('IR validation fails structured and early', () => {
const failCase = (name: string, mutate: (intent: any) => void, expectDetail: RegExp) =>
it(name, async () => {
const { repo, symDirs, cleanup } = await draftRepo();
try {
// draft once so a previous schematic exists…
const first = await draftSchematic(draftOpts(repo, symDirs));
expect(first.ok).toBe(true);
const before = await readFile(path.join(repo, 'board.kicad_sch'), 'utf8');
await mutateIntent(repo, mutate);
const res = await draftSchematic(draftOpts(repo, symDirs));
expect(res.ok).toBe(false);
if (res.ok) return;
expect(res.findings.map((f) => f.detail).join('\n')).toMatch(expectDetail);
// …and prove a failed draft leaves it untouched (AC-16.6)
expect(await readFile(path.join(repo, 'board.kicad_sch'), 'utf8')).toBe(before);
} finally {
await cleanup();
}
}, 60000);
failCase(
'unknown pin is rejected with the real pin range (AC-16.6)',
(i) => i.nets.push({ name: 'X', pins: ['U1.99', 'R1.1'] }),
/U1 has no pin 99.*\[1, 2, 3, 4, 5, 6, 7, 8\]/,
);
failCase(
'BOM mismatch is rejected at validation (AC-16.7)',
(i) => (i.parts.find((p: any) => p.ref === 'R1').value = '22k'),
/R1 value "22k" differs from BOM.md's "10k"/,
);
failCase(
'ungrouped part is rejected with the available groups',
(i) => delete i.parts.find((p: any) => p.ref === 'R1').group,
/R1 has no group assignment.*Power, MCU/,
);
failCase(
'unknown group is rejected against SUBSYSTEMS.md',
(i) => (i.parts.find((p: any) => p.ref === 'R1').group = 'Flux'),
/names group "Flux".*not a SUBSYSTEMS.md heading/,
);
failCase(
'contradictory no-connect is rejected',
(i) => i.noConnect.push('R1.1'),
/R1.1 is declared no-connect but appears in a net/,
);
failCase(
'unsupported intent version is refused',
(i) => (i.version = 99),
/unsupported intent version 99/,
);
failCase(
'single-endpoint net is rejected',
(i) => i.nets.push({ name: 'LONELY', pins: ['U1.5'] }),
/LONELY has 1 endpoint\(s\)/,
);
});
describe('hermetic symbol vendoring (AC-16.5)', () => {
it('a library change does not move drafted bytes until the cache is refreshed; verify_symbols still sees the divergence', async () => {
const { repo, symDirs, cleanup } = await draftRepo();
try {
// vendor from a mutable copy of the fixture library
const libDir = await mkdtemp(path.join(tmpdir(), 'copperhead-libs-'));
await cp(SYMLIB, libDir, { recursive: true });
const first = await draftSchematic(draftOpts(repo, [libDir]));
expect(first.ok).toBe(true);
const before = await readFile(path.join(repo, 'board.kicad_sch'), 'utf8');
expect(existsSync(path.join(repo, 'sym-lib-cache', 'Device.kicad_sym'))).toBe(true);
// upgrade the "installed" library: R grows a pin
const devPath = path.join(libDir, 'Device.kicad_sym');
const dev = await readFile(devPath, 'utf8');
await writeFile(
devPath,
dev.replace(
'(pin passive line (at 0 -3.81 90)',
'(pin passive line (at 2.54 0 180)\n\t\t\t\t(name "~" (effects (font (size 1.27 1.27))))\n\t\t\t\t(number "3" (effects (font (size 1.27 1.27))))\n\t\t\t)\n\t\t\t(pin passive line (at 0 -3.81 90)',
),
'utf8',
);
const again = await draftSchematic(draftOpts(repo, [libDir]));
expect(again.ok).toBe(true);
expect(await readFile(path.join(repo, 'board.kicad_sch'), 'utf8')).toBe(before);
// the divergence is not silently frozen: verify_symbols compares the
// schematic against the (upgraded) installed library and reports it
const { findings } = await verifySchematicSymbols(path.join(repo, 'board.kicad_sch'), {
KICAD_SYMBOL_DIR: libDir,
} as NodeJS.ProcessEnv);
expect(findings.some((f) => f.libId === 'Device:R' && f.kind === 'pin-count')).toBe(true);
await rm(libDir, { recursive: true, force: true });
} finally {
await cleanup();
}
}, 60000);
it('vendoring is deterministic: two fresh repos produce identical caches and schematics', async () => {
const a = await draftRepo();
const b = await draftRepo();
try {
const ra = await draftSchematic(draftOpts(a.repo, a.symDirs));
const rb = await draftSchematic(draftOpts(b.repo, b.symDirs));
expect(ra.ok && rb.ok).toBe(true);
expect(await readFile(path.join(a.repo, 'board.kicad_sch'), 'utf8')).toBe(
await readFile(path.join(b.repo, 'board.kicad_sch'), 'utf8'),
);
expect(await readFile(path.join(a.repo, 'sym-lib-cache', 'Device.kicad_sym'), 'utf8')).toBe(
await readFile(path.join(b.repo, 'sym-lib-cache', 'Device.kicad_sym'), 'utf8'),
);
} finally {
await a.cleanup();
await b.cleanup();
}
}, 60000);
it('the vendored block is byte-identical to the library source apart from nothing at all', async () => {
const { repo, symDirs, cleanup } = await draftRepo();
try {
const src = new SymbolSource(repo, symDirs);
const r = await src.resolve('Device:R');
const libText = await readFile(path.join(SYMLIB, 'Device.kicad_sym'), 'utf8');
expect(libText).toContain(r.sourceText); // verbatim copy, no re-serialization
} finally {
await cleanup();
}
});
});
describe('IR overrides', () => {
it('a net-kind override wins over pin-type inference and is noted in the report', async () => {
const { repo, symDirs, cleanup } = await draftRepo();
try {
await mutateIntent(repo, (i) => {
i.nets.find((n: any) => n.name === 'VCC').kind = 'signal';
});
const res = await draftSchematicToText(draftOpts(repo, symDirs));
expect(res.ok).toBe(true);
if (!res.ok) return;
const vcc = res.report.netClasses.find((n) => n.name === 'VCC');
expect(vcc).toEqual({ name: 'VCC', class: 'signal', overridden: true, basis: 'declared' });
} finally {
await cleanup();
}
});
it('an odd rail name is recognized from pin types without any declaration', async () => {
const { repo, symDirs, cleanup } = await draftRepo();
try {
await mutateIntent(repo, (i) => {
i.nets.find((n: any) => n.name === 'VCC').name = 'VBAT';
// BOM/IR values untouched; only the net renamed
});
const res = await draftSchematicToText(draftOpts(repo, symDirs));
expect(res.ok).toBe(true);
if (!res.ok) return;
expect(res.report.netClasses.find((n) => n.name === 'VBAT')).toEqual({
name: 'VBAT',
class: 'rail',
overridden: false,
// the pin types said so, not the name: VBAT matches no supply shape
basis: 'pin-type',
});
} finally {
await cleanup();
}
});
});b4d619a61This file is pinned to the selected release. Download the original to open it in your design tool.