Files
Agent-System-Backend/internal/docgen/docgen_test.go
T
weichen 6c01d00a3f Close remaining Task 10 renderer review findings
Why: Direct artifact registration trusted filename extensions, the shared renderer parity test used a reduced fixture, and migration comparison omitted canonical list/task/rule/alignment properties plus their Word presentation.
Evidence: go test -count=1 ./...; go test -race ./internal/docgen ./internal/tools ./internal/runmgr; go vet ./...; go build ./...; PYTHONPATH=. python3 -m unittest tests.test_housestyle; git diff --check.
Impact: Reject renamed WordprocessingML packages while preserving non-Word artifacts, pin the reviewed renderer semantics, and report canonical and DOCX presentation regressions as accidental differences.
Refs: Task 10; /tmp/task10-backend-final-review-last.md
2026-10-15 22:33:29 +00:00

248 lines
6.0 KiB
Go

package docgen
import (
"archive/zip"
"bytes"
"encoding/xml"
"io"
"strings"
"testing"
)
// 样例正文:会议纪要形态,也是 legacy/shared 唯一完整语义奇偶性夹具。
// 行内代码用 \x60(反引号)拼接——raw string 里不能直接写字面反引号。
const sampleMinutes = "# 研发周会纪要\n" + `
## 一、基本信息
正文含 **粗体**、*强调*、~~删除~~、` + "\x60inline\x60" + ` 和 [安全标签](https://example.com/private-target)。
## 二、讨论要点
- **架构**:共享渲染器
- 排版要求
1. 有序子项
- 深层无序子项
- [x] 已完成:预览装配
- [ ] 待办:样式打磨
> 会议结论:按周五节点交付。
` + "\x60\x60\x60go" + `
fmt.Println("backtick")
` + "\x60\x60\x60" + `
~~~go
fmt.Println("tilde")
~~~~
## 三、决议
3. 第三项
4. 第四项
---
| 左 | 中 | 右 | 默认 |
| :--- | :---: | ---: | --- |
| L | C | R | D |
`
func buildSample(t *testing.T) []byte {
t.Helper()
b, err := legacyBuild("", sampleMinutes)
if err != nil {
t.Fatalf("Build: %v", err)
}
return b
}
func TestBuildZipStructure(t *testing.T) {
b := buildSample(t)
zr, err := zip.NewReader(bytes.NewReader(b), int64(len(b)))
if err != nil {
t.Fatalf("zip 打开失败(产物不是合法 docx 容器): %v", err)
}
want := map[string]bool{
"[Content_Types].xml": false, "word/document.xml": false,
"word/styles.xml": false, "word/numbering.xml": false,
"word/_rels/document.xml.rels": false, "_rels/.rels": false,
"docProps/core.xml": false,
}
for _, f := range zr.File {
if _, ok := want[f.Name]; ok {
want[f.Name] = true
}
}
for name, seen := range want {
if !seen {
t.Errorf("docx 缺少部件 %s", name)
}
}
}
func TestBuildDocumentXMLWellFormed(t *testing.T) {
b := buildSample(t)
zr, _ := zip.NewReader(bytes.NewReader(b), int64(len(b)))
var doc string
for _, f := range zr.File {
if f.Name == "word/document.xml" {
rc, _ := f.Open()
doc = string(readAll(rc))
rc.Close()
}
}
if doc == "" {
t.Fatal("word/document.xml 为空")
}
dec := xml.NewDecoder(strings.NewReader(doc))
for {
_, err := dec.Token()
if err == io.EOF {
break
}
if err != nil {
t.Fatalf("document.xml 不是良构 XML: %v", err)
}
}
for _, marker := range []string{
`<w:pStyle w:val="Title"/>`,
`<w:pStyle w:val="Heading1"/>`,
`<w:pStyle w:val="Heading2"/>`,
`<w:tbl>`,
`<w:tblHeader/>`,
`<w:numPr>`,
`<w:hyperlink r:id="rIdH1">`,
`<w:strike/>`,
} {
if !strings.Contains(doc, marker) {
t.Errorf("document.xml 缺少排版标记 %s", marker)
}
}
// 标题文本与转义:一级标题进 Title 段(title 取首个标题)。
if !strings.Contains(doc, "研发周会纪要") {
t.Errorf("document.xml 缺少标题文本")
}
}
func TestBuildNumberingRefsResolve(t *testing.T) {
b := buildSample(t)
zr, _ := zip.NewReader(bytes.NewReader(b), int64(len(b)))
read := func(name string) string {
for _, f := range zr.File {
if f.Name == name {
rc, _ := f.Open()
s, _ := io.ReadAll(rc)
rc.Close()
return string(s)
}
}
t.Fatalf("缺少 %s", name)
return ""
}
doc, numbering := read("word/document.xml"), read("word/numbering.xml")
// 收集 document 里引用的 numId,逐一确认 numbering.xml 有对应 w:num。
refs := map[string]bool{}
for _, seg := range strings.Split(doc, "<w:numId w:val=\"")[1:] {
id := seg[:strings.IndexByte(seg, '"')]
refs[id] = true
}
if len(refs) == 0 {
t.Fatal("列表未引用任何 numId")
}
for id := range refs {
if !strings.Contains(numbering, `<w:num w:numId="`+id+`">`) {
t.Errorf("numId %s 在 numbering.xml 无定义", id)
}
}
// 有序/无序列表分属不同编号实例(同文档两类列表都出现时 numId 应不同)。
if len(refs) < 2 {
t.Errorf("样例含无序+有序两类列表,numId 应至少 2 个,得 %d", len(refs))
}
}
func TestBuildHyperlinkRels(t *testing.T) {
b := buildSample(t)
zr, _ := zip.NewReader(bytes.NewReader(b), int64(len(b)))
var rels string
for _, f := range zr.File {
if f.Name == "word/_rels/document.xml.rels" {
rc, _ := f.Open()
rels = string(readAll(rc))
rc.Close()
}
}
if !strings.Contains(rels, `Id="rIdH1"`) || !strings.Contains(rels, "https://example.com/private-target") {
t.Errorf("超链接 relationship 缺失: %s", rels)
}
}
func TestBuildEscapesXML(t *testing.T) {
b, err := legacyBuild("标题<a>&'", "包含 <标签> & \"引号\" 的正文")
if err != nil {
t.Fatalf("Build: %v", err)
}
zr, _ := zip.NewReader(bytes.NewReader(b), int64(len(b)))
for _, f := range zr.File {
if f.Name != "word/document.xml" {
continue
}
rc, _ := f.Open()
doc := readAll(rc)
rc.Close()
if !strings.Contains(string(doc), "&lt;标签&gt;") || !strings.Contains(string(doc), "&amp;") {
t.Errorf("正文 XML 转义缺失")
}
dec := xml.NewDecoder(bytes.NewReader(doc))
for {
_, err := dec.Token()
if err == io.EOF {
break
}
if err != nil {
t.Fatalf("转义后 XML 不良构: %v", err)
}
}
}
}
func TestBuildPlainTextFallback(t *testing.T) {
// 无任何 Markdown 结构的纯文本:按段落输出,不 panic、产物良构。
b, err := legacyBuild("", "第一行\n第二行\n\n第二段")
if err != nil {
t.Fatalf("Build: %v", err)
}
zr, err := zip.NewReader(bytes.NewReader(b), int64(len(b)))
if err != nil {
t.Fatalf("zip: %v", err)
}
var doc string
for _, f := range zr.File {
if f.Name == "word/document.xml" {
rc, _ := f.Open()
doc = string(readAll(rc))
rc.Close()
}
}
if !strings.Contains(doc, "第二段") {
t.Errorf("纯文本段落丢失")
}
// 段内换行应转 w:br,且不残留裸换行进 w:t。
if !strings.Contains(doc, "<w:br/>") {
t.Errorf("段内换行未转 w:br")
}
}
func TestSuggestName(t *testing.T) {
if got := SuggestName("季度/报告:2026"); got != "季度报告2026.docx" {
t.Errorf("SuggestName = %q", got)
}
if got := SuggestName(" "); got != "文档.docx" {
t.Errorf("SuggestName 空 = %q", got)
}
}
func readAll(r io.Reader) []byte {
b, _ := io.ReadAll(r)
return b
}