//go:build cgo package parser import ( "context" "fmt" "image" "strings" "testing" ) // ── MockDocAnalyzer tests ────────────────────────────────────────────── func TestMockDocAnalyzer(t *testing.T) { mock := &MockDocAnalyzer{ Healthy: true, DLARegions: []DLARegion{ {X0: 0, Y0: 0, X1: 100, Y1: 100, Label: "table", Confidence: 0.95}, }, TSRCells: []TSRCell{ {X0: 0, Y0: 0, X1: 50, Y1: 30, Text: "A"}, }, } if !mock.Health() { t.Error("mock should be healthy") } regions, _ := mock.DLA(context.Background(), nil) if len(regions) != 1 || regions[0].Label != "table" { t.Error("mock DLA returned wrong data") } cells, _ := mock.TSR(context.Background(), nil) if len(cells) != 1 || cells[0].Text != "A" { t.Error("mock TSR returned wrong data") } // OCRDetect + OCRRecognize replaces deprecated OCR — tested in TestOCR_scanPage/TestOCR_fallback. _ = mock.OCRDetect _ = mock.OCRRecognize // Unhealthy mock mock2 := &MockDocAnalyzer{Healthy: false} if mock2.Health() { t.Error("unhealthy mock should return false") } } // ── groupTSRCellsToRows ──────────────────────────────────────────────── func TestGroupTSRCellsToRows(t *testing.T) { t.Run("empty", func(t *testing.T) { if rows := groupTSRCellsToRows(nil); rows != nil { t.Error("nil → nil") } if rows := groupTSRCellsToRows([]TSRCell{}); rows != nil { t.Error("empty → nil") } }) t.Run("single cell", func(t *testing.T) { cells := []TSRCell{{X0: 0, Y0: 0, X1: 100, Y1: 50, Text: "A"}} rows := groupTSRCellsToRows(cells) if len(rows) != 1 || rows[0][0].Text != "A" { t.Error("single cell not preserved") } }) t.Run("two rows two cols", func(t *testing.T) { cells := []TSRCell{ {X0: 0, Y0: 0, X1: 50, Y1: 30, Text: "A"}, {X0: 50, Y0: 0, X1: 100, Y1: 30, Text: "B"}, {X0: 0, Y0: 50, X1: 50, Y1: 80, Text: "C"}, {X0: 50, Y0: 50, X1: 100, Y1: 80, Text: "D"}, } rows := groupTSRCellsToRows(cells) if len(rows) != 2 { t.Fatalf("2 rows expected, got %d", len(rows)) } if rows[0][0].Text != "A" || rows[0][1].Text != "B" { t.Errorf("row0: %v", cellTexts(rows[0])) } if rows[1][0].Text != "C" || rows[1][1].Text != "D" { t.Errorf("row1: %v", cellTexts(rows[1])) } }) t.Run("unsorted input", func(t *testing.T) { cells := []TSRCell{ {X0: 50, Y0: 50, X1: 100, Y1: 80, Text: "D"}, {X0: 0, Y0: 0, X1: 50, Y1: 30, Text: "A"}, {X0: 0, Y0: 50, X1: 50, Y1: 80, Text: "C"}, {X0: 50, Y0: 0, X1: 100, Y1: 30, Text: "B"}, } rows := groupTSRCellsToRows(cells) if len(rows) != 2 { t.Fatalf("unsorted: 2 rows expected, got %d", len(rows)) } if rows[0][0].Text != "A" || rows[0][1].Text != "B" { t.Errorf("unsorted row0: %v", cellTexts(rows[0])) } }) t.Run("tall merged cell", func(t *testing.T) { cells := []TSRCell{ {X0: 0, Y0: 0, X1: 50, Y1: 100, Text: "merged"}, {X0: 50, Y0: 0, X1: 100, Y1: 30, Text: "B"}, {X0: 50, Y0: 50, X1: 100, Y1: 80, Text: "D"}, } rows := groupTSRCellsToRows(cells) // merged cell starts Y0=0 → row 0; Y0=50 cell → row 1 if len(rows) != 2 { t.Fatalf("merged cell: 2 rows expected, got %d", len(rows)) } }) t.Run("large gap different rows", func(t *testing.T) { cells := []TSRCell{ {X0: 0, Y0: 0, X1: 50, Y1: 30, Text: "top"}, {X0: 0, Y0: 200, X1: 50, Y1: 230, Text: "far"}, } rows := groupTSRCellsToRows(cells) if len(rows) != 2 { t.Fatalf("large gap: 2 rows expected, got %d", len(rows)) } }) } // ── fillCellTextFromBoxes ────────────────────────────────────────────── func TestFillCellTextFromBoxes(t *testing.T) { t.Run("exact match", func(t *testing.T) { cells := []TSRCell{ {X0: 0, Y0: 0, X1: 100, Y1: 50}, {X0: 100, Y0: 0, X1: 200, Y1: 50}, } boxes := []TextBox{ {X0: 0, X1: 100, Top: 0, Bottom: 50, Text: "A"}, {X0: 100, X1: 200, Top: 0, Bottom: 50, Text: "B"}, } fillCellTextFromBoxes(cells, boxes) if cells[0].Text != "A" || cells[1].Text != "B" { t.Errorf("got %q/%q, want A/B", cells[0].Text, cells[1].Text) } }) t.Run("empty cells", func(t *testing.T) { cells := []TSRCell{ {X0: 0, Y0: 0, X1: 100, Y1: 50}, {X0: 100, Y0: 0, X1: 200, Y1: 50}, } boxes := []TextBox{ {X0: 0, X1: 100, Top: 0, Bottom: 50, Text: "only first"}, } fillCellTextFromBoxes(cells, boxes) if cells[0].Text != "only first" { t.Errorf("cell[0]: got %q", cells[0].Text) } if cells[1].Text != "" { t.Errorf("cell[1] should be empty, got %q", cells[1].Text) } }) t.Run("partial cell coverage — empty cell filled from any overlapping box", func(t *testing.T) { // Box covers 40% of cell area. Old code rejected (<85% cell coverage). // New code: cell is empty → accepts box (≥30% box area inside cell). cells := []TSRCell{{X0: 0, Y0: 0, X1: 200, Y1: 50}} boxes := []TextBox{{X0: 0, X1: 80, Top: 0, Bottom: 50, Text: "partial"}} fillCellTextFromBoxes(cells, boxes) if cells[0].Text != "partial" { t.Errorf("empty cell should be filled from overlapping box, got %q", cells[0].Text) } }) t.Run("box inside cell >85%", func(t *testing.T) { cells := []TSRCell{{X0: 0, Y0: 0, X1: 500, Y1: 300}} boxes := []TextBox{{X0: 10, X1: 490, Top: 10, Bottom: 290, Text: "inside"}} fillCellTextFromBoxes(cells, boxes) if cells[0].Text != "inside" { t.Errorf("got %q", cells[0].Text) } }) t.Run("concatenate two boxes to same cell", func(t *testing.T) { cells := []TSRCell{{X0: 0, Y0: 0, X1: 200, Y1: 100}} boxes := []TextBox{ {X0: 5, X1: 195, Top: 2, Bottom: 98, Text: "hello"}, {X0: 5, X1: 195, Top: 2, Bottom: 98, Text: "world"}, } fillCellTextFromBoxes(cells, boxes) if cells[0].Text != "hello world" { t.Errorf("got %q, want 'hello world'", cells[0].Text) } }) t.Run("empty inputs", func(t *testing.T) { fillCellTextFromBoxes(nil, nil) fillCellTextFromBoxes([]TSRCell{}, []TextBox{}) c := []TSRCell{{X0: 0, Y0: 0, X1: 1, Y1: 1}} fillCellTextFromBoxes(c, nil) if c[0].Text != "" { t.Error("no boxes → text empty") } }) } // ── regionOverlapsBox ────────────────────────────────────────────────── func TestRegionOverlapsBox(t *testing.T) { scale := 3.0 tests := []struct { name string region DLARegion box TextBox expected bool }{ {"full overlap", DLARegion{X0: 0, Y0: 300, X1: 1500, Y1: 2300, Label: "table", Confidence: 0.9}, TextBox{X0: 50, X1: 500, Top: 100, Bottom: 760, Text: "x", PageNumber: 0}, true}, {"no overlap", DLARegion{X0: 0, Y0: 3000, X1: 1500, Y1: 5000, Label: "table", Confidence: 0.9}, TextBox{X0: 50, X1: 500, Top: 0, Bottom: 10, Text: "x", PageNumber: 0}, false}, {"no Y overlap", DLARegion{X0: 150, Y0: 300, X1: 1650, Y1: 336, Label: "table", Confidence: 0.9}, TextBox{X0: 50, X1: 550, Top: 500, Bottom: 520, Text: "x", PageNumber: 0}, false}, {"zero area box", DLARegion{X0: 0, Y0: 300, X1: 1500, Y1: 2300, Label: "table", Confidence: 0.9}, TextBox{X0: 50, X1: 50, Top: 50, Bottom: 50, Text: "x", PageNumber: 0}, false}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if got := regionOverlapsBox(tt.region, tt.box, scale); got != tt.expected { t.Errorf("= %v, want %v", got, tt.expected) } }) } } // ── enrichWithDeepDoc noop ───────────────────────────────────────────── func TestEnrichWithDeepDoc_Noop(t *testing.T) { boxes := []TextBox{ {PageNumber: 0, X0: 50, X1: 550, Top: 100, Bottom: 112, Text: "text"}, } eng := &mockEngine{pageCount: 1} p := NewParser(DefaultParserConfig(), &MockDocAnalyzer{Healthy: false, Model: ModelSaas}) tables := p.enrichWithDeepDoc(context.Background(), eng, boxes, nil) if len(tables) != 0 { t.Error("unhealthy DeepDoc → 0 Tables") } } // ── extractTableBoxesFromImage with mock ─────────────────────────────── func TestExtractTableBoxes_Mock(t *testing.T) { boxes := []TextBox{ {PageNumber: 0, X0: 80, X1: 500, Top: 200, Bottom: 550, Text: "cell 1"}, {PageNumber: 0, X0: 80, X1: 500, Top: 550, Bottom: 760, Text: "cell 2"}, {PageNumber: 0, X0: 50, X1: 550, Top: 100, Bottom: 180, Text: "heading"}, {PageNumber: 0, X0: 50, X1: 550, Top: 780, Bottom: 850, Text: "below"}, } mock := &MockDocAnalyzer{ Healthy: true, DLARegions: []DLARegion{ {X0: 250, Y0: 600, X1: 1500, Y1: 2300, Label: "table", Confidence: 0.95}, }, TSRCells: []TSRCell{ {X0: 0, Y0: 0, X1: 600, Y1: 400, Text: "A1"}, {X0: 600, Y0: 0, X1: 1240, Y1: 400, Text: "B1"}, {X0: 0, Y0: 410, X1: 600, Y1: 800, Text: "A2"}, {X0: 600, Y0: 410, X1: 1240, Y1: 800, Text: "B2"}, }, } p := NewParser(DefaultParserConfig(), mock) dummyImg := image.NewRGBA(image.Rect(0, 0, 2000, 3000)) tables := p.extractTableBoxesFromImage(context.Background(), boxes, dummyImg, 0, 0) if len(tables) != 1 { t.Fatalf("expected 1 TableItem, got %d", len(tables)) } tbl := tables[0] if len(tbl.Cells) != 4 { t.Errorf("expected 4 cells, got %d", len(tbl.Cells)) } // Rows populated later by constructTable via extractTableAndReplace. if tbl.ImageB64 == "" { t.Error("ImageB64 empty") } if len(tbl.Positions) != 2 { t.Errorf("expected 2 Positions, got %d", len(tbl.Positions)) } } func TestExtractTableBoxes_NoTables(t *testing.T) { mock := &MockDocAnalyzer{Healthy: true, DLARegions: []DLARegion{}} p := NewParser(DefaultParserConfig(), mock) dummy := image.NewRGBA(image.Rect(0, 0, 1000, 1000)) tables := p.extractTableBoxesFromImage(context.Background(), nil, dummy, 0, 0) if len(tables) != 0 { t.Errorf("0 tables expected, got %d", len(tables)) } } func TestExtractTableBoxes_NonTableRegions(t *testing.T) { mock := &MockDocAnalyzer{ Healthy: true, DLARegions: []DLARegion{ {X0: 150, Y0: 300, X1: 1650, Y1: 336, Label: "text", Confidence: 0.9}, {X0: 150, Y0: 600, X1: 1650, Y1: 900, Label: "figure", Confidence: 0.8}, }, } p := NewParser(DefaultParserConfig(), mock) dummy := image.NewRGBA(image.Rect(0, 0, 2000, 2000)) tables := p.extractTableBoxesFromImage(context.Background(), nil, dummy, 0, 0) if len(tables) != 0 { t.Errorf("non-table regions → 0 tables, got %d", len(tables)) } } func TestExtractTableBoxes_NoOverlap(t *testing.T) { boxes := []TextBox{ {PageNumber: 0, X0: 50, X1: 550, Top: 10, Bottom: 30, Text: "far away"}, } mock := &MockDocAnalyzer{ Healthy: true, DLARegions: []DLARegion{ {X0: 150, Y0: 1500, X1: 1500, Y1: 2300, Label: "table", Confidence: 0.95}, }, } p := NewParser(DefaultParserConfig(), mock) dummy := image.NewRGBA(image.Rect(0, 0, 2000, 3000)) tables := p.extractTableBoxesFromImage(context.Background(), boxes, dummy, 0, 0) if len(tables) != 0 { t.Errorf("no overlap → 0 tables, got %d", len(tables)) } } func TestExtractTableBoxes_TSRError(t *testing.T) { boxes := []TextBox{ {PageNumber: 0, X0: 80, X1: 500, Top: 210, Bottom: 660, Text: "cell"}, } mock := &MockDocAnalyzer{ Healthy: true, DLARegions: []DLARegion{ {X0: 250, Y0: 600, X1: 1500, Y1: 2000, Label: "table", Confidence: 0.95}, }, TSRCells: nil, // TSR returns nothing } p := NewParser(DefaultParserConfig(), mock) dummy := image.NewRGBA(image.Rect(0, 0, 2000, 3000)) tables := p.extractTableBoxesFromImage(context.Background(), boxes, dummy, 0, 0) if len(tables) != 1 { t.Fatalf("TSR failure: expected 1 TableItem with image+positions, got %d", len(tables)) } if tables[0].ImageB64 == "" { t.Error("should have image despite TSR failure") } if len(tables[0].Positions) == 0 { t.Error("should have positions despite TSR failure") } if len(tables[0].Rows) != 0 { t.Errorf("TSR failure → 0 rows, got %d", len(tables[0].Rows)) } } func TestGroupTSRCellsToRows_SameHeight(t *testing.T) { // All cells have identical height → medianH is that value → threshold = medianH/2 cells := []TSRCell{ {X0: 0, Y0: 0, X1: 50, Y1: 30, Text: "A"}, {X0: 50, Y0: 0, X1: 100, Y1: 30, Text: "B"}, {X0: 0, Y0: 31, X1: 50, Y1: 61, Text: "C"}, // gap = 31-30=1 < 30/2=15 → same row? NO, Y0=31 is right at edge } rows := groupTSRCellsToRows(cells) // medianH=30, threshold=15. C.Y0=31 > curY+threshold?" curY=0, 31 > 15 → new row. // So A,B in row 0, C in row 1. if len(rows) != 2 { t.Fatalf("expected 2 rows, got %d", len(rows)) } if len(rows[0]) != 2 || len(rows[1]) != 1 { t.Errorf("row sizes: %d %d, want 2 1", len(rows[0]), len(rows[1])) } } func TestFillCellTextFromBoxes_WhitespaceTrim(t *testing.T) { cells := []TSRCell{{X0: 0, Y0: 0, X1: 100, Y1: 100}} boxes := []TextBox{{X0: 0, X1: 100, Top: 0, Bottom: 100, Text: " hello "}} fillCellTextFromBoxes(cells, boxes) if cells[0].Text != "hello" { t.Errorf("got %q, want 'hello'", cells[0].Text) } } func TestFillCellTextFromBoxes_EmptyBoxIgnored(t *testing.T) { cells := []TSRCell{{X0: 0, Y0: 0, X1: 100, Y1: 100}} boxes := []TextBox{{X0: 0, X1: 100, Top: 0, Bottom: 100, Text: " "}} // all whitespace fillCellTextFromBoxes(cells, boxes) if cells[0].Text != "" { t.Errorf("whitespace text should produce empty, got %q", cells[0].Text) } } func TestExtractTableBoxes_DLAError(t *testing.T) { // DLA returns only non-table regions → 0 tables mock := &MockDocAnalyzer{Healthy: true, DLARegions: []DLARegion{ {X0: 0, Y0: 0, X1: 100, Y1: 100, Label: "text", Confidence: 0.9}, }} p := NewParser(DefaultParserConfig(), mock) dummy := image.NewRGBA(image.Rect(0, 0, 1000, 1000)) tables := p.extractTableBoxesFromImage(context.Background(), nil, dummy, 0, 0) if len(tables) != 0 { t.Errorf("non-table DLA → 0 tables, got %d", len(tables)) } } func TestAnnotateBoxLayouts(t *testing.T) { boxes := []TextBox{ {X0: 50, X1: 200, Top: 100, Bottom: 200, Text: "title text"}, {X0: 250, X1: 500, Top: 100, Bottom: 200, Text: "body"}, {X0: 50, X1: 500, Top: 300, Bottom: 600, Text: "table content"}, {X0: 50, X1: 500, Top: 700, Bottom: 800, Text: "unmatched"}, } regions := []DLARegion{ {X0: 150, Y0: 300, X1: 600, Y1: 600, Label: "title", Confidence: 0.9}, // PDF pts: X50-200,Y100-200 → only box[0] {X0: 750, Y0: 300, X1: 1500, Y1: 600, Label: "text", Confidence: 0.8}, // PDF pts: X250-500,Y100-200 → box[1] {X0: 150, Y0: 900, X1: 1500, Y1: 1800, Label: "table", Confidence: 0.95}, // PDF pts: X50-500,Y300-600 → box[2] } scale := 3.0 annotateBoxLayouts(boxes, regions, scale, 0) if boxes[0].LayoutType != "title" { t.Errorf("box[0] = %q, want title", boxes[0].LayoutType) } if boxes[1].LayoutType != "text" { t.Errorf("box[1] = %q, want text", boxes[1].LayoutType) } if boxes[2].LayoutType != "table" { t.Errorf("box[2] = %q, want table", boxes[2].LayoutType) } if boxes[3].LayoutType != "" { t.Errorf("box[3] = %q, want empty (no matching region)", boxes[3].LayoutType) } } func TestAnnotateBoxLayouts_Figure(t *testing.T) { // Figure region → box gets "figure" layout type (no TSR needed) boxes := []TextBox{ {X0: 50, X1: 500, Top: 100, Bottom: 400, Text: "chart image"}, } regions := []DLARegion{ {X0: 50, Y0: 200, X1: 2000, Y1: 1000, Label: "figure", Confidence: 0.85}, } annotateBoxLayouts(boxes, regions, 3.0, 0) if boxes[0].LayoutType != "figure" { t.Errorf("LayoutType = %q, want 'figure'", boxes[0].LayoutType) } } func TestAnnotateBoxLayouts_Empty(t *testing.T) { boxes := []TextBox{{Text: "x"}} annotateBoxLayouts(boxes, nil, 3.0, 0) if boxes[0].LayoutType != "" { t.Error("empty regions → no annotation") } } func TestBoxesToSections_PassesLayoutType(t *testing.T) { boxes := []TextBox{ {PageNumber: 0, X0: 50, X1: 550, Top: 100, Bottom: 112, Text: "标题", LayoutType: "title"}, {PageNumber: 0, X0: 50, X1: 550, Top: 200, Bottom: 212, Text: "表格", LayoutType: "table"}, {PageNumber: 0, X0: 50, X1: 550, Top: 300, Bottom: 312, Text: "正文", LayoutType: "text"}, } sections := boxesToSections(boxes, nil) if len(sections) != 3 { t.Fatalf("expected 3 sections, got %d", len(sections)) } if sections[0].LayoutType != "title" { t.Errorf("section[0].LayoutType = %q, want 'title'", sections[0].LayoutType) } if sections[1].LayoutType != "table" { t.Errorf("section[1].LayoutType = %q, want 'table'", sections[1].LayoutType) } if sections[2].LayoutType != "text" { t.Errorf("section[2].LayoutType = %q, want 'text'", sections[2].LayoutType) } } func TestBoxesToSections_PreservesTableLayout(t *testing.T) { // boxesToSections should produce sections for all boxes regardless of LayoutType. boxes := []TextBox{ {PageNumber: 0, X0: 50, X1: 550, Top: 100, Bottom: 112, Text: "标题", LayoutType: "title"}, {PageNumber: 0, X0: 50, X1: 550, Top: 200, Bottom: 212, Text: "表格文字", LayoutType: "table"}, {PageNumber: 0, X0: 50, X1: 550, Top: 300, Bottom: 312, Text: "正文", LayoutType: "text"}, {PageNumber: 0, X0: 50, X1: 550, Top: 400, Bottom: 412, Text: ""}, } sections := boxesToSections(boxes, nil) if len(sections) != 3 { t.Errorf("expected 3 sections (1 empty skipped), got %d", len(sections)) } for _, s := range sections { if strings.Contains(s.Text, "@@") { t.Error("section text should NOT contain position tag") } } t.Logf("boxesToSections: %d sections (all LayoutTypes passed through)", len(sections)) } func TestEnrichWithDeepDoc_PreservesBoxes(t *testing.T) { // Simulate enrichWithDeepDoc's write-back logic: // 1. Create pageBoxes as copies of p.boxes[idx] // 2. annotateBoxLayouts(pageBoxes, regions) — modifies copies // 3. Write LayoutType back to p.boxes[idx] // This test validates step 3 works. original := []TextBox{ {PageNumber: 0, X0: 50, X1: 200, Top: 50, Bottom: 80, Text: "title", LayoutType: ""}, {PageNumber: 0, X0: 50, X1: 200, Top: 100, Bottom: 200, Text: "text before", LayoutType: ""}, {PageNumber: 0, X0: 50, X1: 500, Top: 250, Bottom: 700, Text: "table cell", LayoutType: ""}, {PageNumber: 0, X0: 50, X1: 200, Top: 750, Bottom: 800, Text: "text after", LayoutType: ""}, {PageNumber: 1, X0: 50, X1: 200, Top: 50, Bottom: 80, Text: "page2", LayoutType: ""}, } byPage := map[int][]int{0: {0, 1, 2, 3}, 1: {4}} // indices into original regions := []DLARegion{ {X0: 150, Y0: 150, X1: 600, Y1: 240, Label: "title", Confidence: 0.9}, // PDF: X50-200,Y50-80 → box[0] {X0: 150, Y0: 750, X1: 1500, Y1: 2100, Label: "table", Confidence: 0.95}, // PDF: X50-500,Y250-700 → box[2] } // Step 1-2: copy + annotate for _, indices := range byPage { pageBoxes := make([]TextBox, len(indices)) for i, idx := range indices { pageBoxes[i] = original[idx] } annotateBoxLayouts(pageBoxes, regions, 3.0, 0) // Step 3: write back (this is what enrichWithDeepDoc now does) for i, idx := range indices { if pageBoxes[i].LayoutType != "" { original[idx].LayoutType = pageBoxes[i].LayoutType } } } if original[0].LayoutType != "title" { t.Errorf("box[0] LayoutType = %q, want 'title'", original[0].LayoutType) } if original[2].LayoutType != "table" { t.Errorf("box[2] LayoutType = %q, want 'table'", original[2].LayoutType) } if original[1].LayoutType != "" { t.Errorf("box[1] LayoutType = %q, want '' (no matching region)", original[1].LayoutType) } // All boxes still present if len(original) != 5 { t.Errorf("all boxes preserved: got %d, want 5", len(original)) } t.Logf("Write-back verified: box[0]=%q box[2]=%q", original[0].LayoutType, original[2].LayoutType) } func TestBoxesToSections_PositionsFromTag(t *testing.T) { boxes := []TextBox{ {PageNumber: 0, X0: 50, X1: 550, Top: 100, Bottom: 112, Text: "标题段落"}, } sections := boxesToSections(boxes, nil) if sections[0].PositionTag == "" { t.Error("PositionTag should not be empty") } if len(sections[0].Positions) == 0 { t.Error("Positions should be parsed from PositionTag — BUG: ExtractPositions not called") } if len(sections[0].Positions) > 0 { pos := sections[0].Positions[0] if pos.Left != 50 || pos.Right != 550 || pos.Top != 100 || pos.Bottom != 112 { t.Errorf("position coords wrong: got (%.0f,%.0f,%.0f,%.0f)", pos.Left, pos.Right, pos.Top, pos.Bottom) } } t.Logf("Positions: %v", sections[0].Positions) } func TestParse_TableLinkedToSections(t *testing.T) { // Simulate enrichWithDeepDoc → extractTableAndReplace → boxesToSections: // table boxes are popped and replaced with one HTML box. boxes := []TextBox{ {PageNumber: 0, X0: 50, X1: 200, Top: 50, Bottom: 80, Text: "heading"}, {PageNumber: 0, X0: 50, X1: 500, Top: 250, Bottom: 400, Text: "table text", LayoutType: "table"}, {PageNumber: 0, X0: 50, X1: 200, Top: 450, Bottom: 480, Text: "after"}, } tableItem := TableItem{ Cells: []TSRCell{ {X0: 0, Y0: 0, X1: 200, Y1: 50, Label: "table row"}, {X0: 0, Y0: 51, X1: 200, Y1: 100, Label: "table row"}, }, Positions: []Position{{PageNumbers: []int{0}, Left: 50, Right: 500, Top: 250, Bottom: 400}}, Scale: 1.0, } boxes = extractTableAndReplace(boxes, []TableItem{tableItem}) sections := boxesToSections(boxes, nil) // 3 boxes (heading, table, after) → 3 sections (heading, HTML, after). if len(sections) != 3 { t.Errorf("expected 3 sections, got %d", len(sections)) } tableFound := false for _, s := range sections { if s.LayoutType == "table" && strings.Contains(s.Text, "