/** * Diablo II v1.13c Ground Items Alt Labels & Ladder Collision Tests (Issue #390). */ import { describe, it, expect, vi, beforeAll, afterAll } from 'vitest' import type { GroundItemEntity } from '../src/game/ground-items.ts' import { GROUND_LABEL_QUALITY_COLORS, getGroundItemQualityColor, formatGroundItemLabelText, estimateLabelWidth, doLabelsOverlap, resolveLadderCollisions, computeInitialGroundLabelLayouts, GroundLabelOverlay, type GroundLabelLayout, } from '../src/ui/ground-labels.ts' function createMockItem(partial: Partial = {}): GroundItemEntity { return { id: partial.id ?? 'item-1', item: partial.item ?? {}, name: partial.name ?? 'Short Sword', nameZh: partial.nameZh ?? '短剑', quality: partial.quality ?? 'normal', isGold: partial.isGold ?? false, amount: partial.amount ?? 1, invWidth: partial.invWidth ?? 1, invHeight: partial.invHeight ?? 2, dropTime: 1000, x: partial.x ?? 400, y: partial.y ?? 300, cellX: 20, cellY: 15, sparklePhase: 0, ethereal: partial.ethereal, sockets: partial.sockets, } } describe('Ground Items Alt Labels (Issue #390)', () => { describe('Quality Colors & Text Formatting', () => { it('formats gold amounts accurately as authentic Chinese localized text', () => { const gold1 = createMockItem({ isGold: true, amount: 150 }) expect(formatGroundItemLabelText(gold1)).toBe('150 金币') const gold2 = createMockItem({ isGold: true, amount: 25000 }) expect(formatGroundItemLabelText(gold2)).toBe('25000 金币') }) it('formats item display names preferring nameZh with fallback to name', () => { const item1 = createMockItem({ name: 'Short Sword', nameZh: '短剑' }) expect(formatGroundItemLabelText(item1)).toBe('短剑') const item2 = createMockItem({ name: 'Grand Charm', nameZh: '' }) expect(formatGroundItemLabelText(item2)).toBe('Grand Charm') }) it('maps all Diablo II 1.13c item qualities to authentic palette colors', () => { expect(getGroundItemQualityColor(createMockItem({ quality: 'normal' }))).toBe( GROUND_LABEL_QUALITY_COLORS.normal, ) expect(getGroundItemQualityColor(createMockItem({ quality: 'low' }))).toBe( GROUND_LABEL_QUALITY_COLORS.low, ) expect(getGroundItemQualityColor(createMockItem({ quality: 'superior' }))).toBe( GROUND_LABEL_QUALITY_COLORS.superior, ) expect(getGroundItemQualityColor(createMockItem({ quality: 'magic' }))).toBe( GROUND_LABEL_QUALITY_COLORS.magic, ) expect(getGroundItemQualityColor(createMockItem({ quality: 'rare' }))).toBe( GROUND_LABEL_QUALITY_COLORS.rare, ) expect(getGroundItemQualityColor(createMockItem({ quality: 'set' }))).toBe( GROUND_LABEL_QUALITY_COLORS.set, ) expect(getGroundItemQualityColor(createMockItem({ quality: 'unique' }))).toBe( GROUND_LABEL_QUALITY_COLORS.unique, ) expect(getGroundItemQualityColor(createMockItem({ quality: 'craft' }))).toBe( GROUND_LABEL_QUALITY_COLORS.craft, ) expect(getGroundItemQualityColor(createMockItem({ quality: 'rune' }))).toBe( GROUND_LABEL_QUALITY_COLORS.rune, ) expect(getGroundItemQualityColor(createMockItem({ isGold: true }))).toBe( GROUND_LABEL_QUALITY_COLORS.gold, ) }) it('colors ethereal or socketed normal and superior items gray (#808080) while preserving magic/rare/set/unique/runeword colors (Issue #424)', () => { // Ethereal normal -> gray (#808080) expect( getGroundItemQualityColor(createMockItem({ quality: 'normal', ethereal: true })), ).toBe('#808080') expect( getGroundItemQualityColor(createMockItem({ quality: 'normal', item: { ethereal: true } })), ).toBe('#808080') // Socketed normal -> gray (#808080) expect( getGroundItemQualityColor(createMockItem({ quality: 'normal', sockets: 3 })), ).toBe('#808080') expect( getGroundItemQualityColor(createMockItem({ quality: 'normal', item: { sockets: 4 } })), ).toBe('#808080') // Socketed or ethereal superior / high -> gray (#808080) expect( getGroundItemQualityColor(createMockItem({ quality: 'superior', sockets: 2 })), ).toBe('#808080') expect( getGroundItemQualityColor(createMockItem({ quality: 'high', ethereal: true })), ).toBe('#808080') // Non-ethereal 0-socket normal/superior remains normal/superior white expect( getGroundItemQualityColor(createMockItem({ quality: 'normal', ethereal: false, sockets: 0 })), ).toBe(GROUND_LABEL_QUALITY_COLORS.normal) expect( getGroundItemQualityColor(createMockItem({ quality: 'superior', ethereal: false, sockets: 0 })), ).toBe(GROUND_LABEL_QUALITY_COLORS.superior) // Completed runewords keep unique gold (#908858) expect( getGroundItemQualityColor(createMockItem({ quality: 'normal', sockets: 4, item: { isRuneword: true, sockets: 4 } })), ).toBe(GROUND_LABEL_QUALITY_COLORS.unique) // Magic, rare, set, unique keep their quality colors even when ethereal or socketed expect( getGroundItemQualityColor(createMockItem({ quality: 'magic', ethereal: true, sockets: 2 })), ).toBe(GROUND_LABEL_QUALITY_COLORS.magic) expect( getGroundItemQualityColor(createMockItem({ quality: 'rare', ethereal: true, sockets: 2 })), ).toBe(GROUND_LABEL_QUALITY_COLORS.rare) expect( getGroundItemQualityColor(createMockItem({ quality: 'set', sockets: 3 })), ).toBe(GROUND_LABEL_QUALITY_COLORS.set) expect( getGroundItemQualityColor(createMockItem({ quality: 'unique', ethereal: true, sockets: 1 })), ).toBe(GROUND_LABEL_QUALITY_COLORS.unique) }) }) describe('Label Width Estimation', () => { it('correctly accounts for both ASCII and CJK wide characters', () => { const asciiW = estimateLabelWidth('OK', 13) const cjkW = estimateLabelWidth('戒指', 13) // CJK characters have greater pixel width than Latin characters for same character count expect(cjkW).toBeGreaterThan(asciiW) expect(asciiW).toBeGreaterThan(20) }) }) describe('Overlap Detection & Layout Calculation', () => { it('projects ground items to initial layouts with natural Y offset', () => { const items = [ createMockItem({ id: 'i1', x: 100, y: 200 }), createMockItem({ id: 'i2', x: 300, y: 400 }), ] const project = (wx: number, wy: number) => ({ x: wx * 2, y: wy * 2 }) const layouts = computeInitialGroundLabelLayouts(items, project, 13, 18) expect(layouts.length).toBe(2) expect(layouts[0]!.screenX).toBe(200) expect(layouts[0]!.naturalY).toBe(388) // 200 * 2 - 12 expect(layouts[0]!.screenY).toBe(388) expect(layouts[1]!.screenX).toBe(600) expect(layouts[1]!.naturalY).toBe(788) }) it('filters out items outside viewport when project returns null', () => { const items = [createMockItem({ id: 'i1' })] const layouts = computeInitialGroundLabelLayouts(items, () => null) expect(layouts.length).toBe(0) }) it('accurately identifies overlapping and non-overlapping label rectangles', () => { const l1: GroundLabelLayout = { id: '1', itemId: '1', item: createMockItem(), text: 'A', color: '#fff', borderColor: '#fff', width: 60, height: 18, screenX: 100, screenY: 100, naturalY: 100, } const l2Overlapping: GroundLabelLayout = { id: '2', itemId: '2', item: createMockItem(), text: 'B', color: '#fff', borderColor: '#fff', width: 60, height: 18, screenX: 110, screenY: 105, naturalY: 105, } const l3Distant: GroundLabelLayout = { id: '3', itemId: '3', item: createMockItem(), text: 'C', color: '#fff', borderColor: '#fff', width: 60, height: 18, screenX: 300, screenY: 100, naturalY: 100, } expect(doLabelsOverlap(l1, l2Overlapping)).toBe(true) expect(doLabelsOverlap(l1, l3Distant)).toBe(false) }) }) describe('Diablo II 1.13c Ladder Collision Avoidance Algorithm', () => { it('returns empty array or single label unchanged', () => { expect(resolveLadderCollisions([])).toEqual([]) const single: GroundLabelLayout[] = [ { id: '1', itemId: '1', item: createMockItem(), text: 'A', color: '#fff', borderColor: '#fff', width: 50, height: 18, screenX: 100, screenY: 100, naturalY: 100, }, ] expect(resolveLadderCollisions(single)).toEqual(single) }) it('retains natural positions when labels do not overlap horizontally', () => { const nonOverlapping: GroundLabelLayout[] = [ { id: '1', itemId: '1', item: createMockItem(), text: 'Left Item', color: '#fff', borderColor: '#fff', width: 60, height: 18, screenX: 100, screenY: 200, naturalY: 200, }, { id: '2', itemId: '2', item: createMockItem(), text: 'Right Item', color: '#fff', borderColor: '#fff', width: 60, height: 18, screenX: 300, screenY: 200, naturalY: 200, }, ] const resolved = resolveLadderCollisions(nonOverlapping) expect(resolved[0]!.screenY).toBe(200) expect(resolved[1]!.screenY).toBe(200) }) it('stacks 2 overlapping labels vertically into a clean ladder with zero overlap', () => { const overlapping: GroundLabelLayout[] = [ { id: '1', itemId: '1', item: createMockItem(), text: 'Item A', color: '#fff', borderColor: '#fff', width: 60, height: 18, screenX: 100, screenY: 200, naturalY: 200, }, { id: '2', itemId: '2', item: createMockItem(), text: 'Item B', color: '#fff', borderColor: '#fff', width: 60, height: 18, screenX: 105, screenY: 202, naturalY: 202, }, ] const resolved = resolveLadderCollisions(overlapping, 4, 2) expect(resolved.length).toBe(2) // Zero overlap verification expect(doLabelsOverlap(resolved[0]!, resolved[1]!, 4, 2)).toBe(false) // Vertical distance between centers must be at least height + padY = 20px const distY = Math.abs(resolved[0]!.screenY - resolved[1]!.screenY) expect(distY).toBeGreaterThanOrEqual(20) }) it('resolves dense 5-item loot explosion cluster into an orderly 5-step ladder', () => { const cluster: GroundLabelLayout[] = [] for (let i = 0; i < 5; i += 1) { cluster.push({ id: `item-${i}`, itemId: `item-${i}`, item: createMockItem({ id: `item-${i}` }), text: `Drop ${i}`, color: '#fff', borderColor: '#fff', width: 70, height: 18, screenX: 200 + (i % 2) * 5, // all clustered within 5px horizontally screenY: 300, naturalY: 300, }) } const resolved = resolveLadderCollisions(cluster, 4, 2) expect(resolved.length).toBe(5) // Assert pairwise zero-overlap across all 5 labels for (let i = 0; i < resolved.length; i += 1) { for (let j = i + 1; j < resolved.length; j += 1) { expect(doLabelsOverlap(resolved[i]!, resolved[j]!, 4, 2)).toBe(false) } } // Check ladder sorting: positions strictly decreasing (stacking upward) const sortedByY = [...resolved].sort((a, b) => a.screenY - b.screenY) for (let k = 0; k < sortedByY.length - 1; k += 1) { expect(sortedByY[k + 1]!.screenY - sortedByY[k]!.screenY).toBeGreaterThanOrEqual(20) } }) it('handles multiple independent clusters simultaneously', () => { const items: GroundLabelLayout[] = [ // Cluster 1 around X=100 { id: 'c1-1', itemId: 'c1-1', item: createMockItem(), text: 'Loot A', color: '#fff', borderColor: '#fff', width: 50, height: 18, screenX: 100, screenY: 200, naturalY: 200, }, { id: 'c1-2', itemId: 'c1-2', item: createMockItem(), text: 'Loot B', color: '#fff', borderColor: '#fff', width: 50, height: 18, screenX: 102, screenY: 200, naturalY: 200, }, // Cluster 2 around X=500 { id: 'c2-1', itemId: 'c2-1', item: createMockItem(), text: 'Loot C', color: '#fff', borderColor: '#fff', width: 50, height: 18, screenX: 500, screenY: 200, naturalY: 200, }, { id: 'c2-2', itemId: 'c2-2', item: createMockItem(), text: 'Loot D', color: '#fff', borderColor: '#fff', width: 50, height: 18, screenX: 503, screenY: 200, naturalY: 200, }, ] const resolved = resolveLadderCollisions(items) // Both clusters have their own pairwise overlap resolved expect(doLabelsOverlap(resolved[0]!, resolved[1]!)).toBe(false) expect(doLabelsOverlap(resolved[2]!, resolved[3]!)).toBe(false) // Cluster 1 and Cluster 2 remain at their distinct X expect(resolved[0]!.screenX).toBeLessThan(200) expect(resolved[2]!.screenX).toBeGreaterThan(400) }) }) describe('GroundLabelOverlay DOM Integration', () => { function createMockDomElement(tag = 'div'): any { const listeners: Record void)[]> = {} const children: any[] = [] const el: any = { tagName: tag.toUpperCase(), className: '', textContent: '', style: {}, dataset: {}, hidden: false, children, appendChild(child: any) { children.push(child) return child }, addEventListener(type: string, fn: any) { listeners[type] = listeners[type] ?? [] listeners[type].push(fn) }, dispatchEvent(event: any) { for (const fn of listeners[event.type] ?? []) { fn(event) } }, } return el } const origDocument = (globalThis as any).document beforeAll(() => { ;(globalThis as any).document = { createElement: (tag: string) => createMockDomElement(tag), } }) afterAll(() => { ;(globalThis as any).document = origDocument }) it('hides all pooled label elements when visible is false or layouts empty', () => { const container = createMockDomElement('div') const overlay = new GroundLabelOverlay(container) const layout: GroundLabelLayout = { id: '1', itemId: '1', item: createMockItem(), text: 'Short Sword', color: '#d8d8d8', borderColor: '#d8d8d8', width: 60, height: 18, screenX: 100, screenY: 100, naturalY: 100, } overlay.update(true, [layout]) expect(container.children.length).toBe(1) expect(container.children[0].style.display).toBe('block') overlay.update(false, []) expect(container.children[0].style.display).toBe('none') }) it('renders labels with authentic quality colors and triggers pickup callback on click', () => { const container = createMockDomElement('div') const overlay = new GroundLabelOverlay(container) const onPickup = vi.fn() overlay.setOnPickupCallback(onPickup) const uniqueItem = createMockItem({ id: 'soj', name: 'The Stone of Jordan', nameZh: '乔丹之石', quality: 'unique', }) const layout: GroundLabelLayout = { id: 'soj', itemId: 'soj', item: uniqueItem, text: '乔丹之石', color: GROUND_LABEL_QUALITY_COLORS.unique!, borderColor: GROUND_LABEL_QUALITY_COLORS.unique!, width: 80, height: 18, screenX: 250, screenY: 150, naturalY: 150, } overlay.update(true, [layout]) const el = container.children[0] expect(el.textContent).toBe('乔丹之石') expect(el.style.color).toBe(GROUND_LABEL_QUALITY_COLORS.unique) expect(el.style.left).toBe('250px') expect(el.style.top).toBe('150px') // Simulate pointerdown el.dispatchEvent({ type: 'pointerdown', stopPropagation: vi.fn(), preventDefault: vi.fn() }) expect(onPickup).toHaveBeenCalledWith(uniqueItem) }) it('triggers onHoverCallback on mouseenter and mouseleave for hovered labels (Issue #447)', () => { const container = createMockDomElement('div') const overlay = new GroundLabelOverlay(container) const onHover = vi.fn() overlay.setOnHoverCallback(onHover) const magicItem = createMockItem({ id: 'ring-1', name: 'Ring of the Apprentice', nameZh: '学徒之戒', quality: 'magic', }) const layout: GroundLabelLayout = { id: 'ring-1', itemId: 'ring-1', item: magicItem, text: '学徒之戒', color: GROUND_LABEL_QUALITY_COLORS.magic!, borderColor: GROUND_LABEL_QUALITY_COLORS.magic!, width: 70, height: 18, screenX: 200, screenY: 100, naturalY: 100, } overlay.update(true, [layout]) const el = container.children[0] // Mouseenter el.dispatchEvent({ type: 'mouseenter' }) expect(onHover).toHaveBeenCalledWith(magicItem) expect(el.style.borderColor).toBe('#ffffff') // Mouseleave el.dispatchEvent({ type: 'mouseleave' }) expect(onHover).toHaveBeenCalledWith(null) }) }) })