Why: agent-document-renderer 31fbe54 publishes company.default@1.0.0 with review status approved and publicationAllowed true (owner decision 2026-10-16: A4, ~2.5cm margins, SimSun + Times New Roman 12pt body, SimHei headings, no logo). Backend still hard-coded Candidate=true and every prompt, skill and tool text called the default a pending-human-visual-review candidate. Evidence: go vet ./... exit 0; gofmt -l on changed packages empty; go test -count=1 ./internal/docgen/... ./internal/prompts/... ./internal/skills/... ./internal/tools/... ok; go test -count=1 ./... exit 0 (42 packages ok); go build ./... and CGO_ENABLED=0 go build ./cmd/agent-system ok; git diff --cached --check clean. New tests failed before the change. Impact: bump deps/agent-document-renderer 0d1c512 -> 31fbe54 and regenerate the renderer identity. The manifest profile receipt now reports the renderer's own review status and derives Candidate from the snapshot state (false for the published default). Prompts (base.deliverables v12, tool.render_document v2, task.report v4, task.doc-minutes v4), docx-author and house-style skills, the render_document tool description and comparison exception details describe the default as published (approved by owner 2026-10-16) while still limiting it to ordinary uncontrolled workspace working/preview Word. Routing is unchanged: project, controlled, special-template, review and approved outputs still go to RDM, with no Backend default fallback and no legacy Word builder fallback. Refs: unified renderer plan Tasks 7/10 Co-authored-by: Cursor <cursoragent@cursor.com>
821 lines
30 KiB
Go
821 lines
30 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 != "1.0.0" || 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.loadDefault
|
|
deps.loadDefault = func(ctx context.Context) (profiles.Bundle, error) {
|
|
loads++
|
|
return baseLoad(ctx)
|
|
}
|
|
_, 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@31fbe54fae8bf11be664919ce173da0d68ebfdc9"
|
|
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)
|
|
}
|
|
}
|
|
|
|
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")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|
|
}
|
|
|
|
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()
|
|
}
|