Files
paliad/internal/services/fristenrechner_test.go
mAi f6c8eb5bcf
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
fix(projection): conditional label uses trigger_event_id, not parent_id
t-paliad-294 / m/paliad#126. knuth's #121 conditional-rendering
defaulted the "abhängig von <parent>" chip to the rule's parent_id
display name. For R.262(2) Erwiderung auf Vertraulichkeitsantrag the
parent_id resolves to the SoC (Klageerhebung), but the rule's real
semantic anchor is the opposing party's confidentiality application
(paliad.trigger_events id=25). The chip read "abhängig von
Klageerhebung", which is wrong.

Fix: when a rule has a non-NULL trigger_event_id, the engine stamps
ParentRuleCode / ParentRuleName / ParentRuleNameEN from the
trigger_events catalog row instead of from the parent_id chain. The
parent_id stays as the calc-time arithmetic anchor — only the user-
facing dependency identity shifts.

Generalises across every rule with a real trigger_event_id (2 rows
in the live corpus today: confidentiality_response and
translations_lodge — both relabel correctly).

Touches both surfaces in one shot: verfahrensablauf-core's chip
("abhängig von …") and shape-timeline's "Folgt aus …" footer both
read from ParentRule*, so no frontend change needed.

Tests: extend TestUIDeadline_IsConditional_UncertainAnchors with a
DE+EN string-pinning case for R.262(2) plus a generalisation guard
for translations_lodge. Negative guard asserts the chip no longer
leaks "Klageerhebung" / "Statement of Claim".
2026-05-26 11:19:01 +02:00

