diff --git a/src/game/level-names-zh.ts b/src/game/level-names-zh.ts index 93807d0..edbe774 100644 --- a/src/game/level-names-zh.ts +++ b/src/game/level-names-zh.ts @@ -157,10 +157,10 @@ export const LEVEL_NAMES_ZH: Readonly> = { 130: '世界之石要塞三层', 131: '毁灭王座', 132: '世界之石大殿', - 133: '遗忘之沙', - 134: '巨窟', - 135: '苦痛熔炉', - 136: '混沌崔斯特瑞姆', + 133: '主母巢穴', + 134: '遗忘沙漠', + 135: '痛苦熔炉', + 136: '崔斯特姆', } /** @@ -457,6 +457,16 @@ const SLUG_TO_LEVEL_ID: ReadonlyMap = (() => { 'worldstonekeep3': 130, 'throneofdestruction': 131, 'worldstonechamber': 132, + 'theworldstonechamber': 132, + 'pandemoniumfortress': 103, + 'thepandemoniumfortress': 103, + 'chaossanctuary': 108, + 'thechaossanctuary': 108, + 'matronsden': 133, + 'forgottensands': 134, + 'furnaceofpain': 135, + 'ubertristram': 136, + 'pandemoniumtristram': 136, } for (const [k, v] of Object.entries(aliases)) { map.set(normalizeSlug(k), v) @@ -472,13 +482,21 @@ const SLUG_TO_LEVEL_ID: ReadonlyMap = (() => { * @returns the id (1..136), or null when unresolvable. */ export function levelIdOfSlug(slug: string): number | null { - const match = /^(\d+)(?:-|_|$)/.exec(slug) + if (slug == null || typeof slug !== 'string') return null + const trimmed = slug.trim() + if (!trimmed) return null + const match = /^(\d+)(?:-|_|$)/.exec(trimmed) if (match?.[1] !== undefined) { const parsed = Number(match[1]) if (parsed >= 1 && parsed <= 136) return parsed } - const norm = normalizeSlug(slug) - return SLUG_TO_LEVEL_ID.get(norm) ?? null + const norm = normalizeSlug(trimmed) + return ( + SLUG_TO_LEVEL_ID.get(norm) ?? + SLUG_TO_LEVEL_ID.get(norm.replace(/^the/, '')) ?? + SLUG_TO_LEVEL_ID.get('the' + norm) ?? + null + ) } export interface LevelNames { @@ -491,15 +509,24 @@ export interface LevelNames { * Resolves both numeric level IDs (1..136) and string slugs (e.g. 'burial-grounds'). * Returns authentic Chinese and English names, never empty, never leaking raw slugs. */ -export function getLevelNames(levelIdOrSlug: number | string): LevelNames { +export function getLevelNames(levelIdOrSlug: number | string | null | undefined): LevelNames { + if (levelIdOrSlug == null || levelIdOrSlug === '') { + return { nameZh: 'Unknown Area', nameEn: 'Unknown Area' } + } + let id: number | null = null let rawStr = '' if (typeof levelIdOrSlug === 'number') { - id = levelIdOrSlug + if (Number.isFinite(levelIdOrSlug) && Number.isInteger(levelIdOrSlug)) { + id = levelIdOrSlug + } rawStr = String(levelIdOrSlug) } else { - rawStr = levelIdOrSlug.trim() + rawStr = String(levelIdOrSlug).trim() + if (!rawStr) { + return { nameZh: 'Unknown Area', nameEn: 'Unknown Area' } + } id = levelIdOfSlug(rawStr) } @@ -532,12 +559,18 @@ export function getLevelNames(levelIdOrSlug: number | string): LevelNames { * @returns the display name. */ export function sceneNameZh( - slug: string | number, + slug: string | number | null | undefined, fallback?: string, customMap?: Map | Record, ): string { - const slugStr = String(slug) - const id = typeof slug === 'number' ? slug : levelIdOfSlug(slugStr) + if (slug == null || slug === '') { + return fallback && !isRawSlug(fallback) ? fallback : 'Unknown Area' + } + const slugStr = typeof slug === 'string' ? slug.trim() : String(slug) + if (!slugStr) { + return fallback && !isRawSlug(fallback) ? fallback : 'Unknown Area' + } + const id = typeof slug === 'number' ? (Number.isInteger(slug) ? slug : null) : levelIdOfSlug(slugStr) if (id !== null) { if (customMap) { const val = customMap instanceof Map ? customMap.get(id) : customMap[id] @@ -565,11 +598,17 @@ export function sceneNameZh( * @returns canonical English level name. */ export function sceneNameEn( - slug: string | number, + slug: string | number | null | undefined, fallback?: string, ): string { - const slugStr = String(slug) - const id = typeof slug === 'number' ? slug : levelIdOfSlug(slugStr) + if (slug == null || slug === '') { + return fallback && !isRawSlug(fallback) ? fallback : 'Unknown Area' + } + const slugStr = typeof slug === 'string' ? slug.trim() : String(slug) + if (!slugStr) { + return fallback && !isRawSlug(fallback) ? fallback : 'Unknown Area' + } + const id = typeof slug === 'number' ? (Number.isInteger(slug) ? slug : null) : levelIdOfSlug(slugStr) if (id !== null && CANONICAL_LEVEL_NAME_KEYS[id]) { return CANONICAL_LEVEL_NAME_KEYS[id]! } diff --git a/tests/challenger-m2-level-names.test.ts b/tests/challenger-m2-level-names.test.ts new file mode 100644 index 0000000..7ee8dc9 --- /dev/null +++ b/tests/challenger-m2-level-names.test.ts @@ -0,0 +1,453 @@ +/** + * tests/challenger-m2-level-names.test.ts + * + * Empirical Adversarial Challenger Verification Suite for Milestone M2 (Issue #147). + * Localized Map Area Names from .tbl and Levels.txt Parity. + * + * Adversarial Testing Spectrum: + * 1. Exhaustive 1..136 Level ID Audit: + * - Verify getLevelNames(id) returns non-empty nameZh and nameEn for all 1..136. + * - Verify 0 undefined, 0 null, 0 NaN, 0 raw hyphens, 0 raw underscores, 0 raw slugs. + * 2. 1.13c Ground Truth Alignment: + * - Explicitly verify Act 5 realignment (109..136) against Levels.txt / string.tbl / expansionstring.tbl: + * Specifically IDs 118, 119, 120, 121, 122, 123, 124, 131, 132. + * - Verify Act 1..4 key canonical areas (1, 2, 17, 33, 38, 40, 46, 74, 75, 83, 103, 108). + * 3. Multi-Format Slug Resolution: + * - Lowercase, uppercase, mixed-case, leading/trailing whitespace, numeric prefix (17-burial-grounds). + * - Underscore variants and normalized concatenated slugs. + * 4. Bizarre & Adversarial Inputs: + * - Zero (0), negative (-1, -100), out-of-range (137, 9999). + * - Floats (1.5), NaN, Infinity, -Infinity. + * - Empty strings, whitespace-only, punctuation ('---', '___', '!@#$%^&*()'). + * - Ensure NEVER throws, NEVER crashes, NEVER returns blanks, gracefully falls back. + * 5. Slug Leak Prevention in sceneNameZh & sceneNameEn: + * - Ensure raw hyphenated fallbacks ('custom-dungeon-pack') are intercepted and converted to Title Case. + * 6. Dynamic .tbl Decoding & Global State Switch: + * - decodeLevelNamesFromTbl with synthetic .tbl, custom map overrides, setActiveLevelNames reset. + * 7. HUD Area Banner Integration: + * - WorldPanelsHud.showAreaBanner state retention and lack of double-prefixing. + */ + +import { describe, expect, it } from 'vitest' +import { + getLevelNames, + levelIdOfSlug, + sceneNameZh, + sceneNameEn, + decodeLevelNamesFromTbl, + setActiveLevelNames, + getActiveLevelNames, + LEVEL_NAMES_ZH, + LEVEL_NAMES_EN, + CANONICAL_LEVEL_NAME_KEYS, + type LevelNames, +} from '../src/game/level-names-zh.ts' +import { WorldPanelsHud } from '../src/ui/world-panels.ts' +import { encodeTbl, type TblEntry } from '../scripts/lib/tbl-writer.ts' +import { decodeTbl } from '../src/formats/tbl.ts' +import type { D2Table } from '../src/game/acts.ts' + +describe('Adversarial Challenger Suite: M2 Localized Area Names (#147)', () => { + // -------------------------------------------------------------------------- + // 1. Exhaustive 1..136 Level ID Audit + // -------------------------------------------------------------------------- + describe('1. Exhaustive 1..136 Level ID Audit', () => { + it('verifies getLevelNames(id) returns valid, non-empty nameZh and nameEn for all 1..136', () => { + expect(Object.keys(LEVEL_NAMES_ZH).length).toBe(136) + expect(Object.keys(CANONICAL_LEVEL_NAME_KEYS).length).toBe(136) + + for (let id = 1; id <= 136; id++) { + const names: LevelNames = getLevelNames(id) + + // Non-empty strings + expect(typeof names.nameZh).toBe('string') + expect(typeof names.nameEn).toBe('string') + expect(names.nameZh.trim().length, `Level ID ${id} nameZh is empty`).toBeGreaterThan(0) + expect(names.nameEn.trim().length, `Level ID ${id} nameEn is empty`).toBeGreaterThan(0) + + // No forbidden sentinel substrings + for (const forbidden of ['undefined', 'null', 'NaN', 'Infinity', '[object Object]']) { + expect(names.nameZh).not.toContain(forbidden) + expect(names.nameEn).not.toContain(forbidden) + } + + // No raw slugs or raw hyphens leaking into user display names + expect(names.nameZh, `Level ID ${id} nameZh contains raw hyphen`).not.toContain('-') + expect(names.nameEn, `Level ID ${id} nameEn contains raw hyphen`).not.toContain('-') + expect(names.nameZh, `Level ID ${id} nameZh contains raw underscore`).not.toContain('_') + expect(names.nameEn, `Level ID ${id} nameEn contains raw underscore`).not.toContain('_') + + // Must match constant definitions + expect(names.nameZh).toBe(LEVEL_NAMES_ZH[id]) + expect(names.nameEn).toBe(LEVEL_NAMES_EN[id]) + expect(names.nameEn).toBe(CANONICAL_LEVEL_NAME_KEYS[id]) + + // Direct sceneNameZh & sceneNameEn wrappers + expect(sceneNameZh(id)).toBe(LEVEL_NAMES_ZH[id]) + expect(sceneNameEn(id)).toBe(CANONICAL_LEVEL_NAME_KEYS[id]) + } + }) + }) + + // -------------------------------------------------------------------------- + // 2. 1.13c Ground Truth Alignment Audit + // -------------------------------------------------------------------------- + describe('2. Diablo II v1.13c Ground Truth Alignment', () => { + it('verifies critical Act 5 level IDs align with 1.13c Levels.txt and expansionstring.tbl', () => { + // Specifically required by Issue #147 and ground truth verification + const act5GroundTruth: Record = { + 109: { nameZh: '哈洛加斯', nameEn: 'Harrogath' }, + 110: { nameZh: '血腥丘陵', nameEn: 'Bloody Foothills' }, + 111: { nameZh: '冰冻高地', nameEn: 'Frigid Highlands' }, + 112: { nameZh: '亚瑞特高原', nameEn: 'Arreat Plateau' }, + 113: { nameZh: '水晶通道', nameEn: 'Crystalline Passage' }, + 114: { nameZh: '冰河', nameEn: 'Frozen River' }, + 115: { nameZh: '冰河路径', nameEn: 'Glacial Trail' }, + 116: { nameZh: '漂泊者洞穴', nameEn: 'Drifter Cavern' }, + 117: { nameZh: '冰冻苔原', nameEn: 'Frozen Tundra' }, + 118: { nameZh: '远古之路', nameEn: "The Ancients' Way" }, + 119: { nameZh: '冰窖', nameEn: 'Icy Cellar' }, + 120: { nameZh: '亚瑞特山顶', nameEn: 'Arreat Summit' }, + 121: { nameZh: '尼拉塞克的神殿', nameEn: "Nihlathak's Temple" }, + 122: { nameZh: '悲痛之厅', nameEn: 'Halls of Anguish' }, + 123: { nameZh: '痛苦之厅', nameEn: 'Halls of Pain' }, + 124: { nameZh: '沃德之厅', nameEn: 'Halls of Vaught' }, + 125: { nameZh: '艾巴当', nameEn: 'Abaddon' }, + 126: { nameZh: '阿克隆地道', nameEn: 'Pit of Acheron' }, + 127: { nameZh: '炼狱地坑', nameEn: 'Infernal Pit' }, + 128: { nameZh: '世界之石要塞一层', nameEn: 'Worldstone Keep Level 1' }, + 129: { nameZh: '世界之石要塞二层', nameEn: 'Worldstone Keep Level 2' }, + 130: { nameZh: '世界之石要塞三层', nameEn: 'Worldstone Keep Level 3' }, + 131: { nameZh: '毁灭王座', nameEn: 'Throne of Destruction' }, + 132: { nameZh: '世界之石大殿', nameEn: 'Worldstone Chamber' }, + 133: { nameZh: '主母巢穴', nameEn: "Matron's Den" }, + 134: { nameZh: '遗忘沙漠', nameEn: 'Forgotten Sands' }, + 135: { nameZh: '痛苦熔炉', nameEn: 'Furnace of Pain' }, + 136: { nameZh: '崔斯特姆', nameEn: 'Tristram' }, + } + + for (const [idStr, expected] of Object.entries(act5GroundTruth)) { + const id = Number(idStr) + const names = getLevelNames(id) + expect(names.nameZh, `Act 5 level ${id} nameZh`).toBe(expected.nameZh) + expect(names.nameEn, `Act 5 level ${id} nameEn`).toBe(expected.nameEn) + } + + // Explicit assertions for the 9 key targets called out in mission prompt: + // 118, 119, 120, 121, 122, 123, 124, 131, 132 + expect(getLevelNames(118)).toEqual({ nameZh: '远古之路', nameEn: "The Ancients' Way" }) + expect(getLevelNames(119)).toEqual({ nameZh: '冰窖', nameEn: 'Icy Cellar' }) + expect(getLevelNames(120)).toEqual({ nameZh: '亚瑞特山顶', nameEn: 'Arreat Summit' }) + expect(getLevelNames(121)).toEqual({ nameZh: '尼拉塞克的神殿', nameEn: "Nihlathak's Temple" }) + expect(getLevelNames(122)).toEqual({ nameZh: '悲痛之厅', nameEn: 'Halls of Anguish' }) + expect(getLevelNames(123)).toEqual({ nameZh: '痛苦之厅', nameEn: 'Halls of Pain' }) + expect(getLevelNames(124)).toEqual({ nameZh: '沃德之厅', nameEn: 'Halls of Vaught' }) + expect(getLevelNames(131)).toEqual({ nameZh: '毁灭王座', nameEn: 'Throne of Destruction' }) + expect(getLevelNames(132)).toEqual({ nameZh: '世界之石大殿', nameEn: 'Worldstone Chamber' }) + }) + + it('verifies Acts 1..4 key benchmark areas match canonical D2 1.13c', () => { + // Act 1 + expect(getLevelNames(1)).toEqual({ nameZh: '罗格营地', nameEn: 'Rogue Encampment' }) + expect(getLevelNames(2)).toEqual({ nameZh: '鲜血荒地', nameEn: 'Blood Moor' }) + expect(getLevelNames(8)).toEqual({ nameZh: '邪恶洞窟', nameEn: 'Den of Evil' }) + expect(getLevelNames(17)).toEqual({ nameZh: '埋骨之地', nameEn: 'Burial Grounds' }) + expect(getLevelNames(33)).toEqual({ nameZh: '大教堂', nameEn: 'Cathedral' }) + expect(getLevelNames(37)).toEqual({ nameZh: '地下墓穴四层', nameEn: 'Catacombs Level 4' }) + expect(getLevelNames(38)).toEqual({ nameZh: '崔斯特瑞姆', nameEn: 'Tristram' }) + expect(getLevelNames(39)).toEqual({ nameZh: '奶牛关', nameEn: 'Moo Moo Farm' }) + + // Act 2 + expect(getLevelNames(40)).toEqual({ nameZh: '鲁高因', nameEn: 'Lut Gholein' }) + expect(getLevelNames(46)).toEqual({ nameZh: '术士峡谷', nameEn: 'Canyon of the Magi' }) + expect(getLevelNames(73)).toEqual({ nameZh: '都瑞尔的巢穴', nameEn: "Duriel's Lair" }) + expect(getLevelNames(74)).toEqual({ nameZh: '秘法圣所', nameEn: 'Arcane Sanctuary' }) + + // Act 3 + expect(getLevelNames(75)).toEqual({ nameZh: '库拉斯特码头', nameEn: 'Kurast Docks' }) + expect(getLevelNames(83)).toEqual({ nameZh: '崔凡克', nameEn: 'Travincal' }) + expect(getLevelNames(102)).toEqual({ nameZh: '憎恨囚牢三层', nameEn: 'Durance of Hate Level 3' }) + + // Act 4 + expect(getLevelNames(103)).toEqual({ nameZh: '群魔堡垒', nameEn: 'Pandemonium Fortress' }) + expect(getLevelNames(107)).toEqual({ nameZh: '火焰之河', nameEn: 'River of Flame' }) + expect(getLevelNames(108)).toEqual({ nameZh: '混沌避难所', nameEn: 'Chaos Sanctuary' }) + }) + }) + + // -------------------------------------------------------------------------- + // 3. Multi-Format Slug Resolution + // -------------------------------------------------------------------------- + describe('3. Multi-Format Slug Resolution Across Acts 1..5', () => { + const slugTestCases = [ + // Act 1 + { input: 'burial-grounds', expectedZh: '埋骨之地', expectedEn: 'Burial Grounds', id: 17 }, + { input: '17-burial-grounds', expectedZh: '埋骨之地', expectedEn: 'Burial Grounds', id: 17 }, + { input: 'BURIAL-GROUNDS', expectedZh: '埋骨之地', expectedEn: 'Burial Grounds', id: 17 }, + { input: '17-BURIAL-GROUNDS', expectedZh: '埋骨之地', expectedEn: 'Burial Grounds', id: 17 }, + { input: ' burial-grounds ', expectedZh: '埋骨之地', expectedEn: 'Burial Grounds', id: 17 }, + { input: ' 17-burial-grounds ', expectedZh: '埋骨之地', expectedEn: 'Burial Grounds', id: 17 }, + { input: '17_burial_grounds', expectedZh: '埋骨之地', expectedEn: 'Burial Grounds', id: 17 }, + { input: 'burialgrounds', expectedZh: '埋骨之地', expectedEn: 'Burial Grounds', id: 17 }, + { input: 'rogue-encampment', expectedZh: '罗格营地', expectedEn: 'Rogue Encampment', id: 1 }, + { input: '1-rogue-encampment', expectedZh: '罗格营地', expectedEn: 'Rogue Encampment', id: 1 }, + { input: 'ROGUE-ENCAMPMENT', expectedZh: '罗格营地', expectedEn: 'Rogue Encampment', id: 1 }, + { input: '\t1-rogue-encampment\n', expectedZh: '罗格营地', expectedEn: 'Rogue Encampment', id: 1 }, + { input: 'blood-moor', expectedZh: '鲜血荒地', expectedEn: 'Blood Moor', id: 2 }, + { input: '2-blood-moor', expectedZh: '鲜血荒地', expectedEn: 'Blood Moor', id: 2 }, + + // Act 2 + { input: 'lut-gholein', expectedZh: '鲁高因', expectedEn: 'Lut Gholein', id: 40 }, + { input: '40-lut-gholein', expectedZh: '鲁高因', expectedEn: 'Lut Gholein', id: 40 }, + { input: 'LUT-GHOLEIN', expectedZh: '鲁高因', expectedEn: 'Lut Gholein', id: 40 }, + { input: 'canyon-of-the-magi', expectedZh: '术士峡谷', expectedEn: 'Canyon of the Magi', id: 46 }, + { input: '46-canyon-of-the-magi', expectedZh: '术士峡谷', expectedEn: 'Canyon of the Magi', id: 46 }, + + // Act 3 + { input: 'kurast-docks', expectedZh: '库拉斯特码头', expectedEn: 'Kurast Docks', id: 75 }, + { input: '75-kurast-docks', expectedZh: '库拉斯特码头', expectedEn: 'Kurast Docks', id: 75 }, + { input: 'travincal', expectedZh: '崔凡克', expectedEn: 'Travincal', id: 83 }, + { input: '83-travincal', expectedZh: '崔凡克', expectedEn: 'Travincal', id: 83 }, + + // Act 4 + { input: 'pandemonium-fortress', expectedZh: '群魔堡垒', expectedEn: 'Pandemonium Fortress', id: 103 }, + { input: '103-pandemonium-fortress', expectedZh: '群魔堡垒', expectedEn: 'Pandemonium Fortress', id: 103 }, + { input: 'the-pandemonium-fortress', expectedZh: '群魔堡垒', expectedEn: 'Pandemonium Fortress', id: 103 }, + { input: 'chaos-sanctuary', expectedZh: '混沌避难所', expectedEn: 'Chaos Sanctuary', id: 108 }, + { input: '108-chaos-sanctuary', expectedZh: '混沌避难所', expectedEn: 'Chaos Sanctuary', id: 108 }, + { input: 'THE-CHAOS-SANCTUARY', expectedZh: '混沌避难所', expectedEn: 'Chaos Sanctuary', id: 108 }, + + // Act 5 + { input: 'harrogath', expectedZh: '哈洛加斯', expectedEn: 'Harrogath', id: 109 }, + { input: '109-harrogath', expectedZh: '哈洛加斯', expectedEn: 'Harrogath', id: 109 }, + { input: 'HARROGATH', expectedZh: '哈洛加斯', expectedEn: 'Harrogath', id: 109 }, + { input: 'the-ancients-way', expectedZh: '远古之路', expectedEn: "The Ancients' Way", id: 118 }, + { input: '118-the-ancients-way', expectedZh: '远古之路', expectedEn: "The Ancients' Way", id: 118 }, + { input: 'ancients-way', expectedZh: '远古之路', expectedEn: "The Ancients' Way", id: 118 }, + { input: 'The Ancients\' Way', expectedZh: '远古之路', expectedEn: "The Ancients' Way", id: 118 }, + { input: 'icy-cellar', expectedZh: '冰窖', expectedEn: 'Icy Cellar', id: 119 }, + { input: '119-icy-cellar', expectedZh: '冰窖', expectedEn: 'Icy Cellar', id: 119 }, + { input: 'arreat-summit', expectedZh: '亚瑞特山顶', expectedEn: 'Arreat Summit', id: 120 }, + { input: '120-arreat-summit', expectedZh: '亚瑞特山顶', expectedEn: 'Arreat Summit', id: 120 }, + { input: 'nihlathaks-temple', expectedZh: '尼拉塞克的神殿', expectedEn: "Nihlathak's Temple", id: 121 }, + { input: '121-nihlathaks-temple', expectedZh: '尼拉塞克的神殿', expectedEn: "Nihlathak's Temple", id: 121 }, + { input: 'halls-of-anguish', expectedZh: '悲痛之厅', expectedEn: 'Halls of Anguish', id: 122 }, + { input: '122-halls-of-anguish', expectedZh: '悲痛之厅', expectedEn: 'Halls of Anguish', id: 122 }, + { input: 'halls-of-pain', expectedZh: '痛苦之厅', expectedEn: 'Halls of Pain', id: 123 }, + { input: '123-halls-of-pain', expectedZh: '痛苦之厅', expectedEn: 'Halls of Pain', id: 123 }, + { input: 'halls-of-vaught', expectedZh: '沃德之厅', expectedEn: 'Halls of Vaught', id: 124 }, + { input: '124-halls-of-vaught', expectedZh: '沃德之厅', expectedEn: 'Halls of Vaught', id: 124 }, + { input: 'throne-of-destruction', expectedZh: '毁灭王座', expectedEn: 'Throne of Destruction', id: 131 }, + { input: '131-throne-of-destruction', expectedZh: '毁灭王座', expectedEn: 'Throne of Destruction', id: 131 }, + { input: 'worldstone-chamber', expectedZh: '世界之石大殿', expectedEn: 'Worldstone Chamber', id: 132 }, + { input: '132-worldstone-chamber', expectedZh: '世界之石大殿', expectedEn: 'Worldstone Chamber', id: 132 }, + { input: 'the-worldstone-chamber', expectedZh: '世界之石大殿', expectedEn: 'Worldstone Chamber', id: 132 }, + ] + + for (const tc of slugTestCases) { + it(`resolves slug variant: "${tc.input}" -> ID ${tc.id}`, () => { + expect(levelIdOfSlug(tc.input)).toBe(tc.id) + + const names = getLevelNames(tc.input) + expect(names.nameZh).toBe(tc.expectedZh) + expect(names.nameEn).toBe(tc.expectedEn) + + expect(sceneNameZh(tc.input)).toBe(tc.expectedZh) + expect(sceneNameEn(tc.input)).toBe(tc.expectedEn) + }) + } + }) + + // -------------------------------------------------------------------------- + // 4. Bizarre, Out-of-Range & Adversarial Inputs + // -------------------------------------------------------------------------- + describe('4. Bizarre, Out-of-Range & Adversarial Inputs', () => { + it('gracefully handles level ID 0 without throwing or blank names', () => { + const names0 = getLevelNames(0) + expect(names0.nameZh).toBeTruthy() + expect(names0.nameEn).toBeTruthy() + expect(names0.nameZh.trim().length).toBeGreaterThan(0) + expect(names0.nameEn.trim().length).toBeGreaterThan(0) + expect(levelIdOfSlug('0')).toBeNull() + + expect(sceneNameZh(0)).toBeTruthy() + expect(sceneNameEn(0)).toBeTruthy() + }) + + it('gracefully handles negative IDs (-1, -100) without throwing or blank names', () => { + for (const neg of [-1, -2, -100, -9999]) { + const names = getLevelNames(neg) + expect(names.nameZh).toBeTruthy() + expect(names.nameEn).toBeTruthy() + expect(names.nameZh.trim().length).toBeGreaterThan(0) + expect(names.nameEn.trim().length).toBeGreaterThan(0) + expect(names.nameZh).not.toContain('-') + expect(names.nameEn).not.toContain('-') + expect(levelIdOfSlug(String(neg))).toBeNull() + + expect(sceneNameZh(neg)).toBeTruthy() + expect(sceneNameEn(neg)).toBeTruthy() + } + }) + + it('gracefully handles out-of-range IDs (137, 9999, 100000) without throwing', () => { + for (const oor of [137, 138, 200, 9999, 100000]) { + const names = getLevelNames(oor) + expect(names.nameZh).toBeTruthy() + expect(names.nameEn).toBeTruthy() + expect(names.nameZh.trim().length).toBeGreaterThan(0) + expect(names.nameEn.trim().length).toBeGreaterThan(0) + expect(levelIdOfSlug(String(oor))).toBeNull() + + expect(sceneNameZh(oor)).toBeTruthy() + expect(sceneNameEn(oor)).toBeTruthy() + } + }) + + it('gracefully handles float and special IEEE 754 numbers (1.5, NaN, Infinity)', () => { + const nanRes = getLevelNames(NaN) + expect(nanRes.nameZh).toBeTruthy() + expect(nanRes.nameEn).toBeTruthy() + + const infRes = getLevelNames(Infinity) + expect(infRes.nameZh).toBeTruthy() + expect(infRes.nameEn).toBeTruthy() + + const floatRes = getLevelNames(1.5) + expect(floatRes.nameZh).toBeTruthy() + expect(floatRes.nameEn).toBeTruthy() + }) + + it('gracefully handles empty strings and whitespace-only inputs', () => { + for (const emptyInput of ['', ' ', '\t', '\n\r \t\n']) { + const names = getLevelNames(emptyInput) + expect(names.nameZh).toBe('Unknown Area') + expect(names.nameEn).toBe('Unknown Area') + expect(levelIdOfSlug(emptyInput)).toBeNull() + + expect(sceneNameZh(emptyInput)).toBe('Unknown Area') + expect(sceneNameEn(emptyInput)).toBe('Unknown Area') + } + }) + + it('gracefully handles weird punctuation strings without hyphens leaking', () => { + const weirdStrings = [ + '---', + '___', + '-_-_-', + '!@#$%^&*()_+', + '???::;;;', + '../../../etc/passwd', + ] + + for (const weird of weirdStrings) { + const names = getLevelNames(weird) + expect(names.nameZh).toBeTruthy() + expect(names.nameEn).toBeTruthy() + // Must not contain raw hyphen or raw underscore + expect(names.nameZh).not.toContain('_') + expect(names.nameEn).not.toContain('_') + } + }) + + it('gracefully handles null and undefined inputs without crashing (Anti-Crash Guard)', () => { + expect(() => getLevelNames(null as unknown as string)).not.toThrow() + expect(() => getLevelNames(undefined as unknown as string)).not.toThrow() + expect(() => sceneNameZh(null as unknown as string)).not.toThrow() + expect(() => sceneNameZh(undefined as unknown as string)).not.toThrow() + expect(() => sceneNameEn(null as unknown as string)).not.toThrow() + expect(() => sceneNameEn(undefined as unknown as string)).not.toThrow() + }) + }) + + // -------------------------------------------------------------------------- + // 5. Slug Leak Prevention in sceneNameZh & sceneNameEn + // -------------------------------------------------------------------------- + describe('5. Slug Leak Prevention in Fallback Display', () => { + it('prevents raw un-spaced hyphenated slug leak when fallback parameter is given', () => { + // If someone calls sceneNameZh with a raw slug fallback like 'burial-grounds' + expect(sceneNameZh('burial-grounds', 'burial-grounds')).toBe('埋骨之地') + expect(sceneNameEn('burial-grounds', 'burial-grounds')).toBe('Burial Grounds') + + // For unknown slug with hyphenated fallback: + const unknownSlug = 'custom-dungeon-layer-1' + expect(sceneNameZh(unknownSlug, unknownSlug)).toBe('Custom Dungeon Layer 1') + expect(sceneNameEn(unknownSlug, unknownSlug)).toBe('Custom Dungeon Layer 1') + + // If fallback is already a clean human title, preserve it: + expect(sceneNameZh(unknownSlug, 'Custom Dungeon Layer 1')).toBe('Custom Dungeon Layer 1') + expect(sceneNameEn(unknownSlug, 'Custom Dungeon Layer 1')).toBe('Custom Dungeon Layer 1') + }) + }) + + // -------------------------------------------------------------------------- + // 6. Dynamic .tbl Decoding & Active Level Names + // -------------------------------------------------------------------------- + describe('6. Dynamic .tbl Decoding & Active Level Names', () => { + it('supports round-trip TBL dictionary decode and active level overrides', () => { + const entries: TblEntry[] = [ + { key: 'Rogue Encampment', value: '萝格营区' }, + { key: 'Burial Grounds', value: '古老墓园' }, + { key: "The Ancients' Way", value: '先祖长廊' }, + { key: 'Worldstone Chamber', value: '世界石神殿' }, + ] + const encoded = encodeTbl(entries) + const decoded = decodeTbl(encoded, 'utf-8') + + const customNames = decodeLevelNamesFromTbl(decoded) + expect(customNames.get(1)).toBe('萝格营区') + expect(customNames.get(17)).toBe('古老墓园') + expect(customNames.get(118)).toBe('先祖长廊') + expect(customNames.get(132)).toBe('世界石神殿') + + // Untouched levels fall back to LEVEL_NAMES_ZH + expect(customNames.get(2)).toBe(LEVEL_NAMES_ZH[2]) + expect(customNames.get(109)).toBe(LEVEL_NAMES_ZH[109]) + + // Test active level names global state + try { + setActiveLevelNames(customNames) + expect(getActiveLevelNames()).toBe(customNames) + + expect(getLevelNames(1).nameZh).toBe('萝格营区') + expect(getLevelNames('17-burial-grounds').nameZh).toBe('古老墓园') + expect(getLevelNames(118).nameZh).toBe('先祖长廊') + expect(getLevelNames(132).nameZh).toBe('世界石神殿') + + // English remains canonical + expect(getLevelNames(1).nameEn).toBe('Rogue Encampment') + expect(getLevelNames(17).nameEn).toBe('Burial Grounds') + expect(getLevelNames(118).nameEn).toBe("The Ancients' Way") + expect(getLevelNames(132).nameEn).toBe('Worldstone Chamber') + } finally { + setActiveLevelNames(null) + expect(getActiveLevelNames()).toBeNull() + expect(getLevelNames(1).nameZh).toBe('罗格营地') + } + }) + }) + + // -------------------------------------------------------------------------- + // 7. HUD Area Banner Integration + // -------------------------------------------------------------------------- + describe('7. HUD Area Banner Integration', () => { + it('sets area banner in WorldPanelsHud with exact bilingual names and 3600ms duration', () => { + const hud = new WorldPanelsHud() + const now = 50000 + + const burial = getLevelNames(17) + hud.showAreaBanner(burial.nameZh, burial.nameEn, now) + + expect(hud.areaBanner).toBeDefined() + expect(hud.areaBanner?.titleZh).toBe('埋骨之地') + expect(hud.areaBanner?.titleEn).toBe('Burial Grounds') + expect(hud.areaBanner?.untilMs).toBe(now + 3600) + + const ancients = getLevelNames(118) + hud.showAreaBanner(ancients.nameZh, ancients.nameEn, now + 1000) + expect(hud.areaBanner?.titleZh).toBe('远古之路') + expect(hud.areaBanner?.titleEn).toBe("The Ancients' Way") + expect(hud.areaBanner?.untilMs).toBe(now + 1000 + 3600) + }) + }) +}) diff --git a/tests/ui-hud.test.ts b/tests/ui-hud.test.ts index 91915dd..c8daaed 100644 --- a/tests/ui-hud.test.ts +++ b/tests/ui-hud.test.ts @@ -573,6 +573,10 @@ describe('Diablo II v1.13c UI / HUD (Issue #27)', () => { expect(getLevelNames(124)).toEqual({ nameZh: '沃德之厅', nameEn: 'Halls of Vaught' }) expect(getLevelNames(131)).toEqual({ nameZh: '毁灭王座', nameEn: 'Throne of Destruction' }) expect(getLevelNames(132)).toEqual({ nameZh: '世界之石大殿', nameEn: 'Worldstone Chamber' }) + expect(getLevelNames(133)).toEqual({ nameZh: '主母巢穴', nameEn: "Matron's Den" }) + expect(getLevelNames(134)).toEqual({ nameZh: '遗忘沙漠', nameEn: 'Forgotten Sands' }) + expect(getLevelNames(135)).toEqual({ nameZh: '痛苦熔炉', nameEn: 'Furnace of Pain' }) + expect(getLevelNames(136)).toEqual({ nameZh: '崔斯特姆', nameEn: 'Tristram' }) // 3. Complete 136 level IDs validity: no empty strings, no raw hyphen slugs for (let id = 1; id <= 136; id++) { @@ -588,12 +592,16 @@ describe('Diablo II v1.13c UI / HUD (Issue #27)', () => { // 4. Slug resolution: both un-prefixed and numbered prefixes resolve cleanly expect(getLevelNames('burial-grounds')).toEqual({ nameZh: '埋骨之地', nameEn: 'Burial Grounds' }) expect(getLevelNames('17-burial-grounds')).toEqual({ nameZh: '埋骨之地', nameEn: 'Burial Grounds' }) + expect(getLevelNames(' 17-burial-grounds ')).toEqual({ nameZh: '埋骨之地', nameEn: 'Burial Grounds' }) expect(getLevelNames('rogue-encampment')).toEqual({ nameZh: '罗格营地', nameEn: 'Rogue Encampment' }) expect(getLevelNames('blood-moor')).toEqual({ nameZh: '鲜血荒地', nameEn: 'Blood Moor' }) expect(getLevelNames('harrogath')).toEqual({ nameZh: '哈洛加斯', nameEn: 'Harrogath' }) expect(getLevelNames('the-ancients-way')).toEqual({ nameZh: '远古之路', nameEn: "The Ancients' Way" }) expect(getLevelNames('icy-cellar')).toEqual({ nameZh: '冰窖', nameEn: 'Icy Cellar' }) expect(getLevelNames('arreat-summit')).toEqual({ nameZh: '亚瑞特山顶', nameEn: 'Arreat Summit' }) + expect(getLevelNames('the-pandemonium-fortress')).toEqual({ nameZh: '群魔堡垒', nameEn: 'Pandemonium Fortress' }) + expect(getLevelNames('THE-CHAOS-SANCTUARY')).toEqual({ nameZh: '混沌避难所', nameEn: 'Chaos Sanctuary' }) + expect(getLevelNames('the-worldstone-chamber')).toEqual({ nameZh: '世界之石大殿', nameEn: 'Worldstone Chamber' }) // 5. sceneNameZh and sceneNameEn parity and slug leak prevention expect(sceneNameZh(17)).toBe('埋骨之地') @@ -620,5 +628,11 @@ describe('Diablo II v1.13c UI / HUD (Issue #27)', () => { titleEn: 'Burial Grounds', untilMs: now + 3600, }) + + // 8. Safe fallbacks for null/undefined/empty inputs without throwing TypeError + expect(getLevelNames(null as any)).toEqual({ nameZh: 'Unknown Area', nameEn: 'Unknown Area' }) + expect(getLevelNames(undefined as any)).toEqual({ nameZh: 'Unknown Area', nameEn: 'Unknown Area' }) + expect(sceneNameZh(null as any)).toBe('Unknown Area') + expect(sceneNameEn(null as any)).toBe('Unknown Area') }) })