The official data spells one DEC X module 'dec-X' (uniequip_003_aglina) and the others 'dec-x' (WAH-Y is the other
mixed-case type id), and the asset plan stored each under its own case: module/dec-X.png and module/dec-x.png. On
Windows / macOS those are one file, so a release zip built there could hold only one of them and a case-sensitive
server (a Linux VPS) would miss the other — the release packager found it ("planned but not staged"). The local file
is now module/{typeIcon in lower case}.png; the upstream names keep their order (the id as spelled, then lower case).
data/assets.json regenerated offline (node tools/fetch-assets.mjs --offline --add-only). JSON diff: modules.dec-X
/assets/module/dec-X.png -> /assets/module/dec-x.png, modules.WAH-Y /assets/module/WAH-Y.png -> /assets/module/wah-y.png,
hash, stats.files 6979 -> 6978, stats.bytes 458334505 -> 458333444; nothing else.
fetch-assets compares its orphans (what --prune deletes) without case: on Windows / macOS module/WAH-Y.png on disk is
the listed module/wah-y.png and must not be deleted (orphanFiles, exported).
Tests: test/assets-diy.test.js (one lower-case file per type, no two manifest URLs differing only in case, orphans).
docs/ASSETS.md: the module icon file name.
129 lines
9.6 KiB
JavaScript
129 lines
9.6 KiB
JavaScript
// test/assets-diy.test.js — the art of the 自选 picks in the asset pipeline (tools/fetch-assets.mjs dataExtras,
|
|
// tools/assets/plan.mjs patternOperator / buildPlan `extraOperators` / `moduleTypes`; docs/ASSETS.md): the owned-6★
|
|
// units of data/backups.json are planned from research 07's URL patterns (avatar, portrait — E2 too —, battle Spine
|
|
// Front / Back, every skill icon), their summons as tokens, and every module's type icon as manifest `modules`; the
|
|
// committed data/assets.json lists them (checked against public/assets when present); --add-only never rewrites a file.
|
|
// Run: node --test test/assets-diy.test.js
|
|
|
|
import { test } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { existsSync, readFileSync } from 'node:fs';
|
|
import { join, dirname } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { dataExtras, orphanFiles, parseArgs } from '../tools/fetch-assets.mjs';
|
|
import { patternOperator, buildPlan } from '../tools/assets/plan.mjs';
|
|
import { indexAudio } from '../tools/assets/audio.mjs';
|
|
import { Downloader } from '../tools/assets/downloader.mjs';
|
|
|
|
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
|
|
const load = (f) => JSON.parse(readFileSync(join(ROOT, 'data', `${f}.json`), 'utf8'));
|
|
const BACKUPS = load('backups');
|
|
const CHESS = load('chess');
|
|
const YUAN = 'https://raw.githubusercontent.com/yuanyan3060/ArknightsGameResource/main/';
|
|
const FEXLI = 'https://raw.githubusercontent.com/fexli/ArknightsResource/main/';
|
|
|
|
test('dataExtras: every backups.json unit (skills by index, once), the 自选 summons, the type icon of every module of chess.json and backups.json', () => {
|
|
const x = dataExtras(BACKUPS, CHESS);
|
|
assert.deepEqual(Object.keys(x.extraOperators), Object.keys(BACKUPS.units));
|
|
assert.deepEqual(x.extraOperators.char_112_siege, {
|
|
name: '推进之王', subProfessionId: 'pioneer', nationId: 'victoria',
|
|
skills: [{ index: 0, skillId: 'skcom_charge_cost[3]', iconId: 'skcom_charge_cost[3]' }, { index: 1, skillId: 'skchr_siege_2', iconId: 'skchr_siege_2' }, { index: 2, skillId: 'skchr_siege_3', iconId: 'skchr_siege_3' }],
|
|
});
|
|
assert.deepEqual(x.tokenIds, Object.keys(BACKUPS.tokens).sort());
|
|
const types = new Set();
|
|
for (const c of Object.values(CHESS)) for (const m of c.modules || []) types.add(m.typeIcon);
|
|
for (const u of Object.values(BACKUPS.units)) for (const f of Object.values(u.forms)) for (const m of f.modules || []) types.add(m.typeIcon);
|
|
assert.deepEqual(x.moduleTypes, [...types].sort());
|
|
assert.ok(x.moduleTypes.includes('sol-x') && x.moduleTypes.includes('sol-y'));
|
|
assert.deepEqual(dataExtras(null, null), { extraOperators: {}, tokenIds: [], moduleTypes: [] });
|
|
});
|
|
|
|
test('patternOperator / buildPlan: an operator research 07 lacks gets the 07 URL patterns; known operators keep their research record; module type icons become `modules`', () => {
|
|
const o = patternOperator('char_112_siege', { subProfessionId: 'pioneer', skills: [{ index: 0, skillId: 'skcom_charge_cost[3]', iconId: 'skcom_charge_cost[3]' }] });
|
|
assert.deepEqual([o.avatar.e0e1.url, o.avatar.e2.url, o.portrait.e0e1.url, o.portrait.e2.url],
|
|
[`${YUAN}avatar/char_112_siege.png`, `${YUAN}avatar/char_112_siege_2.png`, `${YUAN}portrait/char_112_siege_1.png`, `${YUAN}portrait/char_112_siege_2.png`]);
|
|
assert.equal(o.battleSpine.front.skel, `${FEXLI}spine/char_112_siege/char_112_siege/Front/char_112_siege.skel`);
|
|
assert.equal(o.battleSpine.back.png, `${FEXLI}spine/char_112_siege/char_112_siege/Back/char_112_siege.png`);
|
|
assert.equal(o.skills[0].icon.url, `${YUAN}skill/skill_icon_skcom_charge_cost%5B3%5D.png`, 'brackets percent-encoded');
|
|
assert.match(o.subProfessionIcon, /arts\/ui\/subprofessionicon\/sub_pioneer_icon\.png$/);
|
|
const known = { name: 'x', avatar: { e0e1: { url: 'https://example.invalid/known.png' } }, skills: [] };
|
|
const t = buildPlan({
|
|
assets07: { operators: { char_609_acguad: known } }, ops03: {}, enemies05: {}, maps05: {}, audio: indexAudio({}), modelsData: {},
|
|
extraOperators: { char_112_siege: dataExtras(BACKUPS, CHESS).extraOperators.char_112_siege, char_609_acguad: { skills: [] }, 'bad id': {} },
|
|
moduleTypes: ['sol-x', 'sol-x', 'bad/type'],
|
|
});
|
|
const c = t.template.chars.char_112_siege;
|
|
assert.deepEqual([c.avatar.alts[0].rel, c.avatarE2.alts[0].rel, c.portrait.alts[0].rel, c.portraitE2.alts[0].rel],
|
|
['char/avatar/char_112_siege.png', 'char/avatar/char_112_siege_2.png', 'char/portrait/char_112_siege_1.png', 'char/portrait/char_112_siege_2.png']);
|
|
assert.deepEqual(c.spine, { front: { model: 'op:char_112_siege:front' }, back: { model: 'op:char_112_siege:back' } });
|
|
assert.deepEqual(t.models.get('op:char_112_siege:front').skillIndices, [0, 1, 2], 'a Spine clip per skill index');
|
|
for (const id of ['skcom_charge_cost[3]', 'skchr_siege_2', 'skchr_siege_3']) assert.ok(t.template.skills[id] && t.template.skillsById[id] === id, id);
|
|
assert.equal(t.template.chars.char_609_acguad.avatar.alts[0].urls[0], 'https://example.invalid/known.png', 'research 07 wins for an operator it lists');
|
|
assert.equal(t.template.chars['bad id'], undefined);
|
|
assert.deepEqual(Object.keys(t.template.modules), ['sol-x']);
|
|
assert.match(t.template.modules['sol-x'].alts[0].urls[0], /arts\/ui\/uniequiptype\/sol-x\.png$/);
|
|
assert.equal(t.template.prof.sub.pioneer.alts[0].rel, 'prof/sub/pioneer.png');
|
|
});
|
|
|
|
test('module type icons: one lower-case file per type, so the official \'dec-X\' (uniequip_003_aglina) and \'dec-x\' share one; --prune keeps a file whose name differs only in case', () => {
|
|
const t = buildPlan({ assets07: {}, ops03: {}, enemies05: {}, maps05: {}, audio: indexAudio({}), modelsData: {}, moduleTypes: ['dec-X', 'dec-x', 'WAH-Y'] });
|
|
const rel = (k) => t.template.modules[k].alts[0].rel;
|
|
assert.deepEqual([rel('dec-X'), rel('dec-x'), rel('WAH-Y')], ['module/dec-x.png', 'module/dec-x.png', 'module/wah-y.png']);
|
|
assert.match(t.template.modules['dec-X'].alts[0].urls[0], /uniequiptype\/dec-X\.png$/, 'the upstream name keeps its case first');
|
|
const m = load('assets');
|
|
assert.equal(m.modules['dec-X'], m.modules['dec-x']);
|
|
const byCase = new Map();
|
|
const walk = (v) => {
|
|
if (typeof v === 'string' && v.startsWith('/assets/')) {
|
|
const k = v.toLowerCase();
|
|
assert.ok(!byCase.has(k) || byCase.get(k) === v, `${v} and ${byCase.get(k)} differ only in case: one file on Windows / macOS`);
|
|
byCase.set(k, v);
|
|
} else if (v && typeof v === 'object') for (const x of Object.values(v)) walk(x);
|
|
};
|
|
walk(m);
|
|
assert.deepEqual(orphanFiles(['module/WAH-Y.png', 'module/old.png', 'local/x.png', 'char/a.png'], new Set(['module/wah-y.png', 'char/a.png'])), ['module/old.png']);
|
|
});
|
|
|
|
test('--add-only: parsed, refused with --prune / --force; its downloader keeps every existing file and records what it wrote', async () => {
|
|
assert.equal(parseArgs(['--add-only']).addOnly, true);
|
|
assert.equal(parseArgs([]).addOnly, false);
|
|
assert.throws(() => parseArgs(['--add-only', '--prune']), /never deletes or rewrites/);
|
|
assert.throws(() => parseArgs(['--add-only', '--force']), /never deletes or rewrites/);
|
|
const dl = new Downloader({ root: join(ROOT, 'data'), ledgerPath: join(ROOT, '.cache', 'test-never-written.json'), keepExisting: true, force: true, log() {} });
|
|
assert.equal(dl.force, false, '--force never applies');
|
|
assert.equal(await dl.existingSize({ rel: 'chess.json', urls: [], kind: 'png', bytes: 1 }), readFileSync(join(ROOT, 'data', 'chess.json')).length, 'an existing file is kept whatever its size / kind');
|
|
assert.equal(await dl.existingSize({ rel: 'no-such-file.png', urls: [], kind: 'png' }), -1);
|
|
assert.deepEqual([...dl.written], []);
|
|
});
|
|
|
|
test('the committed data/assets.json lists every 自选 operator\'s avatar, portrait, Front Spine and skill icons, and a type icon per module (files on disk when public/assets is here)', () => {
|
|
const m = load('assets');
|
|
const disk = existsSync(join(ROOT, 'public', 'assets', 'char'));
|
|
const onDisk = (u) => !disk || existsSync(join(ROOT, 'public', u));
|
|
for (const id of BACKUPS.diy.ownedPool) {
|
|
const c = m.chars[id];
|
|
assert.ok(c && c.avatar && c.portrait && c.spine?.front?.skel, `${id}: art`);
|
|
for (const u of [c.avatar, c.portrait, c.spine.front.skel, c.spine.front.atlas, ...c.spine.front.textures]) assert.ok(onDisk(u), `${id}: ${u} on disk`);
|
|
assert.deepEqual(Object.keys(c.spine.front.anims.skills || {}).sort(), ['0', '1', '2'], `${id}: a clip per skill index`);
|
|
for (const s of BACKUPS.units[id].forms['2/1/4/0'].skills) assert.ok(m.skills[m.skillsById[s.skillId]] || m.skills[s.iconId], `${id}: skill icon ${s.skillId}`);
|
|
}
|
|
for (const t of dataExtras(BACKUPS, CHESS).moduleTypes) assert.ok(m.modules[t] && onDisk(m.modules[t]), `module type icon ${t}`);
|
|
assert.equal(m.stats.modules, Object.keys(m.modules).length);
|
|
});
|
|
|
|
test('an operator left out of 自选 (data/backups.json diy.excluded: the collab picks, 焰狐龙梓兰 MH05 among them) keeps no art in data/assets.json, so the full release zip (tools/package.mjs) never ships it', () => {
|
|
const m = load('assets');
|
|
const text = JSON.stringify(m);
|
|
assert.ok(BACKUPS.diy.excluded.includes('char_1048_orchd2'), '焰狐龙梓兰 is excluded');
|
|
for (const id of BACKUPS.diy.excluded) {
|
|
const code = id.split('_').slice(2).join('_');
|
|
assert.equal(m.chars[id], undefined, `${id}: chars`);
|
|
assert.equal(m.audio.sfx.units[id], undefined, `${id}: unit sfx`);
|
|
assert.equal(m.audio.voice?.[id], undefined, `${id}: voice`);
|
|
assert.ok(!Object.keys(m.skills).some((k) => k.startsWith(`skchr_${code}_`)), `${id}: skill icons`);
|
|
assert.ok(!text.includes(id), `${id}: no file of theirs`);
|
|
}
|
|
assert.equal(m.stats.chars, Object.keys(m.chars).length);
|
|
assert.equal(m.stats.skills, Object.keys(m.skills).length);
|
|
});
|