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

641 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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/client/i18n/lang.ts'
import {
CANONICAL_CHI_TBL_DICT,
CANONICAL_ENG_TBL_DICT,
resolveTblKey,
} from '../src/common/data/canonical-tbl-strings.ts'
import {
ACT_NAMES_EN,
ACT_NAMES_ZH,
LEVEL_NAMES_ZH,
levelDisplayName,
sceneDisplayName,
variantDisplayLabel,
} from '../src/common/world/level-names-zh.ts'
import {
CANONICAL_SUPER_UNIQUE_LANDMARKS,
formatEliteAffixLabel,
monsterDisplayName,
} from '../src/common/units/monsters-data.ts'
import { TOWN_NPC_DESCRIPTORS, getLocalizedNpcName } from '../src/common/units/npc-table.ts'
import { CLASS_EXCLUSIVE_SETS, createStarterBagForClass } from '../src/common/units/class-starter-profiles.ts'
import { getEmbeddedDropTables } from '../src/common/items/embedded-drop-tables.ts'
import { CharacterSheetPanel } from '../src/client/ui/character-sheet.ts'
import { InventoryPanel, itemToUiInventoryItem } from '../src/client/ui/inventory.ts'
import { SkillTreePanel } from '../src/client/ui/skill-tree-panel.ts'
import { ACT_QUESTS, ACT_WAYPOINTS, WorldPanelsHud } from '../src/client/ui/world-panels.ts'
import { openGeneratedVendorShop } from './helpers/vendor-shop-fixture.ts'
import { ControlBarHud } from '../src/client/ui/control-bar.ts'
import { GlobesHud } from '../src/client/ui/globes.ts'
import { SkillHotkeysHud } from '../src/client/ui/hotkeys.ts'
import type { D2FontRenderer } from '../src/client/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/client/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 Page (play.html) & Bilingual Toolbar Controls', () => {
it('verifies play.html exists with required fonts and Toolbar provides bilingual controls', () => {
const root = process.cwd()
const playHtml = readFileSync(join(root, 'play.html'), 'utf8')
const toolbarSrc = readFileSync(join(root, 'src/client/toolbar/toolbar.ts'), 'utf8')
expect(playHtml).toContain('lang="zh-CN"')
expect(playHtml).toContain('id="d2-toolbar-container"')
expect(playHtml).toContain('/src/client/main.ts')
expect(toolbarSrc).toContain('onLangChange')
})
})
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: mockImg,
questBgImg: mockImg,
waypointBgImg: mockImg,
stashBgImg: mockImg,
cubeBgImg: mockImg,
vendorBgImg: mockImg,
vendorTabsImg: mockImg,
buySellBtnImg: mockImg,
miniConvertImg: mockImg,
questsAtlasImg: mockImg,
itemsAtlasImg: mockImg,
waypointTabsImg: mockImg,
waypointIconsImg: mockImg,
questTabsImg: mockImg,
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 mockImg = { complete: true, naturalWidth: 640, naturalHeight: 480 } as unknown as HTMLImageElement
const assets = {
borderLeftImg: mockImg,
questBgImg: mockImg,
waypointBgImg: mockImg,
stashBgImg: mockImg,
cubeBgImg: mockImg,
vendorBgImg: mockImg,
vendorTabsImg: mockImg,
buySellBtnImg: mockImg,
miniConvertImg: mockImg,
questsAtlasImg: mockImg,
itemsAtlasImg: mockImg,
waypointTabsImg: mockImg,
waypointIconsImg: mockImg,
questTabsImg: mockImg,
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.some(t => t === '角色属性 (A/C)' || t === '人物(A)')).toBe(true)
expect(textCalls).toContain('蘿格營地') // tbl key 'Rogue Encampment' (CHI)
expect(textCalls).toContain('你的私人儲藏箱') // tbl key 'bank' (CHI)
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)')
})
it('localizes all 136 levels, 27 quests, and 39 waypoints cleanly in both ENG and CHI', () => {
const levelKeys = Object.keys(LEVEL_NAMES_ZH)
expect(levelKeys.length).toBeGreaterThanOrEqual(136)
for (let levelId = 1; levelId <= 136; levelId++) {
const zhName = LEVEL_NAMES_ZH[levelId]!
expect(zhName).toBeTruthy()
setTblLang('ENG')
const resolvedEn = levelDisplayName(levelId)
expect(resolvedEn.length).toBeGreaterThan(0)
expect(CJK_REGEX.test(resolvedEn)).toBe(false)
setTblLang('CHI')
const resolvedZh = levelDisplayName(levelId)
expect(resolvedZh).toBe(zhName)
expect(CJK_REGEX.test(resolvedZh)).toBe(true)
}
const wpHud = new WorldPanelsHud()
let totalQuests = 0
for (const act of [1, 2, 3, 4, 5] as const) {
const quests = ACT_QUESTS[act] ?? []
for (const q of quests) {
totalQuests++
for (const status of ['completed', 'active', 'locked'] as const) {
wpHud.setQuestStatus(q.id, status)
const enText = wpHud.getQuestDisplayText(q, 'ENG')
expect(CJK_REGEX.test(enText.title)).toBe(false)
expect(CJK_REGEX.test(enText.body)).toBe(false)
expect(CJK_REGEX.test(enText.statusTag)).toBe(false)
if (enText.rewardText) expect(CJK_REGEX.test(enText.rewardText)).toBe(false)
const zhText = wpHud.getQuestDisplayText(q, 'CHI')
expect(CJK_REGEX.test(zhText.title)).toBe(true)
expect(CJK_REGEX.test(zhText.body)).toBe(true)
expect(CJK_REGEX.test(zhText.statusTag)).toBe(true)
}
}
}
expect(totalQuests).toBe(27)
let totalWaypoints = 0
for (const act of [1, 2, 3, 4, 5] as const) {
const wps = ACT_WAYPOINTS[act] ?? []
for (const wp of wps) {
totalWaypoints++
expect(CJK_REGEX.test(wp.nameEn)).toBe(false)
expect(CJK_REGEX.test(wp.nameZh)).toBe(true)
}
}
expect(totalWaypoints).toBe(39)
})
it('renders pure Chinese header (no English parentheticals) in CHI mode and pure English header in ENG mode for all Town NPCs', () => {
expect(TOWN_NPC_DESCRIPTORS.length).toBeGreaterThanOrEqual(30)
const dummyBox = { complete: true, naturalWidth: 192, naturalHeight: 16 } as any
for (const desc of TOWN_NPC_DESCRIPTORS) {
const { ctx: ctxChi, font: fontChi, textCalls: callsChi } = createMockCanvasAndFont()
const wpChi = new WorldPanelsHud()
wpChi.lang = 'CHI'
wpChi.openNpcMenu(desc, 400, 220)
wpChi.drawNpcMenu(ctxChi, fontChi, { boxPiecesImg: dummyBox })
expect(callsChi.length).toBeGreaterThan(0)
const headerChi = callsChi[0]!
expect(CJK_REGEX.test(headerChi)).toBe(true)
expect(/\([A-Za-z]/.test(headerChi)).toBe(false)
const { ctx: ctxEng, font: fontEng, textCalls: callsEng } = createMockCanvasAndFont()
const wpEng = new WorldPanelsHud()
wpEng.lang = 'ENG'
wpEng.openNpcMenu(desc, 400, 220)
wpEng.drawNpcMenu(ctxEng, fontEng, { boxPiecesImg: dummyBox })
expect(callsEng.length).toBeGreaterThan(0)
for (const line of callsEng) {
expect(CJK_REGEX.test(line)).toBe(false)
}
}
})
it('renders zero CJK in ENG tooltips across all 7 class starter sets and bags', () => {
const classCodes = ['ama', 'sor', 'nec', 'pal', 'bar', 'dru', 'ass'] as const
expect(classCodes.length).toBe(7)
for (const code of classCodes) {
const allItems = [
...Object.values(CLASS_EXCLUSIVE_SETS[code]).filter(Boolean),
...createStarterBagForClass(code).map(b => b.item),
]
for (const item of allItems) {
if (!item) continue
// 1. ENG Tooltip check
const { ctx, font, textCalls } = createMockCanvasAndFont()
const wp = new WorldPanelsHud()
wp.lang = 'ENG'
wp.drawItemTooltip(ctx, { item, x: 400, y: 300 }, font, false)
const cjkLines = textCalls.filter(line => CJK_REGEX.test(line))
expect(cjkLines, `Starter item ${item.id} (${code}) leaked CJK in ENG tooltip: ${cjkLines.join(' | ')}`).toEqual([])
}
}
})
it('renders zero CJK and proper One-Hand/Two-Hand/Throw Damage labels in ENG tooltips across all 659 base items and vendor weapons', () => {
setTblLang('ENG')
const tables = getEmbeddedDropTables()
const allBases = [
...tables.weapons.byCode.values(),
...tables.armor.byCode.values(),
...tables.misc.byCode.values(),
]
expect(allBases.length).toBeGreaterThanOrEqual(650)
for (const base of allBases) {
const raw: any = {
id: `test-${base.id}`,
code: base.id,
name: base.name,
nameZh: (base as any).nameZh,
quality: 'normal',
identified: true,
level: 1,
base,
invWidth: base.invWidth ?? 1,
invHeight: base.invHeight ?? 1,
durability: (base as any).durability ?? 10,
maxDurability: (base as any).durability ?? 10,
defense: (base as any).defense ?? 0,
stats: {},
affixes: [],
rolledProps: [],
runewordProps: [],
}
const uiItem = itemToUiInventoryItem(raw, tables)
const { ctx, font, textCalls } = createMockCanvasAndFont()
const wp = new WorldPanelsHud()
wp.lang = 'ENG'
wp.drawItemTooltip(ctx, { item: uiItem, x: 400, y: 300 }, font, true)
for (const line of textCalls) {
expect(CJK_REGEX.test(line), `Base item ${base.id} (${base.name}) leaked CJK in ENG: ${line}`).toBe(false)
expect(line).not.toMatch(/One-Hand Damage:\s*One-Hand Damage:/i)
expect(line).not.toMatch(/\/\s*Two-Hand:/i)
}
}
// Also test Charsi vendor inventory across all 4 tabs
const wpVendor = new WorldPanelsHud()
wpVendor.lang = 'ENG'
openGeneratedVendorShop(wpVendor, 'Charsi', 'trade', 85, 0)
for (const tabId of ['armor', 'weapons1', 'weapons2', 'misc'] as const) {
for (const p of wpVendor.vendorTabPlacements[tabId]) {
const { ctx, font, textCalls } = createMockCanvasAndFont()
wpVendor.drawItemTooltip(ctx, { item: p.item, x: 400, y: 300 }, font, true)
for (const line of textCalls) {
expect(CJK_REGEX.test(line), `Charsi ${tabId} item ${p.item.name} leaked CJK in ENG: ${line}`).toBe(false)
expect(line).not.toMatch(/\/\s*Two-Hand:/i)
}
}
}
})
it('resolves all 23 SuperUnique landmarks in CANONICAL_SUPER_UNIQUE_LANDMARKS to Chinese in CHI mode and English in ENG mode', () => {
expect(CANONICAL_SUPER_UNIQUE_LANDMARKS.length).toBe(23)
for (const su of CANONICAL_SUPER_UNIQUE_LANDMARKS) {
const zhName = monsterDisplayName(
{ id: su.minionMonsterId || su.id, name: su.id, superUniqueId: su.id },
'CHI',
)
expect(CJK_REGEX.test(zhName), `SuperUnique ${su.id} did not resolve to Chinese in CHI mode: ${zhName}`).toBe(true)
const enName = monsterDisplayName(
{ id: su.minionMonsterId || su.id, name: su.id, superUniqueId: su.id },
'ENG',
)
expect(CJK_REGEX.test(enName), `SuperUnique ${su.id} leaked CJK in ENG mode: ${enName}`).toBe(false)
}
})
})