Extends the Composer's MD → OOXML walker per the design at
docs/design-submission-generator-v2-2026-05-26.md §12 Slice D from
Slice B's paragraphs + B/I baseline to the full rich-prose feature set:
headings 1-3, bullet + numbered lists, blockquote, inline hyperlinks.
MD walker (internal/services/submission_md.go, +320 / -75 LoC):
- RenderMarkdownToOOXMLWithStyles is the new Slice-D entry point;
RenderMarkdownToOOXML stays as a thin back-compat wrapper.
- splitMarkdownBlocks classifies every line into one of:
paragraph, heading_1/2/3, list_bullet, list_numbered, blockquote.
CommonMark-style 3-space indent tolerance; "N. " and "N) " for
numbered. Blank-line spacing semantics preserved from Slice B.
- renderBlockParagraph applies stylemap[blk.styleKey] (with
fall-back to stylemap["paragraph"]). List blocks emit visible
"• " / "N. " prefix runs so the structure surfaces even if Word
isn't configured with auto-list-numbering — lawyer can apply a
real Word list style post-export. Numbered-list ordinals reset
on every non-list block (so "1. A\nplain\n1. C" renders 1./1.,
not 1./2.).
- parseInlineRuns adds `[label](url)` recognition. Each link gets
routed through the optional HyperlinkAllocator; the walker emits
`<w:hyperlink r:id="{rId}">…runs…</w:hyperlink>` with the
"Hyperlink" character style on each child run. Nil allocator
falls back to plain-text label (URL drops, label survives).
Composer (internal/services/submission_compose.go, +130 / -10 LoC):
- composerLinkAllocator hands the walker fresh rIds (rIdComposer1,
rIdComposer2, …) outside the base's existing namespace; same URL
shared across multiple sections dedupes to one rId.
- patchDocumentXMLRels appends matching <Relationship Type="…/hyperlink"
Target="URL" TargetMode="External"/> entries to
word/_rels/document.xml.rels. Idempotent on rIds already present;
synthesizes a fresh rels part when missing (defensive for stripped
bases). Returns the patched parts slice (caller must overwrite
because append may grow the backing array — fixed in this slice).
- Compose now passes the full stylemap (paragraph + heading_1/2/3 +
list_bullet + list_numbered + blockquote) into the walker, not
just the paragraph-style entry.
Frontend (frontend/src/client/submission-draft.ts):
- Toolbar adds H1/H2/H3 buttons (formatBlock h1/h2/h3), bullet
list, numbered list, blockquote, and a link button that prompts
for a URL + wraps the selection via execCommand("createLink").
- domToMarkdown serializer extends to <h1>/<h2>/<h3>, <ul>/<ol>
with per-item ordinal counter for numbered lists, <blockquote>,
and <a href="…"> → `[label](url)`. Nested <li> handling sits in
the ul/ol branch.
Tests (internal/services/submission_md_test.go, internal/services/
submission_compose_test.go):
- TestRenderMarkdownToOOXML_Heading1 / _Heading2And3 — stylemap
applied.
- _BulletList / _NumberedList / _NumberedListResetsOnNonList —
prefixes + ordinal counter.
- _Blockquote — stylemap applied.
- _Hyperlink — allocator called, w:hyperlink rId wired, Hyperlink
character style on label runs.
- _HyperlinkNilAllocatorFallsBackToPlain — label survives, no
hyperlink tag emitted.
- TestDetectBlockMarker — 13 marker / non-marker cases.
- TestComposer_HeadingsAndLists — end-to-end through Compose with
a multi-construct draft; verifies stylemap presence + content +
ordinal prefixes.
- TestComposer_HyperlinkWiresRels — body has the right
<w:hyperlink r:id="rIdComposer{N}">, document.xml.rels has the
matching <Relationship> rows with External target mode.
- TestComposer_HyperlinkDedupesByURL — two `[label](url)` references
to the same URL share one rId; second allocation gets no new
Relationship row.
Build hygiene: go build/vet/test -short clean (all packages); bun run
build clean (2906 i18n keys).
NOT in scope (Slice D's brief was rich-prose + toolbar):
- Numbering.xml audit on bases — current approach emits visible
"• " / "N. " prefix runs without depending on numbering.xml. A
future slice can swap to `<w:numPr>` if firm-style auto-numbering
becomes a hard requirement.
- DOM-from-Markdown on initial editor paint — the editor still uses
textContent=md, so toolbar-applied formatting reverts to literal
Markdown text after autosave + repaint. Acceptable trade-off for
Slice D; a future polish could parse MD into the DOM on paint.
- Tables, images, footnotes (still design §13 out of scope).
Hard rules honoured:
- NO new migrations (Slice D is pure code).
- NO behavior change for pre-Composer drafts (gate on draft.BaseID
unchanged).
- {{rule.X}} aliases preserved (placeholders pass through the walker
verbatim, get substituted by the v1 SubmissionRenderer pass).
- Q2 ratification preserved (no building_block_id lineage).
- Q9 ratification preserved (4-tier BB visibility from Slice C).
t-paliad-316 Slice D
396 lines
14 KiB
Go
396 lines
14 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)
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|