772 lines
39 KiB
JavaScript
772 lines
39 KiB
JavaScript
import assert from 'node:assert/strict';
|
|
import { readFile } from 'node:fs/promises';
|
|
import test from 'node:test';
|
|
|
|
const pageSource = async (path) => readFile(new URL(path, import.meta.url), 'utf8');
|
|
|
|
test('共享输入框使用 border-box 且不覆盖原生控件', async () => {
|
|
const source = await pageSource('../src/styles/base.css');
|
|
const textareaStyle = source.match(/\.textarea\s*\{[\s\S]*?\n\}/)?.[0] || '';
|
|
|
|
assert.doesNotMatch(source, /view,\s*\ntext,\s*\ninput,/);
|
|
assert.doesNotMatch(source, /view,\s*\ntext,\s*\ntextarea,/);
|
|
assert.doesNotMatch(source, /(^|\n)input\s*\{[^}]*box-sizing:\s*border-box/m);
|
|
assert.doesNotMatch(source, /(^|\n)textarea\s*\{[^}]*box-sizing:\s*border-box/m);
|
|
assert.match(textareaStyle, /box-sizing:\s*border-box/);
|
|
});
|
|
|
|
test('SOP 查询框不再覆盖 textarea 的 box-sizing', async () => {
|
|
const source = await pageSource('../src/pages/user/sop/index.vue');
|
|
const queryPanelStyle = source.match(/\.query-panel \.textarea \{[\s\S]*?\n\}/)?.[0] || '';
|
|
|
|
assert.equal(queryPanelStyle.includes('box-sizing'), false);
|
|
});
|
|
|
|
test('员工首页核心工具保留真实业务入口', async () => {
|
|
const source = await pageSource('../src/pages/user/today/index.vue');
|
|
|
|
assert.match(source, /class="voice-card employee-card"/);
|
|
assert.match(source, /\{\{ voiceTitle \}\}/);
|
|
assert.match(source, /class="tool-cell tool-questions"/);
|
|
assert.match(source, /<text class="tool-name">问题榜<\/text>/);
|
|
assert.match(source, /const ask = \(query: string\) =>/);
|
|
assert.match(source, /const goPractice = \(\) =>/);
|
|
assert.match(source, /const openQuestions = \(\) =>/);
|
|
});
|
|
|
|
test('训练页提交失败会回滚本次乐观追加的对话', async () => {
|
|
const source = await pageSource('../src/pages/user/practice/index.vue');
|
|
|
|
assert.match(source, /const previousTurns = turns\.value\.slice\(\)/);
|
|
assert.match(source, /turns\.value = previousTurns/);
|
|
assert.match(source, /uni\.showToast\(\{ title: message\.value \|\| '提交失败', icon: 'none'/);
|
|
});
|
|
|
|
test('训练主操作使用当前已发布的场景,而不覆盖为内置默认场景', async () => {
|
|
const source = await pageSource('../src/pages/user/practice/index.vue');
|
|
|
|
assert.match(source, /@click="start\(selectedScenarioId\)"/);
|
|
assert.match(source, /const start = async \(scenarioId = selectedScenarioId\.value, assignmentId\?: number\)/);
|
|
});
|
|
|
|
test('训练求助先留存会话事件,再以 practice_help 来源检索 SOP', async () => {
|
|
const [practicePage, practiceService, knowledgeService] = await Promise.all([
|
|
pageSource('../src/pages/user/practice/index.vue'),
|
|
pageSource('../src/services/practice.ts'),
|
|
pageSource('../src/services/knowledge.ts')
|
|
]);
|
|
|
|
assert.match(practiceService, /sessions\/\$\{encodeURIComponent\(sessionId\)\}\/help/);
|
|
assert.match(practicePage, /await recordPracticeHelp\(sessionId\.value, roundIndex\.value\)/);
|
|
assert.match(practicePage, /'practice_help'/);
|
|
assert.match(knowledgeService, /'practice_help'/);
|
|
});
|
|
|
|
test('语音 profile 透传方言枚举,并保持普通话默认值', async () => {
|
|
const [apiTypes, speechService] = await Promise.all([
|
|
pageSource('../src/types/api.ts'),
|
|
pageSource('../src/services/speech.ts')
|
|
]);
|
|
|
|
assert.match(apiTypes, /dialect\?: 'mandarin' \| 'cantonese'[\s\S]*\| 'sichuanese'/);
|
|
assert.match(speechService, /dialect: 'mandarin'/);
|
|
assert.match(speechService, /customerVoiceProfile = \([\s\S]*?dialect:/);
|
|
assert.match(speechService, /dialect === 'cantonese'.*'zh-HK'/);
|
|
assert.match(speechService, /dialect && dialect !== 'mandarin'/);
|
|
});
|
|
|
|
test('M3 将业主 TTS 绑定到当前训练会话与准确回合,供主管复盘回放', async () => {
|
|
const [practicePage, speechService, apiTypes] = await Promise.all([
|
|
pageSource('../src/pages/user/practice/index.vue'),
|
|
pageSource('../src/services/speech.ts'),
|
|
pageSource('../src/types/api.ts')
|
|
]);
|
|
|
|
assert.match(apiTypes, /export interface PracticeTtsContext/);
|
|
assert.match(speechService, /practiceContext\?: PracticeTtsContext/);
|
|
assert.match(speechService, /data: \{ text: text\.trim\(\)\.slice\(0, 300\), voiceProfile: resolveTtsVoiceProfile\(voiceProfile\), practiceContext \}/);
|
|
assert.match(practicePage, /\{ sessionId: sessionId\.value, turnIndex: Number\(customerTurnIndex\), role: 'customer' \}/);
|
|
assert.match(practicePage, /speechPlayback\.preload\(spoken, profile, practiceContext\)/);
|
|
assert.match(practicePage, /turnVoice\(index\)\.profile, turnVoice\(index\)\.practiceContext/);
|
|
assert.match(practicePage, /let turnVoiceGeneration = 0/);
|
|
assert.match(practicePage, /turnVoiceGeneration === generation && turnVoices\.value\[index\]\?\.requestKey === requestKey/);
|
|
assert.match(practicePage, /turnVoiceGeneration \+= 1/);
|
|
assert.match(practicePage, /appendTurn\('customer', data\.customerText, data\.emotion, true, 0\)/);
|
|
assert.match(practicePage, /appendTurn\('customer', data\.customerText, data\.emotion, true, data\.roundIndex\)/);
|
|
});
|
|
|
|
test('训练结果回到顶部,SOP 答案生成后定位到回答区', async () => {
|
|
const [practiceSource, sopSource, navigationSource] = await Promise.all([
|
|
pageSource('../src/pages/user/practice/index.vue'),
|
|
pageSource('../src/pages/user/sop/index.vue'),
|
|
pageSource('../src/services/navigation.ts')
|
|
]);
|
|
|
|
assert.match(practiceSource, /const nextResult = await finishPractice\([\s\S]*?result\.value = nextResult;\s*resetPageScroll\(\)/);
|
|
assert.match(sopSource, /const scrollToBottom = \(\) =>/);
|
|
assert.match(sopSource, /replaceMsg\(pending\.id/);
|
|
assert.match(sopSource, /threadScrollTop\.value = 100000/);
|
|
assert.match(navigationSource, /export const scrollPageToSelector = \(selector: string/);
|
|
});
|
|
|
|
test('训练历史按摘要、逐句复盘、SOP 依据和行动建议分层', async () => {
|
|
const source = await pageSource('../src/pages/user/history/index.vue');
|
|
|
|
assert.match(source, /训练摘要/);
|
|
assert.match(source, /录音与逐句复盘/);
|
|
assert.match(source, /SOP 依据与改进建议/);
|
|
assert.match(source, /推荐话术/);
|
|
});
|
|
|
|
test('案例页为无权限和无案例提供明确空状态', async () => {
|
|
const source = await pageSource('../src/pages/user/cases/index.vue');
|
|
|
|
assert.match(source, /<StateBlock v-else type="empty" :card="false" title="暂无入库案例"/);
|
|
assert.match(source, /当前岗位可查看本项目已入库案例/);
|
|
assert.match(source, /提交案例素材需要主管或项目负责人权限/);
|
|
});
|
|
|
|
test('manifest 明确声明为 uni-app Vue3 CLI 工程', async () => {
|
|
const source = await pageSource('../src/manifest.json');
|
|
|
|
assert.match(source, /"vueVersion"\s*:\s*"3"/);
|
|
});
|
|
|
|
test('员工端 tabBar 继续保留四个固定入口', async () => {
|
|
const source = await pageSource('../src/pages.json');
|
|
|
|
assert.match(source, /"selectedColor"\s*:\s*"#e60012"/);
|
|
assert.match(source, /pages\/user\/today\/index/);
|
|
assert.match(source, /pages\/user\/practice\/index/);
|
|
assert.match(source, /pages\/user\/sop\/index/);
|
|
assert.match(source, /pages\/user\/profile\/index/);
|
|
});
|
|
|
|
test('登录页保留品牌结构并按开发开关控制验证码行', async () => {
|
|
const source = await pageSource('../src/pages/auth/login/index.vue');
|
|
|
|
assert.match(source, /手机号登录,立刻上手/);
|
|
assert.match(source, /<uni-icons[^>]+type="phone"/);
|
|
assert.match(source, /<uni-icons[^>]+type="locked"/);
|
|
assert.match(source, /v-if="smsVerificationEnabled" label="验证码"/);
|
|
assert.match(source, /const smsVerificationEnabled = !import\.meta\.env\.DEV/);
|
|
assert.match(source, /VITE_SMS_VERIFICATION_ENABLED === 'true'/);
|
|
assert.match(source, /rules: smsVerificationEnabled \? \[/);
|
|
assert.doesNotMatch(source, /class="logo-badge"/);
|
|
assert.doesNotMatch(source, /grid-template-columns:\s*1fr;[\s\S]*?\.code-button/);
|
|
});
|
|
|
|
test('移动端短信登录不再让客户端选择租户', async () => {
|
|
const source = await pageSource('../src/pages/auth/login/index.vue');
|
|
const loginRequest = source.match(/url: '\/auth\/mobile\/sms-login',[\s\S]*?auth: false/)?.[0] || '';
|
|
|
|
assert.match(loginRequest, /phonenumber: value/);
|
|
assert.match(loginRequest, /smsCode: formData\.smsCode\.trim\(\)/);
|
|
assert.doesNotMatch(loginRequest, /tenantId:/);
|
|
});
|
|
|
|
test('登录前协议默认不勾选且协议未配置时禁止发送验证码和登录', async () => {
|
|
const source = await pageSource('../src/pages/auth/login/index.vue');
|
|
|
|
assert.match(source, /const agreed = ref\(false\)/);
|
|
assert.match(source, /openLegalDocument\('terms'\)/);
|
|
assert.match(source, /openLegalDocument\('privacy'\)/);
|
|
assert.match(source, /const ensureLegalConsent = \(\) =>/);
|
|
assert.match(source, /const sendCode = async \(\) => \{[\s\S]*?if \(!ensureLegalConsent\(\)\) return;/);
|
|
assert.match(source, /const login = async \(\) => \{[\s\S]*?if \(!ensureLegalConsent\(\)\) return;/);
|
|
assert.match(source, /协议地址尚未完成法务配置/);
|
|
});
|
|
|
|
test('测试验证码提示必须由开发环境显式开关启用', async () => {
|
|
const source = await pageSource('../src/pages/auth/login/index.vue');
|
|
|
|
assert.match(source, /v-if="smsVerificationEnabled && showDevSmsHint"/);
|
|
assert.match(source, /import\.meta\.env\.VITE_DEV_SMS_HINT_VALUE/);
|
|
assert.match(source, /import\.meta\.env\.VITE_DEV_SMS_HINT_ENABLED === 'true'/);
|
|
assert.match(source, /\/\^\\d\{4,6\}\$\/\.test\(devSmsHintValue\)/);
|
|
assert.doesNotMatch(source, /123456/);
|
|
});
|
|
|
|
test('共享视觉基座包含员工端主色和卡片圆角 token', async () => {
|
|
const source = await pageSource('../src/styles/base.css');
|
|
|
|
assert.match(source, /lxgw-wenkai-tc-webfont/);
|
|
assert.match(source, /font-family:\s*"LXGW WenKai TC"/);
|
|
assert.match(source, /--employee-primary:\s*#e60012/);
|
|
assert.match(source, /--employee-card-radius:\s*24px/);
|
|
assert.match(source, /\.employee-card\s*\{/);
|
|
});
|
|
|
|
test('员工端首页、问师傅和训练页接入当前共享基座', async () => {
|
|
const [todaySource, sopSource, practiceSource] = await Promise.all([
|
|
pageSource('../src/pages/user/today/index.vue'),
|
|
pageSource('../src/pages/user/sop/index.vue'),
|
|
pageSource('../src/pages/user/practice/index.vue')
|
|
]);
|
|
|
|
assert.match(todaySource, /employee-page-shell/);
|
|
assert.match(todaySource, /section-card employee-card/);
|
|
assert.match(sopSource, /sop-chat-page/);
|
|
assert.match(sopSource, /<ChatComposer/);
|
|
assert.match(practiceSource, /scenario-card employee-card/);
|
|
});
|
|
|
|
test('工作助手与查全网使用一致的来源切换头部', async () => {
|
|
const [sopSource, webAiSource, headerSource] = await Promise.all([
|
|
pageSource('../src/pages/user/sop/index.vue'),
|
|
pageSource('../src/pages/user/web-ai/index.vue'),
|
|
pageSource('../src/components/chat/AskSourceHeader.vue')
|
|
]);
|
|
|
|
for (const page of [sopSource, webAiSource]) {
|
|
assert.match(page, /import AskSourceHeader from ['"]@\/components\/chat\/AskSourceHeader\.vue['"]/);
|
|
assert.match(page, /<AskSourceHeader\b/);
|
|
assert.doesNotMatch(page, /(?:^|\n)\s*\.chat-source-switch(?:\s+\.source-option(?:\.active)?)?\s*\{/);
|
|
}
|
|
|
|
assert.match(headerSource, /<text class="brand-name">数字师傅<\/text>/);
|
|
assert.match(headerSource, /\.source-option\s*\{[^}]*height:\s*28px/);
|
|
assert.match(headerSource, /\.new-chat-btn\s*\{[^}]*height:\s*28px/);
|
|
assert.match(headerSource, /@click="emit\('newConversation'\)"/);
|
|
assert.doesNotMatch(sopSource, /(?:^|\n)\s*\.source-switch\s*\{/);
|
|
assert.doesNotMatch(sopSource, /(?:^|\n)\s*\.source-option(?:\.active)?\s*\{/);
|
|
});
|
|
|
|
test('登录后按组织身份切换主管首页', async () => {
|
|
const [source, loginSource] = await Promise.all([
|
|
pageSource('../src/services/position.ts'),
|
|
pageSource('../src/pages/auth/login/index.vue')
|
|
]);
|
|
|
|
assert.match(source, /\/api\/aihr\/mobile\/me/);
|
|
assert.match(source, /role === 'supervisor'/);
|
|
assert.match(source, /pages\/supervisor\/index\/index/);
|
|
assert.match(source, /removeStorageSync\(roleKey\)/);
|
|
assert.match(source, /identity\.role !== 'user' && identity\.role !== 'supervisor'/);
|
|
assert.match(source, /clearOrgPosition\(\);\s*throw error/);
|
|
assert.doesNotMatch(source, /catch\s*\{[\s\S]*return 'user'/);
|
|
assert.doesNotMatch(source, /return hasOrgPosition\(\) \? getSelectedPosition\(\) : ''/);
|
|
assert.match(loginSource, /const resumeAfterLogin = async \(\) => \{/);
|
|
assert.match(loginSource, /message\.value = error instanceof Error \? error\.message : '组织身份加载失败,请重试'/);
|
|
assert.doesNotMatch(loginSource, /void goAfterLogin\(\)/);
|
|
});
|
|
|
|
test('普通员工请求主管深链时回到员工首页', async () => {
|
|
const source = await pageSource('../src/services/position.ts');
|
|
|
|
assert.match(source, /role === 'user' && isSupervisorRoute\(requested\)/);
|
|
assert.match(source, /return '\/pages\/user\/today\/index'/);
|
|
});
|
|
|
|
test('主管可切换到学员显示模式且组织角色保持不变', async () => {
|
|
const [positionSource, authSource, supervisorProfile, userProfile] = await Promise.all([
|
|
pageSource('../src/services/position.ts'),
|
|
pageSource('../src/services/auth.ts'),
|
|
pageSource('../src/pages/supervisor/profile/index.vue'),
|
|
pageSource('../src/pages/user/profile/index.vue')
|
|
]);
|
|
|
|
assert.match(authSource, /supervisorLearnerModeKey/);
|
|
assert.match(authSource, /enterSupervisorLearnerMode/);
|
|
assert.match(authSource, /leaveSupervisorLearnerMode/);
|
|
assert.match(authSource, /经理.*负责人.*组长/);
|
|
assert.match(authSource, /项目管理/);
|
|
assert.match(positionSource, /isSupervisorLearnerMode/);
|
|
assert.match(supervisorProfile, /进入学员模式/);
|
|
assert.match(userProfile, /返回主管工作台/);
|
|
assert.doesNotMatch(supervisorProfile, /setStorageSync\(roleKey,\s*'user'/);
|
|
});
|
|
|
|
test('员工首页保留师傅区、语音条、今日安排和问题榜结构', async () => {
|
|
const source = await pageSource('../src/pages/user/today/index.vue');
|
|
|
|
assert.match(source, /hero-immersive/);
|
|
assert.match(source, /voice-card/);
|
|
assert.match(source, /今日安排/);
|
|
assert.match(source, /问题榜/);
|
|
assert.match(source, /brand-bar/);
|
|
assert.match(source, /mentor-chips/);
|
|
assert.match(source, /小贴士/);
|
|
});
|
|
|
|
test('员工首页高保真入口继续使用真实图标和业务跳转', async () => {
|
|
const source = await pageSource('../src/pages/user/today/index.vue');
|
|
|
|
assert.match(source, /type="notification"/);
|
|
assert.match(source, /type="chatboxes-filled"/);
|
|
assert.match(source, /type="paperplane-filled"/);
|
|
assert.match(source, /type="staff-filled"/);
|
|
assert.match(source, /const ask = \(query: string\) =>/);
|
|
assert.match(source, /const openPrimarySchedule = \(\) =>/);
|
|
assert.match(source, /getMobileHome\('user'\)/);
|
|
assert.match(source, /getPracticeAssignments\(true\)/);
|
|
assert.match(source, /src="\/static\/mentor-female\.png"/);
|
|
});
|
|
|
|
test('员工首页今日安排保留派发任务来源', async () => {
|
|
const source = await pageSource('../src/pages/user/today/index.vue');
|
|
|
|
assert.match(source, /primaryAssignmentTagText/);
|
|
assert.match(source, /source === 'retry'[\s\S]*错题再练/);
|
|
assert.match(source, /source === 'camp'[\s\S]*训练营/);
|
|
assert.match(source, /class="schedule-tag"/);
|
|
assert.match(source, /提交后显示反馈与参考答案/);
|
|
});
|
|
|
|
test('超大字模式下今日安排将文字和操作按钮纵向排布', async () => {
|
|
const source = await pageSource('../src/pages/user/today/index.vue');
|
|
|
|
assert.match(source, /html\[data-aihr-font-size='extra-large'\] \.schedule-row-body\) \{\s*flex-direction:\s*column;[\s\S]*?align-items:\s*stretch;/);
|
|
assert.match(source, /html\[data-aihr-font-size='extra-large'\] \.schedule-action\) \{[\s\S]*?width:\s*100%;[\s\S]*?flex:\s*0 0 auto;/);
|
|
assert.match(source, /html\[data-aihr-font-size='extra-large'\] \.schedule-copy\) \{[\s\S]*?width:\s*100%;[\s\S]*?flex:\s*0 1 auto;/);
|
|
});
|
|
|
|
test('问师傅页保留搜索框、答案区和总结卡入口', async () => {
|
|
const [source, service] = await Promise.all([
|
|
pageSource('../src/pages/user/sop/index.vue'),
|
|
pageSource('../src/services/knowledge.ts')
|
|
]);
|
|
|
|
assert.match(source, /query-panel/);
|
|
assert.match(source, /answer-panel/);
|
|
assert.match(source, /summary-panel/);
|
|
assert.match(source, /MY_PRACTICE_SUMMARY/);
|
|
assert.match(source, /TEAM_PRACTICE_SUMMARY/);
|
|
assert.match(source, /const summaryFragmentIds = \(msg: MasterMsg\)/);
|
|
assert.match(source, /generateSummaryCard\(\{ queryText: value, fragmentIds \}\)/);
|
|
assert.match(service, /fragmentIds: number\[\]/);
|
|
assert.match(service, /fragmentIds: params\.fragmentIds/);
|
|
});
|
|
|
|
test('SOP 没解决先收集结构化原因再提交', async () => {
|
|
const [source, service] = await Promise.all([
|
|
pageSource('../src/pages/user/sop/index.vue'),
|
|
pageSource('../src/services/knowledge.ts')
|
|
]);
|
|
|
|
assert.match(source, /feedbackReasonOptions/);
|
|
assert.match(source, /negative-feedback-panel/);
|
|
assert.match(source, /submitNegativeFeedback/);
|
|
for (const code of ['NO_ANSWER', 'NOT_SPECIFIC', 'WRONG_REFERENCE', 'NOT_APPLICABLE']) {
|
|
assert.match(source, new RegExp(`code: '${code}'`));
|
|
}
|
|
assert.doesNotMatch(source, /MISSING_POLICY|ANSWER_INCORRECT/);
|
|
assert.match(source, /:disabled="msg\.feedbackSubmitting \|\| !msg\.feedbackReasonCodes\.length"/);
|
|
assert.match(source, /if \(!msg\.feedbackReasonCodes\.length\) \{[\s\S]*请至少选择一项未解决原因/);
|
|
assert.match(source, /reasonCodes: verdict === 'down' \? msg\.feedbackReasonCodes/);
|
|
assert.match(source, /comment: verdict === 'down' \? msg\.feedbackComment\.trim\(\)/);
|
|
assert.match(service, /reasonCodes\?:\s*string\[\]/);
|
|
assert.match(service, /comment\?:\s*string/);
|
|
});
|
|
|
|
test('训练页保留任务卡、对话区、回复区和结果区并互斥切换', async () => {
|
|
const source = await pageSource('../src/pages/user/practice/index.vue');
|
|
|
|
assert.match(source, /scenario-card/);
|
|
assert.match(source, /assignment-panel/);
|
|
assert.match(source, /dialogue-card/);
|
|
assert.match(source, /reply-card/);
|
|
assert.match(source, /result-card/);
|
|
assert.match(source, /class="result-actions"/);
|
|
assert.match(source, />再练一次<\/button>/);
|
|
assert.match(source, /practiceView === 'prep'/);
|
|
assert.match(source, /practiceView === 'active'/);
|
|
assert.match(source, /practiceView\.value === 'result'/);
|
|
assert.match(source, /result\.totalScore/);
|
|
assert.match(source, /result\.aiComment/);
|
|
assert.match(source, /result\.mentorRewrite/);
|
|
});
|
|
|
|
test('老师傅提示由 LLM 生成可发送回复后再填入输入框', async () => {
|
|
const [source, service] = await Promise.all([
|
|
pageSource('../src/pages/user/practice/index.vue'),
|
|
pageSource('../src/services/practice.ts')
|
|
]);
|
|
|
|
assert.match(source, /suggestReply:\s*true/);
|
|
assert.match(source, /data\.suggestedReply/);
|
|
assert.match(source, /setDraft\(value\)/);
|
|
assert.doesNotMatch(source, /const adoptCoachHint[\s\S]{0,300}setDraft\(\(turn\.text/);
|
|
assert.match(service, /suggestReply\?:\s*boolean/);
|
|
});
|
|
|
|
test('底部固定导航页面复用安全操作区', async () => {
|
|
const [base, practice, assign, review] = await Promise.all([
|
|
pageSource('../src/styles/base.css'),
|
|
pageSource('../src/pages/user/practice/index.vue'),
|
|
pageSource('../src/pages/supervisor/assign/index.vue'),
|
|
pageSource('../src/pages/supervisor/review/index.vue')
|
|
]);
|
|
|
|
assert.match(base, /--app-bottom-nav-space/);
|
|
assert.match(base, /\.tabbar-safe-action\s*\{/);
|
|
assert.match(practice, /primary-button tabbar-safe-action/);
|
|
assert.match(assign, /primary-button submit-button tabbar-safe-action/);
|
|
assert.match(review, /primary-button submit-button tabbar-safe-action/);
|
|
});
|
|
|
|
test('员工练习页显示当前岗位而不是固定生活顾问', async () => {
|
|
const source = await pageSource('../src/pages/user/practice/index.vue');
|
|
|
|
assert.match(source, /getSelectedPosition/);
|
|
assert.match(source, /const positionLabel = ref\(getSelectedPosition\(\) \|\| '生活顾问'\)/);
|
|
assert.match(source, /const selectedScenarioDescription = computed\(\(\) =>/);
|
|
assert.match(source, /\{\{ selectedScenarioDescription \}\}/);
|
|
assert.doesNotMatch(source, /scenario-desc">生活顾问 · 业主服务场景/);
|
|
});
|
|
|
|
test('场景目录加载失败时清空旧场景并禁止开始训练', async () => {
|
|
const source = await pageSource('../src/pages/user/practice/index.vue');
|
|
|
|
assert.match(source, /selectedScenarioId\.value = ''/);
|
|
assert.match(source, /activeScenarioName\.value = '暂无可用训练场景'/);
|
|
assert.match(source, /handleError\(scenarioResult\.reason, '训练场景加载失败'\)/);
|
|
assert.match(source, /:disabled="!canStartPractice \|\| busy"/);
|
|
assert.match(source, /!curriculumLoading\.value/);
|
|
});
|
|
|
|
test('无已发布场景时隐藏常规训练,但仍展示独立实时陪练入口', async () => {
|
|
const source = await pageSource('../src/pages/user/practice/index.vue');
|
|
const realtimeIndex = source.indexOf('class="panel scenario-card realtime-entry-card employee-card"');
|
|
const sceneIndex = source.indexOf('class="panel scene-picker-card employee-card"');
|
|
const growthIndex = source.indexOf('class="panel growth-card employee-card"');
|
|
|
|
assert.match(source, /v-if="loggedIn && practiceView === 'prep' && availableScenarios\.length" class="panel scenario-card practice-scenario-card employee-card"/);
|
|
assert.match(source, /v-if="loggedIn && practiceView === 'prep'" class="panel scenario-card realtime-entry-card employee-card"/);
|
|
assert.ok(realtimeIndex > -1 && realtimeIndex < sceneIndex && sceneIndex < growthIndex, '核心陪练入口必须排在场景和成长说明之前');
|
|
assert.match(source, /v-if="growthDetailsExpanded" class="growth-details"/);
|
|
assert.match(source, /return '选一种方式,马上开始';/);
|
|
assert.match(source, /:disabled="!canStartRealtime \|\| busy"/);
|
|
assert.match(source, /if \(!canStartRealtime\.value \|\| !requireLogin\(\)\) return;/);
|
|
assert.doesNotMatch(source, /if \(!canStartPractice\.value \|\| !requireLogin\(\)\) return;\s*speechPlayback\.stop\(\);\s*cancelRecording\(\);\s*turnVoiceGeneration \+= 1;/);
|
|
assert.match(source, /const activeScenario = nextScenarios\.find\([\s\S]*?\|\| nextScenarios\[0\];[\s\S]*?else \{\s*selectedScenarioId\.value = '';\s*activeScenarioName\.value = '暂无可用训练场景';\s*prepCard\.value = null;/);
|
|
});
|
|
|
|
test('待训练专项等待目录加载,并对下线或待发布场景给出明确提示', async () => {
|
|
const source = await pageSource('../src/pages/user/practice/index.vue');
|
|
|
|
assert.match(source, /if \(assignmentLoading\.value \|\| curriculumLoading\.value\) return;/);
|
|
assert.match(source, /startAssignment\(item\);\s*uni\.removeStorageSync\(pendingAssignmentKey\);/);
|
|
assert.match(source, /const scenario = availableScenarios\.value\.find\(\(entry\) => entry\.id === scenarioId\);/);
|
|
assert.match(source, /本次专项关联的场景已下线或待发布,请联系内容运营后再练习/);
|
|
});
|
|
|
|
test('员工问师傅页显示当前岗位而不是固定生活顾问', async () => {
|
|
const source = await pageSource('../src/pages/user/sop/index.vue');
|
|
|
|
assert.match(source, /getSelectedPosition/);
|
|
assert.match(source, /const positionLabel = ref\(getSelectedPosition\(\)\)/);
|
|
assert.match(source, /const headerContext = computed\(\(\) =>/);
|
|
assert.match(source, /\$\{positionLabel\.value\} · \$\{currentProject\.value\?\.projectName \|\| '住宅项目'\}/);
|
|
assert.doesNotMatch(source, /sop-context">管家(生活顾问) · 住宅项目/);
|
|
});
|
|
|
|
test('训练页最近练过保留派发任务来源', async () => {
|
|
const source = await pageSource('../src/pages/user/practice/index.vue');
|
|
|
|
assert.match(source, /sourceLabel: assignmentSourceLabel\(item\.source\)/);
|
|
assert.match(source, /class="assignment-badge"/);
|
|
assert.match(source, /source === 'retry'[\s\S]*错题再练/);
|
|
assert.match(source, /source === 'camp'[\s\S]*训练营/);
|
|
});
|
|
|
|
test('学习页展示真实错题本并可进入已有再练任务', async () => {
|
|
const [source, practiceService] = await Promise.all([
|
|
pageSource('../src/pages/user/learning/index.vue'),
|
|
pageSource('../src/services/practice.ts')
|
|
]);
|
|
|
|
assert.match(source, /错题本/);
|
|
assert.match(source, /getPracticeMistakes/);
|
|
assert.match(source, /retryAssignmentId/);
|
|
assert.match(source, /pendingAssignmentKey/);
|
|
assert.match(source, /mistakeFilters/);
|
|
assert.match(source, /visibleMistakes/);
|
|
assert.match(source, /source === 'retry'[\s\S]*错题再练/);
|
|
assert.match(source, /source === 'camp'[\s\S]*训练营/);
|
|
assert.match(practiceService, /\/api\/aihr\/mobile\/practice\/mistakes/);
|
|
});
|
|
|
|
test('学习页允许员工确认本人正式岗前任务完成', async () => {
|
|
const [source, practiceService] = await Promise.all([
|
|
pageSource('../src/pages/user/learning/index.vue'),
|
|
pageSource('../src/services/practice.ts')
|
|
]);
|
|
|
|
assert.match(source, /completeOnboarding/);
|
|
assert.match(source, /确认完成/);
|
|
assert.match(practiceService, /\/api\/aihr\/mobile\/onboard\/tasks\/\$\{id\}\/complete/);
|
|
});
|
|
|
|
test('学习页显示岗位 SOP 适配状态而不冒充上岗资格', async () => {
|
|
const [source, knowledgeService] = await Promise.all([
|
|
pageSource('../src/pages/user/learning/index.vue'),
|
|
pageSource('../src/services/knowledge.ts')
|
|
]);
|
|
|
|
assert.match(source, /positionSopSummary/);
|
|
assert.match(source, /上岗资格/);
|
|
assert.match(knowledgeService, /\/api\/knowledge\/position-sop/);
|
|
});
|
|
|
|
test('员工画像明确参考等级与人工贡献分语义', async () => {
|
|
const source = await pageSource('../src/pages/user/profile/index.vue');
|
|
|
|
assert.match(source, /AI训练综合分仅作参考/);
|
|
assert.match(source, /参考等级/);
|
|
assert.match(source, /人工贡献分/);
|
|
});
|
|
|
|
test('我的页面提供全局三档字号并持久化同步', async () => {
|
|
const [profileSource, accessibilitySource, appSource, baseSource] = await Promise.all([
|
|
pageSource('../src/pages/user/profile/index.vue'),
|
|
pageSource('../src/services/accessibility.ts'),
|
|
pageSource('../src/App.vue'),
|
|
pageSource('../src/styles/base.css')
|
|
]);
|
|
|
|
assert.match(accessibilitySource, /label:\s*'标准'/);
|
|
assert.match(accessibilitySource, /label:\s*'大字'/);
|
|
assert.match(accessibilitySource, /label:\s*'超大字'/);
|
|
assert.match(profileSource, /setAppFontSize/);
|
|
assert.match(accessibilitySource, /aihr_mobile_font_size/);
|
|
assert.match(accessibilitySource, /data-aihr-font-size/);
|
|
assert.match(appSource, /applyStoredFontSize/);
|
|
assert.match(baseSource, /data-aihr-font-size='large'\]\s+body[\s\S]*?zoom:\s*1\.12/);
|
|
assert.match(baseSource, /data-aihr-font-size='extra-large'\]\s+body[\s\S]*?zoom:\s*1\.25/);
|
|
});
|
|
|
|
test('员工岗位只能来自人力系统且个人页不再允许手动切换', async () => {
|
|
const [profileSource, positionSource, authSource] = await Promise.all([
|
|
pageSource('../src/pages/user/profile/index.vue'),
|
|
pageSource('../src/pages/auth/position/index.vue'),
|
|
pageSource('../src/services/auth.ts')
|
|
]);
|
|
|
|
assert.match(profileSource, /岗位由人力系统组织同步/);
|
|
assert.doesNotMatch(profileSource, /changePosition/);
|
|
assert.match(positionSource, /员工端不支持手动切换岗位/);
|
|
assert.doesNotMatch(positionSource, /saveSelectedPosition|selectPosition/);
|
|
assert.match(authSource, /positionSourceKey/);
|
|
assert.match(authSource, /saveOrgPosition/);
|
|
});
|
|
|
|
test('主管专项从正式团队名单按姓名多选并一次批量派发', async () => {
|
|
const [source, practiceService] = await Promise.all([
|
|
pageSource('../src/pages/supervisor/assign/index.vue'),
|
|
pageSource('../src/services/practice.ts')
|
|
]);
|
|
|
|
assert.match(source, /selectedMemberIds/);
|
|
assert.match(source, /v-for="member in teamMembers"/);
|
|
assert.match(source, /member\.name \|\| '未命名员工'/);
|
|
assert.match(source, /snapshot\.members/);
|
|
assert.match(source, /targets: selectedMembers\.map/);
|
|
assert.match(source, /createPracticeAssignmentsBatch/);
|
|
assert.match(source, /明天/);
|
|
assert.match(source, /3天后/);
|
|
assert.match(source, /7天后/);
|
|
assert.match(source, /dueDate: dueDate\.value/);
|
|
assert.match(source, /const resultItems: PracticeAssignmentBatchItemResult\[\] = result\.results \|\| \[\]/);
|
|
assert.match(source, /assignResultStorageKey/);
|
|
assert.match(source, /uni\.redirectTo\(\{ url: '\/pages\/supervisor\/assign\/result' \}\)/);
|
|
assert.match(source, /const maxAssignmentTargets = 100/);
|
|
assert.match(source, /teamMembers\.value\.slice\(0, maxAssignmentTargets\)/);
|
|
assert.match(source, /persistPendingBatchDraft/);
|
|
assert.match(source, /restorePendingBatchDraft/);
|
|
assert.match(source, /persistedDraft\?\.fingerprint === fingerprint/);
|
|
assert.match(source, /:aria-pressed="isMemberSelected\(member\.extPartyId\)"/);
|
|
assert.match(practiceService, /\/api\/aihr\/mobile\/practice\/assignments\/batch/);
|
|
assert.doesNotMatch(source, /placeholder="输入手机号或外部ID"/);
|
|
});
|
|
|
|
test('问师傅支持现场媒体、答案播报和总结卡图片下载', async () => {
|
|
const [source, composerSource, agentService, speechService, imageService] = await Promise.all([
|
|
pageSource('../src/pages/user/sop/index.vue'),
|
|
pageSource('../src/components/chat/ChatComposer.vue'),
|
|
pageSource('../src/services/agent.ts'),
|
|
pageSource('../src/services/speech.ts'),
|
|
pageSource('../src/services/summary-card-image.ts')
|
|
]);
|
|
|
|
assert.match(source, /@pick-media="pickMedia"/);
|
|
assert.match(composerSource, /图片\/视频/);
|
|
assert.match(composerSource, /仅用于本次现场分析/);
|
|
assert.match(source, /askAgentWithMedia/);
|
|
assert.match(source, /toggleAnswerSpeech/);
|
|
assert.match(source, /toggleSummarySpeech/);
|
|
assert.match(source, /downloadSummaryCardImage/);
|
|
assert.match(agentService, /\/api\/aihr\/agent\/messages\/media/);
|
|
assert.match(speechService, /\/api\/ai\/tts/);
|
|
assert.match(speechService, /SpeechSynthesisUtterance/);
|
|
assert.match(speechService, /playWithBrowserSpeech/);
|
|
assert.match(imageService, /toBlob/);
|
|
assert.match(imageService, /link\.download/);
|
|
});
|
|
|
|
test('问师傅恢复旧版语音提问并在录音不可用时回退选择音频', async () => {
|
|
const [source, composerSource, speechService] = await Promise.all([
|
|
pageSource('../src/pages/user/sop/index.vue'),
|
|
pageSource('../src/components/chat/ChatComposer.vue'),
|
|
pageSource('../src/services/speech.ts')
|
|
]);
|
|
|
|
assert.match(composerSource, /按住 说话/);
|
|
assert.match(composerSource, /startHold/);
|
|
assert.match(composerSource, /endHold/);
|
|
assert.match(composerSource, /startSpeechCapture/);
|
|
assert.match(composerSource, /emit\('voice-capture'/);
|
|
assert.match(composerSource, /emit\('pick-audio'\)/);
|
|
assert.match(composerSource, /const teardown = \(\) =>/);
|
|
assert.match(source, /pickVoiceAudio/);
|
|
assert.match(source, /transcribeSpeechCapture/);
|
|
assert.match(source, /transcribeSpeechFile/);
|
|
assert.match(speechService, /\/api\/ai\/asr/);
|
|
});
|
|
|
|
test('生成的每日题和对练对话恢复语音播报入口', async () => {
|
|
const source = await pageSource('../src/pages/user/practice/index.vue');
|
|
|
|
assert.match(source, /daily-question-voice/);
|
|
assert.match(source, /turn\.role === 'customer'/);
|
|
assert.match(source, /turn\.role === 'coach'/);
|
|
assert.match(source, /playTurnVoice/);
|
|
assert.match(source, /speakGenerated/);
|
|
assert.match(source, /createSpeechPlaybackController/);
|
|
assert.match(source, /停止播报/);
|
|
});
|
|
|
|
test('每日题点评参考答案和训练结果均可语音播报', async () => {
|
|
const source = await pageSource('../src/pages/user/practice/index.vue');
|
|
|
|
assert.match(source, /daily-feedback-card/);
|
|
assert.match(source, /item\.feedback/);
|
|
assert.match(source, /item\.referenceAnswer/);
|
|
assert.match(source, /dailyFeedbackSpeech/);
|
|
assert.match(source, /practiceResultSpeech/);
|
|
assert.match(source, /result-voice-button/);
|
|
});
|
|
|
|
test('语音、媒体和私密画像在切页或退出后不会继续写回', async () => {
|
|
const [sopSource, practiceSource, profileSource, knowledgeService] = await Promise.all([
|
|
pageSource('../src/pages/user/sop/index.vue'),
|
|
pageSource('../src/pages/user/practice/index.vue'),
|
|
pageSource('../src/pages/user/profile/index.vue'),
|
|
pageSource('../src/services/knowledge.ts')
|
|
]);
|
|
|
|
assert.match(sopSource, /onHide\(\(\) => \{[\s\S]*speechPlayback\.stop\(\);[\s\S]*composerRef\.value\?\.teardown\(\);[\s\S]*cancelVoiceTranscription\(\);[\s\S]*cancelPendingMediaSearch\(\);/);
|
|
assert.match(sopSource, /activeMediaUploadTask\?\.abort\(\)/);
|
|
assert.match(sopSource, /generation !== knowledgeSearchGeneration/);
|
|
assert.match(sopSource, /abortVoiceTranscription\?\.\(\)/);
|
|
assert.match(sopSource, /generation !== voiceTranscriptionGeneration/);
|
|
assert.match(practiceSource, /onHide\(\(\) => \{[\s\S]*speechPlayback\.stop\(\);[\s\S]*cancelRecording\(\);/);
|
|
assert.match(practiceSource, /recordingCaptureGeneration \+= 1/);
|
|
assert.match(practiceSource, /abortTranscription\?\.\(\)/);
|
|
assert.match(practiceSource, /generation !== transcriptionGeneration/);
|
|
assert.match(profileSource, /let profileLoadGeneration = 0/);
|
|
assert.match(profileSource, /getAuth\(\)\.phone !== accountPhone/);
|
|
assert.match(profileSource, /v-if="loggedIn && profile"/);
|
|
assert.match(profileSource, /v-if="loggedIn && evidence"/);
|
|
assert.match(knowledgeService, /onUploadTask\?:/);
|
|
assert.match(knowledgeService, /onUploadTask\?\.\(task\)/);
|
|
});
|
|
|
|
test('播报复用已合成音频且总结图按内容高度生成并及时释放', async () => {
|
|
const [speechService, imageService] = await Promise.all([
|
|
pageSource('../src/services/speech.ts'),
|
|
pageSource('../src/services/summary-card-image.ts')
|
|
]);
|
|
|
|
assert.match(speechService, /const sourceCache = new Map<string, string>\(\)/);
|
|
assert.match(speechService, /const maxCachedSources = 4/);
|
|
assert.match(speechService, /const sourceKey = cacheKey\(value, effectiveProfile, practiceContext\)/);
|
|
assert.match(speechService, /const cachedSource = sourceCache\.get\(sourceKey\)/);
|
|
assert.match(speechService, /sourceCache\.clear\(\)/);
|
|
assert.match(speechService, /registerAbort\?\.\(\(\) => task\.abort\(\)\)/);
|
|
assert.match(speechService, /registerAbort\?\.\(\(\) => xhr\.abort\(\)\)/);
|
|
assert.match(speechService, /const isCurrent = \(\) => currentGeneration === generation && audio === context && activeKey === key/);
|
|
assert.match(imageService, /canvas\.height = 1/);
|
|
assert.doesNotMatch(imageService, /12000/);
|
|
assert.match(imageService, /canvas\.width = 1;[\s\S]*canvas\.height = 1/);
|
|
assert.match(imageService, /setTimeout\(\(\) => URL\.revokeObjectURL\(url\), 1000\)/);
|
|
});
|
|
|
|
test('主管学员模式只允许查询本人训练概况且登录后保留待执行工具', async () => {
|
|
const source = await pageSource('../src/pages/user/sop/index.vue');
|
|
|
|
assert.match(source, /const supervisorLearner = ref\(isSupervisorLearnerMode\(\)\)/);
|
|
assert.match(source, /supervisorLearner\.value && toolCode === 'TEAM_PRACTICE_SUMMARY'/);
|
|
assert.match(source, /supervisorLearner\.value && toolCode === 'TEAM_PRACTICE_SUMMARY'[\s\S]*\? 'MY_PRACTICE_SUMMARY'/);
|
|
assert.match(source, /uni\.setStorageSync\(pendingToolKey, toolCode\)/);
|
|
assert.match(source, /const generation = \+\+knowledgeSearchGeneration/);
|
|
assert.match(source, /if \(!loggedIn\.value\) \{[\s\S]*goLogin\(\);[\s\S]*return;[\s\S]*\}[\s\S]*uni\.removeStorageSync\(pendingToolKey\)/);
|
|
});
|
|
|
|
test('直通车页支持语音输入并管控切页清理与字数上限', async () => {
|
|
const source = await pageSource('../src/pages/user/direct/index.vue');
|
|
const bodyOf = (pattern) => source.match(pattern)?.[1] || '';
|
|
|
|
// 语音链路复用既有服务,函数与关键契约存在。
|
|
assert.match(source, /import \{[^}]*startSpeechCapture[^}]*\} from '@\/services\/speech-capture'/);
|
|
assert.match(source, /transcribeSpeechCapture\(capture, 'direct-feedback\.webm'/);
|
|
assert.match(source, /const toggleVoiceCapture = \(\) =>/);
|
|
assert.match(source, /const appendTranscript = \(text: string\) =>/);
|
|
assert.match(source, /:maxlength="MAX_CONTENT_LENGTH"/);
|
|
|
|
// 渠道图标映射与兜底。
|
|
assert.match(source, /const channelIcon = \(code: DirectChannelCode\) => channelIcons\[code\] \?\? 'paperplane'/);
|
|
assert.match(source, /PRESIDENT: 'vip'/);
|
|
assert.match(source, /FINANCE: 'wallet'/);
|
|
assert.match(source, /HR: 'staff'/);
|
|
assert.match(source, /AUDIT: 'eye'/);
|
|
assert.match(source, /OPERATIONS: 'gear'/);
|
|
|
|
// 转写结果以换行追加并在 JS 侧截断到字数上限(maxlength 管不住编程式赋值)。
|
|
assert.match(source, /current \? `\$\{current\}\\n\$\{text\}` : text/);
|
|
assert.match(source, /slice\(0, MAX_CONTENT_LENGTH\)/);
|
|
|
|
// stop→onCapture 异步窗口:stopping 状态禁用重复 stop,避免 App 端二次 stop 使录音丢失。
|
|
const stopBody = bodyOf(/const stopVoiceCapture = \(\) => \{([\s\S]*?)\};\r?\n/);
|
|
assert.match(stopBody, /voiceStopping\.value = true/);
|
|
const toggleBody = bodyOf(/const toggleVoiceCapture = \(\) => \{([\s\S]*?)\};\r?\n/);
|
|
assert.match(toggleBody, /voiceStopping\.value/);
|
|
|
|
// 切页清理:onHide/onUnload 各自函数体内必须直接调用 teardownVoice(不允许跨函数误匹配)。
|
|
const onHideBody = bodyOf(/onHide\(\(\) => \{([\s\S]*?)\}\);/);
|
|
const onUnloadBody = bodyOf(/onUnload\(\(\) => \{([\s\S]*?)\}\);/);
|
|
assert.match(onHideBody, /requests\.invalidate\(\)/);
|
|
assert.match(onHideBody, /teardownVoice\(\)/);
|
|
assert.match(onUnloadBody, /requests\.invalidate\(\)/);
|
|
assert.match(onUnloadBody, /teardownVoice\(\)/);
|
|
|
|
// teardown 必须同时处理:generation 递增、转写 abort、录音 cancel、状态复位、计时器停止。
|
|
const teardownBody = bodyOf(/const teardownVoice = \(\) => \{([\s\S]*?)\};\r?\n/);
|
|
assert.match(teardownBody, /voiceGeneration \+= 1/);
|
|
assert.match(teardownBody, /abortVoiceTranscription\?\.\(\)/);
|
|
assert.match(teardownBody, /voiceCapture\?\.cancel\(\)/);
|
|
assert.match(teardownBody, /voiceRecording\.value = false/);
|
|
assert.match(teardownBody, /voiceStopping\.value = false/);
|
|
assert.match(teardownBody, /stopVoiceTimer\(\)/);
|
|
|
|
// 计时器用 Date.now 差值,避免后台节流导致秒数漂移。
|
|
assert.match(source, /Math\.floor\(\(Date\.now\(\) - voiceStartedAt\) \/ 1000\)/);
|
|
|
|
// 模板契约:语音 chip、状态条 aria、提交禁用、装饰图标。
|
|
const chipBody = bodyOf(/<button\s+class="voice-chip"([\s\S]*?)>/);
|
|
assert.match(chipBody, /:aria-pressed="voiceRecording"/);
|
|
assert.match(chipBody, /aria-label="语音输入"/);
|
|
assert.match(chipBody, /:disabled="voiceTranscribing \|\| voiceStarting \|\| voiceStopping"/);
|
|
const statusBody = bodyOf(/<view\s+v-if="voiceBusy \|\| voiceHint"([\s\S]*?)>/);
|
|
assert.match(statusBody, /class="voice-status"/);
|
|
assert.match(statusBody, /aria-live="polite"/);
|
|
assert.match(source, /:disabled="submitting \|\| voiceBusy \|\| !canSubmit"/);
|
|
assert.match(source, /submitting\.value \|\| voiceBusy\.value/);
|
|
assert.match(source, /type="chatboxes"/);
|
|
assert.match(source, /type="eye-slash"/);
|
|
|
|
// 提交成功后清理语音提示,避免残留“语音已转成文字”。
|
|
const submitBody = bodyOf(/const submit = async \(\) => \{([\s\S]*?)await loadMine\(true\)/);
|
|
assert.match(submitBody, /voiceHint\.value = ''/);
|
|
});
|