test(tcl): add TCL variant test suite including PCell generation

All non-TCL tests (extract, gds, drc, cif) now also run against the TCL
variant using magic::-prefixed Tcl scripts. A new PCell test generates
two parameterized rectangle cells and verifies their GDS output.

The CI workflow runs npm run test:tcl after the TCL build and saves the
generated output files as an artifact.
This commit is contained in:
Intubun 2026-05-21 10:25:58 +02:00
parent 8fdd2eec20
commit 1f19ac913f
15 changed files with 351 additions and 2 deletions

View File

@ -97,7 +97,7 @@ jobs:
- name: Run example tests (non-TCL variant)
run: cd npm && npm test
- name: Run smoke test (TCL variant)
- name: Run full test suite (TCL variant)
run: cd npm && npm run test:tcl
# Dump generated text outputs (.ext, .spice, .cif, …) into the CI log

56
npm/examples/all-tcl.js Normal file
View File

@ -0,0 +1,56 @@
// all-tcl.js — Run the full test suite against the TCL variant.
//
// Covers all non-TCL tests (extract, gds, drc, cif) run through the
// Tcl interpreter, plus a PCell generation test that is TCL-only.
//
// Usage: node examples/all-tcl.js
import { run as runExtract } from './extract-tcl.js';
import { run as runGds } from './gds-tcl.js';
import { run as runDrc } from './drc-tcl.js';
import { run as runCif } from './cif-tcl.js';
import { run as runPcell } from './pcell.js';
const PAD = 9;
async function test(name, fn) {
process.stdout.write(` ${name.padEnd(PAD)} `);
try {
const result = await fn();
console.log(`PASS ${formatResult(name, result)}`);
return true;
} catch (e) {
console.log(`FAIL ${e.message ?? e}`);
return false;
}
}
function formatResult(name, r) {
if (!r) return '';
switch (name) {
case 'extract': return [r.ext, r.spice].filter(Boolean).map(p => p.split(/[\\/]/).pop()).join(', ');
case 'gds': return `${r.outPath.split(/[\\/]/).pop()} (${r.bytes} B)`;
case 'cif': return `${r.outPath.split(/[\\/]/).pop()} (${r.bytes} B)`;
case 'drc': return r.violations != null ? `${r.violations} violation${r.violations !== 1 ? 's' : ''}` : '';
case 'pcell': return Object.entries(r).map(([n, { bytes }]) => `${n}.gds (${bytes} B)`).join(', ');
default: return '';
}
}
console.log('\nMagic WASM TCL variant — test suite\n');
const suite = [
['extract', runExtract],
['gds', runGds],
['drc', runDrc],
['cif', runCif],
['pcell', runPcell],
];
const passed = [];
for (const [name, fn] of suite) {
passed.push(await test(name, fn));
}
const ok = passed.filter(Boolean).length;
console.log(`\n${ok}/${suite.length} passed`);
process.exit(ok === suite.length ? 0 : 1);

View File

@ -0,0 +1,3 @@
magic::tech load __TECH__
magic::load /work/__CELL__
magic::cif write /work/__CELL__

28
npm/examples/cif-tcl.js Normal file
View File

