diablo2-web/tests/monster-art-diagnostics.tes...

139 lines
5.2 KiB
TypeScript

/**
* Monster art failures must be observable (issue #58 item 3).
*
* Three `catch` blocks used to turn "the monster art system is broken" into "the
* scene is ready and every monster is a red box", with at most one
* `console.warn`. These tests pin the two halves of the fix:
*
* - `compositeMonsterAnimation` reports every COF layer it drops, rather than
* rendering a monster without its head and saying nothing.
* - `act-scene` can tell which monster ids ended up with no art, and says so in
* the HUD.
*/
import { describe, expect, it, vi } from 'vitest'
import { compositeMonsterAnimation } from '../src/game/monster-art.ts'
import type { MountedArchives } from '../src/mpq/mount.ts'
import { missingMonsterArtIds, monsterArtStatusText } from '../src/scene/act-scene.ts'
/**
* A COF with `layerCount` layers, one frame, one direction.
*
* Mirrors `decodeCof`'s reader: a 28-byte header followed by one 9-byte layer
* record each, then the frame-duration table and the per-direction layer order.
*/
function makeCof(layerCount: number): Uint8Array {
const directions = 1
const frames = 1
const size = 28 + layerCount * 9 + frames * 4 + directions * frames * layerCount
const bytes = new Uint8Array(size)
const view = new DataView(bytes.buffer)
bytes[0] = layerCount
bytes[1] = frames
bytes[2] = directions
let at = 28
for (let layer = 0; layer < layerCount; layer += 1) {
bytes[at] = layer // composite type: 0 = hd, 1 = tr, ...
bytes[at + 1] = 0 // shadow
bytes[at + 2] = 0 // selectable
bytes[at + 3] = 0 // override transparency
// weapon class, 4 characters, 'hth\0'
bytes[at + 4] = 0x68
bytes[at + 5] = 0x74
bytes[at + 6] = 0x68
bytes[at + 7] = 0
at += 9
}
for (let frame = 0; frame < frames; frame += 1) {
view.setInt32(at, 256, true)
at += 4
}
for (let i = 0; i < directions * frames * layerCount; i += 1) {
bytes[at] = i % layerCount
at += 1
}
return bytes
}
/** An archive holding only the COF: every DCC layer will fail to resolve. */
function archivesWithOnlyCof(cof: Uint8Array): MountedArchives {
const cofName = 'data\\global\\monsters\\fa\\cof\\fawlhth.cof'
return {
listFiles: async () => [cofName],
read: async (name: string) => {
if (name === cofName) return cof
throw new Error(`not found: ${name}`)
},
} as unknown as MountedArchives
}
describe('compositeMonsterAnimation layer failures', () => {
it('reports and logs every dropped COF layer instead of swallowing it', async () => {
const error = vi.spyOn(console, 'error').mockImplementation(() => {})
try {
const sheet = await compositeMonsterAnimation(archivesWithOnlyCof(makeCof(3)), 'fa', 'wl', 'hth')
// Three layers, no DCC members in the archive: three honest failures.
expect(sheet.layers).toBe(3)
expect(sheet.layerFailures.length).toBe(3)
expect(sheet.layerFailures.map(f => f.component)).toEqual(['hd', 'tr', 'lg'])
expect(sheet.layerFailures.every(f => f.reason === 'no-member')).toBe(true)
expect(sheet.decodedMembers).toEqual([])
// Silence was the bug; the log is part of the fix.
expect(error).toHaveBeenCalledTimes(3)
} finally {
error.mockRestore()
}
})
it('reports a layer whose DCC is present but undecodable', async () => {
const cofName = 'data\\global\\monsters\\fa\\cof\\fawlhth.cof'
const dccName = 'data\\global\\monsters\\fa\\hd\\fahdlitwlhth.dcc'
const cof = makeCof(1)
const archives = {
listFiles: async () => [cofName, dccName],
read: async (name: string) => {
if (name === cofName) return cof
// A truncated DCC: found, but `decodeDcc` cannot read it.
if (name === dccName) return new Uint8Array([0, 0, 0])
throw new Error(`not found: ${name}`)
},
} as unknown as MountedArchives
const error = vi.spyOn(console, 'error').mockImplementation(() => {})
try {
const sheet = await compositeMonsterAnimation(archives, 'fa', 'wl', 'hth')
expect(sheet.layerFailures.length).toBe(1)
expect(sheet.layerFailures[0]?.reason).toBe('decode')
expect(sheet.layerFailures[0]?.member).toBe(dccName)
expect(error).toHaveBeenCalledTimes(1)
} finally {
error.mockRestore()
}
})
})
describe('act scene monster art diagnostics', () => {
it('names the monster ids that will draw as red boxes', () => {
expect(missingMonsterArtIds(['Fallen1', 'zombie1'], new Set(['fallen1'])))
.toEqual(['zombie1'])
expect(missingMonsterArtIds(['fallen1', 'fallen1'], new Set()))
.toEqual(['fallen1'])
expect(missingMonsterArtIds(['fallen1'], new Set(['fallen1']))).toEqual([])
})
it('says nothing in the HUD when the load was clean', () => {
expect(monsterArtStatusText({ missing: [], errors: 0, layerFailures: 0 })).toBe('')
})
it('shows missing art, load errors and dropped layers in the HUD', () => {
const text = monsterArtStatusText({
missing: ['fallen1', 'zombie1', 'skeleton1', 'quillrat1'],
errors: 2,
layerFailures: 5,
})
expect(text).toContain('怪物美术缺失 4')
expect(text).toContain('fallen1、zombie1、skeleton1…+1')
expect(text).toContain('加载错误 2')
expect(text).toContain('缺层 5')
})
})