Files
paliad/internal/services/submission_compose_test.go
mAi 677849784c
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 D — rich prose (headings, lists, blockquote, hyperlinks) (m/paliad#141)
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
2026-05-26 20:15:28 +02:00

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)
}
}