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>/); 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('训练求助先留存会话事件,再以 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, / { 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, /]+type="phone"/); assert.match(source, /]+type="locked"/); 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('共享视觉基座包含员工端主色和卡片圆角 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, / { 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, /数字师傅<\/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 = await pageSource('../src/services/position.ts'); assert.match(source, /\/api\/aihr\/mobile\/me/); assert.match(source, /role === 'supervisor'/); assert.match(source, /pages\/supervisor\/index\/index/); assert.match(source, /removeStorageSync\(roleKey\)/); }); 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 = await pageSource('../src/pages/user/sop/index.vue'); 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/); }); 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('底部固定导航页面复用安全操作区', 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, /\{\{ positionLabel \}\} · 业主服务场景/); assert.doesNotMatch(source, /scenario-desc">生活顾问 · 业主服务场景/); }); 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\(\)/); 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\)/); });