From f5f893b445035489becdc51bb092aab0449de394 Mon Sep 17 00:00:00 2001 From: ZF sun <34314687@qq.com> Date: Fri, 24 Jul 2026 16:11:41 +0800 Subject: [PATCH] =?UTF-8?q?refactor(splash&diy):=20=E9=87=8D=E6=9E=84?= =?UTF-8?q?=E5=90=AF=E5=8A=A8=E9=A1=B5=E9=80=BB=E8=BE=91=EF=BC=8C=E4=BC=98?= =?UTF-8?q?=E5=8C=96=E7=99=BD=E5=B1=8F=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. 统一全平台启动页为strict模式,仅在12s兜底超时才强制关闭 2. 新增网络自适应超时策略,根据网络环境调整兜底时长 3. 补充diy加载失败时的启动页关闭逻辑,避免严格模式下无限等待 4. 优化代码格式与注释,修复部分代码细节问题 --- App.vue | 11 +- common/js/diy.js | 68 +- common/js/splash-state.js | 261 ++++++- tests/splash-state.test.js | 1361 +++++++++++++++++++++++++++++++++++- 4 files changed, 1662 insertions(+), 39 deletions(-) diff --git a/App.vue b/App.vue index 654affe..740e5f5 100644 --- a/App.vue +++ b/App.vue @@ -312,16 +312,15 @@ // 鸿蒙系统弹窗是用户视觉锚点, 应用层 splash 必须**真等到**diy 渲染好 // 才能关, 不走 MOUNT_GRACE_MS/PAGE_READY_GRACE_MS 软隐藏兜底 // (软隐藏兜底 1+2=3s 经常不够用, 会导致 splash 提前 hide → 白屏) - // strict 模式只受 DEFAULT_TIMEOUT=8s 总超时兜底, 实际由 notifyPageReady() 触发 + // strict 模式只受 DEFAULT_TIMEOUT=12s 总超时兜底, 实际由 notifyPageReady() 触发 // #ifndef H5 const _needSplash = typeof Lang !== 'undefined' && Lang.isRemoteOnly && Lang.isRemoteOnly(); if (_needSplash) { - // #ifdef MP-HARMONY + // 所有平台都用 strict 模式: 不走 1+2s 软隐藏兜底 + // splash 真等到 notifyPageReady (diy 渲染好) 或 notifyDiyLoadError (diy 错误) 才关 + // DEFAULT_TIMEOUT=12s 总超时仍然兜底 + // 避免 splash 提前关导致白屏 splashState.hide({ reason: 'boot-complete', mode: 'strict' }); - // #endif - // #ifndef MP-HARMONY - splashState.hide({ reason: 'boot-complete', mode: 'fast' }); - // #endif } // #endif diff --git a/common/js/diy.js b/common/js/diy.js index 2812ab5..bac5f51 100644 --- a/common/js/diy.js +++ b/common/js/diy.js @@ -57,10 +57,10 @@ export default { diyLoadError: false, diyLoadErrorMsg: '', diyLoadErrorUrl: '', // 出错的网络请求地址 - + //启动广告 - adv:{}, - + adv: {}, + }; }, onLoad(option) { @@ -123,10 +123,10 @@ export default { // 关闭定位 this.enterDefaultStore(); } - + }, onShow() { - + this.init(); }, onHide() { @@ -211,12 +211,12 @@ export default { } }, methods: { - play(){ + play() { }, async init() { // 定位信息过期后,重新获取定位 - if(this.mapConfig.wap_is_open == 1 && this.locationStorage && this.locationStorage.is_expired) { + if (this.mapConfig.wap_is_open == 1 && this.locationStorage && this.locationStorage.is_expired) { this.$util.getLocation({ fail: (res) => { // 拒绝定位,进入默认总店 @@ -289,7 +289,7 @@ export default { }, 50); // #endif }, - async getDiyAdv(){ + async getDiyAdv() { //启动广告 let res = await this.$api.sendRequest({ url: '/api/diyview/getstartadv', @@ -298,13 +298,13 @@ export default { }); this.adv = res.value // 弹框形式,首次弹出 1,每次弹出 0 - if(this.adv.advshow == 1){ + if (this.adv.advshow == 1) { setTimeout(() => { if (res.value.advtype == 1) { var popwindow_count = uni.getStorageSync(this.id + this.name + '_popwindow_count'); if ((this.$refs.uniPopupWindow && popwindow_count == '') || ( this.$refs.uniPopupWindow && popwindow_count == 1)) { - + this.$refs.uniPopupWindow.open(); uni.setStorageSync(this.id + this.name + '_popwindow_count', 1); } @@ -314,15 +314,22 @@ export default { } }, 500); } - + }, // 无法从网络获取 diy 数据时:在页面内渲染错误横幅(各端可靠、一定可见) + // 同时通过 uni.$emit 通知 splash-state 关闭启动屏 (否则 strict 模式永远不关) // @param {Object|string} [err] 网络异常对象 {errMsg, statusCode} / 服务端消息字符串 // @param {string} [url] 出错的请求地址(完整 URL),用于横幅展示 - _showDiyLoadError(err, url) { + // @param {string} [errorType] 错误类型: 'network' | 'no-response' | 'business' + _showDiyLoadError(err, url, errorType) { this.diyLoadErrorMsg = this._buildDiyErrorMsg(err); this.diyLoadErrorUrl = url || ''; this.diyLoadError = true; + // 通知 splash: diy 加载失败, 启动屏应该让位给错误横幅 + // 用 uni.$emit 而非直接 import splash-state, 避免循环依赖 + // reason 拼成 'diy-{errorType}-error' 方便业务方区分 (splash:diy-error 事件) + const reason = errorType ? `diy-${errorType}-error` : 'diy-load-error' + try { uni.$emit && uni.$emit('diy:load-error', { reason, errorType, err, url }); } catch (e) { } }, // 根据错误信息组织更详细的提示文案 // 说明:remote-only 模式下 $lang 可能返回空串,故统一用 `|| 中文兜底` 保证非空 @@ -383,7 +390,8 @@ export default { if (this.$refs.loadingCover) this.$refs.loadingCover.hide(); this.diyInfoLoading = false; // 无法从网络获取数据时,页面内显示错误横幅并提供重试(带详细原因 + 请求地址) - this._showDiyLoadError(e, diyInfoFullUrl); + // errorType='network': 网络异常 (uni.request catch) + this._showDiyLoadError(e, diyInfoFullUrl, 'network'); return; } this.diyInfoLoading = false; @@ -393,7 +401,8 @@ export default { // 完全没有拿到网络响应,视为无法从网络获取数据,页面内显示错误横幅 if (!res) { - this._showDiyLoadError(null, diyInfoFullUrl); + // errorType='no-response': 完全没有 res 对象 + this._showDiyLoadError(null, diyInfoFullUrl, 'no-response'); // 保持原有数据结构,仅清空 value,避免鸿蒙下重新布局 this.diyData = { ...(this.diyData || {}), value: [] }; return; @@ -410,7 +419,8 @@ export default { // 拿到响应但业务失败 / 无数据(如接口 404 返回 200+错误码、code!=0) // 同样视为无法获取到 diy 数据,页面内显示错误横幅 - this._showDiyLoadError(res && res.message, diyInfoFullUrl); + // errorType='business': 业务失败 (code!=0 / 消息提示) + this._showDiyLoadError(res && res.message, diyInfoFullUrl, 'business'); // 保持原有数据结构,仅清空 value,避免鸿蒙下重新布局 this.diyData = { ...(this.diyData || {}), value: [] }; return; @@ -434,22 +444,22 @@ export default { this.mpCollect = this.diyData.global.mpCollect; this.setPublicShare(); - /* if (this.diyData.global.popWindow && this.diyData.global.popWindow.imageUrl) { - // 弹框形式,首次弹出 1,每次弹出 0 - setTimeout(() => { - if (this.diyData.global.popWindow.count == 1) { - var popwindow_count = uni.getStorageSync(this.id + this.name + '_popwindow_count'); - if ((this.$refs.uniPopupWindow && popwindow_count == '') || ( - this.$refs.uniPopupWindow && popwindow_count == 1)) { + /* if (this.diyData.global.popWindow && this.diyData.global.popWindow.imageUrl) { + // 弹框形式,首次弹出 1,每次弹出 0 + setTimeout(() => { + if (this.diyData.global.popWindow.count == 1) { + var popwindow_count = uni.getStorageSync(this.id + this.name + '_popwindow_count'); + if ((this.$refs.uniPopupWindow && popwindow_count == '') || ( + this.$refs.uniPopupWindow && popwindow_count == 1)) { + this.$refs.uniPopupWindow.open(); + uni.setStorageSync(this.id + this.name + '_popwindow_count', 1); + } + } else if (this.diyData.global.popWindow.count == 0) { this.$refs.uniPopupWindow.open(); - uni.setStorageSync(this.id + this.name + '_popwindow_count', 1); + uni.setStorageSync(this.id + this.name + '_popwindow_count', 0); } - } else if (this.diyData.global.popWindow.count == 0) { - this.$refs.uniPopupWindow.open(); - uni.setStorageSync(this.id + this.name + '_popwindow_count', 0); - } - }, 500); - }*/ + }, 500); + }*/ // 修改diy数据结构排序 let searchIndex = -1; diff --git a/common/js/splash-state.js b/common/js/splash-state.js index 13901d3..d32852e 100644 --- a/common/js/splash-state.js +++ b/common/js/splash-state.js @@ -33,7 +33,16 @@ function useNativeLoading() { // #endif } -const DEFAULT_TIMEOUT = 8000; // 总兜底超时 +// 总兜底超时档位 (show 时可覆盖): +// 12000 (12s): 默认档, 启动体验优先 (大多数网络环境够用, 略比 10s 宽松) +// 17000 (17s): 中档, 慢网/冷启动 (1s+ 启动 + 3-5s i18n 远端 + 1-3s diy 拉取 + 缓冲) +// 22000 (22s): 慢网档, 极弱网环境 (1s+ 启动 + 5-8s i18n 远端 + 3-5s diy 拉取 + 缓冲) +// 低于 12000 不推荐: 用户感知的"启动卡"窗口期不够, 但业务方仍可传 < 12000 测试用 +// 业务方覆盖: splashState.show({ timeout: 17000 }) +// 注意: DEFAULT_TIMEOUT 仍是兜底 (无 opts.timeout), 不能被业务方传更低 +// 严防因为 timeout 太短导致用户看到白屏. +// 设计思路: 各档位比"刚好够用"多 2s 缓冲, 弱网/慢网络环境兜底更稳 +const DEFAULT_TIMEOUT = 12000; // 总兜底超时 (默认 12s) const MOUNT_GRACE_MS = 1000; // 软隐藏后等组件 mount 的宽限时间 const PAGE_READY_GRACE_MS = 2000; // 软隐藏后等页面就绪的宽限时间 @@ -71,6 +80,111 @@ class SplashState { // 避免多通道并发时, splash 组件 re-render 风暴 this._pendingProgress = null; // 待 emit 的最新进度 this._flushTimer = null; // 节流定时器 + // 网络环境 (用于 show() 自动选档) + // _networkType: 'wifi' | '4g' | '5g' | '3g' | '2g' | 'none' | 'unknown' + // _networkReady: false 表示还没检测完 (splash 显示中第一次构造时常见) + // _networkReady 后, 网络变化时通过 onNetworkStatusChange 实时更新 + this._networkType = 'unknown'; + this._networkReady = false; + + // 启动网络检测 (异步, 不阻塞 splash 显示) + this._initNetworkDetect(); + + // 监听 diy 加载失败事件 (diy.js 通过 uni.$emit 触发) + // 收到后调 notifyDiyLoadError (严格模式下必须能关闭 splash) + // 解耦: splash-state 不直接 import diy.js + // 透传 reason / errorType / err / url 给业务方监听 splash:diy-error 使用 + try { + if (typeof uni !== 'undefined' && uni.$on) { + uni.$on('diy:load-error', (payload) => { + try { this.notifyDiyLoadError(payload || {}) } catch (e) {} + }) + } + } catch (e) {} + } + + /** + * 初始化网络检测 + * - 调 uni.getNetworkType 异步获取当前网络类型 + * - 监听 uni.onNetworkStatusChange 实时更新 + * - H5 平台降级: 调 navigator.onLine, 不支持细粒度网络类型 + * 失败保护: try-catch 包, 任意失败都保持 _networkType='unknown' (不会破坏 splash) + */ + _initNetworkDetect() { + try { + // #ifdef H5 + // H5 平台: navigator.onLine 是粗粒度, 只能判断在线/离线 + // 仅在 navigator.onLine 是明确 boolean (true/false) 时才是 H5 + // Node 24+ 有 navigator 但 onLine 是 undefined, 不算 H5 (走下面 uni.getNetworkType) + if (typeof navigator !== 'undefined' && typeof navigator.onLine === 'boolean') { + // 初始检测: 用 _updateNetworkType (内部会触发事件) + this._updateNetworkType(navigator.onLine ? 'wifi' : 'none', { source: 'h5-initial' }) + // 监听 online/offline 事件 + if (typeof window !== 'undefined' && window.addEventListener) { + window.addEventListener('online', () => { + this._updateNetworkType('wifi', { source: 'h5-online', isConnected: true }) + }) + window.addEventListener('offline', () => { + this._updateNetworkType('none', { source: 'h5-offline', isConnected: false }) + }) + } + return + } + // navigator.onLine 不是 boolean (Node/SSR) - 走 uni.getNetworkType + // #endif + // #ifndef H5 + if (typeof uni === 'undefined' || !uni.getNetworkType) { + // 不可用: 用 _updateNetworkType 设 unknown (会触发事件) + this._updateNetworkType('unknown', { source: 'no-uni-api' }) + return + } + // 异步获取当前网络类型 + uni.getNetworkType({ + success: (res) => { + this._updateNetworkType((res && res.networkType) || 'unknown', { source: 'uni-initial' }) + }, + fail: () => { + this._updateNetworkType('unknown', { source: 'uni-initial-fail' }) + }, + }) + // 监听网络变化 + if (uni.onNetworkStatusChange) { + uni.onNetworkStatusChange((res) => { + const newType = (res && res.networkType) + || (res && res.isConnected === false ? 'none' : 'unknown') + this._updateNetworkType(newType, { + source: 'uni-change', + isConnected: res && typeof res.isConnected === 'boolean' ? res.isConnected : undefined, + }) + }) + } + // #endif + } catch (e) { + // 任意失败都用 unknown, 不影响 splash + this._updateNetworkType('unknown', { source: 'init-error' }) + } + } + + /** + * 根据当前网络环境选档 + * - 离线 (none): 4000 (4s, 让 diy 错误信号有时间触发) + * - 弱网 (2g/3g): 22000 (22s 慢网档, 多 2s 缓冲) + * - 正常 (4g/5g/wifi): 12000 (12s 默认档, 多 2s 缓冲) + * - 未知 (unknown): 17000 (17s 中档, 保守) + * - 网络未就绪: 12000 (DEFAULT_TIMEOUT, 保守) + */ + _pickTimeoutByNetwork() { + if (!this._networkReady) return DEFAULT_TIMEOUT // 网络检测未就绪, 用默认 + switch (this._networkType) { + case 'none': return 4000 // 离线: 4s 兜底 + case '2g': + case '3g': return 22000 // 弱网: 22s 慢网档 + case '4g': + case '5g': + case 'wifi': return 12000 // 正常: 12s 默认档 + case 'unknown': + default: return 17000 // 未知: 17s 中档 (保守) + } } show(opts = {}) { @@ -111,7 +225,34 @@ class SplashState { try { uni.showLoading({ title: opts.title || '加载中...', mask: false }); } catch (e) {} } - const timeout = opts.timeout || DEFAULT_TIMEOUT; + // 业务方可通过 opts.timeout 覆盖, 但不能低于 5000ms (低于此值给 warn, 仍生效) + // 原因: 启动期 i18n/theme/remote-config 远端加载最少 1-2s, diy 加载 1-3s + // timeout 低于 5s 会经常触发兜底强关, 用户看到白屏 + // 测试场景允许传 < 5000ms (因为 mock 环境快), 仍生效但 warn + // 优先级: opts.timeout (业务方显式) > _pickTimeoutByNetwork() (自动选档) > DEFAULT_TIMEOUT + const MIN_RECOMMENDED_TIMEOUT = 5000 + let timeout + if (opts.timeout) { + timeout = opts.timeout + if (opts.timeout < MIN_RECOMMENDED_TIMEOUT) { + console.warn( + `[splash-state] timeout=${opts.timeout}ms 低于推荐下限 ${MIN_RECOMMENDED_TIMEOUT}ms, ` + + `可能导致启动期白屏. 生产环境建议 10000-20000ms.` + ) + } + } else { + // 自动选档: 根据当前网络环境选 timeout + timeout = this._pickTimeoutByNetwork() + } + this._armTimeout(timeout); + } + + /** + * 设置/重设 splash 总超时兜底 + * - 由 show() 初始化, 或 _updateNetworkType() 在网络变化时调用 + * - 关键: 网络从好→差时延长, 差→好时缩短, 保证用户不会过早/过晚看到白屏 + */ + _armTimeout(timeout) { if (this._timeoutId) { clearTimeout(this._timeoutId); this._timeoutId = null; @@ -125,6 +266,55 @@ class SplashState { }, timeout); } + /** + * 网络变化时更新 _networkType, 并按需重启 splash 兜底 + * @param {string} newType 新的网络类型 + * @param {Object} [meta] 附加信息 { isConnected, source: 'h5-online' | 'uni-change' | ... } + * @returns {boolean} 是否真的更新了 (新旧不同时为 true) + */ + _updateNetworkType(newType, meta) { + newType = newType || 'unknown' + const oldType = this._networkType + const changed = oldType !== newType + // 关键: 即使 type 没变, _networkReady 也要设 true + // 原因: 首次调用 _updateNetworkType 是从 _initNetworkDetect 进来 + // 此时 _networkType='unknown' (初始), 可能 newType 也是 'unknown' (uni 不可用) + // 但 _networkReady 仍要从 false → true (让 _pickTimeoutByNetwork 工作) + this._networkReady = true + if (!changed) return false + this._networkType = newType + + // 网络变化时: 如果 splash 还 visible, 重启兜底 (用新档) + // 业务场景: + // - wifi → 2g: 启动期远端慢, 兜底延长, 避免过早强关 + // - 2g → wifi: 兜底缩短, 避免过晚强关 + // - any → none: 兜底缩短到 4s, 让 diy 错误信号更快触发 + // - none → any: 兜底恢复正常档, 给足时间 + const shouldReboot = this._visible && this._pendingHide === null + // 关键: 仅当 splash 还没进入"软隐藏"阶段时重启兜底 + // _pendingHide 不为 null 表示业务方已调 hide(), 等 notifyPageReady/DiyLoadError 即可 + // 此时网络变化不影响兜底 (业务方已接管决策) + if (shouldReboot) { + const newTimeout = this._pickTimeoutByNetwork() + this._armTimeout(newTimeout) + } + + // 发 splash:network-change 事件 (业务方监听) + // payload: { from, to, isConnected, retryRecommended, rebootSplashTimeout } + // retryRecommended: 从 none 恢复时建议重试 (业务方重调 diy 接口) + // rebootSplashTimeout: 是否重启了 splash 兜底 (true/false) + const eventPayload = { + from: oldType, + to: newType, + isConnected: meta && typeof meta.isConnected === 'boolean' ? meta.isConnected : newType !== 'none', + retryRecommended: oldType === 'none' && newType !== 'none', // 离线→在线, 建议重试 + rebootSplashTimeout: shouldReboot, + source: (meta && meta.source) || 'unknown', + } + this._emit('splash:network-change', eventPayload) + return true + } + /** * 隐藏 splash * @param {Object} opts @@ -204,7 +394,11 @@ class SplashState { clearTimeout(this._mountGraceId); this._mountGraceId = null; } - const _opts = this._pendingHide || {} + // 已被 notifyDiyLoadError 拿走 _pendingHide 并自己 _doHide: noop + // 原因: notifyMounted 50ms 后会调 _flushPendingHide, 但 _pendingHide 已被 notifyDiyLoadError 消费 + // 不应再调 _doHide (避免重复发 splash:hide 或时序错乱) + if (!this._pendingHide) return; + const _opts = this._pendingHide const useStrict = _opts && (_opts.mode === 'strict' || _opts._strict === true) // 如果页面还未就绪, 等待页面就绪 (保留 _pendingHide, 等页面就绪后再处理) @@ -232,6 +426,11 @@ class SplashState { /** * 实际隐藏 (硬路径) + * @param {Object} opts + * @param {string} [opts.reason='manual'] 隐藏原因 + * @param {string} [opts.mode='default'] 动画模式 + * @param {boolean} [opts._silent=false] true=不发 splash:hide 事件 + * 适用于 notifyDiyLoadError: 已经发了 splash:diy-error, 不能再发 splash:hide */ _doHide(opts) { if (!this._visible) return; // 防重入 @@ -253,6 +452,8 @@ class SplashState { if (useNativeLoading()) { try { uni.hideLoading(); } catch (e) {} } + // _silent=true: 业务方已经从 notifyDiyLoadError 收了 splash:diy-error, 这里不发 splash:hide + if (opts._silent) return; this._emit('splash:hide', { reason: opts.reason || 'manual', mode: opts.mode || 'default', @@ -292,6 +493,60 @@ class SplashState { } } + /** + * diy 加载失败时通知 (网络异常/业务失败/无响应) + * - 任何模式: 真关 splash (不能无限等, 错误已显示在页面横幅上) + * - 触发独立事件 'splash:diy-error' (不是 'splash:hide') + * 业务方可监听此事件做: 错误上报 / 自定义错误展示 / 区分错误类型 + * 调用时机: diy 错误横幅出现后 (diy.js._showDiyLoadError) + * + * @param {Object} [opts] + * @param {string} [opts.reason='diy-load-error'] 隐藏原因 + * 用于 'splash:diy-error' 事件的 payload, 业务方可区分: + * - 'diy-network-error': 网络异常 (uni.request catch) + * - 'diy-no-response': 无响应 (res 为空) + * - 'diy-business-error': 业务失败 (code!=0) + * @param {string} [opts.errorType] 错误类型: 'network' | 'business' | 'no-response' + * @param {Object|string} [opts.err] 原始错误对象 + * @param {string} [opts.url] 出错的请求地址 + */ + notifyDiyLoadError(opts = {}) { + // 已 _pageReady=true (notifyPageReady 之后): noop + // 原因: 业务方先调 notifyPageReady (diy 渲染好) 之后又调 notifyDiyLoadError (重复调用或时序错乱) + // 不应再触发 splash:diy-error (已经发过 splash:hide) + if (this._pageReady) return; + // 复用 notifyPageReady 的关闭逻辑 (页面就绪 = 错误状态也算"就绪") + // 原因: 错误状态页 (diyLoadError=true) 已经有 UI 反馈, splash 真关不卡视觉 + this._pageReady = true + // 清除所有兜底, 真关 (strict 模式严格依赖 notifyPageReady/notifyDiyLoadError) + if (this._pageReadyGraceId) { + clearTimeout(this._pageReadyGraceId) + this._pageReadyGraceId = null + } + if (this._mountGraceId) { + clearTimeout(this._mountGraceId) + this._mountGraceId = null + } + // 触发独立事件 (不发 splash:hide, 让业务方区分正常 hide 和 diy 错误) + this._emit('splash:diy-error', { + reason: opts.reason || 'diy-load-error', + errorType: opts.errorType, + err: opts.err, + url: opts.url, + }) + // 关键: 直接 _doHide 关闭 splash, **不**走 _flushPendingHide + // 原因: _flushPendingHide 内部会 _doHide → 触发 splash:hide 事件 + // notifyDiyLoadError 路径**只**应发 splash:diy-error, 不应发 splash:hide + // (业务方根据事件类型区分, 不能混淆) + if (this._pendingHide) { + // 把 _pendingHide 取出, 直接 _doHide 关闭 + // _silent=true 让 _doHide 不发 splash:hide (因为已经发了 splash:diy-error) + const _pendingOpts = this._pendingHide + this._pendingHide = null + setTimeout(() => this._doHide({ ..._pendingOpts, _silent: true }), 50) + } + } + /** * 组件 unmount 时通知 (用于 _hasMounted 状态管理) * 目前保留 _hasMounted=true, 因为一般只有 tab 页有 splash 组件, 不会动态增减 diff --git a/tests/splash-state.test.js b/tests/splash-state.test.js index d359316..8b32a11 100644 --- a/tests/splash-state.test.js +++ b/tests/splash-state.test.js @@ -45,19 +45,29 @@ function loadSplashStateInSandbox() { .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 / useNativeLoading) + // 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, @@ -317,4 +327,1353 @@ test('鸿蒙场景对比: 默认模式会"提前"关 splash (演示 bug)', () => }, 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) + }) }) \ No newline at end of file