package parser
import (
"archive/zip"
"bytes"
"encoding/json"
"reflect"
"testing"
)
// helper: build a minimal docx ZIP in-memory with given header/footer XML.
func buildDocxZIP(t *testing.T, headerText, footerText string) []byte {
t.Helper()
buf := new(bytes.Buffer)
zw := zip.NewWriter(buf)
// Minimal required parts for a valid docx: [Content_Types].xml + word/document.xml.
mustWrite := func(name, body string) {
w, err := zw.Create(name)
if err != nil {
t.Fatal(err)
}
_, _ = w.Write([]byte(body))
}
mustWrite("[Content_Types].xml", ``)
mustWrite("word/document.xml", `Body text`)
if headerText != "" {
mustWrite("word/header1.xml", ``+headerText+``)
}
if footerText != "" {
mustWrite("word/footer1.xml", ``+footerText+``)
}
if err := zw.Close(); err != nil {
t.Fatal(err)
}
return buf.Bytes()
}
func TestExtractDOCXHeaderFooterTexts(t *testing.T) {
data := buildDocxZIP(t, "Company Confidential", "Page 1")
texts := extractDOCXHeaderFooterTexts(data)
if !texts["Company Confidential"] {
t.Error("missing header text 'Company Confidential'")
}
if !texts["Page 1"] {
t.Error("missing footer text 'Page 1'")
}
}
func TestExtractDOCXHeaderFooterTexts_NoHeaders(t *testing.T) {
data := buildDocxZIP(t, "", "")
texts := extractDOCXHeaderFooterTexts(data)
if len(texts) != 0 {
t.Errorf("expected empty map, got %v", texts)
}
}
func TestExtractDOCXHeaderFooterTexts_Normalization(t *testing.T) {
// Whitespace runs should be collapsed and trimmed.
data := buildDocxZIP(t, " Multiple Spaces ", "")
texts := extractDOCXHeaderFooterTexts(data)
if !texts["Multiple Spaces"] {
t.Errorf("expected normalized 'Multiple Spaces', got %v", texts)
}
}
// TestRemoveDOCXHeaderFooterSections filters JSON items whose
// normalized text exactly matches a header/footer text.
func TestRemoveDOCXHeaderFooterSections(t *testing.T) {
items := []map[string]any{
{"text": "Company Confidential"},
{"text": "Real content"},
{"text": "Page 1"},
{"text": "More content"},
}
hfTexts := map[string]bool{"Company Confidential": true, "Page 1": true}
got := removeDOCXHeaderFooterSections(items, hfTexts)
want := []map[string]any{
{"text": "Real content"},
{"text": "More content"},
}
if !reflect.DeepEqual(got, want) {
t.Errorf("got %+v, want %+v", got, want)
}
}
// TestRemoveDOCXHeaderFooterSections_EmptyHF keeps all items when no
// header/footer texts are provided.
func TestRemoveDOCXHeaderFooterSections_EmptyHF(t *testing.T) {
items := []map[string]any{{"text": "A"}, {"text": "B"}}
got := removeDOCXHeaderFooterSections(items, nil)
if !reflect.DeepEqual(got, items) {
t.Errorf("expected unchanged, got %+v", got)
}
}
// TestExtractDOCXOutlines verifies heading elements are extracted
// from the office_oxide IR as (title, level) outlines.
func TestExtractDOCXOutlines(t *testing.T) {
irJSON := `{"sections":[{"title":"","elements":[
{"type":"heading","level":1,"content":[{"type":"text","text":"第一章 概述"}]},
{"type":"paragraph","content":[{"type":"text","text":"正文"}]},
{"type":"heading","level":2,"content":[{"type":"text","text":"1.1 背景"}]}
]}]}`
outlines := extractDOCXOutlines(irJSON)
want := []docxOutline{{Title: "第一章 概述", Level: 0}, {Title: "1.1 背景", Level: 1}}
if !reflect.DeepEqual(outlines, want) {
t.Errorf("got %+v, want %+v", outlines, want)
}
}
// TestRemoveTOCWord_NoOutlines delegates to removeContentsTable when
// no outlines are available (mirrors Python utils.py:263-264).
func TestRemoveTOCWord_NoOutlines(t *testing.T) {
items := []map[string]any{
{"text": "前言"},
{"text": "目录"},
{"text": "第一章 概述"},
{"text": "正文"},
}
got := removeTOCWord(items, nil, false)
want := []map[string]any{
{"text": "前言"},
{"text": "正文"},
}
if !reflect.DeepEqual(got, want) {
t.Errorf("got %+v, want %+v", got, want)
}
}
// TestRemoveTOCWord_WithOutlines deletes the TOC heading and
// following entries that match outline-title prefixes or the
// "dots + page number" regex (mirrors Python utils.py:115-144).
func TestRemoveTOCWord_WithOutlines(t *testing.T) {
items := []map[string]any{
{"text": "前言"},
{"text": "目录"},
{"text": "第一章 概述"},
{"text": "第一章 概述 .......... 1"},
{"text": "第二章 方法 .......... 5"},
{"text": "正文开始"},
}
outlines := []docxOutline{
{Title: "第一章 概述", Level: 0},
{Title: "第二章 方法", Level: 0},
}
got := removeTOCWord(items, outlines, false)
// "目录" heading + "第一章 概述" (outline prefix match) +
// "第一章 概述 .......... 1" (prefix match) +
// "第二章 方法 .......... 5" (prefix match + dot+page regex) removed.
// "前言" and "正文开始" survive.
want := []map[string]any{
{"text": "前言"},
{"text": "正文开始"},
}
if !reflect.DeepEqual(got, want) {
t.Errorf("got %+v, want %+v", got, want)
}
}
// TestRemoveTOCWord_NoTOCHeading keeps items unchanged when no TOC
// heading is found (only removeContentsTable fallback runs).
func TestRemoveTOCWord_NoTOCHeading(t *testing.T) {
items := []map[string]any{
{"text": "第一章 概述"},
{"text": "正文"},
}
outlines := []docxOutline{{Title: "第一章 概述", Level: 0}}
got := removeTOCWord(items, outlines, false)
// No "目录/Contents" heading → outline-prefix loop skipped;
// removeContentsTable fallback finds no TOC heading either.
want := []map[string]any{
{"text": "第一章 概述"},
{"text": "正文"},
}
if !reflect.DeepEqual(got, want) {
t.Errorf("got %+v, want %+v", got, want)
}
}
func TestDocxOutline_JSONRoundtrip(t *testing.T) {
o := docxOutline{Title: "Heading 1", Level: 2}
b, _ := json.Marshal(o)
var got docxOutline
_ = json.Unmarshal(b, &got)
if !reflect.DeepEqual(got, o) {
t.Errorf("roundtrip got %+v, want %+v", got, o)
}
}