1. VoiceGate.start no longer moves lastAt for VOICE_TAP_SLOTS: a tap still skips the 1.2 s gap but starts none, so a 部署 / 技能 / 接敌 the battle asks for once after a short 选中 line has ended plays where the last battle line's gap ends (a 部署 at 0, a tap at 500: a 作战中 at 1300 was dropped, now plays; inside the 部署 gap it still waits). 2. Shop / reward cards hand only the chess id to the detail, so two cards of one operator (the pool deals duplicates) had one 选中 key and the second tap said nothing nor replaced the first line: game.js numbers every card it opens (`tap`, cardTap), resolveDetail keeps it, and detailPanel's new selectVoiceKey() includes it (a re-render of the same tap keeps its key; pieces / units unchanged). Checked in the mock match: the second card of 歌蕾蒂娅 now calls voice() (it made no call on the parent). 3. tools/package.mjs FULL_ZIP_JP_VOICE comment: 89,388,041 bytes = 85.2 MiB on disk, about 76 MB zipped (as ASSETS / DEPLOY), not "~93 MB, barely compresses". Tests: test/ui/audio.test.js (the tap-gap case; one earlier assertion that encoded the old gap after a tap replaced), test/feedback7-voices.test.js (new: the card tap in resolveDetail / selectVoiceKey / game.js) — both fail on the parent. §21.30's 0.2.2 note updated.
177 lines
13 KiB
JavaScript
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 (191 operators, 2674 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, 191);
|
|
// 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, 2674);
|
|
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;
|
|
}
|
|
});
|