diablo2-web/tests/issue-503-bilingual-parity....

466 lines
14 KiB
TypeScript

/**
* tests/issue-503-bilingual-parity.test.ts
*
* Comprehensive unit test suite for Issue #503:
* Full Chinese (`CHI`) and English (`ENG`) Bilingual Versions of Diablo II v1.13c Web Port.
*/
import { afterEach, describe, expect, it, vi } from 'vitest'
import { existsSync, readFileSync } from 'node:fs'
import { join } from 'node:path'
import {
buildLangSwitchHref,
detectLangFromUrl,
getGameLang,
getTblLang,
isEn,
isEnglish,
onTblLangChange,
setTblLang,
} from '../src/i18n/lang.ts'
import {
CANONICAL_CHI_TBL_DICT,
CANONICAL_ENG_TBL_DICT,
resolveTblKey,
} from '../src/data/canonical-tbl-strings.ts'
import {
ACT_NAMES_EN,
ACT_NAMES_ZH,
levelDisplayName,
sceneDisplayName,
variantDisplayLabel,
} from '../src/game/level-names-zh.ts'
import { formatEliteAffixLabel, monsterDisplayName } from '../src/game/monsters.ts'
import { getLocalizedNpcName } from '../src/game/npc-table.ts'
import { CharacterSheetPanel } from '../src/ui/character-sheet.ts'
import { InventoryPanel } from '../src/ui/inventory.ts'
import { SkillTreePanel } from '../src/ui/skill-tree-panel.ts'
import { WorldPanelsHud } from '../src/ui/world-panels.ts'
import { ControlBarHud } from '../src/ui/control-bar.ts'
import { GlobesHud } from '../src/ui/globes.ts'
import { SkillHotkeysHud } from '../src/ui/hotkeys.ts'
import type { D2FontRenderer } from '../src/ui/font.ts'
const CJK_REGEX = /[\u4e00-\u9fff]/
function createMockCanvasAndFont() {
const textCalls: string[] = []
const ctx = {
drawImage: vi.fn(),
fillRect: vi.fn(),
strokeRect: vi.fn(),
fillText: vi.fn((text: string) => {
textCalls.push(String(text))
}),
beginPath: vi.fn(),
moveTo: vi.fn(),
lineTo: vi.fn(),
arc: vi.fn(),
closePath: vi.fn(),
fill: vi.fn(),
stroke: vi.fn(),
save: vi.fn(),
restore: vi.fn(),
clip: vi.fn(),
rect: vi.fn(),
setLineDash: vi.fn(),
createLinearGradient: vi.fn(() => ({ addColorStop: vi.fn() })),
createRadialGradient: vi.fn(() => ({ addColorStop: vi.fn() })),
measureText: vi.fn((t: string) => ({ width: String(t).length * 8 })),
fillStyle: '',
strokeStyle: '',
lineWidth: 1,
globalAlpha: 1,
font: '',
textAlign: 'left',
textBaseline: 'alphabetic',
} as unknown as CanvasRenderingContext2D
const font = {
isLoaded: true,
measureText: vi.fn((t: string) => String(t).length * 8),
drawText: vi.fn((_ctx: CanvasRenderingContext2D, text: string) => {
textCalls.push(String(text))
}),
} as unknown as D2FontRenderer
return { ctx, font, textCalls }
}
describe('Issue #503: Bilingual Language Detection & State (src/i18n/lang.ts)', () => {
afterEach(() => {
setTblLang(null)
})
it('detects ENG vs CHI from URL paths and query parameters', () => {
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')
expect(detectLangFromUrl('http://localhost/acts.html?lang=en')).toBe('ENG')
expect(detectLangFromUrl('http://localhost/acts-zh.html?lang=en')).toBe('ENG')
expect(detectLangFromUrl('http://localhost/acts-en.html?lang=zh')).toBe('CHI')
})
it('updates runtime language state and notifies listeners on setTblLang', () => {
const observed: string[] = []
const unsub = onTblLangChange(l => observed.push(l))
setTblLang('ENG')
expect(getTblLang()).toBe('ENG')
expect(getGameLang()).toBe('en')
expect(isEn()).toBe(true)
expect(isEnglish()).toBe(true)
setTblLang('CHI')
expect(getTblLang()).toBe('CHI')
expect(getGameLang()).toBe('zh')
expect(isEn()).toBe(false)
expect(isEnglish()).toBe(false)
unsub()
expect(observed).toEqual(['ENG', 'CHI'])
})
it('builds language switch href preserving act/level/hero/lighting/density/elite query params', () => {
expect(
buildLangSwitchHref(
'ENG',
'/acts-zh.html',
'?act=2&level=40-lut-gholein&hero=pa&lighting=sunset&density=2&elite=4&lang=zh',
),
).toBe('/acts-en.html?act=2&level=40-lut-gholein&hero=pa&lighting=sunset&density=2&elite=4')
expect(
buildLangSwitchHref(
'CHI',
'/acts-en.html',
'?act=4&level=103-the-pandemonium-fortress&hero=ne',
),
).toBe('/acts-zh.html?act=4&level=103-the-pandemonium-fortress&hero=ne')
})
})
describe('Issue #503: HTML Entry Pages & Zero-CJK Invariant in acts-en.html', () => {
it('verifies index.html, acts-zh.html, acts-en.html, and acts.html exist with required bilingual controls', () => {
const root = process.cwd()
const indexHtml = readFileSync(join(root, 'index.html'), 'utf8')
const actsZhHtml = readFileSync(join(root, 'acts-zh.html'), 'utf8')
const actsEnHtml = readFileSync(join(root, 'acts-en.html'), 'utf8')
const actsHtml = readFileSync(join(root, 'acts.html'), 'utf8')
expect(indexHtml).toContain('id="enter-zh"')
expect(indexHtml).toContain('id="enter-en"')
expect(indexHtml).toContain('acts-zh.html')
expect(indexHtml).toContain('acts-en.html')
expect(actsZhHtml).toContain('lang="zh-CN"')
expect(actsZhHtml).toContain('id="lang-switch"')
expect(actsHtml).toContain('id="lang-switch"')
expect(actsEnHtml).toContain('lang="en"')
expect(actsEnHtml).toContain('id="lang-switch"')
expect(CJK_REGEX.test(actsEnHtml)).toBe(false)
})
})
describe('Issue #503: Canonical 1.13c .tbl Dictionary & Domain Lookups', () => {
afterEach(() => {
setTblLang(null)
})
it('provides comprehensive ENG and CHI .tbl strings for UI, monsters, and NPCs', () => {
expect(Object.keys(CANONICAL_ENG_TBL_DICT).length).toBeGreaterThan(3500)
expect(Object.keys(CANONICAL_CHI_TBL_DICT).length).toBeGreaterThan(3000)
expect(resolveTblKey('strBSArmor', 'ENG')).toBe('Armor')
expect(resolveTblKey('strBSWeapons', 'ENG')).toBe('Weapons')
expect(resolveTblKey('strBSMisc', 'ENG')).toBe('Misc')
expect(resolveTblKey('Fallen', 'ENG')).toBe('Fallen')
expect(resolveTblKey('Fallen', 'CHI')).toBe('沈淪魔')
expect(resolveTblKey('Bishibosh', 'ENG')).toBe('Bishibosh')
expect(resolveTblKey('Bishibosh', 'CHI')).toBe('畢須博須')
})
it('resolves level, scene, variant, monster, affix, and NPC names in both ENG and CHI', () => {
expect(ACT_NAMES_EN[1]).toBe('Act I')
expect(ACT_NAMES_ZH[1]).toBe('第一章')
expect(levelDisplayName(1, 'ENG')).toBe('Rogue Encampment')
expect(levelDisplayName(1, 'CHI')).toBe('罗格营地')
expect(sceneDisplayName('1-rogue-encampment', 'Rogue Encampment', 'ENG')).toBe('Rogue Encampment')
expect(sceneDisplayName('1-rogue-encampment', 'Rogue Encampment', 'CHI')).toBe('罗格营地')
expect(variantDisplayLabel('var1', 'ENG')).toBe('Variant 1')
expect(variantDisplayLabel('var1', 'CHI')).toBe('变体 1')
expect(variantDisplayLabel('towns1', 'ENG')).toBe('South Gate')
expect(variantDisplayLabel('towns1', 'CHI')).toBe('南门')
expect(monsterDisplayName('Fallen', 'ENG')).toBe('Fallen')
expect(monsterDisplayName('Fallen', 'CHI')).toBe('沈淪魔')
expect(formatEliteAffixLabel('strong', 'ENG')).toBe('Extra Strong')
expect(formatEliteAffixLabel('strong', 'CHI')).toBe('特别强壮')
expect(getLocalizedNpcName('akara', 'ENG')).toBe('Akara')
expect(getLocalizedNpcName('akara', 'CHI')).toBe('阿卡拉')
})
})
describe('Issue #503: HUD & Panel Rendering in ENG (Zero CJK) and CHI Modes', () => {
afterEach(() => {
setTblLang(null)
})
it('renders CharacterSheetPanel, InventoryPanel, SkillTreePanel, WorldPanelsHud, ControlBarHud, GlobesHud, and SkillHotkeysHud with zero CJK in ENG mode', () => {
setTblLang('ENG')
const { ctx, font, textCalls } = createMockCanvasAndFont()
const mockImg = { width: 256, height: 256, complete: true, naturalWidth: 256 } as unknown as HTMLImageElement
const charSheet = new CharacterSheetPanel()
charSheet.visible = true
charSheet.lang = 'ENG'
charSheet.draw(
ctx,
{
hp: 100,
maxHp: 100,
mana: 50,
maxMana: 50,
stamina: 80,
maxStamina: 80,
leftSkillDmg: '45-82',
rightSkillDmg: '520-585',
},
{
borderLeftImg: null,
charSheetImg: null,
buySellBtnImg: null,
levelBtnImg: null,
},
font,
)
const invPanel = new InventoryPanel()
invPanel.visible = true
invPanel.draw(
ctx,
{
borderRightImg: null,
invSheetImg: null,
invTab0Img: null,
invTab1Img: null,
buySellBtnImg: null,
goldCoinBtnImg: null,
itemsAtlasImg: null,
itemRects: {},
},
font,
)
const skillTree = new SkillTreePanel()
skillTree.visible = true
skillTree.lang = 'ENG'
skillTree.draw(
ctx,
{
borderRightImg: null,
skillPointsImg: null,
skillTabsImg: null,
buySellBtnImg: null,
},
font,
)
const worldPanels = new WorldPanelsHud()
worldPanels.lang = 'ENG'
const assets = {
borderLeftImg: null,
questBgImg: null,
waypointBgImg: null,
stashBgImg: null,
cubeBgImg: null,
vendorBgImg: null,
vendorTabsImg: null,
buySellBtnImg: null,
miniConvertImg: null,
questsAtlasImg: null,
itemsAtlasImg: null,
waypointTabsImg: null,
waypointIconsImg: null,
questTabsImg: null,
inventory: invPanel,
}
worldPanels.drawLeftDockPanel(ctx, 'quest', assets, font)
worldPanels.drawLeftDockPanel(ctx, 'waypoint', assets, font)
worldPanels.drawLeftDockPanel(ctx, 'stash', assets, font)
worldPanels.drawLeftDockPanel(ctx, 'cube', assets, font)
worldPanels.openNpcMenuByName('Akara', 400, 220)
worldPanels.drawNpcMenu(ctx, font, { boxPiecesImg: mockImg, focus16Img: mockImg })
const controlBar = new ControlBarHud()
controlBar.lang = 'ENG'
controlBar.hoveredElement = 'char'
controlBar.draw(
ctx,
{
level: 1,
xp: 0,
currentLevelXp: 0,
nextLevelXp: 500,
stamina: 80,
maxStamina: 80,
isRunning: true,
unspentStatPoints: 5,
unspentSkillPoints: 2,
},
{
ctrlPnlImg: null,
runBtnImg: null,
menuBtnImg: null,
miniPanelImg: null,
miniPanelBtnsImg: null,
levelBtnImg: null,
levelSocketImg: null,
},
font,
)
const globes = new GlobesHud()
globes.lang = 'ENG'
globes.lifeHovered = true
globes.draw(
ctx,
{
hp: 100,
maxHp: 100,
mana: 50,
maxMana: 50,
poisoned: false,
},
{
lifeImg: null,
manaImg: null,
poisonImg: null,
overlapLeftImg: null,
overlapRightImg: null,
},
font,
)
const hotkeys = new SkillHotkeysHud()
hotkeys.lang = 'ENG'
hotkeys.openPopup = 'right'
hotkeys.draw(ctx, null, font, true)
expect(textCalls.length).toBeGreaterThan(20)
const cjkLeaks = textCalls.filter(t => CJK_REGEX.test(t))
expect(cjkLeaks).toEqual([])
})
it('renders authentic Chinese strings in CHI mode without mixed English parentheticals on CharacterSheet, SkillTree, ControlBar, and WorldPanels', () => {
setTblLang('CHI')
const { ctx, font, textCalls } = createMockCanvasAndFont()
const charSheet = new CharacterSheetPanel()
charSheet.visible = true
charSheet.lang = 'CHI'
charSheet.draw(
ctx,
{
hp: 100,
maxHp: 100,
mana: 50,
maxMana: 50,
stamina: 80,
maxStamina: 80,
leftSkillDmg: '45-82',
rightSkillDmg: '520-585',
},
{
borderLeftImg: null,
charSheetImg: null,
buySellBtnImg: null,
levelBtnImg: null,
},
font,
)
const skillTree = new SkillTreePanel()
skillTree.visible = true
skillTree.lang = 'CHI'
skillTree.draw(
ctx,
{
borderRightImg: null,
skillPointsImg: null,
skillTabsImg: null,
buySellBtnImg: null,
},
font,
)
const controlBar = new ControlBarHud()
controlBar.lang = 'CHI'
controlBar.hoveredElement = 'char'
controlBar.draw(
ctx,
{
level: 1,
xp: 0,
currentLevelXp: 0,
nextLevelXp: 500,
stamina: 80,
maxStamina: 80,
isRunning: true,
unspentStatPoints: 5,
unspentSkillPoints: 2,
},
{
ctrlPnlImg: null,
runBtnImg: null,
menuBtnImg: null,
miniPanelImg: null,
miniPanelBtnsImg: null,
levelBtnImg: null,
levelSocketImg: null,
},
font,
)
const invPanel = new InventoryPanel()
const worldPanels = new WorldPanelsHud()
worldPanels.lang = 'CHI'
const assets = {
borderLeftImg: null,
questBgImg: null,
waypointBgImg: null,
stashBgImg: null,
cubeBgImg: null,
vendorBgImg: null,
vendorTabsImg: null,
buySellBtnImg: null,
miniConvertImg: null,
questsAtlasImg: null,
itemsAtlasImg: null,
waypointTabsImg: null,
waypointIconsImg: null,
questTabsImg: null,
inventory: invPanel,
}
worldPanels.drawLeftDockPanel(ctx, 'waypoint', assets, font)
worldPanels.drawLeftDockPanel(ctx, 'stash', assets, font)
worldPanels.drawLeftDockPanel(ctx, 'cube', assets, font)
expect(textCalls).toContain('力量')
expect(textCalls).toContain('剩余技能点')
expect(textCalls).toContain('角色属性 (A/C)')
expect(textCalls).toContain('罗格营地')
expect(textCalls).toContain('私人储物箱')
expect(textCalls).toContain('赫拉迪克方块 (已合成 0 次)')
expect(textCalls).not.toContain('角色属性 (Character)')
expect(textCalls).not.toContain('技能树 (Skill Tree)')
expect(textCalls).not.toContain('传送站 (Waypoints)')
expect(textCalls).not.toContain('私人储物箱 (Personal Stash)')
expect(textCalls).not.toContain('赫拉迪克方块 (Horadric Cube)')
})
})