Files
paliad/pkg/docforge/docx/authoring_test.go
mAi a111a82640 feat(docforge): slice 6a — docx authoring core + TemplateStore wiring (t-paliad-349)
The verifiable backend heart of the authoring surface, before the HTTP +
frontend layers.

pkg/docforge/docx/authoring.go:
  - ImportForAuthoring(carrier) → AuthoringView{PreviewHTML, Slots}: parses
    an uploaded .docx into a run-addressable preview (one
    <span class="docforge-run" data-run="N"> per <w:t>, document order)
    plus the {{placeholder}} slots already present.
  - InjectSlot(carrier, runIndex, selectedText, slotKey) → new carrier:
    replaces the selection inside run N with a {{slot_key}} token. Keys on
    the selected TEXT (not a byte/UTF-16 offset) so umlauts can't desync the
    client selection from the server slice; preview + injection walk runs in
    the identical paragraph→<w:t> order so data-run indices line up.
  - v1 scope: text slots in body paragraphs; out-of-run / cross-run / not-
    found selections return an error the UI turns into a hint.

6 unit tests cover run-addressable preview, slot detection, injection +
round-trip re-import, umlaut/run-targeting, and the error paths (selection
absent, out-of-range run, invalid slot key) — all passing.

Wired PgTemplateStore through the stack (main.go → handlers.Services →
dbServices) so the upcoming authoring endpoints can reach it.

Verification: go build/vet clean, full module test green (13 pkgs), new
files gofmt-clean. The HTTP endpoints + frontend authoring page land next;
their live flow needs the post-merge e2e/manual loop (DB+Supabase).

m/paliad#157
2026-05-29 16:00:27 +02:00

112 lines
4.1 KiB
Go

package docx
import (
"strings"
"testing"
)
// docBody wraps a <w:body> inner string into a full document.xml.
func docBody(inner string) string {
return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>` +
`<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">` +
`<w:body>` + inner + `</w:body></w:document>`
}
func TestImportForAuthoring_PreviewIsRunAddressable(t *testing.T) {
body := docBody(
`<w:p><w:r><w:t>Az. 4c O 12/23</w:t></w:r></w:p>` +
`<w:p><w:r><w:t>Klägerin</w:t></w:r><w:r><w:t xml:space="preserve"> GmbH</w:t></w:r></w:p>`)
view, err := ImportForAuthoring(minimalMergeDOCX(t, body))
if err != nil {
t.Fatalf("ImportForAuthoring: %v", err)
}
// Three <w:t> → three run spans, indexed 0,1,2 in document order.
for i, want := range []string{`data-run="0"`, `data-run="1"`, `data-run="2"`} {
if !strings.Contains(view.PreviewHTML, want) {
t.Errorf("preview missing %s (run %d); html=%s", want, i, view.PreviewHTML)
}
}
if !strings.Contains(view.PreviewHTML, "Az. 4c O 12/23") {
t.Errorf("preview missing run text; html=%s", view.PreviewHTML)
}
// Two paragraphs.
if n := strings.Count(view.PreviewHTML, "<p>"); n != 2 {
t.Errorf("paragraph count = %d; want 2", n)
}
if len(view.Slots) != 0 {
t.Errorf("fresh doc should have no slots; got %v", view.Slots)
}
}
func TestImportForAuthoring_DetectsExistingSlots(t *testing.T) {
body := docBody(`<w:p><w:r><w:t>Az. {{project.case_number}} vor {{project.court}}</w:t></w:r></w:p>`)
view, err := ImportForAuthoring(minimalMergeDOCX(t, body))
if err != nil {
t.Fatalf("ImportForAuthoring: %v", err)
}
if len(view.Slots) != 2 {
t.Fatalf("slots = %d; want 2 (%v)", len(view.Slots), view.Slots)
}
if view.Slots[0].Key != "project.case_number" || view.Slots[0].Anchor != "{{project.case_number}}" {
t.Errorf("slot[0] = %+v; want project.case_number", view.Slots[0])
}
if view.Slots[1].Key != "project.court" {
t.Errorf("slot[1].Key = %q; want project.court", view.Slots[1].Key)
}
}
func TestInjectSlot_ReplacesSelectionWithPlaceholder(t *testing.T) {
body := docBody(`<w:p><w:r><w:t>Az. 4c O 12/23 vor dem LG</w:t></w:r></w:p>`)
out, err := InjectSlot(minimalMergeDOCX(t, body), 0, "4c O 12/23", "project.case_number")
if err != nil {
t.Fatalf("InjectSlot: %v", err)
}
doc := readMergeDocumentXML(t, out)
if !strings.Contains(doc, "Az. {{project.case_number}} vor dem LG") {
t.Errorf("injected doc wrong; got %s", doc)
}
// Round-trips: re-importing finds the new slot.
view, err := ImportForAuthoring(out)
if err != nil {
t.Fatalf("re-import: %v", err)
}
if len(view.Slots) != 1 || view.Slots[0].Key != "project.case_number" {
t.Errorf("re-imported slots = %v; want [project.case_number]", view.Slots)
}
}
func TestInjectSlot_TargetsTheNamedRun(t *testing.T) {
// "GmbH" appears in run 1 only; "Müller" (with umlaut) in run 0.
body := docBody(
`<w:p><w:r><w:t>Müller</w:t></w:r><w:r><w:t xml:space="preserve"> GmbH</w:t></w:r></w:p>`)
out, err := InjectSlot(minimalMergeDOCX(t, body), 0, "Müller", "parties.claimant.name")
if err != nil {
t.Fatalf("InjectSlot: %v", err)
}
doc := readMergeDocumentXML(t, out)
if !strings.Contains(doc, "{{parties.claimant.name}}") {
t.Errorf("umlaut selection not replaced; got %s", doc)
}
if !strings.Contains(doc, " GmbH") {
t.Errorf("run 1 should be untouched; got %s", doc)
}
}
func TestInjectSlot_ErrorsWhenSelectionNotInRun(t *testing.T) {
body := docBody(`<w:p><w:r><w:t>Hello</w:t></w:r></w:p>`)
if _, err := InjectSlot(minimalMergeDOCX(t, body), 0, "Goodbye", "firm.name"); err == nil {
t.Error("expected error when selection absent from run; got nil")
}
// Out-of-range run index.
if _, err := InjectSlot(minimalMergeDOCX(t, body), 9, "Hello", "firm.name"); err == nil {
t.Error("expected error for out-of-range run index; got nil")
}
}
func TestInjectSlot_RejectsInvalidSlotKey(t *testing.T) {
body := docBody(`<w:p><w:r><w:t>Hello</w:t></w:r></w:p>`)
if _, err := InjectSlot(minimalMergeDOCX(t, body), 0, "Hello", "9bad-key!"); err == nil {
t.Error("expected error for invalid slot key; got nil")
}
}