632 lines
24 KiB
Go
Raw Permalink 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"
"testing"
"time"
"github.com/jmoiron/sqlx"
_ "github.com/lib/pq"
"mgit.msbls.de/m/paliad/internal/db"
)
// Phase 3 Slice 4 (t-paliad-185) dropped isCourtDeterminedRule: the
// is_court_set column (mig 078) backfilled in Slice 2 (mig 082) is now
// the source-of-truth. Calculate reads r.IsCourtSet directly. The
// runtime equivalence of the old heuristic vs the column was verified
// by the Slice 2 backfill integrity test (priority + is_court_set +
// condition_expr). The seven-case discrimination matrix the old test
// exercised lives now as the migration 082 WHERE predicate.
// TestAllFlagsSet covers the t-paliad-131 condition_flag text→text[]
// migration semantic. A rule's flags array gates rendering: every
// element of the array must be present in the caller's flag set, OR
// the rule is suppressed (when alt_duration_value is NULL) or rendered
// without alt swap (when alt_duration_value is non-NULL — legacy
// with_ccr pattern).
func TestAllFlagsSet(t *testing.T) {
mkSet := func(fs ...string) map[string]struct{} {
s := make(map[string]struct{}, len(fs))
for _, f := range fs {
s[f] = struct{}{}
}
return s
}
cases := []struct {
name string
required []string
set map[string]struct{}
want bool
}{
{"empty required → true (unconditional rule)", nil, mkSet(), true},
{"empty required → true even with flags set", nil, mkSet("with_ccr"), true},
{"single flag, present → true (legacy with_ccr pattern)", []string{"with_ccr"}, mkSet("with_ccr"), true},
{"single flag, absent → false", []string{"with_ccr"}, mkSet(), false},
{"single flag, other present → false", []string{"with_ccr"}, mkSet("with_amend"), false},
{"two flags, both present → true (upc.inf.cfi nested)", []string{"with_ccr", "with_amend"}, mkSet("with_ccr", "with_amend"), true},
{"two flags, only one present → false", []string{"with_ccr", "with_amend"}, mkSet("with_ccr"), false},
{"two flags, both present + extra → true (extra flags don't matter)", []string{"with_ccr", "with_amend"}, mkSet("with_ccr", "with_amend", "with_cci"), true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := allFlagsSet(tc.required, tc.set); got != tc.want {
t.Errorf("allFlagsSet(%v, %v) = %v, want %v", tc.required, tc.set, got, tc.want)
}
})
}
}
// TestCalculateRule covers the v4 (t-paliad-136 Phase B) single-rule
// calculator that backs POST /api/tools/fristenrechner/calculate-rule.
// Distinct from Calculate: no parent-chain walk; the trigger date is
// the immediate parent event's effective date, and the calc applies
// the rule's duration + holiday adjustment directly.
//
// Skipped when TEST_DATABASE_URL is unset, mirroring TestDeadlineSearch.
func TestCalculateRule(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()
holidays := NewHolidayService(pool)
rules := NewDeadlineRuleService(pool)
courts := NewCourtService(pool)
svc := NewFristenrechnerService(rules, holidays, courts)
t.Run("plain rule calc — upc.inf.cfi.sod, R.23(1), 3 months", func(t *testing.T) {
// 2026-01-15 + 3 months = 2026-04-15. No vacation overlap.
got, err := svc.CalculateRule(ctx, CalcRuleParams{
ProceedingCode: CodeUPCInfringement,
RuleLocalCode: "upc.inf.cfi.sod",
TriggerDate: "2026-01-15",
})
if err != nil {
t.Fatalf("CalculateRule: %v", err)
}
if got.IsCourtSet {
t.Errorf("upc.inf.cfi.sod is not court-set; got IsCourtSet=true")
}
if got.DueDate != "2026-04-15" {
t.Errorf("dueDate = %q, want 2026-04-15", got.DueDate)
}
if got.Rule.LegalSourceDisplay != "UPC RoP R.23(1)" {
t.Errorf("legalSourceDisplay = %q, want UPC RoP R.23(1)", got.Rule.LegalSourceDisplay)
}
if got.Proceeding.Code != CodeUPCInfringement {
t.Errorf("proceeding code = %q, want upc.inf.cfi", got.Proceeding.Code)
}
})
t.Run("court-determined rule → IsCourtSet=true, no dueDate", func(t *testing.T) {
got, err := svc.CalculateRule(ctx, CalcRuleParams{
ProceedingCode: CodeUPCInfringement,
RuleLocalCode: "upc.inf.cfi.decision",
TriggerDate: "2026-01-15",
})
if err != nil {
t.Fatalf("CalculateRule: %v", err)
}
if !got.IsCourtSet {
t.Errorf("upc.inf.cfi.decision should be court-set; got IsCourtSet=false")
}
if got.DueDate != "" {
t.Errorf("court-set dueDate = %q, want empty", got.DueDate)
}
})
t.Run("flag-conditional rule surfaces FlagsRequired even when not satisfied", func(t *testing.T) {
// upc.inf.cfi.def_to_ccr requires with_ccr. Without the flag,
// FlagsRequired is still surfaced so the UI can render the
// checkbox.
got, err := svc.CalculateRule(ctx, CalcRuleParams{
ProceedingCode: CodeUPCInfringement,
RuleLocalCode: "upc.inf.cfi.def_to_ccr",
TriggerDate: "2026-01-15",
})
if err != nil {
t.Fatalf("CalculateRule: %v", err)
}
if len(got.FlagsRequired) == 0 || got.FlagsRequired[0] != "with_ccr" {
t.Errorf("FlagsRequired = %v, want [with_ccr]", got.FlagsRequired)
}
if len(got.FlagsApplied) != 0 {
t.Errorf("FlagsApplied = %v, want empty (flag not supplied)", got.FlagsApplied)
}
})
t.Run("flag-conditional rule with flag → FlagsApplied populated", func(t *testing.T) {
got, err := svc.CalculateRule(ctx, CalcRuleParams{
ProceedingCode: CodeUPCInfringement,
RuleLocalCode: "upc.inf.cfi.def_to_ccr",
TriggerDate: "2026-01-15",
Flags: []string{"with_ccr"},
})
if err != nil {
t.Fatalf("CalculateRule: %v", err)
}
if len(got.FlagsApplied) == 0 || got.FlagsApplied[0] != "with_ccr" {
t.Errorf("FlagsApplied = %v, want [with_ccr]", got.FlagsApplied)
}
})
t.Run("missing TriggerDate → error", func(t *testing.T) {
_, err := svc.CalculateRule(ctx, CalcRuleParams{
ProceedingCode: CodeUPCInfringement,
RuleLocalCode: "upc.inf.cfi.sod",
TriggerDate: "",
})
if err == nil {
t.Errorf("expected error for empty triggerDate")
}
})
t.Run("unknown rule → ErrUnknownRule", func(t *testing.T) {
_, err := svc.CalculateRule(ctx, CalcRuleParams{
ProceedingCode: CodeUPCInfringement,
RuleLocalCode: "totally.fake",
TriggerDate: "2026-01-15",
})
if err == nil {
t.Errorf("expected ErrUnknownRule, got nil")
}
})
}
// TestEvalConditionExpr covers the Phase 3 Slice 4 (t-paliad-185)
// jsonb gate evaluator. Long-form grammar per design §2.4: leaf
// {"flag":"X"}, AND / OR / NOT compositions. Single-flag values pass
// through unwrapped. NULL / empty expression falls back to
// condition_flag AND-semantics.
func TestEvalConditionExpr(t *testing.T) {
mkSet := func(fs ...string) map[string]struct{} {
m := make(map[string]struct{}, len(fs))
for _, f := range fs {
m[f] = struct{}{}
}
return m
}
cases := []struct {
name string
expr string
flags map[string]struct{}
want bool
}{
// NULL / empty / "null" expr → unconditional. Slice 9 removed
// the legacy condition_flag fallback that used to make this
// branch return false on flags-not-met — the column is gone.
{"empty expr → unconditional", "", mkSet(), true},
{"empty expr with flags set → unconditional", "", mkSet("with_ccr"), true},
{"literal null → unconditional", "null", mkSet(), true},
// Single-flag leaf (mig 084 unwrapped form for [single]).
{"single-flag leaf present → true", `{"flag":"with_ccr"}`, mkSet("with_ccr"), true},
{"single-flag leaf absent → false", `{"flag":"with_ccr"}`, mkSet("with_amend"), false},
// AND.
{"and(a, b) both present → true",
`{"op":"and","args":[{"flag":"with_ccr"},{"flag":"with_amend"}]}`,
mkSet("with_ccr", "with_amend"), true},
{"and(a, b) one absent → false",
`{"op":"and","args":[{"flag":"with_ccr"},{"flag":"with_amend"}]}`,
mkSet("with_ccr"), false},
{"and() empty args → true (vacuously)", `{"op":"and","args":[]}`, mkSet(), true},
// OR.
{"or(a, b) any present → true",
`{"op":"or","args":[{"flag":"with_ccr"},{"flag":"with_amend"}]}`,
mkSet("with_amend"), true},
{"or(a, b) none present → false",
`{"op":"or","args":[{"flag":"with_ccr"},{"flag":"with_amend"}]}`,
mkSet("with_cci"), false},
{"or() empty args → false (vacuously)", `{"op":"or","args":[]}`, mkSet(), false},
// NOT.
{"not(flag) absent → true",
`{"op":"not","args":[{"flag":"with_ccr"}]}`, mkSet(), true},
{"not(flag) present → false",
`{"op":"not","args":[{"flag":"with_ccr"}]}`, mkSet("with_ccr"), false},
// Nested.
{"and(or(a, b), not(c)) all conditions met → true",
`{"op":"and","args":[
{"op":"or","args":[{"flag":"with_ccr"},{"flag":"with_amend"}]},
{"op":"not","args":[{"flag":"expedited"}]}
]}`,
mkSet("with_amend"), true},
{"and(or(a, b), not(c)) NOT condition fails → false",
`{"op":"and","args":[
{"op":"or","args":[{"flag":"with_ccr"},{"flag":"with_amend"}]},
{"op":"not","args":[{"flag":"expedited"}]}
]}`,
mkSet("with_amend", "expedited"), false},
// Malformed → defensive true (rule still renders).
{"malformed JSON → true (defensive)", `{"op":"bro`, mkSet(), true},
{"unknown op → true (forward-compat)", `{"op":"xor","args":[{"flag":"with_ccr"}]}`, mkSet(), true},
{"not with two args → true (malformed NOT)", `{"op":"not","args":[{"flag":"a"},{"flag":"b"}]}`, mkSet(), true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := evalConditionExpr([]byte(tc.expr), tc.flags)
if got != tc.want {
t.Errorf("evalConditionExpr(%q, flags) = %v, want %v",
tc.expr, got, tc.want)
}
})
}
}
// TestWireFlagsFromPriority verifies the priority → (IsMandatory,
// IsOptional) reverse-mapping (Slice 4) matches the Slice 2 backfill so
// the wire shape stays byte-identical through the cutover. The four
// mappings + the safe default for unknown values are exhaustive.
func TestWireFlagsFromPriority(t *testing.T) {
cases := []struct {
priority string
wantMandatory bool
wantOptional bool
}{
{"mandatory", true, false},
{"optional", true, true},
{"recommended", false, false},
{"informational", false, false},
{"", true, false}, // safe default — never drop a rule
{"future_value", true, false},
}
for _, tc := range cases {
t.Run(tc.priority, func(t *testing.T) {
gotM, gotO := wireFlagsFromPriority(tc.priority)
if gotM != tc.wantMandatory || gotO != tc.wantOptional {
t.Errorf("wireFlagsFromPriority(%q) = (%v, %v), want (%v, %v)",
tc.priority, gotM, gotO, tc.wantMandatory, tc.wantOptional)
}
})
}
}
// TestApplyDuration_Matrix exercises the unified date-arithmetic helper
// across the 4 units × 3 timings × calendar/holiday matrix added in
// Slice 4. Mixes calendar units (days/weeks/months with weekend +
// holiday rollover) with working_days (skip-by-construction, no
// rollover).
func TestApplyDuration_Matrix(t *testing.T) {
hs := NewHolidayService(nil)
// Anchor: Thu 2026-04-30. Adjacent Fri (May 1) is Tag der Arbeit;
// Sat-Sun follow. Sequence exercises the rollover path.
thursday := time.Date(2026, 4, 30, 0, 0, 0, 0, time.UTC)
cases := []struct {
name string
base time.Time
value int
unit string
timing string
wantRaw time.Time
wantAdj time.Time
wantDidAdj bool
}{
{
name: "days/after — Thu + 1 calendar day → Fri (holiday) → adjusted to Mon",
base: thursday, value: 1, unit: "days", timing: "after",
wantRaw: time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC),
wantAdj: time.Date(2026, 5, 4, 0, 0, 0, 0, time.UTC),
wantDidAdj: true,
},
{
name: "days/before — Thu - 1 → Wed (working) → no adjust",
base: thursday, value: 1, unit: "days", timing: "before",
wantRaw: time.Date(2026, 4, 29, 0, 0, 0, 0, time.UTC),
wantAdj: time.Date(2026, 4, 29, 0, 0, 0, 0, time.UTC),
wantDidAdj: false,
},
{
name: "weeks/after — Thu + 1 week → next Thu (working) → no adjust",
base: thursday, value: 1, unit: "weeks", timing: "after",
wantRaw: time.Date(2026, 5, 7, 0, 0, 0, 0, time.UTC),
wantAdj: time.Date(2026, 5, 7, 0, 0, 0, 0, time.UTC),
wantDidAdj: false,
},
{
name: "months/after — Thu Apr 30 + 1 month → Sat May 30 → adjusted to Mon Jun 1",
base: thursday, value: 1, unit: "months", timing: "after",
wantRaw: time.Date(2026, 5, 30, 0, 0, 0, 0, time.UTC),
wantAdj: time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC),
wantDidAdj: true,
},
{
name: "working_days/after — Thu + 1 wd → Mon (skip Fri holiday + weekend)",
base: thursday, value: 1, unit: "working_days", timing: "after",
wantRaw: time.Date(2026, 5, 4, 0, 0, 0, 0, time.UTC),
wantAdj: time.Date(2026, 5, 4, 0, 0, 0, 0, time.UTC),
wantDidAdj: false,
},
{
name: "working_days/before — Mon May 4 - 1 wd → Thu Apr 30 (skip Fri holiday)",
base: time.Date(2026, 5, 4, 0, 0, 0, 0, time.UTC),
value: 1, unit: "working_days", timing: "before",
wantRaw: time.Date(2026, 4, 30, 0, 0, 0, 0, time.UTC),
wantAdj: time.Date(2026, 4, 30, 0, 0, 0, 0, time.UTC),
wantDidAdj: false,
},
{
name: "unknown unit → identity (defensive)",
base: thursday, value: 5, unit: "fortnights", timing: "after",
wantRaw: thursday,
wantAdj: thursday, // adjusted = AdjustForNonWorkingDays(raw); thursday is a working day
wantDidAdj: false,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
raw, adj, didAdj, _ := applyDuration(tc.base, tc.value, tc.unit, tc.timing, "DE", "UPC", hs)
if !raw.Equal(tc.wantRaw) {
t.Errorf("raw: got %s, want %s", raw, tc.wantRaw)
}
if !adj.Equal(tc.wantAdj) {
t.Errorf("adjusted: got %s, want %s", adj, tc.wantAdj)
}
if didAdj != tc.wantDidAdj {
t.Errorf("didAdjust: got %v, want %v", didAdj, tc.wantDidAdj)
}
})
}
}
// TestUIDeadline_WireShape_Slice8 asserts Phase 3 Slice 8 (t-paliad-189)
// wire-shape additivity: UIResponse.Deadlines MUST carry the new
// `priority` + `conditionExpr` fields AND the legacy `isMandatory` +
// `isOptional` pair (derived via wireFlagsFromPriority) for one release.
// Slice 9 will drop the legacy fields — until then the response
// shape is a superset.
//
// Live DB required so the rules.List returns real (not synthetic)
// rules with the priority column populated by the Slice 2 backfill.
func TestUIDeadline_WireShape_Slice8(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()
holidays := NewHolidayService(pool)
rules := NewDeadlineRuleService(pool)
courts := NewCourtService(pool)
svc := NewFristenrechnerService(rules, holidays, courts)
resp, err := svc.Calculate(ctx, CodeUPCInfringement, "2026-01-15", CalcOptions{})
if err != nil {
t.Fatalf("Calculate upc.inf.cfi: %v", err)
}
if len(resp.Deadlines) == 0 {
t.Fatal("Calculate upc.inf.cfi returned no deadlines — seed-data missing?")
}
allowed := map[string]bool{
"mandatory": true, "recommended": true, "optional": true, "informational": true,
}
for _, d := range resp.Deadlines {
if !allowed[d.Priority] {
t.Errorf("rule %s: priority=%q not in unified enum", d.Code, d.Priority)
}
}
// At least one rule should carry a populated conditionExpr (the
// 17 with_ccr / with_amend / with_cci rules mig 084 translated).
// Spot-check that the field actually serialises as jsonb (non-empty
// bytes on at least one row).
var sawConditionExpr bool
for _, d := range resp.Deadlines {
if len(d.ConditionExpr) > 0 && string(d.ConditionExpr) != "null" {
sawConditionExpr = true
break
}
}
if !sawConditionExpr {
t.Logf("warning: no upc.inf.cfi rule had conditionExpr populated — verify mig 084 ran")
}
}
// t-paliad-289: rules anchored on uncertain triggers must render as
// conditional (IsConditional=true, empty DueDate, ParentRule* populated)
// rather than fabricating a date off the trigger.
//
// Three pillars from the issue:
// - Symptom A: R.109(1) Antrag auf Simultanübersetzung (timing='before',
// parent=Mündliche Verhandlung which is court-set). Pre-fix the rule
// computed a meaningless "1 month before today" because sequence_order
// places translation_request (45) before oral (50), so the parent
// hadn't been classified as court-set yet. The new pre-pass in
// Calculate seeds courtSet from is_court_set=true on the data, so
// order-of-evaluation no longer matters.
// - R.118(4) cons_orders (parent=Entscheidung, court-set) — already
// worked via the legacy IsCourtSetIndirect path; assertion ensures
// the new IsConditional flag rides alongside it.
// - Symptom B: R.262(2) confidentiality_response (priority='optional',
// primary_party='both', parent=SoC which is the trigger anchor).
// The data-model parent is "always certain" but the real triggering
// event (opposing party's confidentiality motion) sits outside the
// rule data — render conditional until the user anchors the rule.
func TestUIDeadline_IsConditional_UncertainAnchors(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()
holidays := NewHolidayService(pool)
rules := NewDeadlineRuleService(pool)
courts := NewCourtService(pool)
svc := NewFristenrechnerService(rules, holidays, courts)
resp, err := svc.Calculate(ctx, CodeUPCInfringement, "2026-05-25", CalcOptions{})
if err != nil {
t.Fatalf("Calculate: %v", err)
}
byCode := map[string]UIDeadline{}
for _, d := range resp.Deadlines {
byCode[d.Code] = d
}
cases := []struct {
code string
wantConditional bool
wantParentCode string
}{
// Symptom A — backward-anchored on the court-set oral hearing.
// Pre-pass fix: order-of-evaluation no longer matters. These
// rules have no trigger_event_id, so ParentRuleCode stays on
// the parent_id-derived value.
{"upc.inf.cfi.translation_request", true, "upc.inf.cfi.oral"},
{"upc.inf.cfi.interpreter_cost", true, "upc.inf.cfi.oral"},
// R.118(4) chain — parent=decision (court-set). No trigger_event_id.
{"upc.inf.cfi.cons_orders", true, "upc.inf.cfi.decision"},
// Symptom B — optional + both, data-model parent is SoC but the
// real trigger is the opposing party's confidentiality application.
// m/paliad#126 / t-paliad-294: ParentRuleCode now reflects the
// trigger_events catalog row (id=25), NOT the parent_id chain.
{"upc.inf.cfi.confidentiality_response", true, "application_to_request_confidentiality_from_the_public"},
// Negative control — mandatory rule anchored on SoC must keep
// its concrete date (no IsConditional, real DueDate). No
// trigger_event_id, so parent_id-derived code stays.
{"upc.inf.cfi.sod", false, "upc.inf.cfi.soc"},
}
for _, c := range cases {
t.Run(c.code, func(t *testing.T) {
d, ok := byCode[c.code]
if !ok {
t.Fatalf("rule %s missing from response", c.code)
}
if d.IsConditional != c.wantConditional {
t.Errorf("IsConditional = %v, want %v", d.IsConditional, c.wantConditional)
}
if c.wantConditional {
if d.DueDate != "" {
t.Errorf("DueDate = %q, want empty (conditional)", d.DueDate)
}
if d.ParentRuleCode != c.wantParentCode {
t.Errorf("ParentRuleCode = %q, want %q", d.ParentRuleCode, c.wantParentCode)
}
if d.ParentRuleName == "" {
t.Errorf("ParentRuleName empty for conditional rule")
}
} else {
if d.DueDate == "" {
t.Errorf("non-conditional rule has empty DueDate")
}
}
})
}
// m/paliad#126 / t-paliad-294: the conditional chip for R.262(2)
// reads from the trigger_events catalog (id=25), so the user sees
// the actual semantic anchor instead of the parent_id-derived
// "Klageerhebung". Pin the exact DE + EN strings so a future
// rename of the catalog row surfaces here.
t.Run("R.262(2) conditional label uses trigger_event_id, not parent_id", func(t *testing.T) {
d, ok := byCode["upc.inf.cfi.confidentiality_response"]
if !ok {
t.Fatalf("confidentiality_response missing from response")
}
const wantNameDE = "Antrag auf Vertraulichkeit gegenüber der Öffentlichkeit"
const wantNameEN = "Application to request confidentiality from the public"
if d.ParentRuleName != wantNameDE {
t.Errorf("ParentRuleName = %q, want %q (trigger_events.name_de for id=25)", d.ParentRuleName, wantNameDE)
}
if d.ParentRuleNameEN != wantNameEN {
t.Errorf("ParentRuleNameEN = %q, want %q (trigger_events.name for id=25)", d.ParentRuleNameEN, wantNameEN)
}
// Negative guard — neither label should leak the SoC ("Klageerhebung"),
// which is the regression the fix exists to prevent.
if d.ParentRuleName == "Klageerhebung" || d.ParentRuleNameEN == "Statement of Claim" {
t.Errorf("conditional label still resolves via parent_id (SoC); fix regressed")
}
})
// Generalisation guard — translations_lodge also carries a real
// trigger_event_id (113 = judge-rapporteur's order). Its
// conditional chip should reference the order, not its parent_id
// (Zwischenverfahren). Locks in the "any rule with trigger_event_id
// uses THAT, not parent_id" contract from m/paliad#126.
t.Run("translations_lodge conditional label uses trigger_event_id", func(t *testing.T) {
d, ok := byCode["upc.inf.cfi.translations_lodge"]
if !ok {
t.Skip("upc.inf.cfi.translations_lodge missing from response — data drift?")
}
if !d.IsConditional {
t.Skipf("translations_lodge IsConditional=false in current corpus; trigger-event override is only user-visible on conditional rows. Skip but keep the generalisation guard.")
}
if d.ParentRuleName == "Zwischenverfahren" {
t.Errorf("translations_lodge still labelled via parent_id (Zwischenverfahren); should follow trigger_event_id=113")
}
if d.ParentRuleCode != "order_of_the_judge_rapporteur_to_lodge_translations" {
t.Errorf("ParentRuleCode = %q, want trigger_events.code for id=113", d.ParentRuleCode)
}
})
// Override path: when the user anchors the oral hearing, the
// backward-anchored R.109(1) flips back to a concrete date and
// IsConditional clears. This is the click-to-edit unblock.
t.Run("override on court-set parent clears IsConditional", func(t *testing.T) {
resp2, err := svc.Calculate(ctx, CodeUPCInfringement, "2026-05-25", CalcOptions{
AnchorOverrides: map[string]string{
"upc.inf.cfi.oral": "2027-03-01",
},
})
if err != nil {
t.Fatalf("Calculate with override: %v", err)
}
var tr UIDeadline
for _, d := range resp2.Deadlines {
if d.Code == "upc.inf.cfi.translation_request" {
tr = d
break
}
}
if tr.IsConditional {
t.Errorf("translation_request IsConditional=true after oral override; want false")
}
if tr.DueDate == "" {
t.Errorf("translation_request DueDate empty after oral override")
}
// 1 month before 2027-03-01 = ~2027-02-01 (with weekend bump).
if tr.DueDate < "2027-01-25" || tr.DueDate > "2027-02-05" {
t.Errorf("translation_request DueDate=%q not within expected 2027-01-25..2027-02-05 window", tr.DueDate)
}
})
}