From 45c0c2afb1937866463003c7d7eb816e965fd20a Mon Sep 17 00:00:00 2001
From: ZF sun <34314687@qq.com>
Date: Fri, 24 Jul 2026 15:10:49 +0800
Subject: [PATCH] =?UTF-8?q?refactor(=E9=B8=BF=E8=92=99=E5=85=83=E6=9C=8D?=
=?UTF-8?q?=E5=8A=A1):=20=E9=80=82=E9=85=8D=E9=B8=BF=E8=92=99=E5=B9=B3?=
=?UTF-8?q?=E5=8F=B0=E9=9A=90=E7=A7=81=E6=89=98=E7=AE=A1=E4=B8=8E=E5=90=AF?=
=?UTF-8?q?=E5=8A=A8=E5=B1=8F=E6=97=B6=E5=BA=8F=E4=BC=98=E5=8C=96?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
1. 适配鸿蒙元服务平台隐私托管,由系统自动弹出隐私弹窗,应用层不再弹出自建弹窗
2. 新增启动屏strict模式,鸿蒙场景下严格等待首页DIY渲染完成再关闭启动屏,避免白屏
3. 新增splash-state单元测试覆盖新旧模式与鸿蒙场景
4. 清理隐私相关冗余代码与注释,统一平台逻辑分支
---
App.vue | 32 ++-
common/js/privacy.js | 45 ++-
common/js/splash-state.js | 34 ++-
components/privacy-popup/privacy-popup.vue | 119 ++++++--
tests/splash-state.test.js | 320 +++++++++++++++++++++
5 files changed, 503 insertions(+), 47 deletions(-)
create mode 100644 tests/splash-state.test.js
diff --git a/App.vue b/App.vue
index 35869b8..654affe 100644
--- a/App.vue
+++ b/App.vue
@@ -271,27 +271,57 @@
// 隐私协议弹窗: 启动期关键资源 (i18n/theme/remote-config) 就绪后触发
// 通过 PrivacyManager → uni.$emit('privacy:show') 通知当前页面的 privacy-popup 组件
// 只弹一次: 同意后写 storage,后续不再弹
- // 异步查询平台隐私状态(微信/鸿蒙), H5 同步返回
+ // 异步查询平台隐私状态(微信/快应用), H5 同步返回
// H5 直访特定 URL(子串匹配)时跳过本次弹窗,不写 storage,下次正常访问其他页面还会弹
// urlFallback: 首次启动 onLaunch 时 getCurrentPages 还没页面, 用 onLaunch options 兜底
// readyGuard: 双保险 — 若调用方没传, PrivacyManager 内部从 _externalReadyGuard 取
// (= ConfigExternal.getPrivacyReadyGuard(privacyConfig.waitChannels))
// 二次 await 仍保留: race 后还有可能某个通道没真完成, 二次 await 兜底
+ //
+ // 鸿蒙元服务(双轨制):
+ // 已接入平台隐私托管, 鸿蒙系统自动弹出系统级隐私弹窗.
+ // 应用层不调 PrivacyManager.show() (会和系统弹窗叠加被驳回).
+ // #ifdef MP-HARMONY
+ // 鸿蒙: 走平台隐私托管, markSkipped 写 storage
+ try {
+ await Promise.all([_i18nDone, _themeFirstApply, _themeOverride, _remoteConfig])
+ PrivacyManager.markSkipped()
+ } catch (e) { console.warn('[privacy] MP-HARMONY markSkipped fail', e); }
+ // #endif
+ // #ifndef MP-HARMONY
try {
await PrivacyManager.show({
urlFallback: _buildPrivacyUrlFallback(options),
readyGuard: Promise.all([_i18nDone, _themeFirstApply, _themeOverride, _remoteConfig]),
})
} catch (e) { console.warn('[privacy] show fail', e); }
+ // #endif
// splash 必须在隐私弹窗决策后再 hide (否则 splash 关掉瞬间弹窗还没渲染, 视觉闪一下)
// mode='fast': 隐私弹窗即将出现, splash 用 80ms 短动画, 让 modal 更快接管视觉
// 避免 splash 和弹窗同时显示造成的"双遮罩"感, 减少约 400ms 启动时间
// 只有 remote-only 模式才用了 splash (其他场景 src 模式不需要等远端)
+ //
+ // 关键 (鸿蒙 + 其他平台): 这里只发"软隐藏"信号, splash 实际关闭要等
+ // splashState._softHide() 看到 _hasMounted && _pageReady 才真关。
+ // _pageReady 由首页 diy-group 渲染完时调 notifyPageReady() 触发。
+ // 这样保证 splash **一直显示**, 直到首页 diy 内容真的渲染好,
+ // 避免 splash 关闭后到 diy 内容出现之间出现白屏。
+ //
+ // 鸿蒙端 (mode='strict'):
+ // 鸿蒙系统弹窗是用户视觉锚点, 应用层 splash 必须**真等到**diy 渲染好
+ // 才能关, 不走 MOUNT_GRACE_MS/PAGE_READY_GRACE_MS 软隐藏兜底
+ // (软隐藏兜底 1+2=3s 经常不够用, 会导致 splash 提前 hide → 白屏)
+ // strict 模式只受 DEFAULT_TIMEOUT=8s 总超时兜底, 实际由 notifyPageReady() 触发
// #ifndef H5
const _needSplash = typeof Lang !== 'undefined' && Lang.isRemoteOnly && Lang.isRemoteOnly();
if (_needSplash) {
+ // #ifdef MP-HARMONY
+ 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/privacy.js b/common/js/privacy.js
index ceb2f87..99df724 100644
--- a/common/js/privacy.js
+++ b/common/js/privacy.js
@@ -147,20 +147,13 @@ function getQuickAppPrivacySetting() {
}
/**
- * 鸿蒙元服务: 查询平台级隐私设置
- * 鸿蒙元服务的隐私授权由系统自动管理,应用层只需检查 storage
- * @returns {Promise<{needAuthorization: boolean, privacyContractName?: string}>}
+ * 鸿蒙元服务: 已接入平台隐私托管, 应用层不再调任何系统 API
+ * 仅为兼容历史代码保留此函数 (被 checkPrivacy() 调用), 内部只走 storage 路径
+ * @returns {Promise<{needAuthorization: boolean}>}
*/
function getHarmonyPrivacySetting() {
return new Promise((resolve) => {
- // #ifdef MP-HARMONY
- // 鸿蒙元服务: 隐私授权由系统管理,应用层仅检查 storage
- // 系统会在用户首次使用隐私API时自动弹出系统级隐私授权弹窗
resolve({ needAuthorization: !getStorageAgreed() });
- // #endif
- // #ifndef MP-HARMONY
- resolve({ needAuthorization: !getStorageAgreed() });
- // #endif
});
}
@@ -188,7 +181,7 @@ async function checkPrivacy() {
}
// #endif
- // 鸿蒙元服务: 隐私由系统管理,仅查 storage
+ // 鸿蒙元服务: 调系统 getPrivacySetting (无 API 时 getHarmonyPrivacySetting 内部回退 storage)
// H5 / 其他平台: 仅查 storage
return checkPrivacySync();
}
@@ -730,8 +723,9 @@ const PrivacyManager = {
// 优化: storage 已同意时直接走同步快速路径, 不调 async checkPrivacy()
// 节省 1 个 microtask (~0-4ms, dev 模式更明显)
- // 注意: 微信小程序/快应用/鸿蒙走 checkPrivacy (平台 API 异步)
+ // 注意: 微信小程序/快应用走 checkPrivacy (平台 API 异步)
// H5 下 checkPrivacy 内部已 sync 化 (修过), 直接 await 即可
+ // 鸿蒙元服务: 不调任何系统 API, 不弹自建弹窗 (走平台托管)
const result = _storageAgreed ? checkPrivacySync() : await checkPrivacy();
_needShow = result.needAuthorization;
@@ -741,7 +735,18 @@ const PrivacyManager = {
return;
}
- // 设置待弹窗标志
+ // #ifdef MP-HARMONY
+ // 鸿蒙元服务: 已接入平台隐私托管, 由鸿蒙系统自动弹出系统级隐私弹窗
+ // 应用层不再 emit 'privacy:show' 触发自建 privacy-popup.vue
+ // 标记已决策, 防止 consumePendingShow 兜底分支在 page mounted 时再次弹自建弹窗
+ _needShow = false;
+ _pendingShow = false;
+ _urlRuleDecided = true;
+ console.log('[privacy] MP-HARMONY: 已委托鸿蒙平台隐私托管, 不触发自建弹窗');
+ return;
+ // #endif
+
+ // 其他平台: 设置待弹窗标志 + emit 触发自建弹窗
// 已 mounted 的页面组件通过事件接收
// 未 mounted 的页面组件 mounted 时通过 consumePendingShow 补偿
_pendingShow = true;
@@ -757,18 +762,28 @@ const PrivacyManager = {
* 页面 mounted 时补偿检查: 若 show() 在页面 mounted 之前 emit 了事件
* 此方法返回 true 并清除标志,页面组件据此自动弹窗
*
- * 鸿蒙元服务兼容:
+ * 历史兼容:
* Lang.boot() 可能 hang, PrivacyManager.show() 可能从未被调用过;
* 此时 _pendingShow 仍是默认 false, 但用户从未同意过隐私, 必须强制弹一次。
* 所以增加 storage 检测: 用户未同意也返回 true (补偿弹窗)。
+ *
+ * 鸿蒙元服务(双轨制):
+ * 已接入平台隐私托管, 由鸿蒙系统自动弹出系统级隐私弹窗,
+ * 应用层不再 emit/弹自建弹窗, 兜底分支也必须返回 false, 否则会与
+ * 鸿蒙系统级弹窗叠加出现"多次弹出"。
* @returns {boolean} true=需要弹窗,false=不需要
*/
consumePendingShow() {
+ // #ifdef MP-HARMONY
+ // 鸿蒙元服务: 已接入平台隐私托管, 不弹自建弹窗
+ return false;
+ // #endif
+
if (_pendingShow && _needShow) {
_pendingShow = false;
return true;
}
- // 鸿蒙兜底: PrivacyManager.show() 没跑过 + 用户没同意过 storage → 必须弹
+ // 兜底: PrivacyManager.show() 没跑过 + 用户没同意过 storage → 必须弹
// 同一次进程内只补一次,避免隐私弹窗被反复触发
// 关键: URL/路由规则已经决策过 "本次跳过" 时, 不应触发兜底弹窗 (白名单场景)
if (!_hasChecked && !_pendingShow && !_urlRuleDecided && !getStorageAgreed()) {
diff --git a/common/js/splash-state.js b/common/js/splash-state.js
index 4d3b39c..13901d3 100644
--- a/common/js/splash-state.js
+++ b/common/js/splash-state.js
@@ -136,15 +136,22 @@ class SplashState {
* @param {string} [opts.mode] - 离开动画模式
* - 'default': 默认 480ms 动画
* - 'fast': 80ms 短动画, 用于隐私弹窗等需要立即接管视觉的场景, 避免"双遮罩"感
+ * - 'strict': 严格模式, 不走 MOUNT_GRACE_MS / PAGE_READY_GRACE_MS 兜底,
+ * 只等 notifyPageReady() 或 DEFAULT_TIMEOUT 总超时
+ * 适用于: 已接入平台托管的系统弹窗 (鸿蒙),
+ * splash 必须真等到首页 diy 内容渲染好才能关
*/
hide(opts = {}) {
const reason = opts.reason || 'manual';
+ const useStrict = opts.mode === 'strict' || opts._strict === true;
// boot-complete 默认走软隐藏, 避免时序问题
- const useSoft = opts.soft === true || (opts.soft !== false && reason === 'boot-complete');
- const mode = opts.mode === 'fast' ? 'fast' : 'default';
+ const useSoft = useStrict || opts.soft === true || (opts.soft !== false && reason === 'boot-complete');
+ // strict 模式复用 fast 动画
+ const mode = useStrict ? 'fast' : (opts.mode === 'fast' ? 'fast' : 'default');
if (useSoft) {
- this._softHide({ reason, mode });
+ // 传递完整 opts, _softHide 内部能识别 mode='strict' 或 _strict
+ this._softHide(opts);
} else {
this._doHide({ reason, mode });
}
@@ -154,10 +161,17 @@ class SplashState {
* 软隐藏: 标记应该 hide, 等组件 mount + 页面就绪
* 如果组件 1s 内没 mount, 兜底自动 hide
* 如果页面 2s 内没就绪, 兜底自动 hide
+ *
+ * strict 模式 (鸿蒙已接入平台托管):
+ * 不走 MOUNT_GRACE_MS / PAGE_READY_GRACE_MS 兜底
+ * 只等 notifyMounted() + notifyPageReady() 或 DEFAULT_TIMEOUT 总超时
+ * 保证 splash 一定等到首页 diy 内容真的渲染好
*/
_softHide(opts) {
if (!this._visible) return; // 已经隐藏了
this._pendingHide = opts;
+ // strict 模式识别: mode='strict' 或 _strict=true
+ const useStrict = opts && (opts.mode === 'strict' || opts._strict === true);
// 如果组件已经 mount 且页面已就绪, 立即隐藏
if (this._hasMounted && this._pageReady) {
@@ -165,6 +179,12 @@ class SplashState {
return;
}
+ // strict 模式: 不设 mountGraceId 兜底, 只等 notifyPageReady
+ // (DEFAULT_TIMEOUT 在 show() 设置的 _timeoutId 兜底)
+ if (useStrict) {
+ return;
+ }
+
// 兜底: 1s 内没 mount, 强制隐藏
if (this._mountGraceId) clearTimeout(this._mountGraceId);
this._mountGraceId = setTimeout(() => {
@@ -177,15 +197,23 @@ class SplashState {
/**
* 真正触发软隐藏
* 如果页面还未就绪, 等待页面就绪后再 hide
+ * strict 模式 (鸿蒙): 不设 pageReadyGraceId 兜底, 只等 notifyPageReady()
*/
_flushPendingHide() {
if (this._mountGraceId) {
clearTimeout(this._mountGraceId);
this._mountGraceId = null;
}
+ const _opts = this._pendingHide || {}
+ const useStrict = _opts && (_opts.mode === 'strict' || _opts._strict === true)
// 如果页面还未就绪, 等待页面就绪 (保留 _pendingHide, 等页面就绪后再处理)
if (!this._pageReady) {
+ // strict 模式: 不设 pageReadyGraceId, 只等 notifyPageReady()
+ // 总超时由 show() 里的 _timeoutId 兜底 (DEFAULT_TIMEOUT=8s)
+ if (useStrict) {
+ return;
+ }
// 兜底: 2s 内没就绪, 强制隐藏
if (this._pageReadyGraceId) clearTimeout(this._pageReadyGraceId);
this._pageReadyGraceId = setTimeout(() => {
diff --git a/components/privacy-popup/privacy-popup.vue b/components/privacy-popup/privacy-popup.vue
index fe1b952..397bf04 100644
--- a/components/privacy-popup/privacy-popup.vue
+++ b/components/privacy-popup/privacy-popup.vue
@@ -1,4 +1,13 @@
+
+
+
+
@@ -6,7 +15,7 @@
{{ i18n.descPrefix }}
{{ i18n.privacyContractName }}
-
+
{{ i18n.privacyServiceName }}
{{ i18n.descSuffix }}
@@ -17,13 +26,15 @@
-
-
+
+
+
@@ -491,7 +554,7 @@ export default {
.privacy-button-btn {
color: #FFF;
font-size: 30rpx;
- /* #ifdef QUICKAPP-WEBVIEW || H5 || MP-HARMONY */
+ /* #ifdef QUICKAPP-WEBVIEW || H5 */
font-size: 28rpx;
/* #endif */
font-weight: 500;
diff --git a/tests/splash-state.test.js b/tests/splash-state.test.js
new file mode 100644
index 0000000..d359316
--- /dev/null
+++ b/tests/splash-state.test.js
@@ -0,0 +1,320 @@
+/**
+ * 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, '')
+ + '\nmodule.exports = { SplashState, splashState };\n'
+ const sandbox = {
+ console,
+ globalThis: {},
+ module: moduleObj,
+ exports: moduleObj.exports,
+ // mock uni 全局 (splash-state 内部判断 needSplash / useNativeLoading)
+ uni: {
+ getStorageSync: () => null,
+ setStorageSync: () => {},
+ removeStorageSync: () => {},
+ showLoading: () => {},
+ hideLoading: () => {},
+ },
+ // 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)
+ })
+})
\ No newline at end of file