Files
paliad/internal/services/deadline_search_service_test.go
mAi 4131d2e2a6 feat(t-paliad-207): Verfahrensablauf + Fristenrechner polish (jurisdiction prefix, trigger-event, flag rows, rule links, R.19 label)
Five intertwined fixes m surfaced in the interactive session:

1. **Jurisdiction prefix on the picked proceeding** — the collapsed
   summary chip and the result header now read "UPC Verletzungsverfahren"
   / "DE Verletzungsklage (LG)" instead of the bare proceeding name.
   Disambiguates the 4 redundancies in the corpus once the picker
   collapses. Driven by .proceeding-group[data-forum] which is already
   on every group.

2. **Trigger Event label = root rule** — step 2's "Auslösendes Ereignis"
   line now shows the first event in the proceeding (e.g. Klageerhebung,
   Nichtigkeitsklage) instead of the proceeding name. Populated from
   the calc response (isRootEvent=true) on every render; em-dash
   placeholder while step 3 hasn't rendered yet. lang-change keeps it
   coherent.

3. **Flag rows on /tools/verfahrensablauf** — Slice 1 of t-paliad-179
   stripped the with_ccr / with_amend / with_cci toggles when it lifted
   the shared renderer; they never came back. Lifted the 4 existing
   rows from fristenrechner.tsx plus 2 new with_po rows (RoP 19.1
   preliminary objection, mig 095) — same wiring + show/hide rules on
   both surfaces. with_amend stays nested under with_ccr on upc.inf.cfi
   (R.30 only with a CCR).

4. **Rule references → youpc.org/laws links** — new
   BuildLegalSourceURL(src) maps the structured legal_source code to
   the youpc permalink for the UPC corpus (UPCRoP / UPCA / UPCS today;
   39 of 91 active rules carry UPC.RoP.* and now link). DE/EPA/EU
   bodies have no youpc home yet and render as plain display text —
   filed as m/paliad#39. Wired through UIDeadline.LegalSourceDisplay +
   LegalSourceURL so deadlineCardHtml can render <a target="_blank"
   rel="noopener"> when the URL is set.

5. **R.19 label: "Vorab-Einrede" → "Einspruch"** — m's correction. DE
   only (EN canonical UPC RoP term stays "Preliminary objection").
   Client-side change only — i18n + JSX fallbacks. The matching DB
   rename on the two rule-name rows folds into joule's broader mig 097
   (legal-citation backfill, t-paliad-208 follow-up). The live UPDATE
   applied during the session is captured under that audit reason; the
   no-op when joule's mig re-applies is harmless.

Build hygiene:
- go build ./... + go vet ./... clean
- new test TestBuildLegalSourceURL covers UPC corpus + DE/EPA/EU
  fall-through + edge cases (empty input, malformed source)
- bun run build clean (2417 i18n keys total)

Rebased on origin/main @ d126913 (ohm's submission_code rename
workstream B) — no conflicts in this commit's surface area.

Branch: mai/fermi/interactive-session. NOT self-merged.
2026-05-18 17:29:14 +02:00

