92 lines
3.2 KiB
TypeScript
92 lines
3.2 KiB
TypeScript
import { test, expect } from 'vitest'
|
|
import { existsSync, readFileSync } from 'node:fs'
|
|
import { join } from 'node:path'
|
|
import { hasPackedSprite } from '../src/game/npc.ts'
|
|
|
|
const PACK_ROOT = 'samples/d2-packs'
|
|
const PACK_INDEX = join(PACK_ROOT, 'index.json')
|
|
/** Guard on the exact file these tests open, not on the directory above it. */
|
|
const noPack = !existsSync(PACK_INDEX)
|
|
|
|
interface PackedNpc {
|
|
readonly token?: string
|
|
readonly frame?: unknown
|
|
}
|
|
|
|
interface IndexLevel {
|
|
readonly path: string
|
|
readonly label: string
|
|
readonly npcs: number
|
|
}
|
|
|
|
function readLevels(): readonly IndexLevel[] {
|
|
const index = JSON.parse(readFileSync(PACK_INDEX, 'utf8')) as { readonly levels: readonly IndexLevel[] }
|
|
return index.levels
|
|
}
|
|
|
|
function readNpcs(levelPath: string): readonly PackedNpc[] {
|
|
const scene = JSON.parse(readFileSync(join(PACK_ROOT, levelPath, 'scene.json'), 'utf8')) as {
|
|
readonly npcs?: readonly PackedNpc[]
|
|
}
|
|
return scene.npcs ?? []
|
|
}
|
|
|
|
test('hasPackedSprite treats a missing or null frame as art-less', () => {
|
|
expect(hasPackedSprite({ frame: { page: 0 } })).toBe(true)
|
|
expect(hasPackedSprite({ frame: null })).toBe(false)
|
|
expect(hasPackedSprite({})).toBe(false)
|
|
})
|
|
|
|
test('hasPackedSprite narrows frame so callers need no second check', () => {
|
|
const entry: { readonly frame?: { readonly page: number } | null } = { frame: { page: 7 } }
|
|
if (!hasPackedSprite(entry)) throw new Error('expected the guard to admit this entry')
|
|
// Reading .page without a null check is the whole point of the guard: if the
|
|
// predicate ever stops narrowing, this line stops compiling.
|
|
expect(entry.frame.page).toBe(7)
|
|
})
|
|
|
|
/**
|
|
* The placeholder box and the sprite are drawn by two separate passes that both
|
|
* branch on `hasPackedSprite`. Running every real packed NPC through it proves
|
|
* the partition is total — each NPC lands in exactly one pass, never both and
|
|
* never neither — which is what keeps a green box off finished art.
|
|
*/
|
|
test.skipIf(noPack)('every packed NPC is classified into exactly one render pass', () => {
|
|
let total = 0
|
|
let withSprite = 0
|
|
const artless: string[] = []
|
|
|
|
for (const level of readLevels()) {
|
|
if (level.npcs === 0) continue
|
|
for (const npc of readNpcs(level.path)) {
|
|
total += 1
|
|
if (hasPackedSprite(npc)) withSprite += 1
|
|
else artless.push(`${level.path}:${npc.token ?? '?'}`)
|
|
}
|
|
}
|
|
|
|
expect(total).toBeGreaterThan(0)
|
|
// The packer already refuses to bake an art-less NPC, so a survivor here would
|
|
// mean a placeholder box is being drawn where real art was expected.
|
|
expect(artless).toEqual([])
|
|
expect(withSprite).toBe(total)
|
|
})
|
|
|
|
test.skipIf(noPack)('act 1 town quadrants carry their full NPC roster', () => {
|
|
const townLevels = readLevels().filter(l => l.label.includes('act-1-town'))
|
|
expect(townLevels.length).toBe(4)
|
|
|
|
const tokensSeen = new Set<string>()
|
|
for (const level of townLevels) {
|
|
for (const npc of readNpcs(level.path)) {
|
|
if (npc.token !== undefined) tokensSeen.add(npc.token)
|
|
}
|
|
}
|
|
|
|
// Regression guard for the reported 'npc不全': before DS1 type-1 entries were
|
|
// resolved through MonPreset, only Cain (DC) survived in the Rogue Encampment.
|
|
for (const token of ['WA', 'RC', 'PS', 'CI', 'GH', 'DC']) {
|
|
expect(tokensSeen.has(token)).toBe(true)
|
|
}
|
|
})
|