diff --git a/doc/prd/workbench.md b/doc/prd/workbench.md index 1394eff3..2c79799d 100644 --- a/doc/prd/workbench.md +++ b/doc/prd/workbench.md @@ -476,7 +476,7 @@ - `WB-APPLY-002-AC2` 新建 `docx` 后,目标文件立即存在,assistant turn 展示本轮“修改的文件”入口。 - `WB-APPLY-002-AC3` 创建或修改普通文本文件后,目标文件立即更新并生成修改记录;运行期间不展示修改入口,收到 `run_completed / run_failed / run_cancelled` 后 assistant turn 才展示本轮“修改的文件”,右侧可查看由真实写入前后内容计算的文本 diff。`answer_completed` 不视为 run 已结束。 - `WB-APPLY-002-AC4` 普通文本 `edit` 的每个 `oldText` 必须唯一且互不重叠;`replaceAll` 替换一个 `oldText` 的全部非重叠精确匹配;失败时文件保持原样。 - - `WB-APPLY-002-AC5` 同一次 AI Run 对同一个 pptx 的多次逐页编辑只生成一条修改记录,加减数量按变化页计算。 + - `WB-APPLY-002-AC5` 同一次 AI Run 对同一个 pptx 的多次编辑只生成一条修改记录;PPT Diff 固定比较本 Run 第一次接触文件时的状态和 Run 完成后的最终状态,工具调用只用于识别页面、元素和真实移动,不直接累加操作次数。 - `WB-APPLY-002-AC6` 创建或修改 `xlsx` 后,正式路径立即包含最终净工作簿并生成一条修改记录;同一次 AI Run 对同一路径连续编辑时保持首次写入前基线,不重复生成记录。 - `WB-APPLY-002-AC7` `docx_edit`、`ppt_edit` 和 `excel_edit` 统一使用 `sourcePath + actions[]`;目标不存在时由编辑 Action 直接创建,不再提供独立的文档或演示文稿创建 Action。 - `WB-APPLY-002-AC8` `docx_edit.insert_blocks` 的 `position` 必填,`anchorBlockId` 可选;无锚点时 `before` 表示文档开头,`after` 表示文档末尾。 @@ -501,7 +501,7 @@ - `WB-APPLY-003-AC3` 回退成功后,右侧修改的文件工作区应刷新记录状态;普通文本历史 diff 继续保留并标记“已回退”或“已删除”,避免丢失本次修改记录。 - `WB-APPLY-003-AC4` 终端通过 Shell、Python、Node 或其他程序造成的文件变化均由 Run 结束扫描捕获;终端修改的文本可生成普通行级 Diff,终端直接修改的 Docx 等二进制文件只提供修改前/修改后版本,不伪造语义审阅标记。 - `WB-APPLY-003-AC5` Docx 工具修改保存 before、committed 和 Review Docx;Review Docx 继续由现有 Docx Renderer 渲染并保留段落、表格、图片、字体和页面结构。 - - `WB-APPLY-003-AC6` PPT 修改记录在审核模式提供页面级差异内容;Excel 专用工具修改 `xlsx` 时在审核模式提供单元格边框标记,并可切换到预览模式。终端产生的二进制文件变化只提供修改前/修改后版本,不显示模式切换;终端产生的 UTF-8 文本变化按普通文本规则处理。 + - `WB-APPLY-003-AC6` PPT 修改记录使用 Diff Manifest v4,在审核模式提供固定的修改前后页面快照;旧 v3 记录明确提示格式过期,正式 PPT 不受影响。Excel 专用工具修改 `xlsx` 时在审核模式提供单元格边框标记,并可切换到预览模式。终端产生的二进制文件变化只提供修改前/修改后版本,不显示模式切换;终端产生的 UTF-8 文本变化按普通文本规则处理。 - `WB-APPLY-003-AC7` 图片生成记录直接预览最终图片并提供回退,不显示不存在的图片 Diff;更新图片回退后恢复首次生成前的文件,新建图片回退后删除目标文件。 - `WB-APPLY-003-AC8` Docx Review 只展示本次 Run 初始文档到最终净稿的净变化;同一原始 block 连续修改只显示一次原始红色版本和一次最终绿色版本,中间稿不得出现,改回原内容不产生修改记录;本轮新增 block 只显示最终仍存在的绿色版本,新增后删除不产生净变化。 @@ -542,13 +542,18 @@ #### WB-PPT-002 PPT 单页左右审核模式 -- 系统行为:用户打开 pptx 修改记录后,主区域左侧显示当前页修改前画面,右侧显示同一页修改后画面;最右侧显示缩略图。点击缩略图或滚动时左右同步切页。 +- 系统行为:用户打开 pptx 修改记录后,主区域左侧显示当前页修改前画面,右侧显示同一页修改后画面;最右侧显示缩略图。点击缩略图或滚动时左右同步切页。审核内容严格来自同一 Run 首个 baseline 与最终 PPT 的净变化;SVG 只用于展示,不决定变化语义。 - 验收: - `WB-PPT-002-AC1` 主区域按左侧修改前、右侧修改后一次各显示一页。 - `WB-PPT-002-AC2` 缩略图支持只看变化页和查看全部页两种图标模式,默认只看变化页。 - - `WB-PPT-002-AC3` 变化页通过边框和新增、删除、移动图标标记,不显示文字差异说明。 + - `WB-PPT-002-AC3` 新增、修改、移动页使用绿色标记,整页删除使用红色标记,不显示“修改几页”等文字差异说明;已有页面只删除内部元素时,该页仍按修改显示绿色。 - `WB-PPT-002-AC4` 新增页左侧为空;删除页右侧为空。 - `WB-PPT-002-AC5` 点击缩略图或滚动后,左右主区域与当前缩略图保持同步。 + - `WB-PPT-002-AC6` `+N` 统计最终保留的新增、修改和移动单位,`-N` 统计最终保留的删除单位。新增整页为 `+1`、删除整页为 `-1`、移动整页为 `+1`,整页增删不重复统计内部元素。 + - `WB-PPT-002-AC7` 已有页面内新增元素为 `+1`、删除元素为 `-1`、修改或移动元素为 `+1`;同一元素同时修改内容、样式、位置、大小或层级只计一次。页面移动并修改两个元素时为 `+3`。 + - `WB-PPT-002-AC8` 插入或删除页面造成的自然页码变化不算移动;元素增删或置顶造成其他元素层级编号自然变化时,也不得把其他元素误算为修改。 + - `WB-PPT-002-AC9` A→B→A、移动后移回、新增后删除等零净变化不生成记录;原元素修改后删除只计 `-1`,新页面继续修改仍只计 `+1`。 + - `WB-PPT-002-AC10` 审核模式使用固定 Diff 快照,预览模式使用干净的正式 PPT;正式文件不得包含 Diff 标记,回退按修改前 bytes 恢复。 ### 8.10 Excel 读取、编辑与审核模式 diff --git a/doc/test-plan/ai-test.md b/doc/test-plan/ai-test.md index 44c7efd8..e077a70a 100644 --- a/doc/test-plan/ai-test.md +++ b/doc/test-plan/ai-test.md @@ -587,6 +587,15 @@ AI unit / 辅助测试目录: - Oracle:每次 `ppt_edit` 成功后正式文件立即存在、可解析且页面内容正确;Run 收尾后同一文件只生成一条修改记录和页面级 diff。 - Not:不验证 human 的工具选择或页面审美。 +##### `ppt-diff-slide-and-element-net-semantics` + +- 测试类型:`backend / tool` +- What:真实 PPT runtime 按本 Run 首个 baseline 和最终文件生成 Diff Manifest v4,覆盖页面和元素的新增、删除、修改、移动、恢复与组合计数。 +- Why:工具调用次数、插入删除造成的页码偏移和置顶造成的层级编号顺移都可能制造假 Diff,必须直接解析真实 artifact 验证净变化。 +- How:对三个真实 PPT 连续执行页面 A→B→C、A→B→A、修改后删除、新增后继续修改、新增后删除、中间页删除、主动移动及移动后恢复;再分别改变元素内容、样式、位置、大小、层级,覆盖同元素多种修改、公式 replacement lineage、元素新增后修改、修改后删除、新增后删除、修改后恢复、备注修改后恢复,最后提交 Run 并读取 Manifest 和正式 PPT。 +- Oracle:页面组合得到准确 `+3/-2`,元素组合得到准确 `+6/-1`,零净变化文件不生成记录;中间页增删不把后续页标记为移动,置顶只计算真实移动元素;中间稿和临时对象不进入 before/after;正式 PPT 可解析且无 Diff 污染,修改记录的 `insertCount/deleteCount/changeCount` 分别等于 v4 的 positive、negative 和两者之和。 +- Not:不验证 Workbench 布局、按钮、颜色 CSS 或模型是否会自主选择操作;这些由 Workbench E2E 和 human/live case 承担。 + ##### `ppt-professional-element-edit` - 测试类型:`backend / tool` diff --git a/doc/test-plan/workbench-test.md b/doc/test-plan/workbench-test.md index 503ec7f1..89108525 100644 --- a/doc/test-plan/workbench-test.md +++ b/doc/test-plan/workbench-test.md @@ -23,7 +23,7 @@ - `web_search` 与 `web_crawl` 需要分开覆盖:搜索 case 只验证候选网页返回;读取 case 必须验证 AI 继续读取网页正文后再回答。 - Workbench 附件链路当前支持图片、`doc`、`docx`、`pptx`、`pdf`、`md`、`json`、`xlsx`、`xlsm`、`xltx`、`xltm`,统一保存到 `/.ncurator/runtime/assets//`;`assetBucketId` 为输入草稿 UUID,发送给 AI 时只传附件名称与路径,不再做发送前识图或文件解析。 - `WB-EXCEL-001-AC1`、`WB-EXCEL-001-AC2`、`WB-EXCEL-001-AC3`、`WB-EXCEL-001-AC4`、`WB-EXCEL-001-AC6`、`WB-EXCEL-001-AC7` 的工具输入输出、完整编辑操作、原子失败、冲突、只读格式、默认 Sheet 和 Sheet 严格校验由 AI tool 回归承担;`WB-EXCEL-002` 的完整 XLSX diff 语义也由 AI tool 解析 diff 文件校验。Workbench 的 Excel apply case 负责真实模型选用工具、正式文件、修改入口、只读单网格预览和回退闭环,不重复枚举底层操作。 -- docx、ppt、pdf 也按同一分层:格式专用工具的读写、边界、格式保真和 diff artifact 语义放在 AI tool;Workbench e2e 只验证真实用户流程、预览入口、差异面板交互和回退。PDF 当前是只读工具,没有 Workbench apply diff 语义。 +- docx、ppt、pdf 也按同一分层:格式专用工具的读写、边界、格式保真和 diff artifact 语义放在 AI tool;其中 PPT tool 必须完整覆盖 baseline 到最终状态的页面/元素净变化、自然位移、零净变化、lineage、v4 schema 和准确计数。Workbench e2e 只验证真实用户流程、现有预览入口、红绿页面标记、`+N/-N`、差异面板交互和回退,不重复枚举底层元素组合。PDF 当前是只读工具,没有 Workbench apply diff 语义。 ## 3. Suite 设计 @@ -402,19 +402,19 @@ tests/case/workbench/ #### `ppt-create-diff-and-revert` - 测试类型:`e2e / live` -- What:真实 AI 创建两页 PPT 后,用户可打开页面级左右 diff、切换缩略图模式并回撤文件。 +- What:真实 AI 创建两页 PPT 后,用户可打开页面级左右 diff、看到准确 `+2/-0`、切换审核/预览并回撤文件。 - Why:新建 PPT 必须同时验证文件交付、审核模式和回退闭环。 -- How:通过真实 Workbench UI 请求 AI 创建两页 PPT,打开修改记录并操作审核模式和回退。 -- Oracle:真实 PPT 创建、原子写入和 run 级修改记录对齐 `WB-PPT-001`、`WB-APPLY-002`;pptx 可解析,左右差异区和缩略图可见,全部页与变化页切换有效;回撤后新建文件被删除,对齐 `WB-APPLY-003`。 +- How:通过真实 Workbench UI 请求 AI 创建两页 PPT,打开修改记录,操作审核/预览、缩放、拖拽和回退。 +- Oracle:真实 PPT 创建、原子写入和 run 级修改记录对齐 `WB-PPT-001`、`WB-APPLY-002`;pptx 可解析,审核模式显示两页和 `+2/-0`,预览模式显示正式 PPT,现有缩放拖拽可用;回撤后新建文件被删除,对齐 `WB-APPLY-003`。 - Not:不逐字断言最终回答,不对页面审美打分。 #### `ppt-update-diff-interactions` - 测试类型:`e2e` -- What:已有 PPT 更新后的左右差异、变化标记、缩略图筛选、缩放和拖动画布。 +- What:已有 PPT 更新后的左右差异、绿色新增/修改/移动、红色删除、准确 `+N/-N`、缩略图筛选、审核/预览、缩放和拖动画布。 - Why:创建态与更新态的差异语义不同,不能只用新建 PPT case 代替。 -- How:修改真实 PPT 的页面内容和顺序,打开修改记录并操作审核模式。 -- Oracle:修改前后画面、变化页标记、全部页切换、同步切页、缩放和拖动均可用,对齐 `WB-PPT-002`。 +- How:让真实 AI 对已有四页 PPT 主动移动一页、修改一页、删除中间页、新增一页,并要求末页完全不变;打开修改记录并操作审核/预览、筛选、同步切页、缩放、拖动和回退。 +- Oracle:正式 PPT 内容和顺序正确;审核显示准确 `+3/-1`、三个绿色页面标记和一个红色删除标记;删除中间页不会把未改末页标绿,变化筛选为四页、全部模式为五个 before/after 对齐项;审核/预览切换、同步缩放拖动不变,回退后 bytes 与 baseline 完全一致,对齐 `WB-PPT-002`、`WB-APPLY-003`。 - Not:不评价页面设计质量。 #### `excel-update-diff-and-revert` diff --git a/src/main/services/ai/human/tools/ppt/diff.ts b/src/main/services/ai/human/tools/ppt/diff.ts new file mode 100644 index 00000000..3358e24e --- /dev/null +++ b/src/main/services/ai/human/tools/ppt/diff.ts @@ -0,0 +1,273 @@ +import type { + PptDiffElementChange, + PptDiffElementSnapshot, + PptDiffFrame, + PptDiffManifest, + PptDiffSlide, + PptDiffSlideSnapshot +} from './types' + +type Match = { + before: PptDiffElementSnapshot + after: PptDiffElementSnapshot +} + +const compareIds = (left: { elementId: string }, right: { elementId: string }): number => + left.elementId.localeCompare(right.elementId) + +const followLineage = (elementId: string, lineage: ReadonlyMap): string => { + const seen = new Set() + let current = elementId + while (!seen.has(current)) { + seen.add(current) + const next = lineage.get(current) + if (!next) break + current = next + } + return current +} + +export const diffPptElements = (input: { + before: readonly PptDiffElementSnapshot[] + after: readonly PptDiffElementSnapshot[] + lineage?: ReadonlyMap + movedCandidates?: ReadonlySet +}): PptDiffElementChange[] => { + const lineage = input.lineage || new Map() + const unmatchedBefore = new Map(input.before.map((element) => [element.elementId, element])) + const unmatchedAfter = new Map(input.after.map((element) => [element.elementId, element])) + const matches: Match[] = [] + + for (const before of input.before) { + const afterId = unmatchedAfter.has(before.elementId) + ? before.elementId + : followLineage(before.elementId, lineage) + const after = unmatchedAfter.get(afterId) + if (!after) continue + matches.push({ before, after }) + unmatchedBefore.delete(before.elementId) + unmatchedAfter.delete(after.elementId) + } + + const beforeByFingerprint = new Map() + for (const element of unmatchedBefore.values()) { + const entries = beforeByFingerprint.get(element.fingerprint) || [] + entries.push(element) + beforeByFingerprint.set(element.fingerprint, entries) + } + const afterByFingerprint = new Map() + for (const element of unmatchedAfter.values()) { + const entries = afterByFingerprint.get(element.fingerprint) || [] + entries.push(element) + afterByFingerprint.set(element.fingerprint, entries) + } + for (const [fingerprint, beforeElements] of beforeByFingerprint) { + const afterElements = afterByFingerprint.get(fingerprint) + if (!afterElements) continue + const sortedBefore = [...beforeElements].sort(compareIds) + const sortedAfter = [...afterElements].sort(compareIds) + const pairCount = Math.min(sortedBefore.length, sortedAfter.length) + for (let index = 0; index < pairCount; index += 1) { + const before = sortedBefore[index]! + const after = sortedAfter[index]! + matches.push({ before, after }) + unmatchedBefore.delete(before.elementId) + unmatchedAfter.delete(after.elementId) + } + } + + const matchedBeforeIds = new Set(matches.map(({ before }) => before.elementId)) + const beforeOrder = input.before + .filter((element) => matchedBeforeIds.has(element.elementId)) + .sort((left, right) => left.zIndex - right.zIndex) + .map((element) => element.elementId) + const beforeIdByAfterId = new Map( + matches.map(({ before, after }) => [after.elementId, before.elementId]) + ) + const afterOrder = input.after + .filter((element) => beforeIdByAfterId.has(element.elementId)) + .sort((left, right) => left.zIndex - right.zIndex) + .map((element) => beforeIdByAfterId.get(element.elementId)!) + const movedCandidateBeforeIds = new Set( + matches.flatMap(({ before, after }) => + input.movedCandidates?.has(before.elementId) || input.movedCandidates?.has(after.elementId) + ? [before.elementId] + : [] + ) + ) + const movedElementIds = resolveMovedIds({ + beforeIds: beforeOrder, + afterIds: afterOrder, + movedCandidates: movedCandidateBeforeIds + }) + const changes: PptDiffElementChange[] = matches.flatMap(({ before, after }) => + before.fingerprint === after.fingerprint && !movedElementIds.has(before.elementId) + ? [] + : [ + { + kind: 'modified' as const, + beforeElementId: before.elementId, + afterElementId: after.elementId + } + ] + ) + changes.push( + ...[...unmatchedBefore.values()].sort(compareIds).map((element) => ({ + kind: 'deleted' as const, + beforeElementId: element.elementId + })), + ...[...unmatchedAfter.values()].sort(compareIds).map((element) => ({ + kind: 'added' as const, + afterElementId: element.elementId + })) + ) + return changes +} + +type LcsScore = { length: number; protectedCount: number; ids: string[] } + +const betterScore = (left: LcsScore, right: LcsScore): LcsScore => { + if (left.length !== right.length) return left.length > right.length ? left : right + if (left.protectedCount !== right.protectedCount) { + return left.protectedCount > right.protectedCount ? left : right + } + return left.ids.join('\u0000') <= right.ids.join('\u0000') ? left : right +} + +const resolveMovedIds = (input: { + beforeIds: readonly string[] + afterIds: readonly string[] + movedCandidates?: ReadonlySet +}): Set => { + const afterSet = new Set(input.afterIds) + const beforeSet = new Set(input.beforeIds) + const before = input.beforeIds.filter((id) => afterSet.has(id)) + const after = input.afterIds.filter((id) => beforeSet.has(id)) + if ( + before.length === after.length && + before.every((slideId, index) => slideId === after[index]) + ) { + return new Set() + } + + const candidates = input.movedCandidates || new Set() + const table: LcsScore[][] = Array.from({ length: before.length + 1 }, () => + Array.from({ length: after.length + 1 }, () => ({ + length: 0, + protectedCount: 0, + ids: [] + })) + ) + for (let left = before.length - 1; left >= 0; left -= 1) { + for (let right = after.length - 1; right >= 0; right -= 1) { + if (before[left] === after[right]) { + const next = table[left + 1]![right + 1]! + const slideId = before[left]! + table[left]![right] = { + length: next.length + 1, + protectedCount: next.protectedCount + (candidates.has(slideId) ? 0 : 1), + ids: [slideId, ...next.ids] + } + } else { + table[left]![right] = betterScore(table[left + 1]![right]!, table[left]![right + 1]!) + } + } + } + const stationary = new Set(table[0]![0]!.ids) + return new Set(before.filter((slideId) => !stationary.has(slideId))) +} + +export const resolveMovedPptSlideIds = (input: { + beforeSlideIds: readonly string[] + afterSlideIds: readonly string[] + movedCandidates?: ReadonlySet +}): Set => + resolveMovedIds({ + beforeIds: input.beforeSlideIds, + afterIds: input.afterSlideIds, + movedCandidates: input.movedCandidates + }) + +export const buildPptDiffManifest = (input: { + beforeSlides: readonly PptDiffSlideSnapshot[] + afterSlides: readonly PptDiffSlideSnapshot[] + beforeFrames: ReadonlyMap + afterFrames: ReadonlyMap + operation: 'created' | 'updated' + movedCandidates?: ReadonlySet + elementLineage?: ReadonlyMap + movedElementCandidates?: ReadonlySet +}): PptDiffManifest => { + const beforeById = new Map(input.beforeSlides.map((slide) => [slide.slideId, slide])) + const afterById = new Map(input.afterSlides.map((slide) => [slide.slideId, slide])) + const movedSlideIds = resolveMovedPptSlideIds({ + beforeSlideIds: input.beforeSlides.map((slide) => slide.slideId), + afterSlideIds: input.afterSlides.map((slide) => slide.slideId), + movedCandidates: input.movedCandidates + }) + + const slides: PptDiffSlide[] = input.afterSlides.map((afterSlide) => { + const beforeSlide = beforeById.get(afterSlide.slideId) + const afterFrame = input.afterFrames.get(afterSlide.slideId) + if (!beforeSlide) { + return { + slideId: afterSlide.slideId, + changed: true, + changes: ['added'], + elementChanges: [], + positiveCount: 1, + negativeCount: 0, + after: afterFrame + } + } + + const beforeFrame = input.beforeFrames.get(afterSlide.slideId) + const elementChanges = diffPptElements({ + before: beforeSlide.elements, + after: afterSlide.elements, + lineage: input.elementLineage, + movedCandidates: input.movedElementCandidates + }) + const pageModified = beforeSlide.pageFingerprint !== afterSlide.pageFingerprint + const contentModified = pageModified || elementChanges.length > 0 + const moved = movedSlideIds.has(afterSlide.slideId) + const changes: PptDiffSlide['changes'] = [] + if (contentModified) changes.push('modified') + if (moved) changes.push('moved') + return { + slideId: afterSlide.slideId, + changed: changes.length > 0, + changes, + elementChanges, + positiveCount: + elementChanges.filter((change) => change.kind !== 'deleted').length + + (pageModified ? 1 : 0) + + (moved ? 1 : 0), + negativeCount: elementChanges.filter((change) => change.kind === 'deleted').length, + before: beforeFrame, + after: afterFrame + } + }) + + const deletedSlides = input.beforeSlides.filter((slide) => !afterById.has(slide.slideId)) + for (const beforeSlide of deletedSlides) { + slides.splice(Math.min(Math.max(0, beforeSlide.index - 1), slides.length), 0, { + slideId: beforeSlide.slideId, + changed: true, + changes: ['deleted'], + elementChanges: [], + positiveCount: 0, + negativeCount: 1, + before: input.beforeFrames.get(beforeSlide.slideId), + after: undefined + }) + } + + return { + version: 4, + operation: input.operation, + positiveCount: slides.reduce((sum, slide) => sum + slide.positiveCount, 0), + negativeCount: slides.reduce((sum, slide) => sum + slide.negativeCount, 0), + slides + } +} diff --git a/src/main/services/ai/human/tools/ppt/runtime.ts b/src/main/services/ai/human/tools/ppt/runtime.ts index d8ee9afc..cb03ee2e 100644 --- a/src/main/services/ai/human/tools/ppt/runtime.ts +++ b/src/main/services/ai/human/tools/ppt/runtime.ts @@ -21,8 +21,14 @@ import { findShapeById, getShapeBoundsResolved, getShapeDescription, + getShapeEffects, getShapeFillColor, getShapeChartSpec, + getShapeImageBrightness, + getShapeImageBytes, + getShapeImageContrast, + getShapeImageCrop, + getShapeImageOpacity, getShapeId, getShapeKind, getShapePreset, @@ -32,7 +38,12 @@ import { getShapeStrokeWidth, getShapeText, getShapeTextAnchor, + getShapeTextAutoFit, + getShapeTextMargins, + getShapeTextWrap, getShapeZIndex, + getSlideBackground, + getSlideBackgroundImageBytes, getSlideNotes, getSlideCharts, getSlidePartName, @@ -93,6 +104,7 @@ import { PPT_CANVAS_WIDTH } from './htmlCompiler' import { renderLatexToSvg } from './formula' +import { buildPptDiffManifest } from './diff' import { pptEditSchema, pptOutlineSchema, @@ -107,8 +119,7 @@ import type { PptCompiledElement, PptCompiledSlide, PptDiffFrame, - PptDiffManifest, - PptDiffSlide, + PptDiffSlideSnapshot, PptOutlineItem, PptReadbackElement, PptReadbackSlide, @@ -116,6 +127,8 @@ import type { PptToolRuntime } from './types' +export { buildPptDiffManifest } from './diff' + const DEFAULT_WIDTH = PPT_CANVAS_WIDTH const DEFAULT_HEIGHT = PPT_CANVAS_HEIGHT @@ -661,6 +674,63 @@ const readShapeDetails = (shape: SlideShapeData): JsonObject | undefined => { return preset ? { shape: preset } : undefined } +const readShapeType = (shape: SlideShapeData): string => { + const description = getShapeDescription(shape) || '' + if (description.startsWith('ppt-latex:')) return 'formula' + if (isTableShape(shape)) return 'table' + if (isChartShape(shape)) return 'chart' + return getShapeKind(shape) +} + +const canonicalize = (value: unknown): unknown => { + if (value === undefined) return null + if (value instanceof Uint8Array) return hashBytes(value) + if (Array.isArray(value)) return value.map(canonicalize) + if (value && typeof value === 'object') { + return Object.fromEntries( + Object.entries(value as Record) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, entry]) => [key, canonicalize(entry)]) + ) + } + if (typeof value === 'number' && !Number.isFinite(value)) return String(value) + return value +} + +const fingerprint = (value: unknown): string => hashBytes(JSON.stringify(canonicalize(value))) + +const createPptDiffSlideSnapshots = (presentation: PresentationData): PptDiffSlideSnapshot[] => + getSlides(presentation).map((slide, index) => ({ + slideId: slideIdOf(slide), + index: index + 1, + pageFingerprint: fingerprint({ + background: getSlideBackground(slide), + backgroundImage: getSlideBackgroundImageBytes(slide), + notes: getSlideNotes(slide) || '' + }), + elements: getSlideShapes(slide).map((shape) => ({ + elementId: elementIdOf(slide, shape), + fingerprint: fingerprint({ + type: readShapeType(shape), + text: getShapeText(shape), + description: getShapeDescription(shape), + bounds: boundsFromEmu(presentation, getShapeBoundsResolved(presentation, shape)), + style: readShapeStyle(shape), + details: readShapeDetails(shape), + effects: getShapeEffects(presentation, shape), + textMargins: getShapeTextMargins(shape), + textWrap: getShapeTextWrap(shape), + textAutoFit: getShapeTextAutoFit(shape), + image: getShapeImageBytes(shape), + imageCrop: getShapeImageCrop(shape), + imageOpacity: getShapeImageOpacity(shape), + imageBrightness: getShapeImageBrightness(shape), + imageContrast: getShapeImageContrast(shape) + }), + zIndex: getShapeZIndex(shape) + })) + })) + const slideReadback = ( presentation: PresentationData, slide: SlideData, @@ -673,16 +743,9 @@ const slideReadback = ( const latex = description.startsWith('ppt-latex:') ? description.slice('ppt-latex:'.length) : undefined - const type = latex - ? 'formula' - : isTableShape(shape) - ? 'table' - : isChartShape(shape) - ? 'chart' - : getShapeKind(shape) return { elementId: elementIdOf(slide, shape), - type, + type: readShapeType(shape), zIndex: getShapeZIndex(shape), bounds: boundsFromEmu(presentation, getShapeBoundsResolved(presentation, shape)), text: getShapeText(shape) || undefined, @@ -759,50 +822,6 @@ const renderPresentationSlides = async (input: { return output } -export const buildPptDiffManifest = (input: { - before: Map - after: Map - operation: 'created' | 'updated' - forceChangedSlideIds?: ReadonlySet -}): PptDiffManifest => { - const afterEntries: PptDiffSlide[] = [...input.after.entries()] - .sort((a, b) => a[1].index - b[1].index) - .map(([slideId, after]) => { - const before = input.before.get(slideId) - const changes: PptDiffSlide['changes'] = [] - if (!before) changes.push('added') - else { - if ( - before.hash !== after.hash || - (before.index === after.index && Boolean(input.forceChangedSlideIds?.has(slideId))) - ) { - changes.push('modified') - } - if (before.index !== after.index) changes.push('moved') - } - return { - slideId, - changed: changes.length > 0, - changes, - before: before ? { index: before.index, svg: before.svg } : undefined, - after: { index: after.index, svg: after.svg } - } - }) - const deleted = [...input.before.entries()] - .filter(([slideId]) => !input.after.has(slideId)) - .sort((a, b) => a[1].index - b[1].index) - for (const [slideId, before] of deleted) { - afterEntries.splice(Math.min(Math.max(0, before.index - 1), afterEntries.length), 0, { - slideId, - changed: true, - changes: ['deleted'], - before: { index: before.index, svg: before.svg }, - after: undefined - }) - } - return { version: 3, operation: input.operation, slides: afterEntries } -} - const readImageDimensions = (bytes: Uint8Array): { width: number; height: number } | null => { const buffer = Buffer.from(bytes) if (buffer.length >= 24 && buffer.subarray(1, 4).toString('ascii') === 'PNG') { @@ -888,6 +907,7 @@ const applyElementOperation = async (input: { document: PptRuntimeDocument slide: SlideData workspaceRootPath: string + onReplaceElement: (beforeElementId: string, afterElementId: string) => void }) => { const op = input.operation if (op.type === 'set_text') { @@ -1019,6 +1039,7 @@ const applyElementOperation = async (input: { bounds }) setShapeZIndex(shape, zIndex) + input.onReplaceElement(op.elementId, elementIdOf(input.slide, shape)) return } @@ -1158,7 +1179,9 @@ export const createPptToolRuntime = (input: PptToolRuntime['input']): PptToolRun lastPersistedBytes: beforeBytes ? new Uint8Array(beforeBytes) : undefined, presentation, dirty: false, - changedSlideIds: new Set(), + movedSlideIds: new Set(), + movedElementIds: new Set(), + elementLineage: new Map(), warnings: [] } documents.set(sourcePath, document) @@ -1313,10 +1336,13 @@ export const createPptToolRuntime = (input: PptToolRuntime['input']): PptToolRun const document = await loadDocument(args.sourcePath, create) const presentationSnapshot = await savePresentation(document.presentation) const dirtyBeforeAction = document.dirty - const changedSlideIdsBeforeAction = new Set(document.changedSlideIds) + const movedSlideIdsBeforeAction = new Set(document.movedSlideIds) + const movedElementIdsBeforeAction = new Set(document.movedElementIds) + const elementLineageBeforeAction = new Map(document.elementLineage) const warningsBeforeAction = [...document.warnings] try { const changedSlideIds: string[] = [] + const movedSlideIds = new Set() let outlineAnchorIndex: number | undefined for (const action of args.actions) { try { @@ -1363,6 +1389,7 @@ export const createPptToolRuntime = (input: PptToolRuntime['input']): PptToolRun action.anchorSlideId ) changedSlideIds.push(action.slideId) + movedSlideIds.add(action.slideId) outlineAnchorIndex = targetIndex } else if (action.type === 'duplicate_slide') { const source = findSlide(document.presentation, action.slideId) @@ -1407,7 +1434,18 @@ export const createPptToolRuntime = (input: PptToolRuntime['input']): PptToolRun } else if (action.type === 'edit_elements') { const slide = findSlide(document.presentation, action.slideId) for (const operation of action.operations) { - await applyElementOperation({ operation, document, slide, workspaceRootPath }) + if (operation.type === 'set_z_order') { + document.movedElementIds.add(operation.elementId) + } + await applyElementOperation({ + operation, + document, + slide, + workspaceRootPath, + onReplaceElement: (beforeElementId, afterElementId) => { + document.elementLineage.set(beforeElementId, afterElementId) + } + }) } changedSlideIds.push(action.slideId) } else if (action.type === 'set_notes') { @@ -1425,7 +1463,7 @@ export const createPptToolRuntime = (input: PptToolRuntime['input']): PptToolRun } const uniqueChanged = [...new Set(changedSlideIds)] if (uniqueChanged.length > 0) document.dirty = true - uniqueChanged.forEach((slideId) => document.changedSlideIds.add(slideId)) + movedSlideIds.forEach((slideId) => document.movedSlideIds.add(slideId)) const slides = getSlides(document.presentation) const readbackSlides = uniqueChanged.flatMap((slideId) => { const index = slides.findIndex((slide) => slideIdOf(slide) === slideId) @@ -1473,7 +1511,9 @@ export const createPptToolRuntime = (input: PptToolRuntime['input']): PptToolRun } catch (error) { document.presentation = await loadPresentation(presentationSnapshot) document.dirty = dirtyBeforeAction - document.changedSlideIds = changedSlideIdsBeforeAction + document.movedSlideIds = movedSlideIdsBeforeAction + document.movedElementIds = movedElementIdsBeforeAction + document.elementLineage = elementLineageBeforeAction document.warnings = warningsBeforeAction if (create) documents.delete(sourcePath) throw error @@ -1498,6 +1538,30 @@ export const createPptToolRuntime = (input: PptToolRuntime['input']): PptToolRun : null const beforeSlides = beforePresentation ? getSlides(beforePresentation) : [] const afterSlides = getSlides(afterPresentation) + const beforeSnapshots = beforePresentation + ? createPptDiffSlideSnapshots(beforePresentation) + : [] + const afterSnapshots = createPptDiffSlideSnapshots(afterPresentation) + const operation = beforeExists ? ('updated' as const) : ('created' as const) + const netManifest = buildPptDiffManifest({ + beforeSlides: beforeSnapshots, + afterSlides: afterSnapshots, + beforeFrames: new Map(), + afterFrames: new Map(), + operation, + movedCandidates: document.movedSlideIds, + elementLineage: document.elementLineage, + movedElementCandidates: document.movedElementIds + }) + if (netManifest.positiveCount === 0 && netManifest.negativeCount === 0) { + document.beforeBytes = new Uint8Array(afterBytes) + document.lastPersistedBytes = new Uint8Array(afterBytes) + document.dirty = false + document.movedSlideIds.clear() + document.movedElementIds.clear() + document.elementLineage.clear() + continue + } const beforeFrames = beforePresentation ? await renderPresentationSlides({ presentation: beforePresentation, @@ -1513,12 +1577,15 @@ export const createPptToolRuntime = (input: PptToolRuntime['input']): PptToolRun prefix: 'after' }) const manifest = buildPptDiffManifest({ - before: beforeFrames, - after: afterFrames, - operation: beforeExists ? 'updated' : 'created', - forceChangedSlideIds: document.changedSlideIds + beforeSlides: beforeSnapshots, + afterSlides: afterSnapshots, + beforeFrames, + afterFrames, + operation, + movedCandidates: document.movedSlideIds, + elementLineage: document.elementLineage, + movedElementCandidates: document.movedElementIds }) - const changedSlides = manifest.slides.filter((slide) => slide.changed) await fs.writeFile(dirs.manifest, JSON.stringify(manifest, null, 2), 'utf8') let beforeSnapshotPath: string | undefined let beforeChecksum: string | undefined @@ -1544,10 +1611,10 @@ export const createPptToolRuntime = (input: PptToolRuntime['input']): PptToolRun capability: 'ppt', path: document.sourcePath, fileName: document.sourceName, - operation: beforeExists ? 'updated' : 'created', - changeCount: changedSlides.length, - insertCount: changedSlides.filter((slide) => Boolean(slide.after)).length, - deleteCount: changedSlides.filter((slide) => Boolean(slide.before)).length, + operation, + changeCount: manifest.positiveCount + manifest.negativeCount, + insertCount: manifest.positiveCount, + deleteCount: manifest.negativeCount, beforeSnapshotPath, beforeChecksum, afterChecksum: hashBytes(persistedBytes), @@ -1559,7 +1626,9 @@ export const createPptToolRuntime = (input: PptToolRuntime['input']): PptToolRun document.beforeBytes = new Uint8Array(afterBytes) document.lastPersistedBytes = new Uint8Array(afterBytes) document.dirty = false - document.changedSlideIds.clear() + document.movedSlideIds.clear() + document.movedElementIds.clear() + document.elementLineage.clear() records.push(record) } return records diff --git a/src/main/services/ai/human/tools/ppt/types.ts b/src/main/services/ai/human/tools/ppt/types.ts index 8280a382..a9c1735b 100644 --- a/src/main/services/ai/human/tools/ppt/types.ts +++ b/src/main/services/ai/human/tools/ppt/types.ts @@ -66,7 +66,9 @@ export type PptRuntimeDocument = { lastPersistedBytes?: Uint8Array presentation: PresentationData dirty: boolean - changedSlideIds: Set + movedSlideIds: Set + movedElementIds: Set + elementLineage: Map warnings: string[] } @@ -101,17 +103,41 @@ export type PptDiffFrame = { svg: string } +export type PptDiffElementSnapshot = { + elementId: string + fingerprint: string + zIndex: number +} + +export type PptDiffSlideSnapshot = { + slideId: string + index: number + pageFingerprint: string + elements: PptDiffElementSnapshot[] +} + +export type PptDiffElementChange = { + kind: 'added' | 'deleted' | 'modified' + beforeElementId?: string + afterElementId?: string +} + export type PptDiffSlide = { slideId: string changed: boolean changes: Array<'added' | 'deleted' | 'modified' | 'moved'> + elementChanges: PptDiffElementChange[] + positiveCount: number + negativeCount: number before?: PptDiffFrame after?: PptDiffFrame } export type PptDiffManifest = { - version: 3 + version: 4 operation: 'created' | 'updated' + positiveCount: number + negativeCount: number slides: PptDiffSlide[] } diff --git a/src/renderer/src/components/workbench/PptDiffViewer.tsx b/src/renderer/src/components/workbench/PptDiffViewer.tsx index 6c340717..6969aeb4 100644 --- a/src/renderer/src/components/workbench/PptDiffViewer.tsx +++ b/src/renderer/src/components/workbench/PptDiffViewer.tsx @@ -1,5 +1,6 @@ import React, { useEffect, useMemo, useState } from 'react' import type { AiFileChangeRecord } from '../../../../shared/types' +import { assertSupportedPptDiffManifest } from '../../../../shared/pptDiffManifest' import { workbenchSelectors } from '../../testing/selectors' import PptSlideDeckViewer, { type PptViewerSlide } from '../fileRenders/PptSlideDeckViewer' @@ -8,10 +9,23 @@ type DiffSlide = { slideId: string changed: boolean changes: Array<'added' | 'deleted' | 'modified' | 'moved'> + elementChanges: Array<{ + kind: 'added' | 'deleted' | 'modified' + beforeElementId?: string + afterElementId?: string + }> + positiveCount: number + negativeCount: number before?: DiffFrame after?: DiffFrame } -type DiffManifest = { version: 3; operation: 'created' | 'updated'; slides: DiffSlide[] } +type DiffManifest = { + version: 4 + operation: 'created' | 'updated' + positiveCount: number + negativeCount: number + slides: DiffSlide[] +} const loadLocalUrl = async (filePath: string): Promise => { const bytes = await window.api.readFile(filePath) @@ -51,8 +65,7 @@ const PptDiffViewer: React.FC<{ record: AiFileChangeRecord }> = ({ record }) => .then((bytes) => JSON.parse(new TextDecoder().decode(bytes)) as DiffManifest) .then((next) => { if (cancelled) return - if (next.version !== 3 || !Array.isArray(next.slides)) - throw new Error('PPT 审核内容格式已过期,请重新生成修改记录') + assertSupportedPptDiffManifest(next) setManifest(next) setChangedOnly(next.operation === 'updated') }) diff --git a/src/shared/pptDiffManifest.ts b/src/shared/pptDiffManifest.ts new file mode 100644 index 00000000..dd1654dc --- /dev/null +++ b/src/shared/pptDiffManifest.ts @@ -0,0 +1,17 @@ +export type SupportedPptDiffManifest = { + version: 4 + slides: unknown[] +} + +export function assertSupportedPptDiffManifest( + value: unknown +): asserts value is SupportedPptDiffManifest { + if ( + !value || + typeof value !== 'object' || + (value as { version?: unknown }).version !== 4 || + !Array.isArray((value as { slides?: unknown }).slides) + ) { + throw new Error('PPT 审核内容格式已过期,请重新生成修改记录') + } +} diff --git a/tests/case/ai/tool/ppt/ppt-diff-net-semantics.test.ts b/tests/case/ai/tool/ppt/ppt-diff-net-semantics.test.ts new file mode 100644 index 00000000..ae783d7b --- /dev/null +++ b/tests/case/ai/tool/ppt/ppt-diff-net-semantics.test.ts @@ -0,0 +1,452 @@ +import fs from 'node:fs/promises' +import path from 'node:path' +import { getSlides, getSlideText, loadPresentation } from 'pptx-kit' +import { describe, it } from 'vitest' +import { createPptToolRuntime } from '../../../../../src/main/services/ai/human/tools/ppt/runtime' +import type { PptDiffManifest } from '../../../../../src/main/services/ai/human/tools/ppt/types' +import type { JsonObject } from '../../../../../src/shared/types' +import { createPptToolHarness } from '../../../../harness/backend/aiToolHarness' +import { runBackendCase } from '../../../../harness/backend/run-backend-case' + +const slideHtml = (title: string, body = ''): string => ` +
+

