/** * 账号注销流程 — 行为测试 * ============================================================================ * * 修复背景 (用户报告): * * 账号注销页面提示注销成功后, 用户用标题栏回退键回到会员中心, * 发现账号及信息还在 —— 也就是说页面栈里残留了注销前的页面, 且 * token / 会员信息在跳转前没被清掉. * * 修复方案 (在 member-public.js / cancelsuccess.vue 里): * * 1. submitCancelApply 申请成功 → 立刻 clearLoginState() (不等后续页面) * 2. submitCancelApply 后续跳转 → 用 'reLaunch' 模式 (清空页面栈, 防回退) * 3. cancelsuccess.vue 所有"去登录页"分支 → 用 'reLaunch' 模式 * * 测什么 (按用户故事): * * 场景 A: 注销申请提交成功 → 立即清登录态 * 场景 B: 注销申请提交成功 → 用 reLaunch 跳 cancelstatus (清栈) * 场景 C: 注销申请提交成功 → clearStateAndRedirect=true → 用 reLaunch 跳登录页 (清栈) * 场景 D: 注销申请提交失败 → 不清登录态, 只 toast * 场景 E: 注销申请提交失败 → 不调用 redirectTo (避免把用户带跑) * 场景 F: 用户在 modal 点取消 → 不发请求, 不清登录态 * 场景 G: clearLoginState 顺序: token / memberInfo / cart / localStorage 都清 * 场景 H: cancelsuccess.vue onLoad → token 已空 → reLaunch 跳登录页 * 场景 I: cancelsuccess.vue getStatus 检到 status=1 → reLaunch 跳登录页 * 场景 J: cancelsuccess.vue 用户点"完成" → reLaunch 跳登录页 * 场景 K (静态): member-public.js / cancelsuccess.vue 源码里真的用了 'reLaunch' * 场景 L (回归): clearLoginState 必须早于 redirectTo, 不允许反过来 * * 退出登录 (logout) 流程: * 场景 M: 退出登录确认 → clearLoginState + reLaunch 到登录页 * 场景 N: 退出登录 modal 取消 → 不清登录态, 不跳转 * 场景 O: 退出登录跳登录页必须用 reLaunch 模式 (静态) * 场景 P: getCancelStatus 兜底 (res.code == -1) → clearLoginState + reLaunch * 场景 Q (回归): logout 调用顺序 — clearLoginState 必须在 redirectTo 之前 * * 跑法: * * node --test tests/member-cancel-flow.test.js * * 设计原则 (跟其他测试保持一致): * * ✓ 每个测试对应一个真实用户故事, 不测代码结构 * ✓ 失败 = 用户能感知到的 bug * ✓ 通过 mock 模拟 Vue 组件 this + uni 全局 + $store + $util */ 'use strict'; const test = require('node:test'); const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); // ============================================================= // 复用: 加载 member-public.js // member-public.js 是 ESM `export default { methods: {...} }`, // vm 跑需要去掉 export default, 把 methods 暴露成普通对象。 // ============================================================= function loadMemberPublic(opts = {}) { const SRC = fs.readFileSync( path.join(__dirname, '..', 'pages_tool', 'member', 'public', 'js', 'member-public.js'), 'utf8', ); // 把 `export default {...}` 替换成 `module.exports = {...}`, // 然后用 vm 跑 (避免 new Function 直接执行对象字面量体报错) const vm = require('node:vm'); const moduleObj = { exports: {} }; // 沙箱里的 uni: 优先用 opts.uni (测试时由 createThisMock 注入), // 否则挂 noop, 保证源码能跑通不抛 ReferenceError. const wrapped = SRC.replace(/export\s+default\s+/, 'module.exports = '); const sandbox = { module: moduleObj, exports: moduleObj.exports, console, uni: opts.uni || { showModal: () => {}, removeStorageSync: () => {}, setStorageSync: () => {}, getStorageSync: () => null, }, }; vm.createContext(sandbox); vm.runInContext(wrapped, sandbox, { filename: 'member-public.js' }); const exported = sandbox.module.exports; if (!exported || !exported.methods) { throw new Error('member-public.js 未导出 methods 字段'); } return exported.methods; } // ============================================================= // Mock 工具 // ============================================================= /** * 创建 Vue 组件 this 的 mock. * 真实代码里 this 访问: $store.commit / $store.dispatch / $util.redirectTo / $api.sendRequest / $lang */ function createThisMock({ showModalChoice = 'confirm', apiCode = 0 } = {}) { const calls = { commit: [], // [type, payload][] dispatch: [], // [type][] redirectTo: [], // [{to, param, mode}][] showToast: [], // [{title}][] api: [], // [{url, success}][] showModal: [], // [{title, content, success}][] removeStorage: [],// [key][] lang: [], // [key][] }; const store = { commit(type, payload) { calls.commit.push([type, payload]); }, dispatch(type) { calls.dispatch.push([type]); }, }; // $util.redirectTo 接收 (to, param, mode), 我们记原始参数 const util = { LOGIN_PAGE_URL: '/pages_tool/login/login', MEMBER_PAGE_URL: '/pages_tool/member/index', redirectTo(to, param, mode) { calls.redirectTo.push({ to, param: param || {}, mode: mode || 'navigateTo' }); }, showToast(opts) { calls.showToast.push(opts || {}); }, }; // $api.sendRequest: 用 options.success 回调. 支持 mock 一个异步行为 (setTimeout 0) const api = { sendRequest(opts) { calls.api.push({ url: opts.url, data: opts.data }); // 默认: 模拟成功. 立即同步回调 if (typeof opts.success === 'function') { const res = { code: apiCode, message: 'mock-error', data: {} }; opts.success(res); } }, }; // uni.showModal: 默认用户点确认 (showModalChoice='confirm') const uni = { showModal(opts) { calls.showModal.push({ title: opts.title, content: opts.content }); if (typeof opts.success === 'function') { if (showModalChoice === 'confirm') opts.success({ confirm: true }); else opts.success({ confirm: false }); } }, removeStorageSync(key) { calls.removeStorage.push(key); }, setStorageSync(key, val) { /* noop for cancel flow */ }, getStorageSync(key) { return null; }, }; // $lang: 简单返回 key const $lang = (key) => { calls.lang.push(key); return key; }; const mockThis = { $store: store, $util: util, $api: api, $lang, // 把 mock 出来的 uni 也挂上 this.uni (Vue 组件里 this.uni 通常等同于全局 uni), // 同时让 loadMemberPublic() 能拿到这个对象 (因为源码里直接用 uni 全局). uni: uni, }; return { mockThis, calls, uni }; } /** * 把 memberPublic.methods 装到一个 mock this 上, 然后通过 mockThis.xxx() 调用方法. * 这是模拟 Vue mixin 注入 methods 后, 组件里 this.xxx() 的调用方式. * * 重要: 因为 member-public.js 源码里直接引用全局 uni (不是 this.uni), * 加载沙箱时必须把 mockThis 同款 uni 也塞进 vm 沙箱, 否则 uni.showModal * / uni.removeStorageSync 走的是 noop, 行为记录就丢了. * * 用法: bindMethods(mockThis, loadMemberPublic({ uni: mockThis.uni })) */ function bindMethods(mockThis, methodsObj) { for (const name of Object.keys(methodsObj)) { mockThis[name] = methodsObj[name].bind(mockThis); } } // ============================================================= // 场景 A: 注销申请成功 → 立即清登录态 // ============================================================= test('场景 A: 注销申请成功 → 立即 clearLoginState() (不等后续页面)', () => { const { mockThis, calls } = createThisMock(); bindMethods(mockThis, loadMemberPublic({ uni: mockThis.uni })); mockThis.submitCancelApply(); // 申请 URL 被发了 assert.equal(calls.api.length, 1); assert.equal(calls.api[0].url, '/membercancel/api/membercancel/apply'); // ★ 关键修复: clearLoginState 必须先于 redirectTo 被调用 // 检查顺序: 第一个 commit 必须是 setToken const firstCommit = calls.commit[0]; assert.ok(firstCommit, '提交成功后应该有 commit 调用'); assert.equal(firstCommit[0], 'setToken', '提交成功后第一个 commit 应是 setToken(立即清登录态),实际是 ' + firstCommit[0]); assert.equal(firstCommit[1], '', 'setToken 应清成空串'); // setMemberInfo 也必须被清 const memberCommit = calls.commit.find(([t]) => t === 'setMemberInfo'); assert.ok(memberCommit, 'clearLoginState 应同时清 setMemberInfo'); assert.equal(memberCommit[1], ''); // emptyCart 也必须派发 assert.ok(calls.dispatch.some(([t]) => t === 'emptyCart'), 'clearLoginState 应派发 emptyCart'); // localStorage 也必须清 assert.ok(calls.removeStorage.includes('authInfo'), 'clearLoginState 应清 authInfo localStorage'); assert.ok(calls.removeStorage.includes('privacyAgreed'), 'clearLoginState 应清 privacyAgreed localStorage'); // ★ 关键修复: 跳转必须用 reLaunch (清空页面栈) assert.equal(calls.redirectTo.length, 1); assert.equal(calls.redirectTo[0].mode, 'reLaunch', '提交成功后跳转模式应是 reLaunch (清空页面栈), 实际是 ' + calls.redirectTo[0].mode); assert.equal(calls.redirectTo[0].to, '/pages_tool/member/cancelstatus'); }); // ============================================================= // 场景 B: clearStateAndRedirect=true → reLaunch 到登录页 // ============================================================= test('场景 B: clearStateAndRedirect=true → clearLoginState + reLaunch 到登录页', () => { const { mockThis, calls } = createThisMock(); bindMethods(mockThis, loadMemberPublic({ uni: mockThis.uni })); mockThis.submitCancelApply({ clearStateAndRedirect: true }); // clearLoginState 必须执行 assert.ok(calls.commit.some(([t]) => t === 'setToken'), 'submitCancelApply(clearStateAndRedirect) 必须清 token'); // 跳转必须是 reLaunch assert.equal(calls.redirectTo.length, 1); assert.equal(calls.redirectTo[0].mode, 'reLaunch', 'clearStateAndRedirect 模式跳转必须是 reLaunch'); assert.equal(calls.redirectTo[0].to, '/pages_tool/login/login', 'clearStateAndRedirect 模式应跳到登录页'); }); // ============================================================= // 场景 C: 注销申请失败 → 不清登录态, 只 toast // ============================================================= test('场景 C: 注销申请失败 → 不清登录态, 不跳转, 只 toast', () => { const { mockThis, calls } = createThisMock({ apiCode: -1 }); bindMethods(mockThis, loadMemberPublic({ uni: mockThis.uni })); mockThis.submitCancelApply(); // 申请失败 → 不应该有 commit (登录态不清) assert.equal(calls.commit.length, 0, '申请失败时不应清登录态, 实际 commit 次数: ' + calls.commit.length); // 不应有 redirectTo assert.equal(calls.redirectTo.length, 0, '申请失败时不应跳转'); // 应有 toast 提示 assert.equal(calls.showToast.length, 1, '申请失败应 toast 提示'); }); // ============================================================= // 场景 D: 用户在 modal 点取消 → 不发请求, 不清登录态 // ============================================================= test('场景 D: 用户点 modal 取消 → 不发请求, 不清登录态', () => { const { mockThis, calls } = createThisMock({ showModalChoice: 'cancel' }); bindMethods(mockThis, loadMemberPublic({ uni: mockThis.uni })); mockThis.submitCancelApply(); // 没发申请 assert.equal(calls.api.length, 0, '点取消不应发注销申请'); // 没清登录态 assert.equal(calls.commit.length, 0, '点取消不应清登录态'); // 没跳转 assert.equal(calls.redirectTo.length, 0, '点取消不应跳转'); }); // ============================================================= // 场景 E: 自定义 title / content 透传给 modal // ============================================================= test('场景 E: submitCancelApply({title, content}) → 自定义文案透传给 modal', () => { const { mockThis, calls } = createThisMock(); bindMethods(mockThis, loadMemberPublic({ uni: mockThis.uni })); mockThis.submitCancelApply({ title: '自定义标题', content: '自定义内容' }); assert.equal(calls.showModal.length, 1); assert.equal(calls.showModal[0].title, '自定义标题'); assert.equal(calls.showModal[0].content, '自定义内容'); }); // ============================================================= // 场景 F: clearLoginState 单独调用也能正确清 (供其他组件复用) // ============================================================= test('场景 F: clearLoginState 单独被调 → token / memberInfo / cart / storage 全清', () => { const { mockThis, calls } = createThisMock(); bindMethods(mockThis, loadMemberPublic({ uni: mockThis.uni })); mockThis.clearLoginState(); // 顺序: token → memberInfo → emptyCart → removeStorage assert.deepEqual( calls.commit.map(([t]) => t), ['setToken', 'setMemberInfo'], 'clearLoginState 必须先 commit setToken 再 setMemberInfo', ); assert.deepEqual( calls.dispatch.map(([t]) => t), ['emptyCart'], 'clearLoginState 必须 dispatch emptyCart', ); assert.deepEqual( calls.removeStorage, ['authInfo', 'privacyAgreed'], 'clearLoginState 必须清 authInfo + privacyAgreed', ); }); // ============================================================= // 场景 G (静态分析): member-public.js submitCancelApply 源码里真的用了 'reLaunch' // ============================================================= test('场景 G (静态): member-public.js submitCancelApply 源码里 redirectTo 调用必须是 reLaunch 模式', () => { const src = fs.readFileSync( path.join(__dirname, '..', 'pages_tool', 'member', 'public', 'js', 'member-public.js'), 'utf8', ); // 找到 submitCancelApply 函数体 const m = src.match(/submitCancelApply\s*\([^)]*\)\s*\{([\s\S]*?)\n\s{3}\}/); assert.ok(m, '应能找到 submitCancelApply 函数定义'); const body = m[1]; // 关键修复: 函数体里所有 redirectTo 都必须用 reLaunch (防止回退到历史页面) // 提取所有 this.$util.redirectTo( 调用 const redirectCalls = body.match(/this\.\$util\.redirectTo\s*\([^)]+\)/g) || []; assert.ok(redirectCalls.length >= 2, 'submitCancelApply 至少应有 2 处 redirectTo (login / cancelstatus), 实际 ' + redirectCalls.length); for (const call of redirectCalls) { assert.match(call, /['"]reLaunch['"]/, 'submitCancelApply 内的 redirectTo 必须用 reLaunch 模式, 实际: ' + call.trim()); } // 关键修复: clearLoginState 必须在 redirectTo 之前调用 const clearIdx = body.indexOf('this.clearLoginState()'); const firstRedirectIdx = body.indexOf('this.$util.redirectTo('); assert.ok(clearIdx > -1, 'submitCancelApply 必须调用 clearLoginState()'); assert.ok(firstRedirectIdx > -1, 'submitCancelApply 必须调用 redirectTo'); assert.ok(clearIdx < firstRedirectIdx, 'clearLoginState 必须在 redirectTo 之前调用, 实际位置: clear=' + clearIdx + ', redirect=' + firstRedirectIdx); }); // ============================================================= // 场景 H (静态分析): cancelsuccess.vue 所有 redirectTo 都用 'reLaunch' // ============================================================= test('场景 H (静态): cancelsuccess.vue 跳登录页的 redirectTo 必须全部用 reLaunch', () => { const src = fs.readFileSync( path.join(__dirname, '..', 'pages_tool', 'member', 'cancelsuccess.vue'), 'utf8', ); // 提取所有 this.$util.redirectTo( 调用 const calls = src.match(/this\.\$util\.redirectTo\s*\([^)]+\)/g) || []; assert.ok(calls.length >= 2, 'cancelsuccess.vue 应至少有 2 处 redirectTo (onLoad 无 token 分支 + getStatus + success), 实际 ' + calls.length); // 每处都应该是 reLaunch for (const c of calls) { // 跳登录页的必须是 reLaunch if (c.includes('LOGIN_PAGE_URL')) { assert.match(c, /['"]reLaunch['"]/, 'cancelsuccess.vue 跳 LOGIN_PAGE_URL 必须用 reLaunch, 实际: ' + c.trim()); } } // 同时确认: 全文件没有对 LOGIN_PAGE_URL 的 redirectTo 不带 reLaunch // 用反向断言: 不应该有 LOGIN_PAGE_URL 但没有 reLaunch 的 redirectTo const loginRedirects = calls.filter(c => c.includes('LOGIN_PAGE_URL')); const badOnes = loginRedirects.filter(c => !/['"]reLaunch['"]/.test(c)); assert.equal(badOnes.length, 0, 'cancelsuccess.vue 跳登录页不能有非 reLaunch 的 redirectTo, 违规: ' + JSON.stringify(badOnes)); }); // ============================================================= // 场景 I (行为模拟): cancelsuccess.vue 风格的组件 — getStatus 检到 status=1 → reLaunch // // 这里我们重新模拟一个简化的 cancelsuccess.vue 行为, 因为 .vue 文件无法直接 vm 跑 // (uni-app 编译产物含模板, 没法在 node 里直接 require). 我们提取其核心逻辑验证: // onLoad → if (!storeToken) reLaunch(LOGIN_PAGE_URL) // getStatus success → if (status==1) { clearLoginState; reLaunch(LOGIN_PAGE_URL) } // success → { clearLoginState; reLaunch(LOGIN_PAGE_URL) } // ============================================================= test('场景 I (行为模拟): cancelsuccess.vue getStatus 检到 status=1 → clearLoginState + reLaunch', () => { const { mockThis, calls } = createThisMock(); bindMethods(mockThis, loadMemberPublic({ uni: mockThis.uni })); // 定制 $api.sendRequest: 让 /membercancel/api/membercancel/info 返回 status=1 mockThis.$api.sendRequest = (opts) => { calls.api.push({ url: opts.url }); if (typeof opts.success === 'function') { opts.success({ code: 0, data: { status: 1 } }); } }; // 模拟 cancelsuccess.vue 的 getStatus 方法 (跟源码逻辑一致) mockThis.getStatus = function () { mockThis.$api.sendRequest({ url: '/membercancel/api/membercancel/info', success: (res) => { if (res.code >= 0) { if (res.data && res.data.status == 1) { mockThis.clearLoginState(); mockThis.$util.redirectTo(mockThis.$util.LOGIN_PAGE_URL, {}, 'reLaunch'); } } }, }); }; mockThis.getStatus(); // ★ 关键修复: 检到 status=1 必须 (1) 清登录态 (2) reLaunch assert.ok(calls.commit.some(([t]) => t === 'setToken'), 'status=1 必须清 token'); assert.equal(calls.redirectTo.length, 1); assert.equal(calls.redirectTo[0].mode, 'reLaunch', 'status=1 必须用 reLaunch 跳转 (防止用户回退到历史页面看到旧会员信息)'); assert.equal(calls.redirectTo[0].to, '/pages_tool/login/login'); }); // ============================================================= // 场景 J (行为模拟): status != 1 时不重定向, 让用户留在成功页 (避免误跳) // ============================================================= test('场景 J (行为模拟): cancelsuccess.vue getStatus status != 1 → 不清登录态, 不跳转', () => { const { mockThis, calls } = createThisMock(); bindMethods(mockThis, loadMemberPublic({ uni: mockThis.uni })); // 模拟 status=0 (审核中) mockThis.$api.sendRequest = (opts) => { calls.api.push({ url: opts.url }); opts.success({ code: 0, data: { status: 0 } }); }; mockThis.getStatus = function () { mockThis.$api.sendRequest({ url: '/membercancel/api/membercancel/info', success: (res) => { if (res.code >= 0) { if (res.data && res.data.status == 1) { mockThis.clearLoginState(); mockThis.$util.redirectTo(mockThis.$util.LOGIN_PAGE_URL, {}, 'reLaunch'); } } }, }); }; mockThis.getStatus(); // status=0 → 不应清登录态, 不应跳转 (让用户继续看 cancelstatus / cancelsuccess 页) assert.equal(calls.commit.length, 0, 'status != 1 不应清登录态'); assert.equal(calls.redirectTo.length, 0, 'status != 1 不应跳转'); }); // ============================================================= // 场景 K (行为模拟): onLoad — storeToken 为空时直接 reLaunch 跳登录页 (兜底分支) // ============================================================= test('场景 K (行为模拟): cancelsuccess.vue onLoad — storeToken 已空时直接 reLaunch 到登录页', () => { const { mockThis, calls } = createThisMock(); bindMethods(mockThis, loadMemberPublic({ uni: mockThis.uni })); // 模拟 storeToken = false 的组件 Object.defineProperty(mockThis, 'storeToken', { get: () => false, configurable: true }); // 模拟 cancelsuccess.vue onLoad 逻辑 (跟当前修复后的源码一致) function onLoad() { if (!mockThis.storeToken) { mockThis.$util.redirectTo(mockThis.$util.LOGIN_PAGE_URL, {}, 'reLaunch'); } else { mockThis.getStatus(); } } onLoad(); // 不应发请求 (没 token 直接跳登录) assert.equal(calls.api.length, 0, '无 token 不应发请求'); // 必须 reLaunch 到登录页 assert.equal(calls.redirectTo.length, 1, '无 token 必须 reLaunch'); assert.equal(calls.redirectTo[0].mode, 'reLaunch', '必须用 reLaunch 模式'); assert.equal(calls.redirectTo[0].to, '/pages_tool/login/login', '必须跳登录页'); }); // ============================================================= // 场景 L (回归): 不允许"先 redirectTo 再 clearLoginState" (顺序错会留漏洞) // ============================================================= test('场景 L (回归): submitCancelApply 的源码顺序 — clearLoginState 必须早于 redirectTo', () => { const src = fs.readFileSync( path.join(__dirname, '..', 'pages_tool', 'member', 'public', 'js', 'member-public.js'), 'utf8', ); const m = src.match(/submitCancelApply\s*\([^)]*\)\s*\{([\s\S]*?)\n\s{3}\}/); assert.ok(m); const body = m[1]; // 拿到所有 this.clearLoginState() 和 this.$util.redirectTo( 的位置 const idxClear = []; const idxRedirect = []; const re = /(this\.clearLoginState\(\)|this\.\$util\.redirectTo\()/g; let mm; while ((mm = re.exec(body)) !== null) { if (mm[1].startsWith('this.clearLoginState')) idxClear.push(mm.index); else idxRedirect.push(mm.index); } assert.ok(idxClear.length >= 1, 'submitCancelApply 内至少 1 次 clearLoginState'); assert.ok(idxRedirect.length >= 2, 'submitCancelApply 内至少 2 次 redirectTo'); // 第一个 clearLoginState 必须早于第一个 redirectTo assert.ok(idxClear[0] < idxRedirect[0], '第一个 clearLoginState(' + idxClear[0] + ') 必须早于第一个 redirectTo(' + idxRedirect[0] + ')'); }); // ============================================================= // 退出登录 (logout) — 同样的"页面栈没清"问题修复 // // 修复前 bug: 用户在会员中心退出登录 → redirectTo 默认是 navigateTo 模式 → // 跳到登录页 → 按标题栏/导航栏返回 → 回到会员中心看到仍渲染的旧会员信息。 // // 修复: logout / getCancelStatus 兜底分支都用 reLaunch 模式 // ============================================================= // 场景 M: 退出登录确认 → clearLoginState + reLaunch 到登录页 test('场景 M: 退出登录确认 → clearLoginState + reLaunch 到登录页 (清栈防回退)', () => { const { mockThis, calls } = createThisMock(); bindMethods(mockThis, loadMemberPublic({ uni: mockThis.uni })); mockThis.logout(); // ★ 关键修复: 跳登录页必须用 reLaunch (清空页面栈) assert.equal(calls.redirectTo.length, 1); assert.equal(calls.redirectTo[0].mode, 'reLaunch', '退出登录跳转模式必须是 reLaunch (否则按返回键会回到会员中心), 实际: ' + calls.redirectTo[0].mode); assert.equal(calls.redirectTo[0].to, '/pages_tool/login/login', '退出登录应跳登录页'); // 登录态必须清 assert.ok(calls.commit.some(([t]) => t === 'setToken'), '退出登录必须清 token'); assert.ok(calls.commit.some(([t]) => t === 'setMemberInfo'), '退出登录必须清 setMemberInfo'); assert.equal(calls.commit.find(([t]) => t === 'setToken')[1], '', 'setToken 应清成空串'); // emptyCart 必须派发 assert.ok(calls.dispatch.some(([t]) => t === 'emptyCart'), '退出登录必须派发 emptyCart'); // localStorage 必须清 assert.ok(calls.removeStorage.includes('authInfo'), '退出登录必须清 authInfo localStorage'); assert.ok(calls.removeStorage.includes('privacyAgreed'), '退出登录必须清 privacyAgreed localStorage'); // ★ 关键: clearLoginState 必须在 redirectTo 之前调用 (否则页面栈先 push 登录页) // 找到 clearLoginState 引发的首个 commit (setToken) const tokenIdx = calls.commit.findIndex(([t]) => t === 'setToken'); assert.ok(tokenIdx >= 0, '应有 setToken commit'); // commit 是同步的, redirectTo 在 commit 之后 (因为 success 回调里先调 clearLoginState 再调 redirectTo) // 但 redirectTo 的调用顺序晚于所有 commit, 这里检查 calls 的总顺序 // 通过检查 calls.commit 中最后一个 commit 是否为 setMemberInfo 来佐证 assert.equal(calls.commit[calls.commit.length - 1][0], 'setMemberInfo', 'setMemberInfo 应是最后一次 commit (clearLoginState 内最后一步)'); }); // 场景 N: 退出登录 modal 取消 → 不清登录态, 不跳转 test('场景 N: 退出登录 modal 点取消 → 不清登录态, 不跳转', () => { const { mockThis, calls } = createThisMock({ showModalChoice: 'cancel' }); bindMethods(mockThis, loadMemberPublic({ uni: mockThis.uni })); mockThis.logout(); // 不应有任何 commit / dispatch / redirectTo / removeStorage assert.equal(calls.commit.length, 0, '点取消不应清登录态'); assert.equal(calls.dispatch.length, 0, '点取消不应派发 emptyCart'); assert.equal(calls.redirectTo.length, 0, '点取消不应跳转'); assert.equal(calls.removeStorage.length, 0, '点取消不应清 localStorage'); }); // 场景 O (静态): logout 跳登录页的 redirectTo 必须用 reLaunch test('场景 O (静态): member-public.js logout 内跳登录页的 redirectTo 必须用 reLaunch 模式', () => { const src = fs.readFileSync( path.join(__dirname, '..', 'pages_tool', 'member', 'public', 'js', 'member-public.js'), 'utf8', ); // 找到 logout 函数体 const m = src.match(/logout\s*\(\s*\)\s*\{([\s\S]*?)\n\s{3}\}/); assert.ok(m, '应能找到 logout 函数定义'); const body = m[1]; // 提取所有 this.$util.redirectTo( 调用 const redirectCalls = body.match(/this\.\$util\.redirectTo\s*\([^)]+\)/g) || []; assert.ok(redirectCalls.length >= 1, 'logout 应至少有 1 处 redirectTo (到登录页), 实际 ' + redirectCalls.length); for (const call of redirectCalls) { assert.match(call, /['"]reLaunch['"]/, 'logout 内的 redirectTo 必须用 reLaunch 模式 (防回退看到旧会员信息), 实际: ' + call.trim()); } // 跳的必须是登录页 assert.ok(redirectCalls[0].includes('LOGIN_PAGE_URL'), 'logout 跳的目标必须是 LOGIN_PAGE_URL, 实际: ' + redirectCalls[0].trim()); }); // 场景 P: getCancelStatus 兜底分支 (res.code == -1) → clearLoginState + reLaunch test('场景 P: getCancelStatus res.code == -1 → clearLoginState + reLaunch 到登录页', () => { const { mockThis, calls } = createThisMock(); bindMethods(mockThis, loadMemberPublic({ uni: mockThis.uni })); // 模拟后端返回 -1 (token 失效) mockThis.$api.sendRequest = (opts) => { calls.api.push({ url: opts.url }); if (typeof opts.success === 'function') { opts.success({ code: -1, message: 'token invalid', data: null }); } }; mockThis.getCancelStatus(); // 必须清登录态 assert.ok(calls.commit.some(([t]) => t === 'setToken'), 'getCancelStatus 兜底分支必须清 token'); // 必须 reLaunch (不能用 navigateTo, 否则页面栈残留) assert.equal(calls.redirectTo.length, 1); assert.equal(calls.redirectTo[0].mode, 'reLaunch', 'getCancelStatus 兜底分支跳转必须是 reLaunch, 实际: ' + calls.redirectTo[0].mode); assert.equal(calls.redirectTo[0].to, '/pages_tool/login/login', 'getCancelStatus 兜底分支应跳登录页'); }); // 场景 Q (回归): logout 调用顺序 — clearLoginState 必须在 redirectTo 之前 test('场景 Q (回归): member-public.js logout 内 clearLoginState 必须早于 redirectTo', () => { const src = fs.readFileSync( path.join(__dirname, '..', 'pages_tool', 'member', 'public', 'js', 'member-public.js'), 'utf8', ); const m = src.match(/logout\s*\(\s*\)\s*\{([\s\S]*?)\n\s{3}\}/); assert.ok(m, '应能找到 logout 函数定义'); const body = m[1]; const clearIdx = body.indexOf('this.clearLoginState()'); const redirectIdx = body.indexOf('this.$util.redirectTo('); assert.ok(clearIdx > -1, 'logout 必须调用 clearLoginState()'); assert.ok(redirectIdx > -1, 'logout 必须调用 redirectTo()'); assert.ok(clearIdx < redirectIdx, 'clearLoginState 必须在 redirectTo 之前调用, 实际位置: clear=' + clearIdx + ', redirect=' + redirectIdx); }); // 场景 R (回归): getCancelStatus 兜底分支 也满足顺序 test('场景 R (回归): member-public.js getCancelStatus res.code == -1 分支: clearLoginState 必须早于 redirectTo', () => { const src = fs.readFileSync( path.join(__dirname, '..', 'pages_tool', 'member', 'public', 'js', 'member-public.js'), 'utf8', ); // 找到 getCancelStatus 内 res.code == -1 分支 const funcMatch = src.match(/getCancelStatus\s*\(\s*\)\s*\{([\s\S]*?)\n\s{3}\}/); assert.ok(funcMatch, '应能找到 getCancelStatus 函数定义'); const body = funcMatch[1]; // 找 res.code == -1 那个 else if 分支体 const branchMatch = body.match(/res\.code\s*==\s*-1[\s\S]*?(?=\n\s{3}\}|\n\s{4}\})/); assert.ok(branchMatch, '应能找到 res.code == -1 分支'); const branchBody = branchMatch[0]; const clearIdx = branchBody.indexOf('this.clearLoginState()'); const redirectIdx = branchBody.indexOf('this.$util.redirectTo('); assert.ok(clearIdx > -1, 'res.code == -1 分支必须调用 clearLoginState()'); assert.ok(redirectIdx > -1, 'res.code == -1 分支必须调用 redirectTo()'); assert.ok(clearIdx < redirectIdx, 'res.code == -1 分支: clearLoginState 必须在 redirectTo 之前, 实际: clear=' + clearIdx + ', redirect=' + redirectIdx); });