The first slice of the Submission generator v2 ("Composer") per the
design at docs/design-submission-generator-v2-2026-05-26.md §12 Slice A.
Ships the base concept + per-draft section seeding end-to-end with NO
change to the .docx render path — v1 export still works exactly as
today.
Schema (mig 146/147/148):
- paliad.submission_bases — catalog table; one row per template base
(slug, firm, proceeding_family, label_de/en, gitea_path, section_spec
jsonb, is_default_for[]). RLS: wide-open SELECT for authenticated
users, mutations admin-only (handler-enforced, no RLS write paths).
Seeded with 2 rows: hlc-letterhead → _firm-skeleton.docx; neutral →
_skeleton.docx. Each section_spec carries the 10-section default
(letterhead, caption, introduction, requests, facts, legal_argument,
evidence, exhibits, closing, signature) with bilingual labels +
bag-driven seed Markdown for caption/letterhead/signature.
- paliad.submission_drafts gains base_id (FK SET NULL, optional) +
composer_meta jsonb (default '{}'). Purely additive; pre-Composer
drafts keep base_id NULL → v1 fallback render path stays active.
- paliad.submission_sections — per-draft section rows (draft_id,
section_key, order_index, kind ∈ {prose,requests,evidence},
label_de/en, included, content_md_de/en). RLS mirrors
submission_drafts (owner-scoped + can_see_project, four policies).
Backend:
- BaseService (read-only Slice A): List + GetByID + GetBySlug +
GetDefaultForCode (firm/family fallback chain).
- SectionService: ListForDraft + Get + SeedFromSpec (transactional
multi-INSERT).
- SubmissionDraftService.AttachComposer wires both; Create resolves
the firm default base and seeds base_id + section rows in one tx.
Composer wiring is additive — when bases==nil the service stays
v1-shaped.
- Update accepts BaseID **uuid.UUID (set / clear / no-change).
- submissionDraftView gains BaseID, ComposerMeta, Sections fields.
- Routes: GET /api/submission-bases (catalog list). PATCH endpoints
on both project-scoped and global drafts accept "base_id".
Frontend:
- submission-draft.tsx: base picker dropdown above language toggle
(hidden until catalog loads); section-list pane above the preview
(hidden when no rows).
- client/submission-draft.ts: loadBases() parallel-fetches on boot;
paintBasePicker rebuilds <option> list on every paint; onBaseChange
PATCHes base_id and repaints; paintSectionList renders each section
read-only (label + kind chip + excluded badge + Markdown body).
- Per the brief: NO auto-upgrade of existing 11 drafts (that's Slice C).
Pre-Composer drafts get the picker (catalog still loads) but the
section pane stays hidden until they pick a base on a new draft.
Tests:
- TestFamilyOfCode + TestBaseSectionSpec_DecodeShape + _EmptyDecode
(pure unit, no DB).
- TestComposerSeedFlow (live, TEST_DATABASE_URL-gated): asserts mig 146
seeded 10 default sections on both bases; GetDefaultForCode picks
hlc-letterhead for HLC/de.inf.lg.erwidg; new draft via Create seeds
base_id + 10 section rows in tx with ascending order_index and
bilingual labels populated.
NO behavior change to .docx export — the v1 path stays sole render
path this slice. Composer's anchor-based assembly engine + MD→OOXML
walker land in Slice B.
Build hygiene: go build/vet/test -short clean; bun run build clean
(2900 i18n keys, data-i18n scan clean).
t-paliad-313
100 lines
3.4 KiB
Go
100 lines
3.4 KiB
Go
package services
|
|
|
|
// Unit tests for Composer base helpers — pure functions, no DB
|
|
// dependency (t-paliad-313 Slice A).
|
|
|
|
import "testing"
|
|
|
|
func TestFamilyOfCode(t *testing.T) {
|
|
cases := []struct {
|
|
in string
|
|
want string
|
|
}{
|
|
// canonical four-segment codes → first three segments
|
|
{"de.inf.lg.erwidg", "de.inf.lg"},
|
|
{"de.inf.lg.klage", "de.inf.lg"},
|
|
{"de.inf.olg.berufung", "de.inf.olg"},
|
|
{"upc.inf.cfi.soc", "upc.inf.cfi"},
|
|
{"upc.inf.cfi.sod", "upc.inf.cfi"},
|
|
{"upc.apl.cost.leave_app", "upc.apl.cost"},
|
|
{"epa.opp.opd.einspruch", "epa.opp.opd"},
|
|
// five-segment codes (rarely used in the corpus today) → still
|
|
// truncate to three
|
|
{"upc.inf.cfi.appeal_spawn.followup", "upc.inf.cfi"},
|
|
// shorter codes pass through unchanged
|
|
{"de.inf.lg", "de.inf.lg"},
|
|
{"de.inf", "de.inf"},
|
|
{"de", "de"},
|
|
// empty stays empty
|
|
{"", ""},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.in, func(t *testing.T) {
|
|
if got := familyOfCode(tc.in); got != tc.want {
|
|
t.Errorf("familyOfCode(%q) = %q; want %q", tc.in, got, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestBaseSectionSpec_DecodeShape(t *testing.T) {
|
|
// The default seed in mig 146 emits a JSON document the service
|
|
// must decode round-trip; this golden pins the exact field shape
|
|
// the editor expects.
|
|
raw := []byte(`{
|
|
"version": 1,
|
|
"stylemap": {
|
|
"paragraph": "HLpat-Body-B0",
|
|
"heading_1": "HLpat-Heading-H1",
|
|
"heading_2": "HLpat-Heading-H2",
|
|
"heading_3": "HLpat-Heading-H3",
|
|
"list_bullet": "HLpat-Body-B0",
|
|
"list_numbered": "HLpat-Body-B0",
|
|
"blockquote": "HLpat-Body-B1"
|
|
},
|
|
"defaults": [
|
|
{"section_key":"letterhead","kind":"prose","order_index":1,"label_de":"Briefkopf","label_en":"Letterhead","included":true,"seed_md_de":"hi","seed_md_en":"hi"},
|
|
{"section_key":"requests","kind":"requests","order_index":4,"label_de":"Anträge","label_en":"Requests","included":true,"seed_md_de":"","seed_md_en":""}
|
|
]
|
|
}`)
|
|
b := SubmissionBase{SectionSpecRaw: raw}
|
|
if err := b.decode(); err != nil {
|
|
t.Fatalf("decode: %v", err)
|
|
}
|
|
if b.SectionSpec.Version != 1 {
|
|
t.Errorf("Version = %d; want 1", b.SectionSpec.Version)
|
|
}
|
|
if got := b.SectionSpec.Stylemap["heading_1"]; got != "HLpat-Heading-H1" {
|
|
t.Errorf("Stylemap[heading_1] = %q; want HLpat-Heading-H1", got)
|
|
}
|
|
if len(b.SectionSpec.Defaults) != 2 {
|
|
t.Fatalf("Defaults len = %d; want 2", len(b.SectionSpec.Defaults))
|
|
}
|
|
first := b.SectionSpec.Defaults[0]
|
|
if first.SectionKey != "letterhead" || first.Kind != "prose" || first.OrderIndex != 1 {
|
|
t.Errorf("Defaults[0] = %+v; want letterhead/prose/1", first)
|
|
}
|
|
if first.SeedMDDE != "hi" || first.SeedMDEN != "hi" {
|
|
t.Errorf("Defaults[0] seed_md_* = %q/%q; want hi/hi", first.SeedMDDE, first.SeedMDEN)
|
|
}
|
|
second := b.SectionSpec.Defaults[1]
|
|
if second.SectionKey != "requests" || second.Kind != "requests" || second.OrderIndex != 4 {
|
|
t.Errorf("Defaults[1] = %+v; want requests/requests/4", second)
|
|
}
|
|
}
|
|
|
|
func TestBaseSectionSpec_EmptyDecode(t *testing.T) {
|
|
// A bare row (SectionSpecRaw == nil) decodes cleanly into the
|
|
// zero value — no panic, no garbage.
|
|
b := SubmissionBase{}
|
|
if err := b.decode(); err != nil {
|
|
t.Fatalf("decode empty: %v", err)
|
|
}
|
|
if b.SectionSpec.Version != 0 || len(b.SectionSpec.Defaults) != 0 {
|
|
t.Errorf("expected zero SectionSpec on empty raw; got %+v", b.SectionSpec)
|
|
}
|
|
if b.IsDefaultFor == nil {
|
|
t.Errorf("IsDefaultFor must be non-nil (empty slice) after decode; got nil")
|
|
}
|
|
}
|