diablo2-web/tests/e2e-module-split/tier1-feature-coverage.test.ts

854 lines
40 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Tier 1 — Requirement-Driven Feature Coverage E2E Tests (`tests/e2e-module-split/tier1-feature-coverage.test.ts`).
*
* Covers primary happy-path behavior for all 10 features (`F1`–`F10`) in `PROJECT.md § Feature Inventory`
* (`>= 5` tests per feature, `52` tests total), using dynamic `await import(...)`, `node:fs` inspection,
* and AST/source verification against the post-split module layout.
*/
import { readdirSync, statSync } from 'node:fs'
import { describe, expect, it } from 'vitest'
import {
CANONICAL_SRC_MODULES,
LEGACY_HTML_FILES,
LEGACY_SRC_DIRS,
SRC_DIR,
UNIVERSAL_DT1_PATHS,
collectTsFiles,
createSyntheticArchiveSource,
existsInRepo,
extractImportSpecifiers,
importRepoModule,
isDirInRepo,
readJsonFile,
readRepoFile,
resolveImportTargetRel,
} from './helpers'
describe('Tier 1 — Feature Coverage: Module Split & Single Entry Consolidation (F1–F10)', () => {
// ───────────────────────────────────────────────────────────────────────────
// F1: src/common/ Pure Module & Data Registry Decoupling (6 tests)
// ───────────────────────────────────────────────────────────────────────────
describe('F1: src/common/ Pure Module & Data Registry Decoupling', () => {
it('F1.1: populates all 11 canonical subdirectories under src/common/', () => {
const requiredSubdirs = [
'src/common/mpq',
'src/common/formats',
'src/common/data',
'src/common/drlg',
'src/common/world',
'src/common/items',
'src/common/skills',
'src/common/units',
'src/common/i18n',
'src/common/rng',
'src/common/pack-contract',
]
for (const dir of requiredSubdirs) {
expect(isDirInRepo(dir), `Expected directory ${dir} to exist`).toBe(true)
const tsFiles = collectTsFiles(dir)
expect(tsFiles.length, `Expected ${dir} to contain .ts files`).toBeGreaterThan(0)
}
})
it('F1.2: configures tsconfig.common.json with pure ES2022/ES2023 lib and empty types: []', () => {
expect(existsInRepo('tsconfig.common.json')).toBe(true)
const cfg = readJsonFile('tsconfig.common.json')
expect(Array.isArray(cfg.compilerOptions?.lib)).toBe(true)
const libs = (cfg.compilerOptions.lib as string[]).map((s) => s.toUpperCase())
expect(libs.some((l) => l.startsWith('ES202'))).toBe(true)
expect(libs).not.toContain('DOM')
expect(libs).not.toContain('DOM.ITERABLE')
expect(cfg.compilerOptions?.types).toEqual([])
expect(cfg.include).toContain('src/common/**/*.ts')
})
it('F1.3: instantiates D2DataRegistry via injected ArchiveSource in src/common/data/data-registry.ts', async () => {
const mod = await importRepoModule('src/common/data/data-registry.ts')
expect(typeof mod.D2DataRegistry).toBe('function')
expect(typeof mod.getSharedDataRegistry).toBe('function')
expect(Array.isArray(mod.MPQ_LOAD_ORDER)).toBe(true)
expect(mod.MPQ_LOAD_ORDER).toContain('Patch_D2.mpq')
const mockSource = createSyntheticArchiveSource()
if (typeof mod.resetSharedDataRegistryCache === 'function') {
mod.resetSharedDataRegistryCache()
}
const registry =
typeof mod.D2DataRegistry.fromArchiveSource === 'function'
? await mod.D2DataRegistry.fromArchiveSource(mockSource)
: await mod.D2DataRegistry.fromMountedArchives(mockSource)
expect(registry).toBeDefined()
const fireBolt = registry.getSkill(36)
expect(fireBolt).toBeDefined()
expect(fireBolt.skill).toBe('Fire Bolt')
expect(fireBolt.charClass).toBe('sor')
})
it('F1.4: exports deterministic D2Rng in src/common/rng/d2-rng.ts with bit-identical sequences', async () => {
const mod = await importRepoModule('src/common/rng/d2-rng.ts')
expect(typeof mod.D2Rng).toBe('function')
const rngA = new mod.D2Rng(0x12345678)
const rngB = new mod.D2Rng(0x12345678)
const seqA = Array.from({ length: 16 }, () => rngA.nextUint32())
const seqB = Array.from({ length: 16 }, () => rngB.nextUint32())
expect(seqA).toEqual(seqB)
expect(new Set(seqA).size).toBe(16)
})
it('F1.5: exports SplitMix64 pickVariant in src/common/ without 45-degree diagonal stripe correlation', async () => {
const mod = await importRepoModule(
'src/common/world/d2map.ts',
'src/common/rng/variants.ts',
)
const pickVariant =
mod.pickVariant ??
(await importRepoModule('src/common/world/d2map.ts')).pickVariant
expect(typeof pickVariant).toBe('function')
const candidates = [
{ library: 0, tile: 0, weight: 1, animated: false, frameIndex: 0 },
{ library: 0, tile: 1, weight: 1, animated: false, frameIndex: 0 },
{ library: 0, tile: 2, weight: 1, animated: false, frameIndex: 0 },
{ library: 0, tile: 3, weight: 1, animated: false, frameIndex: 0 },
]
let diagMatches = 0
let totalDiag = 0
const N = 32
const grid: number[][] = []
for (let y = 0; y < N; y += 1) {
const row: number[] = []
for (let x = 0; x < N; x += 1) {
row.push(pickVariant(candidates, x, y, 123456).tile)
}
grid.push(row)
}
for (let y = 0; y < N - 1; y += 1) {
for (let x = 0; x < N - 1; x += 1) {
if (grid[y]![x] === grid[y + 1]![x + 1]) diagMatches += 1
totalDiag += 1
}
}
const rate = diagMatches / totalDiag
expect(rate).toBeGreaterThan(0.15)
expect(rate).toBeLessThan(0.35)
})
it('F1.6: exports pure lookupTbl, toGameLang, and toTblLang in src/common/i18n/ decoupled from DOM state', async () => {
const mod = await importRepoModule(
'src/common/i18n/tbl.ts',
'src/common/i18n/lang.ts',
'src/common/i18n/index.ts',
)
expect(typeof mod.lookupTbl).toBe('function')
expect(typeof mod.toGameLang).toBe('function')
expect(typeof mod.toTblLang).toBe('function')
expect(mod.toGameLang('ENG')).toBe('en')
expect(mod.toGameLang('CHI')).toBe('zh')
expect(mod.toTblLang('en')).toBe('ENG')
expect(mod.toTblLang('zh')).toBe('CHI')
})
})
// ───────────────────────────────────────────────────────────────────────────
// F2: Shared Pack Contract (src/common/pack-contract/) (5 tests)
// ───────────────────────────────────────────────────────────────────────────
describe('F2: Shared Pack Contract (src/common/pack-contract/)', () => {
it('F2.1: provides src/common/pack-contract/index.ts with zero imports outside src/common/**', () => {
expect(existsInRepo('src/common/pack-contract/index.ts')).toBe(true)
const files = collectTsFiles('src/common/pack-contract')
expect(files.length).toBeGreaterThan(0)
for (const file of files) {
const src = readRepoFile(file)
const specs = extractImportSpecifiers(src)
for (const spec of specs) {
const target = resolveImportTargetRel(file, spec)
expect(target.startsWith('src/common/')).toBe(true)
}
}
})
it('F2.2: exports fail-fast universal DT1 and pack manifest validators from src/common/pack-contract/', async () => {
const contract = await importRepoModule('src/common/pack-contract/index.ts')
expect(typeof contract.validateUniversalDt1Libraries).toBe('function')
expect(typeof contract.validatePackManifest).toBe('function')
})
it('F2.3: validateUniversalDt1Libraries accepts libraries containing Blank.dt1, InvisWal.dt1 (style=49 seq=7), and Warp.dt1', async () => {
const contract = await importRepoModule('src/common/pack-contract/index.ts')
const validLibraries = [
{
path: 'data\\global\\tiles\\Act1\\Outdoors\\Blank.dt1',
archivePath: 'data\\global\\tiles\\Act1\\Outdoors\\Blank.dt1',
tiles: [{ type: 0, style: 0, sequence: 0 }],
},
{
path: 'data\\global\\tiles\\Act1\\Barracks\\InvisWal.dt1',
archivePath: 'data\\global\\tiles\\Act1\\Barracks\\InvisWal.dt1',
tiles: [{ type: 0, style: 49, sequence: 7 }],
},
{
path: 'data\\global\\tiles\\Act1\\Barracks\\Warp.dt1',
archivePath: 'data\\global\\tiles\\Act1\\Barracks\\Warp.dt1',
tiles: [{ type: 0, style: 0, sequence: 0 }],
},
]
expect(() => contract.validateUniversalDt1Libraries(validLibraries)).not.toThrow()
})
it('F2.4: validatePackManifest accepts a clean manifest with missingFiles === 0 and missingTiles === 0', async () => {
const contract = await importRepoModule('src/common/pack-contract/index.ts')
const cleanManifest = {
schema: 1,
missingFiles: 0,
missingTiles: 0,
entries: [{ name: 'Data\\Global\\Excel\\Levels.txt', offset: 0, length: 128 }],
}
expect(() => contract.validatePackManifest(cleanManifest)).not.toThrow()
})
it('F2.5: decouples src/client/map/map-service.ts from src/baker/ via src/common/pack-contract/', () => {
const mapServiceCode = readRepoFile('src/client/map/map-service.ts')
const specs = extractImportSpecifiers(mapServiceCode)
expect(specs.some((s) => s.includes('pack-tiles'))).toBe(false)
expect(specs.some((s) => s.includes('baker'))).toBe(false)
expect(specs.some((s) => s.includes('pack-contract'))).toBe(true)
})
})
// ───────────────────────────────────────────────────────────────────────────
// F3: Elimination of All 23 Server-to-Client Reverse Dependencies (5 tests)
// ───────────────────────────────────────────────────────────────────────────
describe('F3: Elimination of All 23 Server-to-Client Reverse Dependencies', () => {
it('F3.1: exports pure inventory grid model and gold limits from src/common/items/inventory-model.ts', async () => {
const invModel = await importRepoModule(
'src/common/items/inventory-model.ts',
'src/common/items/items.ts',
)
expect(typeof invModel.getInventoryGoldLimit).toBe('function')
expect(invModel.getInventoryGoldLimit(1)).toBe(10000)
expect(invModel.getInventoryGoldLimit(99)).toBe(990000)
expect(invModel.INV_COLS ?? 10).toBe(10)
expect(invModel.INV_ROWS ?? 4).toBe(4)
})
it('F3.2: exports pure belt potion model and compatibility rules from src/common/items/belt-model.ts', async () => {
const beltModel = await importRepoModule('src/common/items/belt-model.ts')
expect(beltModel.BELT_COLS).toBe(4)
expect(beltModel.BELT_POTION_CATALOG).toBeDefined()
expect(typeof beltModel.itemToBeltPotion).toBe('function')
expect(typeof beltModel.areBeltPotionsCompatible).toBe('function')
const hp1 = beltModel.itemToBeltPotion({ code: 'hp1', name: 'Minor Healing Potion' })
const hp5 = beltModel.itemToBeltPotion({ code: 'hp5', name: 'Super Healing Potion' })
const mp1 = beltModel.itemToBeltPotion({ code: 'mp1', name: 'Minor Mana Potion' })
expect(hp1).not.toBeNull()
expect(hp5).not.toBeNull()
expect(mp1).not.toBeNull()
expect(beltModel.areBeltPotionsCompatible(hp1, hp5)).toBe(true)
expect(beltModel.areBeltPotionsCompatible(hp1, mp1)).toBe(false)
})
it('F3.3: exports itemToUiInventoryItem and 1.13c weapon handedness sets from src/common/items/item-bridge.ts', async () => {
const bridge = await importRepoModule(
'src/common/items/item-bridge.ts',
'src/common/items/inventory-model.ts',
)
expect(typeof bridge.itemToUiInventoryItem).toBe('function')
expect(bridge.BARBARIAN_ONE_OR_TWO_HANDED_SWORD_CODES).toBeInstanceOf(Set)
expect(bridge.BARBARIAN_ONE_OR_TWO_HANDED_SWORD_CODES.size).toBe(18)
expect(bridge.BARBARIAN_ONE_OR_TWO_HANDED_SWORD_CODES.has('2hs')).toBe(true)
expect(bridge.BARBARIAN_ONE_OR_TWO_HANDED_SWORD_CODES.has('7gd')).toBe(true)
})
it('F3.4: exports 7-class CANONICAL_CHARSTATS and resolvePlayerCharToken from src/common/units/classes.ts', async () => {
const classesMod = await importRepoModule('src/common/units/classes.ts')
expect(classesMod.CHARACTER_CLASS_CODES).toEqual([
'ama',
'sor',
'nec',
'pal',
'bar',
'dru',
'ass',
])
expect(typeof classesMod.resolvePlayerCharToken).toBe('function')
expect(classesMod.resolvePlayerCharToken('ass')).toBe('ai')
expect(classesMod.resolvePlayerCharToken('dru')).toBe('dz')
expect(classesMod.resolvePlayerCharToken('sor')).toBe('so')
expect(classesMod.resolvePlayerCharToken('pal')).toBe('pa')
})
it('F3.5: exports getSkillName, isPassiveSkill, isLeftUsableSkill in src/common/skills/ and subTileToScene in src/common/world/', async () => {
const skillTree = await importRepoModule('src/common/skills/skill-tree.ts')
expect(typeof skillTree.getSkillName).toBe('function')
expect(typeof skillTree.isPassiveSkill).toBe('function')
expect(typeof skillTree.isLeftUsableSkill).toBe('function')
expect(skillTree.isPassiveSkill(37)).toBe(true) // Warmth
expect(skillTree.isPassiveSkill(36)).toBe(false) // Fire Bolt
expect(skillTree.isLeftUsableSkill(36)).toBe(true) // Fire Bolt
const d2map = await importRepoModule('src/common/world/d2map.ts')
expect(typeof d2map.subTileToScene).toBe('function')
})
})
// ───────────────────────────────────────────────────────────────────────────
// F4: src/baker/ Offline Baker Module & Unified CLI (src/baker/cli.ts) (5 tests)
// ───────────────────────────────────────────────────────────────────────────
describe('F4: src/baker/ Offline Baker Module & Unified CLI (src/baker/cli.ts)', () => {
it('F4.1: organizes src/baker/ into cli.ts, io/, packers/, extract/, and audit/', () => {
expect(existsInRepo('src/baker/cli.ts')).toBe(true)
expect(isDirInRepo('src/baker/io')).toBe(true)
expect(isDirInRepo('src/baker/packers')).toBe(true)
expect(isDirInRepo('src/baker/extract')).toBe(true)
expect(isDirInRepo('src/baker/audit')).toBe(true)
})
it('F4.2: relocates Node MPQ file-source.ts and png.ts into src/baker/io/ and removes file-source.ts from src/common/mpq/', () => {
expect(existsInRepo('src/baker/io/file-source.ts')).toBe(true)
expect(existsInRepo('src/baker/io/png.ts')).toBe(true)
expect(existsInRepo('src/baker/io/drlg-mpq-source.ts')).toBe(true)
expect(existsInRepo('src/baker/io/tbl-writer.ts')).toBe(true)
expect(existsInRepo('src/common/mpq/file-source.ts')).toBe(false)
})
it('F4.3: populates src/baker/packers/ with all pack-*.ts scripts and src/baker/audit/ with all 4 offline verifiers', () => {
const requiredPackers = [
'src/baker/packers/pack-tiles.ts',
'src/baker/packers/pack-act-assets.ts',
'src/baker/packers/pack-entity-assets.ts',
'src/baker/packers/pack-missiles.ts',
'src/baker/packers/pack-overlays.ts',
'src/baker/packers/pack-ui.ts',
'src/baker/packers/pack-animdata.ts',
'src/baker/packers/pack-characters.ts',
]
for (const p of requiredPackers) {
expect(existsInRepo(p), `Expected packer ${p}`).toBe(true)
}
const requiredAuditors = [
'src/baker/audit/verify-packs.ts',
'src/baker/audit/verify-entity-packs.ts',
'src/baker/audit/verify-tiles.ts',
'src/baker/audit/verify-tile-alignment.ts',
]
for (const a of requiredAuditors) {
expect(existsInRepo(a), `Expected auditor ${a}`).toBe(true)
}
})
it('F4.4: configures tsconfig.baker.json with types: ["node"] and excludes src/client/** and src/server/**', () => {
expect(existsInRepo('tsconfig.baker.json')).toBe(true)
const cfg = readJsonFile('tsconfig.baker.json')
expect(cfg.compilerOptions?.types).toEqual(['node'])
const libs = (cfg.compilerOptions?.lib as string[] | undefined)?.map((s) =>
s.toUpperCase(),
) ?? []
expect(libs).not.toContain('DOM')
const excludeStr = JSON.stringify(cfg.exclude ?? [])
expect(excludeStr).toContain('src/client')
expect(excludeStr).toContain('src/server')
})
it('F4.5: wires npm run bake to src/baker/cli.ts and updates pack:* scripts in package.json', () => {
const pkg = readJsonFile('package.json')
expect(typeof pkg.scripts?.bake).toBe('string')
expect(pkg.scripts.bake).toContain('src/baker/cli.ts')
expect(pkg.scripts['pack:tiles']).toContain('src/baker/')
expect(pkg.scripts['pack:entities']).toContain('src/baker/')
expect(pkg.scripts['pack:missiles']).toContain('src/baker/')
})
})
// ───────────────────────────────────────────────────────────────────────────
// F5: tools/ CLI Consolidation & Legacy Relay Deletion (5 tests)
// ───────────────────────────────────────────────────────────────────────────
describe('F5: tools/ CLI Consolidation & Legacy Relay Deletion', () => {
it('F5.1: consolidates developer, bot, replay, unlock, and verification CLI scripts into tools/', () => {
const requiredTools = [
'tools/d2-bot.ts',
'tools/netproto-replay.ts',
'tools/d2s-unlock.ts',
'tools/verify-play.ts',
'tools/audit-levels-browser.ts',
]
for (const tool of requiredTools) {
expect(existsInRepo(tool), `Expected CLI tool ${tool} to exist`).toBe(true)
}
expect(collectTsFiles('tools').length).toBeGreaterThanOrEqual(60)
})
it('F5.2: migrates tools/lib/drlg-oracle.ts and tools/d2s-unlock.ts with intact exports', async () => {
expect(existsInRepo('tools/lib/drlg-oracle.ts')).toBe(true)
const unlockMod = await importRepoModule('tools/d2s-unlock.ts')
expect(unlockMod).toBeDefined()
expect(Object.keys(unlockMod).length).toBeGreaterThan(0)
})
it('F5.3: deletes legacy P2P lockstep server scripts net-relay.ts and net-server.ts', () => {
expect(existsInRepo('scripts/net-relay.ts')).toBe(false)
expect(existsInRepo('scripts/net-server.ts')).toBe(false)
expect(existsInRepo('tools/net-relay.ts')).toBe(false)
expect(existsInRepo('tools/net-server.ts')).toBe(false)
})
it('F5.4: removes the legacy top-level scripts/ directory completely', () => {
expect(existsInRepo('scripts')).toBe(false)
expect(collectTsFiles('scripts')).toEqual([])
})
it('F5.5: updates package.json tool scripts to target tools/ and removes net-server script', () => {
const pkg = readJsonFile('package.json')
const scriptsJson = JSON.stringify(pkg.scripts ?? {})
expect(scriptsJson).not.toContain('scripts/')
expect(pkg.scripts?.['net-server']).toBeUndefined()
expect(pkg.scripts?.['net']).toBeUndefined()
expect(pkg.scripts?.['verify:play']).toContain('tools/verify-play.ts')
})
})
// ───────────────────────────────────────────────────────────────────────────
// F6: src/server/ Authoritative Simulation Module & LocalLoopbackAdapter (6 tests)
// ───────────────────────────────────────────────────────────────────────────
describe('F6: src/server/ Authoritative Simulation Module & LocalLoopbackAdapter', () => {
it('F6.1: populates all 8 canonical subdirectories under src/server/', () => {
const requiredSubdirs = [
'src/server/engine',
'src/server/ai',
'src/server/skills',
'src/server/items',
'src/server/world',
'src/server/save',
'src/server/scene',
'src/server/adapter',
]
for (const dir of requiredSubdirs) {
expect(isDirInRepo(dir), `Expected directory ${dir} to exist`).toBe(true)
expect(collectTsFiles(dir).length).toBeGreaterThan(0)
}
})
it('F6.2: exports GameEngine and worldToCell from src/server/engine/engine.ts', async () => {
const engineMod = await importRepoModule('src/server/engine/engine.ts')
expect(typeof engineMod.GameEngine).toBe('function')
expect(typeof engineMod.worldToCell).toBe('function')
const cellCoords = engineMod.worldToCell({ widthPx: 800, heightPx: 600, overlap: () => 0 }, 80, 40)
expect(cellCoords).toEqual({ cellX: 1, cellY: 0 })
})
it('F6.3: registers 7-class skill handlers in src/server/skills/registry.ts without node:* builtins', async () => {
const regSource = readRepoFile('src/server/skills/registry.ts')
expect(regSource).not.toContain('node:fs')
expect(regSource).not.toContain('node:path')
expect(regSource).not.toContain('getBuiltinModule')
const regMod = await importRepoModule('src/server/skills/registry.ts')
expect(regMod).toBeDefined()
}, 20_000)
it('F6.4: implements LocalLoopbackAdapter in src/server/adapter/LocalLoopbackAdapter.ts conforming to GameServerAdapter', async () => {
const adapterMod = await importRepoModule(
'src/server/adapter/LocalLoopbackAdapter.ts',
'src/server/adapter/index.ts',
)
const LocalLoopbackAdapter =
adapterMod.LocalLoopbackAdapter ?? adapterMod.default
expect(typeof LocalLoopbackAdapter).toBe('function')
const adapter = new LocalLoopbackAdapter({ tickRateMs: 0 })
expect(typeof adapter.connect).toBe('function')
expect(typeof adapter.disconnect).toBe('function')
expect(typeof adapter.send).toBe('function')
expect(typeof adapter.onEvent).toBe('function')
expect(typeof adapter.isConnected).toBe('boolean')
expect(typeof adapter.tick).toBe('number')
adapter.disconnect()
})
it('F6.5: connects LocalLoopbackAdapter, emits initial ServerEvents, and advances ticks', async () => {
const adapterMod = await importRepoModule(
'src/server/adapter/LocalLoopbackAdapter.ts',
'src/server/adapter/index.ts',
)
const LocalLoopbackAdapter =
adapterMod.LocalLoopbackAdapter ?? adapterMod.default
const events: any[] = []
const adapter = new LocalLoopbackAdapter({
act: 1,
levelId: 1,
seed: 0x113c,
tickRateMs: 0,
})
const unsub = adapter.onEvent((ev: any) => {
events.push(ev)
})
await adapter.connect()
expect(adapter.isConnected).toBe(true)
if (typeof adapter.stepTick === 'function') {
adapter.stepTick()
}
adapter.send({ type: 'WalkToLocation', x: 100, y: 100 })
if (typeof adapter.stepTick === 'function') {
adapter.stepTick()
}
expect(events.length).toBeGreaterThan(0)
unsub()
adapter.disconnect()
expect(adapter.isConnected).toBe(false)
})
it('F6.6: configures tsconfig.server.json without DOM lib and excludes src/client/** and src/baker/**', () => {
expect(existsInRepo('tsconfig.server.json')).toBe(true)
const cfg = readJsonFile('tsconfig.server.json')
const libs = (cfg.compilerOptions?.lib as string[] | undefined)?.map((s) =>
s.toUpperCase(),
) ?? []
expect(libs).not.toContain('DOM')
const excludeStr = JSON.stringify(cfg.exclude ?? [])
expect(excludeStr).toContain('src/client')
expect(excludeStr).toContain('src/baker')
})
})
// ───────────────────────────────────────────────────────────────────────────
// F7: src/client/ Pure Browser Module & MapService Node Decoupling (5 tests)
// ───────────────────────────────────────────────────────────────────────────
describe('F7: src/client/ Pure Browser Module & MapService Node Decoupling', () => {
it('F7.1: consolidates all browser modules under src/client/', () => {
expect(existsInRepo('src/client/main.ts')).toBe(true)
const requiredSubdirs = [
'src/client/render',
'src/client/ui',
'src/client/frontend',
'src/client/scene',
'src/client/audio',
'src/client/i18n',
'src/client/map',
'src/client/session',
'src/client/world',
]
for (const dir of requiredSubdirs) {
expect(isDirInRepo(dir), `Expected client directory ${dir}`).toBe(true)
expect(collectTsFiles(dir).length).toBeGreaterThan(0)
}
})
it('F7.2: decouples MapService in src/client/map/map-service.ts from node:fs/node:path via fromLoader', async () => {
const code = readRepoFile('src/client/map/map-service.ts')
expect(code).not.toContain('node:fs')
expect(code).not.toContain('node:path')
expect(code).not.toContain("node:${'fs'}")
expect(code).not.toContain("node:${'path'}")
const mod = await importRepoModule('src/client/map/map-service.ts')
expect(typeof mod.MapService).toBe('function')
expect(typeof mod.MapService.fromLoader).toBe('function')
expect(
typeof mod.MapService.fromPackedUrls === 'function' ||
typeof mod.MapService.fromPackedUrl === 'function',
).toBe(true)
})
it('F7.3: provides browser language state in src/client/i18n/lang.ts and re-exports lookupTbl', async () => {
const langMod = await importRepoModule('src/client/i18n/lang.ts')
expect(typeof langMod.detectLangFromUrl).toBe('function')
expect(typeof langMod.getTblLang).toBe('function')
expect(typeof langMod.setGameLang).toBe('function')
expect(typeof langMod.lookupTbl).toBe('function')
expect(langMod.TOOLBAR_I18N).toBeDefined()
expect(langMod.detectLangFromUrl('http://localhost/play.html?lang=en')).toBe('ENG')
expect(langMod.detectLangFromUrl('http://localhost/play.html?lang=zh')).toBe('CHI')
})
it('F7.4: decouples HudManager and WorldRenderer in src/client/ from server GameEngine', () => {
const hudCode = readRepoFile('src/client/ui/hud-manager.ts')
const rendererCode = readRepoFile('src/client/scene/world-renderer.ts')
for (const spec of [
...extractImportSpecifiers(hudCode),
...extractImportSpecifiers(rendererCode),
]) {
expect(spec).not.toContain('/server/')
expect(spec).not.toContain('/game/')
}
})
it('F7.5: configures tsconfig.client.json with DOM lib, types: [], and excludes src/server/** & src/baker/**', () => {
expect(existsInRepo('tsconfig.client.json')).toBe(true)
const cfg = readJsonFile('tsconfig.client.json')
const libs = (cfg.compilerOptions?.lib as string[] | undefined)?.map((s) =>
s.toUpperCase(),
) ?? []
expect(libs).toContain('DOM')
expect(cfg.compilerOptions?.types).toEqual([])
const excludeStr = JSON.stringify(cfg.exclude ?? [])
expect(excludeStr).toContain('src/server')
expect(excludeStr).toContain('src/baker')
})
})
// ───────────────────────────────────────────────────────────────────────────
// F8: Single HTML Entry (play.html) Consolidation & Legacy Entry Removal (5 tests)
// ───────────────────────────────────────────────────────────────────────────
describe('F8: Single HTML Entry (play.html) Consolidation & Legacy Entry Removal', () => {
it('F8.1: retains play.html with /src/client/main.ts entry and D2Exocet / D2Formal436 @font-face declarations', () => {
expect(existsInRepo('play.html')).toBe(true)
const html = readRepoFile('play.html')
expect(html).toContain('/src/client/main.ts')
expect(html).toContain('@font-face')
expect(html).toContain('D2Exocet')
expect(html).toContain('D2Formal436')
})
it('F8.2: deletes all 5 legacy HTML files (index.html, acts.html, acts-zh.html, acts-en.html, bnet.html)', () => {
for (const legacyHtml of LEGACY_HTML_FILES) {
expect(existsInRepo(legacyHtml), `Expected ${legacyHtml} to be deleted`).toBe(false)
}
})
it('F8.3: configures vite.config.ts with play.html as the sole rollup input', () => {
const viteCfg = readRepoFile('vite.config.ts')
expect(viteCfg).toContain('play.html')
for (const legacyHtml of LEGACY_HTML_FILES) {
expect(viteCfg).not.toContain(legacyHtml)
}
})
it('F8.4: deletes obsolete entry glue files (src/main.ts, src/walk.ts, bnet-main.ts, act-scene.ts, net-scene.ts, src/net/)', () => {
const obsoletePaths = [
'src/main.ts',
'src/walk.ts',
'src/frontend/bnet-main.ts',
'src/client/frontend/bnet-main.ts',
'src/scene/act-scene.ts',
'src/client/scene/act-scene.ts',
'src/scene/net-scene.ts',
'src/client/scene/net-scene.ts',
'src/scene/backend/net-simulation.ts',
'src/server/scene/net-simulation.ts',
'src/net',
]
for (const p of obsoletePaths) {
expect(existsInRepo(p), `Expected obsolete path ${p} to be deleted`).toBe(false)
}
})
it('F8.5: preserves deterministic state hash calculation (computeLockstepHash / computeStateHash) in src/server/engine/', async () => {
const engineMod = await importRepoModule(
'src/server/engine/state-hash.ts',
'src/server/engine/engine.ts',
)
const hashFn =
engineMod.computeLockstepHash ??
engineMod.computeStateHash ??
(await importRepoModule('src/server/engine/engine.ts')).computeLockstepHash ??
(await importRepoModule('src/server/engine/engine.ts')).computeStateHash
expect(typeof hashFn).toBe('function')
})
})
// ───────────────────────────────────────────────────────────────────────────
// F9: Compiler-Level Isolation & Boundary Gate (tests/arch/boundaries.test.ts) (5 tests)
// ───────────────────────────────────────────────────────────────────────────
describe('F9: Compiler-Level Isolation & Zero-Exception Boundary Gate', () => {
it('F9.1: wires all 5 module tsconfig.*.json files into package.json typecheck', () => {
const configs = [
'tsconfig.common.json',
'tsconfig.netproto.json',
'tsconfig.server.json',
'tsconfig.client.json',
'tsconfig.baker.json',
]
const pkg = readJsonFile('package.json')
const typecheckCmd = String(pkg.scripts?.typecheck ?? '')
for (const cfg of configs) {
expect(existsInRepo(cfg), `Expected ${cfg} to exist`).toBe(true)
expect(typecheckCmd, `Expected npm run typecheck to include ${cfg}`).toContain(cfg)
}
})
it('F9.2: ensures src/ contains ONLY the 5 canonical module directories (common, netproto, server, client, baker)', () => {
const entries = readdirSync(SRC_DIR)
for (const entry of entries) {
const full = `${SRC_DIR}/${entry}`
const isDir = statSync(full).isDirectory()
expect(isDir, `Unexpected top-level file in src/: ${entry}`).toBe(true)
}
expect(entries.sort()).toEqual([...CANONICAL_SRC_MODULES].sort())
for (const legacyDir of LEGACY_SRC_DIRS) {
expect(existsInRepo(`src/${legacyDir}`)).toBe(false)
}
})
it('F9.3: provides tests/arch/boundaries.test.ts enforcing module isolation with 0 whitelist exceptions', () => {
expect(existsInRepo('tests/arch/boundaries.test.ts')).toBe(true)
const content = readRepoFile('tests/arch/boundaries.test.ts')
expect(content).toContain('common')
expect(content).toContain('netproto')
expect(content).toContain('server')
expect(content).toContain('client')
expect(content).toContain('baker')
expect(content).toContain('play.html')
})
it('F9.4: maintains tests/netproto/boundary.test.ts and src/netproto/index.ts public surface', async () => {
expect(existsInRepo('tests/netproto/boundary.test.ts')).toBe(true)
const netproto = await importRepoModule('src/netproto/index.ts')
expect(typeof netproto.createD2OnlineFlow).toBe('function')
expect(typeof netproto.decodeD2gsS2cPacket).toBe('function')
expect(typeof netproto.encodeD2gsClientCommand).toBe('function')
})
it('F9.5: relocates co-located test files to tests/ so src/** has zero test files', () => {
const srcTests = collectTsFiles('src').filter(
(f) => f.endsWith('.test.ts') || f.endsWith('.spec.ts'),
)
expect(srcTests).toEqual([])
expect(
existsInRepo('tests/automap-table.test.ts') ||
existsInRepo('tests/common/formats/automap-table.test.ts'),
).toBe(true)
expect(existsInRepo('tests/frontend/flow-state.test.ts')).toBe(true)
})
})
// ───────────────────────────────────────────────────────────────────────────
// F10: Full 1.13c Parity, Replay, Baker & DRLG Invariants (5 tests)
// ───────────────────────────────────────────────────────────────────────────
describe('F10: Full 1.13c Parity, Replay, Baker & DRLG Invariants', () => {
it('F10.1: exports UNIVERSAL_DT1S in src/common/world/acts.ts and appends all 3 in resolveLevelLibraries', async () => {
const actsMod = await importRepoModule('src/common/world/acts.ts')
expect(actsMod.UNIVERSAL_DT1S).toEqual(UNIVERSAL_DT1_PATHS)
expect(typeof actsMod.resolveLevelLibraries).toBe('function')
})
it('F10.2: enforces COLLIDE_MASK_INVALID (COLLIDE_BLANK | COLLIDE_WALL) on empty/void cells in src/common/world/d2map.ts', async () => {
const d2map = await importRepoModule('src/common/world/d2map.ts')
expect(d2map.COLLIDE_WALL).toBe(0x0001)
expect(d2map.COLLIDE_BLANK).toBe(0x0020)
expect(d2map.COLLIDE_MASK_INVALID & d2map.COLLIDE_BLANK).toBe(d2map.COLLIDE_BLANK)
expect(d2map.COLLIDE_MASK_INVALID & d2map.COLLIDE_WALL).toBe(d2map.COLLIDE_WALL)
const emptyDs1 = {
version: 18,
width: 4,
height: 4,
act: 1,
substitutionType: 0,
wallLayers: 1,
floorLayers: 1,
objects: [],
npcPathOffset: null,
cells: Array.from({ length: 4 }, () =>
Array.from({ length: 4 }, () => ({
floors: [],
walls: [],
shadows: [],
substitutions: [],
})),
),
}
const scene = d2map.buildIsoMapScene(emptyDs1, [], 0)
for (let i = 0; i < scene.blocked.length; i += 1) {
expect(scene.blocked[i]).toBe(1)
expect(scene.collisionMasks[i] & d2map.COLLIDE_MASK_INVALID).toBe(
d2map.COLLIDE_MASK_INVALID,
)
}
})
it('F10.3: ensures findIsoSpawn in src/common/world/d2map.ts rejects void geometric center and spawns on connected floor', async () => {
const d2map = await importRepoModule('src/common/world/d2map.ts')
const walkableTile = {
direction: 0,
roofHeight: 0,
materialFlags: 0,
height: -80,
width: 160,
type: 0,
style: 1,
sequence: 0,
rarityFrameIndex: 1,
animated: 0,
subTileFlags: Array.from({ length: 25 }, () => ({
blockWalk: false,
blockPlayerWalk: false,
blockLight: false,
blockLos: false,
blockJump: false,
raw: 0,
})),
minBlockY: -80,
bitmapHeight: 80,
blocks: [],
}
const dt1 = { versionMajor: 7, versionMinor: 6, warnings: [], tiles: [walkableTile] }
const cells = Array.from({ length: 16 }, (_, y) =>
Array.from({ length: 16 }, (_, x) =>
x >= 1 && x <= 3 && y >= 1 && y <= 3
? {
floors: [{ prop1: 1, sequence: 0, style: 1, hidden: false, unknown1: 0, unknown2: 0 }],
walls: [],
shadows: [],
substitutions: [],
}
: { floors: [], walls: [], shadows: [], substitutions: [] },
),
)
const scene = d2map.buildIsoMapScene(
{
version: 18,
width: 16,
height: 16,
act: 1,
substitutionType: 0,
wallLayers: 1,
floorLayers: 1,
objects: [],
npcPathOffset: null,
cells,
},
[dt1],
0,
)
const spawn = d2map.findIsoSpawn(scene)
expect(spawn).not.toBeNull()
expect(d2map.isBlockedAt(scene, spawn.x, spawn.y)).toBe(false)
const spawnCell = d2map.cellAt(scene, spawn.x, spawn.y)
expect(Math.floor(spawnCell.x)).toBeGreaterThanOrEqual(1)
expect(Math.floor(spawnCell.x)).toBeLessThanOrEqual(3)
})
it('F10.4: preserves 1.13c SuperUniques boss & minion class fidelity in src/common/units/', async () => {
const monstersData = await importRepoModule(
'src/common/units/monsters-data.ts',
'src/common/units/monsters.ts',
)
const monsterMapping = await importRepoModule('src/common/units/monster-mapping.ts')
const parsed = monstersData.readSuperUniques(monstersData.CANONICAL_SUPERUNIQUES_TABLE)
const byId = new Map<string, any>(parsed.map((p: any) => [p.id, p]))
expect(byId.get('Beetleburst')?.monsterId).toBe('scarab2')
expect(byId.get('Dark Elder')?.monsterId).toBe('zombie5')
expect(byId.get('Witch Doctor Endugu')?.monsterId).toBe('fetishshaman4')
expect(monsterMapping.resolveMonsterArtSpec('minion1')).toEqual({
token: 'XX',
weapon: 'hth',
})
})
it('F10.5: replays 02-d2gs-town-walk-chat.d2cap via tools/netproto-replay.ts with zero unknown/opaque/error packets', async () => {
const replayMod = await importRepoModule('tools/netproto-replay.ts')
const capText = readRepoFile('tests/fixtures/netproto/02-d2gs-town-walk-chat.d2cap')
const res = replayMod.replayD2CapText(capText, { strict: true })
expect(res.unknownPackets).toBe(0)
expect(res.opaquePackets).toBe(0)
expect(res.protocolErrors).toBe(0)
expect(res.gameS2cPackets).toBeGreaterThan(0)
})
})
})