feat(ppt): update PPT diff manifest to version 4 with enhanced element tracking
- Changed `PptRuntimeDocument` to include `movedSlideIds`, `movedElementIds`, and `elementLineage`. - Introduced new types for element and slide snapshots in the PPT diff. - Updated `PptDiffSlide` to track element changes and counts for positive and negative changes. - Added a new utility function `assertSupportedPptDiffManifest` to validate the manifest version. - Implemented comprehensive tests for PPT diff functionality, including net semantics for slides and elements. - Enhanced existing tests to cover new diff semantics and ensure backward compatibility with version 4. - Updated end-to-end tests to validate UI interactions with the new diff features.
This commit is contained in:
1 parent
98295b808e
commit
f92cfd5c36
14 files changed
+1261
-102
No files matched your search
@@ -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 读取、编辑与审核模式
|
||||
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
- `web_search` 与 `web_crawl` 需要分开覆盖:搜索 case 只验证候选网页返回;读取 case 必须验证 AI 继续读取网页正文后再回答。
|
||||
- Workbench 附件链路当前支持图片、`doc`、`docx`、`pptx`、`pdf`、`md`、`json`、`xlsx`、`xlsm`、`xltx`、`xltm`,统一保存到 `<workspace>/.ncurator/runtime/assets/<assetBucketId>/`;`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`
|
||||
|
||||
@@ -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, string>): string => {
|
||||
const seen = new Set<string>()
|
||||
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<string, string>
|
||||
movedCandidates?: ReadonlySet<string>
|
||||
}): PptDiffElementChange[] => {
|
||||
const lineage = input.lineage || new Map<string, string>()
|
||||
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<string, PptDiffElementSnapshot[]>()
|
||||
for (const element of unmatchedBefore.values()) {
|
||||
const entries = beforeByFingerprint.get(element.fingerprint) || []
|
||||
entries.push(element)
|
||||
beforeByFingerprint.set(element.fingerprint, entries)
|
||||
}
|
||||
const afterByFingerprint = new Map<string, PptDiffElementSnapshot[]>()
|
||||
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<string>
|
||||
}): Set<string> => {
|
||||
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<string>()
|
||||
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<string>
|
||||
}): Set<string> =>
|
||||
resolveMovedIds({
|
||||
beforeIds: input.beforeSlideIds,
|
||||
afterIds: input.afterSlideIds,
|
||||
movedCandidates: input.movedCandidates
|
||||
})
|
||||
|
||||
export const buildPptDiffManifest = (input: {
|
||||
beforeSlides: readonly PptDiffSlideSnapshot[]
|
||||
afterSlides: readonly PptDiffSlideSnapshot[]
|
||||
beforeFrames: ReadonlyMap<string, PptDiffFrame>
|
||||
afterFrames: ReadonlyMap<string, PptDiffFrame>
|
||||
operation: 'created' | 'updated'
|
||||
movedCandidates?: ReadonlySet<string>
|
||||
elementLineage?: ReadonlyMap<string, string>
|
||||
movedElementCandidates?: ReadonlySet<string>
|
||||
}): 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
|
||||
}
|
||||
}
|
||||
@@ -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<string, unknown>)
|
||||
.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<string, PptDiffFrame & { hash: string }>
|
||||
after: Map<string, PptDiffFrame & { hash: string }>
|
||||
operation: 'created' | 'updated'
|
||||
forceChangedSlideIds?: ReadonlySet<string>
|
||||
}): 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<string>()
|
||||
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
|
||||
|
||||
@@ -66,7 +66,9 @@ export type PptRuntimeDocument = {
|
||||
lastPersistedBytes?: Uint8Array
|
||||
presentation: PresentationData
|
||||
dirty: boolean
|
||||
changedSlideIds: Set<string>
|
||||
movedSlideIds: Set<string>
|
||||
movedElementIds: Set<string>
|
||||
elementLineage: Map<string, string>
|
||||
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[]
|
||||
}
|
||||
|
||||
|
||||
@@ -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<string> => {
|
||||
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')
|
||||
})
|
||||
|
||||
@@ -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 审核内容格式已过期,请重新生成修改记录')
|
||||
}
|
||||
}
|
||||
@@ -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 => `
|
||||
<section data-ppt-slide style="position:relative;width:1920px;height:1080px;background:#FFFFFF">
|
||||
<h1 data-ppt-element="text" style="position:absolute;left:140px;top:100px;width:1500px;height:110px;color:#17324D;font-size:72px">${title}</h1>
|
||||
<p data-ppt-element="text" style="position:absolute;left:140px;top:300px;width:1500px;height:100px;color:#334155;font-size:36px">${body}</p>
|
||||
</section>`
|
||||
|
||||
const elementDeckHtml = `
|
||||
<section data-ppt-slide style="position:relative;width:1920px;height:1080px;background:#FFFFFF">
|
||||
<p data-ppt-element="text" style="position:absolute;left:100px;top:80px;width:420px;height:70px;color:#111827;font-size:28px">Layer Original</p>
|
||||
<p data-ppt-element="text" style="position:absolute;left:100px;top:180px;width:420px;height:70px;color:#111827;font-size:28px">Text Original</p>
|
||||
<p data-ppt-element="text" style="position:absolute;left:100px;top:280px;width:420px;height:70px;color:#111827;font-size:28px">Style Original</p>
|
||||
<p data-ppt-element="text" style="position:absolute;left:100px;top:380px;width:420px;height:70px;color:#111827;font-size:28px">Move Original</p>
|
||||
<p data-ppt-element="text" style="position:absolute;left:100px;top:480px;width:420px;height:70px;color:#111827;font-size:28px">Resize Original</p>
|
||||
<p data-ppt-element="text" style="position:absolute;left:100px;top:580px;width:420px;height:70px;color:#111827;font-size:28px">Delete Original</p>
|
||||
<p data-ppt-element="text" style="position:absolute;left:100px;top:680px;width:420px;height:70px;color:#111827;font-size:28px">Restore Original</p>
|
||||
<span data-pptx-latex="E=mc^2" style="position:absolute;left:900px;top:180px;width:600px;height:180px"></span>
|
||||
</section>`
|
||||
|
||||
const seedDeck = async (
|
||||
workspaceRootPath: string,
|
||||
sourcePath: string,
|
||||
htmlSlides: string[]
|
||||
): Promise<void> => {
|
||||
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<PptDiffManifest> =>
|
||||
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<JsonObject>('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<JsonObject>('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<JsonObject>('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<JsonObject>('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<JsonObject>('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<JsonObject>('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<JsonObject>('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<JsonObject>('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)
|
||||
})
|
||||
@@ -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<string>
|
||||
movedElementCandidates?: Set<string>
|
||||
lineage?: Map<string, string>
|
||||
}) =>
|
||||
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]
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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 审核内容格式已过期,请重新生成修改记录'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
Reference in new issue
Block a user