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
248 lines
6.0 KiB
Go
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), "<标签>") || !strings.Contains(string(doc), "&") {
|
|
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
|
|
}
|