547 lines
17 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package services
import (
"context"
"os"
"strings"
"testing"
"github.com/jmoiron/sqlx"
_ "github.com/lib/pq"
"mgit.msbls.de/m/paliad/internal/db"
)
// TestFormatLegalSourceDisplay covers the structured-form → display-form
// conversion the search API exposes alongside legal_source.
func TestFormatLegalSourceDisplay(t *testing.T) {
cases := []struct {
in, want string
}{
{"UPC.RoP.23.1", "UPC RoP R.23(1)"},
{"UPC.RoP.139", "UPC RoP R.139"},
{"UPC.RoP.220.1", "UPC RoP R.220(1)"},
{"DE.PatG.82.1", "PatG §82(1)"},
{"DE.PatG.111.1", "PatG §111(1)"},
{"DE.PatG.59.3", "PatG §59(3)"},
{"DE.ZPO.276.1", "ZPO §276(1)"},
{"DE.ZPO.517", "ZPO §517"},
{"EU.EPÜ.108", "EPÜ Art.108"},
{"EU.EPÜ.112a", "EPÜ Art.112a"},
{"EU.EPC-R.79.1", "EPC R.79(1)"},
{"EU.RPBA.12.1.c", "RPBA Art.12(1)(c)"},
{"", ""},
}
for _, c := range cases {
got := FormatLegalSourceDisplay(c.in)
if got != c.want {
t.Errorf("FormatLegalSourceDisplay(%q) = %q, want %q", c.in, got, c.want)
}
}
}
// TestBuildLegalSourceURL covers the structured-form → youpc.org/laws
// permalink mapping. Only the UPC corpus has a youpc home today;
// DE/EPA/EU bodies fall through to the empty string and the renderer
// shows display text without a link.
func TestBuildLegalSourceURL(t *testing.T) {
cases := []struct {
in, want string
}{
{"UPC.RoP.23.1", "https://youpc.org/laws#UPCRoP.023"},
{"UPC.RoP.139", "https://youpc.org/laws#UPCRoP.139"},
{"UPC.RoP.220.1", "https://youpc.org/laws#UPCRoP.220"},
{"UPC.RoP.29.a", "https://youpc.org/laws#UPCRoP.029"},
{"UPC.RoP.49.2.a", "https://youpc.org/laws#UPCRoP.049"},
{"UPC.RoP.19.1", "https://youpc.org/laws#UPCRoP.019"},
{"UPC.UPCA.83", "https://youpc.org/laws#UPCA.083"},
{"UPC.UPCS.40.1", "https://youpc.org/laws#UPCS.040"},
{"DE.PatG.82.1", ""},
{"DE.ZPO.276.1", ""},
{"EU.EPÜ.108", ""},
{"EU.EPC-R.79.1", ""},
{"EU.RPBA.12.1.c", ""},
{"UPC.RoP", ""},
{"", ""},
}
for _, c := range cases {
got := BuildLegalSourceURL(c.in)
if got != c.want {
t.Errorf("BuildLegalSourceURL(%q) = %q, want %q", c.in, got, c.want)
}
}
}
// TestNormalizeQuery covers the input-side legal-prefix stripping that
// keeps "§ 82" / "Art. 108" findable against structured legal_source
// values that don't carry the prefix.
func TestNormalizeQuery(t *testing.T) {
cases := []struct {
in, want string
}{
{"§ 82", "82"},
{"§82", "82"},
{"Art. 108", "108"},
{"art.108", "108"},
{"Section 276", "276"},
{" Wiedereinsetzung ", "Wiedereinsetzung"},
{"RoP 23", "RoP 23"}, // RoP is meaningful, kept verbatim
{"", ""},
}
for _, c := range cases {
got := normalizeQuery(c.in)
if got != c.want {
t.Errorf("normalizeQuery(%q) = %q, want %q", c.in, got, c.want)
}
}
}
// TestDeadlineSearch is the Phase C golden table — the binding spec for
// what the search backend must answer for a fixed set of HLC vocabulary
// queries. Skipped when TEST_DATABASE_URL is unset, mirroring the other
// live-DB tests in this package.
func TestDeadlineSearch(t *testing.T) {
url := os.Getenv("TEST_DATABASE_URL")
if url == "" {
t.Skip("TEST_DATABASE_URL not set — skipping live DB test")
}
if err := db.ApplyMigrations(url); err != nil {
t.Fatalf("apply migrations: %v", err)
}
pool, err := sqlx.Connect("postgres", url)
if err != nil {
t.Fatalf("connect: %v", err)
}
defer pool.Close()
ctx := context.Background()
if err := RefreshSearchView(ctx, pool); err != nil {
t.Fatalf("refresh search view: %v", err)
}
svc := NewDeadlineSearchService(pool)
t.Run("Klageerwiderung returns the statement-of-defence concept with multi-jurisdiction pills", func(t *testing.T) {
resp, err := svc.Search(ctx, "Klageerwiderung", SearchOptions{Limit: 12})
if err != nil {
t.Fatalf("search: %v", err)
}
card := findCardBySlug(t, resp, "statement-of-defence")
// Expected at minimum: UPC R.23, ZPO §276, PatG §82, EPC R.79, PatG §59.
// The actual data has 9 rule rows (upc.inf.cfi, upc.rev.cfi,
// upc.pi.cfi, upc.dmgs.cfi, upc.disc.cfi, de.inf.lg,
// de.null.bpatg, epa.opp.opd, dpma.opp.dpma).
mustHaveLegalSource(t, card, "UPC.RoP.23.1")
mustHaveLegalSource(t, card, "DE.ZPO.276.1")
mustHaveLegalSource(t, card, "DE.PatG.82.1")
mustHaveLegalSource(t, card, "EU.EPC-R.79.1")
mustHaveLegalSource(t, card, "DE.PatG.59.3")
mustHaveProceedingCodes(t, card, CodeUPCInfringement, CodeDEInfringementLG, CodeDENullityBPatG, CodeEPAOpposition, CodeDPMAOpposition)
})
t.Run("RoP 23 returns the UPC R.23 hit", func(t *testing.T) {
resp, err := svc.Search(ctx, "RoP 23", SearchOptions{Limit: 12})
if err != nil {
t.Fatalf("search: %v", err)
}
if len(resp.Cards) == 0 {
t.Fatalf("expected at least one card for 'RoP 23', got 0")
}
// Statement-of-defence is the canonical R.23 concept; it must be
// among the cards (not necessarily rank 1 because rule_code-based
// trigram only weighs 0.9, and several rules with adjacent codes
// also hit).
card := findCardBySlug(t, resp, "statement-of-defence")
mustHaveLegalSource(t, card, "UPC.RoP.23.1")
})
t.Run("§ 82 returns the BPatG nullity-defence card", func(t *testing.T) {
resp, err := svc.Search(ctx, "§ 82", SearchOptions{Limit: 12})
if err != nil {
t.Fatalf("search: %v", err)
}
// PatG §82(1) is the BPatG Klageerwiderung anchor — should fall
// under the statement-of-defence concept.
card := findCardBySlug(t, resp, "statement-of-defence")
mustHaveLegalSource(t, card, "DE.PatG.82.1")
})
t.Run("Wiedereinsetzung returns the cross-cutting concept with 4 trigger pills", func(t *testing.T) {
resp, err := svc.Search(ctx, "Wiedereinsetzung", SearchOptions{Limit: 12})
if err != nil {
t.Fatalf("search: %v", err)
}
card := findCardBySlug(t, resp, "wiedereinsetzung")
// Exactly 4 trigger pills: PatG §123 (DE), ZPO §233 (DE), EPÜ
// Art.122 (EU), DPMA §123 — corresponding to trigger_event ids
// 200..203 from migration 046.
triggerIDs := []int64{}
for _, p := range card.Pills {
if p.Kind != "trigger" {
t.Errorf("Wiedereinsetzung card has non-trigger pill: kind=%q proc=%v", p.Kind, p.Proceeding)
}
if p.TriggerEventID != nil {
triggerIDs = append(triggerIDs, *p.TriggerEventID)
}
}
want := map[int64]bool{200: true, 201: true, 202: true, 203: true}
if len(triggerIDs) != 4 {
t.Fatalf("Wiedereinsetzung card: got %d trigger pills, want 4 (ids 200..203)", len(triggerIDs))
}
for _, id := range triggerIDs {
if !want[id] {
t.Errorf("Wiedereinsetzung card has unexpected trigger id %d", id)
}
}
})
t.Run("party filter narrows to defendant-only", func(t *testing.T) {
resp, err := svc.Search(ctx, "Klageerwiderung", SearchOptions{Party: "claimant", Limit: 12})
if err != nil {
t.Fatalf("search: %v", err)
}
// Statement-of-defence is filed by the defendant. Filtering
// party=claimant should NOT drop the concept entirely — the
// effective_party can vary per pill (e.g. epa.opp.opd Erwiderung
// is owed by the patentee/claimant). At least it must not
// return any card with EVERY pill on defendant side.
for _, c := range resp.Cards {
allDefendant := true
for _, p := range c.Pills {
if p.Party != "defendant" {
allDefendant = false
break
}
}
if allDefendant {
t.Errorf("party=claimant filter returned a card whose every pill is defendant: %q", c.Concept.Slug)
}
}
})
t.Run("source filter narrows to UPC.RoP-prefixed pills only", func(t *testing.T) {
resp, err := svc.Search(ctx, "Klageerwiderung", SearchOptions{Source: "UPC.RoP", Limit: 12})
if err != nil {
t.Fatalf("search: %v", err)
}
for _, c := range resp.Cards {
for _, p := range c.Pills {
if p.LegalSource != nil && !startsWith(*p.LegalSource, "UPC.RoP") {
t.Errorf("source=UPC.RoP filter leaked a non-UPC.RoP pill: %q", *p.LegalSource)
}
}
}
})
t.Run("empty query returns empty cards (browse surface is Phase D)", func(t *testing.T) {
resp, err := svc.Search(ctx, "", SearchOptions{Limit: 12})
if err != nil {
t.Fatalf("search: %v", err)
}
if len(resp.Cards) != 0 {
t.Errorf("empty q should return 0 cards, got %d", len(resp.Cards))
}
})
t.Run("limit is capped at MaxLimit", func(t *testing.T) {
resp, err := svc.Search(ctx, "Klageerwiderung", SearchOptions{Limit: 1000, MaxLimit: 5})
if err != nil {
t.Fatalf("search: %v", err)
}
if len(resp.Cards) > 5 {
t.Errorf("limit cap not enforced: got %d cards, want ≤ 5", len(resp.Cards))
}
})
t.Run("legal_source_display is rendered for rule pills with a legal_source", func(t *testing.T) {
resp, err := svc.Search(ctx, "Klageerwiderung", SearchOptions{Limit: 12})
if err != nil {
t.Fatalf("search: %v", err)
}
card := findCardBySlug(t, resp, "statement-of-defence")
for _, p := range card.Pills {
if p.LegalSource == nil {
continue
}
if p.LegalSourceDisplay == nil || *p.LegalSourceDisplay == "" {
t.Errorf("rule pill with legal_source=%q is missing legal_source_display", *p.LegalSource)
}
}
})
// v4 (t-paliad-136): event-category narrowing must apply per-leaf
// (concept_id, proceeding_type_code) tuples, not just concept_id. The
// v3 implementation collapsed tuples to flat concept_ids and surfaced
// pills for every proceeding the matview had a row for. m's repro:
// picking "CMS-Eingang → Gegenseite → UPC Verletzung" leaked DE/EPA/
// DPMA pills.
ec := NewEventCategoryService(pool)
svc.SetEventCategoryService(ec)
t.Run("v4 event_category_slug narrows pills to per-leaf proceeding (UPC infringement subtree)", func(t *testing.T) {
resp, err := svc.Search(ctx, "", SearchOptions{
EventCategorySlug: "cms-eingang.gegenseite.upc-inf",
Limit: 200,
})
if err != nil {
t.Fatalf("search: %v", err)
}
// Every rule pill must be a UPC proceeding. The seed maps every
// concept under this subtree to upc.inf.cfi or upc.apl.merits — no
// DE/EPA/DPMA codes should leak.
allowedRulePrefix := []string{"upc."}
for _, c := range resp.Cards {
for _, p := range c.Pills {
if p.Kind != "rule" {
continue
}
if p.Proceeding == nil {
t.Errorf("rule pill on %q has no proceeding", c.Concept.Slug)
continue
}
ok := false
for _, prefix := range allowedRulePrefix {
if strings.HasPrefix(p.Proceeding.Code, prefix) {
ok = true
break
}
}
if !ok {
t.Errorf("subtree narrowing leaked non-UPC pill on %q: proc=%s rule=%s",
c.Concept.Slug, p.Proceeding.Code, p.RuleLocalCode)
}
}
}
})
t.Run("v4 event_category_slug honours per-tuple narrowing (klageerwiderung-mit-ccr leaf)", func(t *testing.T) {
resp, err := svc.Search(ctx, "", SearchOptions{
EventCategorySlug: "cms-eingang.gegenseite.upc-inf.klageerwiderung-mit-ccr",
Limit: 200,
})
if err != nil {
t.Fatalf("search: %v", err)
}
// Junction maps three concepts × upc.inf.cfi for this leaf:
// defence-to-counterclaim-for-revocation, application-to-amend,
// reply-to-defence. Every pill must be upc.inf.cfi.
for _, c := range resp.Cards {
for _, p := range c.Pills {
if p.Kind != "rule" {
continue
}
if p.Proceeding == nil || p.Proceeding.Code != CodeUPCInfringement {
code := "(nil)"
if p.Proceeding != nil {
code = p.Proceeding.Code
}
t.Errorf("klageerwiderung-mit-ccr leaf leaked non-%s pill on %q: proc=%s",
CodeUPCInfringement, c.Concept.Slug, code)
}
}
}
})
t.Run("v4 trigger-only concept under leaf with NULL proc surfaces", func(t *testing.T) {
// frist-verpasst.epa maps wiedereinsetzung and weiterbehandlung
// with NULL proceeding_type_code (cross-cutting). Both must
// appear and both must surface their trigger pills.
resp, err := svc.Search(ctx, "", SearchOptions{
EventCategorySlug: "frist-verpasst.epa",
Limit: 50,
})
if err != nil {
t.Fatalf("search: %v", err)
}
mustHaveCard := func(slug string) ConceptCard {
for _, c := range resp.Cards {
if c.Concept.Slug == slug {
return c
}
}
t.Fatalf("missing card %q under frist-verpasst.epa; got %v", slug, conceptSlugs(resp.Cards))
return ConceptCard{}
}
wcard := mustHaveCard("wiedereinsetzung")
// Trigger pills (no proceeding) must be present.
hasTrigger := false
for _, p := range wcard.Pills {
if p.Kind == "trigger" {
hasTrigger = true
break
}
}
if !hasTrigger {
t.Errorf("wiedereinsetzung card under frist-verpasst.epa has no trigger pills")
}
})
t.Run("v4 forum filter ANDs against subtree narrowing", func(t *testing.T) {
// Pick the upc.inf.cfi subtree and add a forum chip that excludes
// upc.inf.cfi — the result must be empty (the user contradicted
// themselves; empty is the correct UX).
resp, err := svc.Search(ctx, "", SearchOptions{
EventCategorySlug: "cms-eingang.gegenseite.upc-inf",
Forums: []string{"epa_opp"},
Limit: 200,
})
if err != nil {
t.Fatalf("search: %v", err)
}
for _, c := range resp.Cards {
for _, p := range c.Pills {
if p.Kind == "rule" {
t.Errorf("AND-narrowing produced a rule pill where forum + subtree contradict: %q proc=%v",
c.Concept.Slug, p.Proceeding)
}
}
}
})
// Invariant test: walk every leaf with at least one non-NULL
// proceeding_type_code in the junction and assert that the search
// result for that leaf only surfaces pills whose (concept, proc)
// is authorised by an outcome row. This is the regression gate
// that would have caught the v3 bug at PR time.
t.Run("v4 invariant: per-leaf pills are authorised by junction tuples", func(t *testing.T) {
leafRows, err := pool.QueryxContext(ctx, `
SELECT DISTINCT ec.slug
FROM paliad.event_categories ec
JOIN paliad.event_category_concepts ecc ON ecc.event_category_id = ec.id
WHERE ec.is_active AND ec.is_leaf
AND ecc.proceeding_type_code IS NOT NULL
`)
if err != nil {
t.Fatalf("list leaves: %v", err)
}
var slugs []string
for leafRows.Next() {
var s string
if err := leafRows.Scan(&s); err != nil {
leafRows.Close()
t.Fatalf("scan leaf: %v", err)
}
slugs = append(slugs, s)
}
leafRows.Close()
if len(slugs) == 0 {
t.Fatalf("expected at least one leaf with non-NULL proc; got 0")
}
for _, slug := range slugs {
outcomes, err := ec.ConceptsForSlug(ctx, slug)
if err != nil {
t.Fatalf("ConceptsForSlug(%q): %v", slug, err)
}
// Build tuple validator.
isAllowed := func(conceptID, proc string) bool {
for _, o := range outcomes {
if o.ConceptID != conceptID {
continue
}
if o.ProceedingTypeCode == nil {
return true
}
if *o.ProceedingTypeCode == proc {
return true
}
}
return false
}
resp, err := svc.Search(ctx, "", SearchOptions{
EventCategorySlug: slug,
Limit: 200,
})
if err != nil {
t.Fatalf("search %q: %v", slug, err)
}
for _, c := range resp.Cards {
for _, p := range c.Pills {
if p.Kind != "rule" {
continue
}
proc := ""
if p.Proceeding != nil {
proc = p.Proceeding.Code
}
if !isAllowed(c.Concept.ID, proc) {
t.Errorf("leaf %s leaked unauthorised pill: concept=%s proc=%s rule=%s",
slug, c.Concept.Slug, proc, p.RuleLocalCode)
}
}
}
}
})
}
func findCardBySlug(t *testing.T, resp *SearchResponse, slug string) ConceptCard {
t.Helper()
for _, c := range resp.Cards {
if c.Concept.Slug == slug {
return c
}
}
t.Fatalf("expected concept slug %q in cards, got: %v", slug, conceptSlugs(resp.Cards))
return ConceptCard{}
}
func conceptSlugs(cards []ConceptCard) []string {
out := make([]string, len(cards))
for i, c := range cards {
out[i] = c.Concept.Slug
}
return out
}
func mustHaveLegalSource(t *testing.T, card ConceptCard, want string) {
t.Helper()
for _, p := range card.Pills {
if p.LegalSource != nil && *p.LegalSource == want {
return
}
}
t.Errorf("concept %q card missing pill with legal_source=%q. Got: %v", card.Concept.Slug, want, pillSources(card.Pills))
}
func mustHaveProceedingCodes(t *testing.T, card ConceptCard, codes ...string) {
t.Helper()
have := map[string]bool{}
for _, p := range card.Pills {
if p.Proceeding != nil {
have[p.Proceeding.Code] = true
}
}
for _, c := range codes {
if !have[c] {
t.Errorf("concept %q card missing proceeding pill %q. Got: %v", card.Concept.Slug, c, mapKeys(have))
}
}
}
func pillSources(pills []Pill) []string {
out := []string{}
for _, p := range pills {
if p.LegalSource != nil {
out = append(out, *p.LegalSource)
} else {
out = append(out, "(null)")
}
}
return out
}
func mapKeys(m map[string]bool) []string {
out := make([]string, 0, len(m))
for k := range m {
out = append(out, k)
}
return out
}
func startsWith(s, prefix string) bool {
if len(s) < len(prefix) {
return false
}
return s[:len(prefix)] == prefix
}