diablo2-web/tests/p0-529-async-lifecycle.test.ts

383 lines
12 KiB
TypeScript

import { describe, expect, it, vi } from 'vitest'
import { ActorAnimator } from '../src/client/render/actor-animator.ts'
import type { LoadedMonsterArt } from '../src/client/render/monster-art.ts'
import { LoadGeneration, type AtlasHandle, type SpriteRenderer } from '../src/client/render/renderer.ts'
import * as entityArtLoader from '../src/client/scene/entity-art-loader.ts'
import {
createLevelTransitionController,
disposeRuntimeAtlases,
type MapRuntime,
} from '../src/client/scene/pack-loader.ts'
import { createHeroLightingAndMonsterControls } from '../src/client/scene/toolbar-chat-ui.ts'
function createTrackingRenderer() {
let nextHandle = 1
const liveHandles = new Set<AtlasHandle>()
const deletedHandles: AtlasHandle[] = []
const renderer = {
addAtlas: vi.fn((_source: unknown, _w: number, _h: number): AtlasHandle => {
const h = nextHandle++ as unknown as AtlasHandle
liveHandles.add(h)
return h
}),
deleteAtlas: vi.fn((h: AtlasHandle) => {
if (liveHandles.delete(h)) {
deletedHandles.push(h)
}
}),
setPalette: vi.fn(),
get liveTextureCount() {
return liveHandles.size
},
} as unknown as SpriteRenderer & { liveTextureCount: number }
return { renderer, liveHandles, deletedHandles }
}
function makeDummyRuntime(levelId: number, pages: Array<AtlasHandle | null> = []): MapRuntime {
return {
source: 'pack',
base: '/packs',
act: 1,
level: String(levelId),
levelId,
variant: 'v0',
variants: ['v0'],
title: `Level ${levelId}`,
widthPx: 800,
heightPx: 600,
cellsX: 10,
cellsY: 10,
animSpeed: 16,
frameDurationMs: 62.5,
grid: {
gridWidth: 10,
gridHeight: 10,
tileWidth: 10,
tileHeight: 10,
blocked: new Uint8Array(100),
originX: 0,
originY: 0,
} as any,
floors: [],
shadows: [],
walls: [],
roofs: [],
objectDrawables: [],
npcs: [],
monsterTypes: ['fallen1'],
monsterPacks: [],
baseMonsterPacks: [],
entrances: [],
warps: [],
waypoints: [],
stashes: [],
objects: [],
spawn: { x: 100, y: 100 },
palette: { rgb: new Uint8Array(768), rgba: new Uint32Array(256) } as any,
pages: [...pages],
objectPages: [],
priorityPages: [],
totalPages: pages.length,
charBases: ['/mpq'],
loadPages: vi.fn(async () => {}),
disposed: false,
loadGen: new LoadGeneration(),
} as unknown as MapRuntime
}
describe('P0 #529: Async Scene LoadGeneration Cancellation & GPU Texture Disposal', () => {
it('LoadGeneration increments generation IDs and aborts prior AbortSignal instances', () => {
const loadGen = new LoadGeneration()
expect(loadGen.current).toBe(0)
const { gen: gen1, signal: sig1 } = loadGen.next()
expect(gen1).toBe(1)
expect(loadGen.isCurrent(gen1)).toBe(true)
expect(sig1.aborted).toBe(false)
const { gen: gen2 } = loadGen.next()
expect(gen2).toBe(2)
expect(loadGen.isCurrent(gen1)).toBe(false)
expect(loadGen.isCurrent(gen2)).toBe(true)
expect(sig1.aborted).toBe(true)
loadGen.invalidate()
expect(loadGen.isCurrent(gen2)).toBe(false)
expect(loadGen.signal.aborted).toBe(true)
})
it('discards out-of-order switchHero completions and deletes stale character GPU textures', async () => {
const { renderer, liveHandles, deletedHandles } = createTrackingRenderer()
let resolveFirst!: (val: entityArtLoader.LoadedCharacter) => void
let resolveSecond!: (val: entityArtLoader.LoadedCharacter) => void
const loadCharSpy = vi
.spyOn(entityArtLoader, 'loadCharacterArt')
.mockImplementationOnce(
() =>
new Promise(resolve => {
resolveFirst = resolve
}),
)
.mockImplementationOnce(
() =>
new Promise(resolve => {
resolveSecond = resolve
}),
)
let currentHeroToken = 'so'
const initialHandle = renderer.addAtlas(new Uint8Array(4), 64, 64)
let activeCharacter: entityArtLoader.LoadedCharacter | null = {
handle: initialHandle,
clips: {},
members: 1,
} as unknown as entityArtLoader.LoadedCharacter
const runtime = makeDummyRuntime(1)
const status = { textContent: '' } as HTMLElement
const controls = createHeroLightingAndMonsterControls({
renderer,
dropTables: {} as any,
playerAnimator: new ActorAnimator(),
status,
heroSelect: null,
lightingSelect: null,
densitySelect: null,
eliteSelect: null,
getRuntime: () => runtime,
getEngine: () => ({ world: { player: {} } }) as any,
getHudManager: () => null,
getCurrentHeroToken: () => currentHeroToken,
setCurrentHeroToken: t => {
currentHeroToken = t
},
getCharacter: () => activeCharacter,
setCharacter: c => {
activeCharacter = c
},
getActivePalette: () => runtime.palette,
getPackEntityBase: () => '/packs',
getPackEntityAct: () => 1,
getCurrentLightingPreset: () => 'auto',
setLightingState: () => {},
getMultipliers: () => ({ densityMultiplier: 1, eliteMultiplier: 1 }),
setMultipliers: () => {},
setMonsterArtMap: () => {},
})
// Fire two rapid hero switches: 'am' (slow) then 'ba' (fast)
const p1 = controls.switchHero('am')
const p2 = controls.switchHero('ba')
const handleBa = renderer.addAtlas(new Uint8Array(4), 64, 64)
resolveSecond({ handle: handleBa, clips: {}, members: 1 } as unknown as entityArtLoader.LoadedCharacter)
await p2
expect(currentHeroToken).toBe('ba')
expect(activeCharacter?.handle).toBe(handleBa)
expect(deletedHandles).toContain(initialHandle)
// Now the stale first request ('am') resolves AFTER 'ba'
const handleAm = renderer.addAtlas(new Uint8Array(4), 64, 64)
resolveFirst({ handle: handleAm, clips: {}, members: 1 } as unknown as entityArtLoader.LoadedCharacter)
await p1
// Must keep 'ba' and immediately delete stale 'am' texture handle
expect(currentHeroToken).toBe('ba')
expect(activeCharacter?.handle).toBe(handleBa)
expect(deletedHandles).toContain(handleAm)
expect(liveHandles.size).toBe(1)
expect(liveHandles.has(handleBa)).toBe(true)
loadCharSpy.mockRestore()
})
it('discards out-of-order reloadMonsters completions and deletes replaced monster GPU textures', async () => {
const { renderer, liveHandles, deletedHandles } = createTrackingRenderer()
let resolveFirst!: (val: entityArtLoader.MonsterArtLoad) => void
let resolveSecond!: (val: entityArtLoader.MonsterArtLoad) => void
const loadMonSpy = vi
.spyOn(entityArtLoader, 'loadMonsterArtMap')
.mockImplementationOnce(
() =>
new Promise(resolve => {
resolveFirst = resolve
}),
)
.mockImplementationOnce(
() =>
new Promise(resolve => {
resolveSecond = resolve
}),
)
const initialMonHandle = renderer.addAtlas(new Uint8Array(4), 64, 64)
let monsterArtMap = new Map<string, LoadedMonsterArt>([
['fallen1', { handle: initialMonHandle, clips: {} } as any],
])
const runtime = makeDummyRuntime(2)
runtime.baseMonsterPacks = [
{
leaderId: 'fallen1',
members: [{ id: 'fallen1', rank: 'normal' }],
} as any,
]
const status = { textContent: '' } as HTMLElement
const controls = createHeroLightingAndMonsterControls({
renderer,
dropTables: { monsterKinds: new Map() } as any,
playerAnimator: new ActorAnimator(),
status,
heroSelect: null,
lightingSelect: null,
densitySelect: null,
eliteSelect: null,
getRuntime: () => runtime,
getEngine: () =>
({
terrain: {
overlap: () => 0,
isWalkable: () => true,
},
world: {
player: { x: 100, y: 100 },
monsters: [],
corpses: [],
events: [],
},
projectiles: [],
explosions: [],
}) as any,
getHudManager: () => null,
getCurrentHeroToken: () => 'so',
setCurrentHeroToken: () => {},
getCharacter: () => null,
setCharacter: () => {},
getActivePalette: () => runtime.palette,
getPackEntityBase: () => '/packs',
getPackEntityAct: () => 1,
getCurrentLightingPreset: () => 'auto',
setLightingState: () => {},
getMultipliers: () => ({ densityMultiplier: 1, eliteMultiplier: 1 }),
setMultipliers: () => {},
getMonsterArtMap: () => monsterArtMap,
setMonsterArtMap: m => {
monsterArtMap = m
},
})
const p1 = controls.reloadMonsters({ density: 2 })
const p2 = controls.reloadMonsters({ density: 5 })
const handleSecond = renderer.addAtlas(new Uint8Array(4), 64, 64)
resolveSecond({
map: new Map([['fallen1', { handle: handleSecond, clips: {} } as any]]),
missing: [],
} as unknown as entityArtLoader.MonsterArtLoad)
await p2
expect(monsterArtMap.get('fallen1')?.handle).toBe(handleSecond)
expect(deletedHandles).toContain(initialMonHandle)
// Stale first load resolves later
const handleFirst = renderer.addAtlas(new Uint8Array(4), 64, 64)
resolveFirst({
map: new Map([['fallen1', { handle: handleFirst, clips: {} } as any]]),
missing: [],
} as unknown as entityArtLoader.MonsterArtLoad)
await p1
expect(monsterArtMap.get('fallen1')?.handle).toBe(handleSecond)
expect(deletedHandles).toContain(handleFirst)
expect(liveHandles.size).toBe(1)
loadMonSpy.mockRestore()
})
it('disposes old runtime atlases and clears groundItems on swapLevel', async () => {
const { renderer, liveHandles } = createTrackingRenderer()
const page1 = renderer.addAtlas(new Uint8Array(4), 256, 256)
const page2 = renderer.addAtlas(new Uint8Array(4), 256, 256)
const oldRuntime = makeDummyRuntime(1, [page1, page2])
const nextPage = renderer.addAtlas(new Uint8Array(4), 256, 256)
const nextRuntime = makeDummyRuntime(2, [nextPage])
const loadMonSpy = vi.spyOn(entityArtLoader, 'loadMonsterArtMap').mockResolvedValue({
map: new Map(),
missing: [],
} as unknown as entityArtLoader.MonsterArtLoad)
let activeRuntime = oldRuntime
let monsterArtMap = new Map<string, LoadedMonsterArt>()
const groundItemsClear = vi.fn()
const controller = createLevelTransitionController({
canvas: { width: 800, height: 600 } as HTMLCanvasElement,
status: { textContent: '' } as HTMLElement,
renderer,
engine: {
world: {
player: { x: 100, y: 100, targetX: 100, targetY: 100, vx: 0, vy: 0 },
monsters: [],
corpses: [],
events: [],
},
ground: [{ x: 10, y: 10, item: {} as any }],
groundItems: { clear: groundItemsClear },
npcEntities: [],
projectiles: [],
explosions: [],
} as any,
camera: { setWorldSize: vi.fn(), centerOn: vi.fn() } as any,
input: { clear: vi.fn() } as any,
fade: {} as any,
minimap: { reveal: vi.fn(), revealedRoomsCount: 0 } as any,
minimapLevel: () => ({}) as any,
resetMinimapCache: vi.fn(),
waypointNetwork: { tryAutoUnlockNearest: vi.fn() } as any,
portalSlot: {} as any,
playerAnimator: new ActorAnimator(),
playerSubTile: () => ({ x: 10, y: 10 }),
populatePackSelectors: vi.fn(async () => {}),
getRuntime: () => activeRuntime,
setRuntime: next => {
activeRuntime = next
},
getHudManager: () => null,
clearMouseTarget: vi.fn(),
getCharacter: () => null,
getMonsterArtMap: () => monsterArtMap,
setMonsterArtMap: m => {
monsterArtMap = m
},
setActivePaletteAndPack: vi.fn(),
getMultipliers: () => ({ densityMultiplier: 1, eliteMultiplier: 1 }),
setBoundsState: vi.fn(),
})
await controller.swapLevel(nextRuntime)
expect(oldRuntime.disposed).toBe(true)
expect(oldRuntime.pages).toEqual([null, null])
expect(groundItemsClear).toHaveBeenCalledTimes(1)
// Only nextPage should remain alive
expect(liveHandles.size).toBe(1)
expect(liveHandles.has(nextPage)).toBe(true)
disposeRuntimeAtlases(nextRuntime, renderer)
expect(liveHandles.size).toBe(0)
loadMonSpy.mockRestore()
})
})