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, 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, /reviewFilters/); assert.match(reviewsPageSource, /visibleReviews/); }); 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 filteredScenarioOptions"/); assert.match(assignPageSource, /scenarioSearch/); 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('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); assert.deepEqual( filterTeamProfiles(profiles, '员工 2315').map((item) => item.key), ['member-2315'] ); assert.match(teamPageSource, /v-for="member in visibleTeamProfiles"/); assert.match(teamPageSource, /v-if="selectedMember" class="panel member-detail-panel"/); assert.match(teamPageSource, /