Add tools/build unit tests

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Christopher Monsanto
2026-08-15 22:03:13 -04:00
parent edda6cb310
commit 0ee06d3a5b
5 changed files with 281 additions and 0 deletions

View File

@@ -0,0 +1,25 @@
import assert from 'node:assert/strict';
import {test} from 'node:test';
import {parseConfig} from '../config.ts';
test('parseConfig parses values with spaces and equals', () => {
const cfg = parseConfig([
'# comment',
'',
'MODELS_OPTIPNG=-o7',
'MINISPRITE_ADVPNG = -z4 -i100 ',
'WEIRD=a=b',
'#DISABLED=x',
].join('\n'));
assert.deepEqual([...cfg], [
['MODELS_OPTIPNG', '-o7'],
['MINISPRITE_ADVPNG', '-z4 -i100'],
['WEIRD', 'a=b'],
]);
});
test('parseConfig rejects malformed lines', () => {
assert.throws(() => parseConfig('NOVALUE'), /Invalid config line/);
});

View File

@@ -0,0 +1,29 @@
import assert from 'node:assert/strict';
import {test} from 'node:test';
import {getRules, resetRules, rule} from '../api.ts';
import {BuildError, checkGraph} from '../graph.ts';
test('checkGraph rejects duplicate outputs', () => {
resetRules();
rule('a.png', ['c1 %f %o'], 'out/x.png');
rule('b.png', ['c2 %f %o'], 'out/x.png');
assert.throws(() => checkGraph(getRules()), BuildError);
});
test('checkGraph rejects cycles', () => {
resetRules();
rule('gen/y', ['c1 %f %o'], 'gen/x');
rule('gen/x', ['c2 %f %o'], 'gen/y');
assert.throws(() => checkGraph(getRules()), /cycle/i);
});
test('checkGraph orders producers before consumers', () => {
resetRules();
rule('gen/mid', ['consume %f %o'], 'out/final');
rule('src/a', ['produce %f %o'], 'gen/mid');
const {order, generated} = checkGraph(getRules());
assert.deepEqual(order.map(r => r.outputs[0]), ['gen/mid', 'out/final']);
assert.deepEqual([...generated].sort(), ['gen/mid', 'out/final']);
});

View File

