oh-my-muse/muse-studio/e2e/accept-suggestion.spec.ts
lili 43a08624f8 feat(studio): S7c 采纳恒回传完整正文(所见即所写)+ 流健壮性
分支①第三步(纯前端,无需改后端):采纳恒以 merge_after_edit + modify_then_merge 语义上送用户在
候选面板审定的完整正文作 finalContent(改没改过都送、原样发),使 Canonical 由客户端回传正文写入,
而非后端存的 60/80 字摘要——修掉「原样采纳(accept_as_is)把摘要写进 Canonical」的潜伏缺陷。
后端契约已亲验:ContentSourceServiceImpl.resolveMergedContent 的 merge_after_edit 分支直接落
finalContent、只校验非空、不要求"必须改过";accept_as_is 才用摘要。

流健壮性:done 仅带 taskId/suggestionId/可选 summary、无完整性信号,故按前端可得信号三分——①流非空
(原子 SSE chunk 已收全)→happy;②流非空但短于 done.summary→疑似断连截断,提示重生成、绝不静默采纳;
③流为空→回源只得脱敏摘要,标 degraded 由候选面板明确警告、不当完整正文静默采纳。

验证:tsc -b / vitest 32 files 121 passed / vite build / eslint 均 EXIT=0;MSW-off e2e
accept-suggestion.spec 真后端48080+真 New-API 3 passed——采纳后 acceptMode=merge_after_edit、
revision 180→181、Canonical content_text===客户端回传 finalContent(所见即所写端到端证实)。
改动严格 8 文件全在 muse-studio。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 00:53:56 -07:00

