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 ts = require('typescript') const root = path.resolve(__dirname, '..') function load(file, dependencies = {}, globals = {}) { const exports = {} vm.runInNewContext(ts.transpileModule(fs.readFileSync(path.join(root, file), 'utf8'), { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2019 } }).outputText, { exports, require: id => dependencies[id], setTimeout, clearTimeout, ...globals }) return exports } const ritualModule = load('src/utils/incenseRitual.ts') const { createIncenseRitual, INCENSE_DURATION_MS } = ritualModule const { createTiangongMock } = load('src/utils/tiangongMock.ts', { './incenseRitual': ritualModule }) function harness() { let time = 0, nextTimer = 0, enabled = true, errors = 0, refreshed = 0, creationFails = false const timers = new Map(), audios = [] const mock = createTiangongMock(() => time) const scheduler = { now: () => time, schedule: (callback, delay) => { const id = ++nextTimer; timers.set(id, { callback, at: time + delay }); return id }, cancel: id => { timers.delete(id) } } const create = () => createIncenseRitual({ ...scheduler, getDeadline: () => mock.snapshot().deities.wealth.incenseUntil, recordIncense: () => { mock.incense('wealth') }, refresh: () => { refreshed++; mock.refreshIncense('wealth') }, soundEnabled: () => enabled, createAudio: () => { if (creationFails) throw new Error('unavailable') const startedAt = time const audio = { plays: 0, pauses: 0, stops: 0, destroys: 0, volume: 1, get currentTime() { return Math.min(15, (time - startedAt) / 1000) }, play() { this.plays++ }, pause() { this.pauses++ }, stop() { this.stops++ }, destroy() { this.destroys++ }, onEnded(callback) { this.ended = callback }, onError(callback) { this.error = callback } } audios.push(audio) return audio }, onAudioError: () => { errors++ } }) const ritual = create() const advance = milliseconds => { const target = time + milliseconds while (true) { const next = [...timers.entries()].filter(([, timer]) => timer.at <= target).sort((a, b) => a[1].at - b[1].at)[0] if (!next) break time = next[1].at; timers.delete(next[0]); next[1].callback() } time = target } return { ritual, create, mock, audios, advance, timers, mute: value => { enabled = value; if (!value) ritual.mute(); else ritual.resume() }, creationFails: value => { creationFails = value }, errors: () => errors, refreshed: () => refreshed } } test('首次上香播放一次,15秒后恢复供桌,次数不回退', () => { const h = harness() h.ritual.tap() assert.equal(h.mock.snapshot().deities.wealth.incenseCount, 60) assert.equal(h.mock.snapshot().deities.wealth.lit, true) assert.equal(h.audios.length, 1); assert.equal(h.audios[0].plays, 1) h.advance(INCENSE_DURATION_MS - 1) assert.equal(h.mock.snapshot().deities.wealth.lit, true) h.advance(1) assert.equal(h.mock.snapshot().deities.wealth.lit, false) assert.equal(h.mock.snapshot().deities.wealth.incenseCount, 60) assert.equal(h.refreshed(), 1) assert.equal(h.audios[0].destroys, 1) }) test('重复上香每次+1并按最后点击延长15秒,但音频不重播或续播', () => { const h = harness() h.ritual.tap(); h.advance(10000); h.ritual.tap() assert.equal(h.mock.snapshot().deities.wealth.incenseUntil, 25000) assert.equal(h.mock.snapshot().deities.wealth.incenseCount, 61) h.advance(5000) assert.equal(h.audios[0].destroys, 1) assert.equal(h.mock.snapshot().deities.wealth.lit, true) h.advance(5000); h.ritual.tap() assert.equal(h.audios.length, 1) h.advance(14999); assert.equal(h.mock.snapshot().deities.wealth.lit, true) h.advance(1); assert.equal(h.mock.snapshot().deities.wealth.lit, false) assert.equal(h.mock.snapshot().deities.wealth.incenseCount, 62) h.ritual.tap(); assert.equal(h.audios.length, 2) }) test('关闭立即静音但进度继续,重新开启接实际进度,不重播不增加次数', () => { const h = harness() h.ritual.tap(); h.advance(3000); h.mute(false) assert.equal(h.audios[0].volume, 0); assert.equal(h.audios[0].pauses, 0); assert.equal(h.audios[0].destroys, 0) h.advance(2000); h.mute(true) assert.equal(h.audios[0].volume, 1); assert.equal(h.audios[0].currentTime, 5) assert.equal(h.audios[0].plays, 1); assert.equal(h.mock.snapshot().deities.wealth.incenseCount, 60) h.ritual.tap(); assert.equal(h.audios.length, 1) h.advance(15000); h.ritual.tap(); assert.equal(h.audios.length, 2) }) test('静音上香以音量0推进,轮次内开启按实际进度出声,过期不补播', () => { const h = harness() h.mute(false); h.ritual.tap() assert.equal(h.audios.length, 1); assert.equal(h.audios[0].volume, 0) assert.equal(h.mock.snapshot().deities.wealth.incenseCount, 60) h.advance(7000); h.mute(true); h.ritual.tap(); assert.equal(h.audios.length, 1) assert.equal(h.audios[0].currentTime, 7); assert.equal(h.audios[0].volume, 1) h.mute(false); h.advance(15000); h.mute(true); assert.equal(h.audios.length, 1) h.ritual.tap(); assert.equal(h.audios.length, 2) }) test('播放失败不回退计数、不重复播放,也不妨碍15秒状态恢复', () => { const h = harness() h.ritual.tap(); h.audios[0].error() assert.equal(h.errors(), 1); assert.equal(h.audios[0].destroys, 1) h.audios[0].error(); assert.equal(h.errors(), 1) h.ritual.tap(); assert.equal(h.audios.length, 1) h.advance(15000); assert.equal(h.mock.snapshot().deities.wealth.lit, false) h.creationFails(true); h.ritual.tap(); assert.equal(h.errors(), 2) assert.equal(h.mock.snapshot().deities.wealth.incenseCount, 62) h.advance(15000); assert.equal(h.mock.snapshot().deities.wealth.lit, false) }) test('静音期间自然结束,延长点亮也不能让开关重新播放已结束的音频', () => { const h = harness() h.ritual.tap(); h.advance(3000); h.mute(false) h.advance(7000); h.ritual.tap(); h.advance(5000) assert.equal(h.audios[0].destroys, 1); assert.equal(h.mock.snapshot().deities.wealth.lit, true) h.mute(false); h.mute(true); assert.equal(h.audios.length, 1) assert.equal(h.audios[0].plays, 1) h.advance(10000); h.ritual.tap(); assert.equal(h.audios.length, 2) }) test('异步失败不播音,离开后成功回包也不产生后台声音', async () => { let resolve, reject, deadline = 0, creations = 0 const response = () => new Promise((ok, fail) => { resolve = ok; reject = fail }) const ritual = createIncenseRitual({ now: () => 1000, getDeadline: () => deadline, recordIncense: response, refresh() {}, soundEnabled: () => true, createAudio: () => { creations++; throw new Error('不应该创建') }, onAudioError() {} }) const failed = ritual.tap(); reject(new Error('失败')); await assert.rejects(failed, /失败/) assert.equal(creations, 0) const pending = ritual.tap(); ritual.dispose(); deadline = 16000; resolve({ startsRound: true }); await pending assert.equal(creations, 0) }) test('音频结束回调被延迟时,重新开声仍按15秒截止时间判断,不恢复过期声音', () => { const h = harness() h.ritual.tap(); h.advance(10000); h.ritual.tap(); h.mute(false) h.timers.clear(); h.advance(6000); h.mute(true) assert.equal(h.mock.snapshot().deities.wealth.lit, true) assert.equal(h.audios[0].volume, 0); assert.equal(h.audios[0].destroys, 1) assert.equal(h.audios[0].plays, 1); assert.equal(h.audios.length, 1) h.ritual.dispose() }) test('音频自然结束不提前熄香,旧回调不会停止下一轮音频', () => { const h = harness() h.ritual.tap(); h.advance(12000); h.audios[0].ended() assert.equal(h.mock.snapshot().deities.wealth.lit, true) h.advance(3000); h.ritual.tap() h.audios[0].ended(); h.audios[0].error() assert.equal(h.audios[1].stops, 0); assert.equal(h.errors(), 0) }) test('离开清理声音和定时器,返回只恢复剩余视觉计时,不自动播放', () => { const h = harness() h.ritual.tap(); h.advance(3000); h.ritual.dispose() assert.equal(h.timers.size, 0); assert.equal(h.audios[0].destroys, 1) h.advance(2000) const returning = h.create(); returning.sync() assert.equal(h.audios.length, 1) assert.equal(h.mock.snapshot().deities.wealth.lit, true) h.advance(10000); assert.equal(h.mock.snapshot().deities.wealth.lit, false) returning.tap(); assert.equal(h.audios.length, 2) returning.dispose() }) test('后台定时器延迟时按绝对截止时间校正,解绑与重置清除点亮状态', () => { const h = harness() h.ritual.tap(); h.ritual.dispose(); h.advance(20000) assert.equal(h.mock.snapshot().deities.wealth.lit, false) assert.equal(h.mock.snapshot().deities.wealth.incenseCount, 60) h.mock.incense('wealth'); h.mock.unbind('wealth') assert.equal(h.mock.snapshot().deities.wealth.incenseUntil, 0) h.mock.invite('wealth'); h.mock.incense('wealth'); h.mock.reset() assert.equal(h.mock.snapshot().deities.wealth.incenseUntil, 0) }) test('无恭请权限时不创建声音或定时器,不允许额外扣款或奖励', () => { const h = harness() h.mock.unbind('wealth') assert.throws(() => h.ritual.tap(), /未恭请/) assert.equal(h.audios.length, 0); assert.equal(h.timers.size, 0) assert.equal('balance' in h.mock.snapshot(), false) }) test('喇叭默认开启、开关跨详情记忆,微信音频懒创建且离开销毁', () => { const storage = {}, audio = [], cleanups = [] const instantiate = () => { let state, show, hide, stateIndex = 0 const mock = createTiangongMock() const context = load('src/hooks/useIncenseRitual.ts', { react: { useState: initial => { const index = stateIndex++; const value = typeof initial === 'function' ? initial() : initial; if (!index) state = value; return [value, value => { if (!index) state = value }] }, useRef: initial => ({ current: initial }), useEffect: fn => { cleanups.push(fn()) } }, '@tarojs/taro': { useDidShow: fn => { show = fn }, useDidHide: fn => { hide = fn }, default: { getStorageSync: key => storage[key], setStorageSync: (key, value) => { storage[key] = value }, showToast: () => {}, createInnerAudioContext: () => { const item = { plays: 0, destroys: 0, play() { this.plays++ }, pause() {}, stop() {}, destroy() { this.destroys++ }, onEnded() {}, onError() {} }; audio.push(item); return item } } }, '../services/tiangong': { tiangongSession: { ...mock, incense: id => { const startsRound = !mock.snapshot().deities[id].lit; mock.incense(id); return { startsRound } } } }, '../utils/incenseRitual': ritualModule, '../assets/tiangong/incense-yinqing.m4a': { default: 'assets/incense-yinqing.m4a' } }, { process: { env: { TARO_ENV: 'weapp' } } }) const result = context.useIncenseRitual('wealth') return { result, show: () => show(), hide: () => hide(), state: () => state } } try { const first = instantiate(); first.show() assert.equal(first.state(), true); assert.equal(audio.length, 0) first.result.toggleSound(); assert.equal(first.state(), false) first.hide() const second = instantiate(); second.show() assert.equal(second.state(), false) second.result.incense(); assert.equal(audio.length, 1); assert.equal(audio[0].volume, 0) second.result.toggleSound(); assert.equal(second.state(), true) second.result.incense(); assert.equal(audio.length, 1) second.hide() const third = instantiate(); third.show(); third.result.incense(); third.result.incense() assert.equal(audio.length, 2); assert.equal(audio[1].plays, 1) assert.equal(audio[1].src, '/assets/incense-yinqing.m4a') assert.equal(audio[1].autoplay, false); assert.equal(audio[1].loop, false) third.hide(); assert.equal(audio[1].destroys, 1) } finally { cleanups.forEach(fn => fn?.()) } }) test('打包音频为用户素材转码的本地M4A,大小低于200KB,原WAV不进入主包', () => { const bytes = fs.readFileSync(path.join(root, 'src/assets/tiangong/incense-yinqing.m4a')) assert.ok(bytes.length > 100000 && bytes.length < 200 * 1024) assert.equal(bytes.subarray(4, 8).toString(), 'ftyp') assert.equal(fs.existsSync(path.join(root, 'src/assets/tiangong/incense-yinqing.wav')), false) })