@@ -0,0 +1,150 @@
import assert from 'node:assert/strict';
import {test} from 'node:test';
import {forEachRule, getRules, resetRules, rule, type RuleDecl} from '../api.ts';
import type {StoredRule, StoredRuleOutput} from '../db.ts';
import {computePlan, type OutputStat, ruleInputSig} from '../plan.ts';
function hashOf(content : string) : Buffer {
return Buffer.from(content.padEnd(32, '\0'));
}
function makeForeach(input : string, outputDir : string) : RuleDecl {
resetRules();
forEachRule(input, {cmds: ['convert %f %o']}, `${outputDir}/%b`);
const decl = getRules()[0]!;
assert(decl !== undefined);
return decl;
}
function stored(decl : RuleDecl, hashes : Map<string, Buffer>,
opts : {ok? : boolean, outputs? : StoredRuleOutput[]} = {}) : StoredRule {
return {
id: 1n,
key: decl.key,
command: decl.command,
display: decl.display,
template: decl.template,
inputSig: ruleInputSig(decl, hashes),
ok: opts.ok ?? true,
inputs: [
...decl.inputs.map(path => ({path, isDep: false, hash: hashes.get(path)!})),
...decl.deps.map(path => ({path, isDep: true, hash: hashes.get(path)!})),
],
outputs: opts.outputs ?? decl.outputs.map(path => ({path, size: 10n, mtimeNs: 100n})),
};
}
function statFrom(entries : Record<string, OutputStat | null>) {
return (path : string) : OutputStat | null => entries[path] ?? null;
}
const GOOD : OutputStat = {size: 10n, mtimeNs: 100n};
test('unchanged rule is clean', () => {
const decl = makeForeach('src/a.png', 'build/out');
const hashes = new Map([['src/a.png', hashOf('A')]]);
const plan = computePlan({
current: [decl],
stored: [stored(decl, hashes)],
hashes,
statOutput: statFrom({'build/out/a.png': GOOD}),
adopt: false,
});
assert.deepEqual(plan.clean, [decl]);
assert.equal(plan.run.length, 0);
});
test('dirty reasons', () => {
const decl = makeForeach('src/a.png', 'build/out');
const hashes = new Map([['src/a.png', hashOf('A')]]);
const cases : [StoredRule, (p : string) => OutputStat | null, string][] = [
[stored(decl, new Map([['src/a.png', hashOf('OLD')]])),
statFrom({'build/out/a.png': GOOD}), 'input-changed'],
[stored(decl, hashes, {ok: false}),
statFrom({'build/out/a.png': GOOD}), 'failed-last-run'],
[stored(decl, hashes), statFrom({}), 'output-missing'],
[stored(decl, hashes),
statFrom({'build/out/a.png': {size: 11n, mtimeNs: 100n}}), 'output-tampered'],
];
for (const [s, statOutput, reason] of cases) {
const plan = computePlan({current: [decl], stored: [s], hashes, statOutput, adopt: false});
assert.deepEqual(plan.run.map(r => r.reason), [reason]);
}
});
test('unknown rule runs as new; removed rule is stale', () => {
const decl = makeForeach('src/a.png', 'build/out');
const gone = makeForeach('src/z.png', 'build/out');
const hashes = new Map([['src/a.png', hashOf('A')], ['src/z.png', hashOf('Z')]]);
const plan = computePlan({
current: [decl],
stored: [stored(gone, hashes)],
hashes,
statOutput: statFrom({}),
adopt: false,
});
assert.deepEqual(plan.run.map(r => r.reason), ['new']);
assert.deepEqual(plan.stale.map(s => s.key), [gone.key]);
});
test('renamed input with identical content matches instead of running', () => {
const oldDecl = makeForeach('src/a.png', 'build/out');
const newDecl = makeForeach('src/b.png', 'build/out');
const hashes = new Map([['src/a.png', hashOf('SAME')], ['src/b.png', hashOf('SAME')]]);
const plan = computePlan({
current: [newDecl],
stored: [stored(oldDecl, hashes)],
hashes,
statOutput: statFrom({'build/out/a.png': GOOD}),
adopt: false,
});
assert.equal(plan.run.length, 0);
assert.deepEqual(plan.renames.map(r => [r.from.outputs[0]!.path, r.decl.outputs[0]]),
[['build/out/a.png', 'build/out/b.png']]);
// the source rule is still deleted afterward
assert.deepEqual(plan.stale.map(s => s.key), [oldDecl.key]);
});
test('rename does not match differing content or tampered source outputs', () => {
const oldDecl = makeForeach('src/a.png', 'build/out');
const newDecl = makeForeach('src/b.png', 'build/out');
const differing = new Map([['src/a.png', hashOf('X')], ['src/b.png', hashOf('Y')]]);
let plan = computePlan({
current: [newDecl],
stored: [stored(oldDecl, differing)],
hashes: differing,
statOutput: statFrom({'build/out/a.png': GOOD}),
adopt: false,
});
assert.deepEqual(plan.run.map(r => r.reason), ['new']);
const same = new Map([['src/a.png', hashOf('SAME')], ['src/b.png', hashOf('SAME')]]);
plan = computePlan({
current: [newDecl],
stored: [stored(oldDecl, same)],
hashes: same,
statOutput: statFrom({'build/out/a.png': {size: 99n, mtimeNs: 100n}}),
adopt: false,
});
assert.deepEqual(plan.run.map(r => r.reason), ['new']);
});
test('adopt records existing outputs instead of running', () => {
resetRules();
rule('src/a.png', {cmds: ['convert %f %o']}, 'build/present.png');
rule('src/b.png', {cmds: ['convert %f %o']}, 'build/absent.png');
const [present, absent] = getRules() as [RuleDecl, RuleDecl];
const hashes = new Map([['src/a.png', hashOf('A')], ['src/b.png', hashOf('B')]]);
const plan = computePlan({
current: [present, absent],
stored: [],
hashes,
statOutput: statFrom({'build/present.png': GOOD}),
adopt: true,
});
assert.deepEqual(plan.adopt, [present]);
assert.deepEqual(plan.run.map(r => r.decl), [absent]);
});

