Files
xuetang/TongjiUniApp/scripts/test-tang-page-lifecycle.cjs
2026-09-09 14:47:29 +08:00

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'])
})