411 lines
23 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { expect, test, type Page } from '@playwright/test';
import { Client } from 'pg';
/**
* AI 候选处置端到端冒烟测试(真实后端 + 真 New-API LLM 生成,绝不 stub反假绿
*
* 现状(断层已闭合,正路改真生成):本 spec 正路全链不再 page.route 桩任何 AI 生成接口——
* 经真实浏览器 WorkspacePage→AIPanel 发起真后端 AI 任务→真 New-API LLM→SSE done→候选落库→
* CandidatePanel「采纳替换」/「放弃修改」→真 POST suggestion-merges 或 suggestions/{id}/reject。这清除了进度账 line176②
* 「AI 生成链 e2e 一律 stub /ai/tasks」的最后一处 AI stub 假绿。
*
* ── 为何此前桩、为何现在能删ADR-020 已修commit a16c596──────────────────────────
* 旧根因:真 runtime 产出的 runtimePermissionEnvelopeId 是字符串 `rpe-local-<uuid>`(非数值)
* 旧 authorization_snapshot_id 列为 BIGINT投影时对非数值串解析失败 → 落库 authz=null →
* AiSuggestionMergeProjectionFacade 见 authz==null 即判 unavailable → 采纳被拒 1041001001。
* 故旧正路只能桩 /ai/tasks + /stream 令 onDone 回传种子审齐候选 suggestion3(authz=9001 数值)。
* 修复ADR-020 方案 Aauthorization_snapshot_id 列 BIGINT→VARCHAR直接承载字符串 envelope。
* 修后真生成候选 authz 现存 `rpe-local-*` 字符串 → 天然满足合并门禁,可被真采纳。
* 活体已证2026-06-25 本机):对真生成 suggestion(id=20authz=rpe-local-*) accept_as_is
* merge code=0、newRevision 自增70→71sourceAttribution.authorizationSnapshotId 回传该字符串。
* 故正路删桩改真生成,复用 ai-generation.spec 的真链路模式work1/block1 + writing.continuation 槽位)。
*
* ── 反假绿(正路)──────────────────────────────────────────────────────────────────
* ① 发送指令后真打 POST /app-api/muse/ai/tasks2xx + 业务码 code=0 + 回传 taskId证真接口非 mock 截获);
* ② SSE done 后 CandidatePanel「采纳替换」按钮可见done→onCandidateGenerated 真贯通浏览器);
* ③ DB 锚定:本轮真生成在 muse_ai_suggestion 落新行id > 运行前基准work1/block1content 非空);
* ④ 点击采纳真打 POST .../suggestion-merges → HTTP 200 + code=0 + data.newRevision 自增(相对采纳前实读 revision
* 且 sourceAttribution.parentSuggestionId == 本轮真生成 suggestionId证采纳的是真候选、非种子
* 被采纳的候选与写入 Canonical 全真,无任何 stub。
*
* ── 可重复(跑 2 次绿)────────────────────────────────────────────────────────────
* 每轮真生成产「新」suggestionid 自增,恒新、不复用),故无须复位 suggestion
* 采纳的 expectedBlockRevision 由前端从后端实读chapterDetail.blocks[0].revision断言只比较
* newRevision > 采纳前实读 revision动态不硬编码故 block revision 逐轮 +1 漂移不影响重复跑。
* work1 的 writing.continuation 槽位由 global-setup §15 每轮复位为 agent1/active保证真生成可解析 Agent。
*
* ── 前置 / 慢 + 异步 ──────────────────────────────────────────────────────────────
* - 真实 muse-server 在 48080V30 applied + ADR-020 authz=VARCHAR+ New-API 在线 + muse_slice_live
* e2e 关 MSWVITE_API_MOCK=false前端经真实浏览器直连后端vite 代理 /app-api→48080
* - work1(id=1)/chapter1(id=1)/block1(id=1) 已存在writing.continuation 槽位 activeglobal-setup §15 复位);
* - DB 凭据从 ~/.config/muse-repo/infra.env 环境变量读取MUSE_POSTGRES_*,绝不入库);
* - AI 生成依赖 New-API LLM(~7s) + dispatcher 延迟 + 轮询粒度,实测端到端约 20s故 test timeout 放宽到 150s。
*
* 后端 merge 主链路已由 P1rContentMergeSuggestionIT 在真实 PG 上证明(含正/负/幂等/状态/审门);
* 「真实生成投影产出的候选可被采纳」在 ADR-020 后于活体打通,本 spec 即其真浏览器端到端见证。
*/
/** 真实后端 mock 登录模式下的访问令牌mockSecret 'test' + userId 1 → 'test1')。 */
const TOKEN = 'test1';
/** 真生成 + 采纳所用作品work1 有 writing.continuation 槽位global-setup §15 每轮复位 agent1/active。 */
const WORK_ID = 1;
/** work1/chapter1 下唯一 BlockAIPanel 自动以 blocks[0]=block1 为目标,采纳写入该 Canonical Block。 */
const BLOCK_ID = 1;
/** 与 work1 KB 同主题,确保 grounding 续写命中global-setup 已为 work1 种世界观/魔法体系 schema。 */
const USER_INSTRUCTION = '继续描写星环大陆的魔法体系与元素对抗禁咒';
/** 整套用例放宽 timeoutAI 生成有外部 LLM + dispatcher 延迟 + 轮询粒度,远超默认 30s。 */
test.setTimeout(150_000);
/** 创建直连 muse_slice_live 的 pg 客户端(凭据从 infra.env 环境变量,绝不入库)。 */
function createPgClient(): Client {
return new Client({
host: process.env.MUSE_POSTGRES_HOST,
port: Number(process.env.MUSE_POSTGRES_PORT ?? '5433'),
database: process.env.MUSE_POSTGRES_DATABASE ?? 'muse_slice_live',
user: process.env.MUSE_POSTGRES_USERNAME ?? 'root',
password: process.env.MUSE_POSTGRES_PASSWORD,
ssl: false,
connectionTimeoutMillis: 10_000,
});
}
/** 读运行前 work1/block1 的 suggestion 最大 id作为「本轮新生成」基准workers=1 串行,无并发污染)。 */
async function readMaxSuggestionId(): Promise<number> {
const c = createPgClient();
await c.connect();
try {
const r = await c.query(
`SELECT COALESCE(MAX(id), 0) AS max_id FROM muse_ai_suggestion
WHERE tenant_id=1 AND work_id=$1 AND block_id=$2 AND deleted=false`,
[WORK_ID, BLOCK_ID]
);
return Number(r.rows[0].max_id);
} finally {
await c.end();
}
}
/** 读 id 大于基准的最新真生成 suggestionDB 核验本轮真落库;返回其 id 供采纳后比对 parentSuggestionId。 */
async function readLatestSuggestionAbove(
baselineId: number
): Promise<{ id: number; status: string; authz: string | null; content: string; decisionArchiveId: number | null } | null> {
const c = createPgClient();
await c.connect();
try {
const r = await c.query(
`SELECT id, status, authorization_snapshot_id AS authz, content_snapshot->>'content' AS content,
decision_archive_id
FROM muse_ai_suggestion
WHERE tenant_id=1 AND work_id=$1 AND block_id=$2 AND deleted=false AND id > $3
ORDER BY id DESC LIMIT 1`,
[WORK_ID, BLOCK_ID, baselineId]
);
if (r.rowCount === 0) return null;
const row = r.rows[0];
return {
id: Number(row.id),
status: String(row.status),
authz: row.authz == null ? null : String(row.authz),
content: row.content == null ? '' : String(row.content),
decisionArchiveId: row.decision_archive_id == null ? null : Number(row.decision_archive_id),
};
} finally {
await c.end();
}
}
/** 读指定 suggestion 的处置状态与 decision archive证明采纳后候选离开 pending 队列。 */
async function readSuggestionDecision(
suggestionId: number
): Promise<{ status: string; decisionArchiveId: number | null; decisionCount: number }> {
const c = createPgClient();
await c.connect();
try {
const r = await c.query(
`SELECT s.status, s.decision_archive_id, COUNT(d.id)::int AS decision_count
FROM muse_ai_suggestion s
LEFT JOIN muse_ai_suggestion_decision d
ON d.tenant_id=s.tenant_id AND d.suggestion_id=s.id AND d.deleted=false
WHERE s.tenant_id=1 AND s.id=$1 AND s.deleted=false
GROUP BY s.status, s.decision_archive_id`,
[suggestionId]
);
if (r.rowCount === 0) throw new Error(`suggestion id=${suggestionId} 不存在`);
const row = r.rows[0];
return {
status: String(row.status),
decisionArchiveId: row.decision_archive_id == null ? null : Number(row.decision_archive_id),
decisionCount: Number(row.decision_count),
};
} finally {
await c.end();
}
}
/** 实读目标 Block 当前 revision与前端 blockRevision 同口径:取 muse_content_block.revision。 */
async function readBlockRevision(): Promise<number> {
const c = createPgClient();
await c.connect();
try {
const r = await c.query(
`SELECT revision FROM muse_content_block WHERE tenant_id=1 AND work_id=$1 AND id=$2`,
[WORK_ID, BLOCK_ID]
);
if (r.rowCount === 0) throw new Error(`block id=${BLOCK_ID} 不存在(基础种子缺失)`);
return Number(r.rows[0].revision);
} finally {
await c.end();
}
}
/** 实读目标 Block 当前 Canonical 正文muse_content_block.content_text用于「所见即所写」核验。 */
async function readBlockContentText(): Promise<string> {
const c = createPgClient();
await c.connect();
try {
const r = await c.query(
`SELECT content_text FROM muse_content_block WHERE tenant_id=1 AND work_id=$1 AND id=$2`,
[WORK_ID, BLOCK_ID]
);
if (r.rowCount === 0) throw new Error(`block id=${BLOCK_ID} 不存在(基础种子缺失)`);
return r.rows[0].content_text == null ? '' : String(r.rows[0].content_text);
} finally {
await c.end();
}
}
/** 读 work1/block1 最新一条 pending 候选 id供负路乐观锁用例版本冲突在 authz 校验前先判,故任意 pending 即可)。 */
async function readLatestPendingSuggestionId(): Promise<number | null> {
const c = createPgClient();
await c.connect();
try {
const r = await c.query(
`SELECT id FROM muse_ai_suggestion
WHERE tenant_id=1 AND work_id=$1 AND block_id=$2 AND deleted=false AND status='pending'
ORDER BY id DESC LIMIT 1`,
[WORK_ID, BLOCK_ID]
);
return r.rowCount === 0 ? null : Number(r.rows[0].id);
} finally {
await c.end();
}
}
/**
* 在页面加载前注入登录令牌 + 租户到 localStorage。
* WHYstudio 的 HTTP 客户端从 localStorage 读取 accessToken/tenantId 注入 Bearer/tenant-ide2e 必须在首屏脚本
* 执行前写入,故用 addInitScript 而非加载后再 setItem后者会晚于首批请求
*/
async function seedToken(page: Page): Promise<void> {
await page.addInitScript((t) => {
window.localStorage.setItem('accessToken', t);
window.localStorage.setItem('tenantId', '1');
}, TOKEN);
}
test.describe('AI 候选处置闭环(真实后端 + 真生成,不 stub', () => {
test('真生成候选 → 采纳 → newRevision 自增(全链真后端,无 AI stub', async ({ page }) => {
// 0. 记录运行前 work1/block1 的 suggestion 基准 id本轮 UI 真生成应产出 id 更大的新行DB 反假绿锚点)。
const baselineSuggestionId = await readMaxSuggestionId();
await seedToken(page);
// 导航到真实创作工作台路由(见 src/app/routes/index.tsx 的 workspace/:workId
// WorkspacePage 自动激活 chapter1 + blocks[0]=block1AIPanel 拿到 blockId=1。
await page.goto(`/workspace/${WORK_ID}`);
// 1. 等工作台就绪AIPanel 发送按钮出现。
await expect(page.getByRole('button', { name: /发送指令/ })).toBeVisible({ timeout: 30_000 });
// 2. ① 填真实指令并捕获真 POST /ai/tasks反假绿真打真接口非 page.route 截获)。
const promptBox = page.getByPlaceholder(/在此处输入创作描述/);
await promptBox.fill(USER_INSTRUCTION);
const taskResponsePromise = page.waitForResponse(
(resp) => /\/app-api\/muse\/ai\/tasks$/.test(resp.url()) && resp.request().method() === 'POST',
{ timeout: 30_000 }
);
await page.getByRole('button', { name: /发送指令/ }).click();
const taskResponse = await taskResponsePromise;
// 异步受理:真后端对 POST /ai/tasks 返回 2xx任务入队稍后经 SSE done 推 suggestion。只断言 2xx
// 业务真伪以 code=0 为准(不强求 200避免把正确的 202 误判为红)。
expect(taskResponse.status(), 'POST /ai/tasks HTTP 状态应为 2xx').toBeGreaterThanOrEqual(200);
expect(taskResponse.status(), 'POST /ai/tasks HTTP 状态应为 2xx').toBeLessThan(300);
const taskBody = await taskResponse.json();
expect(taskBody.code, `创建 AI 任务响应: ${JSON.stringify(taskBody)}`).toBe(0);
expect(taskBody?.data?.taskId, 'AI 任务应回传 taskId').toBeTruthy();
// 3. ② 等真 SSE done → onCandidateGenerated → CandidatePanel「采纳替换」按钮可见证候选已就绪。
// 放宽到 120s覆盖 New-API cold/慢 + dispatcher 延迟 + 5s 轮询粒度。绝不因 flaky 改 stub 绕过。
const acceptButton = page.getByRole('button', { name: /采纳替换/ });
await expect(acceptButton).toBeVisible({ timeout: 120_000 });
await expect(acceptButton).toBeEnabled({ timeout: 30_000 });
// 4. ③ DB 核验:本轮真生成应在 muse_ai_suggestion 落新行id > 基准work1/block1、内容非空、authz 非空。
// authz 非空是 ADR-020 修复的直接见证(修前真生成候选 authz=null → 不可采纳)。
const dbRow = await readLatestSuggestionAbove(baselineSuggestionId);
expect(dbRow, `应在 DB 找到本轮真生成 suggestionid > ${baselineSuggestionId}`).not.toBeNull();
expect(dbRow!.content.length, 'DB suggestion 内容应非空(真 LLM 生成)').toBeGreaterThan(0);
expect(dbRow!.authz, 'ADR-020 后真生成候选 authz 应为非空字符串 envelope').toBeTruthy();
const generatedSuggestionId = dbRow!.id;
// 5. 采纳前实读 Block revision前端 expectedBlockRevision 同口径:取 block.revision
// 动态读而非硬编码block revision 逐轮采纳 +1 漂移,跑 2 次须可重复。
const revisionBeforeAccept = await readBlockRevision();
// 6. ④ 点击采纳,捕获真实 suggestion-merges 写入响应(直打真后端 merge反假绿核心
const [mergeResponse] = await Promise.all([
page.waitForResponse(
(resp) => /\/suggestion-merges$/.test(resp.url()) && resp.request().method() === 'POST',
{ timeout: 30_000 }
),
acceptButton.click(),
]);
// (a) 写入命令 HTTP 200。
expect(mergeResponse.status()).toBe(200);
const mergeBody = await mergeResponse.json();
// (b) 业务码 code=0真采纳成功非被门禁/乐观锁拒)。
expect(mergeBody.code, `采纳响应: ${JSON.stringify(mergeBody).slice(0, 200)}`).toBe(0);
// (c) newRevision 自增(相对采纳前实读 revision动态比较不硬编码 revision
const newRevision = mergeBody?.data?.newRevision;
expect(typeof newRevision).toBe('number');
expect(newRevision).toBeGreaterThan(revisionBeforeAccept);
// (d) 采纳的确是本轮真生成候选parentSuggestionId == 真生成 suggestionId证非种子/非 mock。
expect(String(mergeBody?.data?.sourceAttribution?.lineage?.parentSuggestionId)).toBe(
String(generatedSuggestionId)
);
// (d2) 所见即所写S7c 裁决②):前端采纳恒以 merge_after_edit + modify_then_merge 上送用户审定的完整正文,
// 故 acceptMode 现为 merge_after_edit此前为 accept_as_is——后端会改用自己存的摘要写正文已被本步修掉
expect(mergeBody?.data?.sourceAttribution?.lineage?.acceptMode).toBe('merge_after_edit');
// (d3) 采纳请求体核验:真打的 suggestion-merges 携带 merge_after_edit + 非空 finalContent客户端回传正文
const mergeReqBody = JSON.parse(mergeResponse.request().postData() ?? '{}');
expect(mergeReqBody.mergeMode, '采纳恒走 merge_after_edit').toBe('merge_after_edit');
expect(mergeReqBody.decisionType, '与 mergeMode 成对,恒 modify_then_merge').toBe('modify_then_merge');
expect(typeof mergeReqBody.finalContent, 'finalContent 必为客户端回传的完整正文字符串').toBe('string');
expect(String(mergeReqBody.finalContent).length, 'finalContent 非空').toBeGreaterThan(0);
// (d4) 所见即所写 Canonical 证明:写入 muse_content_block.content_text 的正文严格等于客户端回传的 finalContent
// 证 Canonical 由用户审定的回传正文写入(而非后端存的 60/80 字脱敏摘要)。
const canonicalContent = await readBlockContentText();
expect(canonicalContent, 'Canonical 正文应等于客户端回传的 finalContent所见即所写').toBe(
String(mergeReqBody.finalContent)
);
// (e) AI owner 必须把被采纳候选标为 accepted 并写 decision archive否则候选仍 pending后续可被重复处置。
const acceptedDecision = await readSuggestionDecision(generatedSuggestionId);
expect(acceptedDecision.status).toBe('accepted');
expect(acceptedDecision.decisionArchiveId).toBeTruthy();
expect(acceptedDecision.decisionCount).toBeGreaterThanOrEqual(1);
// 留痕:打印真 suggestionId 与 revision 跃迁,作为反假绿证据(运行日志可见)。
console.log(
`[accept-suggestion] 真生成 suggestionId=${generatedSuggestionId} authz=${dbRow!.authz} ` +
`revision ${revisionBeforeAccept}${newRevision}`
);
// (e) 刷新后断言工作台仍渲染该作品(对比面板已关闭,回到 AI 面板)。
await page.reload();
await expect(page.getByRole('button', { name: /发送指令/ })).toBeVisible({ timeout: 30_000 });
});
test('错误的 expectedBlockRevision 导致采纳因乐观锁冲突被拒HTTP 200 + 业务错误码)', async ({ page }) => {
await seedToken(page);
// 取 work1/block1 一条 pending 候选作 suggestionId版本冲突在 authz 校验前先判,故任意 pending 即可
//(活体已证 null-authz 候选 + stale revision 同样先返回 1041000002与候选 authz 无关)。
const pendingId = await readLatestPendingSuggestionId();
expect(pendingId, 'work1/block1 应至少有一条 pending 候选供负路用例').not.toBeNull();
// 实读当前 revision再以「明显过期」revision 触发乐观锁冲突(动态读,避免硬编码漂移误红)。
const liveRevision = await readBlockRevision();
const wrongRevision = liveRevision + 999;
// 用 page.request 直接打接口,绕开 UI专注验证后端乐观锁负路径Bearer/tenant 显式带上。
// 注意Yudao 全局异常处理把 ServiceException 包成 HTTP 200 + CommonResult{code≠0,msg},并非 REST 409
//P1rContentMergeSuggestionIT 已证实该口径)。
const response = await page.request.post(
`/app-api/muse/works/${WORK_ID}/blocks/${BLOCK_ID}/suggestion-merges`,
{
headers: {
'X-API-Version': '1',
Authorization: `Bearer ${TOKEN}`,
// 后端按 tenant-id 头解析租户page.request 绕开 client.ts需手动带上否则报租户未传递
'tenant-id': '1',
'Content-Type': 'application/json',
},
data: {
commandId: crypto.randomUUID(),
suggestionId: String(pendingId),
expectedBlockRevision: wrongRevision,
decisionType: 'accept',
mergeMode: 'accept_as_is',
sourceSnapshot: { sourceType: 'ai_suggestion' },
auditReason: 'e2e negative path: stale revision',
},
}
);
expect(response.status()).toBe(200);
const body = await response.json();
expect(body.code).not.toBe(0);
expect(String(body.msg)).toContain('版本');
});
test('真生成候选 → 放弃修改 → 后端 reject 归档(全链真后端,无 AI stub', async ({ page }) => {
const baselineSuggestionId = await readMaxSuggestionId();
await seedToken(page);
await page.goto(`/workspace/${WORK_ID}`);
await expect(page.getByRole('button', { name: /发送指令/ })).toBeVisible({ timeout: 30_000 });
const promptBox = page.getByPlaceholder(/在此处输入创作描述/);
await promptBox.fill(`${USER_INSTRUCTION},本轮用于拒绝候选闭环验收`);
const taskResponsePromise = page.waitForResponse(
(resp) => /\/app-api\/muse\/ai\/tasks$/.test(resp.url()) && resp.request().method() === 'POST',
{ timeout: 30_000 }
);
await page.getByRole('button', { name: /发送指令/ }).click();
const taskResponse = await taskResponsePromise;
expect(taskResponse.status(), 'POST /ai/tasks HTTP 状态应为 2xx').toBeGreaterThanOrEqual(200);
expect(taskResponse.status(), 'POST /ai/tasks HTTP 状态应为 2xx').toBeLessThan(300);
const taskBody = await taskResponse.json();
expect(taskBody.code, `创建 AI 任务响应: ${JSON.stringify(taskBody)}`).toBe(0);
expect(taskBody?.data?.taskId, 'AI 任务应回传 taskId').toBeTruthy();
const rejectButton = page.getByRole('button', { name: /放弃修改/ });
await expect(rejectButton).toBeVisible({ timeout: 120_000 });
await expect(rejectButton).toBeEnabled({ timeout: 30_000 });
const dbRow = await readLatestSuggestionAbove(baselineSuggestionId);
expect(dbRow, `应在 DB 找到本轮真生成 suggestionid > ${baselineSuggestionId}`).not.toBeNull();
expect(dbRow!.content.length, 'DB suggestion 内容应非空(真 LLM 生成)').toBeGreaterThan(0);
expect(dbRow!.authz, '真生成候选 authz 应为非空字符串 envelope').toBeTruthy();
const generatedSuggestionId = dbRow!.id;
const [rejectResponse] = await Promise.all([
page.waitForResponse(
(resp) =>
new RegExp(`/app-api/muse/suggestions/${generatedSuggestionId}/reject$`).test(resp.url()) &&
resp.request().method() === 'POST',
{ timeout: 30_000 }
),
rejectButton.click(),
]);
expect(rejectResponse.status()).toBe(200);
const rejectBody = await rejectResponse.json();
expect(rejectBody.code, `拒绝候选响应: ${JSON.stringify(rejectBody).slice(0, 200)}`).toBe(0);
const rejectedDecision = await readSuggestionDecision(generatedSuggestionId);
expect(rejectedDecision.status).toBe('rejected');
expect(rejectedDecision.decisionArchiveId).toBeTruthy();
expect(rejectedDecision.decisionCount).toBeGreaterThanOrEqual(1);
await expect(page.getByRole('button', { name: /采纳替换/ })).toBeHidden({ timeout: 15_000 });
console.log(`[reject-suggestion] 真生成 suggestionId=${generatedSuggestionId} 已 reject 并写 decision archive`);
});
});