Files
hera-web/scripts/incenseRitual.test.cjs
T

241 lines
12 KiB
JavaScript
Raw Normal View History

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