|
@@ -0,0 +1,124 @@
|
|
|
|
|
+/**
|
|
|
|
|
+ * 验证「问卷作答后变只读」这条链路 + 选项选中样式那条修(用户 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 } =
|
|
|
|
|
+ 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 = `<scope title="思考中">\n</scope>\n<question-cards>${QUESTIONS_JSON}</question-cards>`;
|
|
|
|
|
+
|
|
|
|
|
+console.log('\n【1】把答案写回问卷行(写回后才会走只读渲染分支)');
|
|
|
|
|
+{
|
|
|
|
|
+ const answers = [{ header: '本次是否需要结合公司情况?', selected: ['不需要公司信息'], freeText: '', skipped: false }];
|
|
|
|
|
+ const written = applyQuestionCardsAnswers(CONTENT, answers);
|
|
|
|
|
+
|
|
|
|
|
+ // QuestionCard 的只读分支:parseAnswersFromContent(row.content) → data.answers
|
|
|
|
|
+ const rowContent = written.slice(
|
|
|
|
|
+ written.indexOf('<question-cards>') + '<question-cards>'.length,
|
|
|
|
|
+ written.lastIndexOf('</question-cards>')
|
|
|
|
|
+ );
|
|
|
|
|
+ let parsed = null;
|
|
|
|
|
+ try {
|
|
|
|
|
+ parsed = JSON.parse(rowContent);
|
|
|
|
|
+ } catch {
|
|
|
|
|
+ /* 下面断言会报出来 */
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ check('写回后仍是合法 JSON(只读分支靠 JSON.parse 读)', !!parsed, JSON.stringify(rowContent)?.slice(0, 120));
|
|
|
|
|
+ check('含 answers 数组(只读分支的判定条件)', Array.isArray(parsed?.answers) && parsed.answers.length === 1);
|
|
|
|
|
+ check('答案形状与只读渲染一致(header / selected / freeText)',
|
|
|
|
|
+ parsed?.answers?.[0]?.header === '本次是否需要结合公司情况?' &&
|
|
|
|
|
+ JSON.stringify(parsed?.answers?.[0]?.selected) === JSON.stringify(['不需要公司信息']) &&
|
|
|
|
|
+ parsed?.answers?.[0]?.freeText === '',
|
|
|
|
|
+ JSON.stringify(parsed?.answers?.[0]));
|
|
|
|
|
+ check('问卷外的前后内容没被动(scope 行仍在前、标签成对)',
|
|
|
|
|
+ written.startsWith('<scope title="思考中">\n</scope>\n<question-cards>') && written.endsWith('</question-cards>'));
|
|
|
|
|
+ check('hasAnsweredQuestionCards 能识别', hasAnsweredQuestionCards(written) === true);
|
|
|
|
|
+ check('作答前不算已作答(只读分支不会误触发)', hasAnsweredQuestionCards(CONTENT) === false);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+console.log('\n【2】边界:不能破坏别人的内容');
|
|
|
|
|
+{
|
|
|
|
|
+ check('没有问卷标记 → 原样返回', applyQuestionCardsAnswers('<scope>正文</scope>', []) === '<scope>正文</scope>');
|
|
|
|
|
+ check('标记不成对 → 原样返回',
|
|
|
|
|
+ applyQuestionCardsAnswers('<question-cards>{"questions":[]}', []) === '<question-cards>{"questions":[]}');
|
|
|
|
|
+
|
|
|
|
|
+ const two = `<question-cards>{"questions":[{"header":"第一轮"}]}</question-cards>\n正文\n<question-cards>{"questions":[{"header":"第二轮"}]}</question-cards>`;
|
|
|
|
|
+ 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('写回的是「最后一条带 <question-cards> 的 AI 消息」',
|
|
|
|
|
+ /reverse\(\)[\s\S]{0,220}item\.content\.includes\('<question-cards>'\)/.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));
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+console.log(`\n===== 通过 ${pass} 项,失败 ${fail} 项 =====`);
|
|
|
|
|
+process.exit(fail ? 1 : 0);
|