Files
paliad/internal/services/submission_compose_test.go
mAi 94310ba498
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
feat(submissions): Composer Slice E — specialist bases + base-swap content survival (m/paliad#141)
Two new firm-agnostic base templates + the generic generator that
produced them + a regression test pinning Q10's base-swap-content-
survival contract.

Mig 150: seeds two `submission_bases` rows with firm=NULL.

- lg-duesseldorf — proceeding_family='de.inf.lg'. Conservative
  German legal style: Times New Roman 11pt; plain black headings.
  Stylemap targets LG-Body / LG-Heading1..3 / LG-ListBullet /
  LG-ListNumber / LG-Quote.

- upc-formal — proceeding_family='upc.inf.cfi'. UPC court style:
  Calibri 11pt body; UPC-blue (#1F3864) headings; Cambria italic
  for blockquotes. Stylemap targets UPC-Body / UPC-Heading1..3 / …

Both rows ship the same 10-section spec.defaults shape as the Slice A
bases (letterhead → signature) with their own seed Markdown.

scripts/gen-submission-base/main.go (NEW, ~240 LoC):

- Generic generator with -preset flag. Two presets baked in
  (lg-duesseldorf + upc-formal). Each preset hard-codes typography
  (font, sizes, colour) so the lawyer can swap between bases and
  see chrome change while section content carries through unchanged.
- Output is byte-reproducible (zip mtime pinned to 2026-05-26 UTC).
- Emits a minimal Composer-mode .docx: [Content_Types].xml,
  _rels/.rels, word/_rels/document.xml.rels (empty envelope so the
  composer's hyperlink-rels patch from Slice D has somewhere to land),
  word/styles.xml (preset's full named-style block + "Hyperlink"
  character style for Slice D link runs), word/document.xml (anchor-
  only body in §6.1 default section order).

Gitea uploads (via mAi):

- 6 - material/Templates/Word/Paliad/Composer/lg-duesseldorf.docx
  blob SHA: 82f57b3cb3b54c755fc5ab36862bfd61b8aaa73e
- 6 - material/Templates/Word/Paliad/Composer/upc-formal.docx
  blob SHA: 41b9a388263ccc43ddc28b55caab301a4cf74fe8

These live under Composer/ (not under HLC/) so a future non-HLC
deployment serves the same cross-firm files.

Backend wiring:

- internal/handlers/files.go: two new fileRegistry entries
  (composerBaseLGDuesseldorfSlug, composerBaseUPCFormalSlug) +
  matching slugs in composerBaseSlugMap so fetchComposerBaseBytes
  routes the new catalog rows to the new Gitea objects.

Tests:

- TestComposer_BaseSwapPreservesContent — composes the same draft
  against an HLC-style stylemap AND an LG-style stylemap; asserts
  (a) content survives both ways, (b) each output carries the
  correct stylemap-entry stylenames, (c) neither output leaks the
  other's stylenames. Pins Q10's base-swap-survives-content
  contract.

Build hygiene: go build/vet/test -short clean (all packages);
bun run build clean.

NOT in scope (Slice E's brief was specialist bases + survival test):

- Generator coverage for HL Patents Style bases — gen-hl-skeleton-
  template stays as the per-firm path (it needs the proprietary
  .dotm source). gen-submission-base is for firm-agnostic bases.
- LG-Düsseldorf-court-style-guide deep fidelity — the LG preset is
  a conservative starting point; admin refines via the admin editor
  in a later slice if needed.
- numbering.xml carrying numId=1/2 — Slice D's MD walker emits
  visible "• " / "N. " prefixes that don't need numbering.xml;
  honours stylemap entry for indentation.

Hard rules honoured:

- Migration purely additive (`ON CONFLICT (slug) DO NOTHING`).
- NO behavior change for pre-Composer drafts.
- NO behavior change for existing hlc-letterhead + neutral seed
  rows.
- {{rule.X}} aliases preserved (walker passes placeholders through;
  v1 SubmissionRenderer pass substitutes).
- Q10 base-swap-content-survival pinned by new test.

t-paliad-317 Slice E
2026-05-26 20:21:12 +02:00

479 lines
17 KiB
Go

package services
// Unit tests for SubmissionComposer's pure splice logic — no DB
// dependency. The end-to-end Compose path is exercised by the live
// integration test in submission_section_service_live_test.go (Slice
// A) once anchors land in the seeded .docx; this file covers the
// anchor-splicing primitives and the section rendering glue.
import (
"archive/zip"
"bytes"
"context"
"strings"
"testing"
"github.com/google/uuid"
)
// minimalBaseBytes builds a tiny .docx zip with one document.xml body
// for the composer tests. The body content is provided by the caller
// so different splice scenarios can be exercised in-process.
func minimalBaseBytes(t *testing.T, body string) []byte {
t.Helper()
var buf bytes.Buffer
zw := zip.NewWriter(&buf)
parts := map[string]string{
"[Content_Types].xml": `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
<Default Extension="xml" ContentType="application/xml"/>
<Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/>
</Types>`,
"_rels/.rels": `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/>
</Relationships>`,
"word/document.xml": `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
<w:body>` + body + `</w:body>
</w:document>`,
}
for name, contents := range parts {
w, err := zw.Create(name)
if err != nil {
t.Fatalf("zip create %s: %v", name, err)
}
if _, err := w.Write([]byte(contents)); err != nil {
t.Fatalf("zip write %s: %v", name, err)
}
}
if err := zw.Close(); err != nil {
t.Fatalf("zip close: %v", err)
}
return buf.Bytes()
}
// extractDocumentXML pulls word/document.xml out of a .docx zip for
// assertions.
func extractDocumentXML(t *testing.T, data []byte) string {
return extractZipEntry(t, data, "word/document.xml")
}
// extractZipEntry pulls any named entry out of a .docx zip.
func extractZipEntry(t *testing.T, data []byte, name string) string {
t.Helper()
zr, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
if err != nil {
t.Fatalf("open zip: %v", err)
}
for _, f := range zr.File {
if f.Name != name {
continue
}
rc, err := f.Open()
if err != nil {
t.Fatalf("open %s: %v", name, err)
}
defer rc.Close()
var buf bytes.Buffer
if _, err := buf.ReadFrom(rc); err != nil {
t.Fatalf("read %s: %v", name, err)
}
return buf.String()
}
t.Fatalf("%s not found in zip", name)
return ""
}
// composerBase returns a SubmissionBase wired with the neutral
// stylemap for composer tests.
func composerBase() *SubmissionBase {
return &SubmissionBase{
ID: uuid.New(),
Slug: "test-base",
SectionSpec: BaseSectionSpec{
Version: 1,
Stylemap: map[string]string{
"paragraph": "Normal",
},
},
}
}
func TestComposer_AppendMode_NoAnchors(t *testing.T) {
// Base has no anchors → composer appends sections before sectPr.
base := composerBase()
body := `<w:p><w:r><w:t>Static chrome</w:t></w:r></w:p><w:sectPr/>`
baseBytes := minimalBaseBytes(t, body)
composer := NewSubmissionComposer(NewSubmissionRenderer())
sections := []SubmissionSection{
{ID: uuid.New(), SectionKey: "facts", OrderIndex: 1, Kind: "prose", Included: true, ContentMDDE: "Section text"},
}
out, err := composer.Compose(context.Background(), ComposeOptions{
Sections: sections,
Base: base,
BaseBytes: baseBytes,
Lang: "de",
Vars: PlaceholderMap{},
})
if err != nil {
t.Fatalf("Compose: %v", err)
}
docXML := extractDocumentXML(t, out)
if !strings.Contains(docXML, "Static chrome") {
t.Errorf("base chrome dropped: %q", docXML)
}
if !strings.Contains(docXML, "Section text") {
t.Errorf("section content missing: %q", docXML)
}
// Section must land before sectPr (rule of thumb: it's an end-of-body element).
staticIdx := strings.Index(docXML, "Section text")
sectPrIdx := strings.Index(docXML, "<w:sectPr")
if staticIdx < 0 || sectPrIdx < 0 || staticIdx > sectPrIdx {
t.Errorf("section landed after sectPr: section=%d sectPr=%d", staticIdx, sectPrIdx)
}
}
func TestComposer_AnchorMode_SpliceContent(t *testing.T) {
base := composerBase()
body := `<w:p><w:r><w:t>Header</w:t></w:r></w:p>` +
`<w:p><w:r><w:t>{{#section:facts}}</w:t></w:r></w:p>` +
`<w:p><w:r><w:t>(seed)</w:t></w:r></w:p>` +
`<w:p><w:r><w:t>{{/section:facts}}</w:t></w:r></w:p>` +
`<w:p><w:r><w:t>Footer</w:t></w:r></w:p>`
baseBytes := minimalBaseBytes(t, body)
composer := NewSubmissionComposer(NewSubmissionRenderer())
sections := []SubmissionSection{
{ID: uuid.New(), SectionKey: "facts", OrderIndex: 1, Kind: "prose", Included: true, ContentMDDE: "Real prose"},
}
out, err := composer.Compose(context.Background(), ComposeOptions{
Sections: sections, Base: base, BaseBytes: baseBytes, Lang: "de",
})
if err != nil {
t.Fatalf("Compose: %v", err)
}
docXML := extractDocumentXML(t, out)
if !strings.Contains(docXML, "Header") || !strings.Contains(docXML, "Footer") {
t.Errorf("base chrome dropped: %q", docXML)
}
if !strings.Contains(docXML, "Real prose") {
t.Errorf("section content missing: %q", docXML)
}
// Anchor paragraphs themselves must be gone.
if strings.Contains(docXML, "{{#section:facts}}") || strings.Contains(docXML, "{{/section:facts}}") {
t.Errorf("anchor markers survived: %q", docXML)
}
// Seed content between anchors must be gone (replaced by the
// composed section).
if strings.Contains(docXML, "(seed)") {
t.Errorf("anchor-spanned seed survived: %q", docXML)
}
}
func TestComposer_ExcludedSection_DropsAnchorPair(t *testing.T) {
base := composerBase()
body := `<w:p><w:r><w:t>Header</w:t></w:r></w:p>` +
`<w:p><w:r><w:t>{{#section:exhibits}}</w:t></w:r></w:p>` +
`<w:p><w:r><w:t>(default)</w:t></w:r></w:p>` +
`<w:p><w:r><w:t>{{/section:exhibits}}</w:t></w:r></w:p>` +
`<w:p><w:r><w:t>Footer</w:t></w:r></w:p>`
baseBytes := minimalBaseBytes(t, body)
composer := NewSubmissionComposer(NewSubmissionRenderer())
sections := []SubmissionSection{
{ID: uuid.New(), SectionKey: "exhibits", OrderIndex: 8, Kind: "prose", Included: false, ContentMDDE: "ignored"},
}
out, err := composer.Compose(context.Background(), ComposeOptions{
Sections: sections, Base: base, BaseBytes: baseBytes, Lang: "de",
})
if err != nil {
t.Fatalf("Compose: %v", err)
}
docXML := extractDocumentXML(t, out)
if strings.Contains(docXML, "{{#section:exhibits}}") || strings.Contains(docXML, "{{/section:exhibits}}") {
t.Errorf("anchors for excluded section survived: %q", docXML)
}
if strings.Contains(docXML, "ignored") {
t.Errorf("excluded section content rendered: %q", docXML)
}
}
func TestComposer_PlaceholdersResolve(t *testing.T) {
base := composerBase()
body := `<w:p><w:r><w:t>{{#section:greeting}}</w:t></w:r></w:p>` +
`<w:p><w:r><w:t>{{/section:greeting}}</w:t></w:r></w:p>`
baseBytes := minimalBaseBytes(t, body)
composer := NewSubmissionComposer(NewSubmissionRenderer())
sections := []SubmissionSection{
{ID: uuid.New(), SectionKey: "greeting", OrderIndex: 1, Kind: "prose", Included: true, ContentMDDE: "Hallo {{user.name}}"},
}
out, err := composer.Compose(context.Background(), ComposeOptions{
Sections: sections, Base: base, BaseBytes: baseBytes, Lang: "de",
Vars: PlaceholderMap{"user.name": "Maria Schmidt"},
})
if err != nil {
t.Fatalf("Compose: %v", err)
}
docXML := extractDocumentXML(t, out)
if !strings.Contains(docXML, "Hallo") || !strings.Contains(docXML, "Maria Schmidt") {
t.Errorf("placeholder not substituted: %q", docXML)
}
if strings.Contains(docXML, "{{user.name}}") {
t.Errorf("placeholder survived: %q", docXML)
}
}
func TestComposer_LangPicksColumn(t *testing.T) {
base := composerBase()
body := `<w:p><w:r><w:t>{{#section:facts}}</w:t></w:r></w:p><w:p><w:r><w:t>{{/section:facts}}</w:t></w:r></w:p>`
baseBytes := minimalBaseBytes(t, body)
composer := NewSubmissionComposer(NewSubmissionRenderer())
sections := []SubmissionSection{
{ID: uuid.New(), SectionKey: "facts", OrderIndex: 1, Kind: "prose", Included: true,
ContentMDDE: "deutscher text", ContentMDEN: "english text"},
}
deOut, _ := composer.Compose(context.Background(), ComposeOptions{
Sections: sections, Base: base, BaseBytes: baseBytes, Lang: "de",
})
enOut, _ := composer.Compose(context.Background(), ComposeOptions{
Sections: sections, Base: base, BaseBytes: baseBytes, Lang: "en",
})
deXML := extractDocumentXML(t, deOut)
enXML := extractDocumentXML(t, enOut)
if !strings.Contains(deXML, "deutscher text") || strings.Contains(deXML, "english text") {
t.Errorf("DE pick failed: %q", deXML)
}
if !strings.Contains(enXML, "english text") || strings.Contains(enXML, "deutscher text") {
t.Errorf("EN pick failed: %q", enXML)
}
}
// Slice D — rich-prose end-to-end through the composer.
func TestComposer_HeadingsAndLists(t *testing.T) {
base := composerBase()
// Extend the stylemap so the walker has named styles to apply.
base.SectionSpec.Stylemap["heading_1"] = "Heading1"
base.SectionSpec.Stylemap["list_bullet"] = "ListBullet"
base.SectionSpec.Stylemap["list_numbered"] = "ListNumber"
base.SectionSpec.Stylemap["blockquote"] = "Quote"
body := `<w:p><w:r><w:t>{{#section:body}}</w:t></w:r></w:p><w:p><w:r><w:t>{{/section:body}}</w:t></w:r></w:p>`
baseBytes := minimalBaseBytes(t, body)
composer := NewSubmissionComposer(NewSubmissionRenderer())
md := "# Heading line\n\n- bullet a\n- bullet b\n\n1. first\n2. second\n\n> quoted"
sections := []SubmissionSection{
{ID: uuid.New(), SectionKey: "body", OrderIndex: 1, Kind: "prose", Included: true, ContentMDDE: md},
}
out, err := composer.Compose(context.Background(), ComposeOptions{
Sections: sections, Base: base, BaseBytes: baseBytes, Lang: "de",
})
if err != nil {
t.Fatalf("Compose: %v", err)
}
docXML := extractDocumentXML(t, out)
for _, want := range []string{
`<w:pStyle w:val="Heading1"/>`,
`<w:pStyle w:val="ListBullet"/>`,
`<w:pStyle w:val="ListNumber"/>`,
`<w:pStyle w:val="Quote"/>`,
"Heading line",
"bullet a",
"bullet b",
`<w:t xml:space="preserve">1. </w:t>`,
`<w:t xml:space="preserve">2. </w:t>`,
"first",
"second",
"quoted",
} {
if !strings.Contains(docXML, want) {
t.Errorf("expected %q in composed body; got: %s", want, docXML)
}
}
}
func TestComposer_HyperlinkWiresRels(t *testing.T) {
base := composerBase()
body := `<w:p><w:r><w:t>{{#section:facts}}</w:t></w:r></w:p><w:p><w:r><w:t>{{/section:facts}}</w:t></w:r></w:p>`
baseBytes := minimalBaseBytes(t, body)
composer := NewSubmissionComposer(NewSubmissionRenderer())
sections := []SubmissionSection{
{ID: uuid.New(), SectionKey: "facts", OrderIndex: 1, Kind: "prose", Included: true,
ContentMDDE: "See [BGH](https://bgh.bund.de) and [EuGH](https://curia.europa.eu)."},
}
out, err := composer.Compose(context.Background(), ComposeOptions{
Sections: sections, Base: base, BaseBytes: baseBytes, Lang: "de",
})
if err != nil {
t.Fatalf("Compose: %v", err)
}
// Body: hyperlink elements with composer rIds.
docXML := extractDocumentXML(t, out)
if !strings.Contains(docXML, `<w:hyperlink r:id="rIdComposer1">`) ||
!strings.Contains(docXML, `<w:hyperlink r:id="rIdComposer2">`) {
t.Errorf("hyperlink rIds missing in body: %q", docXML)
}
if !strings.Contains(docXML, "BGH") || !strings.Contains(docXML, "EuGH") {
t.Errorf("hyperlink labels missing: %q", docXML)
}
// Rels: the matching <Relationship> rows must be in
// word/_rels/document.xml.rels with the URL targets + External mode.
rels := extractZipEntry(t, out, "word/_rels/document.xml.rels")
for _, want := range []string{
`Id="rIdComposer1"`,
`Id="rIdComposer2"`,
`Target="https://bgh.bund.de"`,
`Target="https://curia.europa.eu"`,
`TargetMode="External"`,
"hyperlink", // the Type URL contains "hyperlink"
} {
if !strings.Contains(rels, want) {
t.Errorf("expected %q in document.xml.rels: %s", want, rels)
}
}
}
func TestComposer_HyperlinkDedupesByURL(t *testing.T) {
base := composerBase()
body := `<w:p><w:r><w:t>{{#section:facts}}</w:t></w:r></w:p><w:p><w:r><w:t>{{/section:facts}}</w:t></w:r></w:p>`
baseBytes := minimalBaseBytes(t, body)
composer := NewSubmissionComposer(NewSubmissionRenderer())
// Same URL referenced twice — should produce one rId, two
// <w:hyperlink> elements both pointing at it.
sections := []SubmissionSection{
{ID: uuid.New(), SectionKey: "facts", OrderIndex: 1, Kind: "prose", Included: true,
ContentMDDE: "First [BGH](https://bgh.bund.de) and again [Bundesgerichtshof](https://bgh.bund.de)."},
}
out, _ := composer.Compose(context.Background(), ComposeOptions{
Sections: sections, Base: base, BaseBytes: baseBytes, Lang: "de",
})
docXML := extractDocumentXML(t, out)
if strings.Count(docXML, `<w:hyperlink r:id="rIdComposer1">`) != 2 {
t.Errorf("expected 2 hyperlinks sharing rIdComposer1; got: %s", docXML)
}
if strings.Contains(docXML, `<w:hyperlink r:id="rIdComposer2">`) {
t.Errorf("dedupe failed — second rId allocated for same URL: %s", docXML)
}
}
// Slice E — base swap preserves section content; only chrome / styles
// change. This is the design's "Markdown is base-agnostic" contract
// from Q10 + §5.3 ratification. We compose the SAME section text
// against two bases with DIFFERENT stylemaps and verify:
// 1. The section text appears in both outputs.
// 2. Each base applies its OWN paragraph style (the stylemap diff
// is the only visible delta in the document body).
func TestComposer_BaseSwapPreservesContent(t *testing.T) {
body := `<w:p><w:r><w:t>{{#section:facts}}</w:t></w:r></w:p><w:p><w:r><w:t>{{/section:facts}}</w:t></w:r></w:p>`
baseBytes := minimalBaseBytes(t, body)
// Base A: HLC-style stylemap.
hlc := &SubmissionBase{
ID: uuid.New(), Slug: "hlc-test",
SectionSpec: BaseSectionSpec{
Stylemap: map[string]string{
"paragraph": "HLpat-Body-B0",
"heading_1": "HLpat-Heading-H1",
},
},
}
// Base B: LG-style stylemap.
lg := &SubmissionBase{
ID: uuid.New(), Slug: "lg-test",
SectionSpec: BaseSectionSpec{
Stylemap: map[string]string{
"paragraph": "LG-Body",
"heading_1": "LG-Heading1",
},
},
}
// Identical Markdown content rendered against each base.
md := "# Heading line\n\nA paragraph of substantive prose."
sections := []SubmissionSection{
{ID: uuid.New(), SectionKey: "facts", OrderIndex: 1, Kind: "prose", Included: true, ContentMDDE: md},
}
composer := NewSubmissionComposer(NewSubmissionRenderer())
hlcOut, err := composer.Compose(context.Background(), ComposeOptions{
Sections: sections, Base: hlc, BaseBytes: baseBytes, Lang: "de",
})
if err != nil {
t.Fatalf("Compose hlc: %v", err)
}
lgOut, err := composer.Compose(context.Background(), ComposeOptions{
Sections: sections, Base: lg, BaseBytes: baseBytes, Lang: "de",
})
if err != nil {
t.Fatalf("Compose lg: %v", err)
}
hlcXML := extractDocumentXML(t, hlcOut)
lgXML := extractDocumentXML(t, lgOut)
// Content survives both ways.
for _, want := range []string{"Heading line", "A paragraph of substantive prose."} {
if !strings.Contains(hlcXML, want) {
t.Errorf("HLC output missing content %q", want)
}
if !strings.Contains(lgXML, want) {
t.Errorf("LG output missing content %q", want)
}
}
// Stylemap diff actually shows up in the body — HLC's headings
// use HLpat-Heading-H1, LG's use LG-Heading1. If the composer
// silently passed the wrong stylemap, this would fire.
if !strings.Contains(hlcXML, `<w:pStyle w:val="HLpat-Heading-H1"/>`) {
t.Errorf("HLC heading style missing: %s", hlcXML)
}
if !strings.Contains(lgXML, `<w:pStyle w:val="LG-Heading1"/>`) {
t.Errorf("LG heading style missing: %s", lgXML)
}
if strings.Contains(hlcXML, `<w:pStyle w:val="LG-Heading1"/>`) {
t.Errorf("HLC output leaked LG style: %s", hlcXML)
}
if strings.Contains(lgXML, `<w:pStyle w:val="HLpat-Heading-H1"/>`) {
t.Errorf("LG output leaked HLC style: %s", lgXML)
}
}
func TestComposer_OrderIndexAscending(t *testing.T) {
base := composerBase()
// No anchors → both sections append in order_index ASC order
// before sectPr.
body := `<w:sectPr/>`
baseBytes := minimalBaseBytes(t, body)
composer := NewSubmissionComposer(NewSubmissionRenderer())
sections := []SubmissionSection{
{ID: uuid.New(), SectionKey: "second", OrderIndex: 2, Kind: "prose", Included: true, ContentMDDE: "ZWEITER"},
{ID: uuid.New(), SectionKey: "first", OrderIndex: 1, Kind: "prose", Included: true, ContentMDDE: "ERSTER"},
}
out, err := composer.Compose(context.Background(), ComposeOptions{
Sections: sections, Base: base, BaseBytes: baseBytes, Lang: "de",
})
if err != nil {
t.Fatalf("Compose: %v", err)
}
docXML := extractDocumentXML(t, out)
firstIdx := strings.Index(docXML, "ERSTER")
secondIdx := strings.Index(docXML, "ZWEITER")
if firstIdx < 0 || secondIdx < 0 || firstIdx > secondIdx {
t.Errorf("order_index ASC not honoured: ERSTER=%d ZWEITER=%d", firstIdx, secondIdx)
}
}