@ -0,0 +1,28 @@
// cif-tcl.js — CIF export via the TCL variant.
import { createMagic, vfsRead, loadCell, loadScript,
DEFAULT_TECH, DEFAULT_MAG, DEFAULT_OUT } from './helpers-tcl.js';
import { writeFileSync, mkdirSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { resolve } from 'node:path';
export async function run({ magFile = DEFAULT_MAG, tech = DEFAULT_TECH, outputDir = DEFAULT_OUT } = {}) {
const { magic } = await createMagic();
const { FS } = magic;
const { tech: techName, cell } = loadCell(FS, tech, magFile);
magic.runScript(loadScript('cif-magic.tcl', techName, cell));
mkdirSync(outputDir, { recursive: true });
const data = vfsRead(FS, `/work/${cell}.cif`);
if (!data) throw new Error(`CIF export failed: /work/${cell}.cif not created`);
const outPath = resolve(outputDir, `${cell}.cif`);
writeFileSync(outPath, data);
return { outPath, bytes: data.length };
}
if (process.argv[1] === fileURLToPath(import.meta.url)) {
const { outPath, bytes } = await run().catch(e => { console.error(e.message ?? e); process.exit(1); });
console.log(`\ncif: ${outPath} (${bytes} bytes)`);
console.log('Done.');
}

View File

@ -0,0 +1,4 @@
magic::tech load __TECH__
magic::load /work/__CELL__
magic::drc catchup
magic::drc count total

28
npm/examples/drc-tcl.js Normal file
View File

@ -0,0 +1,28 @@
// drc-tcl.js — DRC check via the TCL variant.
import { createMagic, loadCell, loadScript,
DEFAULT_TECH, DEFAULT_MAG } from './helpers-tcl.js';
import { fileURLToPath } from 'node:url';
export async function run({ magFile = DEFAULT_MAG, tech = DEFAULT_TECH } = {}) {
const output = [];
const { magic } = await createMagic({
onPrint: msg => { output.push(msg); console.log('[magic-tcl]', msg); },
onPrintErr: msg => { output.push(msg); console.error('[magic-tcl]', msg); },
});
const { FS } = magic;
const { tech: techName, cell } = loadCell(FS, tech, magFile);
magic.runScript(loadScript('drc-magic.tcl', techName, cell));
const summary = output.find(l => /Total DRC errors/i.test(l));
const match = summary?.match(/(\d+)/);
const violations = match ? parseInt(match[1], 10) : null;
return { violations, output };
}
if (process.argv[1] === fileURLToPath(import.meta.url)) {
const { violations } = await run().catch(e => { console.error(e.message ?? e); process.exit(1); });
console.log(`\nDRC violations: ${violations ?? '(count not found in output)'}`);
console.log('Done.');
}

View File

@ -0,0 +1,11 @@
magic::tech load __TECH__
magic::load /work/__CELL__
magic::extract path /work
magic::extract do resistance
magic::extract all
magic::select top cell
magic::extresist all
magic::ext2spice format ngspice
magic::ext2spice extresist on
magic::ext2spice cthresh 0
magic::ext2spice /work/__CELL__

View File

@ -0,0 +1,39 @@
// extract-tcl.js — RC extraction via the TCL variant.
import { createMagic, vfsWrite, vfsRead, loadCell, loadScript,
DEFAULT_TECH, DEFAULT_MAG, DEFAULT_OUT } from './helpers-tcl.js';
import { writeFileSync, mkdirSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { resolve } from 'node:path';
export async function run({ magFile = DEFAULT_MAG, tech = DEFAULT_TECH, outputDir = DEFAULT_OUT } = {}) {
const { magic } = await createMagic();
const { FS } = magic;
const { tech: techName, cell } = loadCell(FS, tech, magFile);
magic.runScript(loadScript('extract-magic.tcl', techName, cell));
mkdirSync(outputDir, { recursive: true });
const extData = vfsRead(FS, `/work/${cell}.ext`);
if (!extData) throw new Error(`Extraction failed: /work/${cell}.ext not created`);
writeFileSync(resolve(outputDir, `${cell}.ext`), extData);
const resExtData = vfsRead(FS, `/work/${cell}.res.ext`);
if (resExtData) writeFileSync(resolve(outputDir, `${cell}.res.ext`), resExtData);
const spiceData = vfsRead(FS, `/work/${cell}.spice`) ?? vfsRead(FS, `/work/${cell}.spc`);
const spiceExt = vfsRead(FS, `/work/${cell}.spice`) ? 'spice' : 'spc';
if (spiceData) writeFileSync(resolve(outputDir, `${cell}.${spiceExt}`), spiceData);
return {
ext: resolve(outputDir, `${cell}.ext`),
spice: spiceData ? resolve(outputDir, `${cell}.${spiceExt}`) : null,
};
}
if (process.argv[1] === fileURLToPath(import.meta.url)) {
const { ext, spice } = await run().catch(e => { console.error(e.message ?? e); process.exit(1); });
console.log(`\next: ${ext}`);
if (spice) console.log(`spice: ${spice}`);
console.log('Done.');
}

View File

@ -0,0 +1,3 @@
magic::tech load __TECH__
magic::load /work/__CELL__
magic::gds write /work/__CELL__

28
npm/examples/gds-tcl.js Normal file
View File

@ -0,0 +1,28 @@
// gds-tcl.js — GDS export via the TCL variant.
import { createMagic, vfsRead, loadCell, loadScript,
DEFAULT_TECH, DEFAULT_MAG, DEFAULT_OUT } from './helpers-tcl.js';
import { writeFileSync, mkdirSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { resolve } from 'node:path';
export async function run({ magFile = DEFAULT_MAG, tech = DEFAULT_TECH, outputDir = DEFAULT_OUT } = {}) {
const { magic } = await createMagic();
const { FS } = magic;
const { tech: techName, cell } = loadCell(FS, tech, magFile);
magic.runScript(loadScript('gds-magic.tcl', techName, cell));
mkdirSync(outputDir, { recursive: true });
const data = vfsRead(FS, `/work/${cell}.gds`);
if (!data) throw new Error(`GDS export failed: /work/${cell}.gds not created`);
const outPath = resolve(outputDir, `${cell}.gds`);
writeFileSync(outPath, data);
return { outPath, bytes: data.length };
}
if (process.argv[1] === fileURLToPath(import.meta.url)) {
const { outPath, bytes } = await run().catch(e => { console.error(e.message ?? e); process.exit(1); });
console.log(`\ngds: ${outPath} (${bytes} bytes)`);
console.log('Done.');
}

View File

@ -0,0 +1,76 @@
// Shared utilities for Magic WASM TCL-variant examples.
//
// Loads the TCL-enabled WASM variant. Scripts use magic:: prefixed commands;
// runScript() evaluates them through the Tcl interpreter via Tcl_EvalFile.
import createMagicModule from '../tcl/magic.js';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, resolve, basename } from 'node:path';
export const EXAMPLES_DIR = dirname(fileURLToPath(import.meta.url));
export const wasmBinary = readFileSync(resolve(EXAMPLES_DIR, '../tcl/magic.wasm'));
export const DEFAULT_TECH = 'scmos';
export const DEFAULT_MAG = resolve(EXAMPLES_DIR, 'min.mag');
export const DEFAULT_OUT = resolve(EXAMPLES_DIR, 'output-tcl');
export { basename };
export function vfsWrite(FS, vfsPath, source) {
const dir = vfsPath.substring(0, vfsPath.lastIndexOf('/'));
if (dir) FS.mkdirTree(dir);
FS.writeFile(vfsPath, typeof source === 'string' ? readFileSync(source) : source);
}
export function vfsRead(FS, vfsPath) {
try { return Buffer.from(FS.readFile(vfsPath)); } catch { return null; }
}
export class MagicWasm {
constructor(mod) {
this._init = mod.cwrap('magic_wasm_init', 'number', []);
this._run = mod.cwrap('magic_wasm_run_command', 'number', ['string']);
this._sourceFile = mod.cwrap('magic_wasm_source_file', 'number', ['string']);
this.FS = mod.FS;
}
init() {
const rc = this._init();
if (rc !== 0) throw new Error(`magic_wasm_init failed with code ${rc}`);
}
// Evaluate a Tcl script (with magic:: prefixed commands) through the
// Tcl interpreter. Writes the script to VFS then calls magic_wasm_source_file,
// which in TCL mode uses Tcl_EvalFile.
runScript(text) {
const path = '/tmp/_magic_script.tcl';
this.FS.mkdirTree('/tmp');
this.FS.writeFile(path, text);
this._sourceFile(path);
}
// Evaluate a Tcl expression directly (needed for proc definitions in PCell).
runTcl(text) {
this._run(text);
}
}
export async function createMagic({ onPrint, onPrintErr } = {}) {
const lines = [];
const mod = await createMagicModule({
wasmBinary,
print: onPrint ?? (msg => { lines.push(msg); console.log('[magic-tcl]', msg); }),
printErr: onPrintErr ?? (msg => { lines.push(msg); console.error('[magic-tcl]', msg); }),
});
const magic = new MagicWasm(mod);
magic.init();
return { magic, lines };
}
export function loadCell(FS, tech, magFile) {
const cell = basename(magFile, '.mag');
vfsWrite(FS, `/work/${cell}.mag`, magFile);
return { tech, cell };
}
export function loadScript(name, tech, cell) {
return readFileSync(resolve(EXAMPLES_DIR, name), 'utf8')
.replaceAll('__TECH__', tech)
.replaceAll('__CELL__', cell);
}

View File

@ -0,0 +1,12 @@
magic::tech load __TECH__
proc make_rect {name width height} {
magic::cellname create $name
magic::box 0 0 $width $height
magic::paint m1
magic::save /work/$name
magic::gds write /work/$name
}
make_rect pcell_4x8 4 8
make_rect pcell_8x4 8 4

39
npm/examples/pcell.js Normal file
View File

@ -0,0 +1,39 @@
// pcell.js — PCell generation test (TCL variant only).
//
// Defines a Tcl proc as a PCell, instantiates it with two different sizes,
// and verifies that both GDS outputs are non-empty.
//
// Usage: node examples/pcell.js
import { createMagic, vfsRead, loadScript, DEFAULT_TECH, DEFAULT_OUT } from './helpers-tcl.js';
import { writeFileSync, mkdirSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { resolve } from 'node:path';
export async function run({ tech = DEFAULT_TECH, outputDir = DEFAULT_OUT } = {}) {
const { magic } = await createMagic();
const { FS } = magic;
FS.mkdirTree('/work');
magic.runTcl(loadScript('pcell-magic.tcl', tech, ''));
mkdirSync(outputDir, { recursive: true });
const cells = ['pcell_4x8', 'pcell_8x4'];
const results = {};
for (const name of cells) {
const data = vfsRead(FS, `/work/${name}.gds`);
if (!data || data.length === 0)
throw new Error(`PCell GDS output missing or empty: /work/${name}.gds`);
const outPath = resolve(outputDir, `${name}.gds`);
writeFileSync(outPath, data);
results[name] = { outPath, bytes: data.length };
}
return results;
}
if (process.argv[1] === fileURLToPath(import.meta.url)) {
const results = await run().catch(e => { console.error(e.message ?? e); process.exit(1); });
for (const [name, { outPath, bytes }] of Object.entries(results))
console.log(` ${name}.gds: ${outPath} (${bytes} bytes)`);
console.log('Done.');
}

22
npm/examples/pcell.tcl Normal file
View File

@ -0,0 +1,22 @@
# pcell.tcl PCell generation test.
#
# Defines a parameterized cell proc and instantiates it with two
# different sizes to verify that Tcl proc definitions, Magic drawing
# commands, and GDS output all work end-to-end in the TCL variant.
#
# __TECH__ is substituted by pcell.js before execution.
tech load __TECH__
# PCell definition: a labelled metal1 rectangle of variable size.
proc make_rect {name width height} {
cellname create $name
box 0 0 $width $height
paint m1
save /work/$name
gds write /work/$name
}
# Instantiate with two different sizes.
make_rect pcell_4x8 4 8
make_rect pcell_8x4 8 4

View File

@ -26,7 +26,7 @@
"scripts": {
"example": "node examples/extract.js",
"test": "node examples/all.js",
"test:tcl": "node examples/smoke-tcl.mjs",
"test:tcl": "node examples/all-tcl.js",
"test:gds": "node examples/gds.js",
"test:drc": "node examples/drc.js",
"test:cif": "node examples/cif.js"