import { describe, expect, it, vi } from 'vitest' import { HttpRangeMpqSource, InMemoryChunkCache, OpfsMpqManager, } from '../../src/client/assets/opfs-mpq.ts' import { computeSpatialAudioMetrics, resolveSoundEnvironmentForLevel, SoundService, type AudioBackendLike, } from '../../src/client/audio/sound-service.ts' import { AutomapView } from '../../src/client/automap/automap-view.ts' import { formatHexDump, PacketInspector, } from '../../src/client/inspector/packet-inspector.ts' import { MapService } from '../../src/client/map/map-service.ts' import { OutboundRateLimiter } from '../../src/client/session/rate-limiter.ts' import { DEFAULT_SERVER_HOST, deriveRealmWsEndpoints, parseClientSettingsJson, sanitizeClientSettings, SETTINGS_STORAGE_KEY, 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 { ClientWorldSceneSource, type ClientSceneWorldAdapter, } from '../../src/client/view/scene-source.ts' import { buildSceneDrawables, type RenderableUnitInput, } from '../../src/client/view/unit-drawables.ts' import { applyViewportProfileToDom, computeViewportAnchors, getViewportProfile, screenToSubTile, screenToWorld, subTileToScreen, VIEWPORT_1024x768, VIEWPORT_800x600, worldToScreen, } from '../../src/client/view/viewport-profile.ts' import { LightGrid } from '../../src/common/drlg/light-grid.ts' import { getLang, onLangChange, setLang, tToolbar, } from '../../src/client/i18n/lang.ts' import { asSubTile, asUnitId, InMemoryPacketTap, } from '../../src/netproto/index.ts' import { applyLightingInputs, LIGHTING_PRESETS, } from '../../src/client/scene/lighting-presets.ts' import { renderSceneSourceFrame } from '../../src/client/scene/world-renderer.ts' import { computeDockingLayout, computeHudLayout, HudManager, } from '../../src/client/ui/hud-manager.ts' class MemoryStorage implements StorageLike { readonly data = new Map() 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) } } describe('ClientSettings & SettingsStore (F14)', () => { it('persists defaults to d2web.settings.v1 and sanitizes corrupt/unknown fields without throwing', () => { const storage = new MemoryStorage() storage.setItem( SETTINGS_STORAGE_KEY, JSON.stringify({ version: 999, serverHost: '34.187.177.211', // bare IP rejected on https: lang: 'invalid_lang', lightingPreset: 'non_existent_preset', automapReveal: 'invalid_mode', viewport: '1920x1080', inspectorOpen: 'yes', audioMuted: 123, masterVolume: 5.5, extraHackField: 'should_be_stripped', }), ) const store = new SettingsStore({ storage, defaultLang: 'zh', pageProtocol: 'https:', syncGlobalLang: false, }) const settings = store.get() expect(settings).toEqual({ version: 1, serverHost: DEFAULT_SERVER_HOST, lang: 'zh', lightingPreset: 'auto', automapReveal: 'off', viewport: '800x600', inspectorOpen: false, audioMuted: false, masterVolume: 1.0, }) expect('extraHackField' in (settings as unknown as Record)).toBe(false) // Corrupt non-JSON string falls back cleanly const fallback = parseClientSettingsJson('{not-valid-json', { defaultLang: 'en' }) expect(fallback.serverHost).toBe('www.laiseek.xyz') expect(fallback.lang).toBe('en') expect( sanitizeClientSettings(null, { defaultLang: 'zh' }).viewport ).toBe('800x600') }) it('validates serverHost and rejects bare IPv4/IPv6 on HTTPS with an actionable WSS TLS message', () => { const emptyRes = validateServerHost(' ', 'https:') expect(emptyRes.valid).toBe(false) expect(emptyRes.reason).toBe('empty') const ipv4Https = validateServerHost('34.187.177.211', 'https:') expect(ipv4Https.valid).toBe(false) expect(ipv4Https.reason).toBe('bare_ip_on_https') expect(ipv4Https.errorMessage).toContain('www.laiseek.xyz') const ipv6Https = validateServerHost('[::1]:6112', 'https:') expect(ipv6Https.valid).toBe(false) expect(ipv6Https.reason).toBe('bare_ip_on_https') // Local HTTP development allows bare IP const ipv4Http = validateServerHost('127.0.0.1:8080', 'http:') expect(ipv4Http.valid).toBe(true) expect(ipv4Http.normalizedHost).toBe('127.0.0.1:8080') // Valid domain with optional scheme/path stripped const domainRes = validateServerHost('wss://WWW.LAISEEK.XYZ/ws/bncs', 'https:') expect(domainRes.valid).toBe(true) expect(domainRes.normalizedHost).toBe('www.laiseek.xyz') const endpoints = deriveRealmWsEndpoints('www.laiseek.xyz', 'https:') expect(endpoints).toEqual({ bncsUrl: 'wss://www.laiseek.xyz/ws/bncs', mcpUrl: 'wss://www.laiseek.xyz/ws/mcp', d2gsUrl: 'wss://www.laiseek.xyz/ws/d2gs', }) }) }) describe('Top DOM Toolbar & Live Language / Settings Controls (F14)', () => { it('supports all 6 controls, rejects bare IP on HTTPS, and prompts confirmation when changing host mid-session', () => { const storage = new MemoryStorage() const store = new SettingsStore({ storage, defaultLang: 'zh', pageProtocol: 'https:', }) const reconnectSpy = vi.fn() const lightingSpy = vi.fn() const automapSpy = vi.fn() const viewportSpy = vi.fn() const inspectorSpy = vi.fn() let confirmResponse = false const toolbar = new Toolbar({ store, pageProtocol: 'https:', confirmReconnect: () => confirmResponse, callbacks: { onServerHostReconnect: reconnectSpy, onLightingChange: lightingSpy, onAutomapRevealChange: automapSpy, onViewportChange: viewportSpy, onInspectorToggle: inspectorSpy, }, }) // 1. Connection status + RTT + 5-per-IP quota diagnostic toolbar.setConnectionState({ phase: 'd2gs', rttMs: 34 }) expect(toolbar.getSnapshot().statusLabel).toBe(tToolbar('toolbar.status.d2gs', 'zh')) expect(toolbar.getSnapshot().rttLabel).toBe('34 ms') toolbar.reportDiagnostic('quota_exceeded') expect(toolbar.getSnapshot().diagnosticBannerText).toContain('5') toolbar.setConnectionState({ phase: 'd2gs', rttMs: 28, diagnosticKind: 'none' }) // 2. Server Host validation & mid-game confirmation const bareIpAttempt = toolbar.submitServerHost('34.187.177.211') expect(bareIpAttempt.valid).toBe(false) expect(bareIpAttempt.applied).toBe(false) expect(store.get().serverHost).toBe('www.laiseek.xyz') expect(toolbar.getSnapshot().diagnosticBannerText).toContain('www.laiseek.xyz') // Valid new host while in active D2GS session: user cancels confirmation first confirmResponse = false const cancelledAttempt = toolbar.submitServerHost('eu.laiseek.xyz') expect(cancelledAttempt.valid).toBe(true) expect(cancelledAttempt.applied).toBe(false) expect(store.get().serverHost).toBe('www.laiseek.xyz') expect(reconnectSpy).not.toHaveBeenCalled() // User confirms reconnect confirmResponse = true const confirmedAttempt = toolbar.submitServerHost('eu.laiseek.xyz') expect(confirmedAttempt.valid).toBe(true) expect(confirmedAttempt.applied).toBe(true) expect(store.get().serverHost).toBe('eu.laiseek.xyz') expect(reconnectSpy).toHaveBeenCalledWith('eu.laiseek.xyz', 'www.laiseek.xyz') // 3. Live Language Toggle (δΈ­ / EN) without page reload const langChanges: string[] = [] const unsubLang = onLangChange((l) => langChanges.push(l)) toolbar.toggleLanguage() expect(store.get().lang).toBe('en') expect(getLang()).toBe('en') expect(toolbar.getSnapshot().statusLabel).toBe('In-Game (D2GS)') expect(langChanges).toContain('en') unsubLang() // 4. Lighting Preset Selector toolbar.setLightingPreset('torch') expect(store.get().lightingPreset).toBe('torch') expect(lightingSpy).toHaveBeenCalledWith('torch') toolbar.setLightingPreset('fullbright') expect(store.get().lightingPreset).toBe('fullbright') // 5. Automap Reveal Selector (off / level / act) toolbar.setAutomapReveal('level') expect(store.get().automapReveal).toBe('level') expect(automapSpy).toHaveBeenCalledWith('level') toolbar.setAutomapReveal('act') expect(store.get().automapReveal).toBe('act') expect(automapSpy).toHaveBeenCalledWith('act') // 6. Viewport Size Toggle (800x600 / 1024x768) const nextProfile = toolbar.toggleViewport() expect(nextProfile).toEqual(VIEWPORT_1024x768) expect(store.get().viewport).toBe('1024x768') expect(viewportSpy).toHaveBeenCalledWith(VIEWPORT_1024x768) // Packet Inspector Toggle expect(toolbar.toggleInspector()).toBe(true) expect(store.get().inspectorOpen).toBe(true) expect(inspectorSpy).toHaveBeenCalledWith(true) toolbar.destroy() setLang('zh') }) }) describe('Dual Viewport Profiles, HudManager Anchoring & Coordinate Transforms (F15)', () => { it('computes exact 800x600 and 1024x768 panel/bar anchors with a 224px center gap in 1024x768', () => { const anchors800 = computeViewportAnchors('800x600') expect(anchors800.bottomBar).toEqual({ x: 0, y: 552, width: 800, height: 48 }) expect(anchors800.leftPanel).toEqual({ x: 0, y: 0, width: 400, height: 552 }) expect(anchors800.rightPanel).toEqual({ x: 400, y: 0, width: 400, height: 552 }) expect(anchors800.centerGapWidth).toBe(0) const anchors1024 = computeViewportAnchors('1024x768') expect(anchors1024.profile).toEqual({ id: '1024x768', width: 1024, height: 768, barOffsetX: 112, barOffsetY: 168, panelOffsetY: 84, }) expect(anchors1024.bottomBar).toEqual({ x: 112, y: 720, width: 800, height: 48 }) expect(anchors1024.leftPanel).toEqual({ x: 0, y: 84, width: 400, height: 552 }) expect(anchors1024.rightPanel).toEqual({ x: 624, y: 84, width: 400, height: 552 }) expect(anchors1024.centerGapWidth).toBe(224) const dock1024 = computeDockingLayout(1024, 768, 1, VIEWPORT_1024x768) expect(dock1024.uiScale).toBe(1) expect(dock1024.leftDockX).toBe(0) expect(dock1024.rightDockX).toBe(624) expect(dock1024.channelWidthLogical).toBe(224) expect(dock1024.barOffsetX).toBe(112) expect(dock1024.barOffsetY).toBe(168) expect(dock1024.panelOffsetY).toBe(84) const layout800 = computeHudLayout(800, 600, 1) expect(layout800.uiScale).toBe(1) }) it('intercepts HUD clicks accurately in 1024x768 while leaving the 224px center gap open for world clicks', () => { const mockCanvas = { width: 1024, height: 768, clientWidth: 1024, clientHeight: 768, style: { cursor: '', pointerEvents: '' }, getBoundingClientRect: () => ({ left: 0, top: 0, width: 1024, height: 768 }), } as unknown as HTMLCanvasElement const hud = new HudManager(mockCanvas, { onToggleAutomap: () => {}, onWaypointTeleport: () => {}, }) hud.setViewportProfile('1024x768') hud.toggleLeftPanel('char') hud.toggleRightPanel('inv') // Left panel (0..400, 84..637) is intercepted expect(hud.isPointInterceptedByHud(200, 300)).toBe(true) // Above left panel (y = 40 < 84) is NOT intercepted expect(hud.isPointInterceptedByHud(200, 40)).toBe(false) // Right panel (624..1024, 84..637) is intercepted expect(hud.isPointInterceptedByHud(800, 300)).toBe(true) // 224px center gap (400 < x < 624) is NOT intercepted even with both panels open expect(hud.isPointInterceptedByHud(512, 300)).toBe(false) // Centered bottom control bar (x = 112..912, y = 720..768) is intercepted expect(hud.isPointInterceptedByHud(512, 740)).toBe(true) // Left/right of the 800px bottom bar at the bottom corners (x = 50, y = 740) is NOT intercepted expect(hud.isPointInterceptedByHud(50, 740)).toBe(false) }) it('round-trips world/sub-tile <-> screen coordinates and resizes DOM targets in lockstep', () => { for (const profile of [VIEWPORT_800x600, VIEWPORT_1024x768]) { const camX = 1280 const camY = 640 const screen = worldToScreen(1440, 720, camX, camY, profile) const world = screenToWorld(screen.x, screen.y, camX, camY, profile) expect(world.x).toBeCloseTo(1440, 5) expect(world.y).toBeCloseTo(720, 5) const sSub = subTileToScreen(110, 95, 100, 100, profile) const bSub = screenToSubTile(sSub.x, sSub.y, 100, 100, profile) expect(bSub.x).toBeCloseTo(110, 5) expect(bSub.y).toBeCloseTo(95, 5) } const stageEl = { style: { width: '', height: '' } } const glCanvas = { width: 0, height: 0, style: { width: '', height: '' } } const hudCanvas = { width: 0, height: 0, style: { width: '', height: '' } } applyViewportProfileToDom('1024x768', { stageEl, glCanvas, hudCanvas }) expect(stageEl.style.width).toBe('1024px') expect(stageEl.style.height).toBe('768px') expect(glCanvas.width).toBe(1024) expect(hudCanvas.height).toBe(768) }) }) describe('SceneSource, UnitDrawables, Missiles/Overlays & LightingInputs (F15, F17)', () => { it('normalizes D2GS world sub-tiles by LevelView.subOriginX/Y and submits tile + unit quads to SpriteRenderer', () => { const automap = new AutomapView({ initialMode: 'level' }) const levelView = automap.setContext(0x11223344, 0, 1) const localSubX = Math.floor((levelView.cellsX * 5) / 2) const localSubY = Math.floor((levelView.cellsY * 5) / 2) const worldSubX = levelView.subOriginX + localSubX const worldSubY = levelView.subOriginY + localSubY const playerOrthoX = levelView.isoOriginX + (localSubX - localSubY) * 16 const playerOrthoY = levelView.isoOriginY + (localSubX + localSubY) * 8 ;(levelView.floors as unknown[]).push({ x: playerOrthoX, y: playerOrthoY, cellX: Math.floor(localSubX / 5), cellY: Math.floor(localSubY / 5), layer: 0, kind: 'floor', library: 0, tile: 0, dt1Path: 'data/global/tiles/act1/town/floor.dt1', placement: { act: 1, page: 0, x: 0, y: 0, w: 160, h: 80, dx: -80, dy: -40, }, }) const units: RenderableUnitInput[] = [ { kind: 'player', id: 1, classId: 1, x: worldSubX, y: worldSubY, isLocalPlayer: true, }, { kind: 'monster', id: 2, classId: 148, // Akara x: worldSubX + 5, y: worldSubY + 5, name: 'Akara', }, ] const source = new ClientWorldSceneSource( (): ClientSceneWorldAdapter => ({ tick: 12, actId: 1, levelId: 1, playerSubX: worldSubX, playerSubY: worldSubY, units, levelView, }), '800x600', ) let beginCalls = 0 let drawCalls = 0 let solidCalls = 0 let flushCalls = 0 const fakeRenderer = { begin() { beginCalls += 1 }, draw() { drawCalls += 1 }, drawSolid() { solidCalls += 1 }, setLighting() {}, setPalette() {}, addIndexedAtlas() { return { index: 1, width: 2048, height: 1932 } }, flush() { flushCalls += 1 }, } const fakeTileGpuCache = { loadedPageCount: 1, ensureActPalette() {}, getPageHandle() { return { index: 1, width: 2048, height: 1932 } }, } const snap = renderSceneSourceFrame({ source, lightingPreset: 'fullbright', renderer: fakeRenderer as never, tileGpuCache: fakeTileGpuCache as never, }) expect(snap.floorQuads.length).toBeGreaterThan(0) expect(beginCalls).toBe(1) expect(drawCalls).toBeGreaterThanOrEqual(snap.floorQuads.length) expect(solidCalls).toBeGreaterThanOrEqual(2) expect(flushCalls).toBe(1) }) }) describe('AutomapView Reveal Modes: off / level / act (F17)', () => { it('switches cleanly between off (server rooms only), level (all current area rooms), and act (multi-level Act rooms)', () => { const automap = new AutomapView({ initialMode: 'off', actRevealLevelIds: { 1: [1, 2, 3], 2: [40], 3: [75], 4: [103], 5: [109], }, }) const view = automap.setContext(0xdeadbeef, 0, 1) const allRoomsInArea1 = view.rooms expect(allRoomsInArea1.length).toBeGreaterThan(0) // In 'off' mode with 0 server rooms revealed, 0 rooms are visible expect(automap.buildSnapshot().visibleRoomCount).toBe(0) // Reveal first room via server 0x07 AddMapRoom const firstRoom = allRoomsInArea1[0]! automap.onServerAddRoom(firstRoom.tileX, firstRoom.tileY, 1) const offSnap = automap.buildSnapshot() expect(offSnap.visibleRoomCount).toBeGreaterThanOrEqual(1) expect(offSnap.serverRevealedRoomCount).toBe(1) // Switch to 'level' mode: all rooms in area 1 become visible automap.setMode('level') const levelSnap = automap.buildSnapshot() expect(levelSnap.visibleAreaIds).toEqual([1]) expect(levelSnap.visibleRoomCount).toBe(allRoomsInArea1.length) // Switch to 'act' mode: levels 1, 2, and 3 in Act 1 are generated and revealed automap.setMode('act') const actSnap = automap.buildSnapshot() expect(actSnap.visibleAreaIds).toEqual([1, 2, 3]) expect(actSnap.visibleRoomCount).toBeGreaterThan(allRoomsInArea1.length) expect(actSnap.warpMarkers.length).toBeGreaterThanOrEqual(3) // Switch back to 'off' and remove the server room automap.setMode('off') automap.onServerRemoveRoom(firstRoom.tileX, firstRoom.tileY, 1) expect(automap.buildSnapshot().visibleRoomCount).toBe(0) }) }) describe('HttpRangeMpqSource, OpfsMpqManager & SoundService (F19)', () => { it('fetches byte ranges with Range header and caches chunks to avoid duplicate network fetches', async () => { const fakeArchive = new Uint8Array(1024) for (let i = 0; i < fakeArchive.length; i++) { fakeArchive[i] = i & 0xff } const fetchSpy = vi.fn(async (_url: string, init?: { readonly headers?: Readonly> }) => { const rangeHeader = init?.headers?.Range ?? '' const m = /^bytes=(\d+)-(\d+)$/.exec(rangeHeader) const start = m ? Number(m[1]) : 0 const end = m ? Number(m[2]) : fakeArchive.length - 1 const slice = fakeArchive.slice(start, end + 1) return { ok: true, status: 206, headers: { get(name: string) { return name.toLowerCase() === 'content-range' ? `bytes ${start}-${end}/${fakeArchive.length}` : null }, }, async arrayBuffer() { return slice.buffer.slice(slice.byteOffset, slice.byteOffset + slice.byteLength) }, } }) const cache = new InMemoryChunkCache() const source = new HttpRangeMpqSource({ url: '/diablo2/data/d2sfx.mpq', label: 'd2sfx.mpq', cache, fetchFn: fetchSpy, }) const firstRead = await source.read(0, 32) expect(firstRead).toHaveLength(32) expect(firstRead[15]).toBe(15) expect(source.size).toBe(1024) expect(source.rangeRequestsCount).toBe(1) expect(source.cacheHitsCount).toBe(0) // Second read of same range hits chunk cache without invoking fetch const secondRead = await source.read(0, 32) expect(secondRead).toEqual(firstRead) expect(source.rangeRequestsCount).toBe(1) expect(source.cacheHitsCount).toBe(1) const manager = new OpfsMpqManager({ baseUrl: '/diablo2/data', cache, fetchFn: fetchSpy, }) expect(manager.getSource('d2sfx.mpq').url).toBe('/diablo2/data/d2sfx.mpq') }) it('maps Sounds.txt IDs, SoundEnviron.txt level music, spatial attenuation, and SettingsStore mute/volume', async () => { const store = new SettingsStore({ storage: new MemoryStorage(), defaultLang: 'zh', syncGlobalLang: false, }) const playedSfx: { id: string; volume: number }[] = [] const playedBgm: number[] = [] let mutedState = false const mockBackend: AudioBackendLike = { async init() {}, setMuted(m) { mutedState = m }, async playBGM(actOrId) { playedBgm.push(actOrId) }, playSFX(id, volume = 1) { playedSfx.push({ id, volume }) }, playButtonClick() { playedSfx.push({ id: 'button', volume: 1 }) }, playFootstep() {}, playPortalEnter() { playedSfx.push({ id: 'portal_enter', volume: 1 }) }, playWaypointActivate() { playedSfx.push({ id: 'waypoint_activate', volume: 1 }) }, } const soundService = new SoundService({ store, backend: mockBackend }) await soundService.init() const envAct4 = await soundService.onEnterArea(103) expect(envAct4.actId).toBe(4) expect(playedBgm).toEqual([4]) expect(resolveSoundEnvironmentForLevel(109).actId).toBe(5) // Play FireBall (47) near player const rec = soundService.playSoundById(47, { sourceSubX: 105, sourceSubY: 100, listenerSubX: 100, listenerSubY: 100, }) expect(rec).not.toBeNull() expect(rec!.sfxId).toBe('skill_fire') expect(rec!.pan).toBeGreaterThan(0) expect(playedSfx).toHaveLength(1) // Far-away emitter (>45 sub-tiles) is culled const culled = computeSpatialAudioMetrics(200, 200, 100, 100) expect(culled.culled).toBe(true) // Muting suppresses playback soundService.setMuted(true) expect(mutedState).toBe(true) expect(soundService.playSoundById(47)).toBeNull() soundService.destroy() }) }) describe('OutboundRateLimiter & PacketInspector / .d2cap Replay (F20)', () => { it('throttles rapid movement packets, coalesces trailing move target, and never drops Ping heartbeats', () => { const limiter = new OutboundRateLimiter({ moveMinIntervalMs: 120, skillMinIntervalMs: 100, burstCapacity: 5, refillPerSec: 10, }) const move1 = { type: 'RunToLocation', x: asSubTile(100), y: asSubTile(100) } as const const move2 = { type: 'RunToLocation', x: asSubTile(105), y: asSubTile(102) } as const // First movement at t=1000ms is allowed const d1 = limiter.tryAcquire(move1, 1000) expect(d1.allowed).toBe(true) // Identical duplicate move at t=1050ms is suppressed const dDup = limiter.tryAcquire(move1, 1050) expect(dDup.allowed).toBe(false) expect(dDup.reason).toBe('duplicate_move') // Different movement target at t=1060ms is throttled by cooldown and coalesced const d2 = limiter.tryAcquire(move2, 1060) expect(d2.allowed).toBe(false) expect(d2.reason).toBe('category_cooldown') expect(d2.coalesced).toBe(true) // At t=1130ms (after 120ms cooldown), flushPendingMove emits the coalesced move2 const flushed = limiter.flushPendingMove(1130) expect(flushed).toEqual(move2) // Ping heartbeat is always allowed expect(limiter.tryAcquire({ type: 'Ping' }, 1131).allowed).toBe(true) }) it('captures PacketTap traffic, filters by phase/direction/opcode, exports .d2cap JSONL, and replays deterministically', () => { const store = new SettingsStore({ storage: new MemoryStorage(), defaultLang: 'en', syncGlobalLang: false, }) const tap = new InMemoryPacketTap() const replayed: number[] = [] const inspector = new PacketInspector({ store, tap, onReplayRecord: frame => replayed.push(frame.opcode), }) tap.record({ t: 10, conn: 'bnet', dir: 'c2s', packetId: 0x50, raw: new Uint8Array([0xff, 0x50, 0x3a, 0x00]), }) tap.record({ t: 25, conn: 'game', dir: 's2c', packetId: 0x15, raw: new Uint8Array([0x15, 0x00, 0x01, 0x00, 0x00, 0x00]), }) tap.record({ t: 40, conn: 'game', dir: 'c2s', packetId: 0x03, raw: new Uint8Array([0x03, 0x64, 0x00, 0x64, 0x00]), }) expect(inspector.getAllRows()).toHaveLength(3) expect(formatHexDump(new Uint8Array([0x15, 0x41, 0x42]))).toContain('15 41 42') // Filter by d2gs + s2c inspector.setFilter({ phase: 'd2gs', direction: 's2c' }) const s2cRows = inspector.getFilteredRows() expect(s2cRows).toHaveLength(1) expect(s2cRows[0]!.opcode).toBe(0x15) // Filter by hex opcode string inspector.setFilter({ phase: 'all', direction: 'all', opcode: '0x03' }) expect(inspector.getFilteredRows()).toHaveLength(1) // Export to .d2cap JSONL and replay into a fresh inspector const jsonl = inspector.exportD2CapJsonl() expect(jsonl.split('\n')).toHaveLength(3) inspector.clear() inspector.setFilter({ phase: 'all', direction: 'all', opcode: null }) const loaded = inspector.loadD2CapJsonl(jsonl) expect(loaded).toHaveLength(3) const step1 = inspector.stepReplay() expect(step1?.opcode).toBe(0x50) expect(replayed).toEqual([0x50]) const remaining = inspector.replayAll() expect(remaining).toBe(2) expect(replayed).toEqual([0x50, 0x15, 0x03]) inspector.destroy() }) })