diablo2-web/tests/e2e-module-split/tier2-boundary-corner.test.ts

840 lines
33 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 2 — Boundary & Corner Case E2E Test Suite (`tests/e2e-module-split/tier2-boundary-corner.test.ts`).
*
* Covers >= 50 negative, boundary, fail-fast, and strict-isolation test cases (>= 5 per feature F1–F10):
* - F1: Top-Level 5-Module Directory Layout & Legacy Root Cleanup (6 boundary/fail-fast tests)
* - F2: `src/common` Foundation (Formats, Compression, MPQ, Data Registry, i18n) (5 boundary/isolation tests)
* - F3: `src/common` Domain Models (Items, Units, Skills, World & Collision) (6 boundary/adversarial tests)
* - F4: `src/netproto` Pure Binary Wire Protocol Layer (5 boundary/corrupt-packet tests)
* - F5: `src/server` Authoritative Game Simulation & AI Engine (5 boundary/isolation tests)
* - F6: `src/client` Browser Presentation, WebGL2 Renderer, Audio, UI & Session (5 boundary/isolation tests)
* - F7: Unified `play.html` Single-Entry Launcher & Vite Build Consolidation (5 boundary/legacy-purge tests)
* - F8: `src/baker` Offline MPQ Extraction & CLI Tools Alignment (5 boundary/isolation tests)
* - F9: Multi-TSConfig Compile-Time Module Boundary Enforcement (5 strict exclusion tests)
* - F10: Repository-Wide Import Graph & Test/Benchmark Suite Migration (5 boundary/replay tests)
*/
import { relative } from 'node:path'
import { describe, expect, it } from 'vitest'
import {
collectTsFiles,
existsInRepo,
extractImportSpecifiers,
importRepoModule,
LEGACY_HTML_FILES,
LEGACY_SRC_DIRS,
readJsonFile,
readRepoFile,
resolveImportTargetRel,
ROOT_DIR,
stripComments,
stripCommentsAndStrings,
UNIVERSAL_DT1_PATHS,
} from './helpers'
describe('Tier 2: F1 — Fail-Fast Invariants & Anti-Silent-Fallback Enforcement', () => {
it('F1.B1: calling getSharedDataRegistry() or getSharedMountedArchives() without an ArchiveSource fails fast with an Error', async () => {
const regMod = await importRepoModule('src/common/data/data-registry.ts')
regMod.resetSharedDataRegistryCache()
expect(() => regMod.getSharedMountedArchives()).toThrow(/ArchiveSource/i)
expect(() => regMod.getSharedDataRegistry()).toThrow(/ArchiveSource/i)
})
it('F1.B2: validateUniversalDt1Libraries([]) fails fast naming Blank.dt1', async () => {
const packContract = await importRepoModule('src/common/pack-contract/index.ts')
expect(() => packContract.validateUniversalDt1Libraries([])).toThrow(/Blank\.dt1/i)
})
it('F1.B3: validateUniversalDt1Libraries rejects InvisWal.dt1 when missing floor tile (type=0, style=49, sequence=7)', async () => {
const packContract = await importRepoModule('src/common/pack-contract/index.ts')
const brokenInvisWal = [
{ path: UNIVERSAL_DT1_PATHS[0], tiles: [{ type: 0, style: 0, sequence: 0 }] },
{ path: UNIVERSAL_DT1_PATHS[1], tiles: [{ type: 1, style: 49, sequence: 7 }] }, // wrong type (wall instead of floor=0)
{ path: UNIVERSAL_DT1_PATHS[2], tiles: [{ type: 0, style: 0, sequence: 0 }] },
]
expect(() => packContract.validateUniversalDt1Libraries(brokenInvisWal)).toThrow(/InvisWal\.dt1/i)
})
it('F1.B4: validateUniversalDt1Libraries rejects libraries when Warp.dt1 is missing or empty', async () => {
const packContract = await importRepoModule('src/common/pack-contract/index.ts')
const missingWarp = [
{ path: UNIVERSAL_DT1_PATHS[0], tiles: [{ type: 0, style: 0, sequence: 0 }] },
{ path: UNIVERSAL_DT1_PATHS[1], tiles: [{ type: 0, style: 49,Sequence: 7, sequence: 7 }] },
{ path: UNIVERSAL_DT1_PATHS[2], tiles: [] },
]
expect(() => packContract.validateUniversalDt1Libraries(missingWarp)).toThrow(/Warp\.dt1/i)
})
it('F1.B5: validatePackManifest fails fast when missingFiles > 0 or missingTiles > 0', async () => {
const packContract = await importRepoModule('src/common/pack-contract/index.ts')
expect(() =>
packContract.validatePackManifest({ missingFiles: 1, missingTiles: 0 }),
).toThrow(/missingFiles/i)
expect(() =>
packContract.validatePackManifest({ missingFiles: 0, missingTiles: 3 }),
).toThrow(/missingTiles/i)
})
it('F1.B6: src/common/** contains zero hardcoded /usr/local/google/home/taodao/d2-data paths and zero permissive !libraries fallbacks', () => {
const commonFiles = collectTsFiles('src/common')
expect(commonFiles.length).toBeGreaterThan(0)
const violations: string[] = []
for (const file of commonFiles) {
const raw = readRepoFile(relative(ROOT_DIR, file))
const codeOnly = stripComments(raw)
if (codeOnly.includes('/usr/local/google/home/taodao/d2-data')) {
violations.push(`${relative(ROOT_DIR, file)}: contains hardcoded d2-data path`)
}
if (/if\s*\(\s*!libraries\s*\)\s*return\s+true/.test(codeOnly)) {
violations.push(`${relative(ROOT_DIR, file)}: contains permissive if (!libraries) return true`)
}
}
expect(violations).toEqual([])
})
})
describe('Tier 2: F2 — src/common Foundation Pure-TypeScript Isolation & Corrupt Buffer Handling', () => {
it('F2.B1: src/common/** has zero imports of node:* modules or process.env', () => {
const commonFiles = collectTsFiles('src/common')
expect(commonFiles.length).toBeGreaterThan(0)
const violations: string[] = []
for (const file of commonFiles) {
const rel = relative(ROOT_DIR, file)
const source = readRepoFile(rel)
const specs = extractImportSpecifiers(source)
for (const spec of specs) {
if (
spec.startsWith('node:') ||
spec === 'fs' ||
spec === 'path' ||
spec === 'url' ||
spec === 'child_process'
) {
violations.push(`${rel}: forbidden Node import '${spec}'`)
}
}
const tokens = stripCommentsAndStrings(source)
if (/\bprocess\.env\b/.test(tokens)) {
violations.push(`${rel}: forbidden process.env access`)
}
}
expect(violations).toEqual([])
})
it('F2.B2: src/common/** has zero references to browser DOM/WebGL/Audio globals in executable code', () => {
const commonFiles = collectTsFiles('src/common')
expect(commonFiles.length).toBeGreaterThan(0)
const violations: string[] = []
const forbiddenBrowserPattern =
/\b(?:window|document|localStorage|HTMLCanvasElement|WebGLRenderingContext|WebGL2RenderingContext|AudioContext)\b/
for (const file of commonFiles) {
const rel = relative(ROOT_DIR, file)
const tokens = stripCommentsAndStrings(readRepoFile(rel))
const m = forbiddenBrowserPattern.exec(tokens)
if (m) {
violations.push(`${rel}: forbidden browser global '${m[0]}'`)
}
}
expect(violations).toEqual([])
})
it('F2.B3: parseTbl handles empty and truncated byte buffers deterministically without unhandled RangeError', async () => {
const tblMod = await importRepoModule(
'src/common/i18n/tbl.ts',
'src/common/formats/tbl.ts',
)
for (const buf of [new Uint8Array(0), new Uint8Array([0x01, 0x02, 0x03])]) {
try {
const res = tblMod.parseTbl(buf)
expect(res).toBeDefined()
} catch (err) {
expect(err).toBeInstanceOf(Error)
}
}
})
it('F2.B4: parseDc6 and parseDcc reject invalid headers or truncated buffers deterministically', async () => {
const dc6Mod = await importRepoModule('src/common/formats/dc6.ts')
const dccMod = await importRepoModule('src/common/formats/dcc.ts')
const badDc6 = new Uint8Array([0xff, 0xff, 0xff, 0xff, 0x00, 0x00, 0x00, 0x00])
try {
const parsed = dc6Mod.parseDc6(badDc6)
expect(parsed).toBeDefined()
} catch (err) {
expect(err).toBeInstanceOf(Error)
}
const badDcc = new Uint8Array([0x00, 0x01, 0x02])
expect(() => dccMod.parseDcc(badDcc)).toThrow()
})
it('F2.B5: parseDt1 and parseDs1 handle truncated/invalid headers deterministically', async () => {
const dt1Mod = await importRepoModule('src/common/formats/dt1.ts')
const ds1Mod = await importRepoModule('src/common/formats/ds1.ts')
expect(() => dt1Mod.parseDt1(new Uint8Array([1, 2, 3, 4]))).toThrow()
expect(() => ds1Mod.parseDs1(new Uint8Array([1, 2, 3, 4]))).toThrow()
})
})
describe('Tier 2: F3 — src/common Domain Models Boundary & Adversarial Cases', () => {
it('F3.B1: itemToBeltPotion rejects throwing weapon potions (gpm, opm, opl, ops, gps, gpl) by returning null', async () => {
const beltMod = await importRepoModule('src/common/items/belt-model.ts')
const throwingWeaponCodes = ['gpm', 'opm', 'opl', 'ops', 'gps', 'gpl']
for (const code of throwingWeaponCodes) {
const res = beltMod.itemToBeltPotion({
id: 99,
code,
name: `Throwing ${code}`,
type: 'tpot',
quality: 'normal',
level: 1,
identified: true,
gridX: 0,
gridY: 0,
invW: 1,
invH: 1,
stats: [],
})
expect(res).toBeNull()
}
})
it('F3.B2: findFreeGridSlot returns null on a full 10x4 grid and rejects oversized item dimensions', async () => {
const invMod = await importRepoModule('src/common/items/inventory-model.ts')
const fullGrid: (number | null)[][] = Array.from({ length: invMod.INV_ROWS }, () =>
Array.from({ length: invMod.INV_COLS }, () => 1),
)
expect(invMod.findFreeGridSlot(fullGrid, 1, 1)).toBeNull()
const emptyGrid: (number | null)[][] = Array.from({ length: invMod.INV_ROWS }, () =>
Array.from({ length: invMod.INV_COLS }, () => null),
)
expect(invMod.findFreeGridSlot(emptyGrid, 2, invMod.INV_ROWS + 1)).toBeNull()
expect(invMod.findFreeGridSlot(emptyGrid, invMod.INV_COLS + 1, 1)).toBeNull()
})
it('F3.B3: checkRunewordMatch returns null on incomplete socket count or reversed rune order', async () => {
const rwMod = await importRepoModule('src/common/items/runeword.ts')
// Incomplete socket count (2 runes in 3-socket armor)
expect(rwMod.checkRunewordMatch(['jah', 'ith'], 'armor', 3)).toBeNull()
// Reversed rune order ('ith', 'ber', 'jah' instead of 'jah', 'ith', 'ber')
expect(rwMod.checkRunewordMatch(['ith', 'ber', 'jah'], 'armor', 3)).toBeNull()
// Wrong item category (Enigma is 'armor' only, not 'shield' or 'weapon')
expect(rwMod.checkRunewordMatch(['jah', 'ith', 'ber'], 'shield', 3)).toBeNull()
})
it('F3.B4: socketItemIntoHost rejects non-socketable hosts, full sockets, and non-filler items', async () => {
const gemsMod = await importRepoModule('src/common/items/gems.ts')
const nonSocketHost = {
id: 1,
code: 'cap',
name: 'Cap',
type: 'helm',
quality: 'normal',
level: 1,
identified: true,
gridX: 0,
gridY: 0,
invW: 2,
invH: 2,
numSockets: 0,
sockets: [],
stats: [],
}
const validGem = {
id: 2,
code: 'gcr',
name: 'Chipped Ruby',
type: 'gem',
quality: 'normal',
level: 1,
identified: true,
gridX: 0,
gridY: 0,
invW: 1,
invH: 1,
isSocketFiller: true,
stats: [],
}
expect(gemsMod.socketItemIntoHost(nonSocketHost, validGem)).toBeNull()
const fullHost = {
...nonSocketHost,
numSockets: 1,
sockets: [validGem],
}
expect(gemsMod.socketItemIntoHost(fullHost, validGem)).toBeNull()
const nonFiller = {
...validGem,
isSocketFiller: false,
}
const openHost = {
...nonSocketHost,
numSockets: 2,
sockets: [],
}
expect(gemsMod.socketItemIntoHost(openHost, nonFiller)).toBeNull()
})
it('F3.B5: findIsoSpawn avoids blocked geometric center and isBlockedAt blocks out-of-bounds coordinates', async () => {
const d2mapMod = await importRepoModule('src/common/world/d2map.ts')
const width = 10
const height = 10
const mask = new Uint16Array(width * height)
// Block all cells except (1, 1) and (1, 2)
mask.fill(d2mapMod.COLLIDE_MASK_INVALID)
mask[1 * width + 1] = 0
mask[2 * width + 1] = 0
const fakeMap = {
width,
height,
collisionMask: mask,
rooms: [{ x: 0, y: 0, width: 2, height: 2 }],
Floor: Array.from({ length: height }, (_, y) =>
Array.from({ length: width }, (_, x) =>
(x === 1 && (y === 1 || y === 2)) ? [1] : [],
),
),
Wall: Array.from({ length: height }, () => Array.from({ length: width }, () => [])),
}
expect(d2mapMod.isBlockedAt(fakeMap, -1, 0)).toBe(true)
expect(d2mapMod.isBlockedAt(fakeMap, 0, -1)).toBe(true)
expect(d2mapMod.isBlockedAt(fakeMap, width + 5, height + 5)).toBe(true)
expect(d2mapMod.isBlockedAt(fakeMap, 5, 5)).toBe(true) // geometric center is blocked
expect(d2mapMod.isBlockedAt(fakeMap, 1, 1)).toBe(false)
const spawn = d2mapMod.findIsoSpawn(fakeMap)
expect(spawn).toBeDefined()
expect(d2mapMod.isBlockedAt(fakeMap, Math.floor(spawn.x), Math.floor(spawn.y))).toBe(false)
})
it('F3.B6: pickVariant is deterministic and avoids 45-degree diagonal periodicity', async () => {
const d2mapMod = await importRepoModule('src/common/world/d2map.ts')
const pool = Array.from({ length: 8 }, (_, i) => ({
rarity: 1,
id: i,
}))
const v1 = d2mapMod.pickVariant(pool as any, 3, 4, 5, 6)
const v2 = d2mapMod.pickVariant(pool as any, 3, 4, 5, 6)
expect(v1).toBe(v2)
// Sample diagonal line (tx = k, ty = k) for k = 0..15 and ensure multiple distinct variants are selected
const diagonalSelections = new Set<number>()
for (let k = 0; k < 16; k++) {
const chosen = d2mapMod.pickVariant(pool as any, 0, 0, k, k) as any
if (chosen) diagonalSelections.add(chosen.id)
}
expect(diagonalSelections.size).toBeGreaterThanOrEqual(3)
})
})
describe('Tier 2: F4 — src/netproto Pure Binary Wire Protocol Boundary & Isolation Cases', () => {
it('F4.B1: src/netproto/** has zero imports from ../client, ../server, ../baker, node:*, or three', () => {
const netprotoFiles = collectTsFiles('src/netproto')
expect(netprotoFiles.length).toBeGreaterThan(0)
const violations: string[] = []
for (const file of netprotoFiles) {
const rel = relative(ROOT_DIR, file)
const specs = extractImportSpecifiers(readRepoFile(rel))
for (const spec of specs) {
const target = resolveImportTargetRel(file, spec)
if (
target.startsWith('src/client') ||
target.startsWith('src/server') ||
target.startsWith('src/baker') ||
spec.startsWith('node:') ||
spec === 'three'
) {
violations.push(`${rel}: forbidden import '${spec}' (${target})`)
}
}
}
expect(violations).toEqual([])
})
it('F4.B2: decodePacket fails fast with ok:false on empty 0-byte S2C and C2S buffers', async () => {
const netproto = await importRepoModule('src/netproto/index.ts')
const s2cRes = netproto.decodePacket(new Uint8Array(0), 'S2C')
const c2sRes = netproto.decodePacket(new Uint8Array(0), 'C2S')
expect(s2cRes.ok).toBe(false)
expect(c2sRes.ok).toBe(false)
})
it('F4.B3: decodePacket fails fast with ok:false on truncated variable-length S2C packets', async () => {
const netproto = await importRepoModule('src/netproto/index.ts')
// 0x26 GameChat requires at least 10+ bytes plus 2 null-terminated strings
const truncatedChat = new Uint8Array([0x26, 0x01, 0x00])
const res = netproto.decodePacket(truncatedChat, 'S2C')
expect(res.ok).toBe(false)
})
it('F4.B4: decodePacket fails fast with ok:false on unregistered opcode', async () => {
const netproto = await importRepoModule('src/netproto/index.ts')
// 0xfe is not a registered D2GS S2C opcode
const unknown = new Uint8Array([0xfe, 0x00, 0x00, 0x00])
const res = netproto.decodePacket(unknown, 'S2C')
expect(res.ok).toBe(false)
expect(res.error).toBeTruthy()
})
it('F4.B5: BitReader bounds-checks or throws when reading beyond buffer length', async () => {
const netproto = await importRepoModule('src/netproto/index.ts')
const reader = new netproto.BitReader(new Uint8Array([0xaa]))
expect(reader.readBits(8)).toBe(0xaa)
expect(() => reader.readBits(8)).toThrow()
})
})
describe('Tier 2: F5 — src/server Authoritative Simulation Isolation & Boundary Cases', () => {
it('F5.B1: src/server/** has zero imports from ../client or ../baker and zero DOM/WebGL/Three.js references', () => {
const serverFiles = collectTsFiles('src/server')
expect(serverFiles.length).toBeGreaterThan(0)
const violations: string[] = []
const forbiddenDomPattern =
/\b(?:window|document|localStorage|HTMLCanvasElement|WebGLRenderingContext|WebGL2RenderingContext|AudioContext)\b/
for (const file of serverFiles) {
const rel = relative(ROOT_DIR, file)
const raw = readRepoFile(rel)
const specs = extractImportSpecifiers(raw)
for (const spec of specs) {
const target = resolveImportTargetRel(file, spec)
if (
target.startsWith('src/client') ||
target.startsWith('src/baker') ||
spec === 'three'
) {
violations.push(`${rel}: forbidden import '${spec}' (${target})`)
}
}
const tokens = stripCommentsAndStrings(raw)
const m = forbiddenDomPattern.exec(tokens)
if (m) {
violations.push(`${rel}: forbidden browser global '${m[0]}'`)
}
}
expect(violations).toEqual([])
})
it('F5.B2: LocalLoopbackAdapter handles command dispatch safely when no client session is attached', async () => {
const loopbackMod = await importRepoModule(
'src/server/loopback-adapter.ts',
'src/server/session/loopback-adapter.ts',
)
const serverWorldMod = await importRepoModule('src/server/world/server-world.ts')
const world = new serverWorldMod.ServerWorld()
const adapter = new loopbackMod.LocalLoopbackAdapter(world)
expect(() =>
adapter.sendCommand({ type: 'PlayerMove', targetX: 100, targetY: 100 }),
).not.toThrow()
})
it('F5.B3: ServerWorld handles invalid or out-of-range entity target IDs in PlayerAttack without crashing', async () => {
const serverWorldMod = await importRepoModule('src/server/world/server-world.ts')
const world = new serverWorldMod.ServerWorld()
expect(() =>
world.handleCommand({
type: 'PlayerAttack',
targetId: 999999,
skillId: 0,
x: 50,
y: 50,
} as any),
).not.toThrow()
})
it('F5.B4: ServerWorld handles negative or unallocated skill casts gracefully without throwing', async () => {
const serverWorldMod = await importRepoModule('src/server/world/server-world.ts')
const world = new serverWorldMod.ServerWorld()
expect(() =>
world.handleCommand({
type: 'CastSkill',
skillId: -99,
targetX: 10,
targetY: 10,
} as any),
).not.toThrow()
})
it('F5.B5: createPlayerState initializes valid non-NaN attributes for all 7 canonical character classes', async () => {
const engineMod = await importRepoModule(
'src/server/engine.ts',
'src/server/sim/engine.ts',
)
const classesMod = await importRepoModule('src/common/units/classes.ts')
for (const code of classesMod.CHARACTER_CLASS_CODES) {
const state = engineMod.createPlayerState(code)
expect(state).toBeDefined()
expect(Number.isFinite(state.hp)).toBe(true)
expect(Number.isFinite(state.maxHp)).toBe(true)
expect(state.maxHp).toBeGreaterThan(0)
expect(Number.isFinite(state.mana)).toBe(true)
expect(Number.isFinite(state.stamina)).toBe(true)
}
})
})
describe('Tier 2: F6 — src/client Isolation & Session/World Edge Cases', () => {
it('F6.B1: src/client/** has zero imports from ../server, ../baker, or node:* built-ins', () => {
const clientFiles = collectTsFiles('src/client')
expect(clientFiles.length).toBeGreaterThan(0)
const violations: string[] = []
for (const file of clientFiles) {
const rel = relative(ROOT_DIR, file)
const specs = extractImportSpecifiers(readRepoFile(rel))
for (const spec of specs) {
const target = resolveImportTargetRel(file, spec)
if (
target.startsWith('src/server') ||
target.startsWith('src/baker') ||
spec.startsWith('node:') ||
spec === 'fs' ||
spec === 'path' ||
spec === 'child_process'
) {
violations.push(`${rel}: forbidden import '${spec}' (${target})`)
}
}
}
expect(violations).toEqual([])
})
it('F6.B2: OnlineSession safely ignores dispatchCommand before attachGameServer and after leaveGame', async () => {
const sessionMod = await importRepoModule('src/client/session/online-session.ts')
const session = new sessionMod.OnlineSession()
expect(() =>
session.dispatchCommand({ type: 'PlayerMove', targetX: 10, targetY: 20 }),
).not.toThrow()
session.leaveGame()
expect(() =>
session.dispatchCommand({ type: 'PlayerMove', targetX: 30, targetY: 40 }),
).not.toThrow()
})
it('F6.B3: ClientWorld gracefully handles EntityRemove and UnitMove for unknown unit IDs', async () => {
const clientWorldMod = await importRepoModule('src/client/world/client-world.ts')
const world = new clientWorldMod.ClientWorld()
expect(() =>
world.applyEvent({
type: 'UnitMove',
unitType: 1,
unitId: 404,
x: 10,
y: 10,
targetX: 12,
targetY: 12,
} as any),
).not.toThrow()
expect(() =>
world.applyEvent({
type: 'EntityRemove',
unitType: 1,
unitId: 404,
} as any),
).not.toThrow()
})
it('F6.B4: ClientWorld deduplicates repeated EntitySpawn events for the same unitId', async () => {
const clientWorldMod = await importRepoModule('src/client/world/client-world.ts')
const world = new clientWorldMod.ClientWorld()
world.applyEvent({
type: 'EntitySpawn',
unitType: 1,
unitId: 77,
code: 'fallen1',
name: 'Fallen',
x: 100,
y: 100,
hp: 50,
maxHp: 50,
} as any)
world.applyEvent({
type: 'EntitySpawn',
unitType: 1,
unitId: 77,
code: 'fallen1',
name: 'Fallen',
x: 120,
y: 130,
hp: 40,
maxHp: 50,
} as any)
const unit = world.monsters?.get(77) ?? world.getUnit?.(1, 77) ?? world.units?.get(77)
expect(unit).toBeDefined()
expect(unit.x).toBe(120)
expect(unit.y).toBe(130)
})
it('F6.B5: ClientWorld can be reset cleanly to an empty state', async () => {
const clientWorldMod = await importRepoModule('src/client/world/client-world.ts')
const world = new clientWorldMod.ClientWorld()
world.applyEvent({
type: 'EntitySpawn',
unitType: 1,
unitId: 88,
code: 'fallen1',
name: 'Fallen',
x: 50,
y: 50,
hp: 50,
maxHp: 50,
} as any)
if (typeof world.reset === 'function') {
world.reset()
} else if (typeof world.clear === 'function') {
world.clear()
} else if (world.monsters instanceof Map) {
world.monsters.clear()
}
const after = world.monsters?.get(88) ?? world.units?.get(88)
expect(after).toBeUndefined()
})
})
describe('Tier 2: F7 — Unified play.html Single-Entry & Legacy Entry Purge Verification', () => {
it('F7.B1: all 5 legacy HTML files (index.html, acts.html, acts-zh.html, acts-en.html, bnet.html) are deleted', () => {
for (const htmlFile of LEGACY_HTML_FILES) {
expect(existsInRepo(htmlFile), `Legacy HTML file '${htmlFile}' must be deleted`).toBe(false)
}
})
it('F7.B2: all 12 legacy src/* directories (game, scene, mpq, formats, data, render, ui, frontend, audio, sim, i18n, net) are deleted', () => {
for (const dir of LEGACY_SRC_DIRS) {
expect(existsInRepo(`src/${dir}`), `Legacy directory 'src/${dir}' must be deleted`).toBe(false)
}
})
it('F7.B3: play.html contains zero script references to legacy /src/frontend/, /src/game/, or /src/scene/ paths', () => {
const html = readRepoFile('play.html')
expect(html).not.toMatch(/\/src\/(?:frontend|game|scene|ui|render)\//)
expect(html).toContain('/src/client/main.ts')
})
it('F7.B4: vite.config.ts contains zero references to deleted HTML entry files', () => {
const viteSource = stripComments(readRepoFile('vite.config.ts'))
for (const legacyHtml of LEGACY_HTML_FILES) {
expect(viteSource).not.toContain(legacyHtml)
}
})
it('F7.B5: src/client/launcher/play-entry.ts contains zero imports from src/server, src/baker, or legacy src/* directories', () => {
const source = readRepoFile('src/client/launcher/play-entry.ts')
const specs = extractImportSpecifiers(source)
const entryFileAbs = `${ROOT_DIR}/src/client/launcher/play-entry.ts`
for (const spec of specs) {
const target = resolveImportTargetRel(entryFileAbs, spec)
expect(target.startsWith('src/server')).toBe(false)
expect(target.startsWith('src/baker')).toBe(false)
for (const legacyDir of LEGACY_SRC_DIRS) {
expect(target.startsWith(`src/${legacyDir}`)).toBe(false)
}
}
})
})
describe('Tier 2: F8 — src/baker Isolation, Fail-Fast MPQ Loading & CLI Tools Purity', () => {
it('F8.B1: src/baker/** has zero imports from ../client or ../server and zero DOM global references', () => {
const bakerFiles = collectTsFiles('src/baker')
expect(bakerFiles.length).toBeGreaterThan(0)
const violations: string[] = []
for (const file of bakerFiles) {
const rel = relative(ROOT_DIR, file)
const raw = readRepoFile(rel)
const specs = extractImportSpecifiers(raw)
for (const spec of specs) {
const target = resolveImportTargetRel(file, spec)
if (target.startsWith('src/client') || target.startsWith('src/server')) {
violations.push(`${rel}: forbidden import '${spec}' (${target})`)
}
}
const tokens = stripCommentsAndStrings(raw)
if (/\b(?:window|document|localStorage)\b/.test(tokens)) {
violations.push(`${rel}: forbidden DOM global reference`)
}
}
expect(violations).toEqual([])
})
it('F8.B2: NodeMpqArchiveSource / createNodeMpqArchiveSource fails fast on non-existent MPQ directory', async () => {
const bakerMpq = await importRepoModule(
'src/baker/mpq-archive-source.ts',
'src/baker/index.ts',
)
const bogusDir = '/tmp/non-existent-d2-mpq-dir-999999'
if (typeof bakerMpq.createNodeMpqArchiveSource === 'function') {
expect(() => {
const src = bakerMpq.createNodeMpqArchiveSource(bogusDir)
// If lazy, reading a required file or mounting must fail
src.readBytes('data\\global\\excel\\skills.txt')
}).toThrow()
} else if (typeof bakerMpq.NodeMpqArchiveSource === 'function') {
expect(() => {
const src = new bakerMpq.NodeMpqArchiveSource(bogusDir)
src.readBytes('data\\global\\excel\\skills.txt')
}).toThrow()
} else if (typeof bakerMpq.mountNodeArchives === 'function') {
expect(() => bakerMpq.mountNodeArchives(bogusDir)).toThrow()
}
})
it('F8.B3: tools/** contains zero stale relative imports to legacy src/{mpq,data,formats,game,scene,ui,render,frontend} directories', () => {
const toolFiles = collectTsFiles('tools')
expect(toolFiles.length).toBeGreaterThan(0)
const violations: string[] = []
for (const file of toolFiles) {
const rel = relative(ROOT_DIR, file)
const specs = extractImportSpecifiers(readRepoFile(rel))
for (const spec of specs) {
const target = resolveImportTargetRel(file, spec)
for (const legacyDir of LEGACY_SRC_DIRS) {
if (
target === `src/${legacyDir}` ||
target.startsWith(`src/${legacyDir}/`)
) {
violations.push(`${rel}: stale import '${spec}' -> '${target}'`)
}
}
}
}
expect(violations).toEqual([])
})
it('F8.B4: src/baker/index.ts re-exports validateUniversalDt1Libraries and validatePackManifest with identical fail-fast behavior', async () => {
const bakerMod = await importRepoModule('src/baker/index.ts')
expect(typeof bakerMod.validateUniversalDt1Libraries).toBe('function')
expect(typeof bakerMod.validatePackManifest).toBe('function')
expect(() => bakerMod.validateUniversalDt1Libraries([])).toThrow(/Blank\.dt1/i)
expect(() =>
bakerMod.validatePackManifest({ missingFiles: 2, missingTiles: 0 }),
).toThrow(/missingFiles/i)
})
it('F8.B5: public/act-packs/tiles/index.json has zero acts with missingFiles > 0 or missingTiles > 0', () => {
const packContract = readJsonFile<{
acts?: Record<string, { act: number; missingFiles: number; missingTiles: number }>
}>('public/act-packs/tiles/index.json')
const actEntries = Object.values(packContract.acts ?? {})
expect(actEntries.length).toBe(5)
for (const actEntry of actEntries) {
expect(actEntry.missingFiles, `Act ${actEntry.act} missingFiles`).toBe(0)
expect(actEntry.missingTiles, `Act ${actEntry.act} missingTiles`).toBe(0)
}
})
})
describe('Tier 2: F9 — Multi-TSConfig Compile-Time Boundary Exclusion Invariants', () => {
it('F9.B1: tsconfig.common.json strictly excludes client, server, baker, netproto, tools, and tests with types: []', () => {
const cfg = readJsonFile('tsconfig.common.json')
expect(cfg.compilerOptions?.noEmit).toBe(true)
expect(cfg.compilerOptions?.types).toEqual([])
const ex: string[] = cfg.exclude ?? []
expect(ex).toEqual(
expect.arrayContaining(['src/client', 'src/server', 'src/baker', 'src/netproto']),
)
})
it('F9.B2: tsconfig.netproto.json strictly excludes client, server, baker, tools, and tests with types: []', () => {
const cfg = readJsonFile('tsconfig.netproto.json')
expect(cfg.compilerOptions?.noEmit).toBe(true)
expect(cfg.compilerOptions?.types).toEqual([])
const ex: string[] = cfg.exclude ?? []
expect(ex).toEqual(
expect.arrayContaining(['src/client', 'src/server', 'src/baker']),
)
})
it('F9.B3: tsconfig.server.json strictly excludes client and baker with types: []', () => {
const cfg = readJsonFile('tsconfig.server.json')
expect(cfg.compilerOptions?.noEmit).toBe(true)
expect(cfg.compilerOptions?.types).toEqual([])
const ex: string[] = cfg.exclude ?? []
expect(ex).toEqual(expect.arrayContaining(['src/client', 'src/baker']))
})
it('F9.B4: tsconfig.client.json strictly excludes server and baker with types: []', () => {
const cfg = readJsonFile('tsconfig.client.json')
expect(cfg.compilerOptions?.noEmit).toBe(true)
expect(cfg.compilerOptions?.types).toEqual([])
const ex: string[] = cfg.exclude ?? []
expect(ex).toEqual(expect.arrayContaining(['src/server', 'src/baker']))
})
it('F9.B5: tsconfig.baker.json strictly excludes client and server with types: ["node"]', () => {
const cfg = readJsonFile('tsconfig.baker.json')
expect(cfg.compilerOptions?.noEmit).toBe(true)
expect(cfg.compilerOptions?.types).toEqual(['node'])
const ex: string[] = cfg.exclude ?? []
expect(ex).toEqual(expect.arrayContaining(['src/client', 'src/server']))
})
})
describe('Tier 2: F10 — Repository-Wide Import Hygiene & Netproto Replay Edge Cases', () => {
it('F10.B1: tests/** contains zero stale imports to legacy src/{game,scene,mpq,formats,data,render,ui,frontend,audio,sim,i18n,net} directories', () => {
const testFiles = collectTsFiles('tests').filter(
(f) => !relative(ROOT_DIR, f).startsWith('tests/e2e-module-split/'),
)
expect(testFiles.length).toBeGreaterThan(0)
const violations: string[] = []
for (const file of testFiles) {
const rel = relative(ROOT_DIR, file)
const specs = extractImportSpecifiers(readRepoFile(rel))
for (const spec of specs) {
const target = resolveImportTargetRel(file, spec)
for (const legacyDir of LEGACY_SRC_DIRS) {
if (
target === `src/${legacyDir}` ||
target.startsWith(`src/${legacyDir}/`)
) {
violations.push(`${rel}: stale import '${spec}' -> '${target}'`)
}
}
}
}
expect(violations).toEqual([])
})
it('F10.B2: tools/netproto-replay.ts replayD2CapText handles empty input deterministically', async () => {
const replayMod = await importRepoModule('tools/netproto-replay.ts')
const res = replayMod.replayD2CapText('', { strict: true })
expect(res.totalRecords).toBe(0)
expect(res.unknownPackets).toBe(0)
expect(res.opaquePackets).toBe(0)
expect(res.protocolErrors).toBe(0)
})
it('F10.B3: tools/netproto-replay.ts replayD2CapText in strict mode throws on corrupt/unknown D2GS packet record', async () => {
const replayMod = await importRepoModule('tools/netproto-replay.ts')
const badJsonl = JSON.stringify({
ts: 1,
channel: 'D2GS',
dir: 'S2C',
hex: 'fe001122',
})
expect(() => replayMod.replayD2CapText(badJsonl, { strict: true })).toThrow()
})
it('F10.B4: package.json scripts contain zero references to deleted HTML files', () => {
const pkg = readJsonFile<{ scripts?: Record<string, string> }>('package.json')
const scriptsText = JSON.stringify(pkg.scripts ?? {})
for (const legacyHtml of LEGACY_HTML_FILES) {
expect(scriptsText).not.toContain(legacyHtml)
}
})
it('F10.B5: package.json typecheck script runs all 5 module tsconfigs plus root tsconfig.json', () => {
const pkg = readJsonFile<{ scripts?: Record<string, string> }>('package.json')
const typecheckCmd = pkg.scripts?.typecheck ?? ''
expect(typecheckCmd).toContain('tsconfig.common.json')
expect(typecheckCmd).toContain('tsconfig.netproto.json')
expect(typecheckCmd).toContain('tsconfig.server.json')
expect(typecheckCmd).toContain('tsconfig.client.json')
expect(typecheckCmd).toContain('tsconfig.baker.json')
expect(typecheckCmd).toContain('tsconfig.json')
})
})