1333 lines
58 KiB
TypeScript
1333 lines
58 KiB
TypeScript
import { beforeAll, describe, expect, it } from 'vitest'
|
||
import { ByteReader } from '../../src/netproto/core/byte-reader.ts'
|
||
import { ByteWriter } from '../../src/netproto/core/byte-writer.ts'
|
||
import { BitReader } from '../../src/netproto/core/bit-reader.ts'
|
||
import { BitWriter } from '../../src/netproto/core/bit-writer.ts'
|
||
import { FakeClock } from '../../src/netproto/core/clock.ts'
|
||
import { ProtocolError } from '../../src/netproto/core/errors.ts'
|
||
import {
|
||
hexToBytes,
|
||
InMemoryPacketTap,
|
||
parseD2CapJsonl,
|
||
} from '../../src/netproto/core/packet-tap.ts'
|
||
import { createMemoryStreamPair } from '../../src/netproto/transport/memory-stream.ts'
|
||
import {
|
||
createMixedResolver,
|
||
createTcpResolver,
|
||
createWsBridgeResolver,
|
||
resetActiveWssBnetConnectionsForTesting,
|
||
} from '../../src/netproto/transport/endpoint.ts'
|
||
import { xsha1 } from '../../src/netproto/crypto/xsha1.ts'
|
||
import { doubleHash, hashBnetPassword } from '../../src/netproto/crypto/bnet-hash.ts'
|
||
import { decodeD2CdKey, hashD2CdKey } from '../../src/netproto/crypto/cdkey.ts'
|
||
import { BncsFramer, encodeBncsFrame } from '../../src/netproto/bncs/framing.ts'
|
||
import { BncsOpcode, decodeBncsS2cPacket } from '../../src/netproto/bncs/packets.ts'
|
||
import { BncsSession } from '../../src/netproto/bncs/session.ts'
|
||
import { encodeMcpFrame, McpFramer } from '../../src/netproto/mcp/framing.ts'
|
||
import {
|
||
decodeMcpS2cPacket,
|
||
McpOpcode,
|
||
parseMcpCharStatString,
|
||
} from '../../src/netproto/mcp/packets.ts'
|
||
import { McpSession } from '../../src/netproto/mcp/session.ts'
|
||
import {
|
||
defaultD2gsHuffmanCodec,
|
||
encodeD2gsCompressedFrame,
|
||
parseD2gsBlockHeader,
|
||
unpackCustomHuffmanLengths,
|
||
wrapD2gsCompressedBlock,
|
||
} from '../../src/netproto/d2gs/compression.ts'
|
||
import { D2gsS2cFramer, resolveS2cPacketSize } from '../../src/netproto/d2gs/framing-s2c.ts'
|
||
import { resolveC2sPacketSize, validateC2sPacket } from '../../src/netproto/d2gs/framing-c2s.ts'
|
||
import { decodeD2gsS2cPacket, encodeD2gsClientCommand } from '../../src/netproto/d2gs/registry.ts'
|
||
import {
|
||
decodeD2gsItemPacket,
|
||
decodeItemBitstream,
|
||
} from '../../src/netproto/d2gs/items/item-bitstream.ts'
|
||
import {
|
||
ITEM_FLAG_IDENTIFIED,
|
||
resolveItemContainer,
|
||
} from '../../src/netproto/d2gs/items/item-types.ts'
|
||
import { D2gsSession } from '../../src/netproto/d2gs/session.ts'
|
||
import { D2gsAdapter } from '../../src/netproto/d2gs/adapter.ts'
|
||
import { createD2OnlineFlow } from '../../src/netproto/flow/online-flow.ts'
|
||
import type { ItemDataTables } from '../../src/netproto/domain/ids.ts'
|
||
import type { ByteStream, EndpointKind } from '../../src/netproto/transport/byte-stream.ts'
|
||
import { ClientWorld } from '../../src/client/world/client-world.ts'
|
||
import {
|
||
MapService,
|
||
TileAtlas,
|
||
getCollisionLocalSubTile,
|
||
isWalkableLocalSubTile,
|
||
} from '../../src/client/map/map-service.ts'
|
||
import { COLLIDE_MASK_INVALID } from '../../src/common/world/d2map.ts'
|
||
import { findPathLocal, hasLineOfSightLocal } from '../../src/client/motion/pathfind.ts'
|
||
import { LocalMovementPredictor } from '../../src/client/motion/predictor.ts'
|
||
import { RemoteEntityInterpolator } from '../../src/client/motion/interpolate.ts'
|
||
import {
|
||
D2S_MAGIC,
|
||
D2S_QUEST_OFFSET,
|
||
D2S_VERSION_113C,
|
||
D2S_WAYPOINT_OFFSET,
|
||
unlockD2sBuffer,
|
||
validateBotCharacterName,
|
||
verifyD2sUnlocked,
|
||
writeD2sChecksum,
|
||
} from '../../tools/d2s-unlock.ts'
|
||
import {
|
||
DEFAULT_SERVER_HOST,
|
||
parseClientSettingsJson,
|
||
sanitizeClientSettings,
|
||
validateServerHost,
|
||
} from '../../src/client/settings/client-settings.ts'
|
||
import { SettingsStore, type StorageLike } from '../../src/client/settings/settings-store.ts'
|
||
import { Toolbar } from '../../src/client/toolbar/toolbar.ts'
|
||
import {
|
||
applyViewportProfileToDom,
|
||
getViewportProfile,
|
||
VIEWPORT_1024x768,
|
||
VIEWPORT_800x600,
|
||
} from '../../src/client/view/viewport-profile.ts'
|
||
import {
|
||
ClientWorldSceneSource,
|
||
type ClientSceneWorldAdapter,
|
||
} from '../../src/client/view/scene-source.ts'
|
||
import { buildSceneDrawables, type RenderableUnitInput } from '../../src/client/view/unit-drawables.ts'
|
||
import { computeHudLayout } from '../../src/client/ui/hud-manager.ts'
|
||
import { AutomapView } from '../../src/client/automap/automap-view.ts'
|
||
import {
|
||
computeSpatialAudioMetrics,
|
||
resolveSoundEnvironmentForLevel,
|
||
SoundService,
|
||
type AudioBackendLike,
|
||
} from '../../src/client/audio/sound-service.ts'
|
||
import {
|
||
HttpRangeMpqSource,
|
||
InMemoryChunkCache,
|
||
OpfsMpqManager,
|
||
} from '../../src/client/assets/opfs-mpq.ts'
|
||
import { OutboundRateLimiter } from '../../src/client/session/rate-limiter.ts'
|
||
import { formatHexDump, PacketInspector } from '../../src/client/inspector/packet-inspector.ts'
|
||
import { asSubTile } from '../../src/netproto/index.ts'
|
||
|
||
class MemoryStorage implements StorageLike {
|
||
readonly data = new Map<string, string>()
|
||
getItem(key: string): string | null {
|
||
return this.data.get(key) ?? null
|
||
}
|
||
setItem(key: string, value: string): void {
|
||
this.data.set(key, value)
|
||
}
|
||
removeItem(key: string): void {
|
||
this.data.delete(key)
|
||
}
|
||
}
|
||
|
||
function createSyntheticD2sBuffer(charName: string): Uint8Array {
|
||
const buf = new Uint8Array(765)
|
||
const view = new DataView(buf.buffer, buf.byteOffset, buf.byteLength)
|
||
view.setUint32(0x00, D2S_MAGIC, true)
|
||
view.setUint32(0x04, D2S_VERSION_113C, true)
|
||
view.setUint32(0x08, buf.byteLength, true)
|
||
|
||
for (let i = 0; i < Math.min(15, charName.length); i++) {
|
||
buf[0x14 + i] = charName.charCodeAt(i) & 0x7f
|
||
}
|
||
buf[0x24] = 0x20
|
||
buf[0x25] = 0x00
|
||
buf[0x2b] = 1
|
||
|
||
buf[D2S_QUEST_OFFSET] = 0x57
|
||
buf[D2S_QUEST_OFFSET + 1] = 0x6f
|
||
buf[D2S_QUEST_OFFSET + 2] = 0x6f
|
||
buf[D2S_QUEST_OFFSET + 3] = 0x21
|
||
view.setUint32(D2S_QUEST_OFFSET + 4, 6, true)
|
||
view.setUint16(D2S_QUEST_OFFSET + 8, 298, true)
|
||
|
||
buf[D2S_WAYPOINT_OFFSET] = 0x57
|
||
buf[D2S_WAYPOINT_OFFSET + 1] = 0x53
|
||
view.setUint32(D2S_WAYPOINT_OFFSET + 2, 1, true)
|
||
view.setUint16(D2S_WAYPOINT_OFFSET + 6, 80, true)
|
||
for (let diff = 0; diff < 3; diff++) {
|
||
const wpBase = 0x281 + diff * 24
|
||
buf[wpBase] = 0x02
|
||
buf[wpBase + 1] = 0x01
|
||
buf[wpBase + 2] = 0x01
|
||
}
|
||
|
||
writeD2sChecksum(buf)
|
||
return buf
|
||
}
|
||
|
||
const emptyTables: ItemDataTables = {
|
||
itemStatCost: new Map(),
|
||
armor: new Map(),
|
||
weapons: new Map(),
|
||
misc: new Map(),
|
||
getStatMeta: () => undefined,
|
||
getStatCost: () => undefined,
|
||
getItemBaseMeta: () => undefined,
|
||
getItemMeta: () => undefined,
|
||
}
|
||
|
||
let sharedMapService: MapService
|
||
|
||
beforeAll(async () => {
|
||
sharedMapService = await MapService.fromPackedDir('public/act-packs/tiles')
|
||
}, 60_000)
|
||
|
||
describe('Tier 2 — Negative, Boundary, Malformed & Corner Cases (F1–F20)', () => {
|
||
// ─── F1: Core Binary & Bit Primitives Boundaries ───────────────────────────
|
||
describe('F1: Core Binary & Bit Primitives Boundaries', () => {
|
||
it('F1.B1: ByteReader throws ProtocolError on buffer overrun across u8/u16/u32/bytes/skip', () => {
|
||
const r = new ByteReader(new Uint8Array([1, 2]), 0, 'bnet', 0x50)
|
||
expect(() => r.u32LE()).toThrow(ProtocolError)
|
||
expect(() => r.bytes(3)).toThrow(ProtocolError)
|
||
expect(() => r.skip(-1)).toThrow(ProtocolError)
|
||
expect(() => r.skip(5)).toThrow(ProtocolError)
|
||
})
|
||
|
||
it('F1.B2: ByteReader.cstring throws ProtocolError on unterminated null-delimited string', () => {
|
||
const r = new ByteReader(new Uint8Array([65, 66, 67]), 0, 'mcp', 0x01)
|
||
expect(() => r.cstring()).toThrow(ProtocolError)
|
||
})
|
||
|
||
it('F1.B3: ByteWriter.patchU16 throws RangeError on out-of-range offset and auto-grows capacity on large writes', () => {
|
||
const w = new ByteWriter(16)
|
||
w.u32LE(0x12345678)
|
||
w.u32LE(0x9abcdef0)
|
||
expect(w.length).toBe(8)
|
||
expect(() => w.patchU16(-1, 0)).toThrow(RangeError)
|
||
expect(() => w.patchU16(16, 0)).toThrow(RangeError)
|
||
})
|
||
|
||
it('F1.B4: BitReader and BitWriter reject invalid bit widths (<0 or >32) and throw ProtocolError on bit overrun', () => {
|
||
const br = new BitReader(new Uint8Array([0xff]))
|
||
expect(() => br.readBits(-1)).toThrow(ProtocolError)
|
||
expect(() => br.readBits(33)).toThrow(ProtocolError)
|
||
expect(() => br.readBits(9)).toThrow(ProtocolError)
|
||
expect(() => br.skipBits(-5)).toThrow(ProtocolError)
|
||
|
||
const bw = new BitWriter()
|
||
expect(() => bw.writeBits(1, -1)).toThrow(/invalid bit count/)
|
||
expect(() => bw.writeBits(1, 33)).toThrow(/invalid bit count/)
|
||
})
|
||
|
||
it('F1.B5: hexToBytes rejects odd-length or non-hex strings and InMemoryPacketTap evicts oldest entry at maxEntries', () => {
|
||
expect(() => hexToBytes('abc')).toThrow()
|
||
expect(() => hexToBytes('zz')).toThrow()
|
||
const tap = new InMemoryPacketTap(2)
|
||
tap.record({ t: 1, conn: 'game', dir: 's2c', raw: new Uint8Array([1]) })
|
||
tap.record({ t: 2, conn: 'game', dir: 's2c', raw: new Uint8Array([2]) })
|
||
tap.record({ t: 3, conn: 'game', dir: 's2c', raw: new Uint8Array([3]) })
|
||
expect(tap.entries).toHaveLength(2)
|
||
expect(tap.entries[0]?.t).toBe(2)
|
||
})
|
||
})
|
||
|
||
// ─── F2: Transport & Endpoint Resolution Boundaries ────────────────────────
|
||
describe('F2: Transport & Endpoint Resolution Boundaries', () => {
|
||
it('F2.B1: MemoryStream.write throws Error after stream is closed', () => {
|
||
const [a] = createMemoryStreamPair()
|
||
a.close()
|
||
expect(() => a.write(new Uint8Array([1]))).toThrow(/closed/)
|
||
})
|
||
|
||
it('F2.B2: createWsBridgeResolver enforces maxConcurrentBnetWss = 2 and rejects a 3rd concurrent bnet stream', async () => {
|
||
resetActiveWssBnetConnectionsForTesting()
|
||
const resolver = createWsBridgeResolver({
|
||
maxConcurrentBnetWss: 2,
|
||
streamFactory: async () => createMemoryStreamPair()[0],
|
||
})
|
||
const s1 = await resolver.open('bnet', 'test.local', 6112)
|
||
const s2 = await resolver.open('bnet', 'test.local', 6112)
|
||
await expect(resolver.open('bnet', 'test.local', 6112)).rejects.toThrow(/Rate limit/)
|
||
s1.close()
|
||
s2.close()
|
||
})
|
||
|
||
it('F2.B3: createWsBridgeResolver decrements active bnet connection count if streamFactory rejects', async () => {
|
||
resetActiveWssBnetConnectionsForTesting()
|
||
const resolver = createWsBridgeResolver({
|
||
maxConcurrentBnetWss: 1,
|
||
streamFactory: async () => {
|
||
throw new Error('handshake failed')
|
||
},
|
||
})
|
||
await expect(resolver.open('bnet', 'test.local', 6112)).rejects.toThrow('handshake failed')
|
||
const okResolver = createWsBridgeResolver({
|
||
maxConcurrentBnetWss: 1,
|
||
streamFactory: async () => createMemoryStreamPair()[0],
|
||
})
|
||
const s = await okResolver.open('bnet', 'test.local', 6112)
|
||
s.close()
|
||
})
|
||
|
||
it('F2.B4: createMixedResolver throws descriptive Error when endpoint kind has no mapped or fallback resolver', async () => {
|
||
const mixed = createMixedResolver({})
|
||
await expect(mixed.open('ts', '127.0.0.1', 4002)).rejects.toThrow(/No endpoint resolver/)
|
||
})
|
||
|
||
it('F2.B5: MemoryStream propagates error CloseReason when closed with an Error', () => {
|
||
const [a, b] = createMemoryStreamPair()
|
||
let reasonKind = ''
|
||
b.onClose((r) => {
|
||
reasonKind = r.kind
|
||
})
|
||
a.close({ kind: 'error', error: new Error('socket reset') })
|
||
expect(reasonKind).toBe('error')
|
||
})
|
||
})
|
||
|
||
// ─── F3: Cryptographic & Auth Primitives Boundaries ────────────────────────
|
||
describe('F3: Cryptographic & Auth Primitives Boundaries', () => {
|
||
it('F3.B1: xsha1 handles empty 0-byte input and multi-block (>64 byte) inputs without overrun', () => {
|
||
const emptyHash = xsha1(new Uint8Array(0))
|
||
const longHash = xsha1(new Uint8Array(200).fill(0x61))
|
||
expect(emptyHash.byteLength).toBe(20)
|
||
expect(longHash.byteLength).toBe(20)
|
||
expect(emptyHash).not.toEqual(longHash)
|
||
})
|
||
|
||
it('F3.B2: doubleHash throws Error when passwordHash20 is not exactly 20 bytes', () => {
|
||
expect(() => doubleHash(1, 2, new Uint8Array(19))).toThrow(/20-byte/)
|
||
expect(() => doubleHash(1, 2, new Uint8Array(21))).toThrow(/20-byte/)
|
||
})
|
||
|
||
it('F3.B3: decodeD2CdKey throws Error when cleaned key is not 16 characters', () => {
|
||
expect(() => decodeD2CdKey('ABCD-1234')).toThrow(/16-character/)
|
||
expect(() => decodeD2CdKey('')).toThrow(/16-character/)
|
||
})
|
||
|
||
it('F3.B4: decodeD2CdKey throws Error on invalid alphabet characters (e.g., 0, 1, 3, I, O, U)', () => {
|
||
expect(() => decodeD2CdKey('0000-1111-3333-OOOO')).toThrow(/Invalid D2 CD-key character/)
|
||
})
|
||
|
||
it('F3.B5: hashD2CdKey accepts raw 16-char string or pre-decoded DecodedCdKey identically', () => {
|
||
const raw = '4678-2468-79BC-DEFG'
|
||
const decoded = decodeD2CdKey(raw)
|
||
expect(hashD2CdKey(10, 20, raw)).toEqual(hashD2CdKey(10, 20, decoded))
|
||
})
|
||
})
|
||
|
||
// ─── F4: BNCS Framing, Packets & Session Boundaries ────────────────────────
|
||
describe('F4: BNCS Framing, Packets & Session Boundaries', () => {
|
||
it('F4.B1: BncsFramer throws ProtocolError when header byte is not 0xFF', () => {
|
||
const framer = new BncsFramer()
|
||
expect(() => framer.push(new Uint8Array([0xfe, 0x50, 0x04, 0x00]))).toThrow(ProtocolError)
|
||
})
|
||
|
||
it('F4.B2: BncsFramer throws ProtocolError when declared frame length is < 4 bytes', () => {
|
||
const framer = new BncsFramer()
|
||
expect(() => framer.push(new Uint8Array([0xff, 0x50, 0x03, 0x00]))).toThrow(ProtocolError)
|
||
})
|
||
|
||
it('F4.B3: decodeBncsS2cPacket handles short failed SID_LOGONREALMEX (<16 bytes) and unknown opcodes gracefully', () => {
|
||
const w = new ByteWriter().u32(123).u32(0x80000001)
|
||
const shortRealm = decodeBncsS2cPacket({
|
||
id: BncsOpcode.SID_LOGONREALMEX,
|
||
payload: w.toUint8Array(),
|
||
raw: encodeBncsFrame(BncsOpcode.SID_LOGONREALMEX, w.toUint8Array()),
|
||
})
|
||
expect(shortRealm.type).toBe('SID_LOGONREALMEX')
|
||
if (shortRealm.type === 'SID_LOGONREALMEX') {
|
||
expect(shortRealm.data.mcpStatus).toBe(0x80000001)
|
||
}
|
||
|
||
const unk = decodeBncsS2cPacket({
|
||
id: 0xfe,
|
||
payload: new Uint8Array([1, 2]),
|
||
raw: new Uint8Array([0xff, 0xfe, 6, 0, 1, 2]),
|
||
})
|
||
expect(unk.type).toBe('UNKNOWN')
|
||
})
|
||
|
||
it('F4.B4: BncsSession rejects pending requests with ProtocolError on timeout', async () => {
|
||
const [clientStream] = createMemoryStreamPair()
|
||
const clock = new FakeClock(1000)
|
||
const session = new BncsSession(clientStream, { clock, requestTimeoutMs: 500 })
|
||
const p = session.listRealms()
|
||
clock.advance(600)
|
||
await expect(p).rejects.toThrow(ProtocolError)
|
||
})
|
||
|
||
it('F4.B5: BncsSession.handshake rejects with ProtocolError when SID_AUTH_CHECK returns non-zero result', async () => {
|
||
const [clientStream, serverStream] = createMemoryStreamPair()
|
||
const framer = new BncsFramer()
|
||
let init = false
|
||
serverStream.onData((chunk) => {
|
||
let d = chunk
|
||
if (!init && d[0] === 0x01) {
|
||
init = true
|
||
d = d.subarray(1)
|
||
}
|
||
for (const pkt of framer.push(d)) {
|
||
if (pkt.id === BncsOpcode.SID_AUTH_INFO) {
|
||
const w = new ByteWriter().u32(0).u32(1).u32(0).u64BigInt(0n).cstring('ver-IX86-1.mpq').cstring('A=1')
|
||
serverStream.write(encodeBncsFrame(BncsOpcode.SID_AUTH_INFO, w.toUint8Array()))
|
||
} else if (pkt.id === BncsOpcode.SID_AUTH_CHECK) {
|
||
const w = new ByteWriter().u32(0x100).cstring('Old game version')
|
||
serverStream.write(encodeBncsFrame(BncsOpcode.SID_AUTH_CHECK, w.toUint8Array()))
|
||
}
|
||
}
|
||
})
|
||
const session = new BncsSession(clientStream)
|
||
await expect(session.handshake()).rejects.toThrow(/SID_AUTH_CHECK failed/)
|
||
})
|
||
})
|
||
|
||
// ─── F5: MCP Framing, Packets & Session Boundaries ─────────────────────────
|
||
describe('F5: MCP Framing, Packets & Session Boundaries', () => {
|
||
it('F5.B1: McpFramer throws ProtocolError when packet length < 3', () => {
|
||
const framer = new McpFramer()
|
||
expect(() => framer.push(new Uint8Array([0x02, 0x00, 0x01]))).toThrow(ProtocolError)
|
||
})
|
||
|
||
it('F5.B2: parseMcpCharStatString handles empty/truncated statStrings with safe defaults', () => {
|
||
const parsed = parseMcpCharStatString('ShortChar', 0, new Uint8Array(0))
|
||
expect(parsed.name).toBe('ShortChar')
|
||
expect(parsed.level).toBe(1)
|
||
expect(parsed.charClass).toBe(0)
|
||
})
|
||
|
||
it('F5.B3: decodeMcpS2cPacket returns UNKNOWN for unrecognized MCP opcodes', () => {
|
||
const res = decodeMcpS2cPacket({ id: 0xfe, payload: new Uint8Array([1]), raw: new Uint8Array([4, 0, 0xfe, 1]) })
|
||
expect(res.type).toBe('UNKNOWN')
|
||
})
|
||
|
||
it('F5.B4: McpSession.startup rejects when server returns non-zero status', async () => {
|
||
const [clientStream, serverStream] = createMemoryStreamPair()
|
||
serverStream.onData((chunk) => {
|
||
if (chunk.length > 1) {
|
||
serverStream.write(encodeMcpFrame(McpOpcode.MCP_STARTUP, new ByteWriter().u32(0x02).toUint8Array()))
|
||
}
|
||
})
|
||
const mcp = new McpSession(clientStream)
|
||
await expect(
|
||
mcp.startup({
|
||
mcpCookie: 1,
|
||
mcpStatus: 0,
|
||
mcpIp: '127.0.0.1',
|
||
mcpPort: 6113,
|
||
mcpChunk1: new Uint8Array(8),
|
||
mcpChunk2: new Uint8Array(48),
|
||
uniqueName: 'Hero',
|
||
}),
|
||
).rejects.toThrow(/MCP_STARTUP failed/)
|
||
})
|
||
|
||
it('F5.B5: McpSession rejects pending requests when stream closes unexpectedly', async () => {
|
||
const [clientStream, serverStream] = createMemoryStreamPair()
|
||
const mcp = new McpSession(clientStream)
|
||
const p = mcp.listChars(8)
|
||
serverStream.close()
|
||
await expect(p).rejects.toThrow(ProtocolError)
|
||
})
|
||
})
|
||
|
||
// ─── F6: D2GS Huffman Compression & Block Framing Boundaries ───────────────
|
||
describe('F6: D2GS Huffman Compression & Block Framing Boundaries', () => {
|
||
it('F6.B1: parseD2gsBlockHeader returns null on empty or partial 2-byte header buffers', () => {
|
||
expect(parseD2gsBlockHeader(new Uint8Array(0), 0)).toBeNull()
|
||
expect(parseD2gsBlockHeader(new Uint8Array([0xf0]), 0)).toBeNull()
|
||
})
|
||
|
||
it('F6.B2: wrapD2gsCompressedBlock throws ProtocolError when compressed block exceeds 4095 bytes', () => {
|
||
expect(() => wrapD2gsCompressedBlock(new Uint8Array(4096))).toThrow(ProtocolError)
|
||
})
|
||
|
||
it('F6.B3: unpackCustomHuffmanLengths throws ProtocolError when 0xAF packet is shorter than 130 bytes', () => {
|
||
expect(() => unpackCustomHuffmanLengths(new Uint8Array([0xaf, 0x81, 0x00]))).toThrow(ProtocolError)
|
||
})
|
||
|
||
it('F6.B4: defaultD2gsHuffmanCodec.decompress throws ProtocolError when bitstream ends before expectedLength', () => {
|
||
expect(() => defaultD2gsHuffmanCodec.decompress(new Uint8Array(0), 5)).toThrow(ProtocolError)
|
||
})
|
||
|
||
it('F6.B5: resolveC2sPacketSize and validateC2sPacket reject unknown or mismatched C->S opcodes', () => {
|
||
expect(() => resolveC2sPacketSize(new Uint8Array([0xfe]), 0)).toThrow(ProtocolError)
|
||
expect(() => validateC2sPacket(new Uint8Array(0))).toThrow(ProtocolError)
|
||
expect(() => validateC2sPacket(new Uint8Array([0x01, 0x00]))).toThrow(ProtocolError)
|
||
})
|
||
})
|
||
|
||
// ─── F7: D2GS S->C Registry & C->S Encoders Boundaries ─────────────────────
|
||
describe('F7: D2GS S->C Registry & C->S Encoders Boundaries', () => {
|
||
it('F7.B1: decodeD2gsS2cPacket throws ProtocolError on empty packet', () => {
|
||
expect(() => decodeD2gsS2cPacket(new Uint8Array(0), { tables: emptyTables })).toThrow(ProtocolError)
|
||
})
|
||
|
||
it('F7.B2: decodeD2gsS2cPacket throws ProtocolError on packet length mismatch', () => {
|
||
expect(() => decodeD2gsS2cPacket(new Uint8Array([0x03, 0x00, 0x01]), { tables: emptyTables })).toThrow(ProtocolError)
|
||
})
|
||
|
||
it('F7.B3: decodeD2gsS2cPacket throws ProtocolError on out-of-range opcode (>0xB4)', () => {
|
||
expect(() => decodeD2gsS2cPacket(new Uint8Array([0xfe]), { tables: emptyTables })).toThrow(ProtocolError)
|
||
})
|
||
|
||
it('F7.B4: decodeD2gsS2cPacket decodes rare 1.13c opcodes (e.g. 0x97 WeaponSwitch, 0x58 OpenUiBox) without throwing', () => {
|
||
const ev97 = decodeD2gsS2cPacket(new Uint8Array([0x97]), { tables: emptyTables })
|
||
expect(ev97.type).toBeDefined()
|
||
const ev58 = decodeD2gsS2cPacket(new Uint8Array(7).fill(0x58), { tables: emptyTables })
|
||
expect(ev58.type).toBeDefined()
|
||
})
|
||
|
||
it('F7.B5: D2gsS2cFramer throws ProtocolError when uncompressed or decompressed stream contains invalid opcode', () => {
|
||
const framer = new D2gsS2cFramer()
|
||
expect(() => framer.push(new Uint8Array([0xfe]))).toThrow(ProtocolError)
|
||
})
|
||
})
|
||
|
||
// ─── F8: D2GS 0x9C/0x9D Item Bitstream Boundaries ──────────────────────────
|
||
describe('F8: D2GS 0x9C/0x9D Item Bitstream Boundaries', () => {
|
||
it('F8.B1: decodeD2gsItemPacket rejects packets shorter than 8 bytes or with invalid opcode', () => {
|
||
expect(() => decodeD2gsItemPacket(new Uint8Array([0x9c, 0, 4, 0]), emptyTables)).toThrow(ProtocolError)
|
||
expect(() => decodeD2gsItemPacket(new Uint8Array(8).fill(0x01), emptyTables)).toThrow(ProtocolError)
|
||
})
|
||
|
||
it('F8.B2: decodeD2gsItemPacket rejects 0x9D packet shorter than 13-byte header or mismatched packetSize byte', () => {
|
||
const short9d = new Uint8Array(10)
|
||
short9d[0] = 0x9d
|
||
expect(() => decodeD2gsItemPacket(short9d, emptyTables)).toThrow(ProtocolError)
|
||
|
||
const badSize = new Uint8Array(10)
|
||
badSize[0] = 0x9c
|
||
badSize[2] = 99
|
||
expect(() => decodeD2gsItemPacket(badSize, emptyTables)).toThrow(ProtocolError)
|
||
})
|
||
|
||
it('F8.B3: decodeItemBitstream throws ProtocolError when expectJmHeader is true and JM (0x4D4A) is missing', () => {
|
||
const bw = new BitWriter()
|
||
bw.writeBits(0x1234, 16)
|
||
expect(() =>
|
||
decodeItemBitstream(
|
||
bw.toUint8Array(),
|
||
{ opcode: 0x9c, action: 0, category: 0, itemId: 1 },
|
||
emptyTables,
|
||
{ expectJmHeader: true },
|
||
),
|
||
).toThrow(ProtocolError)
|
||
})
|
||
|
||
it('F8.B4: decodeItemBitstream throws ProtocolError on invalid item quality (0 or >9)', () => {
|
||
const bw = new BitWriter()
|
||
bw.writeBits(ITEM_FLAG_IDENTIFIED, 32)
|
||
bw.writeBits(101, 10)
|
||
bw.writeBits(3, 3)
|
||
bw.writeBits(100, 16)
|
||
bw.writeBits(100, 16)
|
||
bw.writeAscii('rin ')
|
||
bw.writeBits(0, 3)
|
||
bw.writeBits(10, 7)
|
||
bw.writeBits(15, 4)
|
||
expect(() =>
|
||
decodeItemBitstream(
|
||
bw.toUint8Array(),
|
||
{ opcode: 0x9c, action: 0, category: 0, itemId: 1 },
|
||
emptyTables,
|
||
),
|
||
).toThrow(ProtocolError)
|
||
})
|
||
|
||
it('F8.B5: decodeItemBitstream throws ProtocolError when identified item references missing ItemStatCost entry', () => {
|
||
const bw = new BitWriter()
|
||
bw.writeBits(ITEM_FLAG_IDENTIFIED, 32)
|
||
bw.writeBits(101, 10)
|
||
bw.writeBits(3, 3)
|
||
bw.writeBits(100, 16)
|
||
bw.writeBits(100, 16)
|
||
bw.writeAscii('rin ')
|
||
bw.writeBits(0, 3)
|
||
bw.writeBits(10, 7)
|
||
bw.writeBits(2, 4)
|
||
bw.writeBit(0)
|
||
bw.writeBit(0)
|
||
bw.writeBits(123, 9)
|
||
expect(() =>
|
||
decodeItemBitstream(
|
||
bw.toUint8Array(),
|
||
{ opcode: 0x9c, action: 0, category: 0, itemId: 1 },
|
||
emptyTables,
|
||
),
|
||
).toThrow(ProtocolError)
|
||
})
|
||
})
|
||
|
||
// ─── F9: D2GS Session & Adapter Boundaries ─────────────────────────────────
|
||
describe('F9: D2GS Session & Adapter Boundaries', () => {
|
||
it('F9.B1: D2gsSession emits error when incoming stream contains corrupted frame bytes', () => {
|
||
const [clientStream, serverStream] = createMemoryStreamPair()
|
||
const errors: Error[] = []
|
||
const session = new D2gsSession({
|
||
stream: clientStream,
|
||
logon: { gameHash: 1, gameToken: 1, charClass: 1, charName: 'Sorc' },
|
||
})
|
||
session.onError((e) => errors.push(e))
|
||
serverStream.write(new Uint8Array([0xfe]))
|
||
expect(errors).toHaveLength(1)
|
||
})
|
||
|
||
it('F9.B2: D2gsSession ignores sendRawPacket calls after session is closed', () => {
|
||
const [clientStream, serverStream] = createMemoryStreamPair()
|
||
const received: Uint8Array[] = []
|
||
serverStream.onData((c) => received.push(c))
|
||
const session = new D2gsSession({
|
||
stream: clientStream,
|
||
logon: { gameHash: 1, gameToken: 1, charClass: 1, charName: 'Sorc' },
|
||
})
|
||
session.leaveAndClose()
|
||
const countAfterLeave = received.length
|
||
session.sendRawPacket(new Uint8Array([0x6b]))
|
||
expect(received.length).toBe(countAfterLeave)
|
||
})
|
||
|
||
it('F9.B3: D2gsSession stops heartbeat timer when stream closes with error', () => {
|
||
const [clientStream, serverStream] = createMemoryStreamPair()
|
||
const clock = new FakeClock(1000)
|
||
const errors: Error[] = []
|
||
const session = new D2gsSession({
|
||
stream: clientStream,
|
||
logon: { gameHash: 1, gameToken: 1, charClass: 1, charName: 'Sorc' },
|
||
clock,
|
||
})
|
||
session.onError((e) => errors.push(e))
|
||
serverStream.write(new Uint8Array([0xaf, 0x01]))
|
||
serverStream.write(encodeD2gsCompressedFrame(new Uint8Array([0x02, 0x04])))
|
||
serverStream.close({ kind: 'error', error: new Error('reset') })
|
||
expect(session.state).toBe('closed')
|
||
expect(errors).toHaveLength(1)
|
||
const outBefore = session.stats.packetsOut
|
||
clock.advance(15000)
|
||
expect(session.stats.packetsOut).toBe(outBefore)
|
||
})
|
||
|
||
it('F9.B4: D2gsAdapter increments protocolErrors count when a packet fails to decode', () => {
|
||
const [clientStream, serverStream] = createMemoryStreamPair()
|
||
const session = new D2gsSession({
|
||
stream: clientStream,
|
||
logon: { gameHash: 1, gameToken: 1, charClass: 1, charName: 'Sorc' },
|
||
})
|
||
const adapter = new D2gsAdapter({ session, tables: emptyTables })
|
||
serverStream.write(new Uint8Array([0xaf, 0x01]))
|
||
const bad9c = new Uint8Array(15)
|
||
bad9c[0] = 0x9c
|
||
bad9c[2] = 15
|
||
serverStream.write(encodeD2gsCompressedFrame(bad9c))
|
||
expect(adapter.stats.protocolErrors).toBe(1)
|
||
})
|
||
|
||
it('F9.B5: D2gsAdapter.send({ type: "Ping" }) routes through session.sendPing()', () => {
|
||
const [clientStream, serverStream] = createMemoryStreamPair()
|
||
const sent: Uint8Array[] = []
|
||
serverStream.onData((c) => sent.push(c))
|
||
const session = new D2gsSession({
|
||
stream: clientStream,
|
||
logon: { gameHash: 1, gameToken: 1, charClass: 1, charName: 'Sorc' },
|
||
})
|
||
const adapter = new D2gsAdapter({ session, tables: emptyTables })
|
||
adapter.send({ type: 'Ping' })
|
||
expect(sent).toHaveLength(1)
|
||
expect(sent[0]?.[0]).toBe(0x6d)
|
||
expect(sent[0]?.byteLength).toBe(13)
|
||
})
|
||
})
|
||
|
||
// ─── F10: Full Online Flow (D2OnlineFlow) Boundaries ───────────────────────
|
||
describe('F10: Full Online Flow (D2OnlineFlow) Boundaries', () => {
|
||
it('F10.B1: D2OnlineFlow throws ProtocolError when calling listRealms or enterRealm before connectBnet', async () => {
|
||
const flow = createD2OnlineFlow({
|
||
bnetHost: '127.0.0.1',
|
||
resolver: { open: async () => createMemoryStreamPair()[0] },
|
||
})
|
||
await expect(flow.listRealms()).rejects.toThrow(ProtocolError)
|
||
await expect(flow.enterRealm()).rejects.toThrow(ProtocolError)
|
||
})
|
||
|
||
it('F10.B2: D2OnlineFlow throws ProtocolError when calling listCharacters or joinGame before enterRealm', async () => {
|
||
const flow = createD2OnlineFlow({
|
||
bnetHost: '127.0.0.1',
|
||
resolver: { open: async () => createMemoryStreamPair()[0] },
|
||
})
|
||
await expect(flow.listCharacters()).rejects.toThrow(ProtocolError)
|
||
await expect(flow.joinGame('test')).rejects.toThrow(ProtocolError)
|
||
})
|
||
|
||
it('F10.B3: D2OnlineFlow.login rejects with ProtocolError when SID_LOGONRESPONSE2 returns invalid password status', async () => {
|
||
const bncsFramer = new BncsFramer()
|
||
const flow = createD2OnlineFlow({
|
||
bnetHost: '127.0.0.1',
|
||
resolver: {
|
||
async open(): Promise<ByteStream> {
|
||
const [c, s] = createMemoryStreamPair()
|
||
let init = false
|
||
s.onData((chunk) => {
|
||
let d = chunk
|
||
if (!init && d[0] === 0x01) {
|
||
init = true
|
||
d = d.subarray(1)
|
||
}
|
||
for (const pkt of bncsFramer.push(d)) {
|
||
if (pkt.id === BncsOpcode.SID_AUTH_INFO) {
|
||
const w = new ByteWriter().u32(0).u32(1).u32(0).u64BigInt(0n).cstring('v').cstring('f')
|
||
s.write(encodeBncsFrame(BncsOpcode.SID_AUTH_INFO, w.toUint8Array()))
|
||
} else if (pkt.id === BncsOpcode.SID_AUTH_CHECK) {
|
||
s.write(encodeBncsFrame(BncsOpcode.SID_AUTH_CHECK, new ByteWriter().u32(0).cstring('').toUint8Array()))
|
||
} else if (pkt.id === BncsOpcode.SID_LOGONRESPONSE2) {
|
||
s.write(encodeBncsFrame(BncsOpcode.SID_LOGONRESPONSE2, new ByteWriter().u32(2).toUint8Array()))
|
||
}
|
||
}
|
||
})
|
||
return c
|
||
},
|
||
},
|
||
})
|
||
await expect(flow.login('user', 'wrongpass')).rejects.toThrow(/SID_LOGONRESPONSE2 failed/)
|
||
})
|
||
|
||
it('F10.B4: D2OnlineFlow.joinGame rejects when called before selectCharacter()', async () => {
|
||
const bncsFramer = new BncsFramer()
|
||
const mcpFramer = new McpFramer()
|
||
const flow = createD2OnlineFlow({
|
||
bnetHost: '127.0.0.1',
|
||
resolver: {
|
||
async open(kind: EndpointKind): Promise<ByteStream> {
|
||
const [c, s] = createMemoryStreamPair()
|
||
let init = false
|
||
if (kind === 'bnet') {
|
||
s.onData((chunk) => {
|
||
let d = chunk
|
||
if (!init && d[0] === 0x01) {
|
||
init = true
|
||
d = d.subarray(1)
|
||
}
|
||
for (const pkt of bncsFramer.push(d)) {
|
||
if (pkt.id === BncsOpcode.SID_AUTH_INFO) {
|
||
s.write(encodeBncsFrame(BncsOpcode.SID_AUTH_INFO, new ByteWriter().u32(0).u32(1).u32(0).u64BigInt(0n).cstring('v').cstring('f').toUint8Array()))
|
||
} else if (pkt.id === BncsOpcode.SID_AUTH_CHECK || pkt.id === BncsOpcode.SID_LOGONRESPONSE2) {
|
||
s.write(encodeBncsFrame(pkt.id, new ByteWriter().u32(0).cstring('').toUint8Array()))
|
||
} else if (pkt.id === BncsOpcode.SID_LOGONREALMEX) {
|
||
const w = new ByteWriter().u32(1).u32(0).bytes(new Uint8Array(8)).bytes(new Uint8Array([127, 0, 0, 1])).bytes(new Uint8Array([0x17, 0xe1, 0, 0])).bytes(new Uint8Array(48)).cstring('Hero')
|
||
s.write(encodeBncsFrame(BncsOpcode.SID_LOGONREALMEX, w.toUint8Array()))
|
||
}
|
||
}
|
||
})
|
||
} else if (kind === 'realm') {
|
||
s.onData((chunk) => {
|
||
let d = chunk
|
||
if (!init && d[0] === 0x01) {
|
||
init = true
|
||
d = d.subarray(1)
|
||
}
|
||
for (const pkt of mcpFramer.push(d)) {
|
||
if (pkt.id === McpOpcode.MCP_STARTUP) {
|
||
s.write(encodeMcpFrame(McpOpcode.MCP_STARTUP, new ByteWriter().u32(0).toUint8Array()))
|
||
} else if (pkt.id === McpOpcode.MCP_CHARLIST2) {
|
||
s.write(encodeMcpFrame(McpOpcode.MCP_CHARLIST2, new ByteWriter().u16(8).u32(0).u16(0).toUint8Array()))
|
||
}
|
||
}
|
||
})
|
||
}
|
||
return c
|
||
},
|
||
},
|
||
})
|
||
await flow.login('user', 'pass')
|
||
await flow.enterRealm('D2CS')
|
||
await expect(flow.joinGame('g1')).rejects.toThrow(/before selectCharacter/)
|
||
await flow.close()
|
||
})
|
||
|
||
it('F10.B5: D2OnlineFlow.joinGame rejects if D2GS stream closes during handshake', async () => {
|
||
const bncsFramer = new BncsFramer()
|
||
const mcpFramer = new McpFramer()
|
||
const flow = createD2OnlineFlow({
|
||
bnetHost: '127.0.0.1',
|
||
resolver: {
|
||
async open(kind: EndpointKind): Promise<ByteStream> {
|
||
const [c, s] = createMemoryStreamPair()
|
||
let init = false
|
||
if (kind === 'bnet') {
|
||
s.onData((chunk) => {
|
||
let d = chunk
|
||
if (!init && d[0] === 0x01) {
|
||
init = true
|
||
d = d.subarray(1)
|
||
}
|
||
for (const pkt of bncsFramer.push(d)) {
|
||
if (pkt.id === BncsOpcode.SID_AUTH_INFO) {
|
||
s.write(encodeBncsFrame(BncsOpcode.SID_AUTH_INFO, new ByteWriter().u32(0).u32(1).u32(0).u64BigInt(0n).cstring('v').cstring('f').toUint8Array()))
|
||
} else if (pkt.id === BncsOpcode.SID_AUTH_CHECK || pkt.id === BncsOpcode.SID_LOGONRESPONSE2) {
|
||
s.write(encodeBncsFrame(pkt.id, new ByteWriter().u32(0).cstring('').toUint8Array()))
|
||
} else if (pkt.id === BncsOpcode.SID_LOGONREALMEX) {
|
||
const w = new ByteWriter().u32(1).u32(0).bytes(new Uint8Array(8)).bytes(new Uint8Array([127, 0, 0, 1])).bytes(new Uint8Array([0x17, 0xe1, 0, 0])).bytes(new Uint8Array(48)).cstring('Hero')
|
||
s.write(encodeBncsFrame(BncsOpcode.SID_LOGONREALMEX, w.toUint8Array()))
|
||
}
|
||
}
|
||
})
|
||
} else if (kind === 'realm') {
|
||
s.onData((chunk) => {
|
||
let d = chunk
|
||
if (!init && d[0] === 0x01) {
|
||
init = true
|
||
d = d.subarray(1)
|
||
}
|
||
for (const pkt of mcpFramer.push(d)) {
|
||
if (pkt.id === McpOpcode.MCP_STARTUP || pkt.id === McpOpcode.MCP_CHARLOGON) {
|
||
s.write(encodeMcpFrame(pkt.id, new ByteWriter().u32(0).toUint8Array()))
|
||
} else if (pkt.id === McpOpcode.MCP_CHARLIST2) {
|
||
s.write(encodeMcpFrame(McpOpcode.MCP_CHARLIST2, new ByteWriter().u16(8).u32(0).u16(0).toUint8Array()))
|
||
} else if (pkt.id === McpOpcode.MCP_JOINGAME) {
|
||
const reqId = new ByteReader(pkt.payload).u16()
|
||
s.write(encodeMcpFrame(McpOpcode.MCP_JOINGAME, new ByteWriter().u16(reqId).u16(1).u16(0).bytes(new Uint8Array([127, 0, 0, 1])).u32(1).u32(0).toUint8Array()))
|
||
}
|
||
}
|
||
})
|
||
} else if (kind === 'game') {
|
||
setTimeout(() => s.close(), 5)
|
||
}
|
||
return c
|
||
},
|
||
},
|
||
})
|
||
await flow.login('user', 'pass')
|
||
await flow.enterRealm('D2CS')
|
||
await flow.selectCharacter('BotSorc')
|
||
await expect(flow.joinGame('g1')).rejects.toThrow(/closed during handshake/)
|
||
await flow.close()
|
||
})
|
||
})
|
||
|
||
// ─── F11: ClientWorld State Store Boundaries ───────────────────────────────
|
||
describe('F11: ClientWorld State Store Boundaries', () => {
|
||
it('F11.B1: ClientWorld.apply(GameLogout) clears loading flag and preserves safe empty state on reset()', () => {
|
||
const world = new ClientWorld()
|
||
world.apply({ type: 'GameLoading' })
|
||
expect(world.loading).toBe(true)
|
||
world.apply({ type: 'GameLogout' })
|
||
expect(world.loading).toBe(false)
|
||
world.reset()
|
||
expect(world.units.size).toBe(0)
|
||
expect(world.selfId).toBe(0)
|
||
})
|
||
|
||
it('F11.B2: ClientWorld caps chatLog at 200 messages and soundQueue at 64 entries under flood', () => {
|
||
const world = new ClientWorld()
|
||
for (let i = 0; i < 250; i++) {
|
||
world.apply({ type: 'Chat', kind: 'say', text: `msg-${i}` })
|
||
}
|
||
expect(world.chatLog.length).toBe(200)
|
||
expect(world.chatLog[0]?.text).toBe('msg-50')
|
||
|
||
for (let i = 0; i < 100; i++) {
|
||
world.apply({ type: 'PlaySound', sound: i })
|
||
}
|
||
expect(world.soundQueue.length).toBe(64)
|
||
})
|
||
|
||
it('F11.B3: ClientWorld.tick ignores dead or stationary units and snaps units when within step distance', () => {
|
||
const world = new ClientWorld()
|
||
world.apply({
|
||
type: 'UnitAssign',
|
||
unit: { unitType: 1, id: 10, classId: 148, x: 100, y: 100, mode: 1 },
|
||
})
|
||
world.apply({
|
||
type: 'UnitMove',
|
||
unitType: 1,
|
||
id: 10,
|
||
mode: 'walk',
|
||
from: { x: 100, y: 100 },
|
||
to: { x: 101, y: 100 },
|
||
})
|
||
world.tick(1000)
|
||
expect(world.getUnit(1, 10)?.x).toBe(101)
|
||
})
|
||
|
||
it('F11.B4: ClientWorld removes party member when Party event has partyId === 0xFFFF (PlayerLeftGame)', () => {
|
||
const world = new ClientWorld()
|
||
world.apply({ type: 'Party', kind: 'roster', unitId: 5, charName: 'Peer', partyId: 1 })
|
||
expect(world.party.has(5)).toBe(true)
|
||
world.apply({ type: 'Party', kind: 'roster', unitId: 5, partyId: 0xffff })
|
||
expect(world.party.has(5)).toBe(false)
|
||
})
|
||
|
||
it('F11.B5: ClientWorld handles LifeMana with life=-1 / mana=-1 (from 0x96 WalkVerify) without clobbering existing HP/MP', () => {
|
||
const world = new ClientWorld()
|
||
world.apply({ type: 'LifeMana', life: 300, mana: 200, stamina: 100 })
|
||
world.apply({ type: 'LifeMana', life: -1, mana: -1, stamina: 90, at: { x: 5010, y: 5020 } })
|
||
expect(world.self.life).toBe(300)
|
||
expect(world.self.mana).toBe(200)
|
||
expect(world.self.stamina).toBe(90)
|
||
expect(world.self.x).toBe(5010)
|
||
})
|
||
})
|
||
|
||
// ─── F12: DRLG Map Generation & TileAtlas Boundaries ───────────────────────
|
||
describe('F12: DRLG Map Generation & TileAtlas Boundaries', () => {
|
||
it('F12.B1: MapService.getLevelView throws Error when called before loadAct()', async () => {
|
||
const fresh = await MapService.fromPackedDir('public/act-packs/tiles')
|
||
expect(() => fresh.getLevelView(1)).toThrow()
|
||
})
|
||
|
||
it('F12.B2: MapService.getLevelView throws Error when levelId is out of valid range', () => {
|
||
sharedMapService.loadAct({ act: 0, seed: 0x12345678, difficulty: 0 })
|
||
expect(() => sharedMapService.getLevelView(999)).toThrow()
|
||
})
|
||
|
||
it('F12.B3: getCollisionLocalSubTile and isWalkableLocalSubTile reject negative and beyond-grid coordinates', () => {
|
||
const view = sharedMapService.loadAct({ act: 0, seed: 0x12345678, difficulty: 0, areaId: 1 })!
|
||
expect(getCollisionLocalSubTile(view, -100, 0)).toBe(COLLIDE_MASK_INVALID)
|
||
expect(getCollisionLocalSubTile(view, 0, view.subHeight + 50)).toBe(COLLIDE_MASK_INVALID)
|
||
expect(isWalkableLocalSubTile(view, view.subWidth, view.subHeight)).toBe(false)
|
||
})
|
||
|
||
it('F12.B4: TileAtlas.getLibrary returns null for nonexistent DT1 path', () => {
|
||
const atlas = sharedMapService.getTileAtlas()!
|
||
expect(atlas.getLibrary('data\\global\\tiles\\nonexistent\\fake.dt1')).toBeNull()
|
||
})
|
||
|
||
it('F12.B5: TileAtlas.resolveCellTile returns null when dt1Path does not exist in catalog', () => {
|
||
const atlas = sharedMapService.getTileAtlas()!
|
||
expect(
|
||
atlas.resolveCellTile({
|
||
dt1Path: 'data\\global\\tiles\\nonexistent\\fake.dt1',
|
||
tileIndex: 0,
|
||
style: 0,
|
||
sequence: 0,
|
||
tileType: 0,
|
||
x: 0,
|
||
y: 0,
|
||
}),
|
||
).toBeNull()
|
||
})
|
||
})
|
||
|
||
// ─── F13: Pathfinding, LOS, Predictor & Interpolator Boundaries ────────────
|
||
describe('F13: Pathfinding, LOS, Predictor & Interpolator Boundaries', () => {
|
||
it('F13.B1: findPathLocal handles out-of-bounds start/goal or blocked grid gracefully', () => {
|
||
const blockedGrid = { subWidth: 5, subHeight: 5, walkable: new Uint8Array(25).fill(0) }
|
||
const res = findPathLocal(blockedGrid, 0, 0, 4, 4)
|
||
expect(res.reachedGoal).toBe(false)
|
||
})
|
||
|
||
it('F13.B2: findPathLocal prevents diagonal corner-cutting through pinch walls and stops at closest reachable sub-tile when boxed in', () => {
|
||
const subWidth = 5
|
||
const subHeight = 5
|
||
const walkable = new Uint8Array(subWidth * subHeight).fill(1)
|
||
// Surround walkable goal (3, 3) completely with walls (walkable = 0)
|
||
walkable[2 * subWidth + 2] = 0
|
||
walkable[2 * subWidth + 3] = 0
|
||
walkable[2 * subWidth + 4] = 0
|
||
walkable[3 * subWidth + 2] = 0
|
||
walkable[3 * subWidth + 4] = 0
|
||
walkable[4 * subWidth + 2] = 0
|
||
walkable[4 * subWidth + 3] = 0
|
||
walkable[4 * subWidth + 4] = 0
|
||
const res = findPathLocal({ subWidth, subHeight, walkable }, 0, 0, 3, 3)
|
||
expect(res.reachedGoal).toBe(false)
|
||
})
|
||
|
||
it('F13.B3: hasLineOfSightLocal returns false when wall blocks straight line or out of bounds', () => {
|
||
const subWidth = 5
|
||
const subHeight = 5
|
||
const walkable = new Uint8Array(subWidth * subHeight).fill(1)
|
||
walkable[2 * subWidth + 2] = 0
|
||
expect(hasLineOfSightLocal({ subWidth, subHeight, walkable }, 0, 0, 4, 4)).toBe(false)
|
||
expect(hasLineOfSightLocal({ subWidth, subHeight, walkable }, -1, 0, 2, 2)).toBe(false)
|
||
})
|
||
|
||
it('F13.B4: LocalMovementPredictor handles 0-tick steps and empty paths without NaN', () => {
|
||
const pred = new LocalMovementPredictor({ initialX: 100, initialY: 100 })
|
||
pred.setPath([], false)
|
||
const s0 = pred.stepTicks(0)
|
||
expect(s0.moving).toBe(false)
|
||
expect(s0.x).toBe(100)
|
||
})
|
||
|
||
it('F13.B5: RemoteEntityInterpolator stopEntity snaps immediately to server stop coordinates', () => {
|
||
const interp = new RemoteEntityInterpolator()
|
||
interp.spawnOrSnap({ unitType: 1, unitId: 50, x: 100, y: 100 })
|
||
const stopped = interp.stopEntity({ unitType: 1, unitId: 50, x: 200, y: 200 })
|
||
expect(stopped.x).toBe(200)
|
||
expect(stopped.y).toBe(200)
|
||
expect(stopped.moving).toBe(false)
|
||
})
|
||
})
|
||
|
||
// ─── F14: Offline .d2s Unlock Tool Boundaries ──────────────────────────────
|
||
describe('F14: Offline .d2s Unlock Tool Boundaries', () => {
|
||
it('F14.B1: validateBotCharacterName rejects non-Bot names, path traversal, and invalid characters', () => {
|
||
expect(() => validateBotCharacterName('Taodao')).toThrow(/R6 Safety Violation/)
|
||
expect(() => validateBotCharacterName('../d2webbot')).toThrow(/R6 Safety Violation/)
|
||
expect(() => validateBotCharacterName('my_d2webbot')).toThrow(/R6 Safety Violation/)
|
||
})
|
||
|
||
it('F14.B2: unlockD2sBuffer rejects buffers shorter than minimum header size', () => {
|
||
expect(() => unlockD2sBuffer(new Uint8Array(100))).toThrow()
|
||
})
|
||
|
||
it('F14.B3: unlockD2sBuffer rejects buffers with invalid .d2s magic header', () => {
|
||
const bad = new Uint8Array(800)
|
||
expect(() => unlockD2sBuffer(bad)).toThrow()
|
||
})
|
||
|
||
it('F14.B4: unlockD2sBuffer rejects buffers with unsupported .d2s version != 96', () => {
|
||
const bad = createSyntheticD2sBuffer('d2webbot_badver')
|
||
const view = new DataView(bad.buffer)
|
||
view.setUint32(4, 92, true)
|
||
expect(() => unlockD2sBuffer(bad)).toThrow()
|
||
})
|
||
|
||
it('F14.B5: verifyD2sUnlocked detects corrupted checksum when a byte is tampered after unlock', () => {
|
||
const buf = createSyntheticD2sBuffer('d2webbot_tamper')
|
||
const unlocked = unlockD2sBuffer(buf)
|
||
unlocked[0x2b] = 99 // tamper character level without recomputing checksum
|
||
expect(verifyD2sUnlocked(unlocked).checksumValid).toBe(false)
|
||
})
|
||
})
|
||
|
||
// ─── F15: Client Settings Store Boundaries ─────────────────────────────────
|
||
describe('F15: Client Settings Store Boundaries', () => {
|
||
it('F15.B1: validateServerHost rejects empty strings and bare IPv4/IPv6 on HTTPS', () => {
|
||
expect(validateServerHost('', 'https:').valid).toBe(false)
|
||
expect(validateServerHost('34.187.177.211', 'https:').valid).toBe(false)
|
||
expect(validateServerHost('[::1]:6112', 'https:').valid).toBe(false)
|
||
})
|
||
|
||
it('F15.B2: validateServerHost rejects invalid ports and malformed host labels', () => {
|
||
expect(validateServerHost('www.laiseek.xyz:99999', 'https:').valid).toBe(false)
|
||
expect(validateServerHost('www.laiseek.xyz:0', 'https:').valid).toBe(false)
|
||
expect(validateServerHost('-badhost-.xyz', 'https:').valid).toBe(false)
|
||
})
|
||
|
||
it('F15.B3: parseClientSettingsJson falls back to DEFAULT_SERVER_HOST on malformed JSON or null', () => {
|
||
expect(parseClientSettingsJson('{corrupt json', { defaultLang: 'en' }).serverHost).toBe(DEFAULT_SERVER_HOST)
|
||
expect(parseClientSettingsJson(null, { defaultLang: 'en' }).serverHost).toBe(DEFAULT_SERVER_HOST)
|
||
})
|
||
|
||
it('F15.B4: sanitizeClientSettings clamps masterVolume to [0, 1] and replaces NaN with default 0.8', () => {
|
||
const s = sanitizeClientSettings({ masterVolume: 5.0 })
|
||
expect(s.masterVolume).toBe(1)
|
||
const sNeg = sanitizeClientSettings({ masterVolume: -2.0 })
|
||
expect(sNeg.masterVolume).toBe(0)
|
||
const sNan = sanitizeClientSettings({ masterVolume: Number.NaN })
|
||
expect(sNan.masterVolume).toBe(0.8)
|
||
})
|
||
|
||
it('F15.B5: SettingsStore survives StorageLike that throws QuotaExceededError on setItem', () => {
|
||
const brokenStorage: StorageLike = {
|
||
getItem: () => null,
|
||
setItem: () => {
|
||
throw new Error('QuotaExceededError')
|
||
},
|
||
removeItem: () => {
|
||
throw new Error('SecurityError')
|
||
},
|
||
}
|
||
const store = new SettingsStore({ storage: brokenStorage, defaultLang: 'en', syncGlobalLang: false })
|
||
expect(() => store.set({ viewport: '1024x768' })).not.toThrow()
|
||
expect(store.get().viewport).toBe('1024x768')
|
||
expect(() => store.reset()).not.toThrow()
|
||
})
|
||
})
|
||
|
||
// ─── F16: Unified Top Toolbar Boundaries ───────────────────────────────────
|
||
describe('F16: Unified Top Toolbar Boundaries', () => {
|
||
it('F16.B1: Toolbar.submitServerHost rejects bare IP on HTTPS and sets diagnostic banner', () => {
|
||
const store = new SettingsStore({
|
||
storage: new MemoryStorage(),
|
||
defaultLang: 'en',
|
||
pageProtocol: 'https:',
|
||
syncGlobalLang: false,
|
||
})
|
||
const toolbar = new Toolbar({ store, pageProtocol: 'https:' })
|
||
const res = toolbar.submitServerHost('34.187.177.211')
|
||
expect(res.valid).toBe(false)
|
||
expect(res.applied).toBe(false)
|
||
expect(toolbar.getSnapshot().diagnosticBannerText).toContain('www.laiseek.xyz')
|
||
toolbar.destroy()
|
||
})
|
||
|
||
it('F16.B2: Toolbar.submitServerHost does not apply host change when user cancels mid-session confirmation', () => {
|
||
const store = new SettingsStore({
|
||
storage: new MemoryStorage(),
|
||
defaultLang: 'en',
|
||
pageProtocol: 'https:',
|
||
syncGlobalLang: false,
|
||
})
|
||
const toolbar = new Toolbar({
|
||
store,
|
||
pageProtocol: 'https:',
|
||
confirmReconnect: () => false,
|
||
})
|
||
toolbar.setConnectionState({ phase: 'd2gs', rttMs: 25 })
|
||
const res = toolbar.submitServerHost('eu.laiseek.xyz')
|
||
expect(res.valid).toBe(true)
|
||
expect(res.applied).toBe(false)
|
||
expect(store.get().serverHost).toBe('www.laiseek.xyz')
|
||
toolbar.destroy()
|
||
})
|
||
|
||
it('F16.B3: Toolbar.reportDiagnostic surfaces 5-per-IP quota_exceeded banner', () => {
|
||
const store = new SettingsStore({
|
||
storage: new MemoryStorage(),
|
||
defaultLang: 'en',
|
||
syncGlobalLang: false,
|
||
})
|
||
const toolbar = new Toolbar({ store })
|
||
toolbar.reportDiagnostic('quota_exceeded')
|
||
expect(toolbar.getSnapshot().diagnosticBannerText).toContain('5')
|
||
toolbar.destroy()
|
||
})
|
||
|
||
it('F16.B4: Toolbar.toggleInspector flips inspectorOpen boolean state cleanly', () => {
|
||
const store = new SettingsStore({
|
||
storage: new MemoryStorage(),
|
||
defaultLang: 'en',
|
||
syncGlobalLang: false,
|
||
})
|
||
const toolbar = new Toolbar({ store })
|
||
expect(toolbar.toggleInspector()).toBe(true)
|
||
expect(toolbar.toggleInspector()).toBe(false)
|
||
toolbar.destroy()
|
||
})
|
||
|
||
it('F16.B5: Toolbar.destroy unsubscribes from SettingsStore updates cleanly', () => {
|
||
const store = new SettingsStore({
|
||
storage: new MemoryStorage(),
|
||
defaultLang: 'en',
|
||
syncGlobalLang: false,
|
||
})
|
||
let calls = 0
|
||
const toolbar = new Toolbar({
|
||
store,
|
||
callbacks: {
|
||
onViewportChange: () => {
|
||
calls++
|
||
},
|
||
},
|
||
})
|
||
toolbar.destroy()
|
||
store.set({ viewport: '1024x768' })
|
||
expect(calls).toBe(0)
|
||
})
|
||
})
|
||
|
||
// ─── F17: Dual Viewport & Scene Drawables Boundaries ───────────────────────
|
||
describe('F17: Dual Viewport & Scene Drawables Boundaries', () => {
|
||
it('F17.B1: getViewportProfile falls back to VIEWPORT_800x600 for unrecognized resolution strings', () => {
|
||
expect(getViewportProfile('invalid' as '800x600')).toEqual(VIEWPORT_800x600)
|
||
})
|
||
|
||
it('F17.B2: computeHudLayout handles 0, negative, and NaN CSS dimensions or DPR without dividing by zero', () => {
|
||
const layout = computeHudLayout(0, -100, Number.NaN)
|
||
expect(Number.isFinite(layout.uiScale)).toBe(true)
|
||
expect(layout.uiScale).toBeGreaterThan(0)
|
||
})
|
||
|
||
it('F17.B3: applyViewportProfileToDom updates canvas dimensions for both 800x600 and 1024x768', () => {
|
||
const stageEl = { style: { width: '', height: '' } }
|
||
const glCanvas = { width: 0, height: 0, style: { width: '', height: '' } }
|
||
const hudCanvas = { width: 0, height: 0, style: { width: '', height: '' } }
|
||
applyViewportProfileToDom('800x600', { stageEl, glCanvas, hudCanvas })
|
||
expect(glCanvas.width).toBe(800)
|
||
applyViewportProfileToDom('1024x768', { stageEl, glCanvas, hudCanvas })
|
||
expect(glCanvas.width).toBe(1024)
|
||
})
|
||
|
||
it('F17.B4: ClientWorldSceneSource handles empty unit list at (0,0)', () => {
|
||
const automap = new AutomapView({ initialMode: 'off' })
|
||
const levelView = automap.setContext(0x12345678, 0, 1)
|
||
const source = new ClientWorldSceneSource(
|
||
(): ClientSceneWorldAdapter => ({
|
||
tick: 0,
|
||
actId: 1,
|
||
levelId: 1,
|
||
playerSubX: 0,
|
||
playerSubY: 0,
|
||
units: [],
|
||
levelView,
|
||
}),
|
||
'800x600',
|
||
)
|
||
expect(source.getViewportProfile()).toEqual(VIEWPORT_800x600)
|
||
})
|
||
|
||
it('F17.B5: buildSceneDrawables sorts sortedDrawables deterministically and applies freeze tint on state 1', () => {
|
||
const units: RenderableUnitInput[] = [
|
||
{ kind: 'monster', id: 20, classId: 10, x: 100, y: 110, states: [1] },
|
||
{ kind: 'monster', id: 10, classId: 10, x: 100, y: 102 },
|
||
]
|
||
const drawables = buildSceneDrawables(units, { tick: 1 })
|
||
expect(drawables.sortedDrawables.filter((d) => d.drawableKind === 'unit').map((d) => d.unitId)).toEqual([10, 20])
|
||
expect(drawables.unitDrawables[0]?.tintColor).toEqual([90, 155, 255])
|
||
})
|
||
})
|
||
|
||
// ─── F18: Automap Overlay Boundaries ───────────────────────────────────────
|
||
describe('F18: Automap Overlay Boundaries', () => {
|
||
it('F18.B1: AutomapView.onServerRemoveRoom on unrevealed room is a safe no-op', () => {
|
||
const automap = new AutomapView({ initialMode: 'off' })
|
||
automap.setContext(0xdeadbeef, 0, 1)
|
||
expect(() => automap.onServerRemoveRoom(99999, 99999, 1)).not.toThrow()
|
||
expect(automap.buildSnapshot().visibleRoomCount).toBe(0)
|
||
})
|
||
|
||
it('F18.B2: AutomapView.onServerAddRoom deduplicates repeated reveals of the same room', () => {
|
||
const automap = new AutomapView({ initialMode: 'off' })
|
||
const view = automap.setContext(0xdeadbeef, 0, 1)
|
||
const r0 = view.rooms[0]!
|
||
automap.onServerAddRoom(r0.tileX, r0.tileY, 1)
|
||
automap.onServerAddRoom(r0.tileX, r0.tileY, 1)
|
||
expect(automap.buildSnapshot().serverRevealedRoomCount).toBe(1)
|
||
})
|
||
|
||
it('F18.B3: AutomapView switches from act -> off mode and hides non-server rooms', () => {
|
||
const automap = new AutomapView({
|
||
initialMode: 'act',
|
||
actRevealLevelIds: { 1: [1, 2], 2: [40], 3: [75], 4: [103], 5: [109] },
|
||
})
|
||
automap.setContext(0xdeadbeef, 0, 1)
|
||
expect(automap.buildSnapshot().visibleAreaIds).toEqual([1, 2])
|
||
automap.setMode('off')
|
||
expect(automap.buildSnapshot().visibleRoomCount).toBe(0)
|
||
})
|
||
|
||
it('F18.B4: AutomapView.setContext resets server-revealed rooms when seed or area changes', () => {
|
||
const automap = new AutomapView({ initialMode: 'off' })
|
||
const v1 = automap.setContext(0x11111111, 0, 1)
|
||
automap.onServerAddRoom(v1.rooms[0]!.tileX, v1.rooms[0]!.tileY, 1)
|
||
expect(automap.buildSnapshot().serverRevealedRoomCount).toBe(1)
|
||
automap.setContext(0x22222222, 0, 1)
|
||
expect(automap.buildSnapshot().serverRevealedRoomCount).toBe(0)
|
||
})
|
||
|
||
it('F18.B5: AutomapView handles Act 4 (103) and Act 5 (109) town contexts cleanly', () => {
|
||
const automap = new AutomapView({ initialMode: 'level' })
|
||
const v4 = automap.setContext(0xdeadbeef, 0, 103)
|
||
expect(v4.id).toBe(103)
|
||
const v5 = automap.setContext(0xdeadbeef, 0, 109)
|
||
expect(v5.id).toBe(109)
|
||
})
|
||
})
|
||
|
||
// ─── F19: Spatial Audio & OPFS/HTTP-Range MPQ Boundaries ───────────────────
|
||
describe('F19: Spatial Audio & OPFS/HTTP-Range MPQ Boundaries', () => {
|
||
it('F19.B1: computeSpatialAudioMetrics culls sounds beyond maxAudibleSubTiles with gain=0', () => {
|
||
const far = computeSpatialAudioMetrics(200, 200, 100, 100)
|
||
expect(far.culled).toBe(true)
|
||
expect(far.gain).toBe(0)
|
||
})
|
||
|
||
it('F19.B2: resolveSoundEnvironmentForLevel falls back safely when given out-of-range levelId', () => {
|
||
expect(resolveSoundEnvironmentForLevel(999).actId).toBeGreaterThanOrEqual(1)
|
||
expect(resolveSoundEnvironmentForLevel(-5).actId).toBeGreaterThanOrEqual(1)
|
||
})
|
||
|
||
it('F19.B3: SoundService returns null on playSoundById when muted or spatially culled', async () => {
|
||
const store = new SettingsStore({ storage: new MemoryStorage(), defaultLang: 'en', syncGlobalLang: false })
|
||
const backend: AudioBackendLike = {
|
||
async init() {},
|
||
setMuted() {},
|
||
async playBGM() {},
|
||
playSFX() {},
|
||
playButtonClick() {},
|
||
playFootstep() {},
|
||
playPortalEnter() {},
|
||
playWaypointActivate() {},
|
||
}
|
||
const svc = new SoundService({ store, backend })
|
||
await svc.init()
|
||
svc.setMuted(true)
|
||
expect(svc.playSoundById(47)).toBeNull()
|
||
svc.setMuted(false)
|
||
expect(
|
||
svc.playSoundById(47, {
|
||
sourceSubX: 300,
|
||
sourceSubY: 300,
|
||
listenerSubX: 100,
|
||
listenerSubY: 100,
|
||
}),
|
||
).toBeNull()
|
||
svc.destroy()
|
||
})
|
||
|
||
it('F19.B4: HttpRangeMpqSource rejects negative offset/length and throws on HTTP 404 failure', async () => {
|
||
const source = new HttpRangeMpqSource({
|
||
url: '/diablo2/data/missing.mpq',
|
||
label: 'missing.mpq',
|
||
fetchFn: async () => ({
|
||
ok: false,
|
||
status: 404,
|
||
headers: { get: () => null },
|
||
arrayBuffer: async () => new ArrayBuffer(0),
|
||
}),
|
||
})
|
||
await expect(source.read(-1, 10)).rejects.toThrow()
|
||
await expect(source.read(0, 10)).rejects.toThrow(/404/)
|
||
expect(await source.read(0, 0)).toEqual(new Uint8Array(0))
|
||
})
|
||
|
||
it('F19.B5: InMemoryChunkCache evicts oldest entry when maxEntries (16) is exceeded', async () => {
|
||
const lru = new InMemoryChunkCache(16)
|
||
for (let i = 0; i < 16; i++) {
|
||
await lru.putChunk(`k${i}`, new Uint8Array([i]))
|
||
}
|
||
await lru.putChunk('k16', new Uint8Array([16]))
|
||
expect(await lru.getChunk('k0')).toBeNull()
|
||
expect(await lru.getChunk('k16')).toEqual(new Uint8Array([16]))
|
||
})
|
||
})
|
||
|
||
// ─── F20: Outbound Rate Limiter & Packet Inspector Boundaries ──────────────
|
||
describe('F20: Outbound Rate Limiter & Packet Inspector Boundaries', () => {
|
||
it('F20.B1: OutboundRateLimiter throttles rapid skill casts within skillMinIntervalMs', () => {
|
||
const limiter = new OutboundRateLimiter({ skillMinIntervalMs: 200 })
|
||
const cast = { type: 'CastRightSkillOnLocation', x: asSubTile(100), y: asSubTile(100) } as const
|
||
expect(limiter.tryAcquire(cast, 1000).allowed).toBe(true)
|
||
const second = limiter.tryAcquire(cast, 1050)
|
||
expect(second.allowed).toBe(false)
|
||
expect(second.reason).toBe('category_cooldown')
|
||
})
|
||
|
||
it('F20.B2: OutboundRateLimiter exhausts token bucket under burst across distinct categories', () => {
|
||
const limiter = new OutboundRateLimiter({
|
||
moveMinIntervalMs: 0,
|
||
skillMinIntervalMs: 0,
|
||
burstCapacity: 2,
|
||
refillPerSec: 1,
|
||
})
|
||
expect(limiter.tryAcquire({ type: 'RunToLocation', x: asSubTile(10), y: asSubTile(20) }, 1000).allowed).toBe(true)
|
||
expect(limiter.tryAcquire({ type: 'CastRightSkillOnLocation', x: asSubTile(10), y: asSubTile(20) }, 1000).allowed).toBe(true)
|
||
const third = limiter.tryAcquire({ type: 'Interact', target: { unitType: 1, id: 1 } }, 1000)
|
||
expect(third.allowed).toBe(false)
|
||
expect(third.reason).toBe('token_bucket_empty')
|
||
})
|
||
|
||
it('F20.B3: PacketInspector respects maxRows ring buffer limit and stepReplay returns null when empty', () => {
|
||
const store = new SettingsStore({ storage: new MemoryStorage(), defaultLang: 'en', syncGlobalLang: false })
|
||
const tap = new InMemoryPacketTap()
|
||
const inspector = new PacketInspector({ store, tap, maxRows: 2 })
|
||
tap.record({ t: 1, conn: 'game', dir: 's2c', packetId: 0x00, raw: new Uint8Array([0x00]) })
|
||
tap.record({ t: 2, conn: 'game', dir: 's2c', packetId: 0x02, raw: new Uint8Array([0x02]) })
|
||
tap.record({ t: 3, conn: 'game', dir: 's2c', packetId: 0x04, raw: new Uint8Array([0x04]) })
|
||
expect(inspector.getAllRows()).toHaveLength(2)
|
||
expect(inspector.getAllRows()[0]?.opcode).toBe(0x02)
|
||
expect(inspector.stepReplay()).toBeNull()
|
||
inspector.destroy()
|
||
})
|
||
|
||
it('F20.B4: formatHexDump handles empty 0-byte buffer and multi-line (>16 byte) buffers', () => {
|
||
expect(formatHexDump(new Uint8Array(0))).toBeDefined()
|
||
const dump = formatHexDump(new Uint8Array(32).fill(0x41))
|
||
expect(dump.split('\n').length).toBeGreaterThanOrEqual(2)
|
||
})
|
||
|
||
it('F20.B5: parseD2CapJsonl ignores blank and comment lines and throws on malformed JSON line', () => {
|
||
const validWithComments = '# comment\n\n{"t":1,"conn":"game","dir":"s2c","hex":"04"}\n'
|
||
const parsed = parseD2CapJsonl(validWithComments)
|
||
expect(parsed.records).toHaveLength(1)
|
||
expect(() => parseD2CapJsonl('{bad json')).toThrow()
|
||
})
|
||
})
|
||
})
|