Report missing asset leaves and add opt-in strict mode (#421)

This commit is contained in:
sganggs committed 2026-10-09 17:13:13 +08:00
1 parent 174ccdf94d
commit 3e3919c917
6 files changed
+211 -14

No files matched your search

+40
View File
@@ -443,10 +443,12 @@ describe('audio banks and plan id sets', () => {
const r = resolveTemplate(tpl, { root: ROOT, spine: new Map([['k', { skel: '/assets/s.skel', atlas: '/assets/s.atlas', textures: [] }]]) });
assert.deepEqual(Object.keys(r.value).sort(), ['keep', 'm']);
assert.deepEqual(r.misses.sort(), ['a', 'b.c']);
assert.deepEqual(r.droppedLeaves, ['a', 'b.c'], 'a leaf no alternative of which is on disk is a dropped leaf');
assert.equal(collectLeaves(tpl).length, 2);
const ok = resolveTemplate({ p: { alts: [{ rel: 'nope.png', urls: ['x'] }, { rel: 'package.json', urls: ['y'] }] } }, { root: ROOT, spine: new Map() });
assert.equal(ok.value.p, '/assets/package.json');
assert.equal(ok.fallbacks.length, 1);
assert.deepEqual(ok.droppedLeaves, [], 'a fallback on disk is not a drop');
});
test('an array whose lines are all missing is dropped, with the entry it empties (a voice slot never downloaded)', () => {
@@ -462,6 +464,44 @@ describe('audio banks and plan id sets', () => {
assert.deepEqual(r.value.voice, { b: { select: ['/assets/package.json'] } }, 'operator a has no line on disk: no entry at all');
assert.deepEqual(r.value.empty, [], 'an array that was empty in the template stays');
assert.deepEqual(r.misses.sort(), ['voice.a.place[0]', 'voice.a.select[0]', 'voice.a.select[1]', 'voice.b.select[0]']);
assert.deepEqual(r.droppedLeaves.sort(), r.misses.slice().sort(), 'every dropped line is a dropped leaf too');
});
// A unit's attack sound is a leaf like any other: when its file was never downloaded (or a checkout lost it) the whole
// key vanishes from data/assets.json and the client plays nothing — there is no fallback and no URL to retry. The
// resolution therefore reports it as a dropped leaf, which the run prints and --strict fails on
// (tools/fetch-assets.mjs; docs/ASSETS.md "A dropped leaf is reported, never silent").
test('move a resolved sound aside: the leaf leaves the manifest and is reported as dropped, never silently', async (t) => {
const { mkdtemp, mkdir, writeFile, rm } = await import('node:fs/promises');
const { tmpdir } = await import('node:os');
const root = await mkdtemp(join(tmpdir(), 'sp-leaf-drop-'));
t.after(() => rm(root, { recursive: true, force: true }));
const rel = 'audio/sfx/player/p_atk/p_atk_somechar_n.mp3';
const imp = 'audio/sfx/player/p_imp/p_imp_somechar_n.mp3';
const ui = 'audio/sfx/general/g_ui/g_ui_btn_h.mp3';
const leaf = (r) => ({ alts: [{ rel: r, urls: [`https://raw.githubusercontent.com/o/r/main/${r}`], kind: 'mp3' }] });
const tpl = { audio: { sfx: { units: { char_x: { attack: leaf(rel), hit: leaf(imp) } }, ui: { click: leaf(ui) } } } };
for (const r of [rel, imp, ui]) {
await mkdir(join(root, dirname(r)), { recursive: true });
await writeFile(join(root, r), 'ID3');
}
const before = resolveTemplate(tpl, { root, spine: new Map() });
assert.equal(JSON.stringify(before.value), JSON.stringify({
audio: {
sfx: {
units: { char_x: { attack: `/assets/${rel}`, hit: `/assets/${imp}` } },
ui: { click: `/assets/${ui}` },
},
},
}), 'every file on disk: the manifest is exactly the plan, byte for byte');
assert.deepEqual([before.misses, before.droppedLeaves, before.fallbacks], [[], [], []]);
await rm(join(root, rel)); // the download never happened / the file went missing
const after = resolveTemplate(tpl, { root, spine: new Map() });
assert.equal(Object.hasOwn(after.value.audio.sfx.units.char_x, 'attack'), false, 'the key is gone from the manifest');
assert.equal(after.value.audio.sfx.units.char_x.hit, `/assets/${imp}`, 'the roles whose file is on disk stay');
assert.deepEqual(after.droppedLeaves, ['audio.sfx.units.char_x.attack'], 'and the drop is reported, not silent');
assert.ok(after.misses.includes('audio.sfx.units.char_x.attack'), 'the wider miss list names it too');
});
});
+38
View File
@@ -96,4 +96,42 @@ test('fetch-assets still runs as a script: --help lists --allow-shrink', () => {
assert.equal(r.status, 0, r.stderr);
assert.match(r.stdout, /--allow-shrink/);
assert.match(r.stdout, /--prune .*\n.*implies --allow-shrink/);
assert.match(r.stdout, /--strict\s+exit 1 when a leaf was dropped/);
assert.match(r.stdout, /SP_ASSETS_STRICT=1 is the same as --strict/);
});
// A leaf whose every alternative file is missing on disk disappears from data/assets.json: the client has no entry at all
// (a unit whose attack sound was never downloaded plays nothing), and the shrink guard cannot see it once it is gone from
// the committed manifest. The run reports it as `droppedLeaves`; --strict / SP_ASSETS_STRICT=1 makes it a failure for CI
// and packaging runs, which is the only part that changes a run's outcome (docs/ASSETS.md).
test('--strict / SP_ASSETS_STRICT=1 fails a run that dropped a leaf for having no file on disk, instead of warning', async () => {
const { parseArgs, runExitCode, leafDropSummary, strictLeafDropError } = await import('../tools/fetch-assets.mjs');
const leaf = 'audio.sfx.units.char_x.attack';
assert.equal(parseArgs([]).strict, false, 'a plain run only warns');
assert.equal(parseArgs(['--strict']).strict, true);
assert.throws(() => parseArgs(['--stricts']), /unknown option/);
// the environment variable is the CI / packaging switch, like SP_ASSET_SOURCE
const saved = process.env.SP_ASSETS_STRICT;
try {
for (const v of ['1', 'true', 'YES', 'on']) { process.env.SP_ASSETS_STRICT = v; assert.equal(parseArgs([]).strict, true, `SP_ASSETS_STRICT=${v}`); }
for (const v of ['', '0', 'false', 'no', 'off']) { process.env.SP_ASSETS_STRICT = v; assert.equal(parseArgs([]).strict, false, `SP_ASSETS_STRICT=${v}`); }
delete process.env.SP_ASSETS_STRICT;
assert.equal(parseArgs([]).strict, false, 'unset');
} finally {
if (saved === undefined) delete process.env.SP_ASSETS_STRICT; else process.env.SP_ASSETS_STRICT = saved;
}
// the exit code: a dropped leaf fails a strict run and only warns otherwise
assert.equal(runExitCode({ droppedLeaves: 1 }), 0, 'default: reported, the run still succeeds');
assert.equal(runExitCode({ droppedLeaves: 48, strict: true }), 1, '--strict: the drop is a failure');
assert.equal(runExitCode({ droppedLeaves: 0, strict: true }), 0, 'nothing dropped: a strict run succeeds');
assert.equal(runExitCode({}), 0);
assert.equal(runExitCode({ requiredMisses: 1 }), 1, 'unchanged: a missing avatar / portrait / Front Spine always fails');
assert.equal(runExitCode({ manifestWritten: false }), 1, 'unchanged: the shrink guard keeps the run red');
// and the messages name the leaf
assert.match(strictLeafDropError([leaf]), /^ERROR: 1 leaf dropped for having no alternative on disk \(--strict \/ SP_ASSETS_STRICT=1\): audio\.sfx\.units\.char_x\.attack$/);
assert.match(strictLeafDropError([leaf].concat(Array.from({ length: 12 }, (_, i) => `ui.g${i}`))), /all 13 in \.cache/);
assert.equal(leafDropSummary([leaf]), '[assets] 1 leaf dropped: no alternative on disk (audio.sfx.units.char_x.attack)');
assert.match(leafDropSummary(['a', 'b']), /^\[assets\] 2 leaves dropped: no alternative on disk \(a, b\)$/);
assert.match(leafDropSummary(Array.from({ length: 9 }, (_, i) => `f${i}`)), /\(f0, f1, f2, f3, f4, f5, f6, f7, …\)$/, 'long lists are truncated on the line');
assert.match(leafDropSummary([]), /^\[assets\] no leaf dropped: every planned leaf has a file on disk$/);
});