293 lines
11 KiB
TypeScript
293 lines
11 KiB
TypeScript
import { readFileSync } from 'node:fs'
|
|
import { resolve } from 'node:path'
|
|
import { afterEach, describe, expect, it } from 'vitest'
|
|
import {
|
|
BootProgressTracker,
|
|
extractAssetFileName,
|
|
extractAssetResourceType,
|
|
normalizeAssetPath,
|
|
type BootProgressSnapshot,
|
|
} from '../src/client/ui/boot-progress.ts'
|
|
|
|
const ROOT_DIR = resolve(__dirname, '..')
|
|
|
|
interface MockElement {
|
|
id: string
|
|
hidden: boolean
|
|
textContent: string
|
|
innerHTML: string
|
|
style: { width: string; display: string }
|
|
attributes: Map<string, string>
|
|
setAttribute(name: string, val: string): void
|
|
removeAttribute(name: string): void
|
|
getAttribute(name: string): string | null
|
|
appendChild(child: MockElement): MockElement
|
|
}
|
|
|
|
function createMockDomEnvironment(): {
|
|
readonly stageEl: HTMLElement
|
|
readonly elementsById: Map<string, MockElement>
|
|
readonly cleanup: () => void
|
|
} {
|
|
const elementsById = new Map<string, MockElement>()
|
|
|
|
const makeEl = (id = ''): MockElement => {
|
|
const el: MockElement = {
|
|
id,
|
|
hidden: false,
|
|
textContent: '',
|
|
innerHTML: '',
|
|
style: { width: '0%', display: 'flex' },
|
|
attributes: new Map<string, string>(),
|
|
setAttribute(name: string, val: string) {
|
|
this.attributes.set(name, val)
|
|
},
|
|
removeAttribute(name: string) {
|
|
this.attributes.delete(name)
|
|
},
|
|
getAttribute(name: string) {
|
|
return this.attributes.get(name) ?? null
|
|
},
|
|
appendChild(child: MockElement) {
|
|
if (child.id) {
|
|
elementsById.set(child.id, child)
|
|
}
|
|
return child
|
|
},
|
|
}
|
|
if (id) {
|
|
elementsById.set(id, el)
|
|
}
|
|
return el
|
|
}
|
|
|
|
// Pre-register the boot progress DOM elements matching play.html
|
|
makeEl('d2-boot-loading')
|
|
makeEl('d2-boot-progress-bar')
|
|
makeEl('d2-boot-progress-fill')
|
|
makeEl('d2-boot-progress-percent')
|
|
makeEl('d2-boot-progress-file')
|
|
makeEl('d2-boot-progress-count')
|
|
const stageEl = makeEl('play-stage') as unknown as HTMLElement
|
|
|
|
const prevDoc = (globalThis as Record<string, unknown>).document
|
|
const prevWin = (globalThis as Record<string, unknown>).window
|
|
|
|
;(globalThis as Record<string, unknown>).document = {
|
|
getElementById(id: string) {
|
|
return (elementsById.get(id) ?? null) as unknown as HTMLElement | null
|
|
},
|
|
createElement() {
|
|
return makeEl() as unknown as HTMLElement
|
|
},
|
|
}
|
|
;(globalThis as Record<string, unknown>).window = globalThis
|
|
|
|
return {
|
|
stageEl,
|
|
elementsById,
|
|
cleanup: () => {
|
|
if (prevDoc === undefined) {
|
|
delete (globalThis as Record<string, unknown>).document
|
|
} else {
|
|
;(globalThis as Record<string, unknown>).document = prevDoc
|
|
}
|
|
if (prevWin === undefined) {
|
|
delete (globalThis as Record<string, unknown>).window
|
|
} else {
|
|
;(globalThis as Record<string, unknown>).window = prevWin
|
|
}
|
|
},
|
|
}
|
|
}
|
|
|
|
describe('Client Boot Resource Download Progress Bar (boot-progress)', () => {
|
|
let cleanupDom: (() => void) | null = null
|
|
|
|
afterEach(() => {
|
|
if (cleanupDom) {
|
|
cleanupDom()
|
|
cleanupDom = null
|
|
}
|
|
})
|
|
|
|
it('extractAssetFileName extracts clean filenames and strips query/hash fragments', () => {
|
|
expect(extractAssetFileName('/act-packs/tiles/drlg-bundle.bin')).toBe('drlg-bundle.bin')
|
|
expect(extractAssetFileName('samples/d2-packs/items/items-atlas.png?v=123')).toBe(
|
|
'items-atlas.png',
|
|
)
|
|
expect(extractAssetFileName('http://127.0.0.1:5173/ui/frontend/bg-main-menu.png#top')).toBe(
|
|
'bg-main-menu.png',
|
|
)
|
|
expect(extractAssetFileName('data\\global\\tiles\\act1\\town\\town.dt1')).toBe('town.dt1')
|
|
expect(extractAssetFileName('data:image/png;base64,iVBORw0KGgo=')).toBe('')
|
|
expect(extractAssetFileName('blob:http://127.0.0.1:5173/abc-123')).toBe('')
|
|
expect(extractAssetFileName('')).toBe('')
|
|
})
|
|
|
|
it('extractAssetResourceType classifies asset paths into semantic resource types instead of raw filenames', () => {
|
|
expect(extractAssetResourceType('/ui/fonts/metrics.json')).toBe('字体资源')
|
|
expect(extractAssetResourceType('/ui/frontend/fonts/fontformal12-c4.png')).toBe('字体资源')
|
|
expect(extractAssetResourceType('/act-packs/tiles/drlg-bundle.bin')).toBe('地图地形资源')
|
|
expect(extractAssetResourceType('data\\global\\tiles\\act1\\town\\town.dt1')).toBe('地图地形资源')
|
|
expect(extractAssetResourceType('http://127.0.0.1:5173/ui/frontend/bg-main-menu.png#top')).toBe(
|
|
'主菜单与大厅界面',
|
|
)
|
|
expect(extractAssetResourceType('/ui/ctrlpnl-800.png')).toBe('游戏界面资源')
|
|
expect(extractAssetResourceType('samples/d2-packs/items/items-atlas.png?v=123')).toBe(
|
|
'物品图标资源',
|
|
)
|
|
expect(extractAssetResourceType('/skills/icon_36.png')).toBe('技能图标资源')
|
|
expect(extractAssetResourceType('/assets/ui/automap/MaxiMap.png')).toBe('小地图资源')
|
|
expect(extractAssetResourceType('samples/d2-packs/entities/char-so.r8')).toBe('角色美术资源')
|
|
expect(extractAssetResourceType('samples/d2-packs/entities/monster-fallen1-hth.r8')).toBe(
|
|
'怪物美术资源',
|
|
)
|
|
expect(extractAssetResourceType('/missiles/firebolt.png')).toBe('投射物特效资源')
|
|
expect(extractAssetResourceType('/overlays/AuraDefianceBack.png')).toBe('光环与状态特效')
|
|
expect(extractAssetResourceType('./asset-manifest.json')).toBe('资源配置数据')
|
|
expect(extractAssetResourceType('data:image/png;base64,iVBORw0KGgo=')).toBe('')
|
|
expect(extractAssetResourceType('blob:http://127.0.0.1:5173/abc-123')).toBe('')
|
|
expect(extractAssetResourceType('')).toBe('')
|
|
})
|
|
|
|
it('normalizeAssetPath strips origin, query, and hash while preserving path', () => {
|
|
expect(normalizeAssetPath('http://127.0.0.1:5173/ui/frontend/bg-main-menu.png?t=1#a')).toBe(
|
|
'/ui/frontend/bg-main-menu.png',
|
|
)
|
|
expect(normalizeAssetPath('/act-packs/tiles/index.json')).toBe('/act-packs/tiles/index.json')
|
|
})
|
|
|
|
it('tracks downloading resource types (instead of raw filenames), monotonic percentage (0% -> 100%), and updates DOM elements', () => {
|
|
const dom = createMockDomEnvironment()
|
|
cleanupDom = dom.cleanup
|
|
|
|
const tracker = new BootProgressTracker({
|
|
stageEl: dom.stageEl,
|
|
expectedTotal: 4,
|
|
}).install()
|
|
|
|
const snapshots: BootProgressSnapshot[] = []
|
|
tracker.subscribe((snap) => snapshots.push(snap))
|
|
|
|
const overlayEl = dom.elementsById.get('d2-boot-loading')!
|
|
const percentEl = dom.elementsById.get('d2-boot-progress-percent')!
|
|
const fillEl = dom.elementsById.get('d2-boot-progress-fill')!
|
|
const fileEl = dom.elementsById.get('d2-boot-progress-file')!
|
|
|
|
expect(overlayEl.hidden).toBe(false)
|
|
expect(percentEl.textContent).toBe('0%')
|
|
|
|
// Start downloading file 1 & file 2 concurrently -> displays resource types instead of filenames
|
|
const id1 = tracker.beginFile('/ui/fonts/metrics.json')
|
|
expect(tracker.getSnapshot().currentFile).toBe('字体资源')
|
|
expect(tracker.getSnapshot().currentResourceType).toBe('字体资源')
|
|
expect(fileEl.textContent).toBe('字体资源')
|
|
expect(fileEl.getAttribute('title')).toBe('字体资源')
|
|
|
|
const id2 = tracker.beginFile('/act-packs/tiles/drlg-bundle.bin')
|
|
expect(tracker.getSnapshot().currentFile).toBe('地图地形资源')
|
|
expect(fileEl.textContent).toBe('地图地形资源')
|
|
|
|
// Finish file 2 while file 1 is still in flight -> currentFile falls back to in-flight file 1's resource type
|
|
tracker.endFile(id2)
|
|
expect(tracker.getSnapshot().percent).toBe(25)
|
|
expect(tracker.getSnapshot().currentFile).toBe('字体资源')
|
|
expect(percentEl.textContent).toBe('25%')
|
|
expect(fillEl.style.width).toBe('25%')
|
|
|
|
// Finish file 1
|
|
tracker.endFile(id1)
|
|
expect(tracker.getSnapshot().percent).toBe(50)
|
|
expect(percentEl.textContent).toBe('50%')
|
|
|
|
// Start & finish file 3 & file 4
|
|
const id3 = tracker.beginFile('/ui/frontend/bg-main-menu.png')
|
|
expect(fileEl.textContent).toBe('主菜单与大厅界面')
|
|
tracker.endFile(id3)
|
|
expect(tracker.getSnapshot().percent).toBe(75)
|
|
|
|
const id4 = tracker.beginFile('samples/d2-packs/items/items-atlas.png')
|
|
expect(fileEl.textContent).toBe('物品图标资源')
|
|
tracker.endFile(id4)
|
|
expect(tracker.getSnapshot().percent).toBe(99)
|
|
|
|
// Complete boot
|
|
tracker.complete()
|
|
const finalSnap = tracker.getSnapshot()
|
|
expect(finalSnap.percent).toBe(100)
|
|
expect(finalSnap.status).toBe('completed')
|
|
expect(finalSnap.loadedCount).toBe(4)
|
|
expect(finalSnap.loadedFiles).toEqual([
|
|
'地图地形资源',
|
|
'字体资源',
|
|
'主菜单与大厅界面',
|
|
'物品图标资源',
|
|
])
|
|
expect(percentEl.textContent).toBe('100%')
|
|
expect(fillEl.style.width).toBe('100%')
|
|
expect(overlayEl.hidden).toBe(true)
|
|
|
|
// Verify percentage is strictly monotonic across all emitted snapshots
|
|
for (let i = 1; i < snapshots.length; i++) {
|
|
expect(snapshots[i]!.percent).toBeGreaterThanOrEqual(snapshots[i - 1]!.percent)
|
|
}
|
|
})
|
|
|
|
it('intercepts global fetch calls during boot and restores original fetch upon complete()', async () => {
|
|
const dom = createMockDomEnvironment()
|
|
cleanupDom = dom.cleanup
|
|
|
|
const mockFetch = async (input: RequestInfo | URL): Promise<Response> => {
|
|
const url = String(input)
|
|
return new Response(JSON.stringify({ url }), {
|
|
status: 200,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
})
|
|
}
|
|
const prevFetch = globalThis.fetch
|
|
globalThis.fetch = mockFetch as typeof globalThis.fetch
|
|
|
|
try {
|
|
const tracker = new BootProgressTracker({
|
|
stageEl: dom.stageEl,
|
|
expectedTotal: 2,
|
|
}).install()
|
|
|
|
expect(globalThis.fetch).not.toBe(mockFetch)
|
|
|
|
const res1 = await globalThis.fetch('/act-packs/tiles/index.json')
|
|
expect(tracker.getSnapshot().currentFile).toBe('地图地形资源')
|
|
await res1.json()
|
|
expect(tracker.getSnapshot().loadedCount).toBe(1)
|
|
expect(tracker.getSnapshot().percent).toBe(50)
|
|
|
|
const res2 = await globalThis.fetch('/ui/frontend/manifest.json')
|
|
expect(tracker.getSnapshot().currentFile).toBe('主菜单与大厅界面')
|
|
await res2.json()
|
|
expect(tracker.getSnapshot().loadedCount).toBe(2)
|
|
|
|
await tracker.waitForIdle(200)
|
|
tracker.complete()
|
|
|
|
expect(tracker.getSnapshot().percent).toBe(100)
|
|
expect(globalThis.fetch).toBe(mockFetch)
|
|
} finally {
|
|
globalThis.fetch = prevFetch
|
|
}
|
|
})
|
|
|
|
it('play.html includes #d2-boot-loading progress bar elements and main.ts wires BootProgressTracker', () => {
|
|
const playHtml = readFileSync(resolve(ROOT_DIR, 'play.html'), 'utf8')
|
|
expect(playHtml).toContain('id="d2-boot-loading"')
|
|
expect(playHtml).toContain('id="d2-boot-progress-bar"')
|
|
expect(playHtml).toContain('id="d2-boot-progress-fill"')
|
|
expect(playHtml).toContain('id="d2-boot-progress-percent"')
|
|
expect(playHtml).toContain('id="d2-boot-progress-file"')
|
|
|
|
const mainTs = readFileSync(resolve(ROOT_DIR, 'src/client/main.ts'), 'utf8')
|
|
expect(mainTs).toContain('BootProgressTracker')
|
|
expect(mainTs).toContain('bootProgress.complete()')
|
|
})
|
|
})
|