Loading files
This file is pinned to the selected release. Download the original to open it in your design tool.
696b6f9f0This 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 });
} 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,
});
} finally {
await cleanup();
}
});
});