Why: Agent-Document-Renderer main publishes company.default@1.1.0 with a declarative TOC (spec 04, acceptance 10 §4 §7) and keeps 1.0.0 loadable by version. The fixed defaultProfileVersion "1.0.0" no longer matched LoadCompanyDefaultContext (1.1.0), so every delivery failed with a profile mismatch. New documents must take the latest published version, while documents that already bind a published version keep rendering with exactly that version, because published rules are immutable and old versions remain retrievable (spec 08). Evidence: go vet ./... ok; go test -count=1 ./... ok (all packages); go run ./internal/docgen/internal/genrendereridentity -check ok; scripts/agent-exec validate --repo . ok; scripts/dm governance check --repo . ok; gofmt -l clean; git diff --cached --check clean. New tests cover the default binding (1.1.0, TOC present), pinned 1.0.0 (no TOC) and 1.1.0 for both a request pin and a persisted-Markdown replay, and refusal of unknown versions and other profile IDs before any profile load. Impact: docgen resolves the profile version from the persisted Markdown binding or the request, defaults new documents to profiles.LatestCompanyDefaultVersion, and loads it through LoadCompanyDefaultVersionContext. The legacy migration comparison records the declared TOC as an intentional corporate-layout difference (RDR-T10-CMP-002) and keeps it out of the body visible-text dimension. RendererIdentity is regenerated for 3f259990b5d4. Refs: unified renderer plan Task 13; renderer PR #7 Co-authored-by: Cursor <cursoragent@cursor.com>
1076 lines
42 KiB
Go
1076 lines
42 KiB
Go
package docgen
|
|
|
|
import (
|
|
"archive/zip"
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"encoding/xml"
|
|
"errors"
|
|
"io"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"reflect"
|
|
"strings"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
markdownrenderer "agent-document-renderer/markdown"
|
|
"agent-document-renderer/model"
|
|
"agent-document-renderer/profile"
|
|
"agent-document-renderer/profiles"
|
|
"agent-document-renderer/render"
|
|
)
|
|
|
|
const representativeMarkdown = `# 研发周会纪要
|
|
|
|
这是普通工作区交付物的正文。
|
|
|
|
## 要点
|
|
|
|
- 统一解析入口
|
|
- 保留 Markdown
|
|
|
|
| 项目 | 状态 |
|
|
|---|---|
|
|
| 解析器 | 统一 |
|
|
|
|

|
|
|
|
:::callout warning
|
|
公司默认规则只用于普通工作区交付。
|
|
:::
|
|
|
|
:::page-break
|
|
|
|
分页后的结论。
|
|
`
|
|
|
|
func workspaceRequest(markdown []byte) Request {
|
|
return Request{
|
|
DocumentID: "workspace-minutes",
|
|
Title: "研发周会纪要",
|
|
DocumentType: "general",
|
|
Language: "zh-CN",
|
|
Markdown: markdown,
|
|
Mode: render.ModePreview,
|
|
FrozenAt: time.Date(2026, 10, 15, 12, 0, 0, 0, time.UTC),
|
|
RequestedBy: "alice",
|
|
}
|
|
}
|
|
|
|
func TestGenerateProducesDeterministicHashedDeliveryPackage(t *testing.T) {
|
|
request := workspaceRequest([]byte(representativeMarkdown))
|
|
first, err := Generate(context.Background(), request)
|
|
if err != nil {
|
|
t.Fatalf("first Generate: %v", err)
|
|
}
|
|
second, err := Generate(context.Background(), request)
|
|
if err != nil {
|
|
t.Fatalf("second Generate: %v", err)
|
|
}
|
|
for _, pair := range []struct {
|
|
name string
|
|
a Artifact
|
|
b Artifact
|
|
}{
|
|
{"Markdown", first.Markdown, second.Markdown},
|
|
{"DOCX", first.DOCX, second.DOCX},
|
|
{"manifest", first.Manifest, second.Manifest},
|
|
{"archive", first.Archive, second.Archive},
|
|
} {
|
|
if pair.a.SHA256 == "" || pair.a.SHA256 != pair.b.SHA256 || !bytes.Equal(pair.a.Bytes, pair.b.Bytes) {
|
|
t.Errorf("%s is not byte deterministic: first=%s second=%s", pair.name, pair.a.SHA256, pair.b.SHA256)
|
|
}
|
|
}
|
|
|
|
var manifest Manifest
|
|
if err := json.Unmarshal(first.Manifest.Bytes, &manifest); err != nil {
|
|
t.Fatalf("decode manifest: %v", err)
|
|
}
|
|
bundle, err := profiles.LoadCompanyDefault()
|
|
if err != nil {
|
|
t.Fatalf("load renderer company default: %v", err)
|
|
}
|
|
if manifest.SchemaVersion != ManifestSchemaVersion || manifest.RendererIdentity != RendererIdentity ||
|
|
manifest.SelectionSource != "organization-default" || manifest.Profile.Candidate ||
|
|
manifest.Profile.ReviewStatus != bundle.Review.Status || manifest.Profile.PublicationAllowed != bundle.Review.PublicationAllowed {
|
|
t.Fatalf("manifest profile provenance = %#v, renderer review = %#v", manifest, bundle.Review)
|
|
}
|
|
if manifest.Profile.ReviewStatus != "approved" || !manifest.Profile.PublicationAllowed {
|
|
t.Fatalf("published organization default reported review=%q publicationAllowed=%v", manifest.Profile.ReviewStatus, manifest.Profile.PublicationAllowed)
|
|
}
|
|
if manifest.Profile.ProfileSHA256 == "" || manifest.Profile.AssetManifestSHA256 == "" || manifest.Profile.ReviewSHA256 == "" {
|
|
t.Fatalf("manifest omitted source hashes: %#v", manifest.Profile)
|
|
}
|
|
listed := map[string]string{}
|
|
for _, item := range manifest.Artifacts {
|
|
listed[item.Name] = item.SHA256
|
|
}
|
|
if listed[first.Markdown.Name] != first.Markdown.SHA256 || listed[first.DOCX.Name] != first.DOCX.SHA256 {
|
|
t.Fatalf("manifest artifact hashes = %#v, want Markdown=%s DOCX=%s", listed, first.Markdown.SHA256, first.DOCX.SHA256)
|
|
}
|
|
if manifest.Renderer.ArtifactHash != first.DOCX.SHA256 || manifest.Renderer.FrozenAt != request.FrozenAt || manifest.Renderer.RequestedBy != request.RequestedBy {
|
|
t.Fatalf("renderer receipt does not match frozen request: %#v", manifest.Renderer)
|
|
}
|
|
|
|
zr, err := zip.NewReader(bytes.NewReader(first.Archive.Bytes), int64(len(first.Archive.Bytes)))
|
|
if err != nil {
|
|
t.Fatalf("open delivery archive: %v", err)
|
|
}
|
|
archiveFiles := map[string][]byte{}
|
|
for _, file := range zr.File {
|
|
stream, err := file.Open()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
raw, err := io.ReadAll(stream)
|
|
stream.Close()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
archiveFiles[file.Name] = raw
|
|
}
|
|
for _, item := range []Artifact{first.Markdown, first.DOCX, first.Manifest} {
|
|
if !bytes.Equal(archiveFiles[item.Name], item.Bytes) {
|
|
t.Errorf("archive %s bytes do not match delivered artifact", item.Name)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestGeneratePersistsCanonicalMarkdownWithStableNodeParity(t *testing.T) {
|
|
delivery, err := Generate(context.Background(), workspaceRequest([]byte(representativeMarkdown)))
|
|
if err != nil {
|
|
t.Fatalf("Generate: %v", err)
|
|
}
|
|
|
|
reparsed, err := markdownrenderer.Parse(delivery.Markdown.Bytes, markdownrenderer.Options{
|
|
FirstImport: false,
|
|
MaxBytes: 2 << 20,
|
|
MaxNodes: 10_000,
|
|
MaxDepth: 64,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("persisted Markdown is not independently parseable: %v", err)
|
|
}
|
|
if !reflect.DeepEqual(reparsed.Document, delivery.Document) {
|
|
t.Fatalf("canonical nodes changed across the persisted Markdown entry\nfirst: %#v\nsecond: %#v", delivery.Document, reparsed.Document)
|
|
}
|
|
if reparsed.Metadata.DocumentID != "workspace-minutes" || reparsed.Metadata.ProjectID != WorkspaceProjectID ||
|
|
reparsed.Metadata.DocumentType != "general" || reparsed.Metadata.Title != "研发周会纪要" ||
|
|
reparsed.Metadata.Language != "zh-CN" || reparsed.Metadata.ProfileID != "company.default" ||
|
|
reparsed.Metadata.ProfileVersion != profiles.LatestCompanyDefaultVersion || reparsed.Metadata.Controlled {
|
|
t.Fatalf("controlled metadata changed: %#v", reparsed.Metadata)
|
|
}
|
|
|
|
wantKinds := map[model.NodeKind]int{
|
|
model.NodeHeading: 2,
|
|
model.NodeParagraph: 2,
|
|
model.NodeList: 1,
|
|
model.NodeTable: 1,
|
|
model.NodeImage: 1,
|
|
model.NodeCallout: 1,
|
|
model.NodePageBreak: 1,
|
|
}
|
|
gotKinds := map[model.NodeKind]int{}
|
|
seenIDs := map[string]bool{}
|
|
var visit func(model.Node)
|
|
visit = func(node model.Node) {
|
|
if node.ID == "" || seenIDs[node.ID] {
|
|
t.Fatalf("node ID is missing or unstable: %q", node.ID)
|
|
}
|
|
seenIDs[node.ID] = true
|
|
gotKinds[node.Kind]++
|
|
for _, child := range node.Children {
|
|
visit(child)
|
|
}
|
|
}
|
|
visit(delivery.Document.Root)
|
|
for kind, want := range wantKinds {
|
|
if got := gotKinds[kind]; got != want {
|
|
t.Errorf("%s nodes = %d, want %d", kind, got, want)
|
|
}
|
|
}
|
|
if !strings.Contains(string(delivery.Markdown.Bytes), "<!-- node-id:") {
|
|
t.Fatal("persisted Markdown omitted stable node markers")
|
|
}
|
|
}
|
|
|
|
func TestGeneratePreservesCompleteLegacyMarkdownContract(t *testing.T) {
|
|
request := workspaceRequest([]byte(sampleMinutes))
|
|
delivery, err := Generate(context.Background(), request)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
replayed, err := markdownrenderer.Parse(delivery.Markdown.Bytes, markdownrenderer.Options{
|
|
MaxBytes: 2 << 20, MaxNodes: 10_000, MaxDepth: 64,
|
|
})
|
|
if err != nil || !reflect.DeepEqual(delivery.Document, replayed.Document) {
|
|
t.Fatalf("complete legacy fixture did not replay canonically: err=%v", err)
|
|
}
|
|
|
|
wantKinds := []model.NodeKind{
|
|
model.NodeHeading, model.NodeHeading, model.NodeParagraph,
|
|
model.NodeHeading, model.NodeList, model.NodeQuote,
|
|
model.NodeCodeBlock, model.NodeCodeBlock, model.NodeHeading,
|
|
model.NodeList, model.NodeThematicBreak, model.NodeTable,
|
|
}
|
|
gotKinds := make([]model.NodeKind, len(delivery.Document.Root.Children))
|
|
for index := range delivery.Document.Root.Children {
|
|
gotKinds[index] = delivery.Document.Root.Children[index].Kind
|
|
}
|
|
if !reflect.DeepEqual(gotKinds, wantKinds) {
|
|
t.Fatalf("canonical block kinds = %#v, want %#v", gotKinds, wantKinds)
|
|
}
|
|
|
|
var quote *model.Node
|
|
var codes []*model.Node
|
|
var hasStrong, hasEmphasis, hasStrike, hasInlineCode, hasLink bool
|
|
var walkInline func([]model.Inline)
|
|
walkInline = func(inlines []model.Inline) {
|
|
for index := range inlines {
|
|
switch inlines[index].Kind {
|
|
case model.InlineStrong:
|
|
hasStrong = true
|
|
case model.InlineEmphasis:
|
|
hasEmphasis = true
|
|
case model.InlineStrike:
|
|
hasStrike = true
|
|
case model.InlineCode:
|
|
hasInlineCode = true
|
|
case model.InlineLink:
|
|
hasLink = true
|
|
}
|
|
walkInline(inlines[index].Children)
|
|
}
|
|
}
|
|
var walk func(*model.Node)
|
|
walk = func(node *model.Node) {
|
|
if node.Kind == model.NodeQuote {
|
|
quote = node
|
|
}
|
|
if node.Kind == model.NodeCodeBlock {
|
|
codes = append(codes, node)
|
|
}
|
|
walkInline(node.Inlines)
|
|
for index := range node.Children {
|
|
walk(&node.Children[index])
|
|
}
|
|
}
|
|
walk(&delivery.Document.Root)
|
|
if quote == nil || len(codes) != 2 || !hasStrong || !hasEmphasis || !hasStrike || !hasInlineCode || !hasLink {
|
|
t.Fatalf("semantic canonical document incomplete: quote=%v codes=%d strong=%v emphasis=%v strike=%v inlineCode=%v link=%v", quote != nil, len(codes), hasStrong, hasEmphasis, hasStrike, hasInlineCode, hasLink)
|
|
}
|
|
if codes[0].Text != "fmt.Println(\"backtick\")\n" || codes[1].Text != "fmt.Println(\"tilde\")\n" {
|
|
t.Fatalf("fenced code text = %q / %q", codes[0].Text, codes[1].Text)
|
|
}
|
|
|
|
unordered := delivery.Document.Root.Children[4]
|
|
orderedChild := unordered.Children[1].Children[0]
|
|
deepBullet := orderedChild.Children[0].Children[0]
|
|
if nodePropertyValue(unordered, "ordered") != "false" ||
|
|
nodePropertyValue(orderedChild, "ordered") != "true" ||
|
|
nodePropertyValue(deepBullet, "ordered") != "false" {
|
|
t.Fatalf("nested list semantics = %#v / %#v / %#v", unordered.Properties, orderedChild.Properties, deepBullet.Properties)
|
|
}
|
|
if nodePropertyValue(unordered.Children[2], "checked") != "true" || nodePropertyValue(unordered.Children[3], "checked") != "false" {
|
|
t.Fatalf("task states = %#v / %#v", unordered.Children[2].Properties, unordered.Children[3].Properties)
|
|
}
|
|
ordered := delivery.Document.Root.Children[9]
|
|
if nodePropertyValue(ordered, "ordered") != "true" || nodePropertyValue(ordered, "start") != "3" {
|
|
t.Fatalf("ordered-list start = %#v, want 3", ordered.Properties)
|
|
}
|
|
table := delivery.Document.Root.Children[11]
|
|
wantAlignments := []string{"left", "center", "right", ""}
|
|
for rowIndex, row := range table.Children {
|
|
for cellIndex, cell := range row.Children {
|
|
if got := nodePropertyValue(cell, "alignment"); got != wantAlignments[cellIndex] {
|
|
t.Fatalf("row %d cell %d alignment = %q, want %q", rowIndex, cellIndex, got, wantAlignments[cellIndex])
|
|
}
|
|
}
|
|
}
|
|
|
|
documentXML := docxPart(t, delivery.DOCX.Bytes, "word/document.xml")
|
|
for _, fragment := range []string{
|
|
`<w:b/>`, `<w:i/>`, `<w:strike/>`, `w:val="inlineCode"`, `w:val="link"`,
|
|
`w:val="quote"`, `w:val="code"`, `<w:numPr>`, `<w:tbl>`,
|
|
`<w:t xml:space="preserve">☑ </w:t>`, `<w:t xml:space="preserve">☐ </w:t>`,
|
|
`<w:pBdr><w:bottom w:val="single"`, `<w:jc w:val="left"/>`,
|
|
`<w:jc w:val="center"/>`, `<w:jc w:val="right"/>`,
|
|
} {
|
|
if !strings.Contains(documentXML, fragment) {
|
|
t.Errorf("Word output missing %s", fragment)
|
|
}
|
|
}
|
|
numberingXML := docxPart(t, delivery.DOCX.Bytes, "word/numbering.xml")
|
|
for _, fragment := range []string{
|
|
`<w:ilvl w:val="0"/>`, `<w:ilvl w:val="1"/>`, `<w:ilvl w:val="2"/>`,
|
|
`<w:lvlOverride w:ilvl="0"><w:startOverride w:val="3"/></w:lvlOverride>`,
|
|
} {
|
|
if !strings.Contains(documentXML+numberingXML, fragment) {
|
|
t.Errorf("Word numbering missing %s", fragment)
|
|
}
|
|
}
|
|
if bytes.Contains(delivery.DOCX.Bytes, []byte("https://example.com/private-target")) || strings.Contains(documentXML, "TargetMode=\"External\"") {
|
|
t.Fatal("safe link target leaked into DOCX package")
|
|
}
|
|
legacy, err := legacyBuild(request.Title, sampleMinutes)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
receipt := compareLegacyAndShared(request.Title, []byte(sampleMinutes), delivery.Document, legacy, delivery.DOCX.Bytes)
|
|
for _, name := range []string{"list-structure", "task-state", "thematic-break", "table-alignment"} {
|
|
if dimension := comparisonDimension(t, receipt, name); dimension.Status != "match" {
|
|
t.Errorf("complete fixture comparison dimension %s = %#v, want match", name, dimension)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestMaterializedMarkdownMustFitAndReplayAtOneBudget(t *testing.T) {
|
|
request := workspaceRequest([]byte("# T\n\nsmall body\n"))
|
|
request.Title = "T"
|
|
request.Limits.MaxMarkdownBytes = 256
|
|
if delivery, err := Generate(context.Background(), request); !errors.Is(err, ErrResourceLimit) {
|
|
t.Fatalf("materialized overflow returned delivery=%d bytes err=%v, want ErrResourceLimit", len(delivery.Markdown.Bytes), err)
|
|
}
|
|
|
|
request.Limits.MaxMarkdownBytes = 0
|
|
first, err := Generate(context.Background(), request)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
request.Markdown = first.Markdown.Bytes
|
|
request.Limits.MaxMarkdownBytes = len(first.Markdown.Bytes)
|
|
second, err := Generate(context.Background(), request)
|
|
if err != nil || !bytes.Equal(first.Markdown.Bytes, second.Markdown.Bytes) {
|
|
t.Fatalf("exact-budget replay failed: err=%v first=%d second=%d", err, len(first.Markdown.Bytes), len(second.Markdown.Bytes))
|
|
}
|
|
}
|
|
|
|
func TestPersistedControlledMetadataRoutesBeforeDefaultProfileLoad(t *testing.T) {
|
|
source := []byte("---\ndocumentId: controlled\nprojectId: workspace\ndocumentType: general\ntitle: Controlled\nlanguage: zh-CN\nprofileId: company.default\nprofileVersion: 1.0.0\ncontrolled: true\n---\n\n<!-- node-id:heading -->\n# Controlled\n")
|
|
request := workspaceRequest(source)
|
|
request.DocumentID = "controlled"
|
|
request.Title = "Controlled"
|
|
loads := 0
|
|
deps := defaultGeneratorDependencies()
|
|
baseLoad := deps.loadProfile
|
|
deps.loadProfile = func(ctx context.Context, version string) (profiles.Bundle, error) {
|
|
loads++
|
|
return baseLoad(ctx, version)
|
|
}
|
|
_, err := generate(context.Background(), request, deps)
|
|
if !errors.Is(err, ErrRDMRequired) || loads != 0 {
|
|
t.Fatalf("persisted controlled route err=%v profileLoads=%d, want ErrRDMRequired before load", err, loads)
|
|
}
|
|
}
|
|
|
|
func TestRendererIdentityMatchesPublishedPin(t *testing.T) {
|
|
const want = "agent-document-renderer@3f259990b5d43ec0d318f39d496816140ab559b5"
|
|
if RendererIdentity != want {
|
|
t.Fatalf("RendererIdentity = %q, want %q", RendererIdentity, want)
|
|
}
|
|
}
|
|
|
|
func TestRendererIdentityIsGeneratedFromCheckedOutComponent(t *testing.T) {
|
|
repository, err := filepath.Abs(filepath.Join("..", ".."))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
command := exec.Command("go", "run", "./internal/docgen/internal/genrendereridentity", "-check")
|
|
command.Dir = repository
|
|
if output, err := command.CombinedOutput(); err != nil {
|
|
t.Fatalf("renderer identity generation check: %v\n%s", err, output)
|
|
}
|
|
}
|
|
|
|
func TestGenerateReusesPersistedMarkdownWithoutChangingNodeIdentity(t *testing.T) {
|
|
request := workspaceRequest([]byte(representativeMarkdown))
|
|
first, err := Generate(context.Background(), request)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
request.Markdown = first.Markdown.Bytes
|
|
second, err := Generate(context.Background(), request)
|
|
if err != nil {
|
|
t.Fatalf("Generate persisted Markdown: %v", err)
|
|
}
|
|
if !reflect.DeepEqual(first.Document, second.Document) {
|
|
t.Fatalf("canonical node identities changed\nfirst: %#v\nsecond: %#v", first.Document, second.Document)
|
|
}
|
|
if !bytes.Equal(first.Markdown.Bytes, second.Markdown.Bytes) || !bytes.Equal(first.DOCX.Bytes, second.DOCX.Bytes) {
|
|
t.Fatal("identical persisted Markdown and frozen inputs changed delivery bytes")
|
|
}
|
|
}
|
|
|
|
func TestGenerateRejectsUnknownPersistedMetadata(t *testing.T) {
|
|
request := workspaceRequest([]byte(representativeMarkdown))
|
|
first, err := Generate(context.Background(), request)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
request.Markdown = bytes.Replace(first.Markdown.Bytes, []byte("controlled: false\n"), []byte("controlled: false\nscript: build.sh\n"), 1)
|
|
_, err = Generate(context.Background(), request)
|
|
var diagnostic *DiagnosticError
|
|
if !errors.As(err, &diagnostic) || diagnostic.Stage != "parse" || diagnostic.Code != "unknown_metadata" {
|
|
t.Fatalf("unknown metadata error = %#v (%v)", diagnostic, err)
|
|
}
|
|
}
|
|
|
|
func TestGenerateRejectsControlledAndProjectRoutesBeforeDefaultFallback(t *testing.T) {
|
|
for _, mutate := range []func(*Request){
|
|
func(request *Request) { request.Controlled = true },
|
|
func(request *Request) { request.ProjectDocument = true },
|
|
} {
|
|
request := workspaceRequest([]byte(representativeMarkdown))
|
|
mutate(&request)
|
|
if _, err := Generate(context.Background(), request); !errors.Is(err, ErrRDMRequired) {
|
|
t.Fatalf("Generate route error = %v, want ErrRDMRequired", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestGenerateRoutesReviewAndApprovalModesToRDM(t *testing.T) {
|
|
for _, mode := range []render.Mode{render.ModeReview, render.ModeApproved} {
|
|
request := workspaceRequest([]byte(representativeMarkdown))
|
|
request.Mode = mode
|
|
if _, err := Generate(context.Background(), request); !errors.Is(err, ErrRDMRequired) {
|
|
t.Errorf("mode %s error = %v, want ErrRDMRequired", mode, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestGenerateRejectsProfileAndAssetMismatch(t *testing.T) {
|
|
request := workspaceRequest([]byte(representativeMarkdown))
|
|
request.ProfileID = "company.other"
|
|
request.ProfileVersion = "9.9.9"
|
|
if _, err := Generate(context.Background(), request); !errors.Is(err, ErrProfileMismatch) {
|
|
t.Fatalf("profile mismatch error = %v, want ErrProfileMismatch", err)
|
|
}
|
|
|
|
request = workspaceRequest([]byte(strings.ReplaceAll(representativeMarkdown,
|
|
"asset:company.default.sample-image@1.0.0", "asset:undeclared.image@1.0.0")))
|
|
if _, err := Generate(context.Background(), request); err == nil || !strings.Contains(err.Error(), "undeclared asset") {
|
|
t.Fatalf("asset mismatch error = %v, want an honest undeclared-asset diagnostic", err)
|
|
}
|
|
}
|
|
|
|
const tocFieldInstruction = `TOC \o "1-`
|
|
|
|
func TestGenerateBindsNewDocumentsToLatestPublishedProfile(t *testing.T) {
|
|
request := workspaceRequest([]byte(representativeMarkdown))
|
|
prepared, err := Prepare(context.Background(), request)
|
|
if err != nil {
|
|
t.Fatalf("Prepare: %v", err)
|
|
}
|
|
delivery, err := Generate(context.Background(), request)
|
|
if err != nil {
|
|
t.Fatalf("Generate: %v", err)
|
|
}
|
|
latest, err := profiles.LoadCompanyDefault()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, version := latest.Snapshot.ProfileIdentity(); version != profiles.LatestCompanyDefaultVersion {
|
|
t.Fatalf("renderer latest company default = %s, want %s", version, profiles.LatestCompanyDefaultVersion)
|
|
}
|
|
for name, markdown := range map[string][]byte{"Prepare": prepared.Markdown.Bytes, "Generate": delivery.Markdown.Bytes} {
|
|
metadata := persistedMetadata(t, markdown)
|
|
if metadata.ProfileID != "company.default" || metadata.ProfileVersion != profiles.LatestCompanyDefaultVersion {
|
|
t.Fatalf("%s bound new document to %s@%s, want company.default@%s", name, metadata.ProfileID, metadata.ProfileVersion, profiles.LatestCompanyDefaultVersion)
|
|
}
|
|
}
|
|
if delivery.Profile.Version != profiles.LatestCompanyDefaultVersion || delivery.Profile.SnapshotHash != latest.Snapshot.SnapshotHash() {
|
|
t.Fatalf("delivery profile receipt = %#v, want latest snapshot %s", delivery.Profile, latest.Snapshot.SnapshotHash())
|
|
}
|
|
documentXML := docxPart(t, delivery.DOCX.Bytes, "word/document.xml")
|
|
if !strings.Contains(documentXML, tocFieldInstruction) || !strings.Contains(docxVisibleText(t, delivery.DOCX.Bytes), "目录") {
|
|
t.Fatal("new document rendered with the latest company default omitted its declared TOC")
|
|
}
|
|
}
|
|
|
|
func TestGenerateKeepsPinnedPublishedProfileVersion(t *testing.T) {
|
|
for _, test := range []struct {
|
|
version string
|
|
toc bool
|
|
}{
|
|
{version: "1.0.0", toc: false},
|
|
{version: "1.1.0", toc: true},
|
|
} {
|
|
t.Run(test.version, func(t *testing.T) {
|
|
pinned, err := profiles.LoadCompanyDefaultVersion(test.version)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
request := workspaceRequest([]byte(representativeMarkdown))
|
|
request.ProfileID, request.ProfileVersion = "company.default", test.version
|
|
prepared, err := Prepare(context.Background(), request)
|
|
if err != nil {
|
|
t.Fatalf("Prepare pinned %s: %v", test.version, err)
|
|
}
|
|
if metadata := persistedMetadata(t, prepared.Markdown.Bytes); metadata.ProfileVersion != test.version {
|
|
t.Fatalf("Prepare bound pinned request to %s, want %s", metadata.ProfileVersion, test.version)
|
|
}
|
|
first, err := Generate(context.Background(), request)
|
|
if err != nil {
|
|
t.Fatalf("Generate pinned %s: %v", test.version, err)
|
|
}
|
|
|
|
replay := workspaceRequest(first.Markdown.Bytes)
|
|
second, err := Generate(context.Background(), replay)
|
|
if err != nil {
|
|
t.Fatalf("Generate persisted %s Markdown: %v", test.version, err)
|
|
}
|
|
for name, delivery := range map[string]Delivery{"pinned request": first, "persisted replay": second} {
|
|
if delivery.Profile.ProfileID != "company.default" || delivery.Profile.Version != test.version ||
|
|
delivery.Profile.SnapshotHash != pinned.Snapshot.SnapshotHash() || delivery.Profile.ProfileSHA256 != pinned.Review.ProfileSHA256 {
|
|
t.Fatalf("%s profile receipt = %#v, want company.default@%s snapshot %s", name, delivery.Profile, test.version, pinned.Snapshot.SnapshotHash())
|
|
}
|
|
if metadata := persistedMetadata(t, delivery.Markdown.Bytes); metadata.ProfileVersion != test.version {
|
|
t.Fatalf("%s Markdown binds %s, want %s", name, metadata.ProfileVersion, test.version)
|
|
}
|
|
if got := strings.Contains(docxPart(t, delivery.DOCX.Bytes, "word/document.xml"), tocFieldInstruction); got != test.toc {
|
|
t.Fatalf("%s TOC present = %v, want %v for company.default@%s", name, got, test.toc, test.version)
|
|
}
|
|
}
|
|
if !bytes.Equal(first.DOCX.Bytes, second.DOCX.Bytes) {
|
|
t.Fatal("replaying persisted Markdown changed the pinned delivery bytes")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestProfileBindingRefusesUnpublishedVersionsBeforeLoad(t *testing.T) {
|
|
persisted, err := Generate(context.Background(), workspaceRequest([]byte(representativeMarkdown)))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
latestBinding := []byte("profileVersion: \"" + profiles.LatestCompanyDefaultVersion + "\"\n")
|
|
if !bytes.Contains(persisted.Markdown.Bytes, latestBinding) {
|
|
t.Fatalf("persisted Markdown does not carry %q", latestBinding)
|
|
}
|
|
pinnedOld := workspaceRequest(bytes.Replace(persisted.Markdown.Bytes, latestBinding, []byte("profileVersion: \"1.0.0\"\n"), 1))
|
|
pinnedOld.ProfileID, pinnedOld.ProfileVersion = "company.default", profiles.LatestCompanyDefaultVersion
|
|
|
|
tests := map[string]Request{}
|
|
for name, identity := range map[string][2]string{
|
|
"unknown version": {"company.default", "9.9.9"},
|
|
"other profile": {"company.other", "1.0.0"},
|
|
"missing version": {"company.default", ""},
|
|
"missing profile id": {"", "1.0.0"},
|
|
} {
|
|
request := workspaceRequest([]byte(representativeMarkdown))
|
|
request.ProfileID, request.ProfileVersion = identity[0], identity[1]
|
|
tests["requested "+name] = request
|
|
}
|
|
tests["persisted unknown version"] = workspaceRequest(bytes.Replace(persisted.Markdown.Bytes, latestBinding, []byte("profileVersion: \"9.9.9\"\n"), 1))
|
|
tests["persisted other profile"] = workspaceRequest(bytes.Replace(persisted.Markdown.Bytes,
|
|
[]byte("profileId: \"company.default\"\n"), []byte("profileId: \"company.other\"\n"), 1))
|
|
tests["request disagrees with persisted binding"] = pinnedOld
|
|
|
|
for name, request := range tests {
|
|
t.Run(name, func(t *testing.T) {
|
|
if _, err := Prepare(context.Background(), request); !errors.Is(err, ErrProfileMismatch) {
|
|
t.Fatalf("Prepare error = %v, want ErrProfileMismatch", err)
|
|
}
|
|
loads := 0
|
|
deps := defaultGeneratorDependencies()
|
|
baseLoad := deps.loadProfile
|
|
deps.loadProfile = func(ctx context.Context, version string) (profiles.Bundle, error) {
|
|
loads++
|
|
return baseLoad(ctx, version)
|
|
}
|
|
if _, err := generate(context.Background(), request, deps); !errors.Is(err, ErrProfileMismatch) || loads != 0 {
|
|
t.Fatalf("Generate error = %v profileLoads=%d, want ErrProfileMismatch before load", err, loads)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestGenerateRefusesLoadedProfileWithUnexpectedIdentity(t *testing.T) {
|
|
deps := defaultGeneratorDependencies()
|
|
deps.loadProfile = func(ctx context.Context, _ string) (profiles.Bundle, error) {
|
|
return profiles.LoadCompanyDefaultVersionContext(ctx, "1.0.0")
|
|
}
|
|
if _, err := generate(context.Background(), workspaceRequest([]byte(representativeMarkdown)), deps); !errors.Is(err, ErrProfileMismatch) {
|
|
t.Fatalf("Generate with a loader returning the wrong version error = %v, want ErrProfileMismatch", err)
|
|
}
|
|
}
|
|
|
|
func persistedMetadata(t *testing.T, markdown []byte) markdownrenderer.Metadata {
|
|
t.Helper()
|
|
parsed, err := markdownrenderer.Parse(markdown, markdownrenderer.Options{MaxBytes: 2 << 20, MaxNodes: 10_000, MaxDepth: 64})
|
|
if err != nil {
|
|
t.Fatalf("persisted Markdown is not parseable: %v", err)
|
|
}
|
|
return parsed.Metadata
|
|
}
|
|
|
|
func TestGenerateEnforcesLimitsAndCancellation(t *testing.T) {
|
|
request := workspaceRequest([]byte(representativeMarkdown))
|
|
request.Limits.MaxMarkdownBytes = 32
|
|
if _, err := Generate(context.Background(), request); !errors.Is(err, ErrResourceLimit) {
|
|
t.Fatalf("limit error = %v, want ErrResourceLimit", err)
|
|
}
|
|
|
|
canceled, cancel := context.WithCancel(context.Background())
|
|
cancel()
|
|
if _, err := Generate(canceled, workspaceRequest([]byte(representativeMarkdown))); !errors.Is(err, context.Canceled) {
|
|
t.Fatalf("cancellation error = %v, want context.Canceled", err)
|
|
}
|
|
}
|
|
|
|
func TestGenerateReturnsTypedDiagnosticsForUnsafeMarkdownAndAssets(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
markdown string
|
|
stage string
|
|
code string
|
|
}{
|
|
{"script", "# Report\n\n<script>alert(1)</script>\n", "parse", "unsafe_markdown"},
|
|
{"external asset", "# Report\n\n\n", "parse", "unsafe_markdown"},
|
|
{"unknown directive", "# Report\n\n:::execute script=build.sh\n", "parse", "unknown_directive"},
|
|
{"undeclared asset", "# Report\n\n\n", "render", "asset_mismatch"},
|
|
}
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
delivery, err := Generate(context.Background(), workspaceRequest([]byte(test.markdown)))
|
|
if err == nil {
|
|
t.Fatal("Generate succeeded for unsafe input")
|
|
}
|
|
var diagnostic *DiagnosticError
|
|
if !errors.As(err, &diagnostic) {
|
|
t.Fatalf("error %T %v is not a DiagnosticError", err, err)
|
|
}
|
|
if diagnostic.Stage != test.stage || diagnostic.Code != test.code || strings.TrimSpace(diagnostic.Error()) == "" {
|
|
t.Fatalf("diagnostic = %#v, want stage=%s code=%s", diagnostic, test.stage, test.code)
|
|
}
|
|
if len(delivery.Markdown.Bytes)+len(delivery.DOCX.Bytes)+len(delivery.Manifest.Bytes)+len(delivery.Archive.Bytes) != 0 {
|
|
t.Fatal("failed render returned partial delivery bytes")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestRendererNodeDiagnosticsPassThroughUnchanged pins the wire contract for
|
|
// node-located content diagnostics: the renderer's codes, severities, NodeID
|
|
// and RulePath reach the manifest and the typed failure without rewriting.
|
|
func TestRendererNodeDiagnosticsPassThroughUnchanged(t *testing.T) {
|
|
warnings := []render.Diagnostic{
|
|
{Code: "required_content_missing", Severity: render.SeverityWarning, NodeID: "node-7", RulePath: "mappings[2]", Message: "required node node-7 is empty", Suggestion: "add content"},
|
|
{Code: "intentionally_empty", Severity: render.SeverityInfo, NodeID: "node-8", RulePath: "mappings[2]", Message: "node node-8 is intentionally empty"},
|
|
{Code: "mapping_fallback", Severity: render.SeverityWarning, NodeID: "node-9", Message: "style not defined", Suggestion: "define the style"},
|
|
}
|
|
deps := defaultGeneratorDependencies()
|
|
deps.newRenderer = func(*profile.AssetReader) render.Renderer {
|
|
return diagnosticsRenderer{diagnostics: warnings}
|
|
}
|
|
delivery, err := generate(context.Background(), workspaceRequest([]byte(representativeMarkdown)), deps)
|
|
if err != nil {
|
|
t.Fatalf("generate with warnings: %v", err)
|
|
}
|
|
var manifest Manifest
|
|
if err := json.Unmarshal(delivery.Manifest.Bytes, &manifest); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !reflect.DeepEqual(manifest.Diagnostics, warnings) {
|
|
t.Fatalf("manifest diagnostics = %#v, want renderer diagnostics %#v", manifest.Diagnostics, warnings)
|
|
}
|
|
for _, fragment := range []string{`"nodeId": "node-7"`, `"rulePath": "mappings[2]"`, `"code": "required_content_missing"`} {
|
|
if !strings.Contains(string(delivery.Manifest.Bytes), fragment) {
|
|
t.Errorf("manifest JSON omits %s", fragment)
|
|
}
|
|
}
|
|
|
|
refusal := []render.Diagnostic{{Code: "mapping_unresolved", Severity: render.SeverityError, NodeID: "node-3", Message: "node node-3 cannot be mapped"}}
|
|
deps.newRenderer = func(*profile.AssetReader) render.Renderer {
|
|
return diagnosticsRenderer{diagnostics: refusal, err: errors.New("word: mapping unresolved for node node-3")}
|
|
}
|
|
_, err = generate(context.Background(), workspaceRequest([]byte(representativeMarkdown)), deps)
|
|
var diagnostic *DiagnosticError
|
|
if !errors.As(err, &diagnostic) {
|
|
t.Fatalf("refusal error %T %v is not a DiagnosticError", err, err)
|
|
}
|
|
if !reflect.DeepEqual(diagnostic.Diagnostics, refusal) {
|
|
t.Fatalf("refusal diagnostics = %#v, want %#v", diagnostic.Diagnostics, refusal)
|
|
}
|
|
}
|
|
|
|
// diagnosticsRenderer returns fixed renderer diagnostics without a template, so
|
|
// the Backend's carriage of the renderer's diagnostics is tested on its own.
|
|
type diagnosticsRenderer struct {
|
|
diagnostics []render.Diagnostic
|
|
err error
|
|
}
|
|
|
|
func (renderer diagnosticsRenderer) Validate(context.Context, render.Request) ([]render.Diagnostic, error) {
|
|
return renderer.diagnostics, renderer.err
|
|
}
|
|
|
|
func (renderer diagnosticsRenderer) Render(context.Context, render.Request) (render.Result, error) {
|
|
if renderer.err != nil {
|
|
return render.Result{Diagnostics: renderer.diagnostics}, renderer.err
|
|
}
|
|
return render.Result{
|
|
Artifact: []byte("docx"), MIME: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
Diagnostics: renderer.diagnostics,
|
|
}, nil
|
|
}
|
|
|
|
type failingRenderer struct{ err error }
|
|
|
|
func (renderer failingRenderer) Validate(context.Context, render.Request) ([]render.Diagnostic, error) {
|
|
return []render.Diagnostic{{Code: "forced_failure", Severity: render.SeverityError, Message: renderer.err.Error()}}, renderer.err
|
|
}
|
|
|
|
func (renderer failingRenderer) Render(context.Context, render.Request) (render.Result, error) {
|
|
return render.Result{Diagnostics: []render.Diagnostic{{Code: "forced_failure", Severity: render.SeverityError, Message: renderer.err.Error()}}}, renderer.err
|
|
}
|
|
|
|
type cleanupRenderer struct {
|
|
active *atomic.Int32
|
|
started chan struct{}
|
|
canceled chan struct{}
|
|
release chan struct{}
|
|
}
|
|
|
|
func (renderer cleanupRenderer) Validate(context.Context, render.Request) ([]render.Diagnostic, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func (renderer cleanupRenderer) Render(ctx context.Context, _ render.Request) (render.Result, error) {
|
|
renderer.active.Add(1)
|
|
defer renderer.active.Add(-1)
|
|
close(renderer.started)
|
|
<-ctx.Done()
|
|
close(renderer.canceled)
|
|
<-renderer.release
|
|
return render.Result{}, ctx.Err()
|
|
}
|
|
|
|
func TestCancellationWaitsForRendererUnwindAndRecoversCapacity(t *testing.T) {
|
|
var active atomic.Int32
|
|
started := make(chan struct{})
|
|
canceled := make(chan struct{})
|
|
release := make(chan struct{})
|
|
deps := defaultGeneratorDependencies()
|
|
deps.newRenderer = func(*profile.AssetReader) render.Renderer {
|
|
return cleanupRenderer{active: &active, started: started, canceled: canceled, release: release}
|
|
}
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
result := make(chan error, 1)
|
|
go func() {
|
|
_, err := generate(ctx, workspaceRequest([]byte(representativeMarkdown)), deps)
|
|
result <- err
|
|
}()
|
|
<-started
|
|
cancel()
|
|
<-canceled
|
|
select {
|
|
case err := <-result:
|
|
t.Fatalf("Generate returned before renderer cleanup: %v", err)
|
|
default:
|
|
}
|
|
close(release)
|
|
select {
|
|
case err := <-result:
|
|
if !errors.Is(err, context.Canceled) || active.Load() != 0 {
|
|
t.Fatalf("cancellation err=%v active=%d, want canceled and zero capacity", err, active.Load())
|
|
}
|
|
case <-time.After(time.Second):
|
|
t.Fatal("Generate did not return after renderer cleanup")
|
|
}
|
|
}
|
|
|
|
func TestRendererFailureNeverFallsBackToLegacy(t *testing.T) {
|
|
forced := errors.New("forced renderer failure")
|
|
legacyCalls := 0
|
|
deps := defaultGeneratorDependencies()
|
|
deps.newRenderer = func(*profile.AssetReader) render.Renderer { return failingRenderer{err: forced} }
|
|
deps.legacyBuild = func(string, string) ([]byte, error) {
|
|
legacyCalls++
|
|
return []byte("legacy"), nil
|
|
}
|
|
request := workspaceRequest([]byte(representativeMarkdown))
|
|
if _, err := generateWithOptions(context.Background(), request, RenderOptions{MigrationComparison: true}, deps); !errors.Is(err, forced) {
|
|
t.Fatalf("generate error = %v, want forced renderer failure", err)
|
|
}
|
|
if legacyCalls != 0 {
|
|
t.Fatalf("legacy renderer called %d times after shared renderer failure", legacyCalls)
|
|
}
|
|
}
|
|
|
|
func TestLegacyComparisonIsExplicitAndDoesNotReplaceSharedOutput(t *testing.T) {
|
|
legacyCalls := 0
|
|
deps := defaultGeneratorDependencies()
|
|
deps.legacyBuild = func(string, string) ([]byte, error) {
|
|
legacyCalls++
|
|
return []byte("legacy-comparison-only"), nil
|
|
}
|
|
request := workspaceRequest([]byte(representativeMarkdown))
|
|
withoutFlag, err := generate(context.Background(), request, deps)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if legacyCalls != 0 || withoutFlag.Comparison.Enabled {
|
|
t.Fatalf("disabled comparison called legacy renderer: calls=%d receipt=%#v", legacyCalls, withoutFlag.Comparison)
|
|
}
|
|
|
|
withFlag, err := generateWithOptions(context.Background(), request, RenderOptions{MigrationComparison: true}, deps)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if legacyCalls != 1 || !withFlag.Comparison.Enabled || withFlag.Comparison.LegacySHA256 == "" || withFlag.Comparison.Assessment != "unassessed" || len(withFlag.Comparison.Unassessed) == 0 {
|
|
t.Fatalf("enabled comparison receipt=%#v calls=%d", withFlag.Comparison, legacyCalls)
|
|
}
|
|
if !bytes.Equal(withoutFlag.DOCX.Bytes, withFlag.DOCX.Bytes) {
|
|
t.Fatal("legacy comparison replaced the shared-renderer production output")
|
|
}
|
|
}
|
|
|
|
func TestRepresentativeLegacyComparisonRecordsOnlyIntentionalDifferences(t *testing.T) {
|
|
const common = "# 研发周会纪要\n\n正文有 **粗体**、*斜体*、~~删除~~、`代码` 和 [链接](https://example.com/private)。\n\n> 引用内容。\n\n```go\nfmt.Println(1)\n```\n\n## 要点\n\n- [x] 已完成\n- [ ] 待办\n\n1. 第一项\n2. 第二项\n\n---\n\n| 左 | 中 | 右 |\n| :--- | :---: | ---: |\n| L | C | R |\n"
|
|
request := workspaceRequest([]byte(common))
|
|
delivery, err := generateWithOptions(context.Background(), request, RenderOptions{MigrationComparison: true}, defaultGeneratorDependencies())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if delivery.Comparison.Assessment != "assessed" || len(delivery.Comparison.AccidentalDifferences) != 0 || len(delivery.Comparison.Unassessed) != 0 {
|
|
t.Fatalf("accidental differences remain: %#v", delivery.Comparison.AccidentalDifferences)
|
|
}
|
|
exceptions := map[string]bool{}
|
|
for _, dimension := range delivery.Comparison.Dimensions {
|
|
if dimension.Status == "intentional" {
|
|
exceptions[dimension.ExceptionID] = true
|
|
}
|
|
}
|
|
for _, id := range []string{"RDR-T10-CMP-001", "RDR-T10-CMP-002", "RDR-T10-CMP-003", "RDR-T10-CMP-004"} {
|
|
if !exceptions[id] {
|
|
t.Errorf("comparison omitted reviewed exception %s: %#v", id, delivery.Comparison.Dimensions)
|
|
}
|
|
}
|
|
for _, name := range []string{
|
|
"list-structure", "task-state", "thematic-break", "table-alignment",
|
|
"word-numbering", "word-task-state", "word-thematic-rule", "word-table-alignment",
|
|
} {
|
|
dimension := comparisonDimension(t, delivery.Comparison, name)
|
|
if dimension.Status != "match" {
|
|
t.Errorf("comparison dimension %s = %#v, want match", name, dimension)
|
|
}
|
|
}
|
|
for _, dimension := range delivery.Comparison.Dimensions {
|
|
if dimension.ExceptionID == comparisonExceptionCorporate && !strings.Contains(dimension.Detail, "published organization default") {
|
|
t.Errorf("corporate layout exception %s detail = %q, want published organization default", dimension.Name, dimension.Detail)
|
|
}
|
|
}
|
|
if dimension := comparisonDimension(t, delivery.Comparison, "table-of-contents"); dimension.Status != "intentional" || dimension.ExceptionID != comparisonExceptionCorporate {
|
|
t.Errorf("declared TOC comparison = %#v, want intentional %s", dimension, comparisonExceptionCorporate)
|
|
}
|
|
if dimension := comparisonDimension(t, delivery.Comparison, "visible-text"); dimension.Status == "accidental" {
|
|
t.Errorf("declared TOC leaked into the body visible-text comparison: %#v", dimension)
|
|
}
|
|
|
|
request.ProfileID, request.ProfileVersion = "company.default", "1.0.0"
|
|
pinned, err := generateWithOptions(context.Background(), request, RenderOptions{MigrationComparison: true}, defaultGeneratorDependencies())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if pinned.Comparison.Assessment != "assessed" || len(pinned.Comparison.AccidentalDifferences) != 0 {
|
|
t.Fatalf("pinned 1.0.0 comparison has accidental differences: %#v", pinned.Comparison.AccidentalDifferences)
|
|
}
|
|
if dimension := comparisonDimension(t, pinned.Comparison, "table-of-contents"); dimension.Status != "match" {
|
|
t.Errorf("pinned 1.0.0 TOC comparison = %#v, want match without a TOC", dimension)
|
|
}
|
|
}
|
|
|
|
func TestComparisonKeepsBodyTextLossAccidentalBesideDeclaredTOC(t *testing.T) {
|
|
const source = "# Report\n\n## Scope\n\nScope body.\n\n## Result\n\nResult body.\n"
|
|
request := workspaceRequest([]byte(source))
|
|
request.Title = "Report"
|
|
delivery, err := Generate(context.Background(), request)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
legacy, err := legacyBuild(request.Title, "# Report\n\n## Scope\n\nScope body.\n\n## Result\n\nDifferent body.\n")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
receipt := compareLegacyAndShared(request.Title, []byte(source), delivery.Document, legacy, delivery.DOCX.Bytes)
|
|
if dimension := comparisonDimension(t, receipt, "table-of-contents"); dimension.Status != "intentional" {
|
|
t.Fatalf("declared TOC comparison = %#v, want intentional", dimension)
|
|
}
|
|
if dimension := comparisonDimension(t, receipt, "visible-text"); dimension.Status != "accidental" {
|
|
t.Fatalf("body text loss beside a TOC = %#v, want accidental", dimension)
|
|
}
|
|
}
|
|
|
|
func TestComparisonDetectsCanonicalAndDOCXTableAlignmentLoss(t *testing.T) {
|
|
const aligned = "# Alignment\n\n| Left | Center | Right | Default |\n| :--- | :---: | ---: | --- |\n| L | C | R | D |\n"
|
|
request := workspaceRequest([]byte(aligned))
|
|
request.Title = "Alignment"
|
|
delivery, err := Generate(context.Background(), request)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
legacy, err := legacyBuild(request.Title, aligned)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
canonicalWithoutAlignment := delivery.Document.Clone()
|
|
for rowIndex := range canonicalWithoutAlignment.Root.Children[1].Children {
|
|
for cellIndex := range canonicalWithoutAlignment.Root.Children[1].Children[rowIndex].Children {
|
|
canonicalWithoutAlignment.Root.Children[1].Children[rowIndex].Children[cellIndex].Properties = nil
|
|
}
|
|
}
|
|
canonicalLoss := compareLegacyAndShared(request.Title, []byte(aligned), canonicalWithoutAlignment, legacy, delivery.DOCX.Bytes)
|
|
if dimension := comparisonDimension(t, canonicalLoss, "table-alignment"); dimension.Status != "accidental" {
|
|
t.Fatalf("canonical table-alignment loss = %#v, want accidental", dimension)
|
|
}
|
|
|
|
docxWithoutAlignment := rewriteDOCXPart(t, delivery.DOCX.Bytes, "word/document.xml", func(content []byte) []byte {
|
|
for _, value := range []string{"left", "center", "right"} {
|
|
content = bytes.ReplaceAll(content, []byte(`<w:jc w:val="`+value+`"/>`), nil)
|
|
}
|
|
return content
|
|
})
|
|
docxLoss := compareLegacyAndShared(request.Title, []byte(aligned), delivery.Document, legacy, docxWithoutAlignment)
|
|
if docxLoss.Assessment != "differences" {
|
|
t.Fatalf("DOCX alignment loss assessment = %q, want differences", docxLoss.Assessment)
|
|
}
|
|
if dimension := comparisonDimension(t, docxLoss, "word-table-alignment"); dimension.Status != "accidental" {
|
|
t.Fatalf("DOCX table-alignment loss = %#v, want accidental", dimension)
|
|
}
|
|
}
|
|
|
|
func TestLegacyComparisonReportsAccidentalAndUnassessedDimensions(t *testing.T) {
|
|
request := workspaceRequest([]byte("# Expected\n\nExpected body.\n"))
|
|
deps := defaultGeneratorDependencies()
|
|
deps.legacyBuild = func(title, _ string) ([]byte, error) { return legacyBuild(title, "# Wrong\n\nLost body.\n") }
|
|
accidental, err := generateWithOptions(context.Background(), request, RenderOptions{MigrationComparison: true}, deps)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if accidental.Comparison.Assessment != "differences" || len(accidental.Comparison.AccidentalDifferences) == 0 {
|
|
t.Fatalf("content loss was not accidental: %#v", accidental.Comparison)
|
|
}
|
|
|
|
deps.legacyBuild = func(string, string) ([]byte, error) { return []byte("not a DOCX"), nil }
|
|
unassessed, err := generateWithOptions(context.Background(), request, RenderOptions{MigrationComparison: true}, deps)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if unassessed.Comparison.Assessment != "unassessed" || len(unassessed.Comparison.Unassessed) == 0 {
|
|
t.Fatalf("invalid legacy sample was reported as assessed: %#v", unassessed.Comparison)
|
|
}
|
|
}
|
|
|
|
func docxVisibleText(t *testing.T, raw []byte) string {
|
|
t.Helper()
|
|
zr, err := zip.NewReader(bytes.NewReader(raw), int64(len(raw)))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, file := range zr.File {
|
|
if file.Name != "word/document.xml" {
|
|
continue
|
|
}
|
|
stream, err := file.Open()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
decoder := xml.NewDecoder(stream)
|
|
var text strings.Builder
|
|
for {
|
|
token, err := decoder.Token()
|
|
if err == io.EOF {
|
|
break
|
|
}
|
|
if err != nil {
|
|
stream.Close()
|
|
t.Fatal(err)
|
|
}
|
|
if chars, ok := token.(xml.CharData); ok {
|
|
text.Write(chars)
|
|
text.WriteByte('\n')
|
|
}
|
|
}
|
|
stream.Close()
|
|
return text.String()
|
|
}
|
|
t.Fatal("word/document.xml missing")
|
|
return ""
|
|
}
|
|
|
|
func docxPart(t *testing.T, raw []byte, name string) string {
|
|
t.Helper()
|
|
zr, err := zip.NewReader(bytes.NewReader(raw), int64(len(raw)))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, file := range zr.File {
|
|
if file.Name != name {
|
|
continue
|
|
}
|
|
stream, err := file.Open()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
content, err := io.ReadAll(stream)
|
|
stream.Close()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return string(content)
|
|
}
|
|
t.Fatalf("DOCX part %s missing", name)
|
|
return ""
|
|
}
|
|
|
|
func comparisonDimension(t *testing.T, receipt ComparisonReceipt, name string) ComparisonDimension {
|
|
t.Helper()
|
|
for _, dimension := range receipt.Dimensions {
|
|
if dimension.Name == name {
|
|
return dimension
|
|
}
|
|
}
|
|
t.Fatalf("comparison dimension %s missing: %#v", name, receipt.Dimensions)
|
|
return ComparisonDimension{}
|
|
}
|
|
|
|
func rewriteDOCXPart(t *testing.T, raw []byte, name string, rewrite func([]byte) []byte) []byte {
|
|
t.Helper()
|
|
reader, err := zip.NewReader(bytes.NewReader(raw), int64(len(raw)))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var output bytes.Buffer
|
|
writer := zip.NewWriter(&output)
|
|
for _, file := range reader.File {
|
|
stream, err := file.Open()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
content, err := io.ReadAll(stream)
|
|
stream.Close()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if file.Name == name {
|
|
content = rewrite(content)
|
|
}
|
|
target, err := writer.Create(file.Name)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := target.Write(content); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
if err := writer.Close(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return output.Bytes()
|
|
}
|