diff --git a/tools/build/test/config.test.ts b/tools/build/test/config.test.ts new file mode 100644 index 00000000..e330bee1 --- /dev/null +++ b/tools/build/test/config.test.ts @@ -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/); +}); diff --git a/tools/build/test/graph.test.ts b/tools/build/test/graph.test.ts new file mode 100644 index 00000000..670971bb --- /dev/null +++ b/tools/build/test/graph.test.ts @@ -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']); +}); diff --git a/tools/build/test/plan.test.ts b/tools/build/test/plan.test.ts new file mode 100644 index 00000000..612d6bec --- /dev/null +++ b/tools/build/test/plan.test.ts @@ -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, + 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) { + 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]); +}); diff --git a/tools/build/test/sprites.test.ts b/tools/build/test/sprites.test.ts new file mode 100644 index 00000000..00b92510 --- /dev/null +++ b/tools/build/test/sprites.test.ts @@ -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}); + } +}); diff --git a/tools/build/test/subst.test.ts b/tools/build/test/subst.test.ts new file mode 100644 index 00000000..31746512 --- /dev/null +++ b/tools/build/test/subst.test.ts @@ -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'); +});