feedback7 R22-VOICES: the Japanese voice dub as a second tree beside the Chinese one — audio.voiceJp (the same slots and file names from ArknightsAssets2 voice/: 2674 files, 89.4 MB, for the 191 voiced operators), 设置 → 语音语言 中文 / 日本語 (default 中文, not tied to the interface language), a JP line the manifest or the host lacks plays the Chinese one; the full zip ships both dubs (one switch, tools/package.mjs FULL_ZIP_JP_VOICE), the lite zip stays art-free (the owner's request of 2026-10-08 「全套的日配语音」); golden: unchanged
- tools/assets/plan.mjs: one voiceTree() per dub — audio.voice (--voice-lang, cn by default) and audio.voiceJp (VOICE_JP_LANG, always the JP dub, audio/voice/jp/<charId>/cn_nnn.mp3); fetch-assets plans and downloads both (stats.voiceJpChars), so setup (checkAssets walks every listed URL) fetches both too. - data/assets.json regenerated with the project's tool (node tools/fetch-assets.mjs --add-only, raw.githubusercontent.com, 107 s): the only change is audio.voiceJp + stats (+2674 files, +89,388,041 bytes, voiceJpChars 191) + hash — the rest is byte-identical; the JP tree is the Chinese one with the folder swapped (191 × 14 files, every file a valid MP3). The audio files are not committed (public/assets/ is git-ignored). - public/js/audio.js voiceLine(): 'jp' draws from voiceJp and falls back to the Chinese line per slot (a JP entry the manifest lacks) and per line at play time (the Chinese file of the same name when the host lacks the JP one — same token, same gate); operators without voice (stand-ins, 盟约·辅助干员, summons) stay silent. setVoiceLang(). - settings: voiceLang 'cn' | 'jp' (VOICE_LANGS, sanitizeSettings), the 语音语言 row under 干员语音 (each dub named in its own language, like the language switch), 语音语言 in the en / ja / ko / zh-TW packs. - tools/package.mjs FULL_ZIP_JP_VOICE (true): false holds the JP files out of the full zip (and its update zip) — not missing, not orphans, never in an update's `removed`; setup then downloads them. - Sizes measured: art download 548 MiB (was 463), full zip ≈ 505 MB (0.2.1: 428 MiB + 76 MiB of zipped JP voice), unzipped 711 MB (package --dry-run); lite zip unchanged. README, DEPLOY, ASSETS, setup's fallback size updated; PLAYING voice paragraph; §21.30 "Changed in 0.2.2" note. Tests: test/feedback7-voices.test.js (new: plan, manifest, settings, package switch), test/ui/audio.test.js (voiceLine, the manager's dub choice and fallback), test/ui/gameLogic.test.js, test/docs-consistency.test.js — all fail on the parent.
This commit is contained in:
1 parent
cb8865d9d8
commit
ddf8c39ed0
22 files changed
+414
-76
No files matched your search
@@ -909,6 +909,13 @@ test('干员战斗语音 (DESIGN §21.30): the manifest data, the official prior
|
||||
assert.match(DESIGN, /\*\*Where each line plays — battle only\.\*\*/);
|
||||
assert.match(doc('docs/ASSETS.md'), /\| 干员战斗语音 \|/);
|
||||
assert.match(doc('docs/ASSETS.md'), /voiceChars/);
|
||||
// 0.2.2: the Japanese dub beside the Chinese one (audio.voiceJp, settings 语音语言, the full zip's switch)
|
||||
assert.equal(Object.keys(manifest.audio?.voiceJp ?? {}).length, charIds.length, 'every voiced operator has its JP tree');
|
||||
assert.equal(manifest.stats.voiceJpChars, charIds.length);
|
||||
assert.match(doc('docs/ASSETS.md'), /voiceJp: \{ \[charId\]: \{ …the slots of `voice` \} \}/);
|
||||
assert.match(doc('docs/DEPLOY.md'), /`FULL_ZIP_JP_VOICE` 改成 `false` 时/);
|
||||
assert.match(PLAYING, /「语音语言」选 \*\*中文 \/ 日本語\*\*(默认中文,和界面语言无关/);
|
||||
assert.match(PLAYING, /会说一句官方的「选中干员」语音,休整期也一样/);
|
||||
assert.match(SIM, /\['engage', id\]/);
|
||||
// the code: every slot the client asks for comes from a running battle's own stream — the three prep-only lines
|
||||
// (干员报到 / 编入队伍 / 任命队长) are never requested — except 选中干员, the detail panel's line on every tap of an
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
// 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\)/);
|
||||
});
|
||||
+64
-1
@@ -6,7 +6,7 @@ import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { bgmKeyFor, resolveBgm, SfxLimiter, AudioManager, normalAttackSfx, installAudio, audio, combatTrackFor, COMBAT_TRACK_SWITCH_ROUND, VoiceGate, resultSpeaker, resultVoiceSlot, VOICE_PRIORITY, VOICE_COOLDOWN_MS } from '../../public/js/audio.js';
|
||||
import { bgmKeyFor, resolveBgm, SfxLimiter, AudioManager, normalAttackSfx, installAudio, audio, combatTrackFor, COMBAT_TRACK_SWITCH_ROUND, VoiceGate, resultSpeaker, resultVoiceSlot, VOICE_PRIORITY, VOICE_COOLDOWN_MS, voiceLine } from '../../public/js/audio.js';
|
||||
import { mediaUrl } from '../../public/js/media.js';
|
||||
import { PHASE } from '../../shared/constants.js';
|
||||
import { makeBattle, chessRec } from '../helpers/battleHarness.js';
|
||||
@@ -300,6 +300,69 @@ describe('operator battle voice', () => {
|
||||
}
|
||||
});
|
||||
|
||||
test('语音语言 (0.2.2): 日本語 plays audio.voiceJp — the same slots and file names — and falls back to the Chinese line per slot and per line', async () => {
|
||||
const audioM = {
|
||||
voice: { char_a: { select: ['/a/voice/cn/char_a/cn_021.mp3', '/a/voice/cn/char_a/cn_022.mp3'], place: '/a/voice/cn/char_a/cn_023.mp3', start: '/a/voice/cn/char_a/cn_019.mp3' } },
|
||||
voiceJp: { char_a: { select: ['/a/voice/jp/char_a/cn_021.mp3', '/a/voice/jp/char_a/cn_022.mp3'], place: '/a/voice/jp/char_a/cn_023.mp3' } },
|
||||
};
|
||||
assert.deepEqual(voiceLine(audioM, 'char_a', 'place'), { url: '/a/voice/cn/char_a/cn_023.mp3', fallback: null }, '中文 by default');
|
||||
assert.deepEqual(voiceLine(audioM, 'char_a', 'place', 'cn'), { url: '/a/voice/cn/char_a/cn_023.mp3', fallback: null });
|
||||
assert.deepEqual(voiceLine(audioM, 'char_a', 'place', 'jp'), { url: '/a/voice/jp/char_a/cn_023.mp3', fallback: '/a/voice/cn/char_a/cn_023.mp3' });
|
||||
// a drawn line keeps its Chinese twin as the fallback (选中干员2 ⇒ 选中干员2)
|
||||
assert.deepEqual(voiceLine(audioM, 'char_a', 'select', 'jp', () => 0.99), { url: '/a/voice/jp/char_a/cn_022.mp3', fallback: '/a/voice/cn/char_a/cn_022.mp3' });
|
||||
assert.deepEqual(voiceLine(audioM, 'char_a', 'select', 'jp', () => 0), { url: '/a/voice/jp/char_a/cn_021.mp3', fallback: '/a/voice/cn/char_a/cn_021.mp3' });
|
||||
assert.deepEqual(voiceLine(audioM, 'char_a', 'start', 'jp'), { url: '/a/voice/cn/char_a/cn_019.mp3', fallback: null }, 'a slot the JP tree lacks: the Chinese line');
|
||||
assert.equal(voiceLine(audioM, 'char_zz', 'select', 'jp'), null, 'an operator no dub voices (stand-ins, 盟约·辅助干员, summons) stays silent');
|
||||
assert.equal(voiceLine({ voice: {} }, 'char_a', 'select', 'jp'), null);
|
||||
assert.equal(voiceLine(null, 'char_a', 'select', 'jp'), null);
|
||||
assert.deepEqual(voiceLine({ voice: audioM.voice }, 'char_a', 'place', 'jp'), { url: '/a/voice/cn/char_a/cn_023.mp3', fallback: null }, 'a manifest without voiceJp');
|
||||
// the real manifest: every operator with a Chinese line has its Japanese twin
|
||||
const real = voiceLine(manifest.audio, 'char_263_skadi', 'select', 'jp', () => 0);
|
||||
assert.match(real.url, /^\/assets\/audio\/voice\/jp\/char_263_skadi\/cn_021\.mp3$/);
|
||||
assert.equal(real.fallback, '/assets/audio/voice/cn/char_263_skadi/cn_021.mp3');
|
||||
|
||||
// the manager: the setting picks the tree; a JP file the host lacks (404) plays the Chinese one, holding the channel
|
||||
const fw = fakeWindow();
|
||||
const origFetch = globalThis.fetch;
|
||||
const urls = [];
|
||||
const missing = new Set(['/a/voice/jp/char_a/cn_023.mp3', mediaUrl('/a/voice/jp/char_a/cn_023.mp3')]);
|
||||
globalThis.fetch = async (u) => { urls.push(u); return missing.has(u) ? { ok: false, status: 404 } : { ok: true, arrayBuffer: async () => new ArrayBuffer(8) }; };
|
||||
const tick = () => new Promise((r) => setTimeout(r, 10));
|
||||
try {
|
||||
const a = new AudioManager({ win: fw.win, getManifest: () => ({ audio: { sfx: { ui: {}, battle: {}, units: {} }, ...audioM } }) });
|
||||
a.voiceGate = new VoiceGate({ gapMs: 0 }); // the gap itself is covered above
|
||||
a.install();
|
||||
fw.fire('pointerdown');
|
||||
await tick();
|
||||
assert.equal(a.voiceLang, 'cn');
|
||||
a.setVoiceLang('jp');
|
||||
assert.equal(a.voiceLang, 'jp');
|
||||
assert.equal(a.voice('char_a', 'select'), true);
|
||||
await tick();
|
||||
assert.match(a.voiceNode?.url ?? '', /^\/a\/voice\/jp\/char_a\/cn_02[12]\.mp3$/, '日本語: the JP line');
|
||||
a._stopVoice();
|
||||
assert.equal(a.voice('char_a', 'place', { unitKey: 1 }), true);
|
||||
await tick(); await tick();
|
||||
assert.ok(asked(urls, '/a/voice/jp/char_a/cn_023.mp3'), 'the JP file was asked for first');
|
||||
assert.equal(a.voiceNode?.url, '/a/voice/cn/char_a/cn_023.mp3', 'the host lacks it: the Chinese line of the same name plays');
|
||||
assert.equal(a.voice('char_a', 'place', { unitKey: 2 }), false, 'the fallback holds the channel like any line');
|
||||
a._stopVoice();
|
||||
a.setVoiceLang('cn');
|
||||
assert.equal(a.voice('char_a', 'select'), true);
|
||||
await tick();
|
||||
assert.match(a.voiceNode?.url ?? '', /^\/a\/voice\/cn\/char_a\/cn_02[12]\.mp3$/, '中文 again');
|
||||
a._stopVoice();
|
||||
a.setVoiceLang('kr');
|
||||
assert.equal(a.voiceLang, 'cn', 'no other dub: anything but jp is 中文');
|
||||
// the settings store hands the choice over (installAudio and ui/settings.js)
|
||||
const settings = readFileSync(path.join(ROOT, 'public/js/ui/settings.js'), 'utf8');
|
||||
assert.match(settings, /audio\.setVoiceLang\(s\.voiceLang\)/);
|
||||
assert.match(settings, /t\('语音语言'\)/);
|
||||
} finally {
|
||||
globalThis.fetch = origFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test('AudioManager.voice: manifest slots (a drawn array), the gate, and the battle events that drive them', async () => {
|
||||
const fw = fakeWindow();
|
||||
const origFetch = globalThis.fetch;
|
||||
|
||||
@@ -642,9 +642,14 @@ describe('keyboard & settings', () => {
|
||||
test('sanitizeSettings', () => {
|
||||
assert.deepEqual(sanitizeSettings(null), { ...DEFAULT_SETTINGS });
|
||||
assert.deepEqual(sanitizeSettings({ bgm: 3, sfx: -1, voice: 2, muted: 'yes', damageNumbers: false, quality: 'ultra' }),
|
||||
{ bgm: 1, sfx: 0, voice: 1, muted: false, damageNumbers: false, quality: 'high', keys: { ...DEFAULT_SETTINGS.keys } },
|
||||
{ bgm: 1, sfx: 0, voice: 1, voiceLang: 'cn', muted: false, damageNumbers: false, quality: 'high', keys: { ...DEFAULT_SETTINGS.keys } },
|
||||
'a saved profile without `keys` (before 0.2.0) gets the default key map (test/ui/feedback5-hotkeys.test.js)');
|
||||
assert.equal(sanitizeSettings({ bgm: 0.5 }).voice, DEFAULT_SETTINGS.voice, 'a saved profile without `voice` gets the default');
|
||||
// 语音语言 (0.2.2): 中文 by default — a profile saved before it, or any other value, plays the Chinese dub
|
||||
assert.equal(DEFAULT_SETTINGS.voiceLang, 'cn');
|
||||
assert.equal(sanitizeSettings({ bgm: 0.5 }).voiceLang, 'cn');
|
||||
assert.equal(sanitizeSettings({ voiceLang: 'jp' }).voiceLang, 'jp');
|
||||
for (const bad of ['en', 'kr', 'JP', 'ja', 1, null]) assert.equal(sanitizeSettings({ voiceLang: bad }).voiceLang, 'cn', String(bad));
|
||||
assert.equal(sanitizeSettings({ bgm: 0.333 }).bgm, 0.33);
|
||||
assert.equal(sanitizeSettings({ quality: 'low' }).quality, 'low');
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user