840 lines
33 KiB
TypeScript
840 lines
33 KiB
TypeScript
/**
|
||
* 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/main.ts contains zero imports from src/server, src/baker, or legacy src/* directories', () => {
|
||
const source = readRepoFile('src/client/main.ts')
|
||
const specs = extractImportSpecifiers(source)
|
||
const entryFileAbs = `${ROOT_DIR}/src/client/main.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')
|
||
})
|
||
})
|