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