package litigationplanner import ( "context" "testing" "github.com/google/uuid" ) // Tests for t-paliad-342 / youpcorg#2568 + #2570. // // Two paired engine semantics: // // - Optional rules (priority='optional') don't auto-fire in the // default timeline; the caller opts in via // CalcOptions.IncludeOptional. // - Rules with explicit trigger_event_id anchor on the trigger // event's date (CalcOptions.TriggerEventAnchors keyed by // trigger_events.code). Missing anchor = render conditional // instead of fabricating a date off the proceeding's trigger date. // stubCatalogWithTriggers extends stubCatalog with a trigger-events // map so the engine can resolve TriggerEventID → code for the // trigger-anchor branch. stubCatalog from before_court_set_anchor_test.go // returns an empty map, which suffices for tests that don't exercise // trigger_event_id; here we need real entries. type stubCatalogWithTriggers struct { stubCatalog triggerEvents map[int64]TriggerEvent } func (s *stubCatalogWithTriggers) LoadTriggerEventsByIDs(_ context.Context, ids []int64) (map[int64]TriggerEvent, error) { out := make(map[int64]TriggerEvent, len(ids)) for _, id := range ids { if te, ok := s.triggerEvents[id]; ok { out[id] = te } } return out, nil } // mandatory_socRule builds a minimal SoC root rule + the proceeding // type wrapper that nearly every test below needs. func mandatorySocFixture(t *testing.T) (ProceedingType, Rule, uuid.UUID) { t.Helper() jurisdiction := "UPC" procID := 1 pt := ProceedingType{ ID: procID, Code: "upc.inf.cfi", Name: "Verletzungsverfahren", Jurisdiction: &jurisdiction, IsActive: true, } socID, _ := uuid.NewRandom() socCode := "upc.inf.cfi.soc" procIDPtr := &procID str := func(s string) *string { return &s } soc := Rule{ ID: socID, ProceedingTypeID: procIDPtr, ParentID: nil, SubmissionCode: &socCode, Name: "Klageerhebung", NameEN: "SoC", PrimaryParty: str("claimant"), DurationValue: 0, DurationUnit: "months", Timing: str("after"), SequenceOrder: 0, IsActive: true, LifecycleState: "published", Priority: "mandatory", } return pt, soc, socID } // TestCalculate_TriggerEventIDWithoutAnchor_Suppressed verifies the // RoP.109.5 bug from youpcorg#2568: a rule with trigger_event_id and // no parent_id must NOT fall back to the proceeding's trigger date. // The buggy behaviour rendered the rule with a fabricated date 2 weeks // before the user's SoC date. func TestCalculate_TriggerEventIDWithoutAnchor_Suppressed(t *testing.T) { ctx := context.Background() pt, soc, _ := mandatorySocFixture(t) str := func(s string) *string { return &s } procIDPtr := &pt.ID ruleID, _ := uuid.NewRandom() ruleCode := "upc.inf.cfi.rop_109_5" rop109_5Trigger := int64(49) rop109_5 := Rule{ ID: ruleID, ProceedingTypeID: procIDPtr, ParentID: nil, SubmissionCode: &ruleCode, Name: "Vorbereitung mündliche Verhandlung", NameEN: "Oral hearing preparation", PrimaryParty: str("both"), DurationValue: 2, DurationUnit: "weeks", Timing: str("before"), SequenceOrder: 100, IsActive: true, LifecycleState: "published", Priority: "mandatory", TriggerEventID: &rop109_5Trigger, } cat := &stubCatalogWithTriggers{ stubCatalog: stubCatalog{pt: pt, rules: []Rule{soc, rop109_5}}, triggerEvents: map[int64]TriggerEvent{ 49: {ID: 49, Code: "oral_hearing", Name: "Oral hearing", NameDE: "Mündliche Verhandlung"}, }, } timeline, err := Calculate(ctx, "upc.inf.cfi", "2026-05-26", CalcOptions{}, cat, noOpHolidays{}, fixedCourts{}) if err != nil { t.Fatalf("Calculate: %v", err) } byCode := make(map[string]TimelineEntry, len(timeline.Deadlines)) for _, d := range timeline.Deadlines { byCode[d.Code] = d } rop, ok := byCode[ruleCode] if !ok { t.Fatalf("RoP.109.5 missing from timeline; want present with conditional-no-date") } if rop.DueDate != "" { t.Errorf("RoP.109.5: DueDate=%q, want empty (no oral_hearing anchor supplied)", rop.DueDate) } if !rop.IsConditional { t.Errorf("RoP.109.5: IsConditional=%v, want true", rop.IsConditional) } if timeline.RulesAwaitingAnchor != 1 { t.Errorf("RulesAwaitingAnchor=%d, want 1", timeline.RulesAwaitingAnchor) } } // TestCalculate_TriggerEventIDWithAnchor_Renders verifies the // caller-supplied trigger-event anchor produces correct arithmetic. // 2 weeks before 2026-10-15 = 2026-10-01. func TestCalculate_TriggerEventIDWithAnchor_Renders(t *testing.T) { ctx := context.Background() pt, soc, _ := mandatorySocFixture(t) str := func(s string) *string { return &s } procIDPtr := &pt.ID ruleID, _ := uuid.NewRandom() ruleCode := "upc.inf.cfi.rop_109_5" rop109_5Trigger := int64(49) rop109_5 := Rule{ ID: ruleID, ProceedingTypeID: procIDPtr, ParentID: nil, SubmissionCode: &ruleCode, Name: "Vorbereitung mündliche Verhandlung", NameEN: "Oral hearing preparation", PrimaryParty: str("both"), DurationValue: 2, DurationUnit: "weeks", Timing: str("before"), SequenceOrder: 100, IsActive: true, LifecycleState: "published", Priority: "mandatory", TriggerEventID: &rop109_5Trigger, } cat := &stubCatalogWithTriggers{ stubCatalog: stubCatalog{pt: pt, rules: []Rule{soc, rop109_5}}, triggerEvents: map[int64]TriggerEvent{ 49: {ID: 49, Code: "oral_hearing", Name: "Oral hearing", NameDE: "Mündliche Verhandlung"}, }, } opts := CalcOptions{ TriggerEventAnchors: map[string]string{ "oral_hearing": "2026-10-15", }, } timeline, err := Calculate(ctx, "upc.inf.cfi", "2026-05-26", opts, cat, noOpHolidays{}, fixedCourts{}) if err != nil { t.Fatalf("Calculate: %v", err) } byCode := make(map[string]TimelineEntry, len(timeline.Deadlines)) for _, d := range timeline.Deadlines { byCode[d.Code] = d } rop := byCode[ruleCode] if rop.DueDate != "2026-10-01" { t.Errorf("RoP.109.5: DueDate=%q, want 2026-10-01 (2 weeks before oral_hearing 2026-10-15)", rop.DueDate) } if rop.IsConditional { t.Errorf("RoP.109.5: IsConditional=%v, want false (anchor supplied)", rop.IsConditional) } if timeline.RulesAwaitingAnchor != 0 { t.Errorf("RulesAwaitingAnchor=%d, want 0", timeline.RulesAwaitingAnchor) } } // TestCalculate_MandatoryRule_RendersByDefault is the control case for // the optional-suppression fix: mandatory rules render with their // computed dates by default. Prevents regression where the optional // filter accidentally drops mandatory rules too. func TestCalculate_MandatoryRule_RendersByDefault(t *testing.T) { ctx := context.Background() pt, soc, socID := mandatorySocFixture(t) str := func(s string) *string { return &s } procIDPtr := &pt.ID replyID, _ := uuid.NewRandom() replyCode := "upc.inf.cfi.reply" reply := Rule{ ID: replyID, ProceedingTypeID: procIDPtr, ParentID: &socID, SubmissionCode: &replyCode, Name: "Klageerwiderung", NameEN: "Reply to SoC", PrimaryParty: str("defendant"), DurationValue: 3, DurationUnit: "months", Timing: str("after"), SequenceOrder: 10, IsActive: true, LifecycleState: "published", Priority: "mandatory", } cat := &stubCatalogWithTriggers{ stubCatalog: stubCatalog{pt: pt, rules: []Rule{soc, reply}}, } timeline, err := Calculate(ctx, "upc.inf.cfi", "2026-05-26", CalcOptions{}, cat, noOpHolidays{}, fixedCourts{}) if err != nil { t.Fatalf("Calculate: %v", err) } byCode := make(map[string]TimelineEntry, len(timeline.Deadlines)) for _, d := range timeline.Deadlines { byCode[d.Code] = d } got, ok := byCode[replyCode] if !ok { t.Fatalf("mandatory reply rule missing from default timeline") } if got.DueDate != "2026-08-26" { t.Errorf("reply: DueDate=%q, want 2026-08-26 (3 months after SoC)", got.DueDate) } } // TestCalculate_OptionalRule_SuppressedByDefault pins the // youpcorg#2570 fix: priority='optional' rules don't render in the // default timeline. The caller opts in via IncludeOptional=true. func TestCalculate_OptionalRule_SuppressedByDefault(t *testing.T) { ctx := context.Background() pt, soc, socID := mandatorySocFixture(t) str := func(s string) *string { return &s } procIDPtr := &pt.ID confID, _ := uuid.NewRandom() confCode := "upc.inf.cfi.rop_262_2" conf := Rule{ ID: confID, ProceedingTypeID: procIDPtr, ParentID: &socID, SubmissionCode: &confCode, Name: "Erwiderung Vertraulichkeitsantrag", NameEN: "Reply to confidentiality motion", PrimaryParty: str("both"), DurationValue: 14, DurationUnit: "days", Timing: str("after"), SequenceOrder: 20, IsActive: true, LifecycleState: "published", Priority: "optional", } cat := &stubCatalogWithTriggers{ stubCatalog: stubCatalog{pt: pt, rules: []Rule{soc, conf}}, } timeline, err := Calculate(ctx, "upc.inf.cfi", "2026-05-26", CalcOptions{}, cat, noOpHolidays{}, fixedCourts{}) if err != nil { t.Fatalf("Calculate: %v", err) } for _, d := range timeline.Deadlines { if d.Code == confCode { t.Errorf("optional R.262(2) rule rendered in default timeline (DueDate=%q); want suppressed", d.DueDate) } } } // TestCalculate_OptionalRule_RendersWithIncludeOptional verifies the // opt-in path: when the caller passes IncludeOptional=true, optional // rules show up in the timeline with their computed dates. func TestCalculate_OptionalRule_RendersWithIncludeOptional(t *testing.T) { ctx := context.Background() pt, soc, socID := mandatorySocFixture(t) str := func(s string) *string { return &s } procIDPtr := &pt.ID confID, _ := uuid.NewRandom() confCode := "upc.inf.cfi.rop_262_2" conf := Rule{ ID: confID, ProceedingTypeID: procIDPtr, ParentID: &socID, SubmissionCode: &confCode, Name: "Erwiderung Vertraulichkeitsantrag", NameEN: "Reply to confidentiality motion", PrimaryParty: str("both"), DurationValue: 14, DurationUnit: "days", Timing: str("after"), SequenceOrder: 20, IsActive: true, LifecycleState: "published", Priority: "optional", } cat := &stubCatalogWithTriggers{ stubCatalog: stubCatalog{pt: pt, rules: []Rule{soc, conf}}, } timeline, err := Calculate(ctx, "upc.inf.cfi", "2026-05-26", CalcOptions{IncludeOptional: true}, cat, noOpHolidays{}, fixedCourts{}) if err != nil { t.Fatalf("Calculate: %v", err) } byCode := make(map[string]TimelineEntry, len(timeline.Deadlines)) for _, d := range timeline.Deadlines { byCode[d.Code] = d } got, ok := byCode[confCode] if !ok { t.Fatalf("optional R.262(2) missing from timeline despite IncludeOptional=true") } // R.262(2) is the "optional opposing-side" pattern (priority=optional, // primary_party=both, parent=SoC root) — the engine renders this as // IsConditional (no concrete date) per the t-paliad-289 logic // preserved in the walk. The point of this test is that the rule // is no longer suppressed wholesale by the t-paliad-342 default — // it surfaces, just with the conditional-render UX. if !got.IsConditional { t.Errorf("R.262(2): IsConditional=%v, want true (optional opposing-side, no real trigger recorded)", got.IsConditional) } } // TestCalculate_TriggerEventID_MalformedAnchor_Errors ensures // malformed dates in TriggerEventAnchors fail fast at the top of the // engine, before any rule walking — same protocol as AnchorOverrides. func TestCalculate_TriggerEventID_MalformedAnchor_Errors(t *testing.T) { ctx := context.Background() pt, soc, _ := mandatorySocFixture(t) cat := &stubCatalogWithTriggers{ stubCatalog: stubCatalog{pt: pt, rules: []Rule{soc}}, } opts := CalcOptions{ TriggerEventAnchors: map[string]string{ "oral_hearing": "15-10-2026", // wrong format }, } _, err := Calculate(ctx, "upc.inf.cfi", "2026-05-26", opts, cat, noOpHolidays{}, fixedCourts{}) if err == nil { t.Fatalf("Calculate: want error on malformed TriggerEventAnchors date, got nil") } }