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
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,18 @@ All notable changes to this project will be documented in this file.
preserved; emitting new tracked table revisions remains #455. Coverage:
`DocxSessionTableAddressingTests` DT250–DT257, the existing table/MCP suites, and
`python/tests/test_table_addressing.py`.
- **Portable, renderer-authored `PageMap` and exact page citations** (issue #454).
Browser pagination can now materialize a versioned map of physical pages and every
canonical `kind:scope:unid` source fragment, with page-relative point geometry,
story/table ownership, page style identity, document version, and renderer
fingerprint. `DocxSession` validates and registers external maps; search, structural
find, and scoped projection APIs optionally attach citations across .NET, WASM/npm,
stdio/Python, and MCP. Mutations stale maps automatically, fingerprint mismatches are
rejected, and continuous/no-map layouts return typed unavailable results instead of
guessed pages. `paginateHtml`, React `PaginatedDocument`, and
`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).
- **Optimistic mutation preconditions and a monotonic document version** (issue
#447). Every `DocxSession` starts at version `0` and advances exactly once for
each committed mutation, undo, or redo; failures and successful no-ops leave it
Expand Down
1 change: 1 addition & 0 deletions Docxodus.Tests/DocumentMetadataTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ public void DM001_GetDocumentMetadata_ReturnsValidMetadata()
Assert.True(metadata.TotalParagraphs >= 0, "Total paragraphs should be non-negative");
Assert.True(metadata.TotalTables >= 0, "Total tables should be non-negative");
Assert.True(metadata.EstimatedPageCount >= 1, "Estimated page count should be at least 1");
Assert.Equal("heuristic", metadata.EstimatedPageCountSource);
}

[Fact]
Expand Down
134 changes: 132 additions & 2 deletions Docxodus.Tests/McpServerDispatcherTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -305,6 +305,84 @@ public void MCP031_Search_KindMode_FindsParagraphs()
Assert.True(found.GetProperty("matches").GetArrayLength() > 0);
}

[Fact]
public void MCP032_Pagination_RegisterSearchPreviewAndStaleStatus()
{
var sessionId = OpenSession();
var anchor = FirstBodyAnchorId(sessionId, _store);
Assert.True(ReplaceText(_store, sessionId, anchor, "citation target")
.GetProperty("success").GetBoolean());

var version = Parse(Dispatcher.Call(_store, "docxodus_get_content", J(
JsonSerializer.Serialize(new { sessionId, format = "version" }))))
.GetProperty("version").GetInt64();
const string fingerprint = "mcp-page-map-v1";
var pageMap = new
{
schemaVersion = 1,
mode = "paginated",
availability = "available",
documentVersion = version,
rendererFingerprint = fingerprint,
pages = new[]
{
new
{
pageNumber = 1,
pageInSection = 1,
width = 612,
height = 792,
sectionIndex = 0,
pageName = "docxodus-section-0",
},
},
fragments = new[]
{
new
{
fragmentId = $"p1-f0-{anchor}",
anchorId = anchor,
fragmentIndex = 0,
pageNumber = 1,
geometry = new { x = 72, y = 90, width = 468, height = 18 },
story = "body",
inTableCell = false,
},
},
};
var registered = Parse(Dispatcher.Call(_store, "docxodus_pagination", J(
JsonSerializer.Serialize(new { sessionId, action = "register", pageMap }))));
Assert.True(registered.GetProperty("success").GetBoolean());

var citation = new { documentVersion = version, rendererFingerprint = fingerprint };
var found = Parse(Dispatcher.Call(_store, "docxodus_search", J(
JsonSerializer.Serialize(new
{
sessionId,
mode = "text",
query = "citation target",
citation,
}))));
Assert.Equal("available", found.GetProperty("matches")[0]
.GetProperty("citation").GetProperty("availability").GetString());

var preview = Parse(Dispatcher.Call(_store, "docxodus_preview", J(
JsonSerializer.Serialize(new { sessionId, anchorId = anchor, citation }))));
Assert.Equal("available_registered_map",
preview.GetProperty("pageNavigation").GetString());
Assert.Equal(1, preview.GetProperty("citation").GetProperty("fragments")[0]
.GetProperty("pageNumber").GetInt32());
Assert.Equal(612, preview.GetProperty("citation").GetProperty("pages")[0]
.GetProperty("width").GetDouble());
Assert.Contains("pagination-staging", preview.GetProperty("html").GetString());

Assert.True(ReplaceText(_store, sessionId, anchor, "changed")
.GetProperty("success").GetBoolean());
var stale = Parse(Dispatcher.Call(_store, "docxodus_pagination", J(
JsonSerializer.Serialize(new { sessionId, action = "status", citation }))));
Assert.Equal("stale_document_version", stale.GetProperty("unavailableReason").GetString());
}

// ─── Format / List ──────────────────────────────────────────────────

[Fact]
Expand Down Expand Up @@ -954,9 +1032,9 @@ public void MCP092_Mutations_RejectsUndoRedoAsSteps()
// ─── Tool catalog ───────────────────────────────────────────────────

[Fact]
public void MCP100_ToolCatalog_HasFifteenDistinctNamedToolsWithValidSchemas()
public void MCP100_ToolCatalog_HasSixteenDistinctNamedToolsWithValidSchemas()
{
Assert.Equal(15, ToolCatalog.Tools.Count);
Assert.Equal(16, ToolCatalog.Tools.Count);
var names = new System.Collections.Generic.HashSet<string>();
foreach (var tool in ToolCatalog.Tools)
{
Expand All @@ -968,6 +1046,48 @@ public void MCP100_ToolCatalog_HasFifteenDistinctNamedToolsWithValidSchemas()
}
}

[Fact]
public void MCP101_PageMapSchemas_DescribeStrictTokensAndActionRequirements()
{
static void AssertCitationSchema(JsonElement schema)
{
Assert.False(schema.GetProperty("additionalProperties").GetBoolean());
var required = schema.GetProperty("required").EnumerateArray()
.Select(value => value.GetString()).ToArray();
Assert.Contains("documentVersion", required);
Assert.Contains("rendererFingerprint", required);
Assert.Equal("integer", schema.GetProperty("properties")
.GetProperty("documentVersion").GetProperty("type").GetString());
Assert.Equal(1, schema.GetProperty("properties")
.GetProperty("rendererFingerprint").GetProperty("minLength").GetInt32());
}

foreach (var toolName in new[] { "docxodus_get_content", "docxodus_preview", "docxodus_search" })
{
var tool = Assert.Single(ToolCatalog.Tools, item => item.Name == toolName);
using var schema = JsonDocument.Parse(tool.InputSchemaJson);
AssertCitationSchema(schema.RootElement.GetProperty("properties").GetProperty("citation"));
}

var pagination = Assert.Single(ToolCatalog.Tools, item => item.Name == "docxodus_pagination");
using var paginationSchema = JsonDocument.Parse(pagination.InputSchemaJson);
var root = paginationSchema.RootElement;
AssertCitationSchema(root.GetProperty("properties").GetProperty("citation"));
var pageMap = root.GetProperty("properties").GetProperty("pageMap");
Assert.False(pageMap.GetProperty("additionalProperties").GetBoolean());
Assert.Equal(1, pageMap.GetProperty("properties").GetProperty("schemaVersion")
.GetProperty("const").GetInt32());
Assert.False(pageMap.GetProperty("properties").GetProperty("fragments")
.GetProperty("items").GetProperty("additionalProperties").GetBoolean());
var variants = root.GetProperty("oneOf").EnumerateArray().ToArray();
Assert.Contains(variants, variant => variant.GetProperty("properties").GetProperty("action")
.GetProperty("const").GetString() == "register"
&& variant.GetProperty("required").EnumerateArray().Any(v => v.GetString() == "pageMap"));
Assert.Contains(variants, variant => variant.GetProperty("properties").GetProperty("action")
.GetProperty("const").GetString() == "cite"
&& variant.GetProperty("required").EnumerateArray().Any(v => v.GetString() == "citation"));
}

[Fact]
public void MCP139_ToolCatalog_AdvertisesHeaderFooterCreateAndSearchScope()
{
Expand Down Expand Up @@ -1031,6 +1151,13 @@ public void MCP141_WrapToolResult_RoutesHtmlToMetaNotModelContent()
Assert.Equal("<html><body>big</body></html>".Length,
structured.GetProperty("htmlLength").GetInt32());

var cited = Parse(UiResources.WrapToolResult("docxodus_preview",
"""{"sessionId":"s1","html":"<p>x</p>","citation":{"availability":"available","pages":[{"pageNumber":3,"pageInSection":1,"width":612,"height":792,"pageName":"docxodus-section-0"}],"fragments":[{"pageNumber":3}]},"pageNavigation":"available_registered_map"}""",
isError: false)).GetProperty("structuredContent");
Assert.Equal(3, cited.GetProperty("citation").GetProperty("fragments")[0]
.GetProperty("pageNumber").GetInt32());
Assert.Equal("available_registered_map", cited.GetProperty("pageNavigation").GetString());

// docxodus_open mirrors its result as structuredContent for the widget…
var open = Parse(UiResources.WrapToolResult("docxodus_open",
"""{"sessionId":"s1","path":"a.docx"}""", isError: false));
Expand Down Expand Up @@ -1058,6 +1185,9 @@ public void MCP142_UiResources_ServeViewerTemplate()
var htmlText = contents.GetProperty("text").GetString()!;
Assert.StartsWith("<!DOCTYPE html>", htmlText.TrimStart());
Assert.Contains("docxodus_preview", htmlText); // the widget's refresh path
Assert.Contains("unavailable_continuous_preview", htmlText);
Assert.Contains("available_registered_map", htmlText);
Assert.Contains("materializeCitationPage", htmlText);
Assert.True(contents.GetProperty("_meta").TryGetProperty("ui", out _));

Assert.Throws<InvalidParamsException>(() =>
Expand Down
Loading