Files
nexarch/dms/internal/batchsplit/classify_test.go
patrickandClaude Sonnet 5 3dda93e988
CI / Core-Modul (Build/Vet/Test) (push) Canceled after 0s
CI / DMS Security-Test-Gate (AC5, Merge-Gate) (push) Canceled after 0s
CI / DMS-Modul (Lint/Build/Test/Coverage/Artefakt) (push) Canceled after 0s
CI / DMS-Modul Supply-Chain-Scan (govulncheck) (push) Canceled after 0s
CI / Web (dms-browser) (push) Canceled after 0s
CI / Web (pdf-viewer) (push) Canceled after 0s
CI / Web (shl) (push) Canceled after 0s
INT-10: zeitbudget-test fuer grossen stapel
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-20 20:10:14 +02:00

193 lines
6.5 KiB
Go

package batchsplit
import (
"bytes"
"errors"
"testing"
"time"
"gitea.perlbach24.de/scripte/nexarch/dms/internal/testcorpus"
)
const sepPrefix = "NXSEP:"
func scanStack(t *testing.T, pages ...testcorpus.ScanPage) []byte {
t.Helper()
b, err := testcorpus.GenerateScanStack(pages)
if err != nil {
t.Fatal(err)
}
return b
}
func text() testcorpus.ScanPage { return testcorpus.ScanPage{Kind: testcorpus.ScanText} }
func blank() testcorpus.ScanPage { return testcorpus.ScanPage{Kind: testcorpus.ScanBlank} }
func noisy() testcorpus.ScanPage { return testcorpus.ScanPage{Kind: testcorpus.ScanBlankNoisy} }
func code(v string) testcorpus.ScanPage {
return testcorpus.ScanPage{Kind: testcorpus.ScanCode128, Value: sepPrefix + v}
}
func qr(v string) testcorpus.ScanPage {
return testcorpus.ScanPage{Kind: testcorpus.ScanQR, Value: sepPrefix + v}
}
func blankClassifier() Classifier { return BlankClassifier{} }
func barcodeClassifier() Classifier {
return BarcodeClassifier{Prefix: sepPrefix}
}
func split(t *testing.T, in []byte, c Classifier) []Part {
t.Helper()
parts, err := SplitParts(in, c, PopplerRenderer{})
if err != nil {
t.Fatal(err)
}
return parts
}
func pageCounts(t *testing.T, parts []Part) []int {
t.Helper()
out := make([]int, len(parts))
for i, p := range parts {
out[i] = pageCount(t, p.Content)
}
return out
}
func equalInts(a, b []int) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}
// AK1 / Pruefung 1: Leerseiten trennen den Stapel in die richtige Anzahl Dokumente.
func TestBlank_ReferenceBatches(t *testing.T) {
cases := []struct {
name string
pages []testcorpus.ScanPage
want []int
}{
{"zwei dokumente", []testcorpus.ScanPage{text(), text(), blank(), text()}, []int{2, 1}},
{"drei dokumente", []testcorpus.ScanPage{text(), blank(), text(), text(), text(), blank(), text()}, []int{1, 3, 1}},
{"leerseiten am rand", []testcorpus.ScanPage{blank(), text(), text(), blank()}, []int{2}},
{"aufeinanderfolgende leerseiten", []testcorpus.ScanPage{text(), blank(), blank(), text()}, []int{1, 1}},
{"verrauschte leerseite", []testcorpus.ScanPage{text(), noisy(), text(), text()}, []int{1, 2}},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got := pageCounts(t, split(t, scanStack(t, c.pages...), blankClassifier()))
if !equalInts(got, c.want) {
t.Fatalf("seiten je dokument = %v, want %v", got, c.want)
}
})
}
}
// AK2 / Pruefung 2: Barcode-Trennseiten (Code 128 und QR) ordnen ihren Wert dem folgenden Dokument zu.
func TestBarcode_ValuesAssignedToFollowingDocument(t *testing.T) {
in := scanStack(t, code("RE-2026-001"), text(), text(), qr("VERTRAG-77"), text(), code("AKTE-3"), text(), text(), text())
parts := split(t, in, barcodeClassifier())
if got, want := pageCounts(t, parts), []int{2, 1, 3}; !equalInts(got, want) {
t.Fatalf("seiten je dokument = %v, want %v", got, want)
}
for i, want := range []string{"RE-2026-001", "VERTRAG-77", "AKTE-3"} {
if parts[i].Value != want {
t.Errorf("dokument %d: wert = %q, want %q", i+1, parts[i].Value, want)
}
}
}
func TestBarcode_ForeignBarcodeDoesNotSplit(t *testing.T) {
in := scanStack(t, text(), testcorpus.ScanPage{Kind: testcorpus.ScanCode128, Value: "RECHNUNG-12345"}, text())
parts := split(t, in, barcodeClassifier())
if len(parts) != 1 || !bytes.Equal(parts[0].Content, in) {
t.Fatalf("barcode ohne trennseiten-praefix darf nicht trennen (teile = %d)", len(parts))
}
}
func TestBarcode_LastValueBeforeDocumentWins(t *testing.T) {
parts := split(t, scanStack(t, code("A"), code("B"), text()), barcodeClassifier())
if len(parts) != 1 || parts[0].Value != "B" {
t.Fatalf("teile=%d wert=%q, want 1/B", len(parts), parts[0].Value)
}
}
func TestBarcode_MissingPrefixIsRejected(t *testing.T) {
_, err := SplitParts(scanStack(t, text()), BarcodeClassifier{}, PopplerRenderer{})
if err == nil {
t.Fatal("ohne praefix muss die erkennung abgelehnt werden")
}
}
// AK3 / Pruefung 3: Stapel ohne Trennseiten bleibt ein unveraendertes Dokument.
func TestNoSeparators_StaysOneDocument(t *testing.T) {
in := scanStack(t, text(), text(), text())
parts := split(t, in, Chain{barcodeClassifier(), blankClassifier()})
if len(parts) != 1 || !bytes.Equal(parts[0].Content, in) || parts[0].Value != "" {
t.Fatalf("stapel ohne trennseite muss unveraendert als ein dokument zurueckkommen (teile = %d)", len(parts))
}
}
func TestChain_MarkerBlankAndBarcodeTogether(t *testing.T) {
in := scanStack(t, text(), blank(), code("X1"), text(), text())
parts := split(t, in, Chain{DetectorClassifier{MarkerDetector{}}, barcodeClassifier(), blankClassifier()})
if got, want := pageCounts(t, parts), []int{1, 2}; !equalInts(got, want) {
t.Fatalf("seiten je dokument = %v, want %v", got, want)
}
if parts[0].Value != "" || parts[1].Value != "X1" {
t.Errorf("werte = %q/%q, want ''/X1", parts[0].Value, parts[1].Value)
}
}
func TestClassifier_OnlySeparatorsIsRejected(t *testing.T) {
_, err := SplitParts(scanStack(t, blank(), blank()), blankClassifier(), PopplerRenderer{})
if !errors.Is(err, ErrOnlySeparators) {
t.Fatalf("err = %v, want ErrOnlySeparators", err)
}
}
func TestClassifier_WithoutRendererFailsInsteadOfMisSplitting(t *testing.T) {
_, err := SplitParts(scanStack(t, text(), blank(), text()), blankClassifier(), nil)
if !errors.Is(err, ErrNoRenderer) {
t.Fatalf("err = %v, want ErrNoRenderer", err)
}
}
func TestClassifier_RendererFailureIsReported(t *testing.T) {
_, err := SplitParts(scanStack(t, text(), blank()), blankClassifier(), PopplerRenderer{Binary: "/nicht/vorhanden/pdftoppm"})
if err == nil {
t.Fatal("fehlendes rasterwerkzeug muss als fehler gemeldet werden")
}
}
func TestSplit_TextMarkerStackStillWorksThroughSplitParts(t *testing.T) {
parts, err := SplitParts(testcorpus.GenerateStack([]string{"A1", sep, "B1"}), DetectorClassifier{MarkerDetector{}}, nil)
if err != nil || len(parts) != 2 {
t.Fatalf("teile=%d err=%v", len(parts), err)
}
}
func TestChain_LargeStackSplitsWithinTimeBudget(t *testing.T) {
var pages []testcorpus.ScanPage
want := 0
for d := 0; d < 10; d++ {
pages = append(pages, code("D-"+string(rune('A'+d))), text(), text(), blank(), text())
want += 2
}
start := time.Now()
parts := split(t, scanStack(t, pages...), Chain{barcodeClassifier(), blankClassifier()})
elapsed := time.Since(start)
t.Logf("%d seiten in %v", len(pages), elapsed)
if len(parts) != want {
t.Fatalf("teile = %d, want %d", len(parts), want)
}
if elapsed > 90*time.Second {
t.Fatalf("stapel mit %d seiten dauerte %v", len(pages), elapsed)
}
}