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 @@ @@ -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