verify-question-cards.mjs 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194
  1. /**
  2. * 验证「问卷作答后变只读」这条链路 + 选项选中样式那条修(用户 2026-09-24 反馈的两个问题)。
  3. *
  4. * 用户原话:
  5. * ①「如果下方已经新对话了,上方的问卷应该是只读状态」
  6. * ②「在问卷中选择具体项时,边框有点问题……一次应该只有一个选项是选中样式」
  7. *
  8. * ①的根因:`QuestionCard.vue` 有只读渲染分支,但判定是「内容里带 answers」——
  9. * 而此前**没有任何代码把答案写回内容**,所以永远是可交互态。
  10. * 修法:提交时用 `applyQuestionCardsAnswers()` 把答案写回那一行(新增纯函数模块)。
  11. * ②的根因:`isFreeformSelected()` 对**常驻输入框恒返回 true** → 那一行永远带选中边框。
  12. * 修法:改成"填了内容、且单选时没选别的选项"才算选中。
  13. *
  14. * 怎么跑(先打包):
  15. * npx esbuild harness/tools/_entry-question-cards.ts --bundle --format=esm \
  16. * --outfile=harness/tools/_question-cards.mjs --alias:@=./src --define:import.meta.env='{}'
  17. * node harness/tools/verify-question-cards.mjs
  18. */
  19. import { readFileSync } from 'node:fs';
  20. const {
  21. buildAnsweredQuestionCardsContent,
  22. applyQuestionCardsAnswers,
  23. hasAnsweredQuestionCards,
  24. StreamXMLFilter,
  25. RecordRowTypes,
  26. parseQuestionsFromContent,
  27. parseAnswersFromContent,
  28. } = await import('./_question-cards.mjs');
  29. let pass = 0;
  30. let fail = 0;
  31. const check = (name, ok, extra = '') => {
  32. if (ok) {
  33. pass++;
  34. console.log(' ok ' + name);
  35. } else {
  36. fail++;
  37. console.log(' FAIL ' + name + ' ' + extra);
  38. }
  39. };
  40. const QUESTIONS_JSON = JSON.stringify({
  41. questions: [
  42. {
  43. header: '本次是否需要结合具体公司情况?如需要,请补充公司名称或信用代码。',
  44. freeformInputOnTop: true,
  45. freeformPlaceholder: '请输入工商注册全名或统一社会信用代码',
  46. options: [{ label: '不需要公司信息' }],
  47. },
  48. ],
  49. });
  50. const CONTENT = `<scope title="思考中">\n</scope>\n<question-cards>${QUESTIONS_JSON}</question-cards>`;
  51. console.log('\n【1】把答案写回问卷行(写回后才会走只读渲染分支)');
  52. {
  53. const answers = [{ header: '本次是否需要结合公司情况?', selected: ['不需要公司信息'], freeText: '', skipped: false }];
  54. const written = applyQuestionCardsAnswers(CONTENT, answers);
  55. // ⚠️ 这里必须用**组件真正用的那个解析函数**(`parseAnswersFromContent`,它会解包标记)。
  56. // 上一版测试是手工把 `<question-cards>` 去掉再 JSON.parse —— 于是"渲染层交给组件的 content
  57. // 是带标记的"这个事实被测试掩盖了,放过了真实的「卡片渲染成空」bug(见【5】)。
  58. const parsedAnswers = parseAnswersFromContent(written);
  59. check('写回后能被组件的只读分支解析出 answers', Array.isArray(parsedAnswers) && parsedAnswers.length === 1,
  60. JSON.stringify(written)?.slice(0, 140));
  61. check('答案形状与只读渲染一致(header / selected / freeText)',
  62. parsedAnswers?.[0]?.header === '本次是否需要结合公司情况?' &&
  63. JSON.stringify(parsedAnswers?.[0]?.selected) === JSON.stringify(['不需要公司信息']) &&
  64. parsedAnswers?.[0]?.freeText === '',
  65. JSON.stringify(parsedAnswers?.[0]));
  66. // ⚠️ 写回**保留 questions**(只读态复用同一个问卷模板 → 「样式不能变」),
  67. // 只读的开关是"有 answers",不是"没有 questions"
  68. check('**写回后 questions 仍在**(只读态复用同一模板,样式不变)',
  69. parseQuestionsFromContent(written).length === 1,
  70. JSON.stringify(written).slice(0, 140));
  71. check('问卷外的前后内容没被动(scope 行仍在前、标签成对)',
  72. written.startsWith('<scope title="思考中">\n</scope>\n<question-cards>') && written.endsWith('</question-cards>'));
  73. check('hasAnsweredQuestionCards 能识别', hasAnsweredQuestionCards(written) === true);
  74. check('作答前不算已作答(只读分支不会误触发)', hasAnsweredQuestionCards(CONTENT) === false);
  75. check('作答前能解析出题目(交互分支照常渲染)', parseQuestionsFromContent(CONTENT).length === 1);
  76. }
  77. console.log('\n【2】边界:不能破坏别人的内容');
  78. {
  79. check('没有问卷标记 → 原样返回', applyQuestionCardsAnswers('<scope>正文</scope>', []) === '<scope>正文</scope>');
  80. check('标记不成对 → 原样返回',
  81. applyQuestionCardsAnswers('<question-cards>{"questions":[]}', []) === '<question-cards>{"questions":[]}');
  82. const two = `<question-cards>{"questions":[{"header":"第一轮"}]}</question-cards>\n正文\n<question-cards>{"questions":[{"header":"第二轮"}]}</question-cards>`;
  83. const applied = applyQuestionCardsAnswers(two, [{ header: '第二轮', selected: ['A'] }]);
  84. const firstBlock = applied.slice(0, applied.indexOf('正文'));
  85. const secondBlock = applied.slice(applied.indexOf('正文'));
  86. check('**只改最后一个**问卷块(多轮时不会改错行)',
  87. firstBlock.includes('{"questions":[{"header":"第一轮"}]}') && !firstBlock.includes('"answers"') &&
  88. secondBlock.includes('"answers"'),
  89. applied.slice(0, 200));
  90. check('只替换一次(幂等)', applyQuestionCardsAnswers(applied, [{ header: '第二轮', selected: ['A'] }]) === applied);
  91. }
  92. console.log('\n【3】答案字段归一化(不让无关状态进内容)');
  93. {
  94. const built = buildAnsweredQuestionCardsContent([
  95. { header: ' 问题 ', selected: [' A ', '', 'B'], freeText: ' 手输内容 ', skipped: undefined },
  96. ]);
  97. const parsed = JSON.parse(built);
  98. check('selected 去空白并过滤空项', JSON.stringify(parsed.answers[0].selected) === JSON.stringify(['A', 'B']),
  99. JSON.stringify(parsed.answers[0].selected));
  100. check('freeText 原样保留(不清空内部空白)', parsed.answers[0].freeText === ' 手输内容 ');
  101. check('skipped 归一化成布尔', parsed.answers[0].skipped === false);
  102. }
  103. console.log('\n【4】接线(源码级:这两处都在 Vue 组件 / hook 里,node 跑不起来)');
  104. {
  105. const hook = readFileSync('src/components/business-assistant/useBusinessAssistantChat.ts', 'utf8');
  106. check('submitQuestionAnswers 里调用了写回',
  107. /applyQuestionCardsAnswers\(questionCardsMessage\.content, answers\)/.test(hook));
  108. check('写回的是「最后一条带 <question-cards> 的 AI 消息」',
  109. /reverse\(\)[\s\S]{0,220}item\.content\.includes\('<question-cards>'\)/.test(hook));
  110. const card = readFileSync('src/components/Chat/QuestionCard.vue', 'utf8');
  111. check('只读分支的判定仍是「内容里有 answers」',
  112. /const isReadonly = computed\(\(\) => !!contentAnswers\.value\)/.test(card));
  113. check('**常驻输入框不再恒判为选中**(第二个 bug 的修)',
  114. !/const isFreeformSelected = \(cardIdx: number\): boolean => \{\s*return isCardFreeformAlwaysOn\(cardIdx\) \|\|/.test(card) &&
  115. /if \(!isCardFreeformAlwaysOn\(cardIdx\)\) \{/.test(card));
  116. check('单选时"选了别的选项就不算它选中"', /if \(card\?\.multiSelect\) return true;/.test(card));
  117. // ⚠️ 第二轮修正(用户 2026-09-24 追加反馈):「点了输入框应该把高亮移过来、并取消右边的选中」。
  118. // 只按"填了内容才算选中"是不够的 —— 点了输入框却不亮、右边还亮着,同样是不对的。
  119. check('常驻输入框的 focus 与 click 都接同一处理函数(点一下就等于选了它)',
  120. (card.match(/@focus="handleAlwaysOnFreeformInteract\(cardIdx\)"/g) || []).length === 1 &&
  121. (card.match(/@click="handleAlwaysOnFreeformInteract\(cardIdx\)"/g) || []).length === 1);
  122. check('该处理函数:记下"输入框已激活"(高亮它)',
  123. /const handleAlwaysOnFreeformInteract[\s\S]{0,400}isFreeformActive\.value\[cardIdx\] = true/.test(card) ||
  124. /const handleAlwaysOnFreeformInteract[\s\S]{0,400}isFreeformActive\.value = \{ \.\.\.isFreeformActive\.value, \[cardIdx\]: true \}/.test(card));
  125. check('该处理函数:**单选时清掉已选选项**(高亮只跟一个走)',
  126. /const handleAlwaysOnFreeformInteract[\s\S]{0,600}!card\.multiSelect &&[\s\S]{0,200}new Set\(\)/.test(card));
  127. check('选中判定里包含"被交互过"这一项(chosenByInteraction)',
  128. /const chosenByInteraction = !!isFreeformActive\.value\[cardIdx\]/.test(card));
  129. // ③ 第三轮(2026-09-24):「问卷长什么样,选完后还是长什么样,样式不能变」
  130. // ⇒ 只读态**不许**再另起一套模板,必须复用同一个问卷模板 + 只读开关
  131. check('**没有单独的只读模板分支**(旧的 v-if="isReadonly && contentAnswers.length > 0" 必须消失)',
  132. !/v-if="isReadonly && contentAnswers\.length > 0"/.test(card));
  133. check('模板只有一处 question-card-shell(一套样式)',
  134. (card.match(/class="question-card-shell"/g) || []).length === 1);
  135. check('只读开关挂在同一模板上(submitted / readonly)',
  136. /:class="\{ submitted: submitted \|\| isReadonly, readonly: isReadonly \}"/.test(card));
  137. check('只读态把答案还原成界面选中态(hydrateFromAnswers)',
  138. /const hydrateFromAnswers = \(\) => \{/.test(card) && /if \(isReadonly\.value\) hydrateFromAnswers\(\)/.test(card));
  139. check('只读态禁交互(守卫含 isReadonly)', /if \(submitted\.value \|\| isReadonly\.value\) return;/.test(card));
  140. check('只读态提交按钮禁用(canSubmit 含 isReadonly)',
  141. /if \(submitted\.value \|\| isReadonly\.value\) return false;/.test(card));
  142. check('只读态输入框可读不可改(:readonly="isReadonly")',
  143. (card.match(/:readonly="isReadonly"/g) || []).length >= 2);
  144. }
  145. console.log('\n【5】完整往返:真实切行器 → 组件拿到的 content → 解析(这条会抓「卡片渲染成空」)');
  146. {
  147. const answers = [{ header: '(单选)本次是否需要结合公司情况?', selected: ['不需要公司信息'], freeText: '', skipped: false }];
  148. /** 跑真实切行器,取问卷行的 content —— 渲染层就是这么把内容交给 QuestionCard 的 */
  149. const questionCardsRowContent = (content) => {
  150. const rows = new StreamXMLFilter({}).processChunk(content);
  151. const rowsOfType = rows.filter((row) => row.type === RecordRowTypes.QuestionCards);
  152. return rowsOfType.length ? rowsOfType[rowsOfType.length - 1].content : '';
  153. };
  154. const rowBefore = questionCardsRowContent(CONTENT);
  155. check('切行器产出的问卷行 content **带 `<question-cards>` 标记**(所以解析必须先解包)',
  156. rowBefore.startsWith('<question-cards>') && rowBefore.endsWith('</question-cards>'), rowBefore.slice(0, 60));
  157. check('未作答:交互分支解析得到题目', parseQuestionsFromContent(rowBefore).length === 1);
  158. const rowAfter = questionCardsRowContent(applyQuestionCardsAnswers(CONTENT, answers));
  159. const parsedAnswers = parseAnswersFromContent(rowAfter);
  160. const parsedQuestions = parseQuestionsFromContent(rowAfter);
  161. check('作答后:只读分支解析得到答案', Array.isArray(parsedAnswers) && parsedAnswers.length === 1,
  162. JSON.stringify(rowAfter)?.slice(0, 140));
  163. // ⚠️ 写回保留 questions(只读态复用同一模板),所以断言的是**两者都在**:
  164. // questions 负责「长得和原来一样」,answers 负责「只读」
  165. check('作答后:questions 仍在(同一模板渲染 → 样式不变)', parsedQuestions.length === 1);
  166. // ★ 这条是"卡片渲染成空"的直接守卫:两个分支至少有一个能渲染出东西
  167. // (组件模板:`v-if="isReadonly && contentAnswers.length > 0"` / `v-else-if="questions.length > 0"`)
  168. check('**只读态能渲染出问卷本体**(questions 在 → 与作答前同一套样式;answers 在 → 只读)',
  169. (parsedAnswers?.length || 0) > 0 && parsedQuestions.length > 0,
  170. `answers=${parsedAnswers?.length ?? 'null'} questions=${parsedQuestions.length}`);
  171. check('直接传「标记内 JSON」也要能解析(兼容不带标记的调用方)',
  172. parseAnswersFromContent(JSON.stringify({ answers }))?.length === 1);
  173. }
  174. console.log(`\n===== 通过 ${pass} 项,失败 ${fail} 项 =====`);
  175. process.exit(fail ? 1 : 0);