466 lines
14 KiB
TypeScript
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)')
|
|
})
|
|
})
|