test(parser): add shared golden-doc + alignment helpers

Extract the alignment scaffolding so the format-specific PRs (text&code,
markdown golden, HTML) don't conflict on align_test.go.

Adds (test-only, no production behavior change):
- GoldenDoc{Meta,Items} and parseGolden: tolerant of both the legacy bare
  array and the new {meta, items} golden format.
- LoadGoldenDoc, AcceptedDivergences(meta), FilterOutDocTypes(items, drop):
  meta-driven divergence handling, no hardcoded divergence lists.
- TextCodeAlignOptions / DefaultTextCodeDelimiter, HTMLAlignOptions /
  StripHTMLHeadingMarker: normalizer presets reused by the alignment tests.
- LoadGolden now tolerates the {meta, items} format.

Stacks on #18014 (the align_test.go framework is already in main).
This commit is contained in:
xugangqiang
2026-08-11 15:52:35 +08:00
parent 8e04f95773
commit 7c19bb074a

View File

@@ -188,19 +188,104 @@ func diffReport(g, p string) string {
return "alignment mismatch after normalization:\n--- GO ---\n" + g + "\n--- PY ---\n" + p
}
// LoadGolden reads a Python golden JSON file (a JSON list of item objects)
// produced by the Python flow parser for the same input.
// GoldenDoc is a {meta, items} Python golden baseline. Meta records how the
// baseline was produced (generator, sample, delimiter, accepted divergences)
// so it stays reproducible without a committed generator script; Items is the
// list of parsed output items compared against Go's parser.
type GoldenDoc struct {
Meta map[string]any
Items []map[string]any
}
// parseGolden unmarshals a golden file that may be either a bare JSON array of
// items (legacy format) or a {meta, items} document (current format). It
// returns the full document either way. Tolerant parsing keeps older
// callers/tests working after the format gained a meta block.
func parseGolden(t *testing.T, data []byte) *GoldenDoc {
t.Helper()
var doc GoldenDoc
if err := json.Unmarshal(data, &doc); err == nil && doc.Items != nil {
return &doc
}
// Legacy flat-array format: treat the whole file as the items list.
var items []map[string]any
if err := json.Unmarshal(data, &items); err != nil {
t.Fatalf("parse golden: %v", err)
}
return &GoldenDoc{Items: items}
}
// LoadGolden reads a Python golden JSON file and returns its items. The file
// may be a bare array (legacy) or a {meta, items} document; either way only
// the items are returned, so existing callers keep working unchanged.
func LoadGolden(t *testing.T, path string) []map[string]any {
t.Helper()
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("load golden %s: %v", path, err)
}
var items []map[string]any
if err := json.Unmarshal(data, &items); err != nil {
t.Fatalf("parse golden %s: %v", path, err)
doc := parseGolden(t, data)
if len(doc.Items) == 0 {
t.Fatalf("golden %s has no items", path)
}
return items
return doc.Items
}
// LoadGoldenDoc reads a Python golden JSON file and returns the full
// {meta, items} document, including the meta block. Used by tests that drive
// behavior from the golden's metadata (e.g. accepted_divergences).
func LoadGoldenDoc(t *testing.T, path string) *GoldenDoc {
t.Helper()
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("load golden %s: %v", path, err)
}
doc := parseGolden(t, data)
if len(doc.Items) == 0 {
t.Fatalf("golden %s has no items", path)
}
return doc
}
// AcceptedDivergences returns the doc_type_kwd values the golden baseline
// declares as accepted representation differences (e.g. "table"/"image"), so
// the comparison can ignore them on both sides. Driven entirely by the
// golden's meta block — the test holds no hardcoded divergence list.
func AcceptedDivergences(meta map[string]any) []string {
raw, ok := meta["accepted_divergences"]
if !ok {
return nil
}
list, ok := raw.([]any)
if !ok {
return nil
}
out := make([]string, 0, len(list))
for _, e := range list {
if s, ok := e.(string); ok {
out = append(out, s)
}
}
return out
}
// FilterOutDocTypes returns the items whose doc_type_kwd is NOT in drop. Used
// to exclude the meta-declared accepted divergences from the comparison.
func FilterOutDocTypes(items []map[string]any, drop []string) []map[string]any {
if len(drop) == 0 {
return items
}
banned := make(map[string]bool, len(drop))
for _, d := range drop {
banned[d] = true
}
out := make([]map[string]any, 0, len(items))
for _, it := range items {
if v, _ := it["doc_type_kwd"].(string); !banned[v] {
out = append(out, it)
}
}
return out
}
// MarkdownAlignOptions returns the normalizer preset for Markdown. The order
@@ -235,3 +320,73 @@ func MarkdownAlignOptions(delimiter string) AlignOptions {
// DefaultMarkdownDelimiter is the flow parser's default Markdown delimiter
// set, used when generating/loading the golden baseline.
const DefaultMarkdownDelimiter = "\n!?;。;!?"
// DefaultTextCodeDelimiter is the flow parser's default text&code delimiter
// set, used when generating/loading the golden baseline.
const DefaultTextCodeDelimiter = "\n!?;。;!?"
// TextCodeAlignOptions returns the normalizer preset for the text&code family.
// Unlike markdown it has no syntax or HTML markup to strip, so only the
// delimiter-set replacement and whitespace collapse run:
// - WithDelimiterStrip: replace the delimiter runes Python consumes at split
// points (kept inline on the Go side via keep_delimiters=True) with a space
// so both sides keep the same token separation.
// - CollapseWhitespace last: folds the introduced spaces and any inter-segment
// gaps into single spaces.
//
// Reused by every text&code alignment test; shares CompareAlignment with the
// other format presets (MarkdownAlignOptions).
func TextCodeAlignOptions(delimiter string) AlignOptions {
return AlignOptions{
Normalizers: []Normalizer{
WithDelimiterStrip(delimiter),
CollapseWhitespace(),
},
ItemKey: "text",
}
}
// htmlHeadingMarkerRE matches a leading ATX heading marker so Python's
// "# Title" (deepdoc merge_block_text prefixes h1h6 with "# ") can be
// normalized to Go's clean heading text.
var htmlHeadingMarkerRE = regexp.MustCompile(`(?m)^#{1,6}\s+`)
// StripHTMLHeadingMarker returns a Normalizer that removes a leading ATX
// heading marker ("#"/"##"/…) from a line. Python's HTML flow parser
// (deepdoc parser.py merge_block_text) prefixes h1h6 sections with "# ",
// while the Go HTML parser emits clean heading text. This is a representation
// difference, not a content divergence, so it is stripped before comparing.
// It must run before CollapseWhitespace because the marker relies on the line
// start.
func StripHTMLHeadingMarker() Normalizer {
return func(s string) string {
return htmlHeadingMarkerRE.ReplaceAllString(s, "")
}
}
// HTMLAlignOptions returns the normalizer preset for HTML. Order matters:
// - StripHTMLHeadingMarker first: drops the "# " Python prefixes from h1h6
// sections (relies on the line start, so before CollapseWhitespace).
// - StripHTMLTags next: replace table/HTML tags with a space (not delete) so
// adjacent cell text does not fuse, e.g. "<td>A</td><td>B</td>" → "A B".
// - CollapseWhitespace last: folds all remaining internal whitespace (the
// inter-tag gaps of a table, the space from any heading-marker removal)
// into single spaces.
//
// No WithDelimiterStrip: HTML is emitted one item per block (no delimiter
// split), and the Python flow chunks HTML at 512 tokens — boundaries differ,
// but CompareAlignment concatenates normalized text boundary-agnostically, so
// only the concatenated content (order preserved) is compared.
//
// Reused by the HTML alignment test; shares CompareAlignment with the other
// format presets (MarkdownAlignOptions, TextCodeAlignOptions).
func HTMLAlignOptions() AlignOptions {
return AlignOptions{
Normalizers: []Normalizer{
StripHTMLHeadingMarker(),
StripHTMLTags(),
CollapseWhitespace(),
},
ItemKey: "text",
}
}