Files
ark-stronghold-protocol/test/feedback7-voices.test.js

177 lines
13 KiB
JavaScript

// test/feedback7-voices.test.js — the Japanese voice dub beside the Chinese one (0.2.2; the owner's request of 2026-10-08
// 「全套的日配语音」): tools/assets/plan.mjs plans `audio.voiceJp` — the same slots and file names from the
// ArknightsAssets2 `voice/` folder (the JP dub), under audio/voice/jp/ — the committed data/assets.json lists it for every
// operator the Chinese tree has (checked against public/assets when present), the settings carry 语音语言 (中文 by
// default, the four language packs translate the row), and tools/package.mjs FULL_ZIP_JP_VOICE decides whether the full
// zip ships the JP files (default: yes) — held back, they are neither missing nor deleted by an update.
// The client's choice of line and its fallback: test/ui/audio.test.js (voiceLine).
// Run: node --test test/feedback7-voices.test.js
import { test } from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { buildPlan, VOICE_JP_LANG } from '../tools/assets/plan.mjs';
import { indexAudio, VOICE_DIRS } from '../tools/assets/audio.mjs';
import { RAW } from '../tools/assets/sources.mjs';
import { artPlan, FULL_ZIP_JP_VOICE, JP_VOICE_DIR } from '../tools/package.mjs';
import { DEFAULT_SETTINGS, VOICE_LANGS, sanitizeSettings } from '../public/js/ui/gameLogic.js';
const ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
const readJson = (rel) => JSON.parse(fs.readFileSync(path.join(ROOT, rel), 'utf8'));
/** A charword_table.json with two operators: one the game fields (planned), one it does not (a 预备干员: no voice). */
const CHARWORD = {
charWords: {
a: { charId: 'char_263_skadi', wordKey: 'char_263_skadi', placeType: 'BATTLE_SELECT', voiceId: 'CN_021', voiceIndex: 21, voiceAsset: 'char_263_skadi/CN_021' },
b: { charId: 'char_263_skadi', wordKey: 'char_263_skadi', placeType: 'BATTLE_SELECT', voiceId: 'CN_022', voiceIndex: 22, voiceAsset: 'char_263_skadi/CN_022' },
c: { charId: 'char_263_skadi', wordKey: 'char_263_skadi', placeType: 'BATTLE_START', voiceId: 'CN_019', voiceIndex: 19, voiceAsset: 'char_263_skadi/CN_019' },
d: { charId: 'char_263_skadi', wordKey: 'char_263_skadi', placeType: 'GACHA', voiceId: 'CN_011', voiceIndex: 11, voiceAsset: 'char_263_skadi/CN_011' },
e: { charId: 'char_602_cdfend', wordKey: 'char_602_cdfend', placeType: 'BATTLE_SELECT', voiceId: 'CN_021', voiceIndex: 21, voiceAsset: 'char_602_cdfend/CN_021' },
},
};
const OPS = { char_263_skadi: { name: '斯卡蒂', skills: [] } };
const plan = (extra = {}) => buildPlan({ assets07: {}, ops03: {}, enemies05: {}, maps05: {}, audio: indexAudio({}), modelsData: {},
charword: CHARWORD, extraOperators: OPS, ...extra }).template;
test('plan: audio.voiceJp is the JP dub (ArknightsAssets2 voice/) of the very slots and file names of audio.voice, under audio/voice/jp/', () => {
assert.equal(VOICE_JP_LANG, 'jp');
assert.equal(VOICE_DIRS.jp, 'voice', 'the dump folder of the JP dub');
const t = plan();
const cn = t.audio.voice.char_263_skadi;
const jp = t.audio.voiceJp.char_263_skadi;
assert.deepEqual(Object.keys(jp).sort(), ['select', 'start'], 'the battle slots only, like the Chinese tree (干员报到 not planned)');
assert.deepEqual(Object.keys(jp).sort(), Object.keys(cn).sort());
assert.equal(jp.select.length, 2, 'one leaf per line: 选中干员1 / 2');
assert.deepEqual(jp.select.map((l) => l.alts[0].rel), ['audio/voice/jp/char_263_skadi/cn_021.mp3', 'audio/voice/jp/char_263_skadi/cn_022.mp3']);
assert.deepEqual(jp.select[0].alts[0].urls, [`${RAW.aa2voice}voice/char_263_skadi/cn_021.mp3`]);
assert.deepEqual(cn.select.map((l) => l.alts[0].rel), ['audio/voice/cn/char_263_skadi/cn_021.mp3', 'audio/voice/cn/char_263_skadi/cn_022.mp3']);
assert.deepEqual(cn.select[0].alts[0].urls, [`${RAW.aa2voice}voice_cn/char_263_skadi/cn_021.mp3`]);
assert.equal(jp.start.alts[0].rel, 'audio/voice/jp/char_263_skadi/cn_019.mp3');
assert.equal(t.audio.voiceJp.char_602_cdfend, undefined, 'an operator the game does not field (a stand-in) gets neither tree');
assert.equal(t.audio.voice.char_602_cdfend, undefined);
assert.deepEqual(Object.keys(t.audio), ['bgm', 'bossBgm', 'voice', 'voiceJp', 'sfx'], 'voiceJp right after voice');
// --voice-lang puts another dub in audio.voice; voiceJp stays the JP one; voiceJp: false plans no JP tree
const en = plan({ voiceLang: 'en' });
assert.equal(en.audio.voice.char_263_skadi.start.alts[0].rel, 'audio/voice/en/char_263_skadi/cn_019.mp3');
assert.equal(en.audio.voiceJp.char_263_skadi.start.alts[0].rel, 'audio/voice/jp/char_263_skadi/cn_019.mp3');
assert.equal(plan({ voiceJp: false }).audio.voiceJp, undefined);
// --voice-all widens both trees alike
assert.ok(plan({ voiceSlots: null }).audio.voiceJp.char_263_skadi.gacha);
});
test('data/assets.json: voiceJp gives every voiced operator the JP twin of each Chinese line (192 operators, 2688 files); stand-ins, 盟约·辅助干员 and summons have neither', () => {
const m = readJson('data/assets.json');
const cn = m.audio.voice;
const jp = m.audio.voiceJp;
assert.ok(jp && typeof jp === 'object', 'the manifest carries the JP tree');
assert.deepEqual(Object.keys(jp), Object.keys(cn), 'the same operators');
assert.equal(m.stats.voiceJpChars, Object.keys(jp).length);
assert.equal(m.stats.voiceChars, Object.keys(cn).length);
assert.equal(Object.keys(jp).length, 192);
// the JP tree is the Chinese one with the folder swapped: same slots, same lines in the same order, same file names
assert.deepEqual(JSON.parse(JSON.stringify(cn).replaceAll('/assets/audio/voice/cn/', '/assets/audio/voice/jp/')), jp);
const lines = [];
const walk = (x) => { if (typeof x === 'string') lines.push(x); else if (Array.isArray(x)) x.forEach(walk); else if (x && typeof x === 'object') Object.values(x).forEach(walk); };
walk(jp);
assert.equal(lines.length, 2688);
for (const u of lines) assert.match(u, /^\/assets\/audio\/voice\/jp\/char_[^/]+\/cn_\d+\.mp3$/);
// no voice: the 17 stand-ins of data/backups.json (预备干员 / 原型干员), the mode's 盟约·辅助干员, every summon
const backups = readJson('data/backups.json');
const standIns = Object.entries(backups.units).filter(([, u]) => Array.isArray(u.standsIn) ? u.standsIn.length : u.standsIn).map(([id]) => id);
assert.equal(standIns.length, 17);
for (const id of [...standIns, 'char_616_pithst', ...Object.keys(readJson('data/tokens.json'))]) {
assert.equal(jp[id], undefined, `${id}: no JP line`);
assert.equal(cn[id], undefined, `${id}: no line`);
}
// the files themselves, where this checkout has downloaded the art
const dir = path.join(ROOT, 'public', 'assets', 'audio', 'voice', 'jp');
if (fs.existsSync(dir)) for (const u of lines) assert.ok(fs.statSync(path.join(ROOT, 'public', u)).size > 0, u);
});
test('settings 语音语言: 中文 by default, 日本語 kept, nothing else; the row is translated in every language pack', () => {
assert.deepEqual([...VOICE_LANGS], ['cn', 'jp']);
assert.equal(DEFAULT_SETTINGS.voiceLang, 'cn', 'not tied to the interface language: 中文 until the player picks 日本語');
assert.equal(sanitizeSettings({}).voiceLang, 'cn');
assert.equal(sanitizeSettings({ voiceLang: 'jp' }).voiceLang, 'jp');
assert.equal(sanitizeSettings({ voiceLang: 'en' }).voiceLang, 'cn', 'no English / Korean dub');
const ui = fs.readFileSync(path.join(ROOT, 'public/js/ui/settings.js'), 'utf8');
assert.match(ui, /updateSettings\(\{ voiceLang: id \}\)/);
assert.match(ui, /const VOICE_LANG_NAMES = \{ cn: '中文', jp: '日本語' \};/, 'each dub named in its own language');
for (const code of ['en', 'ja', 'ko', 'zh-TW']) {
const pack = readJson(`public/i18n/${code}.json`);
assert.ok(typeof pack['语音语言'] === 'string' && pack['语音语言'] && pack['语音语言'] !== '语音语言', `${code}: 语音语言`);
}
});
test('package: the full zip ships the JP dub by default; FULL_ZIP_JP_VOICE off holds it back (not missing, not an orphan, never removed by an update); the lite zip has no art', () => {
assert.equal(FULL_ZIP_JP_VOICE, true, '0.2.2 ships both dubs in the full zip');
assert.equal(JP_VOICE_DIR, 'public/assets/audio/voice/jp/');
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'sp-voicejp-'));
try {
const cnRel = 'public/assets/audio/voice/cn/char_263_skadi/cn_021.mp3';
const jpRel = 'public/assets/audio/voice/jp/char_263_skadi/cn_021.mp3';
const jpRel2 = 'public/assets/audio/voice/jp/char_263_skadi/cn_022.mp3';
for (const rel of [cnRel, jpRel]) { fs.mkdirSync(path.dirname(path.join(dir, rel)), { recursive: true }); fs.writeFileSync(path.join(dir, rel), 'ID3'); }
fs.mkdirSync(path.join(dir, 'data'));
const url = (rel) => `/${rel.slice('public/'.length)}`;
fs.writeFileSync(path.join(dir, 'data', 'assets.json'), JSON.stringify({ audio: {
voice: { char_263_skadi: { select: url(cnRel) } }, voiceJp: { char_263_skadi: { select: [url(jpRel), url(jpRel2)] } } } }));
const on = artPlan(dir);
assert.ok(on.files.includes(cnRel) && on.files.includes(jpRel), 'both dubs ship');
assert.deepEqual(on.missing, [jpRel2], 'a listed JP file not on disk is missing, like any art');
assert.deepEqual(on.held, []);
const off = artPlan(dir, { jpVoice: false });
assert.ok(off.files.includes(cnRel), 'the Chinese dub ships');
assert.ok(!off.files.includes(jpRel), 'the JP files stay out of the full zip');
assert.deepEqual(off.held, [jpRel, jpRel2], 'held back on purpose (setup downloads them; an update never deletes them)');
assert.deepEqual(off.missing, [], 'not missing');
assert.deepEqual(off.orphans, [], 'not orphans either');
assert.deepEqual(artPlan(dir, { lite: true }).files, [], 'the lite zip carries no art');
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
// the update zip keeps a held file out of `removed` (tools/package.mjs buildUpdate)
const src = fs.readFileSync(path.join(ROOT, 'tools/package.mjs'), 'utf8');
assert.match(src, /removable: \(rel\) => !removalProblem\(rel\) && !heldBack\.has\(rel\)/);
});
test('选中干员 on every card tap (review of fb7-voices): two shop / reward cards of one operator are two taps, a re-render is none', async () => {
// shop / reward cards hand only the chess id to the detail target (no piece, no unit): game.js numbers every card it opens
// (`tap`), resolveDetail keeps it, and the panel's 选中 key (selectVoiceKey) includes it — the second 斯卡蒂 card spoke
// nothing before, nor replaced the first card's line (the pool deals duplicates)
const origFetch = globalThis.fetch;
globalThis.fetch = async (url) => {
const name = String(url).split('/').pop();
let body;
try { body = readJson(`data/${name}`); } catch { return { ok: false, status: 404, json: async () => ({}) }; }
return { ok: true, status: 200, json: async () => body };
};
try {
const { resolveDetail, selectVoiceKey } = await import('../public/js/ui/detailPanel.js');
const { data } = await import('../public/js/data.js');
await data.loadAll('chess', 'backups');
const id = Object.values(readJson('data/chess.json')).find((c) => c.charId === 'char_263_skadi' && !c.isGolden).chessId;
const card = (tap) => resolveDetail({ kind: 'chess', id, hint: null, tap }, new Map(), { priv: null });
const a = card(1), b = card(2);
assert.equal(a.chess.charId, 'char_263_skadi');
assert.deepEqual([a.tap, b.tap], [1, 2], 'the tap rides along');
assert.notEqual(selectVoiceKey(a), selectVoiceKey(b), 'the second card of the same operator is a new opening');
assert.equal(selectVoiceKey(card(1)), selectVoiceKey(a), 'the same tap resolved again (a re-render) says nothing more');
assert.equal(resolveDetail({ kind: 'chess', id }, new Map(), { priv: null }).tap, undefined, 'no tap, no field');
// pieces and battle units keep their own identity; a non-operator card has no key
assert.equal(selectVoiceKey({ type: 'chess', chess: { chessId: 'x' }, piece: { uid: 4 } }), 'x:4:');
assert.equal(selectVoiceKey({ type: 'chess', chess: { chessId: 'x' }, unitId: 9 }), 'x:9:');
assert.equal(selectVoiceKey({ type: 'item' }), null);
assert.equal(selectVoiceKey(null), null);
// the panel keys its 选中 effect on it, and the game screen numbers every operator card it opens
assert.match(fs.readFileSync(path.join(ROOT, 'public/js/ui/detailPanel.js'), 'utf8'), /const selectKey = voice && detail\?\.type === 'chess' \? selectVoiceKey\(detail\) : null;/);
const game = fs.readFileSync(path.join(ROOT, 'public/js/screens/game.js'), 'utf8');
assert.match(game, /onDetail=\$\{\(id, kind, hint\) => setDetail\(\{ kind: kind === 'item' \? 'item' : 'chess', id, hint: hint \|\| null, tap: \+\+cardTap\.current \}\)\}/);
} finally {
globalThis.fetch = origFetch;
}
});