/** * 验证「问卷作答后变只读」这条链路 + 选项选中样式那条修(用户 2026-09-24 反馈的两个问题)。 * * 用户原话: * ①「如果下方已经新对话了,上方的问卷应该是只读状态」 * ②「在问卷中选择具体项时,边框有点问题……一次应该只有一个选项是选中样式」 * * ①的根因:`QuestionCard.vue` 有只读渲染分支,但判定是「内容里带 answers」—— * 而此前**没有任何代码把答案写回内容**,所以永远是可交互态。 * 修法:提交时用 `applyQuestionCardsAnswers()` 把答案写回那一行(新增纯函数模块)。 * ②的根因:`isFreeformSelected()` 对**常驻输入框恒返回 true** → 那一行永远带选中边框。 * 修法:改成"填了内容、且单选时没选别的选项"才算选中。 * * 怎么跑(先打包): * npx esbuild harness/tools/_entry-question-cards.ts --bundle --format=esm \ * --outfile=harness/tools/_question-cards.mjs --alias:@=./src --define:import.meta.env='{}' * node harness/tools/verify-question-cards.mjs */ import { readFileSync } from 'node:fs'; const { buildAnsweredQuestionCardsContent, applyQuestionCardsAnswers, hasAnsweredQuestionCards, StreamXMLFilter, RecordRowTypes, parseQuestionsFromContent, parseAnswersFromContent, } = await import('./_question-cards.mjs'); let pass = 0; let fail = 0; const check = (name, ok, extra = '') => { if (ok) { pass++; console.log(' ok ' + name); } else { fail++; console.log(' FAIL ' + name + ' ' + extra); } }; const QUESTIONS_JSON = JSON.stringify({ questions: [ { header: '本次是否需要结合具体公司情况?如需要,请补充公司名称或信用代码。', freeformInputOnTop: true, freeformPlaceholder: '请输入工商注册全名或统一社会信用代码', options: [{ label: '不需要公司信息' }], }, ], }); const CONTENT = `\n\n${QUESTIONS_JSON}`; console.log('\n【1】把答案写回问卷行(写回后才会走只读渲染分支)'); { const answers = [{ header: '本次是否需要结合公司情况?', selected: ['不需要公司信息'], freeText: '', skipped: false }]; const written = applyQuestionCardsAnswers(CONTENT, answers); // ⚠️ 这里必须用**组件真正用的那个解析函数**(`parseAnswersFromContent`,它会解包标记)。 // 上一版测试是手工把 `` 去掉再 JSON.parse —— 于是"渲染层交给组件的 content // 是带标记的"这个事实被测试掩盖了,放过了真实的「卡片渲染成空」bug(见【5】)。 const parsedAnswers = parseAnswersFromContent(written); check('写回后能被组件的只读分支解析出 answers', Array.isArray(parsedAnswers) && parsedAnswers.length === 1, JSON.stringify(written)?.slice(0, 140)); check('答案形状与只读渲染一致(header / selected / freeText)', parsedAnswers?.[0]?.header === '本次是否需要结合公司情况?' && JSON.stringify(parsedAnswers?.[0]?.selected) === JSON.stringify(['不需要公司信息']) && parsedAnswers?.[0]?.freeText === '', JSON.stringify(parsedAnswers?.[0])); check('写回后不代表"还有题目要作答"(交互分支不会再渲染)', parseQuestionsFromContent(written).length === 0); check('问卷外的前后内容没被动(scope 行仍在前、标签成对)', written.startsWith('\n\n') && written.endsWith('')); check('hasAnsweredQuestionCards 能识别', hasAnsweredQuestionCards(written) === true); check('作答前不算已作答(只读分支不会误触发)', hasAnsweredQuestionCards(CONTENT) === false); check('作答前能解析出题目(交互分支照常渲染)', parseQuestionsFromContent(CONTENT).length === 1); } console.log('\n【2】边界:不能破坏别人的内容'); { check('没有问卷标记 → 原样返回', applyQuestionCardsAnswers('正文', []) === '正文'); check('标记不成对 → 原样返回', applyQuestionCardsAnswers('{"questions":[]}', []) === '{"questions":[]}'); const two = `{"questions":[{"header":"第一轮"}]}\n正文\n{"questions":[{"header":"第二轮"}]}`; const applied = applyQuestionCardsAnswers(two, [{ header: '第二轮', selected: ['A'] }]); check('**只替换最后一个**问卷块(多轮时不会改错行)', applied.indexOf('{"questions":[{"header":"第一轮"}]}') > 0 && applied.indexOf('{"answers"') > applied.indexOf('第二轮') - 100, applied.slice(0, 160)); check('只替换一次(幂等)', applyQuestionCardsAnswers(applied, [{ header: '第二轮', selected: ['A'] }]) === applied); } console.log('\n【3】答案字段归一化(不让无关状态进内容)'); { const built = buildAnsweredQuestionCardsContent([ { header: ' 问题 ', selected: [' A ', '', 'B'], freeText: ' 手输内容 ', skipped: undefined }, ]); const parsed = JSON.parse(built); check('selected 去空白并过滤空项', JSON.stringify(parsed.answers[0].selected) === JSON.stringify(['A', 'B']), JSON.stringify(parsed.answers[0].selected)); check('freeText 原样保留(不清空内部空白)', parsed.answers[0].freeText === ' 手输内容 '); check('skipped 归一化成布尔', parsed.answers[0].skipped === false); } console.log('\n【4】接线(源码级:这两处都在 Vue 组件 / hook 里,node 跑不起来)'); { const hook = readFileSync('src/components/business-assistant/useBusinessAssistantChat.ts', 'utf8'); check('submitQuestionAnswers 里调用了写回', /applyQuestionCardsAnswers\(questionCardsMessage\.content, answers\)/.test(hook)); check('写回的是「最后一条带 的 AI 消息」', /reverse\(\)[\s\S]{0,220}item\.content\.includes\(''\)/.test(hook)); const card = readFileSync('src/components/Chat/QuestionCard.vue', 'utf8'); check('只读分支的判定仍是「内容里有 answers」', /const isReadonly = computed\(\(\) => !!contentAnswers\.value\)/.test(card)); check('**常驻输入框不再恒判为选中**(第二个 bug 的修)', !/const isFreeformSelected = \(cardIdx: number\): boolean => \{\s*return isCardFreeformAlwaysOn\(cardIdx\) \|\|/.test(card) && /if \(!isCardFreeformAlwaysOn\(cardIdx\)\) \{/.test(card)); check('单选时"选了别的选项就不算它选中"', /if \(card\?\.multiSelect\) return true;/.test(card)); // ⚠️ 第二轮修正(用户 2026-09-24 追加反馈):「点了输入框应该把高亮移过来、并取消右边的选中」。 // 只按"填了内容才算选中"是不够的 —— 点了输入框却不亮、右边还亮着,同样是不对的。 check('常驻输入框的 focus 与 click 都接同一处理函数(点一下就等于选了它)', (card.match(/@focus="handleAlwaysOnFreeformInteract\(cardIdx\)"/g) || []).length === 1 && (card.match(/@click="handleAlwaysOnFreeformInteract\(cardIdx\)"/g) || []).length === 1); check('该处理函数:记下"输入框已激活"(高亮它)', /const handleAlwaysOnFreeformInteract[\s\S]{0,400}isFreeformActive\.value\[cardIdx\] = true/.test(card) || /const handleAlwaysOnFreeformInteract[\s\S]{0,400}isFreeformActive\.value = \{ \.\.\.isFreeformActive\.value, \[cardIdx\]: true \}/.test(card)); check('该处理函数:**单选时清掉已选选项**(高亮只跟一个走)', /const handleAlwaysOnFreeformInteract[\s\S]{0,600}!card\.multiSelect &&[\s\S]{0,200}new Set\(\)/.test(card)); check('选中判定里包含"被交互过"这一项(chosenByInteraction)', /const chosenByInteraction = !!isFreeformActive\.value\[cardIdx\]/.test(card)); } console.log('\n【5】完整往返:真实切行器 → 组件拿到的 content → 解析(这条会抓「卡片渲染成空」)'); { const answers = [{ header: '(单选)本次是否需要结合公司情况?', selected: ['不需要公司信息'], freeText: '', skipped: false }]; /** 跑真实切行器,取问卷行的 content —— 渲染层就是这么把内容交给 QuestionCard 的 */ const questionCardsRowContent = (content) => { const rows = new StreamXMLFilter({}).processChunk(content); const rowsOfType = rows.filter((row) => row.type === RecordRowTypes.QuestionCards); return rowsOfType.length ? rowsOfType[rowsOfType.length - 1].content : ''; }; const rowBefore = questionCardsRowContent(CONTENT); check('切行器产出的问卷行 content **带 `` 标记**(所以解析必须先解包)', rowBefore.startsWith('') && rowBefore.endsWith(''), rowBefore.slice(0, 60)); check('未作答:交互分支解析得到题目', parseQuestionsFromContent(rowBefore).length === 1); const rowAfter = questionCardsRowContent(applyQuestionCardsAnswers(CONTENT, answers)); const parsedAnswers = parseAnswersFromContent(rowAfter); const parsedQuestions = parseQuestionsFromContent(rowAfter); check('作答后:只读分支解析得到答案', Array.isArray(parsedAnswers) && parsedAnswers.length === 1, JSON.stringify(rowAfter)?.slice(0, 140)); check('作答后:交互分支不再有题目', parsedQuestions.length === 0); // ★ 这条是"卡片渲染成空"的直接守卫:两个分支至少有一个能渲染出东西 // (组件模板:`v-if="isReadonly && contentAnswers.length > 0"` / `v-else-if="questions.length > 0"`) check('**至少有一个分支能渲染**(否则整张卡片是空的 —— 用户报的「提交完直接看不见了」)', (parsedAnswers?.length || 0) > 0 || parsedQuestions.length > 0, `answers=${parsedAnswers?.length ?? 'null'} questions=${parsedQuestions.length}`); check('直接传「标记内 JSON」也要能解析(兼容不带标记的调用方)', parseAnswersFromContent(JSON.stringify({ answers }))?.length === 1); } console.log(`\n===== 通过 ${pass} 项,失败 ${fail} 项 =====`); process.exit(fail ? 1 : 0);