import assert from 'node:assert/strict'; import { readFile } from 'node:fs/promises'; import test, { after } from 'node:test'; import { fileURLToPath } from 'node:url'; import { createServer } from 'vite'; const vite = await createServer({ root: fileURLToPath(new URL('..', import.meta.url)), configFile: false, optimizeDeps: { noDiscovery: true }, server: { middlewareMode: true, hmr: false }, appType: 'custom' }); after(async () => { await vite.close(); }); const supervisorView = await vite.ssrLoadModule('/src/services/supervisor-view.ts'); const { actionablePracticeAlerts, buildSupervisorMetrics, buildTeamProfileRollup, buildTeamProfiles, buildTrainingTrend, createAsyncGenerationGuard, filterReviewRecords, filterTeamProfiles, inferSupervisorScenarioId, isReviewEditable, isSupervisorAccessDenied, paginateTeamProfiles, reviewCompletionAction, supervisorScenarioOptions } = supervisorView; const { maskPhone } = await vite.ssrLoadModule('/src/services/auth.ts'); const assignPageSource = await readFile( new URL('../src/pages/supervisor/assign/index.vue', import.meta.url), 'utf8' ); const reviewPageSource = await readFile( new URL('../src/pages/supervisor/review/index.vue', import.meta.url), 'utf8' ); const dashboardPageSource = await readFile( new URL('../src/pages/supervisor/index/index.vue', import.meta.url), 'utf8' ); const teamPageSource = await readFile( new URL('../src/pages/supervisor/team/index.vue', import.meta.url), 'utf8' ); const reviewsPageSource = await readFile( new URL('../src/pages/supervisor/reviews/index.vue', import.meta.url), 'utf8' ); const practiceServiceSource = await readFile( new URL('../src/services/practice.ts', import.meta.url), 'utf8' ); const review = (overrides = {}) => ({ id: 1, extPartyId: '13800138000', time: '2026-07-10 09:00:00', trainee: '张三', scene: '投诉接待', score: 86, status: '待复盘', summary: '训练摘要', ...overrides }); const assignment = (overrides = {}) => ({ id: 1, extPartyId: '13800138000', traineeName: '张三', scenarioId: 'complaint-water', scenarioName: '投诉接待', source: 'manual', reason: '主管指派', status: '待训练', createTime: '2026-07-10 09:00:00', ...overrides }); const alert = (overrides = {}) => ({ type: 'low_score', title: '低分训练预警', desc: '0 条训练低于 80 分', severity: 'high', ...overrides }); test('daily 任务不计入主管待训练指标,也不创建团队成员', () => { const assignments = [ assignment({ id: 1, extPartyId: 'daily-user', traineeName: '每日任务员工', source: 'daily' }), assignment({ id: 2, extPartyId: 'manual-user', traineeName: '专项员工' }), assignment({ id: 3, extPartyId: 'done-user', traineeName: '已完成员工', status: '已完成' }) ]; const metrics = buildSupervisorMetrics([], assignments, []); const profiles = buildTeamProfiles([], assignments); assert.equal(metrics.find((item) => item.key === 'pendingAssignments')?.value, 1); assert.deepEqual(profiles.map((item) => item.extPartyId).sort(), ['done-user', 'manual-user']); }); test('低分指标只取 alerts 的后端聚合值', () => { const metrics = buildSupervisorMetrics( [review({ score: 32 })], [], [alert({ desc: '7 条训练低于 80 分,建议派发定向再练' })] ); assert.equal(metrics.find((item) => item.key === 'lowScores')?.value, 7); assert.equal(buildSupervisorMetrics([review({ score: 32 })], [], []) .find((item) => item.key === 'lowScores')?.value, 0); }); test('按 extPartyId 聚合复盘和专项,并区分同名成员', () => { const profiles = buildTeamProfiles( [ review({ id: 1, extPartyId: '13800138000', trainee: '张三', score: 76 }), review({ id: 2, extPartyId: '13800138000', trainee: '张三', score: 91, status: '已复盘' }) ], [ assignment({ id: 1, extPartyId: '13800138000', traineeName: '张三' }), assignment({ id: 2, extPartyId: '13900139000', traineeName: '张三', status: '已完成' }) ] ); assert.equal(profiles.length, 2); assert.deepEqual( profiles.map((item) => item.displayName).sort(), ['张三 · 尾号8000', '张三 · 尾号9000'].sort() ); const first = profiles.find((item) => item.extPartyId === '13800138000'); assert.equal(first?.pendingReview, 1); assert.equal(first?.pendingAssignment, 1); assert.equal(first?.latestScore, 76); assert.equal(first?.latestScene, '投诉接待'); }); test('低于 80 分为高风险,有待复盘为中风险,其余为低风险', () => { const profiles = buildTeamProfiles( [ review({ id: 1, extPartyId: 'high', trainee: '高风险', score: 79 }), review({ id: 2, extPartyId: 'medium', trainee: '中风险', score: 88 }), review({ id: 3, extPartyId: 'low', trainee: '低风险', score: 95, status: '已复盘' }) ], [] ); assert.equal(profiles.find((item) => item.extPartyId === 'high')?.risk, 'high'); assert.equal(profiles.find((item) => item.extPartyId === 'medium')?.risk, 'medium'); assert.equal(profiles.find((item) => item.extPartyId === 'low')?.risk, 'low'); }); test('待复盘列表支持全部、低分和待处理筛选', () => { const rows = [ review({ id: 1, score: 68, status: '待复盘' }), review({ id: 2, score: 88, status: '待复盘' }), review({ id: 3, score: 92, status: '已复盘' }) ]; assert.equal(filterReviewRecords(rows, 'all').length, 3); assert.deepEqual(filterReviewRecords(rows, 'low').map((item) => item.id), [1]); assert.deepEqual(filterReviewRecords(rows, 'pending').map((item) => item.id), [1, 2]); assert.match(reviewsPageSource, /filterTabs/); assert.match(reviewsPageSource, /activeFilter/); assert.match(reviewsPageSource, /displayedReviews/); assert.match(reviewsPageSource, /below60/); assert.match(reviewsPageSource, /repeatLow/); }); test('主管页面共享三个业务场景及文本推荐规则', () => { assert.deepEqual(supervisorScenarioOptions.map((item) => item.id), [ 'fee-parking', 'complaint-water', 'repair-elevator-delay' ]); assert.match(practiceServiceSource, /getPracticeScenarios/); assert.match(assignPageSource, /v-for="item in scenarioOptions"/); assert.match(assignPageSource, /assignmentPosition/); assert.match(assignPageSource, /暂无可派发的已启用场景/); assert.match(reviewPageSource, /scenarioOptions/); assert.match(reviewPageSource, /scenarioOptions\.value = \[\]/); assert.equal(inferSupervisorScenarioId('电梯故障沟通低分'), 'repair-elevator-delay'); assert.equal(inferSupervisorScenarioId('投诉接待需要补练'), 'complaint-water'); assert.equal(inferSupervisorScenarioId('催缴物业费'), 'fee-parking'); }); test('专项派发使用后端批量接口并保留逐人结果和截止日期', () => { assert.match(assignPageSource, /selectedMemberIds/); assert.match(assignPageSource, /targets: selectedMembers\.map/); assert.match(assignPageSource, /requestId: pendingRequestId\.value/); assert.match(assignPageSource, /dueDate: dueDate\.value/); assert.match(assignPageSource, /const resultItems: PracticeAssignmentBatchItemResult\[\] = result\.results \|\| \[\]/); assert.match(assignPageSource, /uni\.setStorageSync\(assignResultStorageKey/); assert.match(assignPageSource, /uni\.redirectTo\(\{ url: '\/pages\/supervisor\/assign\/result' \}\)/); assert.match(assignPageSource, /const maxAssignmentTargets = 100/); assert.match(assignPageSource, /persistPendingBatchDraft/); assert.match(assignPageSource, /restorePendingBatchDraft/); assert.match(assignPageSource, /const isPendingBatchDraft = \(value: unknown\)/); assert.match(assignPageSource, /draft\.selectedMemberIds\.every/); assert.match(assignPageSource, /pendingMatchesDeepLink/); assert.match(assignPageSource, /pendingDraft && \(!hasExplicitDraft \|\| pendingMatchesDeepLink\)/); assert.match(assignPageSource, /const assignmentDeepLink =/); assert.match(assignPageSource, /rememberLoginRedirect\(loginRedirectPath\.value\)/); assert.match(assignPageSource, /loginRedirectPath\.value = assignmentDeepLink\(requestedExtPartyId, requestedReason, requestedScenario\)/); assert.match(practiceServiceSource, /createPracticeAssignmentsBatch/); assert.match(practiceServiceSource, /\/practice\/assignments\/batch/); assert.doesNotMatch(assignPageSource, /for \([\s\S]*createPracticeAssignment/); }); test('stable 提示不计入团队预警指标', () => { const alerts = [ alert({ type: 'stable', title: '团队状态稳定', desc: '当前无新增风险', severity: 'low' }), alert({ type: 'pending_review', title: '待复盘积压', desc: '2 条对练等待主管复盘', severity: 'medium' }) ]; const metrics = buildSupervisorMetrics([], [], alerts); const alertMetric = metrics.find((item) => item.key === 'alerts'); assert.equal(alertMetric?.value, 1); assert.equal(alertMetric?.desc, '请及时跟进风险项'); assert.deepEqual(actionablePracticeAlerts(alerts).map((item) => item.type), ['pending_review']); assert.deepEqual(alerts.map((item) => item.type), ['stable', 'pending_review']); assert.equal(buildSupervisorMetrics([], [], [ alert({ type: 'stable', title: '团队状态稳定', desc: '当前无新增风险', severity: 'low' }) ]).find((item) => item.key === 'alerts')?.value, 0); }); test('主管工作台展示真实训练趋势并压缩预警首屏密度', () => { const trend = buildTrainingTrend([ review({ id: 1, time: '2026-07-15 09:00:00', score: 70 }), review({ id: 2, time: '2026-07-16 09:00:00', score: 82 }), review({ id: 3, time: '2026-07-17 09:00:00', score: 91 }) ]); assert.deepEqual(trend.map((item) => item.value), [70, 82, 91]); assert.match(dashboardPageSource, /class="panel training-trend-panel"/); assert.match(dashboardPageSource, /trainingTrend/); assert.match(dashboardPageSource, /visibleAlerts/); assert.doesNotMatch(dashboardPageSource, / { const profiles = buildTeamProfiles([ review({ id: 1, time: '2026-07-08 09:00:00', scene: '旧低分场景', score: 62, status: '待复盘' }), review({ id: 2, time: '2026-07-10 10:30:00', scene: '新高分场景', score: 93, status: '已复盘' }) ], []); const profile = profiles[0]; assert.equal(profile.latestScore, 93); assert.equal(profile.latestScoreText, '93分'); assert.equal(profile.latestScene, '新高分场景'); assert.equal(profile.lastPracticeAt, '2026-07-10 10:30:00'); assert.equal(profile.risk, 'high'); assert.match(profile.reason, /未复盘低分/); }); test('组织快照中的稳定成员即使没有训练记录也保留在画像中', () => { const profiles = buildTeamProfiles([], [], [ { extPartyId: 'stable-1', name: '稳定员工' } ]); assert.equal(profiles.length, 1); assert.equal(profiles[0].extPartyId, 'stable-1'); assert.equal(profiles[0].latestScoreText, '暂无评分'); assert.equal(profiles[0].risk, 'low'); }); test('团队画像搜索和分页不把 2316 名成员一次性渲染', () => { const profiles = Array.from({ length: 2316 }, (_, index) => ({ key: `member-${index}`, extPartyId: `employee-${index}`, displayName: `员工 ${index}` })); assert.equal(paginateTeamProfiles(profiles, 1, 20).items.length, 20); assert.equal(paginateTeamProfiles(profiles, 1, 20).pageCount, 116); const afterLoadingMore = paginateTeamProfiles(profiles, 2, 20); assert.equal(afterLoadingMore.items.length, 40); assert.equal(afterLoadingMore.items[0].key, 'member-0'); assert.equal(afterLoadingMore.items.at(-1).key, 'member-39'); assert.deepEqual( filterTeamProfiles(profiles, '员工 2315').map((item) => item.key), ['member-2315'] ); assert.match(teamPageSource, /v-for="member in visibleTeamProfiles"/); assert.match(teamPageSource, /filterTeamProfiles\(teamProfiles\.value, teamSearch\.value\)/); assert.match(teamPageSource, /paginateTeamProfiles\(filteredTeamProfiles\.value, teamPage\.value, teamPageSize\)/); assert.match(teamPageSource, /class="load-more-members"/); }); test('全员汇总覆盖截断明细,较早成员的待复盘低分不能被降为正常', () => { const truncatedRecords = Array.from({ length: 200 }, (_, index) => review({ id: index + 1, extPartyId: `recent-${index}`, trainee: `近期员工${index}`, score: 95, status: '已复盘' })); const profiles = buildTeamProfiles( truncatedRecords, [], [ { extPartyId: 'recent-0', name: '近期员工0' }, { extPartyId: 'older-risk', name: '较早成员' } ], [{ extPartyId: 'older-risk', latestScore: 62, latestScene: '投诉接待', lastPracticeAt: '07-01 09:00', pendingReview: 1, pendingLowScore: 62, pendingAssignment: 1 }] ); const olderRisk = profiles.find((item) => item.extPartyId === 'older-risk'); assert.equal(olderRisk?.risk, 'high'); assert.equal(olderRisk?.pendingReview, 1); assert.equal(olderRisk?.pendingAssignment, 1); assert.equal(olderRisk?.latestScore, 62); assert.match(teamPageSource, /snapshot\.profiles/); assert.match(dashboardPageSource, /snapshot\.profiles/); }); test('主管首页从全员汇总计算今日完训和待训练,不受 200 条明细上限影响', () => { const members = Array.from({ length: 201 }, (_, index) => ({ extPartyId: `member-${index}`, name: `员工${index}` })); const profiles = buildTeamProfiles( Array.from({ length: 200 }, (_, index) => review({ id: index + 1, extPartyId: `member-${index}`, trainee: `员工${index}`, time: '07-22 10:00', score: 92, status: '已复盘' })), [], members, members.map((member, index) => ({ extPartyId: member.extPartyId, latestScore: index === 200 ? 88 : 92, latestScene: '投诉接待', lastPracticeAt: '07-22 10:00', completedToday: index === 200, pendingReview: 0, pendingLowScore: null, pendingAssignment: index === 200 ? 1 : 0 })) ); assert.deepEqual(buildTeamProfileRollup(profiles, '07-23'), { teamTotal: 201, pendingReview: 0, pendingAssignment: 1, todayDone: 1 }); assert.match(dashboardPageSource, /buildTeamProfileRollup/); }); test('员工和主管个人页只展示脱敏手机号', async () => { const [employeeProfile, supervisorProfile] = await Promise.all([ readFile(new URL('../src/pages/user/profile/index.vue', import.meta.url), 'utf8'), readFile(new URL('../src/pages/supervisor/profile/index.vue', import.meta.url), 'utf8') ]); assert.equal(maskPhone('181368856126'), '181****6126'); assert.equal(maskPhone('invalid'), 'invalid'); assert.match(employeeProfile, /maskedPhone/); assert.match(supervisorProfile, /maskedPhone/); assert.doesNotMatch(employeeProfile, /\{\{ phone \|\|/); assert.doesNotMatch(supervisorProfile, /\{\{ phone \|\|/); }); test('主管个人页展示当前账号范围、团队成果和学员模式入口', async () => { const source = await readFile(new URL('../src/pages/supervisor/profile/index.vue', import.meta.url), 'utf8'); assert.match(source, /maskedPhone/); assert.match(source, /主管端团队数据仅对当前项目范围可见/); assert.match(source, /团队工作成果/); assert.match(source, /\/pages\/supervisor\/work-results\/index/); assert.match(source, /进入学员模式/); assert.match(source, /enterSupervisorLearnerMode/); assert.match(source, / { const profiles = buildTeamProfiles([], [], [ { extPartyId: '13900001111', name: '???????' }, { extPartyId: 'employee-project-A-000123', name: '????????A_000123' } ]); assert.deepEqual( profiles.map((item) => item.displayName).sort(), ['员工 尾号1111', '员工 末尾000123'].sort() ); assert.equal(profiles.every((item) => !item.name.includes('?')), true); assert.equal(profiles.every((item) => !item.reason.includes('?')), true); }); test('只有待复盘可编辑,成功返回仅在上一页为 reviews 时后退', () => { assert.equal(isReviewEditable('待复盘'), true); assert.equal(isReviewEditable('已复盘'), false); assert.equal(reviewCompletionAction('pages/supervisor/reviews/index'), 'back'); assert.equal(reviewCompletionAction('/pages/supervisor/reviews/index'), 'back'); assert.equal(reviewCompletionAction('pages/supervisor/index/index'), 'replace'); assert.equal(reviewCompletionAction(undefined), 'replace'); }); test('异步代次守卫拒绝刷新后的旧结果和组件销毁后的结果', () => { const guard = createAsyncGenerationGuard(); const audioGeneration = guard.capture(); assert.equal(guard.isCurrent(audioGeneration), true); guard.invalidate(); assert.equal(guard.isCurrent(audioGeneration), false); const historyGeneration = guard.begin(); assert.equal(guard.isCurrent(historyGeneration), true); guard.dispose(); assert.equal(guard.isCurrent(historyGeneration), false); }); test('只把主管权限错误识别为访问拒绝', () => { assert.equal(isSupervisorAccessDenied(new Error('无主管权限,无法访问主管功能')), true); assert.equal(isSupervisorAccessDenied('当前账号无主管权限'), true); assert.equal(isSupervisorAccessDenied(new Error('登录已过期,请重新登录')), false); assert.equal(isSupervisorAccessDenied(new Error('网络请求失败')), false); assert.equal(isSupervisorAccessDenied({ message: '无主管权限' }), false); assert.equal(isSupervisorAccessDenied(null), false); }); test('复盘详情只允许待复盘提交并保留未保存草稿', () => { assert.match(reviewPageSource, /const editable = computed\(\(\) => isReviewEditable\(detail\.value\?\.status\)\)/); assert.match(reviewPageSource, /v-if="editable" class="panel review-form"/); assert.match(reviewPageSource, /v-else class="panel review-complete"/); assert.match(reviewPageSource, /if \(!editable\.value\)[\s\S]*该记录已完成复盘/); assert.match(reviewPageSource, /onShow\(\(\) => \{[\s\S]*refreshAuthState\(\);[\s\S]*if \(!loggedIn\.value\) \{[\s\S]*accessDenied\.value = false;[\s\S]*clearDetail\(\);\s*return;\s*\}[\s\S]*if \(!detail\.value && !loading\.value\)[\s\S]*loadDetail\(\)/); assert.match(reviewPageSource, /AI 摘要/); assert.match(reviewPageSource, /对话与录音/); assert.match(reviewPageSource, /标注与建议/); assert.match(reviewPageSource, /复盘后建议补练/); }); test('复盘详情登录和完成导航不堆叠页面', () => { assert.match(reviewPageSource, /rememberLoginRedirect\(currentPath\(\)\);\s*uni\.redirectTo/); assert.match(reviewPageSource, /const goBack = \(\) => \{[\s\S]*getCurrentPages\(\)\.length <= 1/); assert.match(reviewPageSource, /uni\.navigateBack\(\{ delta: 1, fail: goToReviews \}\)/); assert.match(reviewPageSource, /const goToReviews = \(\) => \{\s*uni\.redirectTo/); }); test('复盘音频晚到结果被释放且不会写入页面状态', () => { assert.match(reviewPageSource, /const audioRequests = createAsyncGenerationGuard\(\)/); assert.match(reviewPageSource, /const generation = audioRequests\.capture\(\)/); assert.match(reviewPageSource, /if \(!audioRequests\.isCurrent\(generation\)\) \{\s*releaseAuthenticatedFileUrl\(source\);\s*return;/); assert.match(reviewPageSource, /audioRequests\.dispose\(\)/); }); test('指派页登录失效清空私密状态但保留明确深链任务', () => { assert.match(assignPageSource, /const clearPrivateState = \(\) => \{[\s\S]*resetDraft\(\);[\s\S]*assignments\.value = \[\]/); assert.match(assignPageSource, /if \(!loggedIn\.value\) return '选择员工并配置训练任务'/); assert.match(assignPageSource, /rememberLoginRedirect\(loginRedirectPath\.value\);\s*uni\.redirectTo/); assert.match(assignPageSource, /const loginRedirectPath = ref\(basePath\)/); }); test('指派中锁定返回和刷新,写入成功后强制启动新历史读取', () => { assert.match(assignPageSource, /class="subpage-back-button"[\s\S]*:disabled="assigning"/); assert.match(assignPageSource, /:disabled="historyLoading \|\| assigning"\s*@click="loadAssignments\(\)"/); assert.match(assignPageSource, /const loadAssignments = async \(force = false\)/); assert.match(assignPageSource, /const generation = historyRequests\.begin\(\)/); assert.match(assignPageSource, /await loadAssignments\(true\)/); assert.match(assignPageSource, /const goBack = \(\) => \{\s*if \(assigning\.value\) return;/); }); test('指派页销毁时废止历史请求,晚到结果不能写回', () => { assert.match(assignPageSource, /import \{ computed, onBeforeUnmount, ref \} from 'vue'/); assert.match(assignPageSource, /onBeforeUnmount\(\(\) => \{\s*historyRequests\.dispose\(\);\s*\}\)/); }); test('五个主管页面优先显示统一无权限状态并清理私密数据', () => { const pages = [ { source: dashboardPageSource, clear: 'clearData' }, { source: teamPageSource, clear: 'clearData' }, { source: assignPageSource, clear: 'clearPrivateState' }, { source: reviewsPageSource, clear: 'clearData' }, { source: reviewPageSource, clear: 'clearDetail' } ]; pages.forEach(({ source, clear }) => { assert.match(source, /const accessDenied = ref\(false\)/); assert.match(source, / \{\s*uni\.switchTab\(\{ url: '\/pages\/user\/today\/index' \}\);\s*\};/); assert.match(source, /const switchAccount = \(\) => \{\s*clearAuth\(\);\s*rememberLoginRedirect\([\s\S]*?\);\s*uni\.redirectTo\(\{ url: '\/pages\/auth\/login\/index' \}\);\s*\};/); }); }); test('指派页通过团队快照在进入时校验主管权限', () => { assert.match(assignPageSource, /getPracticeTeamSnapshot/); assert.match(assignPageSource, /getPracticeTeamSnapshot\(\)/); assert.match(assignPageSource, /assignments\.value = snapshot\.assignments/); assert.doesNotMatch(assignPageSource, /getPracticeAssignments/); }); test('指派页将逐人派发结果交由独立结果页呈现,避免在表单页堆叠', () => { assert.match(assignPageSource, /class="panel form-panel"/); assert.match(assignPageSource, /class="panel progress-panel"/); assert.match(assignPageSource, /assignResultStorageKey/); assert.match(assignPageSource, /\/pages\/supervisor\/assign\/result/); }); test('指派页保留 H5 查询参数二次解码', () => { assert.match(assignPageSource, /decodeURIComponent\(value\)\.trim\(\)/); }); test('主管 dashboard 和 team 使用团队快照,reviews 继续使用待复盘接口', () => { assert.match(practiceServiceSource, /export const getPracticeTeamSnapshot = \(includeProfiles = false\) =>/); assert.match(practiceServiceSource, /'\/api\/aihr\/mobile\/practice\/team\?includeProfiles=true'/); assert.match(practiceServiceSource, /: '\/api\/aihr\/mobile\/practice\/team'/); assert.match(practiceServiceSource, /includeProfiles=true/); assert.match(dashboardPageSource, /getPracticeTeamSnapshot\(true\)/); assert.match(dashboardPageSource, /buildTeamProfiles\(records\.value, assignments\.value, members\.value, teamProfileSummaries\.value\)/); assert.match(teamPageSource, /getPracticeTeamSnapshot\(true\)/); assert.match(teamPageSource, /buildTeamProfiles\(records\.value, assignments\.value, members\.value, teamProfileSummaries\.value\)/); assert.match(reviewsPageSource, /getPracticeReviews/); assert.match(reviewsPageSource, /getPracticeTeamSnapshot\(\)\.catch/); }); test('五个主管页面登录都使用 redirectTo 避免返回栈重复', () => { const pages = [ dashboardPageSource, teamPageSource, reviewsPageSource, assignPageSource, reviewPageSource ]; pages.forEach((source) => { const goLogin = source.match(/const goLogin = \(\) => \{\n[\s\S]*?\n\};/)?.[0] || ''; assert.match(goLogin, /rememberLoginRedirect\(/); assert.match(goLogin, /uni\.redirectTo\(\{ url: '\/pages\/auth\/login\/index' \}\)/); assert.doesNotMatch(goLogin, /uni\.navigateTo\(/); }); });