import { describe, it, expect, beforeEach } from 'vitest' import { SkillHotkeysHud, DEFAULT_UNIVERSAL_SKILLS, DEFAULT_SORCERESS_SKILLS, isLeftUsableSkill, isPassiveSkill, isAuraSkill, getSkillListRow, getSkillReqLevel, LEFT_SKILL_BOUNDS, RIGHT_SKILL_BOUNDS, } from '../src/ui/hotkeys.ts' import type { HotkeySkillEntry } from '../src/ui/hotkeys.ts' import { SKILLS_CATALOG, SKILLS_BY_CLASS } from '../src/data/skills-catalog.ts' import type { SkillCatalogEntry } from '../src/data/skills-catalog.ts' import { CHARACTER_CLASS_CODES } from '../src/game/classes.ts' describe('Diablo II v1.13c Speedbar Authentic Parity (All 7 Classes)', () => { let hud: SkillHotkeysHud beforeEach(() => { hud = new SkillHotkeysHud() }) // Helper to convert catalog entries into complete HotkeySkillEntry objects function createClassSkillEntries(entries: readonly SkillCatalogEntry[]): HotkeySkillEntry[] { return entries.map(s => ({ skillId: s.id, name: s.name, nameZh: s.nameZh, level: 1, manaCost: s.mana, leftUsable: isLeftUsableSkill(s.id), rightUsable: !isPassiveSkill(s.id), isAura: isAuraSkill(s.id), isPassive: isPassiveSkill(s.id), tabIndex: s.tabIndex, reqlevel: s.reqlevel, })) } // ========================================================================= // 1. Ground Truth Catalog Mapping: ListRow & ReqLevel // ========================================================================= describe('1. Ground Truth Catalog Mapping', () => { it('maps all 210 class skills in SKILLS_CATALOG to ListRow = tabIndex + 1 with 100% parity', () => { expect(SKILLS_CATALOG.length).toBe(210) for (const s of SKILLS_CATALOG) { const entry: HotkeySkillEntry = { skillId: s.id, name: s.name, nameZh: s.nameZh, level: 1, manaCost: s.mana, leftUsable: isLeftUsableSkill(s.id), rightUsable: !isPassiveSkill(s.id), tabIndex: s.tabIndex, } const row = getSkillListRow(entry) expect(row).toBe(s.tabIndex + 1) expect(row).toBeGreaterThanOrEqual(1) expect(row).toBeLessThanOrEqual(3) } }) it('maps universal / basic actions to ListRow = 0', () => { const basicActions = [ { skillId: 0, name: 'Attack', leftUsable: true, rightUsable: true }, { skillId: 2, name: 'Throw', leftUsable: true, rightUsable: true }, { skillId: 3, name: 'Unsummon', leftUsable: false, rightUsable: true }, { skillId: 4, name: 'Left Hand Throw', leftUsable: true, rightUsable: true }, { skillId: 5, name: 'Left Hand Swing', leftUsable: true, rightUsable: true }, { skillId: 350, name: 'Delerium Change', leftUsable: false, rightUsable: true }, ] for (const action of basicActions) { const row = getSkillListRow({ skillId: action.skillId, name: action.name, nameZh: action.name, level: 1, manaCost: 0, leftUsable: action.leftUsable, rightUsable: action.rightUsable, }) expect(row).toBe(0) } }) it('maps item skills (Town Portal, Identify, Charges) to ListRow = 4', () => { const itemSkills = [ { skillId: 217, name: 'Scroll of Identify' }, { skillId: 218, name: 'Tome of Identify' }, { skillId: 219, name: 'Scroll of Town Portal' }, { skillId: 220, name: 'Tome of Town Portal' }, { skillId: 999, name: 'Teleport Charge Item', isItemSkill: true }, ] for (const item of itemSkills) { const row = getSkillListRow({ skillId: item.skillId, name: item.name, nameZh: item.name, level: 1, manaCost: 0, leftUsable: false, rightUsable: true, isItemSkill: (item as any).isItemSkill, }) expect(row).toBe(4) } }) it('maps charged class skills (e.g. Teleport 54, Life Tap 82, Lower Resist 91) to ListRow = 4', () => { const chargedSkills: HotkeySkillEntry[] = [ { skillId: 54, name: 'Teleport', nameZh: '传送', level: 1, manaCost: 24, charges: 20, maxCharges: 20, leftUsable: false, rightUsable: true }, { skillId: 82, name: 'Life Tap', nameZh: '偷取生命', level: 1, manaCost: 9, charges: 15, maxCharges: 15, leftUsable: false, rightUsable: true }, { skillId: 91, name: 'Lower Resist', nameZh: '降低抵抗', level: 1, manaCost: 22, charges: 10, maxCharges: 10, leftUsable: false, rightUsable: true }, ] for (const skill of chargedSkills) { expect(getSkillListRow(skill)).toBe(4) } }) it('retrieves accurate reqlevel from catalog for all skills', () => { for (const s of SKILLS_CATALOG) { const entry: HotkeySkillEntry = { skillId: s.id, name: s.name, nameZh: s.nameZh, level: 1, manaCost: s.mana, leftUsable: true, rightUsable: true, } expect(getSkillReqLevel(entry)).toBe(s.reqlevel) } // Basic actions default to reqlevel 1 expect(getSkillReqLevel({ skillId: 0, name: 'Attack', nameZh: '攻击', level: 1, manaCost: 0, leftUsable: true, rightUsable: true })).toBe(1) expect(getSkillReqLevel({ skillId: 2, name: 'Throw', nameZh: '投掷', level: 1, manaCost: 0, leftUsable: true, rightUsable: true })).toBe(1) expect(getSkillReqLevel({ skillId: 3, name: 'Unsummon', nameZh: '取消召唤', level: 1, manaCost: 0, leftUsable: false, rightUsable: true })).toBe(1) }) }) // ========================================================================= // 2. Usability Filters (Left vs Right) Across All 7 Classes // ========================================================================= describe('2. Usability Filters (Left vs Right) Across All 7 Classes', () => { CHARACTER_CLASS_CODES.forEach(cls => { it(`enforces authentic 1.13c left and right filters for ${cls.toUpperCase()}`, () => { const classSkills = createClassSkillEntries(SKILLS_BY_CLASS[cls]) const allSkills: HotkeySkillEntry[] = [...DEFAULT_UNIVERSAL_SKILLS, ...classSkills] hud.setAvailableSkills(allSkills) // --- Left Click Speedbar --- const leftCells = hud.getPopupCells('left') for (const cell of leftCells) { const id = cell.skill.skillId // 1. Passive skills strictly filtered out expect(isPassiveSkill(id)).toBe(false) expect(cell.skill.isPassive).toBeFalsy() // 2. Aura skills strictly filtered out expect(isAuraSkill(id)).toBe(false) expect(cell.skill.isAura).toBeFalsy() // 3. Must be an official 1.13c left skill expect(isLeftUsableSkill(id)).toBe(true) // 4. Unsummon (3) is right-only expect(id).not.toBe(3) } // --- Right Click Speedbar --- const rightCells = hud.getPopupCells('right') for (const cell of rightCells) { const id = cell.skill.skillId // 1. Passive skills strictly filtered out expect(isPassiveSkill(id)).toBe(false) expect(cell.skill.isPassive).toBeFalsy() // 2. Right click allows both active skills and auras expect(cell.skill.rightUsable !== false).toBe(true) } }) }) it('strictly excludes Meteor (56) and Blizzard (59) from Sorceress left speedbar', () => { hud.setAvailableSkills([...DEFAULT_SORCERESS_SKILLS]) const leftCells = hud.getPopupCells('left') const leftIds = leftCells.map(c => c.skill.skillId) expect(leftIds).not.toContain(56) // Meteor expect(leftIds).not.toContain(59) // Blizzard expect(leftIds).toContain(47) // Fire Ball expect(leftIds).toContain(55) // Glacial Spike expect(leftIds).toContain(38) // Charged Bolt expect(leftIds).toContain(0) // Normal Attack const rightCells = hud.getPopupCells('right') const rightIds = rightCells.map(c => c.skill.skillId) expect(rightIds).toContain(56) // Meteor present on right expect(rightIds).toContain(59) // Blizzard present on right }) it('strictly includes Maul (233) on Druid left speedbar', () => { const druidSkills = createClassSkillEntries(SKILLS_BY_CLASS.dru) hud.setAvailableSkills([...DEFAULT_UNIVERSAL_SKILLS, ...druidSkills]) const leftIds = hud.getPopupCells('left').map(c => c.skill.skillId) expect(leftIds).toContain(233) // Maul expect(leftIds).not.toContain(228) // Cyclone Armor (buff, leftskill=0) expect(leftIds).not.toContain(226) // Oak Sage (summon, leftskill=0) }) it('strictly excludes all Paladin auras and Holy Shield from left speedbar', () => { const paladinSkills = createClassSkillEntries(SKILLS_BY_CLASS.pal) hud.setAvailableSkills([...DEFAULT_UNIVERSAL_SKILLS, ...paladinSkills]) const leftCells = hud.getPopupCells('left') const leftIds = leftCells.map(c => c.skill.skillId) // No aura allowed expect(leftIds).not.toContain(98) // Might expect(leftIds).not.toContain(102) // Holy Fire expect(leftIds).not.toContain(113) // Holy Freeze expect(leftIds).not.toContain(118) // Holy Shock expect(leftIds).not.toContain(122) // Fanaticism expect(leftIds).not.toContain(125) // Salvation expect(leftIds).not.toContain(99) // Prayer expect(leftIds).not.toContain(109) // Cleansing expect(leftIds).not.toContain(115) // Vigor expect(leftIds).not.toContain(124) // Redemption expect(leftIds).not.toContain(117) // Holy Shield (self-buff, leftskill=0) // Combat skills allowed expect(leftIds).toContain(96) // Sacrifice expect(leftIds).toContain(97) // Smite expect(leftIds).toContain(101) // Holy Bolt expect(leftIds).toContain(106) // Zeal expect(leftIds).toContain(107) // Charge expect(leftIds).toContain(111) // Vengeance expect(leftIds).toContain(112) // Blessed Hammer expect(leftIds).toContain(116) // Conversion expect(leftIds).toContain(121) // Fist of the Heavens // Right speedbar includes auras with yellow border marker const rightCells = hud.getPopupCells('right') const rightAuras = rightCells.filter(c => isAuraSkill(c.skill.skillId)) expect(rightAuras.length).toBe(20) // All 20 Paladin auras }) it('strictly excludes Barbarian combat mastery passives from both speedbars', () => { const barbarianSkills = createClassSkillEntries(SKILLS_BY_CLASS.bar) hud.setAvailableSkills([...DEFAULT_UNIVERSAL_SKILLS, ...barbarianSkills]) const leftIds = hud.getPopupCells('left').map(c => c.skill.skillId) const rightIds = hud.getPopupCells('right').map(c => c.skill.skillId) const passives = [127, 128, 129, 134, 135, 136, 141, 145, 148, 153] for (const p of passives) { expect(leftIds).not.toContain(p) expect(rightIds).not.toContain(p) } }) }) // ========================================================================= // 3. Multi-Row Grouping & Row Compaction // ========================================================================= describe('3. Multi-Row Grouping & Row Compaction', () => { it('compacts Barbarian right-click speedbar from [0, 1, 3] into contiguous visualRows [0, 1, 2]', () => { const barbarianSkills = createClassSkillEntries(SKILLS_BY_CLASS.bar) hud.setAvailableSkills([...DEFAULT_UNIVERSAL_SKILLS, ...barbarianSkills]) const rightCells = hud.getPopupCells('right') // Barbarian has: // ListRow 0: Attack (0), Throw (2), Unsummon (3) // ListRow 1: Combat Skills (10 active) // ListRow 2: Combat Masteries (10 PASSIVES -> 0 active) // ListRow 3: Warcries (10 active) const listRowsPresent = Array.from(new Set(rightCells.map(c => c.listRow))).sort((a, b) => a - b) expect(listRowsPresent).toEqual([0, 1, 3]) const visualRowsPresent = Array.from(new Set(rightCells.map(c => c.visualRow))).sort((a, b) => a - b) expect(visualRowsPresent).toEqual([0, 1, 2]) // Verify that every cell in ListRow 3 is compacted into visualRow 2 const warcriesCells = rightCells.filter(c => c.listRow === 3) expect(warcriesCells.length).toBe(10) for (const cell of warcriesCells) { expect(cell.visualRow).toBe(2) } }) it('compacts Paladin left-click speedbar to contiguous visualRows [0, 1]', () => { const paladinSkills = createClassSkillEntries(SKILLS_BY_CLASS.pal) hud.setAvailableSkills([...DEFAULT_UNIVERSAL_SKILLS, ...paladinSkills]) const leftCells = hud.getPopupCells('left') // ListRow 0 (Universal), ListRow 1 (Combat skills). ListRow 2 and 3 are auras (filtered out). const listRowsPresent = Array.from(new Set(leftCells.map(c => c.listRow))).sort((a, b) => a - b) expect(listRowsPresent).toEqual([0, 1]) const visualRowsPresent = Array.from(new Set(leftCells.map(c => c.visualRow))).sort((a, b) => a - b) expect(visualRowsPresent).toEqual([0, 1]) }) it('compacts single-tree character without gap rows', () => { // Character that only learned Lightning Spells (Tab 2 => ListRow 2) const lightSpells = createClassSkillEntries(SKILLS_BY_CLASS.sor.filter(s => s.tabIndex === 1 && !isPassiveSkill(s.id))) hud.setAvailableSkills([...DEFAULT_UNIVERSAL_SKILLS, ...lightSpells]) const rightCells = hud.getPopupCells('right') // ListRow 0 (Universal) and ListRow 2 (Lightning) const listRowsPresent = Array.from(new Set(rightCells.map(c => c.listRow))).sort((a, b) => a - b) expect(listRowsPresent).toEqual([0, 2]) // Compacted to visualRow 0 and 1 const visualRowsPresent = Array.from(new Set(rightCells.map(c => c.visualRow))).sort((a, b) => a - b) expect(visualRowsPresent).toEqual([0, 1]) const lightCells = rightCells.filter(c => c.listRow === 2) for (const cell of lightCells) { expect(cell.visualRow).toBe(1) } }) it('maintains contiguous visualRow compaction with item skills across all 7 classes', () => { const itemSkills: HotkeySkillEntry[] = [ { skillId: 219, name: 'Scroll of Town Portal', nameZh: '城镇传送卷轴', level: 1, manaCost: 0, isItemSkill: true, leftUsable: false, rightUsable: true }, { skillId: 217, name: 'Scroll of Identify', nameZh: '辨识卷轴', level: 1, manaCost: 0, isItemSkill: true, leftUsable: false, rightUsable: true }, ] for (const cls of CHARACTER_CLASS_CODES) { const classSkills = createClassSkillEntries(SKILLS_BY_CLASS[cls]) hud.setAvailableSkills([...DEFAULT_UNIVERSAL_SKILLS, ...classSkills, ...itemSkills]) const rightCells = hud.getPopupCells('right') const visualRows = Array.from(new Set(rightCells.map(c => c.visualRow))).sort((a, b) => a - b) for (let i = 0; i < visualRows.length; i++) { expect(visualRows[i]).toBe(i) } const itemCells = rightCells.filter(c => c.listRow === 4) const maxRow = Math.max(...visualRows) for (const cell of itemCells) { expect(cell.visualRow).toBe(maxRow) } } }) it('correctly places charged skills in ListRow 4 on a non-native class (Barbarian with Teleport charges)', () => { const barbarianSkills = createClassSkillEntries(SKILLS_BY_CLASS.bar) const tpChargedItem: HotkeySkillEntry = { skillId: 54, name: 'Teleport', nameZh: '传送', level: 1, manaCost: 24, charges: 20, maxCharges: 20, leftUsable: false, rightUsable: true, } hud.setAvailableSkills([...DEFAULT_UNIVERSAL_SKILLS, ...barbarianSkills, tpChargedItem]) const rightCells = hud.getPopupCells('right') const tpCell = rightCells.find(c => c.skill.skillId === 54) expect(tpCell).toBeDefined() expect(tpCell!.listRow).toBe(4) // Visual rows: 0 (Universal), 1 (Combat skills), 2 (Warcries), 3 (Item row with Teleport) expect(tpCell!.visualRow).toBe(3) }) }) // ========================================================================= // 4. Intra-Row Ordering // ========================================================================= describe('4. Intra-Row Ordering', () => { it('strictly sorts skills within each row by reqlevel ascending, then skillId ascending', () => { for (const cls of CHARACTER_CLASS_CODES) { const classSkills = createClassSkillEntries(SKILLS_BY_CLASS[cls]) hud.setAvailableSkills([...DEFAULT_UNIVERSAL_SKILLS, ...classSkills]) for (const side of ['left', 'right'] as const) { const cells = hud.getPopupCells(side) // Group cells by visualRow const rows = new Map() for (const c of cells) { let list = rows.get(c.visualRow) if (!list) { list = [] rows.set(c.visualRow, list) } list.push(c) } for (const rowCells of rows.values()) { for (let i = 0; i < rowCells.length - 1; i++) { const a = rowCells[i] const b = rowCells[i + 1] const reqA = getSkillReqLevel(a.skill) const reqB = getSkillReqLevel(b.skill) if (reqA === reqB) { expect(a.skill.skillId).toBeLessThan(b.skill.skillId) } else { expect(reqA).toBeLessThan(reqB) } // Verify column index is sequential 0, 1, 2... expect(a.col).toBe(i) expect(b.col).toBe(i + 1) } } } } }) }) // ========================================================================= // 5. Adaptive Alignment & Canvas Bounds Clamping // ========================================================================= describe('5. Adaptive Alignment & Canvas Bounds Clamping', () => { it('expands left speedbar rightward and right speedbar leftward', () => { hud.setAvailableSkills([...DEFAULT_SORCERESS_SKILLS]) const leftCells = hud.getPopupCells('left') const rightCells = hud.getPopupCells('right') // In left popup, col 0 is at base.x (117), col 1 is at 117 + 48 = 165 const leftRow0 = leftCells.filter(c => c.visualRow === 0) if (leftRow0.length >= 2) { expect(leftRow0[0].x).toBe(LEFT_SKILL_BOUNDS.x) expect(leftRow0[1].x).toBe(LEFT_SKILL_BOUNDS.x + 48) } // In right popup, col 0 is at base.x (635), col 1 is at 635 - 48 = 587 const rightRow0 = rightCells.filter(c => c.visualRow === 0) if (rightRow0.length >= 2) { expect(rightRow0[0].x).toBe(RIGHT_SKILL_BOUNDS.x) expect(rightRow0[1].x).toBe(RIGHT_SKILL_BOUNDS.x - 48) } }) it('strictly clamps all cells within [0, 800 - 48] and [0, 600 - 48]', () => { // Simulate extreme row with 25 skills const megaSkills: HotkeySkillEntry[] = [] for (let i = 1; i <= 25; i++) { megaSkills.push({ skillId: 1000 + i, name: `Custom Skill ${i}`, nameZh: `自定义技能 ${i}`, level: 1, manaCost: 10, leftUsable: true, rightUsable: true, tabIndex: 0, reqlevel: i, }) } hud.setAvailableSkills(megaSkills) for (const side of ['left', 'right'] as const) { const cells = hud.getPopupCells(side) for (const cell of cells) { expect(cell.x).toBeGreaterThanOrEqual(0) expect(cell.x).toBeLessThanOrEqual(800 - 48) expect(cell.y).toBeGreaterThanOrEqual(0) expect(cell.y).toBeLessThanOrEqual(600 - 48) } } }) }) // ========================================================================= // 6. Item Skills & Tooltips // ========================================================================= describe('6. Item Skills & Tooltips', () => { it('positions item skills at the top row (ListRow = 4)', () => { const skillsWithItems: HotkeySkillEntry[] = [ ...DEFAULT_SORCERESS_SKILLS, { skillId: 219, name: 'Scroll of Town Portal', nameZh: '城镇传送卷轴', level: 1, manaCost: 0, isItemSkill: true, leftUsable: false, rightUsable: true }, { skillId: 217, name: 'Scroll of Identify', nameZh: '辨识卷轴', level: 1, manaCost: 0, isItemSkill: true, leftUsable: false, rightUsable: true }, ] hud.setAvailableSkills(skillsWithItems) const rightCells = hud.getPopupCells('right') const itemCells = rightCells.filter(c => c.listRow === 4) expect(itemCells.length).toBe(2) const maxVisualRow = Math.max(...rightCells.map(c => c.visualRow)) for (const cell of itemCells) { expect(cell.visualRow).toBe(maxVisualRow) } }) it('formats tooltips correctly for Town Portal, Identify, and Left Hand Throw', () => { hud.setSkillCharges(219, 12, 20) const tpLines = hud.formatTooltipLines(219, 'right', true) expect(tpLines[0].text).toContain('城镇传送卷轴') expect(tpLines.some(l => l.text.includes('12'))).toBe(true) hud.setSkillCharges(218, 20, 20) const idLines = hud.formatTooltipLines(218, 'right', true) expect(idLines[0].text).toContain('辨识之书') expect(idLines.some(l => l.text.includes('20'))).toBe(true) const lhtLines = hud.formatTooltipLines(4, 'left', true) expect(lhtLines[0].text).toContain('左手投掷') }) }) // ========================================================================= // 7. Interactive Assignments & Guardrails // ========================================================================= describe('7. Interactive Assignments & Guardrails', () => { beforeEach(() => { hud.setAvailableSkills([...DEFAULT_SORCERESS_SKILLS]) }) it('rejects assigning Meteor (56) and Blizzard (59) to left slot', () => { expect(hud.assignSkill('left', 56)).toBe(false) expect(hud.assignSkill('left', 59)).toBe(false) expect(hud.leftSkillId).not.toBe(56) expect(hud.leftSkillId).not.toBe(59) // Right slot succeeds expect(hud.assignSkill('right', 56)).toBe(true) expect(hud.rightSkillId).toBe(56) expect(hud.assignSkill('right', 59)).toBe(true) expect(hud.rightSkillId).toBe(59) }) it('rejects assigning auras to left slot', () => { expect(hud.assignSkill('left', 113)).toBe(false) // Holy Freeze expect(hud.leftSkillId).not.toBe(113) expect(hud.assignSkill('right', 113)).toBe(true) expect(hud.rightSkillId).toBe(113) }) it('rejects assigning passive skills to any slot', () => { expect(hud.assignSkill('left', 61)).toBe(false) // Fire Mastery expect(hud.assignSkill('right', 61)).toBe(false) // Fire Mastery expect(hud.assignSkill('left', 127)).toBe(false) // Sword Mastery expect(hud.assignSkill('right', 127)).toBe(false) }) it('enforces left-usability on setDualSkill', () => { expect(hud.setDualSkill(0)).toBe(true) // Attack expect(hud.leftSkillId).toBe(0) expect(hud.rightSkillId).toBe(0) expect(hud.setDualSkill(56)).toBe(false) // Meteor expect(hud.setDualSkill(113)).toBe(false) // Holy Freeze }) it('handles speedbar popup opening, cell click selection, and slot closing', () => { expect(hud.openPopup).toBeNull() // 1. Click Left Slot opens left popup const leftBounds = LEFT_SKILL_BOUNDS hud.handleClick(leftBounds.x + 10, leftBounds.y + 10) expect(hud.openPopup).toBe('left') // 2. Select a cell in left popup (e.g. Fire Ball 47) const leftCells = hud.getPopupCells('left') const fbCell = leftCells.find(c => c.skill.skillId === 47)! expect(fbCell).toBeDefined() hud.handleClick(fbCell.x + 5, fbCell.y + 5) expect(hud.leftSkillId).toBe(47) expect(hud.openPopup).toBeNull() // 3. Click Right Slot opens right popup const rightBounds = RIGHT_SKILL_BOUNDS hud.handleClick(rightBounds.x + 10, rightBounds.y + 10) expect(hud.openPopup).toBe('right') // 4. Select Blizzard (59) in right popup const rightCells = hud.getPopupCells('right') const blizCell = rightCells.find(c => c.skill.skillId === 59)! expect(blizCell).toBeDefined() hud.handleClick(blizCell.x + 5, blizCell.y + 5) expect(hud.rightSkillId).toBe(59) expect(hud.openPopup).toBeNull() }) }) })