1. 统一全平台启动页为strict模式,仅在12s兜底超时才强制关闭 2. 新增网络自适应超时策略,根据网络环境调整兜底时长 3. 补充diy加载失败时的启动页关闭逻辑,避免严格模式下无限等待 4. 优化代码格式与注释,修复部分代码细节问题
1679 lines
68 KiB
JavaScript
1679 lines
68 KiB
JavaScript
/**
|
|
* 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)
|
|
})
|
|
}) |