${title}

+

${body}

+
` + +const elementDeckHtml = ` +
+

Layer Original

+

Text Original

+

Style Original

+

Move Original

+

Resize Original

+

Delete Original

+

Restore Original

+ +
` + +const seedDeck = async ( + workspaceRootPath: string, + sourcePath: string, + htmlSlides: string[] +): Promise => { + const runtime = createPptToolRuntime({ + ctx: { saveFileChangeRecord: async () => undefined } as never, + cache: {} as never, + runId: `seed-${sourcePath}`, + sessionId: 'ppt-diff-seed', + taskId: 'ppt-diff-seed', + workspaceRootPath, + emitActivity: () => undefined + }) + await runtime.edit({ + sourcePath, + actions: htmlSlides.map((html) => ({ type: 'insert_slide', position: 'after', html })) + }) +} + +const readManifest = async (previewPath: string): Promise => + JSON.parse(await fs.readFile(previewPath, 'utf8')) as PptDiffManifest + +describe('ai tool ppt net diff backend', () => { + it('ppt-diff-slide-and-element-net-semantics', async () => { + await runBackendCase({ + caseId: 'ppt-diff-slide-and-element-net-semantics', + harnessMode: 'tool-real', + domain: 'ai', + suite: 'tool', + groups: ['ppt'], + description: '验证 PPT 页面与元素都按运行初始状态和最终状态生成净 Diff。', + fixtures: [], + runMode: 'single', + execute: async (runtime) => { + await seedDeck(runtime.workspaceRoot, 'slide-net.pptx', [ + slideHtml('Chain A'), + slideHtml('Delete A'), + slideHtml('Keep'), + slideHtml('Delete Middle'), + slideHtml('Move Me') + ]) + await seedDeck(runtime.workspaceRoot, 'element-net.pptx', [elementDeckHtml]) + await seedDeck(runtime.workspaceRoot, 'zero-net.pptx', [ + slideHtml('Zero A'), + slideHtml('Zero Middle'), + slideHtml('Zero Tail') + ]) + await seedDeck(runtime.workspaceRoot, 'notes-net.pptx', [ + slideHtml('Notes Final'), + slideHtml('Notes Restore') + ]) + + const harness = createPptToolHarness(runtime) + const slideOutline = await harness.invoke('ppt_read_outline', { + sourcePath: 'slide-net.pptx', + ranges: [{ type: 'percent', fromPercent: 0, toPercent: 100 }] + }) + const slideIds = new Map( + (slideOutline.slides as Array<{ slideId: string; title?: string; text: string }>).map( + (slide) => [slide.text.trim(), slide.slideId] + ) + ) + const chainId = slideIds.get('Chain A')! + const deleteId = slideIds.get('Delete A')! + const keepId = slideIds.get('Keep')! + const deleteMiddleId = slideIds.get('Delete Middle')! + const moveId = slideIds.get('Move Me')! + + await harness.invoke('ppt_edit', { + sourcePath: 'slide-net.pptx', + actions: [ + { type: 'replace_text', slideIds: [chainId], query: 'Chain A', replace: 'Chain B' } + ] + }) + await harness.invoke('ppt_edit', { + sourcePath: 'slide-net.pptx', + actions: [ + { type: 'replace_text', slideIds: [chainId], query: 'Chain B', replace: 'Chain C' } + ] + }) + await harness.invoke('ppt_edit', { + sourcePath: 'slide-net.pptx', + actions: [ + { type: 'replace_text', slideIds: [deleteId], query: 'Delete A', replace: 'Delete B' }, + { type: 'delete_slides', slideIds: [deleteId] } + ] + }) + const inserted = await harness.invoke('ppt_edit', { + sourcePath: 'slide-net.pptx', + actions: [ + { + type: 'insert_slide', + position: 'after', + anchorSlideId: chainId, + html: slideHtml('New Draft') + } + ] + }) + const insertedId = (inserted.postWriteReadback as { slides: Array<{ slideId: string }> }) + .slides[0]!.slideId + await harness.invoke('ppt_edit', { + sourcePath: 'slide-net.pptx', + actions: [ + { + type: 'replace_text', + slideIds: [insertedId], + query: 'New Draft', + replace: 'New Final' + } + ] + }) + const temporary = await harness.invoke('ppt_edit', { + sourcePath: 'slide-net.pptx', + actions: [ + { + type: 'insert_slide', + position: 'after', + anchorSlideId: insertedId, + html: slideHtml('Temporary Slide') + } + ] + }) + const temporaryId = (temporary.postWriteReadback as { slides: Array<{ slideId: string }> }) + .slides[0]!.slideId + await harness.invoke('ppt_edit', { + sourcePath: 'slide-net.pptx', + actions: [ + { type: 'delete_slides', slideIds: [temporaryId, deleteMiddleId] }, + { type: 'move_slide', slideId: moveId, position: 'before', anchorSlideId: keepId } + ] + }) + + const elementRead = await harness.invoke('ppt_read_slides', { + sourcePath: 'element-net.pptx', + ranges: [{ type: 'slide_index', fromSlideIndex: 1, toSlideIndex: 1 }] + }) + const elementSlide = ( + elementRead.slides as Array<{ + slideId: string + elements: Array<{ + elementId: string + text?: string + type: string + bounds?: { x: number; y: number; width: number; height: number } + }> + }> + )[0]! + const byText = (text: string) => + elementSlide.elements.find((element) => element.text === text)! + const formula = elementSlide.elements.find((element) => element.type === 'formula')! + const moveBounds = byText('Move Original').bounds! + const resizeBounds = byText('Resize Original').bounds! + + await harness.invoke('ppt_edit', { + sourcePath: 'element-net.pptx', + actions: [ + { + type: 'edit_elements', + slideId: elementSlide.slideId, + operations: [ + { + type: 'set_text', + elementId: byText('Text Original').elementId, + text: 'Text Final' + }, + { + type: 'set_style', + elementId: byText('Style Original').elementId, + style: { color: '#D14343', underline: true } + }, + { + type: 'set_bounds', + elementId: byText('Move Original').elementId, + bounds: { ...moveBounds, x: moveBounds.x + 80 } + }, + { + type: 'set_bounds', + elementId: byText('Resize Original').elementId, + bounds: { ...resizeBounds, width: resizeBounds.width + 120 } + }, + { + type: 'set_z_order', + elementId: byText('Layer Original').elementId, + placement: 'front' + }, + { type: 'delete_element', elementId: byText('Delete Original').elementId }, + { + type: 'set_text', + elementId: byText('Restore Original').elementId, + text: 'Restore Temporary' + }, + { type: 'update_formula', elementId: formula.elementId, latex: 'a^2+b^2=c^2' }, + { + type: 'add_element', + element: { + type: 'text', + text: 'Added Draft', + bounds: { x: 900, y: 500, width: 500, height: 80 } + } + }, + { + type: 'add_element', + element: { + type: 'text', + text: 'Temporary Element', + bounds: { x: 900, y: 650, width: 500, height: 80 } + } + } + ] + }, + { type: 'set_notes', slideId: elementSlide.slideId, text: 'Temporary Notes' } + ] + }) + const elementAfterFirst = await harness.invoke('ppt_read_slides', { + sourcePath: 'element-net.pptx', + ranges: [{ type: 'slide_ids', slideIds: [elementSlide.slideId] }] + }) + const updatedElements = ( + elementAfterFirst.slides as Array<{ + elements: Array<{ elementId: string; text?: string; type: string }> + }> + )[0]!.elements + const updatedFormula = updatedElements.find((element) => element.type === 'formula')! + const added = updatedElements.find((element) => element.text === 'Added Draft')! + const temporaryElement = updatedElements.find( + (element) => element.text === 'Temporary Element' + )! + await harness.invoke('ppt_edit', { + sourcePath: 'element-net.pptx', + actions: [ + { + type: 'edit_elements', + slideId: elementSlide.slideId, + operations: [ + { + type: 'set_text', + elementId: byText('Restore Original').elementId, + text: 'Restore Original' + }, + { type: 'update_formula', elementId: updatedFormula.elementId, latex: 'E=mc^2' }, + { type: 'set_text', elementId: added.elementId, text: 'Added Final' }, + { type: 'delete_element', elementId: temporaryElement.elementId } + ] + }, + { type: 'set_notes', slideId: elementSlide.slideId, text: '' } + ] + }) + + const zeroOutline = await harness.invoke('ppt_read_outline', { + sourcePath: 'zero-net.pptx', + ranges: [{ type: 'percent', fromPercent: 0, toPercent: 100 }] + }) + const zeroSlides = zeroOutline.slides as Array<{ slideId: string; text: string }> + const zeroId = zeroSlides.find((slide) => slide.text.trim() === 'Zero A')!.slideId + const zeroMiddleId = zeroSlides.find( + (slide) => slide.text.trim() === 'Zero Middle' + )!.slideId + const zeroTailId = zeroSlides.find((slide) => slide.text.trim() === 'Zero Tail')!.slideId + await harness.invoke('ppt_edit', { + sourcePath: 'zero-net.pptx', + actions: [ + { type: 'replace_text', slideIds: [zeroId], query: 'Zero A', replace: 'Zero B' }, + { type: 'replace_text', slideIds: [zeroId], query: 'Zero B', replace: 'Zero A' }, + { + type: 'move_slide', + slideId: zeroTailId, + position: 'before', + anchorSlideId: zeroMiddleId + }, + { + type: 'move_slide', + slideId: zeroTailId, + position: 'after', + anchorSlideId: zeroMiddleId + } + ] + }) + const zeroTemporary = await harness.invoke('ppt_edit', { + sourcePath: 'zero-net.pptx', + actions: [ + { + type: 'insert_slide', + position: 'after', + anchorSlideId: zeroId, + html: slideHtml('Zero Temporary') + } + ] + }) + const zeroTemporaryId = ( + zeroTemporary.postWriteReadback as { slides: Array<{ slideId: string }> } + ).slides[0]!.slideId + await harness.invoke('ppt_edit', { + sourcePath: 'zero-net.pptx', + actions: [{ type: 'delete_slides', slideIds: [zeroTemporaryId] }] + }) + + const notesOutline = await harness.invoke('ppt_read_outline', { + sourcePath: 'notes-net.pptx', + ranges: [{ type: 'percent', fromPercent: 0, toPercent: 100 }] + }) + const notesSlides = notesOutline.slides as Array<{ slideId: string; text: string }> + const notesFinalId = notesSlides.find( + (slide) => slide.text.trim() === 'Notes Final' + )!.slideId + const notesRestoreId = notesSlides.find( + (slide) => slide.text.trim() === 'Notes Restore' + )!.slideId + await harness.invoke('ppt_edit', { + sourcePath: 'notes-net.pptx', + actions: [ + { type: 'set_notes', slideId: notesFinalId, text: 'Final speaker notes' }, + { type: 'set_notes', slideId: notesRestoreId, text: 'Temporary speaker notes' } + ] + }) + await harness.invoke('ppt_edit', { + sourcePath: 'notes-net.pptx', + actions: [{ type: 'set_notes', slideId: notesRestoreId, text: '' }] + }) + + const records = await harness.runtime.commitAll() + const recordByName = new Map(records.map((record) => [record.fileName, record])) + const slideRecord = recordByName.get('slide-net.pptx')! + const elementRecord = recordByName.get('element-net.pptx')! + const notesRecord = recordByName.get('notes-net.pptx')! + const slideManifest = await readManifest(slideRecord.previewPath!) + const elementManifest = await readManifest(elementRecord.previewPath!) + const notesManifest = await readManifest(notesRecord.previewPath!) + const slideFrameTexts = await Promise.all( + slideManifest.slides.flatMap((slide) => + [slide.before?.svg, slide.after?.svg] + .filter((filePath): filePath is string => Boolean(filePath)) + .map((filePath) => fs.readFile(filePath, 'utf8')) + ) + ) + const finalSlidePresentation = await loadPresentation( + new Uint8Array(await fs.readFile(path.join(runtime.workspaceRoot, 'slide-net.pptx'))) + ) + return { + recordNames: records.map((record) => record.fileName).sort(), + slideRecord, + elementRecord, + notesRecord, + slideManifest, + elementManifest, + notesManifest, + slideFrameText: slideFrameTexts.join('\n'), + finalSlideTexts: getSlides(finalSlidePresentation).map((slide) => getSlideText(slide)) + } + }, + assert: async (result, runtime) => { + const slideById = new Map( + result.slideManifest.slides.map((slide) => [slide.slideId, slide]) + ) + const changedSlides = result.slideManifest.slides.filter((slide) => slide.changed) + const unchangedSlides = result.slideManifest.slides.filter((slide) => !slide.changed) + const elementSlide = result.elementManifest.slides.find((slide) => slide.changed)! + runtime.assertThat( + JSON.stringify(result.recordNames) === + JSON.stringify(['element-net.pptx', 'notes-net.pptx', 'slide-net.pptx']) && + result.slideManifest.version === 4 && + result.slideManifest.positiveCount === 3 && + result.slideManifest.negativeCount === 2 && + result.slideRecord.insertCount === 3 && + result.slideRecord.deleteCount === 2 && + result.slideRecord.changeCount === 5 && + changedSlides.length === 5 && + unchangedSlides.length === 1 && + unchangedSlides[0]?.changes.length === 0 && + [...slideById.values()].filter((slide) => slide.changes.includes('moved')).length === + 1 && + result.slideFrameText.includes('Chain A') && + result.slideFrameText.includes('Chain C') && + !result.slideFrameText.includes('Chain B') && + result.slideFrameText.includes('Delete A') && + !result.slideFrameText.includes('Delete B') && + result.slideFrameText.includes('New Final') && + !result.slideFrameText.includes('New Draft') && + !result.slideFrameText.includes('Temporary Slide') && + JSON.stringify(result.finalSlideTexts.map((text) => text.trim())) === + JSON.stringify(['Chain C', 'New Final', 'Move Me', 'Keep']), + { + name: 'ppt-slide-net-diff-collapses-intermediate-and-structural-noise', + message: 'PPT 页面 Diff 应只保留最终净变化,插入删除造成的页码偏移不得标记移动。', + data: result + } + ) + runtime.assertThat( + result.elementManifest.version === 4 && + result.elementManifest.positiveCount === 6 && + result.elementManifest.negativeCount === 1 && + result.elementRecord.insertCount === 6 && + result.elementRecord.deleteCount === 1 && + result.elementRecord.changeCount === 7 && + JSON.stringify(elementSlide.changes) === JSON.stringify(['modified']) && + elementSlide.elementChanges.filter((change) => change.kind === 'modified').length === + 5 && + elementSlide.elementChanges.filter((change) => change.kind === 'added').length === 1 && + elementSlide.elementChanges.filter((change) => change.kind === 'deleted').length === 1, + { + name: 'ppt-element-net-diff-counts-final-add-modify-move-and-delete', + message: 'PPT 元素加减数量必须来自 baseline 与最终状态,同一元素多次修改只计一次。', + data: result.elementManifest + } + ) + runtime.assertThat( + result.notesManifest.version === 4 && + result.notesManifest.positiveCount === 1 && + result.notesManifest.negativeCount === 0 && + result.notesRecord.insertCount === 1 && + result.notesRecord.deleteCount === 0 && + result.notesRecord.changeCount === 1 && + result.notesManifest.slides.filter((slide) => slide.changed).length === 1 && + result.notesManifest.slides.filter((slide) => !slide.changed).length === 1, + { + name: 'ppt-page-notes-net-diff-collapses-restored-notes', + message: 'PPT 页面备注应按最终净变化计数,恢复原备注的页面不得留下修改。', + data: result.notesManifest + } + ) + } + }) + }, 120_000) +}) diff --git a/tests/case/unit/ai/ppt-diff.test.ts b/tests/case/unit/ai/ppt-diff.test.ts new file mode 100644 index 00000000..e2dc1c90 --- /dev/null +++ b/tests/case/unit/ai/ppt-diff.test.ts @@ -0,0 +1,189 @@ +import { describe, expect, it } from 'vitest' + +import { + buildPptDiffManifest, + diffPptElements, + resolveMovedPptSlideIds +} from '../../../../src/main/services/ai/human/tools/ppt/diff' +import type { + PptDiffElementSnapshot, + PptDiffSlideSnapshot +} from '../../../../src/main/services/ai/human/tools/ppt/types' + +const element = (elementId: string, fingerprint: string, zIndex = 0): PptDiffElementSnapshot => ({ + elementId, + fingerprint, + zIndex +}) + +const slide = ( + slideId: string, + index: number, + elements: PptDiffElementSnapshot[] = [], + pageFingerprint = 'page' +): PptDiffSlideSnapshot => ({ slideId, index, elements, pageFingerprint }) + +const manifest = (input: { + before: PptDiffSlideSnapshot[] + after: PptDiffSlideSnapshot[] + movedCandidates?: Set + movedElementCandidates?: Set + lineage?: Map +}) => + buildPptDiffManifest({ + beforeSlides: input.before, + afterSlides: input.after, + beforeFrames: new Map(), + afterFrames: new Map(), + operation: 'updated', + movedCandidates: input.movedCandidates, + movedElementCandidates: input.movedElementCandidates, + elementLineage: input.lineage + }) + +describe('ppt net diff', () => { + it('does not mark later slides as moved after middle insertion or deletion', () => { + expect( + resolveMovedPptSlideIds({ + beforeSlideIds: ['A', 'B', 'C', 'D'], + afterSlideIds: ['A', 'C', 'D'] + }) + ).toEqual(new Set()) + expect( + resolveMovedPptSlideIds({ + beforeSlideIds: ['A', 'B', 'C', 'D'], + afterSlideIds: ['A', 'X', 'B', 'C', 'D'] + }) + ).toEqual(new Set()) + }) + + it('marks only the explicitly moved slide and collapses a move restored to baseline', () => { + expect( + resolveMovedPptSlideIds({ + beforeSlideIds: ['A', 'B', 'C', 'D'], + afterSlideIds: ['B', 'C', 'A', 'D'], + movedCandidates: new Set(['A']) + }) + ).toEqual(new Set(['A'])) + expect( + resolveMovedPptSlideIds({ + beforeSlideIds: ['A', 'B', 'C'], + afterSlideIds: ['A', 'B', 'C'], + movedCandidates: new Set(['A']) + }) + ).toEqual(new Set()) + }) + + it('uses stable ids, replacement lineage, and exact fingerprints for element matching', () => { + expect( + diffPptElements({ + before: [element('same', 'A'), element('formula-old', 'B'), element('renumbered-old', 'C')], + after: [ + element('same', 'A2'), + element('formula-new', 'B2'), + element('renumbered-new', 'C') + ], + lineage: new Map([['formula-old', 'formula-new']]) + }) + ).toEqual([ + { kind: 'modified', beforeElementId: 'same', afterElementId: 'same' }, + { + kind: 'modified', + beforeElementId: 'formula-old', + afterElementId: 'formula-new' + } + ]) + }) + + it('counts element additions, deletions, modifications, and slide movement as net changes', () => { + const result = manifest({ + before: [slide('A', 1, [element('keep', 'old'), element('delete', 'old')])], + after: [slide('A', 1, [element('keep', 'new'), element('add', 'new')])], + movedCandidates: new Set(['A']) + }) + expect(result.slides[0]).toMatchObject({ + changes: ['modified'], + positiveCount: 2, + negativeCount: 1 + }) + expect({ positive: result.positiveCount, negative: result.negativeCount }).toEqual({ + positive: 2, + negative: 1 + }) + + const movedAndModified = manifest({ + before: [slide('A', 1, [element('one', 'old'), element('two', 'old')]), slide('B', 2)], + after: [slide('B', 1), slide('A', 2, [element('one', 'new'), element('two', 'new')])], + movedCandidates: new Set(['A']) + }) + expect(movedAndModified.slides.find((item) => item.slideId === 'A')).toMatchObject({ + changes: ['modified', 'moved'], + positiveCount: 3, + negativeCount: 0 + }) + }) + + it('collapses restored edits and temporary elements to zero net changes', () => { + const result = manifest({ + before: [slide('A', 1, [element('one', 'baseline')])], + after: [slide('A', 1, [element('one', 'baseline')])], + movedCandidates: new Set(['A']) + }) + expect(result.slides[0]).toMatchObject({ + changed: false, + changes: [], + elementChanges: [], + positiveCount: 0, + negativeCount: 0 + }) + expect(result.positiveCount + result.negativeCount).toBe(0) + }) + + it('counts page-level background or notes fingerprints once and collapses restoration', () => { + const changed = manifest({ + before: [slide('A', 1, [], 'page-before')], + after: [slide('A', 1, [], 'page-after')] + }) + expect(changed.slides[0]).toMatchObject({ + changes: ['modified'], + positiveCount: 1, + negativeCount: 0 + }) + + const restored = manifest({ + before: [slide('A', 1, [], 'same-page')], + after: [slide('A', 1, [], 'same-page')] + }) + expect(restored.positiveCount + restored.negativeCount).toBe(0) + }) + + it('counts only the element explicitly moved in z-order and ignores natural renumbering', () => { + expect( + diffPptElements({ + before: [element('A', 'same', 0), element('B', 'same', 1), element('C', 'same', 2)], + after: [element('B', 'same', 0), element('C', 'same', 1), element('A', 'same', 2)], + movedCandidates: new Set(['A']) + }) + ).toEqual([{ kind: 'modified', beforeElementId: 'A', afterElementId: 'A' }]) + + expect( + diffPptElements({ + before: [element('A', 'same', 0), element('B', 'same', 1), element('C', 'same', 2)], + after: [element('A', 'same', 0), element('C', 'same', 1)] + }) + ).toEqual([{ kind: 'deleted', beforeElementId: 'B' }]) + }) + + it('subsumes child elements when an entire slide is added or deleted', () => { + const result = manifest({ + before: [slide('deleted', 1, [element('old-1', 'A'), element('old-2', 'B')])], + after: [slide('added', 1, [element('new-1', 'A'), element('new-2', 'B')])] + }) + expect( + result.slides.map((item) => [item.changes, item.positiveCount, item.negativeCount]) + ).toEqual([ + [['deleted'], 0, 1], + [['added'], 1, 0] + ]) + }) +}) diff --git a/tests/case/unit/ai/ppt-tool-contract.test.ts b/tests/case/unit/ai/ppt-tool-contract.test.ts index eeb5246a..7e3e73ad 100644 --- a/tests/case/unit/ai/ppt-tool-contract.test.ts +++ b/tests/case/unit/ai/ppt-tool-contract.test.ts @@ -243,26 +243,48 @@ describe('ai unit ppt tool contract', () => { ).resolves.toBeTruthy() }) - it('emits v3 SVG created and updated diff semantics', () => { - const before = new Map([ - ['slide-1', { index: 1, svg: 'before-1.svg', hash: 'same' }], - ['slide-2', { index: 2, svg: 'before-2.svg', hash: 'old' }] + it('emits v4 net diff semantics without treating structural shifts as moves', () => { + const beforeSlides = [ + { slideId: 'slide-1', index: 1, pageFingerprint: 'same', elements: [] }, + { slideId: 'slide-2', index: 2, pageFingerprint: 'old', elements: [] } + ] + const afterSlides = [ + { slideId: 'slide-2', index: 1, pageFingerprint: 'new', elements: [] }, + { slideId: 'slide-3', index: 2, pageFingerprint: 'new', elements: [] } + ] + const beforeFrames = new Map([ + ['slide-1', { index: 1, svg: 'before-1.svg' }], + ['slide-2', { index: 2, svg: 'before-2.svg' }] ]) - const after = new Map([ - ['slide-2', { index: 1, svg: 'after-2.svg', hash: 'new' }], - ['slide-3', { index: 2, svg: 'after-3.svg', hash: 'new' }] + const afterFrames = new Map([ + ['slide-2', { index: 1, svg: 'after-2.svg' }], + ['slide-3', { index: 2, svg: 'after-3.svg' }] ]) - const manifest = buildPptDiffManifest({ before, after, operation: 'updated' }) - expect(manifest.version).toBe(3) + const manifest = buildPptDiffManifest({ + beforeSlides, + afterSlides, + beforeFrames, + afterFrames, + operation: 'updated' + }) + expect(manifest.version).toBe(4) expect(manifest.slides.map((slide) => slide.changes)).toEqual([ ['deleted'], - ['modified', 'moved'], + ['modified'], ['added'] ]) + expect({ positive: manifest.positiveCount, negative: manifest.negativeCount }).toEqual({ + positive: 2, + negative: 1 + }) expect( - buildPptDiffManifest({ before: new Map(), after, operation: 'created' }).slides.every( - (slide) => slide.changes.includes('added') - ) + buildPptDiffManifest({ + beforeSlides: [], + afterSlides, + beforeFrames: new Map(), + afterFrames, + operation: 'created' + }).slides.every((slide) => slide.changes.includes('added')) ).toBe(true) }) }) diff --git a/tests/case/unit/workbench/ppt-diff-manifest-version.test.ts b/tests/case/unit/workbench/ppt-diff-manifest-version.test.ts new file mode 100644 index 00000000..12b27333 --- /dev/null +++ b/tests/case/unit/workbench/ppt-diff-manifest-version.test.ts @@ -0,0 +1,11 @@ +import { describe, expect, it } from 'vitest' +import { assertSupportedPptDiffManifest } from '../../../../src/shared/pptDiffManifest' + +describe('ppt diff manifest version', () => { + it('accepts v4 and explicitly rejects legacy v3 artifacts', () => { + expect(() => assertSupportedPptDiffManifest({ version: 4, slides: [] })).not.toThrow() + expect(() => assertSupportedPptDiffManifest({ version: 3, slides: [] })).toThrow( + 'PPT 审核内容格式已过期,请重新生成修改记录' + ) + }) +}) diff --git a/tests/case/workbench/result/apply/ppt-create-diff-and-revert.e2e.test.ts b/tests/case/workbench/result/apply/ppt-create-diff-and-revert.e2e.test.ts index 7230fcb1..ab28a031 100644 --- a/tests/case/workbench/result/apply/ppt-create-diff-and-revert.e2e.test.ts +++ b/tests/case/workbench/result/apply/ppt-create-diff-and-revert.e2e.test.ts @@ -66,6 +66,16 @@ describe('workbench result e2e', () => { state: 'visible', timeout: 30_000 }) + const reviewToolbarText = ( + (await getWorkbenchLocator(page, workbenchSelectors.documentToolbar).textContent()) || + '' + ).trim() + const documentModeToggle = getWorkbenchLocator( + page, + workbenchSelectors.documentModeToggle + ) + const documentModeTag = getWorkbenchLocator(page, workbenchSelectors.documentModeTag) + const initialModeTag = ((await documentModeTag.textContent()) || '').trim() const beforeVisible = await getWorkbenchLocator( page, workbenchSelectors.pptDiffBefore @@ -114,6 +124,17 @@ describe('workbench result e2e', () => { await page.mouse.up() } const draggedTransform = (await transform.getAttribute('style')) || '' + await documentModeToggle.click() + await page + .getByTestId('ppt-preview-viewer') + .waitFor({ state: 'visible', timeout: 30_000 }) + const previewModeTag = ((await documentModeTag.textContent()) || '').trim() + await documentModeToggle.click() + await getWorkbenchLocator(page, workbenchSelectors.pptDiffViewer).waitFor({ + state: 'visible', + timeout: 30_000 + }) + const restoredModeTag = ((await documentModeTag.textContent()) || '').trim() await getWorkbenchLocator(page, workbenchSelectors.applyRevertButton).click() await page.getByText('回退这次修改?').waitFor({ state: 'visible' }) await page.getByText('文件将恢复到这次修改发生前的状态。').waitFor({ state: 'visible' }) @@ -127,6 +148,10 @@ describe('workbench result e2e', () => { slideCount: getSlides(presentation).length, slideText, applyEntryText, + reviewToolbarText, + initialModeTag, + previewModeTag, + restoredModeTag, beforeVisible, afterVisible, thumbnailCount, @@ -151,6 +176,11 @@ describe('workbench result e2e', () => { result.slideCount === 2 && result.slideText.includes(MARKER) && result.applyEntryText.includes(FILE_NAME) && + result.reviewToolbarText.includes('+2') && + result.reviewToolbarText.includes('-0') && + result.initialModeTag === '审核' && + result.previewModeTag === '预览' && + result.restoredModeTag === '审核' && !result.beforeVisible && result.afterVisible && result.thumbnailCount === 2 && diff --git a/tests/case/workbench/result/apply/ppt-update-diff-interactions.e2e.test.ts b/tests/case/workbench/result/apply/ppt-update-diff-interactions.e2e.test.ts index 20d86980..8f8096d4 100644 --- a/tests/case/workbench/result/apply/ppt-update-diff-interactions.e2e.test.ts +++ b/tests/case/workbench/result/apply/ppt-update-diff-interactions.e2e.test.ts @@ -106,13 +106,22 @@ describe('workbench result e2e', () => { const updatedPresentation = await loadPresentation( new Uint8Array(await fs.readFile(filePath)) ) - const updatedText = getSlides(updatedPresentation) - .map((slide) => getSlideText(slide)) - .join('\n') + const updatedSlideTexts = getSlides(updatedPresentation).map((slide) => + getSlideText(slide).trim() + ) + const updatedText = updatedSlideTexts.join('\n') const applyEntry = await waitForApplyEntry(page) await applyEntry.click() const viewer = getWorkbenchLocator(page, workbenchSelectors.pptDiffViewer) await viewer.waitFor({ state: 'visible', timeout: 30_000 }) + const documentToolbar = getWorkbenchLocator(page, workbenchSelectors.documentToolbar) + const reviewToolbarText = ((await documentToolbar.textContent()) || '').trim() + const documentModeToggle = getWorkbenchLocator( + page, + workbenchSelectors.documentModeToggle + ) + const documentModeTag = getWorkbenchLocator(page, workbenchSelectors.documentModeTag) + const initialModeTag = ((await documentModeTag.textContent()) || '').trim() const beforeSurface = getWorkbenchLocator(page, workbenchSelectors.pptDiffBefore) const afterSurface = getWorkbenchLocator(page, workbenchSelectors.pptDiffAfter) const beforeVisible = await beforeSurface.isVisible() @@ -158,6 +167,21 @@ describe('workbench result e2e', () => { ) .locator('button') .count() + const allGreenMarkerCount = await viewer + .locator( + '[aria-label="新增页"], [aria-label="内容已修改"], [aria-label="位置已移动"], [aria-label="内容已修改,位置已移动"]' + ) + .count() + + await documentModeToggle.click() + await page + .getByTestId('ppt-preview-viewer') + .waitFor({ state: 'visible', timeout: 30_000 }) + const previewModeTag = ((await documentModeTag.textContent()) || '').trim() + const reviewViewerHiddenInPreview = !(await viewer.isVisible().catch(() => false)) + await documentModeToggle.click() + await viewer.waitFor({ state: 'visible', timeout: 30_000 }) + const restoredModeTag = ((await documentModeTag.textContent()) || '').trim() await getWorkbenchLocator(page, workbenchSelectors.applyRevertButton).click() await page.getByText('回退这次修改?').waitFor({ state: 'visible' }) @@ -171,11 +195,18 @@ describe('workbench result e2e', () => { return { updatedText, + updatedSlideTexts, + reviewToolbarText, + initialModeTag, + previewModeTag, + restoredModeTag, + reviewViewerHiddenInPreview, beforeVisible, afterVisible, changedThumbnailCount, allThumbnailCount, greenMarkerCount, + allGreenMarkerCount, redMarkerCount, inactiveThumbnailClass, beforeZoomTransform, @@ -194,11 +225,23 @@ describe('workbench result e2e', () => { result.updatedText.includes('市场信号已确认') && result.updatedText.includes('风险与应对') && !result.updatedText.includes('产品进展') && + result.updatedSlideTexts.length === 4 && + result.updatedSlideTexts[0]?.includes('交付质量') && + result.updatedSlideTexts[1]?.includes('市场信号已确认') && + result.updatedSlideTexts[2]?.includes('风险与应对') && + result.updatedSlideTexts[3]?.includes('附录数据') && + result.reviewToolbarText.includes('+3') && + result.reviewToolbarText.includes('-1') && + result.initialModeTag === '审核' && + result.previewModeTag === '预览' && + result.restoredModeTag === '审核' && + result.reviewViewerHiddenInPreview && result.beforeVisible && result.afterVisible && result.changedThumbnailCount === 4 && result.allThumbnailCount === 5 && - result.greenMarkerCount >= 3 && + result.greenMarkerCount === 3 && + result.allGreenMarkerCount === 3 && result.redMarkerCount === 1 && result.inactiveThumbnailClass.includes('border-transparent') && result.beforeZoomTransform === result.afterZoomTransform &&