diablo2-web/tests/ground-items-alt-labels.tes...

569 lines
18 KiB
TypeScript

/**
* 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> = {}): 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<string, ((e: any) => 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)
})
})
})