241 lines
12 KiB
JavaScript
241 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 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)
|
||
|
|
})
|