281 lines
12 KiB
JavaScript
281 lines
12 KiB
JavaScript
const test = require('node:test')
|
|
const assert = require('node:assert/strict')
|
|
const fs = require('node:fs')
|
|
const path = require('node:path')
|
|
const vm = require('node:vm')
|
|
|
|
const root = path.resolve(__dirname, '..')
|
|
const native = path.join(root, 'native/tang-detective')
|
|
const adapter = path.join(root, 'native-adapter/tang-detective')
|
|
const settle = () => new Promise(resolve => setImmediate(resolve))
|
|
|
|
function fixture(relative, customOptions) {
|
|
let page
|
|
let finishBoot
|
|
let timerId = 0
|
|
const state = { scope: 'account-A', events: [], audio: [], timers: new Map(), updates: 0, saves: [], resets: [], modals: [], redirects: [] }
|
|
const boot = new Promise(resolve => { finishBoot = resolve })
|
|
const bridge = { getScope: () => state.scope, open: () => boot, flush: () => state.events.push('flush') }
|
|
const storage = {
|
|
getProgress: () => ({ completedHotspots: {}, completedChapters: [], lastChapter: 1, collectedMemoryCards: [], comicReaderByChapter: {} }),
|
|
saveProgress: value => { state.saves.push({ kind: 'progress', scope: state.scope, value }); return true },
|
|
getSettings: () => ({ fontScale: 'large', sound: true }),
|
|
saveSettings: () => true,
|
|
getAudioProgress: () => ({}),
|
|
saveAudioProgress: value => { state.saves.push({ kind: 'audio', scope: state.scope, value }); return true },
|
|
resetStoryProgress: scope => { state.resets.push(scope); return scope === state.scope },
|
|
}
|
|
const beginHandlers = new Set()
|
|
const endHandlers = new Set()
|
|
const wx = {
|
|
env: { USER_DATA_PATH: '' },
|
|
getWindowInfo: () => ({ windowWidth: 812, windowHeight: 375, screenWidth: 812, screenHeight: 375, safeArea: { top: 0, left: 0, right: 812, bottom: 375 } }),
|
|
getMenuButtonBoundingClientRect: () => ({ top: 8, bottom: 40, left: 724, right: 804, width: 80, height: 32 }),
|
|
pageScrollTo() {},
|
|
reLaunch: value => state.redirects.push(value),
|
|
redirectTo: value => state.redirects.push(value),
|
|
navigateTo: value => state.redirects.push(value),
|
|
showToast: value => state.events.push(value.title),
|
|
showModal: value => state.modals.push(value),
|
|
onAudioInterruptionBegin: handler => beginHandlers.add(handler),
|
|
onAudioInterruptionEnd: handler => endHandlers.add(handler),
|
|
offAudioInterruptionBegin: handler => { beginHandlers.delete(handler); state.events.push('unbind-begin') },
|
|
offAudioInterruptionEnd: handler => { endHandlers.delete(handler); state.events.push('unbind-end') },
|
|
createInnerAudioContext() {
|
|
const audio = { handlers: {}, duration: 90, currentTime: 0, playbackRate: 1, plays: 0, pauses: 0, destroys: 0,
|
|
play() { this.plays += 1 }, pause() { this.pauses += 1 }, stop() {},
|
|
seek(value) { this.currentTime = value }, destroy() { this.destroys += 1 } }
|
|
for (const name of ['Canplay', 'Play', 'Pause', 'Stop', 'Waiting', 'TimeUpdate', 'Ended', 'Error', 'Seeked']) {
|
|
audio[`on${name}`] = handler => { audio.handlers[name] = handler }
|
|
}
|
|
state.audio.push(audio)
|
|
return audio
|
|
},
|
|
}
|
|
const cache = new Map()
|
|
function capture(options) {
|
|
page = { ...options, data: JSON.parse(JSON.stringify(options.data || {})), setData(values) {
|
|
state.updates += 1
|
|
for (const [key, value] of Object.entries(values)) {
|
|
const parts = key.split('.')
|
|
let target = this.data
|
|
for (const part of parts.slice(0, -1)) target = target[part] || (target[part] = {})
|
|
target[parts.at(-1)] = value
|
|
}
|
|
} }
|
|
}
|
|
let register
|
|
function load(filename, isPage = false) {
|
|
filename = path.resolve(filename)
|
|
if (filename.endsWith('/utils/storage.js')) return storage
|
|
if (filename.endsWith('/utils/platformBridge.js')) return bridge
|
|
if (cache.has(filename)) return cache.get(filename).exports
|
|
const module = { exports: {} }
|
|
cache.set(filename, module)
|
|
let source = fs.readFileSync(filename, 'utf8')
|
|
if (isPage) source = source.replace(/^Page\(\{/m, 'registerTangPage({')
|
|
vm.runInNewContext(source, {
|
|
module, exports: module.exports, Page: capture, registerTangPage: register, wx,
|
|
getCurrentPages: () => [page],
|
|
setTimeout: callback => { const id = ++timerId; state.timers.set(id, callback); return id },
|
|
clearTimeout: id => { state.timers.delete(id); state.events.push(`clear:${id}`) },
|
|
require(specifier) {
|
|
let dependency = path.resolve(path.dirname(filename), specifier)
|
|
if (!path.extname(dependency)) dependency += '.js'
|
|
// Catalog uses the final route adapter; all chapter/player helpers and
|
|
// data are real original modules, with only platform I/O mocked.
|
|
if (!fs.existsSync(dependency) && dependency.startsWith(adapter + path.sep)) {
|
|
dependency = path.join(native, path.relative(adapter, dependency))
|
|
}
|
|
return load(dependency)
|
|
},
|
|
}, { filename })
|
|
return module.exports
|
|
}
|
|
register = load(path.join(adapter, 'utils/tangPage.js'))
|
|
if (customOptions) register(customOptions(state))
|
|
else load(path.join(relative.startsWith('pages/catalog/') ? adapter : native, relative), true)
|
|
return { page, state, bridge, finishBoot, beginHandlers, endHandlers }
|
|
}
|
|
|
|
async function open(f, query = {}) {
|
|
f.page.onLoad(query)
|
|
f.page.onShow()
|
|
f.page.onReady()
|
|
f.finishBoot()
|
|
await settle()
|
|
}
|
|
|
|
test('deferred lifecycle delivers onLoad -> onShow -> onReady once and guards custom onX events', async () => {
|
|
const f = fixture(null, state => ({
|
|
onLoad() { state.events.push('load') }, onShow() { state.events.push('show') }, onReady() { state.events.push('ready') },
|
|
onAudioTimeUpdate() { state.events.push('audio-event') },
|
|
}))
|
|
f.page.onLoad({})
|
|
f.page.onShow()
|
|
f.page.onReady()
|
|
f.page.onAudioTimeUpdate()
|
|
f.page.onHide()
|
|
f.page.onShow()
|
|
f.finishBoot()
|
|
await settle()
|
|
assert.deepEqual(f.state.events, ['flush', 'load', 'show', 'ready'])
|
|
f.page.onReady()
|
|
f.page.onAudioTimeUpdate()
|
|
assert.equal(f.state.events.at(-1), 'audio-event')
|
|
f.page.onHide()
|
|
const count = f.state.events.length
|
|
f.page.onAudioTimeUpdate()
|
|
assert.equal(f.state.events.length, count)
|
|
f.page.onShow()
|
|
await settle()
|
|
assert.equal(f.state.events.filter(item => item === 'ready').length, 1)
|
|
f.state.scope = 'account-B'
|
|
f.page.onAudioTimeUpdate()
|
|
assert.equal(f.state.events.filter(item => item === 'audio-event').length, 1)
|
|
assert.equal(f.state.redirects.length, 1)
|
|
})
|
|
|
|
for (const [directory, pageId] of [['package-audio-c01-a', 'S01-C01-P01'], ['package-audio-c01-b', 'S01-C01-P05']]) {
|
|
test(`${directory}: real onReady waits for _page and unload clears both timers, context, and listeners`, async () => {
|
|
const f = fixture(`${directory}/pages/player/player.js`)
|
|
f.page.onLoad({ pageId })
|
|
f.page.onShow()
|
|
f.page.onReady()
|
|
assert.equal(f.state.audio.length, 0)
|
|
f.finishBoot()
|
|
await settle()
|
|
assert.equal(f.page.data.audioReady, true)
|
|
assert.equal(f.state.audio.length, 1)
|
|
const context = f.state.audio[0]
|
|
f.page.onReady()
|
|
assert.equal(f.state.audio.length, 1)
|
|
f.page.requestSeek(10)
|
|
f.page.beginPauseLock(context)
|
|
assert.equal(f.state.timers.size, 2)
|
|
const staleTimers = [...f.state.timers.values()]
|
|
const staleAudio = Object.values(context.handlers)
|
|
f.page.onUnload()
|
|
assert.equal(context.destroys, 1)
|
|
assert.equal(f.state.timers.size, 0)
|
|
assert.equal(f.beginHandlers.size, 0)
|
|
assert.equal(f.endHandlers.size, 0)
|
|
assert.equal(f.page.audioContext, null)
|
|
const updates = f.state.updates
|
|
for (const callback of [...staleTimers, ...staleAudio]) callback()
|
|
assert.equal(context.plays, 0)
|
|
assert.equal(f.state.updates, updates)
|
|
f.page.onUnload()
|
|
assert.equal(context.destroys, 1)
|
|
})
|
|
|
|
test(`${directory}: hidden boot never initializes audio until return; changed-account hide retires old audio`, async () => {
|
|
const f = fixture(`${directory}/pages/player/player.js`)
|
|
f.page.onLoad({ pageId })
|
|
f.page.onShow()
|
|
f.page.onReady()
|
|
f.page.onHide()
|
|
f.finishBoot()
|
|
await settle()
|
|
assert.equal(f.state.audio.length, 0)
|
|
f.page.onShow()
|
|
await settle()
|
|
assert.equal(f.page.data.audioReady, true)
|
|
const context = f.state.audio[0]
|
|
f.page.togglePlayback()
|
|
assert.equal(context.plays, 1)
|
|
f.state.scope = 'account-B'
|
|
f.page.onHide()
|
|
assert.ok(context.pauses >= 1)
|
|
assert.equal(context.destroys, 1)
|
|
assert.equal(f.state.timers.size, 0)
|
|
assert.equal(f.beginHandlers.size + f.endHandlers.size, 0)
|
|
const updates = f.state.updates
|
|
for (const callback of Object.values(context.handlers)) callback()
|
|
assert.equal(context.plays, 1)
|
|
assert.equal(f.state.updates, updates)
|
|
f.state.scope = 'account-A'
|
|
f.page.onShow()
|
|
await settle()
|
|
assert.equal(f.state.audio.length, 1)
|
|
assert.equal(f.state.redirects.length, 1)
|
|
f.page.onUnload()
|
|
assert.equal(context.destroys, 1)
|
|
})
|
|
}
|
|
|
|
test('real chapter unload destroys audio and stale callbacks cannot save or update', async () => {
|
|
const f = fixture('package-game/pages/chapter/chapter.js')
|
|
await open(f, { chapter: 1 })
|
|
f.page.createAudioContext({ src: '/example.mp3', progressKey: 'page:S01-C01-P01', durationSeconds: 40 })
|
|
const context = f.state.audio[0]
|
|
const staleAudio = Object.values(context.handlers)
|
|
f.page.onUnload()
|
|
assert.equal(context.destroys, 1)
|
|
assert.equal(f.page.audioContext, null)
|
|
assert.equal(f.state.saves.filter(item => item.kind === 'audio').length, 1)
|
|
const updates = f.state.updates
|
|
const saves = f.state.saves.length
|
|
for (const callback of staleAudio) callback()
|
|
assert.equal(f.state.updates, updates)
|
|
assert.equal(f.state.saves.length, saves)
|
|
assert.equal(context.plays, 0)
|
|
})
|
|
|
|
test('real chapter account-change hide cleans resources without writing previous audio progress into next account', async () => {
|
|
const f = fixture('package-game/pages/chapter/chapter.js')
|
|
await open(f, { chapter: 1 })
|
|
f.page.createAudioContext({ src: '/example.mp3', progressKey: 'page:S01-C01-P01', durationSeconds: 40 })
|
|
const context = f.state.audio[0]
|
|
f.state.scope = 'account-B'
|
|
f.page.onHide()
|
|
assert.equal(context.destroys, 1)
|
|
assert.equal(f.page.audioContext, null)
|
|
assert.equal(f.state.saves.filter(item => item.scope === 'account-B').length, 0)
|
|
context.handlers.Ended()
|
|
assert.equal(f.state.saves.filter(item => item.scope === 'account-B').length, 0)
|
|
})
|
|
|
|
test('unload before hydration prevents late onLoad/onShow/onReady and playback', async () => {
|
|
const f = fixture('package-audio-c01-a/pages/player/player.js')
|
|
f.page.onLoad({ pageId: 'S01-C01-P01' })
|
|
f.page.onShow()
|
|
f.page.onReady()
|
|
f.page.onUnload()
|
|
f.finishBoot()
|
|
await settle()
|
|
assert.equal(f.state.audio.length, 0)
|
|
assert.equal(f.beginHandlers.size, 0)
|
|
})
|
|
|
|
test('catalog reset requires unchanged account and the same visible page lifetime', async () => {
|
|
for (const transition of ['none', 'account', 'hidden', 'returned', 'unloaded']) {
|
|
const f = fixture('pages/catalog/catalog.js')
|
|
await open(f)
|
|
f.page.restartStory()
|
|
assert.equal(f.state.modals.length, 1)
|
|
if (transition === 'account') f.state.scope = 'account-B'
|
|
if (transition === 'hidden' || transition === 'returned') f.page.onHide()
|
|
if (transition === 'returned') { f.page.onShow(); await settle() }
|
|
if (transition === 'unloaded') f.page.onUnload()
|
|
f.state.modals[0].success({ confirm: true })
|
|
assert.deepEqual(f.state.resets, transition === 'none' ? ['account-A'] : [], transition)
|
|
assert.equal(f.state.redirects.length, transition === 'none' ? 1 : 0, transition)
|
|
}
|
|
})
|
|
|
|
test('cleanup permission ends synchronously even when the original unload throws', async () => {
|
|
const f = fixture(null, state => ({
|
|
onUnload() { this.clearTimers(); throw new Error('cleanup failed') },
|
|
clearTimers() { state.events.push('clear') },
|
|
onAudioTimeUpdate() { state.events.push('late-event') },
|
|
}))
|
|
await open(f)
|
|
assert.throws(() => f.page.onUnload(), /cleanup failed/)
|
|
assert.equal(f.page.__tangCleaning, false)
|
|
assert.deepEqual(f.state.events, ['clear', 'flush'])
|
|
f.page.clearTimers()
|
|
f.page.onAudioTimeUpdate()
|
|
assert.deepEqual(f.state.events, ['clear', 'flush'])
|
|
})
|