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
479 lines
17 KiB
Go
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)
|
|
}
|
|
}
|