mirror of
https://github.com/smogon/sprites.git
synced 2026-08-18 07:16:46 -05:00
Add tools/build unit tests
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
25
tools/build/test/config.test.ts
Normal file
25
tools/build/test/config.test.ts
Normal 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/);
|
||||
});
|
||||
29
tools/build/test/graph.test.ts
Normal file
29
tools/build/test/graph.test.ts
Normal 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']);
|
||||
});
|
||||
150
tools/build/test/plan.test.ts
Normal file
150
tools/build/test/plan.test.ts
Normal 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]);
|
||||
});
|
||||
41
tools/build/test/sprites.test.ts
Normal file
41
tools/build/test/sprites.test.ts
Normal 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});
|
||||
}
|
||||
});
|
||||
36
tools/build/test/subst.test.ts
Normal file
36
tools/build/test/subst.test.ts
Normal 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');
|
||||
});
|
||||
Reference in New Issue
Block a user