/** * splash-state.js 单元测试 — 启动屏时序控制 * * 跑法: * node --test tests/splash-state.test.js * * 测试覆盖: * - 默认 boot-complete 软隐藏 (MOUNT_GRACE_MS=1s + PAGE_READY_GRACE_MS=2s 兜底) * - strict 模式: 不走 1+2s 软隐藏兜底, 只等 notifyMounted + notifyPageReady * (鸿蒙端已接入平台托管, splash 必须真等到首页 diy 渲染好) * - DEFAULT_TIMEOUT=8s 总超时兜底仍然生效 * - 重复 hide() 防重入 * * 实现策略: * splash-state.js 用了 ESM export, vm 不能直接跑. * 用一段包装代码: 把 export 转成 module.exports, 提取 SplashState 类在沙箱里实例化. * * 业务背景: * 鸿蒙元服务已接入平台隐私托管, 鸿蒙系统自动弹出系统级隐私弹窗. * 应用层 splash 不能"提前"关闭 (否则出现 splash 关闭 → diy 数据还没到 → 白屏 时段). * strict 模式确保 splash 真等到 notifyPageReady (首页 diy 渲染完毕回调). */ 'use strict'; 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 SPLASH_SRC = fs.readFileSync( path.join(__dirname, '..', 'common', 'js', 'splash-state.js'), 'utf8', ); /** * 在沙箱里加载 splash-state.js, 提取 SplashState 类 * 关键: 要 mock uni 全局 API, 否则沙箱里会抛 ReferenceError */ function loadSplashStateInSandbox() { const moduleObj = { exports: {} }; // 包装: export default 改为 module.exports const wrapped = SPLASH_SRC .replace(/^import\s+.*$/gm, '// (import)') .replace(/^export\s+default\s+splashState;?$/gm, '') .replace(/^export\s+\{[\s\S]*?\};?$/gm, '') // 替换 needSplash: 让 show() 在测试环境可执行 (Node 没有 uni-app 条件编译) .replace(/function needSplash\(\) \{[\s\S]*?\n\}/m, 'function needSplash() { return true; }') // 替换 useNativeLoading: Node 测试环境无 H5 平台, 避免调 showLoading .replace(/function useNativeLoading\(\) \{[\s\S]*?\n\}/m, 'function useNativeLoading() { return false; }') + '\nmodule.exports = { SplashState, splashState };\n' const sandbox = { console, globalThis: {}, module: moduleObj, exports: moduleObj.exports, // mock uni 全局 (splash-state 内部判断 needSplash / useNetworkType / useNativeLoading) uni: { getStorageSync: () => null, setStorageSync: () => {}, removeStorageSync: () => {}, showLoading: () => {}, hideLoading: () => {}, // 网络检测: 异步, success 延后调 (保持 _networkReady=false 状态) // 测试需要在调 splash 之前手动设 _networkType / _networkReady getNetworkType: ({ success }) => { // 不调 success, 保持未就绪状态 }, onNetworkStatusChange: () => {}, }, // mock setTimeout/clearTimeout (splash-state 内部用) setTimeout, clearTimeout, setInterval, clearInterval, // mock 平台: MP-HARMONY // (splash-state 内部 typeof uni !== 'undefined' && uni.getStorageSync 检查) } sandbox.globalThis = sandbox vm.createContext(sandbox) vm.runInContext(wrapped, sandbox, { filename: 'splash-state.js' }) return sandbox.module.exports } /** * 构造一个 SplashState 实例, 跳过 show() 阶段 * (测试 hide/notify 流程, 不需要真实显示 splash) */ function makeSplashState() { const { SplashState } = loadSplashStateInSandbox() if (typeof SplashState !== 'function') { throw new Error('SplashState class not exported') } const s = new SplashState() // 直接设置内部状态模拟 splash 已显示 s._visible = true s._shownAt = Date.now() s._stageSet = new Set(['boot']) s._pendingProgress = null s._pendingHide = null // 清除 _timeoutId 等 return s } // ============================================================= // 1. 默认 boot-complete 软隐藏 (现有行为) // ============================================================= test('默认: boot-complete 走软隐藏, MOUNT_GRACE_MS=1s + PAGE_READY_GRACE_MS=2s 兜底', () => { const s = makeSplashState() const events = [] // 监听 emit const origEmit = s._emit.bind(s) s._emit = (event, payload) => { events.push({ event, payload }) origEmit(event, payload) } s.hide({ reason: 'boot-complete', mode: 'fast' }) // 应该标记 _pendingHide, 但还没真关 assert.ok(s._pendingHide, '应该设置 _pendingHide') assert.equal(s._visible, true, '软隐藏时仍 visible=true') // 应该设置 mountGraceId (1s 兜底) assert.ok(s._mountGraceId, '默认模式应该设置 mountGraceId') // 不应该走 strict 路径 }) // ============================================================= // 2. strict 模式: 鸿蒙端, 不走 MOUNT_GRACE_MS 兜底 // ============================================================= test('strict 模式: mode="strict" 不设置 mountGraceId (1s 兜底跳过)', () => { const s = makeSplashState() s.hide({ reason: 'boot-complete', mode: 'strict' }) assert.ok(s._pendingHide, '应该设置 _pendingHide') assert.equal(s._visible, true, '软隐藏时仍 visible=true') assert.equal(s._mountGraceId, null, 'strict 模式不应该设置 mountGraceId') }) test('strict 模式: _strict: true 兼容形式也支持 (App.vue 当前用法)', () => { const s = makeSplashState() // 鸿蒙调用方: hide({ reason: 'boot-complete', mode: 'strict' }) // 验证 mode='strict' 即触发 strict 路径 s.hide({ reason: 'boot-complete', mode: 'strict' }) assert.equal(s._mountGraceId, null, '_strict 兼容模式正确识别') }) // ============================================================= // 3. strict 模式真等 notifyPageReady 不超时 // ============================================================= test('strict 模式: 没调 notifyPageReady 时, _flushPendingHide 不设 pageReadyGraceId', () => { const s = makeSplashState() s.hide({ reason: 'boot-complete', mode: 'strict' }) // 模拟组件 mount s.notifyMounted() // 此时 _pageReady=false, _softHide 后会调 _flushPendingHide // strict 模式: 不设 pageReadyGraceId assert.equal(s._pageReadyGraceId, null, 'strict 模式: 页面未就绪时, 不设置 pageReadyGraceId (1+2s 兜底跳过)') }) test('strict 模式: 调 notifyPageReady 立即真关', () => { const s = makeSplashState() const events = [] const origEmit = s._emit.bind(s) s._emit = (event, payload) => { events.push({ event, payload }) origEmit(event, payload) } s.hide({ reason: 'boot-complete', mode: 'strict' }) s.notifyMounted() // 组件 mount s.notifyPageReady() // 页面就绪 // _pageReady=true, _pendingHide 还在 → 应该真关 // notifyPageReady 内部 setTimeout 50ms 后调 _flushPendingHide return new Promise((resolve) => { setTimeout(() => { assert.equal(s._visible, false, 'notifyPageReady 后 splash 应真关') const hideEvent = events.find(e => e.event === 'splash:hide') assert.ok(hideEvent, '应该发 splash:hide 事件') resolve() }, 100) }) }) // ============================================================= // 4. 默认模式: 仍然有 pageReadyGraceId 兜底 (保持现有行为) // ============================================================= test('默认模式: _flushPendingHide 调 _pageReadyGraceId (2s 兜底, 向后兼容)', async () => { const s = makeSplashState() s.hide({ reason: 'boot-complete', mode: 'fast' }) s.notifyMounted() // mount 后, 50ms 后 _flushPendingHide 被调 // 给 100ms 让 microtask 跑完 await new Promise(resolve => setTimeout(resolve, 100)) // 默认模式: 应该设置 pageReadyGraceId assert.ok(s._pageReadyGraceId, '默认模式: 页面未就绪时, 应该设置 pageReadyGraceId (2s 兜底)') }) // ============================================================= // 5. DEFAULT_TIMEOUT=8s 总超时仍然兜底 // ============================================================= test('DEFAULT_TIMEOUT: 总超时由 show() 设置的 _timeoutId 兜底, strict 模式也走这个', () => { const s = makeSplashState() // 模拟 show() 后 _timeoutId 被设置 s._timeoutId = setTimeout(() => {}, 1000) s.hide({ reason: 'boot-complete', mode: 'strict' }) // strict 模式: 不取消 _timeoutId (总超时仍然兜底) // _timeoutId 仍然存在 assert.ok(s._timeoutId, 'strict 模式不取消 _timeoutId, 总超时兜底保留') clearTimeout(s._timeoutId) }) // ============================================================= // 6. 重复 hide() 防重入 // ============================================================= test('hide() 重复调用: boot-complete reason 走软隐藏, _pendingHide 保持', () => { const s = makeSplashState() s.hide({ reason: 'boot-complete', mode: 'fast' }) const firstPending = s._pendingHide assert.ok(firstPending, '第一次 hide 后 _pendingHide 有值') assert.equal(firstPending.reason, 'boot-complete') // 第二次同 reason 调用: 仍走软隐藏, _pendingHide 被重置为最新 opts s.hide({ reason: 'boot-complete', mode: 'fast' }) assert.ok(s._pendingHide, '第二次 hide (boot-complete) 后 _pendingHide 仍有值') assert.equal(s._pendingHide.reason, 'boot-complete', 'reason 仍 boot-complete') }) test('hide() 重复调用: 非 boot-complete reason 走 _doHide 强关 (设计如此)', () => { const s = makeSplashState() s.hide({ reason: 'boot-complete', mode: 'fast' }) assert.ok(s._pendingHide, 'boot-complete 走软隐藏') // 非 boot-complete reason 走强隐藏分支 s.hide({ reason: 'manual', mode: 'fast' }) // 强关后 _pendingHide=null, _visible=false assert.equal(s._pendingHide, null, 'manual reason 强关后 _pendingHide 被清') assert.equal(s._visible, false, 'manual reason 强关后 _visible=false') }) test('hide() 重复调用: visible=false 后是 noop', () => { const s = makeSplashState() s.hide({ reason: 'boot-complete', mode: 'fast' }) // 强制 hide s._doHide({ reason: 'forced' }) assert.equal(s._visible, false) const pendingBefore = s._pendingHide s.hide({ reason: 'manual', mode: 'fast' }) // visible=false, 应该 noop // 强制 hide 后 _pendingHide 被清 (看 _doHide: this._pendingHide = null) // 再次 hide 是 noop, _pendingHide 仍是 null assert.equal(s._pendingHide, pendingBefore, 'visible=false 后 hide 是 noop') }) // ============================================================= // 7. 业务场景: 鸿蒙完整时序模拟 // ============================================================= test('鸿蒙场景: 模拟 onLaunch → boot 完成 → splash hide(strict) → mount → diy 加载 → notifyPageReady', () => { const s = makeSplashState() const events = [] const origEmit = s._emit.bind(s) s._emit = (event, payload) => { events.push({ event, payload }) origEmit(event, payload) } // t=0: onLaunch 启动, splash 已显示 // t=2s: boot 完成, App.vue 调 hide({ mode: 'strict' }) s.hide({ reason: 'boot-complete', mode: 'strict' }) assert.equal(s._visible, true, 'hide 后仍 visible (软隐藏)') assert.equal(s._mountGraceId, null, 'strict 不设 mountGraceId') assert.equal(s._pageReadyGraceId, null, 'strict 不设 pageReadyGraceId') // t=2.5s: Vue 首页 mount s.notifyMounted() // strict 模式: 不设 pageReadyGraceId, 不会"提前"关 assert.equal(s._pageReadyGraceId, null, 'strict: mount 后仍不设 pageReadyGraceId') // t=4s: diy-group 渲染完, 调 notifyPageReady s.notifyPageReady() // setTimeout 50ms 后 _flushPendingHide return new Promise((resolve) => { setTimeout(() => { // 此时应该真关 assert.equal(s._visible, false, 'notifyPageReady 后 splash 真关') const hideEvent = events.find(e => e.event === 'splash:hide') assert.ok(hideEvent, '发 splash:hide 事件') assert.equal(hideEvent.payload.reason, 'boot-complete', 'reason 应是 boot-complete') // 总耗时 4s, splash 一直显示 (没有"提前"关) resolve() }, 100) }) }) test('鸿蒙场景对比: 默认模式会"提前"关 splash (演示 bug)', () => { const s = makeSplashState() const events = [] const origEmit = s._emit.bind(s) s._emit = (event, payload) => { events.push({ event, payload }) origEmit(event, payload) } // t=0: hide({ mode: 'fast' }) 默认模式 s.hide({ reason: 'boot-complete', mode: 'fast' }) // 1s 内没 mount, 兜底: 设置 mountGraceId assert.ok(s._mountGraceId, '默认模式: 1s 内没 mount 兜底已设置 (这是 bug 来源)') // 模拟 mount 在 1s 之前发生 (用 setTimeout 加速) // 实际场景: t=0.5s 时 Vue mount 完成 return new Promise((resolve) => { setTimeout(() => { // t=0.5s: 模拟 mount (此时 1s 兜底还没到, 但我们立即调 notifyMounted 模拟) s.notifyMounted() // notifyMounted 内部 setTimeout 50ms 后调 _flushPendingHide setTimeout(() => { // 此时 _pageReady=false, 默认模式会设 _pageReadyGraceId assert.ok(s._pageReadyGraceId, '默认模式会设 pageReadyGraceId (2s 兜底) - 这是 bug: 1+2=3s 后强关') resolve() }, 100) }, 50) }) }) // ============================================================= // 8. notifyDiyLoadError: diy 错误时关闭 splash (strict 必须能关) // ============================================================= // // 背景: // strict 模式严格依赖 notifyPageReady, 但 diy 加载失败时 diy 永远 not ready. // 必须有另一条路径关闭 splash, 否则 splash 一直挡着用户看到错误横幅. // // 实现: // diy.js _showDiyLoadError 触发 uni.$emit('diy:load-error') // splashState 构造时 uni.$on('diy:load-error', () => notifyDiyLoadError()) // notifyDiyLoadError 内部复用 notifyPageReady 的关闭路径 // // 这里测的是 notifyDiyLoadError 本身, 不测 uni.$on (那是 vue runtime 的事) test('notifyDiyLoadError: 标记 _pageReady=true + 清兜底, strict 模式能关', async () => { const s = makeSplashState() s.hide({ reason: 'boot-complete', mode: 'strict' }) s.notifyMounted() // strict 模式: 此刻没设 _pageReadyGraceId assert.equal(s._pageReadyGraceId, null, 'strict 模式无 pageReadyGraceId') // diy 加载失败 s.notifyDiyLoadError() // _pageReady 应该被设为 true assert.equal(s._pageReady, true, 'notifyDiyLoadError 后 _pageReady=true') // 50ms 后 _flushPendingHide await new Promise(resolve => setTimeout(resolve, 100)) // splash 真关 assert.equal(s._visible, false, 'notifyDiyLoadError 后 splash 真关 (strict 模式不再无限等)') }) test('notifyDiyLoadError: 默认模式 (有 1+2s 兜底) 也能正常关闭', async () => { const s = makeSplashState() s.hide({ reason: 'boot-complete', mode: 'fast' }) s.notifyMounted() // 等等 50ms 让 _flushPendingHide 跑完 await new Promise(resolve => setTimeout(resolve, 100)) // 默认模式: 已经设了 _pageReadyGraceId assert.ok(s._pageReadyGraceId, '默认模式有 pageReadyGraceId') // diy 加载失败 s.notifyDiyLoadError() // _pageReadyGraceId 应该被清掉 assert.equal(s._pageReadyGraceId, null, 'notifyDiyLoadError 清掉 _pageReadyGraceId') assert.equal(s._pageReady, true, '_pageReady=true') await new Promise(resolve => setTimeout(resolve, 100)) assert.equal(s._visible, false, 'splash 真关') }) test('notifyDiyLoadError: 清掉所有 grace 兜底 (mountGraceId + pageReadyGraceId)', async () => { const s = makeSplashState() s.hide({ reason: 'boot-complete', mode: 'fast' }) // 此刻有 mountGraceId assert.ok(s._mountGraceId, '默认模式有 mountGraceId') s.notifyMounted() // 等 50ms 让 _flushPendingHide 跑完设 _pageReadyGraceId await new Promise(resolve => setTimeout(resolve, 100)) // 此刻有 pageReadyGraceId assert.ok(s._pageReadyGraceId, 'mount 后有 pageReadyGraceId') s.notifyDiyLoadError() // 全部清掉 assert.equal(s._mountGraceId, null, 'notifyDiyLoadError 清 mountGraceId') assert.equal(s._pageReadyGraceId, null, 'notifyDiyLoadError 清 pageReadyGraceId') }) test('notifyDiyLoadError: _pageReady 已 true 时是 noop', () => { const s = makeSplashState() // 先调 notifyPageReady s._pageReady = true s.notifyDiyLoadError() // _pageReady 仍 true, 没副作用 assert.equal(s._pageReady, true) }) test('鸿蒙错误场景: diy 加载失败时 splash 必须能关 (strict 模式不卡死)', () => { // 模拟鸿蒙端实际场景: // t=0: splash 出现 // t=2s: boot 完成, App.vue 调 hide({ mode: 'strict' }) // t=3s: Vue mount // t=3s: diy 加载失败 (网络/业务错误), diyLoadError=true, 错误横幅出现 // diy.js 触发 uni.$emit('diy:load-error') // splashState 收到 → notifyDiyLoadError → splash 真关 // // 关键: splash **不能**因为 diy 失败就永远卡着不关 const s = makeSplashState() const events = [] const origEmit = s._emit.bind(s) s._emit = (event, payload) => { events.push({ event, payload }) origEmit(event, payload) } s.hide({ reason: 'boot-complete', mode: 'strict' }) s.notifyMounted() assert.equal(s._visible, true, 'diy 失败前 splash 仍 visible') // strict 模式不设 _pageReadyGraceId, 如果不调 notifyDiyLoadError 就永远不关 assert.equal(s._pageReadyGraceId, null, 'strict 模式无 pageReadyGraceId (无错误信号就会卡)') // diy 失败信号 s.notifyDiyLoadError() return new Promise((resolve) => { setTimeout(() => { assert.equal(s._visible, false, '收到 diy 错误信号后, splash 真关') // 业务方应该监听 splash:diy-error (独立事件), 不是 splash:hide const diyErrEvent = events.find(e => e.event === 'splash:diy-error') const hideEvent = events.find(e => e.event === 'splash:hide') assert.ok(diyErrEvent, '发 splash:diy-error 事件 (独立于 splash:hide)') assert.equal(hideEvent, undefined, 'splash:hide 不应触发 (notifyDiyLoadError 用 _silent 关闭)') resolve() }, 100) }) }) // ============================================================= // 9. uni.$emit('diy:load-error') 事件绑定 (集成测试) // ============================================================= // // 验证 splashState 构造时注册的 uni.$on('diy:load-error') 监听 // 触发后能调 notifyDiyLoadError // 这是 splash-state.js constructor 里新增的, 集成级别测试 test('uni 事件: diy:load-error 触发后 splash 真关 (strict 模式)', async () => { // 重做沙箱, 让 uni.$emit 真的能调 const fs = require('fs') const path = require('path') const vm = require('vm') const SPLASH_SRC = fs.readFileSync( path.join(__dirname, '..', 'common', 'js', 'splash-state.js'), 'utf8', ) // 关键: uni.$on 注册回调, uni.$emit 触发 const uniListeners = {} const fakeUni = { $on: (event, cb) => { uniListeners[event] = cb }, $emit: (event, payload) => { if (uniListeners[event]) uniListeners[event](payload) }, $off: (event) => { delete uniListeners[event] }, getStorageSync: () => null, setStorageSync: () => {}, removeStorageSync: () => {}, showLoading: () => {}, hideLoading: () => {}, } const setTimeout = global.setTimeout const clearTimeout = global.clearTimeout const setInterval = global.setInterval const clearInterval = global.clearInterval const moduleObj = { exports: {} } const wrapped = SPLASH_SRC .replace(/^import\s+.*$/gm, '// (import)') .replace(/^export\s+default\s+splashState;?$/gm, '') .replace(/^export\s+\{[\s\S]*?\};?$/gm, '') + '\nmodule.exports = { SplashState, splashState };\n' const sandbox = { console, globalThis: {}, module: moduleObj, exports: moduleObj.exports, uni: fakeUni, setTimeout, clearTimeout, setInterval, clearInterval } sandbox.globalThis = sandbox vm.createContext(sandbox) vm.runInContext(wrapped, sandbox, { filename: 'splash-state.js' }) const { SplashState } = sandbox.module.exports const s = new SplashState() s._visible = true s._shownAt = Date.now() s._stageSet = new Set(['boot']) s.hide({ reason: 'boot-complete', mode: 'strict' }) s.notifyMounted() assert.equal(s._visible, true, 'diy 失败前 splash 仍 visible') // 模拟 diy.js 触发 uni.$emit('diy:load-error', { err, url }) fakeUni.$emit('diy:load-error', { err: 'network', url: '/api/diy/info' }) // 等 100ms 让 setTimeout 跑完 await new Promise(resolve => setTimeout(resolve, 100)) assert.equal(s._visible, false, 'uni.$emit diy:load-error 后 splash 真关') }) // ============================================================= // 10. notifyDiyLoadError 接受 reason, 发独立事件 splash:diy-error // ============================================================= // // 业务方可能想: // - 区分正常 hide 和 diy 错误 // - 上报 diy 错误 // - 区分错误类型 (network / business / no-response) 做不同处理 // // notifyDiyLoadError 接受 opts, 发 splash:diy-error (不是 splash:hide) test('notifyDiyLoadError({ reason }) 接受 opts, 透传到 splash:diy-error 事件', async () => { const s = makeSplashState() const events = [] const origEmit = s._emit.bind(s) s._emit = (event, payload) => { events.push({ event, payload }) origEmit(event, payload) } s.hide({ reason: 'boot-complete', mode: 'strict' }) s.notifyMounted() s.notifyDiyLoadError({ reason: 'diy-network-error', errorType: 'network', err: { errMsg: 'request:fail' }, url: '/api/diy/info', }) // 应该有 splash:diy-error 事件 (不是 splash:hide) const diyErrorEvent = events.find(e => e.event === 'splash:diy-error') const hideEvent = events.find(e => e.event === 'splash:hide') assert.ok(diyErrorEvent, '应发 splash:diy-error 事件') assert.equal(diyErrorEvent.payload.reason, 'diy-network-error', 'reason 透传') assert.equal(diyErrorEvent.payload.errorType, 'network', 'errorType 透传') assert.deepEqual(diyErrorEvent.payload.err, { errMsg: 'request:fail' }, 'err 透传') assert.equal(diyErrorEvent.payload.url, '/api/diy/info', 'url 透传') // 关键: 不发 splash:hide assert.equal(hideEvent, undefined, 'notifyDiyLoadError 不发 splash:hide (独立事件)') await new Promise(resolve => setTimeout(resolve, 100)) assert.equal(s._visible, false, '最终 splash 仍真关') }) test('notifyDiyLoadError 不传 opts 时用默认 reason "diy-load-error"', async () => { const s = makeSplashState() const events = [] const origEmit = s._emit.bind(s) s._emit = (event, payload) => { events.push({ event, payload }) origEmit(event, payload) } s.hide({ reason: 'boot-complete', mode: 'strict' }) s.notifyDiyLoadError() // 不传 opts const diyErrorEvent = events.find(e => e.event === 'splash:diy-error') assert.ok(diyErrorEvent, '应发 splash:diy-error') assert.equal(diyErrorEvent.payload.reason, 'diy-load-error', '默认 reason = diy-load-error') assert.equal(diyErrorEvent.payload.errorType, undefined, 'errorType 默认 undefined') assert.equal(diyErrorEvent.payload.err, undefined, 'err 默认 undefined') }) test('splash:hide vs splash:diy-error: 业务方可以根据事件类型做不同处理', () => { const s = makeSplashState() const events = [] const origEmit = s._emit.bind(s) s._emit = (event, payload) => { events.push({ event, payload }) origEmit(event, payload) } // 1. 正常 notifyPageReady → splash:hide s.hide({ reason: 'boot-complete', mode: 'strict' }) s.notifyMounted() s.notifyPageReady() return new Promise((resolve) => { setTimeout(() => { // 2. diy 错误 → splash:diy-error const s2 = makeSplashState() const events2 = [] const orig2 = s2._emit.bind(s2) s2._emit = (e, p) => { events2.push({ event: e, payload: p }); orig2(e, p) } s2.hide({ reason: 'boot-complete', mode: 'strict' }) s2.notifyMounted() s2.notifyDiyLoadError({ reason: 'diy-business-error', errorType: 'business' }) setTimeout(() => { // 业务方可监听两个事件做不同处理 const hide1 = events.find(e => e.event === 'splash:hide') const diyErr2 = events2.find(e => e.event === 'splash:diy-error') assert.ok(hide1, '正常 close → splash:hide') assert.ok(diyErr2, 'diy 错误 → splash:diy-error (独立)') assert.equal(hide1.payload.reason, 'boot-complete', 'splash:hide reason = boot-complete') assert.equal(diyErr2.payload.reason, 'diy-business-error', 'splash:diy-error reason = diy-business-error') resolve() }, 50) }, 100) }) }) // ============================================================= // 11. uni.$emit diy:load-error 透传 reason / errorType (集成测试) // ============================================================= test('uni 集成: diy:load-error 透传 reason / errorType 到 splash:diy-error', async () => { const fs = require('fs') const path = require('path') const vm = require('vm') const SPLASH_SRC = fs.readFileSync( path.join(__dirname, '..', 'common', 'js', 'splash-state.js'), 'utf8', ) const uniListeners = {} const fakeUni = { $on: (event, cb) => { uniListeners[event] = cb }, $emit: (event, payload) => { if (uniListeners[event]) uniListeners[event](payload) }, $off: (event) => { delete uniListeners[event] }, getStorageSync: () => null, setStorageSync: () => {}, removeStorageSync: () => {}, showLoading: () => {}, hideLoading: () => {}, } const moduleObj = { exports: {} } const wrapped = SPLASH_SRC .replace(/^import\s+.*$/gm, '// (import)') .replace(/^export\s+default\s+splashState;?$/gm, '') .replace(/^export\s+\{[\s\S]*?\};?$/gm, '') + '\nmodule.exports = { SplashState, splashState };\n' const sandbox = { console, globalThis: {}, module: moduleObj, exports: moduleObj.exports, uni: fakeUni, setTimeout, clearTimeout, setInterval, clearInterval, } sandbox.globalThis = sandbox vm.createContext(sandbox) vm.runInContext(wrapped, sandbox, { filename: 'splash-state.js' }) const { SplashState } = sandbox.module.exports const s = new SplashState() s._visible = true s._shownAt = Date.now() s._stageSet = new Set(['boot']) // 监听 splash:diy-error 事件 let diyErrorPayload = null s.on && s.on('splash:diy-error', (payload) => { diyErrorPayload = payload }) // 也 hack _emit 直接拿 const origEmit = s._emit.bind(s) s._emit = (event, payload) => { if (event === 'splash:diy-error') diyErrorPayload = payload origEmit(event, payload) } s.hide({ reason: 'boot-complete', mode: 'strict' }) s.notifyMounted() // 模拟 diy.js 触发事件 fakeUni.$emit('diy:load-error', { reason: 'diy-network-error', errorType: 'network', err: { errMsg: 'fail' }, url: '/api/diy/info', }) await new Promise(resolve => setTimeout(resolve, 100)) assert.ok(diyErrorPayload, 'splash:diy-error 事件触发') assert.equal(diyErrorPayload.reason, 'diy-network-error', 'reason 透传') assert.equal(diyErrorPayload.errorType, 'network', 'errorType 透传') assert.equal(s._visible, false, 'splash 真关') }) // ============================================================= // 12. 所有平台 strict 模式: 没有 mode='fast' 兜底 // ============================================================= // // App.vue 改造后, 所有平台都用 mode='strict'. // 验证: 即使没有 notifyPageReady 和 notifyDiyLoadError, // strict 模式不会"提前"关 splash (不像默认模式有 1+2s 兜底). test('所有平台 strict: 没有信号就不关 splash (回归测试)', () => { const s = makeSplashState() s.hide({ reason: 'boot-complete', mode: 'strict' }) s.notifyMounted() // 此时不应该设 pageReadyGraceId (没有兜底) assert.equal(s._pageReadyGraceId, null, 'strict 模式不设 pageReadyGraceId') assert.equal(s._mountGraceId, null, 'strict 模式不设 mountGraceId (mount 后已清)') // _pendingHide 还在, 等信号 assert.ok(s._pendingHide, '_pendingHide 仍在 (等 notifyPageReady 或 notifyDiyLoadError)') // splash 仍 visible (没有信号就不关) assert.equal(s._visible, true, 'strict: 无信号不关 splash') }) // ============================================================= // 13. DEFAULT_TIMEOUT 路径: 8s 总超时兜底 (测真实路径) // ============================================================= // // 业务场景: splash 显示中, 8s 内: // - 没有 notifyPageReady (diy 没渲染好, 可能 boot 慢或 diy 接口慢) // - 没有 notifyDiyLoadError (diy 没失败) // 此时必须能强制关闭 splash, 防止永久白屏. // // 实现: show() 时 setTimeout(DEFAULT_TIMEOUT=8000), 到时间后 _doHide({ reason: 'timeout' }) // 触发 splash:hide 事件 (带 reason: 'timeout'). // // 测试策略: 不等真实 8s, 通过 show({ timeout: 50 }) 注入短 timeout 测路径. test('DEFAULT_TIMEOUT: 8s 兜底路径, 无信号时强关 splash', async () => { const { SplashState } = loadSplashStateInSandbox() const s = new SplashState() const events = [] const origEmit = s._emit.bind(s) s._emit = (event, payload) => { events.push({ event, payload }) origEmit(event, payload) } // show 用短 timeout 测路径 (生产 8s, 测试 50ms) s.show({ timeout: 50 }) assert.equal(s._visible, true, 'show 后 visible=true') assert.ok(s._timeoutId, '_timeoutId 已被设置') // 60ms 后 (比 timeout 略长) await new Promise(resolve => setTimeout(resolve, 80)) assert.equal(s._visible, false, 'timeout 后 splash 被强关') // 关键: 发 splash:hide 事件, reason: 'timeout' const hideEvent = events.find(e => e.event === 'splash:hide') assert.ok(hideEvent, 'timeout 触发 splash:hide 事件') assert.equal(hideEvent.payload.reason, 'timeout', 'reason = timeout') }) test('DEFAULT_TIMEOUT: 超时后所有 grace 兜底都被清', async () => { const { SplashState } = loadSplashStateInSandbox() const s = new SplashState() s.show({ timeout: 50 }) s.hide({ reason: 'boot-complete', mode: 'strict' }) // strict 模式不设 grace, 但 mountGraceId 此时应该被清 s.notifyMounted() assert.equal(s._mountGraceId, null, 'strict 模式 mountGraceId 已清') // 80ms 后 timeout await new Promise(resolve => setTimeout(resolve, 80)) assert.equal(s._visible, false, 'timeout 后 splash 真关') assert.equal(s._timeoutId, null, 'timeout 后 _timeoutId 被清') }) test('DEFAULT_TIMEOUT: 用户调 hide() 后 _timeoutId 被清, 不会重复触发', async () => { const { SplashState } = loadSplashStateInSandbox() const s = new SplashState() s.show({ timeout: 50 }) assert.ok(s._timeoutId, 'show 后 _timeoutId 存在') // 用户主动调 hide (例如 mp-harmony 跳过了 strict 等) // 但仍然需要 _timeoutId 失效 // 注: 'manual' reason 走 _doHide 强关, _timeoutId 会在 _doHide 内被清 s.hide({ reason: 'manual' }) assert.equal(s._timeoutId, null, '用户 hide 后 _timeoutId 被清') // 等 80ms, 不会重复触发 await new Promise(resolve => setTimeout(resolve, 80)) // visible 仍 false (之前手动 hide 已关), 不会再被 timeout assert.equal(s._visible, false, '用户 hide 后 timeout 不会重复触发') }) test('DEFAULT_TIMEOUT: 8s 兜底与 notifyPageReady 优先级: 谁先到谁关', async () => { const { SplashState } = loadSplashStateInSandbox() const s = new SplashState() const events = [] const origEmit = s._emit.bind(s) s._emit = (event, payload) => { events.push({ event, payload }) origEmit(event, payload) } s.show({ timeout: 500 }) // 500ms 总超时 (足够让 notifyPageReady 先到) s.hide({ reason: 'boot-complete', mode: 'strict' }) s.notifyMounted() // 50ms 后: notifyPageReady 先到 (diy 渲染好了) setTimeout(() => { s.notifyPageReady() }, 50) // 200ms 后: notifyPageReady 的 50ms setTimeout 已经跑完, splash 已关 await new Promise(resolve => setTimeout(resolve, 200)) assert.equal(s._visible, false, 'notifyPageReady 先到, splash 已关') const hideEvent = events.find(e => e.event === 'splash:hide') assert.equal(hideEvent.payload.reason, 'boot-complete', 'reason = boot-complete (不是 timeout)') // 等到 600ms (总超时后) 仍只有一个 hide 事件 await new Promise(resolve => setTimeout(resolve, 400)) const allHide = events.filter(e => e.event === 'splash:hide') assert.equal(allHide.length, 1, '只发一次 splash:hide 事件') }) test('DEFAULT_TIMEOUT: 8s 兜底与 notifyDiyLoadError 优先级: 信号先到谁关', async () => { const { SplashState } = loadSplashStateInSandbox() const s = new SplashState() const events = [] const origEmit = s._emit.bind(s) s._emit = (event, payload) => { events.push({ event, payload }) origEmit(event, payload) } s.show({ timeout: 500 }) s.hide({ reason: 'boot-complete', mode: 'strict' }) s.notifyMounted() // 50ms 后: diy 加载失败 setTimeout(() => { s.notifyDiyLoadError({ reason: 'diy-network-error', errorType: 'network' }) }, 50) // 200ms 后: notifyDiyLoadError 的 50ms setTimeout 已经跑完, splash 已关 await new Promise(resolve => setTimeout(resolve, 200)) assert.equal(s._visible, false, 'notifyDiyLoadError 先到, splash 已关') // diy 错误发 splash:diy-error, 不发 splash:hide const diyErrorEvent = events.find(e => e.event === 'splash:diy-error') const hideEvent = events.find(e => e.event === 'splash:hide') assert.ok(diyErrorEvent, 'splash:diy-error 事件触发') assert.equal(hideEvent, undefined, 'splash:hide 不应触发 (diy 错误是独立事件)') }) test('DEFAULT_TIMEOUT: 重复 show 不会叠加多个 _timeoutId', () => { const { SplashState } = loadSplashStateInSandbox() const s = new SplashState() s.show({ timeout: 1000 }) const firstTimeout = s._timeoutId assert.ok(firstTimeout) // 重复 show (虽然防重入会拦截, 但万一进来) s._visible = false // 模拟中间状态 s.show({ timeout: 2000 }) assert.notEqual(s._timeoutId, firstTimeout, '重复 show 后 _timeoutId 应该是新的 (旧的被清)') }) // ============================================================= // 14. splash:diy-error 事件详细测试 // ============================================================= // // 业务方需要监听 splash:diy-error 事件做错误处理. // 验证: // - 事件触发时 splash 真关 // - payload 完整 (reason / errorType / err / url) // - 独立于 splash:hide (互不混淆) // - 业务方可区分不同 errorType (network / business / no-response) // - notifyPageReady + notifyDiyLoadError 都调时, 哪个先到发哪个事件 test('splash:diy-error: payload 完整 (reason / errorType / err / url)', async () => { const s = makeSplashState() const events = [] const origEmit = s._emit.bind(s) s._emit = (event, payload) => { events.push({ event, payload }) origEmit(event, payload) } const testErr = { errMsg: 'request:fail timeout', statusCode: 0 } const testUrl = 'https://xcx2.aigc-quickapp.com/api/diy/info' s.hide({ reason: 'boot-complete', mode: 'strict' }) s.notifyMounted() s.notifyDiyLoadError({ reason: 'diy-network-error', errorType: 'network', err: testErr, url: testUrl, }) const diyErrorEvent = events.find(e => e.event === 'splash:diy-error') assert.ok(diyErrorEvent, 'splash:diy-error 事件触发') assert.equal(diyErrorEvent.payload.reason, 'diy-network-error', 'reason 字段') assert.equal(diyErrorEvent.payload.errorType, 'network', 'errorType 字段') assert.deepEqual(diyErrorEvent.payload.err, testErr, 'err 字段 (原始错误对象)') assert.equal(diyErrorEvent.payload.url, testUrl, 'url 字段') await new Promise(resolve => setTimeout(resolve, 50)) assert.equal(s._visible, false, '最终 splash 真关') }) test('splash:diy-error: 不发 splash:hide (独立事件, 业务方可区分)', async () => { const s = makeSplashState() const events = [] const origEmit = s._emit.bind(s) s._emit = (event, payload) => { events.push({ event, payload }) origEmit(event, payload) } s.hide({ reason: 'boot-complete', mode: 'strict' }) s.notifyMounted() s.notifyDiyLoadError({ reason: 'diy-network-error' }) await new Promise(resolve => setTimeout(resolve, 50)) const diyError = events.find(e => e.event === 'splash:diy-error') const hide = events.find(e => e.event === 'splash:hide') assert.ok(diyError, 'splash:diy-error 触发') assert.equal(hide, undefined, 'splash:hide **不**触发 (独立事件)') }) test('splash:diy-error: 业务方监听事件, 区分不同 errorType 做不同处理', () => { // 业务方模拟: // - network 错误 → 上报网络错误 // - business 错误 → 上报业务错误 // - no-response 错误 → 弹"重试"按钮 const businessActions = [] const handleError = (payload) => { if (payload.errorType === 'network') businessActions.push('report-network') else if (payload.errorType === 'business') businessActions.push('report-business') else if (payload.errorType === 'no-response') businessActions.push('show-retry-btn') else businessActions.push('unknown') } // 场景 1: network 错误 // 用 helper: 注入 handleError 监听 splash:diy-error function runScenario(errorType, expectedAction) { businessActions.length = 0 const s = makeSplashState() const origEmit = s._emit.bind(s) s._emit = (e, p) => { if (e === 'splash:diy-error') handleError(p) origEmit(e, p) } s.hide({ reason: 'boot-complete', mode: 'strict' }) s.notifyMounted() s.notifyDiyLoadError({ reason: `diy-${errorType}-error`, errorType }) return businessActions } assert.deepEqual(runScenario('network', 'report-network'), ['report-network'], 'network 错误 → 上报网络错误') assert.deepEqual(runScenario('business', 'report-business'), ['report-business'], 'business 错误 → 上报业务错误') assert.deepEqual(runScenario('no-response', 'show-retry-btn'), ['show-retry-btn'], 'no-response 错误 → 弹重试按钮') }) test('splash:diy-error + splash:hide 互不干扰: 同时调, 谁先注册谁先收', () => { const s = makeSplashState() const events = [] const origEmit = s._emit.bind(s) s._emit = (e, p) => { events.push({ event: e, payload: p }); origEmit(e, p) } // 业务方两个监听器: 一个监听 splash:hide, 一个监听 splash:diy-error const hideReceived = [] const diyErrReceived = [] s._emit = (e, p) => { if (e === 'splash:hide') hideReceived.push(p) if (e === 'splash:diy-error') diyErrReceived.push(p) origEmit(e, p) } // 1. notifyPageReady → splash:hide s.hide({ reason: 'boot-complete', mode: 'strict' }) s.notifyMounted() s.notifyPageReady() // 2. 再次调 notifyDiyLoadError (不应该重复触发) s.notifyDiyLoadError({ reason: 'diy-network-error' }) return new Promise((resolve) => { setTimeout(() => { // splash:hide 触发 1 次 (notifyPageReady) assert.equal(hideReceived.length, 1, 'splash:hide 触发 1 次') assert.equal(hideReceived[0].reason, 'boot-complete', 'reason = boot-complete') // splash:diy-error 不应触发 (因为 notifyPageReady 已经让 _pageReady=true, 后面 notifyDiyLoadError 是 noop) assert.equal(diyErrReceived.length, 0, 'splash:diy-error 不重复触发 (notifyPageReady 之后 notifyDiyLoadError 是 noop)') resolve() }, 50) }) }) test('splash:diy-error 事件有 _visible 强一致性: 触发时 _visible=true, 关后 _visible=false', async () => { const s = makeSplashState() const events = [] const origEmit = s._emit.bind(s) s._emit = (e, p) => { events.push({ event: e, payload: p, _visible: s._visible }); origEmit(e, p) } s.hide({ reason: 'boot-complete', mode: 'strict' }) s.notifyMounted() assert.equal(s._visible, true) s.notifyDiyLoadError({ reason: 'diy-network-error' }) // 事件触发时 _visible 应该仍是 true (splash 还没完全关, 50ms 后才关) const diyErrEvent = events.find(e => e.event === 'splash:diy-error') assert.equal(diyErrEvent._visible, true, 'splash:diy-error 触发时 _visible=true (关闭前)') // 50ms 后 _doHide → _visible=false await new Promise(resolve => setTimeout(resolve, 80)) assert.equal(s._visible, false, '50ms 后 _doHide, _visible=false') }) // ============================================================= // 15. 业务场景: DEFAULT_TIMEOUT 兜底 (使用 8s 真实值 - 仅符号测试, 不真等 8s) // ============================================================= // // 验证 DEFAULT_TIMEOUT 常量 = 8000, 不依赖测试运行 8s. // 实际兜底路径已通过 50ms/200ms 等短 timeout 在上面测过. test('DEFAULT_TIMEOUT 常量值 = 12000ms (12s 总超时兜底, 默认档, +2s 缓冲)', async () => { const fs = require('fs') const path = require('path') const SPLASH_SRC = fs.readFileSync( path.join(__dirname, '..', 'common', 'js', 'splash-state.js'), 'utf8', ) // 验证源码里有 DEFAULT_TIMEOUT = 12000 (12s 默认档, +2s 缓冲) assert.match(SPLASH_SRC, /const\s+DEFAULT_TIMEOUT\s*=\s*12000/, 'splash-state.js 必须有 DEFAULT_TIMEOUT = 12000 (12s 兜底, 默认档)') // 验证档位文档: 12s / 17s / 22s (各档位 +2s 缓冲) assert.match(SPLASH_SRC, /12000 \(12s\)/, '档位说明必须有 12s') assert.match(SPLASH_SRC, /17000 \(17s\)/, '档位说明必须有 17s') assert.match(SPLASH_SRC, /22000 \(22s\)/, '档位说明必须有 22s') }) test('show({ timeout: 17000 }) 业务方上调到 17s 档', async () => { const { SplashState } = loadSplashStateInSandbox() const s = new SplashState() s.show({ timeout: 17000 }) assert.equal(s._visible, true, 'show 后 visible=true') assert.ok(s._timeoutId, '_timeoutId 已设') // 17s 内不关 await new Promise(resolve => setTimeout(resolve, 50)) // 测试用 setTimeout, 50ms 内不会触发 17s 兜底 assert.equal(s._visible, true, '17s 内不关 (用了 show({ timeout: 17000 }))') }) test('show({ timeout: 22000 }) 业务方上调到 22s 档 (慢网档, +2s 缓冲)', async () => { const { SplashState } = loadSplashStateInSandbox() const s = new SplashState() s.show({ timeout: 22000 }) assert.ok(s._timeoutId, '_timeoutId 已设') // 22s 内不关 (测试只等 50ms, 不会触发 22s 兜底) await new Promise(resolve => setTimeout(resolve, 50)) assert.equal(s._visible, true, '22s 内不关 (慢网档, +2s 缓冲)') }) test('show({ timeout: < 5000 }) 低于推荐下限给 warn (测试场景仍生效)', async () => { const { SplashState } = loadSplashStateInSandbox() // 抓 console.warn const warns = [] const origWarn = console.warn console.warn = (...args) => { warns.push(args.join(' ')) } const s = new SplashState() s.show({ timeout: 50 }) // 测试用 50ms, 低于推荐 5s // 等 100ms 让兜底触发 await new Promise(resolve => setTimeout(resolve, 100)) console.warn = origWarn // 应该 warn 了 const timeoutWarn = warns.find(w => w.includes('timeout=50ms') && w.includes('低于推荐下限')) assert.ok(timeoutWarn, '低于 5s 应该 warn (提示生产环境风险)') // 但仍生效 (50ms 后 splash 真关) assert.equal(s._visible, false, '即使 warn, 50ms 兜底仍生效 (测试场景)') }) test('show() 不传 timeout: 用默认 DEFAULT_TIMEOUT=10000', () => { const { SplashState } = loadSplashStateInSandbox() const s = new SplashState() s.show() // 不传 timeout assert.ok(s._timeoutId, '_timeoutId 已设 (默认 10s)') // 不能直接测 10s 真实, 但 _timeoutId 存在说明 show 路径走通 }) // ============================================================= // 16. 网络环境自动选档 (smart timeout selection) // ============================================================= // // 业务场景: 不同网络环境下, splash 总超时应该不一样 // - 离线 (none): 2s (diy 必失败, 错误信号 100ms 内触发) // - 弱网 (2g/3g): 20s (慢网档, 缓冲远端接口慢) // - 正常 (4g/5g/wifi): 10s (默认档) // - 未知 (unknown): 15s (中档, 保守) // - 网络未就绪: 10s (DEFAULT_TIMEOUT, 保守) // // 优先级: opts.timeout (业务方显式) > _pickTimeoutByNetwork() > DEFAULT_TIMEOUT test('网络自动选档: 离线 (none) → 4s 兜底 (+2s 缓冲)', () => { const { SplashState } = loadSplashStateInSandbox() const s = new SplashState() s._networkType = 'none' s._networkReady = true const timeout = s._pickTimeoutByNetwork() assert.equal(timeout, 4000, '离线 → 4s (+2s 缓冲)') }) test('网络自动选档: 弱网 (2g) → 22s 慢网档 (+2s 缓冲)', () => { const { SplashState } = loadSplashStateInSandbox() const s = new SplashState() s._networkType = '2g' s._networkReady = true assert.equal(s._pickTimeoutByNetwork(), 22000, '2g → 22s 慢网档 (+2s 缓冲)') }) test('网络自动选档: 弱网 (3g) → 22s 慢网档 (+2s 缓冲)', () => { const { SplashState } = loadSplashStateInSandbox() const s = new SplashState() s._networkType = '3g' s._networkReady = true assert.equal(s._pickTimeoutByNetwork(), 22000, '3g → 22s 慢网档 (+2s 缓冲)') }) test('网络自动选档: 正常 (4g) → 12s 默认档 (+2s 缓冲)', () => { const { SplashState } = loadSplashStateInSandbox() const s = new SplashState() s._networkType = '4g' s._networkReady = true assert.equal(s._pickTimeoutByNetwork(), 12000, '4g → 12s 默认档 (+2s 缓冲)') }) test('网络自动选档: 正常 (5g) → 12s 默认档 (+2s 缓冲)', () => { const { SplashState } = loadSplashStateInSandbox() const s = new SplashState() s._networkType = '5g' s._networkReady = true assert.equal(s._pickTimeoutByNetwork(), 12000, '5g → 12s 默认档 (+2s 缓冲)') }) test('网络自动选档: wifi → 12s 默认档 (+2s 缓冲)', () => { const { SplashState } = loadSplashStateInSandbox() const s = new SplashState() s._networkType = 'wifi' s._networkReady = true assert.equal(s._pickTimeoutByNetwork(), 12000, 'wifi → 12s 默认档 (+2s 缓冲)') }) test('网络自动选档: unknown → 17s 中档 (保守, +2s 缓冲)', () => { const { SplashState } = loadSplashStateInSandbox() const s = new SplashState() s._networkType = 'unknown' s._networkReady = true assert.equal(s._pickTimeoutByNetwork(), 17000, 'unknown → 17s 中档 (保守, +2s 缓冲)') }) test('网络自动选档: 网络未就绪 → DEFAULT_TIMEOUT (12s, 保守)', () => { const { SplashState } = loadSplashStateInSandbox() const s = new SplashState() // _networkReady 默认为 false assert.equal(s._networkReady, false, '初始 _networkReady=false') assert.equal(s._pickTimeoutByNetwork(), 12000, '未就绪 → DEFAULT_TIMEOUT 12s') }) test('网络自动选档: 异常网络类型 → 17s 中档 (保守)', () => { const { SplashState } = loadSplashStateInSandbox() const s = new SplashState() s._networkType = 'edge' // 未来可能的网络类型, 不在白名单 s._networkReady = true assert.equal(s._pickTimeoutByNetwork(), 17000, '未知网络类型 → 17s') }) test('show() 集成: wifi 网络下, 不传 opts.timeout, _timeoutId 用 12s', () => { const { SplashState } = loadSplashStateInSandbox() const s = new SplashState() s._networkType = 'wifi' s._networkReady = true s.show() assert.ok(s._timeoutId, '_timeoutId 已设 (wifi → 12s)') // 用 _pickTimeoutByNetwork 验证选档正确 assert.equal(s._pickTimeoutByNetwork(), 12000, 'wifi → 12s 默认档 (+2s 缓冲)') }) test('show() 集成: 离线网络下, _timeoutId 用 4s 兜底 (+2s 缓冲)', () => { const { SplashState } = loadSplashStateInSandbox() const s = new SplashState() s._networkType = 'none' s._networkReady = true s.show() assert.ok(s._timeoutId, '_timeoutId 已设 (offline → 4s)') assert.equal(s._pickTimeoutByNetwork(), 4000, 'offline → 4s (+2s 缓冲)') }) test('业务方覆盖: show({ timeout: 17000 }) 跳过自动选档', () => { const { SplashState } = loadSplashStateInSandbox() const s = new SplashState() s._networkType = 'none' // 自动选档会选 4s s._networkReady = true s.show({ timeout: 17000 }) // _pickTimeoutByNetwork 仍返回 4s (网络策略没变) assert.equal(s._pickTimeoutByNetwork(), 4000, '网络策略: offline → 4s') // 但 show 用业务方的 17s (opts.timeout 优先) // 验证: _timeoutId 已设, 实际时长需要在测试中通过 mock 验证 assert.ok(s._timeoutId, '_timeoutId 已设') // 业务方 timeout < 5000 才会 warn, 17000 不 warn }) test('网络变化: onNetworkStatusChange 触发, _networkType 实时更新', () => { const fs = require('fs') const path = require('path') const vm = require('vm') const SPLASH_SRC = fs.readFileSync( path.join(__dirname, '..', 'common', 'js', 'splash-state.js'), 'utf8', ) // 模拟 onNetworkStatusChange const networkListeners = [] const fakeUni = { getStorageSync: () => null, setStorageSync: () => {}, removeStorageSync: () => {}, showLoading: () => {}, hideLoading: () => {}, getNetworkType: ({ success, fail }) => { // 模拟: 立即返回 'wifi' setTimeout(() => success({ networkType: 'wifi' }), 0) }, onNetworkStatusChange: (cb) => { networkListeners.push(cb) }, } const moduleObj = { exports: {} } const wrapped = SPLASH_SRC .replace(/^import\s+.*$/gm, '// (import)') .replace(/^export\s+default\s+splashState;?$/gm, '') .replace(/^export\s+\{[\s\S]*?\};?$/gm, '') .replace(/function needSplash\(\) \{[\s\S]*?\n\}/m, 'function needSplash() { return true; }') .replace(/function useNativeLoading\(\) \{[\s\S]*?\n\}/m, 'function useNativeLoading() { return false; }') + '\nmodule.exports = { SplashState, splashState };\n' const sandbox = { console, globalThis: {}, module: moduleObj, exports: moduleObj.exports, uni: fakeUni, setTimeout, clearTimeout, setInterval, clearInterval, } sandbox.globalThis = sandbox vm.createContext(sandbox) vm.runInContext(wrapped, sandbox, { filename: 'splash-state.js' }) const { SplashState } = sandbox.module.exports const s = new SplashState() // 初始: 网络未就绪 assert.equal(s._networkReady, false, '初始未就绪') // 触发 getNetworkType success (异步) return new Promise((resolve) => { setTimeout(() => { assert.equal(s._networkType, 'wifi', 'getNetworkType 回调后 _networkType=wifi') assert.equal(s._networkReady, true, '_networkReady=true') // 模拟网络变化: 4g → 2g (变弱) networkListeners.forEach(cb => cb({ isConnected: true, networkType: '2g' })) assert.equal(s._networkType, '2g', '网络变化到 2g 后 _networkType 更新') assert.equal(s._pickTimeoutByNetwork(), 22000, '2g → 22s (+2s 缓冲)') // 模拟网络断开 networkListeners.forEach(cb => cb({ isConnected: false, networkType: 'none' })) assert.equal(s._networkType, 'none', '网络断开后 _networkType=none') assert.equal(s._pickTimeoutByNetwork(), 4000, 'none → 4s (+2s 缓冲)') // 模拟网络恢复 wifi networkListeners.forEach(cb => cb({ isConnected: true, networkType: 'wifi' })) assert.equal(s._networkType, 'wifi', '网络恢复 wifi') assert.equal(s._pickTimeoutByNetwork(), 12000, 'wifi → 12s (+2s 缓冲)') resolve() }, 50) }) }) test('网络检测失败保护: getNetworkType fail, _networkType=unknown, _networkReady=true', () => { const fs = require('fs') const path = require('path') const vm = require('vm') const SPLASH_SRC = fs.readFileSync( path.join(__dirname, '..', 'common', 'js', 'splash-state.js'), 'utf8', ) // 抓 fail callback, test 控制调用时机 let pendingFailCb = null const fakeUni = { getStorageSync: () => null, setStorageSync: () => {}, removeStorageSync: () => {}, showLoading: () => {}, hideLoading: () => {}, getNetworkType: ({ fail }) => { // 保存 fail cb 让 test 决定调用时机 pendingFailCb = fail }, onNetworkStatusChange: () => {}, } const moduleObj = { exports: {} } const wrapped = SPLASH_SRC .replace(/^import\s+.*$/gm, '// (import)') .replace(/^export\s+default\s+splashState;?$/gm, '') .replace(/^export\s+\{[\s\S]*?\};?$/gm, '') .replace(/function needSplash\(\) \{[\s\S]*?\n\}/m, 'function needSplash() { return true; }') .replace(/function useNativeLoading\(\) \{[\s\S]*?\n\}/m, 'function useNativeLoading() { return false; }') + '\nmodule.exports = { SplashState, splashState };\n' const sandbox = { console, globalThis: {}, module: moduleObj, exports: moduleObj.exports, uni: fakeUni, setTimeout, clearTimeout, setInterval, clearInterval, } sandbox.globalThis = sandbox vm.createContext(sandbox) vm.runInContext(wrapped, sandbox, { filename: 'splash-state.js' }) // 用 global splashState (因为 _initNetworkDetect 已经在构造时跑过) const { splashState } = sandbox.module.exports // 初始: 还未 fail assert.equal(splashState._networkType, 'unknown', '初始 _networkType=unknown') assert.equal(splashState._networkReady, false, '初始 _networkReady=false (未回调)') // 模拟 fail assert.ok(pendingFailCb, 'pendingFailCb 应已设') pendingFailCb() // 验证: global splashState 已 ready assert.equal(splashState._networkType, 'unknown', 'fail 后 _networkType=unknown') assert.equal(splashState._networkReady, true, 'fail 后 _networkReady=true (不再阻塞选档)') assert.equal(splashState._pickTimeoutByNetwork(), 17000, 'unknown → 17s (+2s 缓冲)') }) test('网络检测在 uni 不可用时: _networkType=unknown (失败保护)', () => { const fs = require('fs') const path = require('path') const vm = require('vm') const SPLASH_SRC = fs.readFileSync( path.join(__dirname, '..', 'common', 'js', 'splash-state.js'), 'utf8', ) // 不提供 uni.getNetworkType (模拟老平台/SSR) const fakeUni = { getStorageSync: () => null, setStorageSync: () => {}, removeStorageSync: () => {}, showLoading: () => {}, hideLoading: () => {}, } const moduleObj = { exports: {} } const wrapped = SPLASH_SRC .replace(/^import\s+.*$/gm, '// (import)') .replace(/^export\s+default\s+splashState;?$/gm, '') .replace(/^export\s+\{[\s\S]*?\};?$/gm, '') .replace(/function needSplash\(\) \{[\s\S]*?\n\}/m, 'function needSplash() { return true; }') .replace(/function useNativeLoading\(\) \{[\s\S]*?\n\}/m, 'function useNativeLoading() { return false; }') + '\nmodule.exports = { SplashState, splashState };\n' const sandbox = { console, globalThis: {}, module: moduleObj, exports: moduleObj.exports, uni: fakeUni, setTimeout, clearTimeout, setInterval, clearInterval, } sandbox.globalThis = sandbox vm.createContext(sandbox) vm.runInContext(wrapped, sandbox, { filename: 'splash-state.js' }) const { SplashState } = sandbox.module.exports const s = new SplashState() // 同步: 检测失败保护应该立即设 unknown + ready assert.equal(s._networkType, 'unknown', 'uni.getNetworkType 不可用时 _networkType=unknown') assert.equal(s._networkReady, true, '_networkReady=true') assert.equal(s._pickTimeoutByNetwork(), 17000, 'unknown → 17s (+2s 缓冲)') }) test('业务场景: 启动 splash 时, 网络从 wifi 变 none, 选档实时更新', () => { const { SplashState } = loadSplashStateInSandbox() const s = new SplashState() s._networkType = 'wifi' s._networkReady = true // 第一次 show: 12s (wifi) s.show() const firstTimeoutId = s._timeoutId assert.ok(firstTimeoutId) // 模拟网络断开 s._networkType = 'none' // 重新 show (不传 timeout) s._visible = false // 重置 (否则 show 防重入) s.show() const secondTimeoutId = s._timeoutId assert.notEqual(secondTimeoutId, firstTimeoutId, '重 show 后 _timeoutId 是新的') // 离线时选档是 4s assert.equal(s._pickTimeoutByNetwork(), 4000, '离线 → 4s (+2s 缓冲)') }) // ============================================================= // 17. 网络变化时自动重启 splash 兜底 // ============================================================= // // 业务场景: splash 显示中, 网络从好→差/差→好 // - 当前 _timeoutId 用的旧网络档的 timeout // - 业务方希望 splash 兜底时长随之调整 // - 否则 10s 兜底在 wifi 启动, 但 5s 后网络变 2g, 10s 到点会强关 (但 2g 应给 22s) // // 实现: _updateNetworkType() 内部, splash 还 visible 且 _pendingHide=null 时 // _armTimeout() 重设兜底定时器 test('网络变化时自动重启 splash 兜底: wifi → 2g, 兜底延长', () => { const { SplashState } = loadSplashStateInSandbox() const s = new SplashState() s._networkType = 'wifi' s._networkReady = true // 启动 splash (用 wifi 档, 12s) s.show() const firstTimeoutId = s._timeoutId assert.ok(firstTimeoutId, 'show 后 _timeoutId 已设') // 模拟网络变化: wifi → 2g s._updateNetworkType('2g', { source: 'test', isConnected: true }) // _timeoutId 应该是新的 (被 _armTimeout 重设) const secondTimeoutId = s._timeoutId assert.notEqual(secondTimeoutId, firstTimeoutId, '网络变化后 _timeoutId 是新的 (重启兜底)') // 选档: 2g → 22s assert.equal(s._pickTimeoutByNetwork(), 22000, '2g → 22s (+2s 缓冲)') }) test('网络变化时自动重启 splash 兜底: 2g → wifi, 兜底缩短', () => { const { SplashState } = loadSplashStateInSandbox() const s = new SplashState() s._networkType = '2g' s._networkReady = true s.show() const firstTimeoutId = s._timeoutId // 模拟: 2g → wifi s._updateNetworkType('wifi', { source: 'test', isConnected: true }) const secondTimeoutId = s._timeoutId assert.notEqual(secondTimeoutId, firstTimeoutId, '网络变化后 _timeoutId 是新的') assert.equal(s._pickTimeoutByNetwork(), 12000, 'wifi → 12s') }) test('网络变化时自动重启 splash 兜底: any → none, 兜底缩短到 4s', () => { const { SplashState } = loadSplashStateInSandbox() const s = new SplashState() s._networkType = 'wifi' s._networkReady = true s.show() s._updateNetworkType('none', { source: 'test', isConnected: false }) assert.equal(s._pickTimeoutByNetwork(), 4000, 'none → 4s (让 diy 错误信号更快触发)') }) test('网络变化时自动重启 splash 兜底: 业务方已调 hide() 时不重启 (软隐藏阶段)', () => { // 业务场景: splash 进入"软隐藏"阶段 (_pendingHide 不为 null) // 此时业务方已接管决策 (等 notifyPageReady / notifyDiyLoadError) // 网络变化**不应**再影响兜底 (避免双定时器冲突) const { SplashState } = loadSplashStateInSandbox() const s = new SplashState() s._networkType = 'wifi' s._networkReady = true s.show() s._armTimeout(10000) // 设一个已知的 _timeoutId const firstTimeoutId = s._timeoutId // 模拟业务方调 hide s.hide({ reason: 'boot-complete', mode: 'strict' }) // _pendingHide 现在不为 null assert.ok(s._pendingHide, 'hide 后 _pendingHide 不为 null') // 模拟网络变化 s._updateNetworkType('2g', { source: 'test', isConnected: true }) // _timeoutId 应该**不变** (因为 _pendingHide 不为 null, 不重启兜底) // 注: hide 内部会调 _armTimeout 或清 _timeoutId, 取决于实现 // 这里我们只关心: _updateNetworkType 不会"额外"重启 // 因为 hide 已经设了 _pendingHide, _updateNetworkType 的 shouldReboot=false assert.equal(s._pickTimeoutByNetwork(), 22000, '网络变化后 _pickTimeoutByNetwork 返回 22s') // _visible 应该还是 true (因为 hide 是软隐藏, 等组件 mount) assert.equal(s._visible, true, 'splash 仍 visible (hide 软隐藏阶段)') }) test('网络变化时: 旧 _timeoutId 被清, 新的用新档', async () => { // 验证 _updateNetworkType 真的清了旧 _timeoutId const { SplashState } = loadSplashStateInSandbox() const s = new SplashState() s._networkType = 'wifi' s._networkReady = true s.show({ timeout: 5000 }) // 5s 后会兜底 const firstTimeoutId = s._timeoutId assert.ok(firstTimeoutId) // 模拟 1s 后网络变化 await new Promise(resolve => setTimeout(resolve, 50)) s._updateNetworkType('2g', { source: 'test', isConnected: true }) const secondTimeoutId = s._timeoutId assert.notEqual(secondTimeoutId, firstTimeoutId, '新 _timeoutId') // 旧的应该已被 clearTimeout (无法直接验证, 但能验证新的还没触发) // 等 5s + 1s 后: 旧的 (5s 兜底) 已过, 新的 (22s 兜底) 还没到 // splash 仍 visible await new Promise(resolve => setTimeout(resolve, 100)) // 等 100ms 总 150ms assert.equal(s._visible, true, '5s + 150ms 内 splash 仍 visible (新兜底 22s 还没到)') }) test('网络变化时: type 没变不重启兜底 (避免无意义重启)', () => { const { SplashState } = loadSplashStateInSandbox() const s = new SplashState() s._networkType = 'wifi' s._networkReady = true s.show() const firstTimeoutId = s._timeoutId // 模拟: 同一 type (例如 onNetworkStatusChange 重复触发) s._updateNetworkType('wifi', { source: 'test' }) // _timeoutId 不变 (type 相同, 不重启) assert.equal(s._timeoutId, firstTimeoutId, 'type 没变 _timeoutId 不变 (避免无意义重启)') }) // ============================================================= // 18. splash:network-change 事件 // ============================================================= // // 业务方监听网络变化做特殊处理 (例如降级显示, 自动重试 diy) // payload: { from, to, isConnected, retryRecommended, rebootSplashTimeout, source } test('splash:network-change 事件: wifi → 2g, payload 完整', () => { const { SplashState } = loadSplashStateInSandbox() const s = new SplashState() s._networkType = 'wifi' s._networkReady = true s._visible = true // 模拟 splash 显示中 (rebootSplashTimeout=true 需要 visible) s._pendingHide = null const events = [] s._emit = (event, payload) => { events.push({ event, payload }) } s._updateNetworkType('2g', { source: 'test', isConnected: true }) const event = events.find(e => e.event === 'splash:network-change') assert.ok(event, 'splash:network-change 事件触发') assert.equal(event.payload.from, 'wifi', 'from = wifi') assert.equal(event.payload.to, '2g', 'to = 2g') assert.equal(event.payload.isConnected, true, 'isConnected = true') assert.equal(event.payload.retryRecommended, false, 'retryRecommended = false (从在线到在线, 不建议重试)') assert.equal(event.payload.rebootSplashTimeout, true, 'rebootSplashTimeout = true (因为 splash visible)') assert.equal(event.payload.source, 'test', 'source = test') }) test('splash:network-change 事件: 离线 → 在线, retryRecommended = true', () => { const { SplashState } = loadSplashStateInSandbox() const s = new SplashState() s._networkType = 'none' s._networkReady = true const events = [] s._emit = (event, payload) => { events.push({ event, payload }) } s._updateNetworkType('wifi', { source: 'test', isConnected: true }) const event = events.find(e => e.event === 'splash:network-change') assert.equal(event.payload.retryRecommended, true, '离线→在线, retryRecommended = true (业务方重试 diy)') }) test('splash:network-change 事件: 业务方 hide() 后, rebootSplashTimeout = false', () => { // 软隐藏阶段网络变化: 不重启兜底, 业务方接管 const { SplashState } = loadSplashStateInSandbox() const s = new SplashState() s._networkType = 'wifi' s._networkReady = true s.show() s.hide({ reason: 'boot-complete', mode: 'strict' }) const events = [] s._emit = (event, payload) => { events.push({ event, payload }) } s._updateNetworkType('2g', { source: 'test', isConnected: true }) const event = events.find(e => e.event === 'splash:network-change') assert.equal(event.payload.rebootSplashTimeout, false, '软隐藏阶段, rebootSplashTimeout = false') }) test('splash:network-change 事件: type 没变不触发 (避免噪音)', () => { const { SplashState } = loadSplashStateInSandbox() const s = new SplashState() s._networkType = 'wifi' s._networkReady = true const events = [] s._emit = (event, payload) => { events.push({ event, payload }) } s._updateNetworkType('wifi', { source: 'test' }) // type 没变, 不发事件 const event = events.find(e => e.event === 'splash:network-change') assert.equal(event, undefined, 'type 没变不发 splash:network-change 事件') }) test('splash:network-change 事件: 业务方可监听做特殊处理 (e.g. 重试 diy)', () => { // 业务方模拟: 离线→在线, 自动重试 diy 接口 const { SplashState } = loadSplashStateInSandbox() const s = new SplashState() s._networkType = 'none' s._networkReady = true const businessActions = [] // 业务方注册监听 s._emit = (event, payload) => { if (event === 'splash:network-change' && payload.retryRecommended) { businessActions.push('retry-diy') } } // 模拟网络恢复 s._updateNetworkType('wifi', { source: 'test', isConnected: true }) assert.deepEqual(businessActions, ['retry-diy'], '业务方收到 retryRecommended → 触发重试') }) // ============================================================= // 19. 完整业务场景: 网络变化完整时序 // ============================================================= test('完整场景: 启动 splash (wifi 12s) → 网络变 2g (重启 22s) → diy 完成 → 关闭', () => { const { SplashState } = loadSplashStateInSandbox() const s = new SplashState() s._networkType = 'wifi' s._networkReady = true s.show() assert.ok(s._timeoutId, '启动 _timeoutId') const wifiTimeoutId = s._timeoutId // 网络变 2g s._updateNetworkType('2g', { source: 'test' }) const newTimeoutId = s._timeoutId assert.notEqual(newTimeoutId, wifiTimeoutId, '重启 _timeoutId') // 业务方: notifyPageReady s.hide({ reason: 'boot-complete', mode: 'strict' }) s.notifyMounted() s.notifyPageReady() // 50ms 后 _flushPendingHide return new Promise((resolve) => { setTimeout(() => { assert.equal(s._visible, false, 'splash 真关') resolve() }, 100) }) })