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".
632 lines
24 KiB
Go
632 lines
24 KiB
Go
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)
|
||
}
|
||
})
|
||
}
|