Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,39 @@ All notable changes to this project will be documented in this file.
`navigateToPageCitation` expose materialization and preview navigation. The MCP inline
preview remains explicitly continuous pending #434. See
[`docs/architecture/page_map.md`](docs/architecture/page_map.md).
- **Intrinsically isolated mutation preview** (issue #446). `.NET` `PreviewBatch`,
Ops/JSON, WASM/npm, stdio/Python, and MCP now run the identical atomic or explicit
`best_effort` batch path on a complete shadow package instead of applying to the live
session and undoing. The clone carries every OPC part/relationship/media/custom-XML
payload plus version, mutable configuration, diff baseline, and id generators, while
caches and undo/redo history remain independent; failure, interruption, disposal, and
abandonment therefore cannot touch live bytes or history. Rich apply/preview receipts
include predicted versions, per-step created/removed/modified anchors and patches,
revision/comment/annotation deltas, warnings, a canonical package-content SHA-256, and
optional scoped/full shadow-only HTML. Deterministic previews and applies have exact
receipt/hash equivalence. Operations that generate anchors/OOXML ids or timestamps are
explicitly semantic-equivalence-only (same outcomes and structure/content/relationship
effects modulo generated metadata) and emit warnings. This supersedes the undo-depth,
redo-destruction, and crash window described in #468.

The preview HTML profile has a single owner (`HtmlConversionOps.PreviewDocumentOptions`
/ `PreviewBlockOptions`), reached from the browser through the new
`RenderPreviewHtml` / `RenderPreviewBlockHtml` bridge exports, so every surface's
preview of the same batch describes the same document (tracked changes, comments,
annotations, notes, and headers/footers shown) rather than the editor's authoring
render. Receipt change-set membership is compared on each entry's serialized wire
projection rather than CLR equality, matching what the browser client compares.
`packageHash` is `null`, never `""`, when it could not be computed, so an absent hash
cannot satisfy a replay-equality assertion. `MutationPreviewHtmlMode` is exposed to
Python as an enum (`docx_scalpel.MutationPreviewHtmlMode`).

**Cost note.** Receipt enrichment is unconditional on both the apply and the preview
path: each batch inspects revisions, comments, and annotations twice (each forcing an
anchor index) and computes a package-content hash, which serializes and hashes a full
package checkpoint. A preview additionally clones the package and opens a second
`WordprocessingDocument`, roughly doubling peak memory for its duration — material for a
large document on a browser WASM heap. There is deliberately no opt-out in this release;
whether to gate enrichment behind a setting remains an open public-API decision.
- **Atomic multi-step mutation batches** (issue #445). `DocxSession.ExecuteBatch`
and the reusable nested-safe `BeginTransaction` primitive checkpoint the complete
OPC package, relationship topology, anchor/revision generators, mutable session
Expand Down
864 changes: 864 additions & 0 deletions Docxodus.Tests/DocxSessionPreviewBatchTests.cs

Large diffs are not rendered by default.

69 changes: 69 additions & 0 deletions Docxodus.Tests/McpServerDispatcherTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -962,6 +962,13 @@ public void MCP091_Mutations_PreviewMode_LeavesDocumentUnchanged()
var sessionArg = JsonSerializer.Serialize(sessionId);
var anchor = FirstBodyAnchorId(sessionId, _store);

// Preserve a live redo cursor across preview; apply-then-undo used to destroy it.
Assert.True(ReplaceText(_store, sessionId, anchor, "redo target")
.GetProperty("success").GetBoolean());
Assert.True(Parse(Dispatcher.Call(_store, "docxodus_edit", J(
$$"""{"sessionId":{{sessionArg}},"action":"undo"}""")))
.GetProperty("success").GetBoolean());

var before = Parse(Dispatcher.Call(_store, "docxodus_get_content", J($$"""{"sessionId":{{sessionArg}},"format":"markdown"}""")))
.GetProperty("markdown").GetString()!;
var versionBefore = Parse(Dispatcher.Call(_store, "docxodus_get_content", J(
Expand All @@ -973,12 +980,23 @@ public void MCP091_Mutations_PreviewMode_LeavesDocumentUnchanged()
{
"sessionId": {{sessionArg}},
"mode": "preview",
"previewHtml": "full",
"steps": [
{ "tool": "docxodus_edit", "args": { "action": "replace_text", "anchorId": "{{anchor}}", "markdown": "should not stick" } }
]
}
""")));
Assert.Equal("ok", batch.GetProperty("status").GetString());
Assert.True(batch.GetProperty("preview").GetBoolean());
Assert.True(batch.GetProperty("success").GetBoolean());
Assert.Equal(versionBefore, batch.GetProperty("baseVersion").GetInt64());
Assert.Equal(versionBefore + 1, batch.GetProperty("resultVersion").GetInt64());
Assert.Equal(64, batch.GetProperty("packageHash").GetString()!.Length);
Assert.Single(batch.GetProperty("steps").EnumerateArray());
Assert.True(batch.GetProperty("revisionChanges").TryGetProperty("added", out _));
Assert.True(batch.GetProperty("commentChanges").TryGetProperty("added", out _));
Assert.True(batch.GetProperty("annotationChanges").TryGetProperty("added", out _));
Assert.Contains("should not stick", batch.GetProperty("html").GetString());

var after = Parse(Dispatcher.Call(_store, "docxodus_get_content", J($$"""{"sessionId":{{sessionArg}},"format":"markdown"}""")))
.GetProperty("markdown").GetString()!;
Expand All @@ -987,6 +1005,57 @@ public void MCP091_Mutations_PreviewMode_LeavesDocumentUnchanged()
$$"""{"sessionId":{{sessionArg}},"format":"version"}""")))
.GetProperty("version").GetInt64();
Assert.Equal(versionBefore, versionAfter);

var undo = Parse(Dispatcher.Call(_store, "docxodus_edit", J(
$$"""{"sessionId":{{sessionArg}},"action":"undo"}""")));
Assert.False(undo.GetProperty("success").GetBoolean());
var redo = Parse(Dispatcher.Call(_store, "docxodus_edit", J(
$$"""{"sessionId":{{sessionArg}},"action":"redo"}""")));
Assert.True(redo.GetProperty("success").GetBoolean());
var redone = Parse(Dispatcher.Call(_store, "docxodus_get_content", J(
$$"""{"sessionId":{{sessionArg}},"format":"markdown"}""")))
.GetProperty("markdown").GetString();
Assert.Contains("redo target", redone);
}

[Fact]
public void MCP098_Mutations_PreviewFlagSupportsExplicitBestEffortWithoutLivePartialApply()
{
var sessionId = OpenSession();
var sessionArg = JsonSerializer.Serialize(sessionId);
var anchor = FirstBodyAnchorId(sessionId, _store);
var before = Parse(Dispatcher.Call(_store, "docxodus_get_content", J(
$$"""{"sessionId":{{sessionArg}},"format":"markdown"}""")))
.GetProperty("markdown").GetString();

var batch = Parse(Dispatcher.Call(_store, "docxodus_mutations", J(
$$"""
{
"sessionId": {{sessionArg}},
"mode": "best_effort",
"preview": true,
"steps": [
{ "tool": "docxodus_edit", "args": { "action": "replace_text", "anchorId": "{{anchor}}", "markdown": "shadow partial" } },
{ "tool": "docxodus_edit", "args": { "action": "replace_text", "anchorId": "p:body:missing", "markdown": "failure" } }
]
}
""")));

Assert.True(batch.GetProperty("preview").GetBoolean());
Assert.Equal("best_effort", batch.GetProperty("mode").GetString());
Assert.Equal("partial", batch.GetProperty("status").GetString());
Assert.False(batch.GetProperty("success").GetBoolean());
Assert.False(batch.GetProperty("rolledBack").GetBoolean());
Assert.Equal(batch.GetProperty("baseVersion").GetInt64() + 1,
batch.GetProperty("resultVersion").GetInt64());
Assert.Contains(batch.GetProperty("warnings").EnumerateArray(),
warning => warning.GetString()!.Contains("Best-effort", StringComparison.Ordinal));

var after = Parse(Dispatcher.Call(_store, "docxodus_get_content", J(
$$"""{"sessionId":{{sessionArg}},"format":"markdown"}""")))
.GetProperty("markdown").GetString();
Assert.Equal(before, after);
Assert.Equal(0, Docxodus.Internal.DocxSessionOps.GetVersion(_store.Get(sessionId).Handle));
}

[Fact]
Expand Down
Loading
Loading