View File

@@ -0,0 +1,41 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import pathlib from 'node:path';
import {test} from 'node:test';
import {base, spritedata, spriteglob} from '../api.ts';
test('spritedata parses id and flags', () => {
assert.deepEqual(spritedata('10080'), {id: '10080', data: {}});
assert.deepEqual(spritedata('10080-a'), {id: '10080', data: {a: true}});
assert.deepEqual(spritedata('10016-b-s'), {id: '10016', data: {b: true, s: true}});
assert.deepEqual(spritedata('123-xmega'), {id: '123', data: {x: 'mega'}});
// Lua gmatch("[^-]+") skips empty segments
assert.deepEqual(spritedata('12--b'), {id: '12', data: {b: true}});
});
test('base strips directory and final extension', () => {
assert.equal(base('src/models/6-mega-x.gif'), '6-mega-x');
});
test('spriteglob filters by flag truthiness', () => {
const dir = fs.mkdtempSync(pathlib.join(os.tmpdir(), 'spriteglob-'));
try {
for (const name of ['1.png', '1-a.png', '2-b.png', '2-b-s.png', '3-g.png']) {
fs.writeFileSync(pathlib.join(dir, name), '');
}
assert.deepEqual(
spriteglob(`${dir}/*.png`, {a: false}).map(p => pathlib.basename(p)),
['1.png', '2-b-s.png', '2-b.png', '3-g.png']);
assert.deepEqual(
spriteglob(`${dir}/*.png`, {b: false, s: false}).map(p => pathlib.basename(p)),
['1-a.png', '1.png', '3-g.png']);
assert.deepEqual(
spriteglob(`${dir}/*.png`, {b: true}).map(p => pathlib.basename(p)),
['2-b-s.png', '2-b.png']);
} finally {
fs.rmSync(dir, {recursive: true, force: true});
}
});

View File

@@ -0,0 +1,36 @@
import assert from 'node:assert/strict';
import {test} from 'node:test';
import {basenameNoExt, flattenCmds, substitute} from '../subst.ts';
test('substitute expands %f/%o/%b/%B', () => {
assert.equal(
substitute('convert %f -x %o', ['src/a.png'], ['build/a.png']),
'convert src/a.png -x build/a.png');
assert.equal(
substitute('%f + %b + %B', ['src/d/x.tar.gz', 'y.png'], []),
'src/d/x.tar.gz y.png + x.tar.gz y.png + x.tar y');
});
test('substitute handles quoted frame selector', () => {
assert.equal(
substitute('magick convert "%f[0]" -trim %o', ['src/models/a.gif'], ['out/a.png']),
'magick convert "src/models/a.gif[0]" -trim out/a.png');
});
test('substitute rejects unknown escapes', () => {
assert.throws(() => substitute('%d', ['a'], ['b']), /Unknown substitution/);
});
test('flattenCmds flattens, trims, drops empties', () => {
assert.deepEqual(
flattenCmds(['a', [' b ', [], ['c', '']], 'd']),
['a', 'b', 'c', 'd']);
});
test('basenameNoExt', () => {
assert.equal(basenameNoExt('src/dex/10001-b.png'), '10001-b');
assert.equal(basenameNoExt('noext'), 'noext');
assert.equal(basenameNoExt('a/.hidden'), '.hidden');
});