chore: 保存当前架构收口与 bug 修复快照
归档本轮 P0/P1 bug 修复、设计审查迁移、AI selection scope 收口与 stream contract 调整,并保留当前 05 主线迁移起点。
This commit is contained in:
@@ -17,6 +17,7 @@ const {
|
||||
} = require("./tree-shell-smoke-helpers");
|
||||
|
||||
const OUT_DIR = path.join(process.cwd(), "tmp", "page-block-ai-tools-smoke");
|
||||
const CHROME_EXECUTABLE = process.env.PLAYWRIGHT_CHROME_EXECUTABLE || "";
|
||||
|
||||
function blockById(blocks, blockId) {
|
||||
return blocks.find((block) => block.blockId === blockId);
|
||||
@@ -26,6 +27,16 @@ function blockTexts(blocks) {
|
||||
return blocks.map((block) => block.text);
|
||||
}
|
||||
|
||||
function aggregateBlocks(aggregate) {
|
||||
const blocks = aggregate?.body?.blockDocument?.blocks;
|
||||
assert(Array.isArray(blocks), "Page Aggregate 必须返回 body.blockDocument.blocks");
|
||||
return blocks;
|
||||
}
|
||||
|
||||
function aggregateBlockById(aggregate, blockId) {
|
||||
return aggregateBlocks(aggregate).find((block) => block.blockId === blockId);
|
||||
}
|
||||
|
||||
async function waitForVisibleTexts(page, expectedTexts) {
|
||||
await page.waitForFunction(
|
||||
({ expected }) => {
|
||||
@@ -63,6 +74,16 @@ async function callMnoteTool(request, payload) {
|
||||
});
|
||||
}
|
||||
|
||||
async function fetchPageAggregate(request, workspaceId, documentId) {
|
||||
const payload = await requestJson(
|
||||
request,
|
||||
`/api/page-aggregate/${encodeURIComponent(documentId)}?workspaceId=${encodeURIComponent(workspaceId)}`,
|
||||
{ method: "GET" },
|
||||
);
|
||||
assert.equal(payload.schema, "mnote.page_aggregate.v1", "Page Aggregate schema 应保持稳定");
|
||||
return payload.result;
|
||||
}
|
||||
|
||||
async function fetchBlocks(request, target, suffix, label, actorId) {
|
||||
const response = await callMnoteTool(request, {
|
||||
toolName: "mnote.doc.fetch",
|
||||
@@ -96,8 +117,14 @@ async function main() {
|
||||
steps: [],
|
||||
};
|
||||
await fs.mkdir(OUT_DIR, { recursive: true });
|
||||
const hermesTesterDir = path.join(process.cwd(), "tmp", "hermes-tester", `page-block-ai-tools-${suffix}`);
|
||||
await fs.mkdir(hermesTesterDir, { recursive: true });
|
||||
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const browser = await chromium.launch({
|
||||
headless: true,
|
||||
...(CHROME_EXECUTABLE ? { executablePath: CHROME_EXECUTABLE } : {}),
|
||||
args: ["--no-sandbox", "--disable-dev-shm-usage"],
|
||||
});
|
||||
const context = await browser.newContext({ viewport: { width: 1280, height: 900 } });
|
||||
const page = await context.newPage();
|
||||
|
||||
@@ -111,9 +138,28 @@ async function main() {
|
||||
await renameDocument(context.request, target.workspaceId, target.documentId, title);
|
||||
|
||||
const initialContent = [
|
||||
{ id: "heading_1", type: "heading", props: { level: 1 }, content: [{ type: "text", text: `标题 ${suffix}` }] },
|
||||
{ id: "p_1", type: "paragraph", content: [{ type: "text", text: `第一段 ${suffix}` }] },
|
||||
{ id: "p_2", type: "paragraph", content: [{ type: "text", text: `第二段 ${suffix}` }] },
|
||||
{ id: "p_3", type: "paragraph", content: [{ type: "text", text: `第三段 ${suffix}` }] },
|
||||
{
|
||||
id: "heading_parent",
|
||||
type: "heading",
|
||||
props: { level: 2 },
|
||||
content: [{ type: "text", text: `带子块标题 ${suffix}` }],
|
||||
children: [
|
||||
{ id: "heading_child", type: "paragraph", content: [{ type: "text", text: `标题子段落 ${suffix}` }] },
|
||||
],
|
||||
},
|
||||
{ id: "list_item_1", type: "bullet_list_item", content: [{ type: "text", text: `列表项 ${suffix}` }] },
|
||||
{ id: "table_1", type: "table", content: [{ type: "text", text: `表格块 ${suffix}` }] },
|
||||
{
|
||||
id: "mindmap_1",
|
||||
type: "mindmap",
|
||||
props: { mindmapId: `mindmap_smoke_${suffix}` },
|
||||
content: [{ type: "text", text: `思维导图块 ${suffix}` }],
|
||||
},
|
||||
{ id: "resource_1", type: "resource", content: [{ type: "text", text: `资源块 ${suffix}` }] },
|
||||
];
|
||||
const seed = await callMnoteTool(context.request, {
|
||||
toolName: "mnote.page.save",
|
||||
@@ -136,16 +182,139 @@ async function main() {
|
||||
evidence.steps.push({ name: "seed", commandName: seed.result.commandName });
|
||||
|
||||
let snapshot = await fetchBlocks(context.request, target, suffix, "initial", actorId);
|
||||
const initialAggregate = await fetchPageAggregate(context.request, target.workspaceId, target.documentId);
|
||||
await fs.writeFile(
|
||||
path.join(hermesTesterDir, "page-aggregate.json"),
|
||||
JSON.stringify(initialAggregate, null, 2),
|
||||
"utf8",
|
||||
);
|
||||
const p1 = blockById(snapshot.blocks, "p_1");
|
||||
const p2 = blockById(snapshot.blocks, "p_2");
|
||||
const p3 = blockById(snapshot.blocks, "p_3");
|
||||
assert(p1 && p2 && p3, "初始化后应能读取 p_1/p_2/p_3");
|
||||
const heading = blockById(snapshot.blocks, "heading_1");
|
||||
assert(heading && p1 && p2 && p3, "初始化后应能读取 heading_1/p_1/p_2/p_3");
|
||||
assert(p1.revisionRef && p2.revisionRef && p3.revisionRef, "块投影必须返回 revisionRef");
|
||||
const initialAggregateBlocks = aggregateBlocks(initialAggregate);
|
||||
assert.deepEqual(
|
||||
initialAggregateBlocks.map((block) => block.blockId),
|
||||
snapshot.blocks.map((block) => block.blockId),
|
||||
"Page Aggregate blockDocument 与 mnote.doc.fetch 块顺序必须一致",
|
||||
);
|
||||
assert(
|
||||
["heading_1", "p_1", "p_2", "p_3", "list_item_1"].every((blockId) => {
|
||||
const block = aggregateBlockById(initialAggregate, blockId);
|
||||
return block?.editable === true && Boolean(block.revisionRef);
|
||||
}),
|
||||
"普通可编辑块必须在 Page Aggregate 中带 blockId/revisionRef",
|
||||
);
|
||||
const complexBlocks = ["heading_parent", "list_item_1", "table_1", "mindmap_1", "resource_1"]
|
||||
.map((blockId) => blockById(snapshot.blocks, blockId));
|
||||
assert(complexBlocks.every(Boolean), "初始化后应能读取复杂块投影");
|
||||
const unsupportedBlocks = ["table_1", "mindmap_1", "resource_1"].map((blockId) => blockById(snapshot.blocks, blockId));
|
||||
assert(
|
||||
unsupportedBlocks.every((block) => block.editable === false && block.unsupportedReason),
|
||||
"table/mindmap/resource 不能在 AI 投影中伪装成完全可编辑块",
|
||||
);
|
||||
assert.deepEqual(
|
||||
blockById(snapshot.blocks, "heading_parent").children,
|
||||
["heading_child"],
|
||||
"带子块标题必须在投影中保留 children,用于阻断 move_after",
|
||||
);
|
||||
evidence.steps.push({
|
||||
name: "doc.fetch.initial",
|
||||
revision: snapshot.revision,
|
||||
conflictDetectionKey: snapshot.conflictDetectionKey,
|
||||
blockIds: snapshot.blocks.map((block) => block.blockId),
|
||||
aggregatePath: `/api/page-aggregate/${target.documentId}`,
|
||||
aggregateEvidencePath: path.join(hermesTesterDir, "page-aggregate.json"),
|
||||
aggregateBlockProjectionVersion: initialAggregate.body.blockProjectionVersion,
|
||||
aggregateProjectionSource: initialAggregate.body.projectionSource,
|
||||
aggregateBlockCount: initialAggregateBlocks.length,
|
||||
unsupportedBlocks: unsupportedBlocks.map((block) => ({
|
||||
blockId: block.blockId,
|
||||
type: block.type,
|
||||
editable: block.editable,
|
||||
unsupportedReason: block.unsupportedReason,
|
||||
})),
|
||||
});
|
||||
|
||||
const outline = await callMnoteTool(context.request, {
|
||||
toolName: "mnote.doc.fetch",
|
||||
workspaceId: target.workspaceId,
|
||||
documentId: target.documentId,
|
||||
actorId,
|
||||
sessionId: `sess_outline_${suffix}`,
|
||||
runId: `run_outline_${suffix}`,
|
||||
toolCallId: `call_outline_${suffix}`,
|
||||
traceId: `trace_outline_${suffix}`,
|
||||
capabilityScope: ["page.read"],
|
||||
args: {
|
||||
scope: "outline",
|
||||
detail: "with_ids",
|
||||
},
|
||||
});
|
||||
assert.equal(outline.ok, true, "doc.fetch outline 应成功");
|
||||
assert.deepEqual(
|
||||
outline.result.blocks.map((block) => block.blockId),
|
||||
["heading_1", "heading_parent"],
|
||||
"outline 应只返回标题块",
|
||||
);
|
||||
|
||||
const truncated = await callMnoteTool(context.request, {
|
||||
toolName: "mnote.doc.fetch",
|
||||
workspaceId: target.workspaceId,
|
||||
documentId: target.documentId,
|
||||
actorId,
|
||||
sessionId: `sess_truncated_${suffix}`,
|
||||
runId: `run_truncated_${suffix}`,
|
||||
toolCallId: `call_truncated_${suffix}`,
|
||||
traceId: `trace_truncated_${suffix}`,
|
||||
capabilityScope: ["page.read"],
|
||||
args: {
|
||||
scope: "full",
|
||||
detail: "with_ids",
|
||||
maxBlocks: 2,
|
||||
},
|
||||
});
|
||||
assert.equal(truncated.result.truncated, true, "maxBlocks 裁剪应返回 truncated=true");
|
||||
assert.equal(truncated.result.blocks.length, 2, "maxBlocks=2 应只返回两个块");
|
||||
assert(truncated.result.continuation, "truncated 结果必须返回 continuation");
|
||||
|
||||
const findP2 = await callMnoteTool(context.request, {
|
||||
toolName: "mnote.doc.find",
|
||||
workspaceId: target.workspaceId,
|
||||
documentId: target.documentId,
|
||||
actorId,
|
||||
sessionId: `sess_find_${suffix}`,
|
||||
runId: `run_find_${suffix}`,
|
||||
toolCallId: `call_find_${suffix}`,
|
||||
traceId: `trace_find_${suffix}`,
|
||||
capabilityScope: ["page.read"],
|
||||
args: {
|
||||
query: `第二段 ${suffix}`,
|
||||
},
|
||||
});
|
||||
assert.equal(findP2.result.matches[0].blockId, "p_2", "doc.find 应定位第二段");
|
||||
await fs.writeFile(
|
||||
path.join(hermesTesterDir, "doc-fetch-find.json"),
|
||||
JSON.stringify(
|
||||
{
|
||||
full: snapshot,
|
||||
outline: outline.result,
|
||||
truncated: truncated.result,
|
||||
find: findP2.result,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
"utf8",
|
||||
);
|
||||
evidence.steps.push({
|
||||
name: "doc.fetch.find",
|
||||
outlineBlockIds: outline.result.blocks.map((block) => block.blockId),
|
||||
truncated: truncated.result.truncated,
|
||||
findBlockId: findP2.result.matches[0].blockId,
|
||||
evidencePath: path.join(hermesTesterDir, "doc-fetch-find.json"),
|
||||
});
|
||||
|
||||
const blockFetch = await callMnoteTool(context.request, {
|
||||
@@ -159,15 +328,180 @@ async function main() {
|
||||
traceId: `trace_block_${suffix}`,
|
||||
capabilityScope: ["block.read"],
|
||||
args: {
|
||||
blockId: "p_2",
|
||||
blockId: findP2.result.matches[0].blockId,
|
||||
includeChildren: true,
|
||||
contextBefore: 1,
|
||||
contextAfter: 1,
|
||||
},
|
||||
});
|
||||
assert.equal(blockFetch.result.block.blockId, "p_2", "block.fetch 应读取目标块");
|
||||
assert(blockFetch.result.block.revisionRef, "block.fetch 应返回 revisionRef");
|
||||
assert.equal(blockFetch.result.context.before[0].blockId, "p_1", "block.fetch before 应来自同父级");
|
||||
assert.equal(blockFetch.result.context.after[0].blockId, "p_3", "block.fetch after 应来自同父级");
|
||||
evidence.steps.push({ name: "block.fetch", blockId: "p_2" });
|
||||
evidence.steps.push({
|
||||
name: "block.fetch",
|
||||
blockId: "p_2",
|
||||
revisionRef: blockFetch.result.block.revisionRef,
|
||||
before: blockFetch.result.context.before.map((block) => block.blockId),
|
||||
after: blockFetch.result.context.after.map((block) => block.blockId),
|
||||
});
|
||||
|
||||
const complexMoveBlocked = [];
|
||||
for (const blockId of ["heading_parent", "list_item_1", "table_1", "mindmap_1", "resource_1"]) {
|
||||
const complexBlock = blockById(snapshot.blocks, blockId);
|
||||
const plan = await callMnoteTool(context.request, {
|
||||
toolName: "mnote.doc.plan_update",
|
||||
workspaceId: target.workspaceId,
|
||||
documentId: target.documentId,
|
||||
actorId,
|
||||
sessionId: `sess_plan_complex_${suffix}`,
|
||||
runId: `run_plan_complex_${suffix}_${blockId}`,
|
||||
toolCallId: `call_plan_complex_${suffix}_${blockId}`,
|
||||
traceId: `trace_plan_complex_${suffix}_${blockId}`,
|
||||
idempotencyKey: `idem_plan_complex_${suffix}_${blockId}`,
|
||||
dryRun: true,
|
||||
capabilityScope: ["block.write"],
|
||||
args: {
|
||||
command: "block_move_after",
|
||||
blockId,
|
||||
anchorBlockId: "p_1",
|
||||
},
|
||||
});
|
||||
assert.equal(plan.result.blocked, true, `${blockId} move_after dry-run 必须阻断`);
|
||||
const blockedMove = await callMnoteTool(context.request, {
|
||||
toolName: "mnote.block.move_after",
|
||||
workspaceId: target.workspaceId,
|
||||
documentId: target.documentId,
|
||||
actorId,
|
||||
sessionId: `sess_move_complex_${suffix}`,
|
||||
runId: `run_move_complex_${suffix}_${blockId}`,
|
||||
toolCallId: `call_move_complex_${suffix}_${blockId}`,
|
||||
traceId: `trace_move_complex_${suffix}_${blockId}`,
|
||||
idempotencyKey: `idem_move_complex_${suffix}_${blockId}`,
|
||||
dryRun: false,
|
||||
capabilityScope: ["block.write"],
|
||||
args: {
|
||||
blockId,
|
||||
anchorBlockId: "p_1",
|
||||
revision: snapshot.revision,
|
||||
conflictDetectionKey: snapshot.conflictDetectionKey,
|
||||
blockRevisionRef: complexBlock.revisionRef,
|
||||
anchorRevisionRef: p1.revisionRef,
|
||||
},
|
||||
});
|
||||
assert.equal(blockedMove.result.blocked, true, `${blockId} 正式 move_after 必须返回 blocked`);
|
||||
assert.equal(
|
||||
blockedMove.result.warnings[0].code,
|
||||
"block_move_after_blocked",
|
||||
`${blockId} 正式 move_after 必须返回阻断 warning`,
|
||||
);
|
||||
complexMoveBlocked.push({
|
||||
blockId,
|
||||
type: complexBlock.type,
|
||||
editable: complexBlock.editable,
|
||||
dryRunBlocked: plan.result.blocked,
|
||||
writeBlocked: blockedMove.result.blocked,
|
||||
warningCode: blockedMove.result.warnings[0].code,
|
||||
});
|
||||
}
|
||||
const afterComplexBlocked = await fetchBlocks(context.request, target, suffix, "after_complex_blocked", actorId);
|
||||
assert.equal(afterComplexBlocked.revision, snapshot.revision, "复杂块 move_after 阻断后 revision 不应变化");
|
||||
assert.deepEqual(blockTexts(afterComplexBlocked.blocks), blockTexts(snapshot.blocks), "复杂块 move_after 阻断后正文不应变化");
|
||||
evidence.steps.push({
|
||||
name: "block.move_after.complex_blocked",
|
||||
blocked: complexMoveBlocked,
|
||||
revisionAfterBlocked: afterComplexBlocked.revision,
|
||||
});
|
||||
|
||||
const dryRunBaselineTexts = blockTexts(snapshot.blocks);
|
||||
const dryRunBaselineAggregate = await fetchPageAggregate(
|
||||
context.request,
|
||||
target.workspaceId,
|
||||
target.documentId,
|
||||
);
|
||||
const replacePlan = await callMnoteTool(context.request, {
|
||||
toolName: "mnote.doc.plan_update",
|
||||
workspaceId: target.workspaceId,
|
||||
documentId: target.documentId,
|
||||
actorId,
|
||||
sessionId: `sess_plan_replace_${suffix}`,
|
||||
runId: `run_plan_replace_${suffix}`,
|
||||
toolCallId: `call_plan_replace_${suffix}`,
|
||||
traceId: `trace_plan_replace_${suffix}`,
|
||||
idempotencyKey: `idem_plan_replace_${suffix}`,
|
||||
dryRun: true,
|
||||
capabilityScope: ["block.write"],
|
||||
args: {
|
||||
command: "block_replace",
|
||||
blockId: "p_2",
|
||||
content: `dry-run 替换 ${suffix}`,
|
||||
},
|
||||
});
|
||||
assert.equal(replacePlan.result.dryRun, true, "block_replace plan 必须是 dry-run");
|
||||
assert.equal(replacePlan.result.diff[0].before, `第二段 ${suffix}`, "replace plan 应解释 before");
|
||||
assert.equal(replacePlan.result.diff[0].after, `dry-run 替换 ${suffix}`, "replace plan 应解释 after");
|
||||
|
||||
const insertPlan = await callMnoteTool(context.request, {
|
||||
toolName: "mnote.doc.plan_update",
|
||||
workspaceId: target.workspaceId,
|
||||
documentId: target.documentId,
|
||||
actorId,
|
||||
sessionId: `sess_plan_insert_${suffix}`,
|
||||
runId: `run_plan_insert_${suffix}`,
|
||||
toolCallId: `call_plan_insert_${suffix}`,
|
||||
traceId: `trace_plan_insert_${suffix}`,
|
||||
idempotencyKey: `idem_plan_insert_${suffix}`,
|
||||
dryRun: true,
|
||||
capabilityScope: ["block.write"],
|
||||
args: {
|
||||
command: "block_insert_after",
|
||||
anchorBlockId: "p_1",
|
||||
content: `dry-run 插入 ${suffix}`,
|
||||
},
|
||||
});
|
||||
assert.equal(insertPlan.result.dryRun, true, "block_insert_after plan 必须是 dry-run");
|
||||
assert.equal(insertPlan.result.diff[0].after, `第一段 ${suffix}`, "insert plan 应解释 anchor after");
|
||||
|
||||
const blockedPlan = await callMnoteTool(context.request, {
|
||||
toolName: "mnote.doc.plan_update",
|
||||
workspaceId: target.workspaceId,
|
||||
documentId: target.documentId,
|
||||
actorId,
|
||||
sessionId: `sess_plan_blocked_${suffix}`,
|
||||
runId: `run_plan_blocked_${suffix}`,
|
||||
toolCallId: `call_plan_blocked_${suffix}`,
|
||||
traceId: `trace_plan_blocked_${suffix}`,
|
||||
idempotencyKey: `idem_plan_blocked_${suffix}`,
|
||||
dryRun: true,
|
||||
capabilityScope: ["block.write"],
|
||||
args: {
|
||||
command: "block_move_after",
|
||||
blockId: "p_3",
|
||||
anchorBlockId: "p_3",
|
||||
},
|
||||
});
|
||||
assert.equal(blockedPlan.result.blocked, true, "不支持 move 场景应返回 blocked=true");
|
||||
|
||||
const dryRunAfterAggregate = await fetchPageAggregate(
|
||||
context.request,
|
||||
target.workspaceId,
|
||||
target.documentId,
|
||||
);
|
||||
const dryRunAfter = await fetchBlocks(context.request, target, suffix, "after_dry_run", actorId);
|
||||
assert.equal(dryRunAfter.revision, snapshot.revision, "dry-run 后 revision 不应变化");
|
||||
assert.deepEqual(blockTexts(dryRunAfter.blocks), dryRunBaselineTexts, "dry-run 后正文不应变化");
|
||||
assert.equal(
|
||||
dryRunAfterAggregate.body.conflictDetectionKey,
|
||||
dryRunBaselineAggregate.body.conflictDetectionKey,
|
||||
"dry-run 后 Page Aggregate conflictDetectionKey 不应变化",
|
||||
);
|
||||
evidence.steps.push({
|
||||
name: "plan_update.dry_run",
|
||||
replaceDiff: replacePlan.result.diff[0],
|
||||
insertDiff: insertPlan.result.diff[0],
|
||||
blocked: blockedPlan.result.blocked,
|
||||
revisionAfterDryRun: dryRunAfter.revision,
|
||||
});
|
||||
|
||||
const replacedText = `第二段已替换 ${suffix}`;
|
||||
const replace = await callMnoteTool(context.request, {
|
||||
@@ -192,8 +526,25 @@ async function main() {
|
||||
});
|
||||
assert.equal(replace.result.commandName, "page.body.save", "block.replace 必须走 page.body.save");
|
||||
snapshot = await fetchBlocks(context.request, target, suffix, "after_replace", actorId);
|
||||
const afterReplaceAggregate = await fetchPageAggregate(context.request, target.workspaceId, target.documentId);
|
||||
assert.equal(blockById(snapshot.blocks, "p_2").text, replacedText, "替换后 doc.fetch 应回读新文本");
|
||||
evidence.steps.push({ name: "block.replace", changedBlocks: replace.result.changedBlocks });
|
||||
assert.equal(
|
||||
aggregateBlockById(afterReplaceAggregate, "p_2").text,
|
||||
replacedText,
|
||||
"替换后 Page Aggregate 必须回读新文本",
|
||||
);
|
||||
assert.equal(blockById(snapshot.blocks, "p_1").text, `第一段 ${suffix}`, "替换后相邻 p_1 不应变化");
|
||||
assert.equal(blockById(snapshot.blocks, "p_3").text, `第三段 ${suffix}`, "替换后相邻 p_3 不应变化");
|
||||
assert(blockById(snapshot.blocks, "p_2"), "替换后目标 block id 必须保持 p_2");
|
||||
evidence.steps.push({
|
||||
name: "block.replace",
|
||||
changedBlocks: replace.result.changedBlocks,
|
||||
targetBlockStillExists: Boolean(blockById(snapshot.blocks, "p_2")),
|
||||
aggregateRevision: afterReplaceAggregate.body.revision,
|
||||
aggregateConflictDetectionKey: afterReplaceAggregate.body.conflictDetectionKey,
|
||||
aggregateText: aggregateBlockById(afterReplaceAggregate, "p_2").text,
|
||||
adjacentTexts: [blockById(snapshot.blocks, "p_1").text, blockById(snapshot.blocks, "p_3").text],
|
||||
});
|
||||
|
||||
const insertedText = `插入段 ${suffix}`;
|
||||
const anchorAfterReplace = blockById(snapshot.blocks, "p_1");
|
||||
@@ -211,7 +562,11 @@ async function main() {
|
||||
capabilityScope: ["block.write"],
|
||||
args: {
|
||||
anchorBlockId: "p_1",
|
||||
content: insertedText,
|
||||
content: {
|
||||
type: "todo",
|
||||
props: { checked: false },
|
||||
content: [{ type: "text", text: insertedText }],
|
||||
},
|
||||
revision: snapshot.revision,
|
||||
conflictDetectionKey: snapshot.conflictDetectionKey,
|
||||
anchorRevisionRef: anchorAfterReplace.revisionRef,
|
||||
@@ -221,7 +576,73 @@ async function main() {
|
||||
assert(insertedBlockId.startsWith("ai_block_"), "插入块 id 必须由 Rust/mnote 侧生成");
|
||||
snapshot = await fetchBlocks(context.request, target, suffix, "after_insert", actorId);
|
||||
assert.equal(blockById(snapshot.blocks, insertedBlockId).text, insertedText, "插入后 doc.fetch 应回读新块");
|
||||
evidence.steps.push({ name: "block.insert_after", insertedBlockId });
|
||||
const orderAfterInsert = snapshot.blocks.map((block) => block.blockId);
|
||||
assert.equal(
|
||||
orderAfterInsert.indexOf(insertedBlockId),
|
||||
orderAfterInsert.indexOf("p_1") + 1,
|
||||
"插入块必须紧跟 p_1",
|
||||
);
|
||||
const insertedBlockFetch = await callMnoteTool(context.request, {
|
||||
toolName: "mnote.block.fetch",
|
||||
workspaceId: target.workspaceId,
|
||||
documentId: target.documentId,
|
||||
actorId,
|
||||
sessionId: `sess_insert_fetch_${suffix}`,
|
||||
runId: `run_insert_fetch_${suffix}`,
|
||||
toolCallId: `call_insert_fetch_${suffix}`,
|
||||
traceId: `trace_insert_fetch_${suffix}`,
|
||||
capabilityScope: ["block.read"],
|
||||
args: {
|
||||
blockId: insertedBlockId,
|
||||
contextBefore: 1,
|
||||
contextAfter: 1,
|
||||
},
|
||||
});
|
||||
assert.equal(insertedBlockFetch.result.block.blockId, insertedBlockId, "block.fetch 应能读取新插入块");
|
||||
assert.equal(insertedBlockFetch.result.block.type, "todo", "insert_after 应能插入 todo 块");
|
||||
const replayInsertText = `不应重复插入 ${suffix}`;
|
||||
const insertReplay = await callMnoteTool(context.request, {
|
||||
toolName: "mnote.block.insert_after",
|
||||
workspaceId: target.workspaceId,
|
||||
documentId: target.documentId,
|
||||
actorId,
|
||||
sessionId: `sess_insert_replay_${suffix}`,
|
||||
runId: `run_insert_replay_${suffix}`,
|
||||
toolCallId: `call_insert_replay_${suffix}`,
|
||||
traceId: `trace_insert_replay_${suffix}`,
|
||||
idempotencyKey: `idem_insert_${suffix}`,
|
||||
dryRun: false,
|
||||
capabilityScope: ["block.write"],
|
||||
args: {
|
||||
anchorBlockId: "p_1",
|
||||
content: replayInsertText,
|
||||
revision: snapshot.revision,
|
||||
conflictDetectionKey: snapshot.conflictDetectionKey,
|
||||
anchorRevisionRef: blockById(snapshot.blocks, "p_1").revisionRef,
|
||||
},
|
||||
});
|
||||
assert.equal(
|
||||
insertReplay.audit.commandId,
|
||||
insert.audit.commandId,
|
||||
"重复 insert_after idempotencyKey 应返回缓存 commandId",
|
||||
);
|
||||
const afterInsertReplay = await fetchBlocks(context.request, target, suffix, "after_insert_replay", actorId);
|
||||
assert.equal(
|
||||
afterInsertReplay.blocks.filter((block) => block.blockId === insertedBlockId).length,
|
||||
1,
|
||||
"重复 insert_after idempotencyKey 不应产生第二个相同插入块",
|
||||
);
|
||||
assert(!afterInsertReplay.blocks.some((block) => block.text === replayInsertText), "重复 insert_after 不应写入新内容");
|
||||
assert.equal(afterInsertReplay.revision, snapshot.revision, "重复 insert_after 后 revision 不应再次递增");
|
||||
snapshot = afterInsertReplay;
|
||||
evidence.steps.push({
|
||||
name: "block.insert_after",
|
||||
insertedBlockId,
|
||||
insertedBlockType: insertedBlockFetch.result.block.type,
|
||||
orderAfterInsert,
|
||||
replayCommandId: insertReplay.audit.commandId,
|
||||
revisionAfterReplay: snapshot.revision,
|
||||
});
|
||||
|
||||
const moveBlock = blockById(snapshot.blocks, "p_3");
|
||||
const moveAnchor = blockById(snapshot.blocks, "p_1");
|
||||
@@ -269,14 +690,31 @@ async function main() {
|
||||
snapshot = await fetchBlocks(context.request, target, suffix, "after_move", actorId);
|
||||
const order = snapshot.blocks.map((block) => block.blockId);
|
||||
assert(order.indexOf("p_3") === order.indexOf("p_1") + 1, "移动后 p_3 必须紧跟 p_1");
|
||||
evidence.steps.push({ name: "block.move_after", order, texts: blockTexts(snapshot.blocks) });
|
||||
assert(blockById(snapshot.blocks, "p_3"), "移动后 moving block id 必须保持 p_3");
|
||||
evidence.steps.push({
|
||||
name: "block.move_after",
|
||||
dryRunBlocked: moveDryRun.result.blocked,
|
||||
dryRunDiff: moveDryRun.result.diff[0],
|
||||
order,
|
||||
texts: blockTexts(snapshot.blocks),
|
||||
movingBlockStillExists: Boolean(blockById(snapshot.blocks, "p_3")),
|
||||
});
|
||||
|
||||
await openDocument(page, target.workspaceId, target.documentId);
|
||||
await waitForVisibleTexts(page, [replacedText, insertedText, `第三段 ${suffix}`]);
|
||||
await page.reload({ waitUntil: "domcontentloaded" });
|
||||
await waitForVisibleTexts(page, [replacedText, insertedText, `第三段 ${suffix}`]);
|
||||
const afterRefreshAggregate = await fetchPageAggregate(context.request, target.workspaceId, target.documentId);
|
||||
assert.deepEqual(
|
||||
aggregateBlocks(afterRefreshAggregate).map((block) => block.blockId),
|
||||
snapshot.blocks.map((block) => block.blockId),
|
||||
"页面刷新后 Page Aggregate block ids 必须保持稳定",
|
||||
);
|
||||
const screenshotPath = path.join(OUT_DIR, `${suffix}-page.png`);
|
||||
await page.screenshot({ path: screenshotPath, fullPage: true });
|
||||
evidence.screenshot = screenshotPath;
|
||||
evidence.visibleTextCheck = [replacedText, insertedText, `第三段 ${suffix}`];
|
||||
evidence.afterRefreshBlockIds = aggregateBlocks(afterRefreshAggregate).map((block) => block.blockId);
|
||||
evidence.finalTexts = blockTexts(snapshot.blocks);
|
||||
evidence.ok = true;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user