diablo2-web/tests/client/frontend-i18n-manifest-batc...

227 lines
8.8 KiB
TypeScript

import { existsSync, readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import {
detectLangFromUrl,
onGameLangChange,
onLangChange,
resolveLangFromPathname,
setGameLang,
setLang,
} from '../../src/client/i18n/lang.ts'
const ROOT_DIR = resolve(import.meta.dirname, '../..')
const FRONTEND_DIR = resolve(ROOT_DIR, 'public/ui/frontend')
const MANIFEST_PATH = resolve(FRONTEND_DIR, 'manifest.json')
const MAIN_TS_PATH = resolve(ROOT_DIR, 'src/client/main.ts')
function readPngHeader(filePath: string): { readonly width: number; readonly height: number } {
const buf = readFileSync(filePath)
expect(buf[0]).toBe(0x89)
expect(buf[1]).toBe(0x50)
expect(buf[2]).toBe(0x4e)
expect(buf[3]).toBe(0x47)
return {
width: buf.readUInt32BE(16),
height: buf.readUInt32BE(20),
}
}
describe('Batch 7 & 8 Phase 2 — Campfire Overlays, Manifest TBL Strings, main.ts Guards & i18n Fail-Fast (#702, #712, #697, #698, #699, #700, #704, #707, #708, #710)', () => {
const manifest = JSON.parse(readFileSync(MANIFEST_PATH, 'utf8')) as {
readonly animStrips: Record<
string,
{
readonly file: string
readonly width: number
readonly height: number
readonly frameCount: number
readonly rate?: number
readonly drawMode?: number
}
>
readonly campfireClasses: Record<
string,
Record<
string,
{
readonly file: string
readonly width: number
readonly height: number
readonly frameCount: number
readonly rate?: number
readonly drawMode?: number
readonly frames: readonly unknown[]
}
>
>
readonly strings: Record<string, unknown> & {
readonly lobby: Record<string, unknown>
}
}
afterEach(() => {
setGameLang(null)
})
it('#712: lang.ts notifyListeners propagates listener errors instead of swallowing them', () => {
const unsub = onLangChange(() => {
throw new Error('broken tbl listener')
})
try {
expect(() => setLang('en')).toThrow('broken tbl listener')
} finally {
unsub()
}
const calls: string[] = []
const u1 = onGameLangChange((l) => {
calls.push(`first:${l}`)
throw new Error('first error')
})
const u2 = onGameLangChange((l) => {
calls.push(`second:${l}`)
})
try {
expect(() => setGameLang('zh')).toThrow('first error')
expect(calls).toEqual(['first:zh', 'second:zh'])
} finally {
u1()
u2()
}
expect(resolveLangFromPathname('/acts-en.html')).toBe('en')
expect(resolveLangFromPathname('/acts-zh.html')).toBe('zh')
expect(resolveLangFromPathname('/acts.html')).toBe('zh')
expect(detectLangFromUrl('http://localhost/acts-en.html')).toBe('ENG')
expect(detectLangFromUrl('http://localhost/acts-zh.html')).toBe('CHI')
expect(detectLangFromUrl('http://localhost/acts.html')).toBe('CHI')
})
it('#702: bakes Barbarian (bafws.dc6) and Paladin (pafws.dc6) forward overlays with drawMode=5 and ne/so overlays with drawMode=3', () => {
const baFwOv = manifest.campfireClasses.ba?.fw_ov
const paFwOv = manifest.campfireClasses.pa?.fw_ov
expect(baFwOv).toBeDefined()
expect(paFwOv).toBeDefined()
expect(baFwOv?.drawMode).toBe(5)
expect(paFwOv?.drawMode).toBe(5)
expect(baFwOv?.frameCount).toBe(manifest.campfireClasses.ba?.fw?.frameCount)
expect(paFwOv?.frameCount).toBe(manifest.campfireClasses.pa?.fw?.frameCount)
// Verify PNG files exist on disk under both flat (`campfire/ba-fw-ov.png`) and nested (`campfire/ba/fw_ov.png`) paths
for (const relPath of [
baFwOv!.file,
paFwOv!.file,
'campfire/ba/fw_ov.png',
'campfire/pa/fw_ov.png',
]) {
const fullPath = resolve(FRONTEND_DIR, relPath)
expect(existsSync(fullPath)).toBe(true)
const hdr = readPngHeader(fullPath)
expect(hdr.width).toBeGreaterThan(0)
expect(hdr.height).toBeGreaterThan(0)
}
const baPng = readPngHeader(resolve(FRONTEND_DIR, baFwOv!.file))
expect(baPng.width).toBe(baFwOv!.width)
expect(baPng.height).toBe(baFwOv!.height)
const paPng = readPngHeader(resolve(FRONTEND_DIR, paFwOv!.file))
expect(paPng.width).toBe(paFwOv!.width)
expect(paPng.height).toBe(paFwOv!.height)
// Necromancer and Sorceress additive overlays use drawMode 3 (DRAWMODE_MODULATE)
for (const clsId of ['ne', 'so'] as const) {
for (const ovKey of ['nu3_ov', 'fw_ov', 'bw_ov'] as const) {
const ov = manifest.campfireClasses[clsId]?.[ovKey]
expect(ov).toBeDefined()
expect(ov?.drawMode).toBe(3)
}
}
expect(manifest.animStrips.campfireFlame?.drawMode).toBe(3)
})
it('#698, #699, #700, #704, #708: manifest.json contains canonical .tbl strings without fake suffixes or hardcoded expiresIn90Days', () => {
const s = manifest.strings
// #698: Character expiration .tbl 11132, 11133, 11134; hardcoded expiresIn90Days removed
expect(s.expiresIn90Days).toBeUndefined()
expect(s['11132']).toBe('Expires Today')
expect(s['11133']).toBe('Expires in %d days')
expect(s['11134']).toBe('Character Expired')
expect(s.expiresToday).toBe('Expires Today')
expect(s.expiresInDays).toBe('Expires in %d days')
expect(s.characterExpired).toBe('Character Expired')
// #699: Canonical BNet connect status strings 5171..5177 without "..." suffixes
expect(s['5171']).toBe('Connecting to Battle.net')
expect(s['5172']).toBe('Querying gateway')
expect(s['5173']).toBe('Connecting to the fastest server')
expect(s['5174']).toBe('Accessing your account')
expect(s['5175']).toBe('Checking versions')
expect(s['5176']).toBe('Verifying CD-key')
expect(s['5177']).toBe('Getting player profile')
expect(s.connectingToFastest).toBe('Connecting to the fastest server')
expect(s.accessingAccount).toBe('Accessing your account')
expect(s.checkingVersions).toBe('Checking versions')
expect(String(s.connectingToFastest).endsWith('...')).toBe(false)
expect(String(s.accessingAccount).endsWith('...')).toBe(false)
expect(String(s.checkingVersions).endsWith('...')).toBe(false)
// #700: Login title (5204) and instruction (5205)
expect(s['5204']).toBe('Battle.net Log In')
expect(s['5205']).toBe(
"To use an existing Battle.net account, enter the information below and click 'Log In'. Otherwise, click 'Create New Account'.",
)
expect(s.loginTitle).toBe(s['5204'])
expect(s.loginInstruction).toBe(s['5205'])
// #704: Chat gem .tbl 5334, 5335, 5336
expect(s['5334']).toBe('Gem Activated')
expect(s['5335']).toBe('Gem Deactivated')
expect(s['5336']).toBe('Perfect Gem Activated')
expect(s.lobby.gemActivated).toBe('Gem Activated')
expect(s.lobby.gemDeactivated).toBe('Gem Deactivated')
expect(s.lobby.perfectGemActivated).toBe('Perfect Gem Activated')
// #707 & #708: Create Account instruction (5223), Please Read paragraphs (5228..5230), Level & class names
expect(s['5223']).toBe('Enter the Name and Password for your new battle.net account.')
expect(s.createAccountInstruction).toBe(s['5223'])
expect(typeof s.pleaseReadPara1).toBe('string')
expect(typeof s.pleaseReadPara2).toBe('string')
expect(typeof s.pleaseReadPara3).toBe('string')
expect(s['4057']).toBe('Level')
expect(s['5017']).toBe('Level %d')
expect(s['4007']).toBe('Barbarian')
expect(s['4008']).toBe('Paladin')
expect(s['4009']).toBe('Necromancer')
expect(s['4010']).toBe('Sorceress')
expect(s['4011']).toBe('Amazon')
expect(s['4012']).toBe('Druid')
expect(s['4013']).toBe('Assassin')
})
it('#697, #699, #704, #707, #710: main.ts has zero invented English validation popups, zero st.gateway hostname fallback, and zero silent difficulty downgrade', () => {
const mainSrc = readFileSync(MAIN_TS_PATH, 'utf8')
// #697 & #707: Zero invented English validation error strings
expect(mainSrc).not.toContain('PLEASE ENTER AN ACCOUNT NAME.')
expect(mainSrc).not.toContain('CHARACTER NAME MUST BE 2-15 LETTERS.')
expect(mainSrc).not.toContain('PLEASE ENTER A GAME NAME.')
expect(mainSrc).not.toContain('MAX NUMBER OF PLAYERS MUST BE 1-8.')
expect(mainSrc).not.toContain('CHARACTER DIFFERENCE MUST BE 1-99.')
// #704: Never use display gateway name (`st.gateway`) as network hostname fallback
expect(mainSrc).not.toContain('|| st.gateway')
// #710: Never silently downgrade locked difficulty to 'normal'
expect(mainSrc).not.toContain("st.difficulty = 'normal'")
expect(mainSrc).toContain('is not unlocked for character')
// #699: onConnect wires canonical BNCS status stages (5171, 5173) into bnet_login
expect(mainSrc).toContain("manifestStrings?.['5171']")
expect(mainSrc).toContain("manifestStrings?.['5173']")
})
})