package litigationplanner import ( "context" "errors" "testing" "time" "github.com/google/uuid" ) // Regression test for t-paliad-304 / m/paliad#135. // // Reproduces the R.109.1 / R.109.4 anchor bug on upc.inf.cfi: // - Trigger (Klageerhebung): parent_id=nil, duration=0, !IsCourtSet, sequence_order=0 // - Translation request: parent_id=oral, duration=1mo before, sequence_order=45 // - Interpreter cost: parent_id=oral, duration=2w before, sequence_order=46 // - Oral hearing: parent_id=nil, duration=0, IsCourtSet, sequence_order=50 // // The "before" children are listed BEFORE the oral hearing in sequence // order (because chronologically they happen before it). The engine walks // rules in sequence_order, so when it processes the translation/ // interpreter rows, the oral hearing has not yet been processed → // courtSet[oral.ID] is not yet set → parentIsCourtSet is false → the // engine falls back to the trigger date as the base. Result: the timing= // 'before' arithmetic produces 27.04.2026 (1mo before SoC) instead of // the conditional-no-date treatment that a court-set parent should // trigger. // // Expected post-fix: translation_request + interpreter_cost render as // IsConditional (no concrete date) because their parent's date is // court-set and the proceeding does not yet have an explicit override. // stubCatalog implements lp.Catalog backed by an in-memory rule slice. // Only LoadProceeding is needed for the engine path under test; the // other interface methods return errors so an unintended call surfaces // immediately. type stubCatalog struct { pt ProceedingType rules []Rule } func (s *stubCatalog) LoadProceeding(_ context.Context, code string, _ ProjectHint) (*ProceedingType, []Rule, error) { if code != s.pt.Code { return nil, nil, ErrUnknownProceedingType } rules := make([]Rule, len(s.rules)) copy(rules, s.rules) pt := s.pt return &pt, rules, nil } func (s *stubCatalog) LoadProceedingByID(_ context.Context, _ int) (*ProceedingType, error) { return nil, errors.New("stubCatalog.LoadProceedingByID: not implemented") } func (s *stubCatalog) LoadRuleByID(_ context.Context, _ string) (*Rule, error) { return nil, errors.New("stubCatalog.LoadRuleByID: not implemented") } func (s *stubCatalog) LoadRuleByCode(_ context.Context, _, _ string) (*Rule, *ProceedingType, error) { return nil, nil, errors.New("stubCatalog.LoadRuleByCode: not implemented") } func (s *stubCatalog) LoadRulesByTriggerEvent(_ context.Context, _ int64) ([]Rule, error) { return nil, nil } func (s *stubCatalog) LoadTriggerEventsByIDs(_ context.Context, _ []int64) (map[int64]TriggerEvent, error) { return map[int64]TriggerEvent{}, nil } func (s *stubCatalog) LookupEvents(_ context.Context, _ EventLookupAxes, _ EventLookupDepth) ([]EventMatch, error) { return nil, nil } func (s *stubCatalog) LoadScenarios(_ context.Context, _ ScenarioFilter) ([]Scenario, error) { return nil, nil } func (s *stubCatalog) MatchScenario(_ context.Context, _ uuid.UUID) (*Scenario, error) { return nil, ErrUnknownScenario } // noOpHolidays never adjusts dates — the test fixture doesn't care about // weekends or holidays, only about which base date the engine resolves. type noOpHolidays struct{} func (noOpHolidays) IsNonWorkingDay(_ time.Time, _, _ string) bool { return false } func (noOpHolidays) AdjustForNonWorkingDays(d time.Time, _, _ string) (time.Time, time.Time, bool) { return d, d, false } func (noOpHolidays) AdjustForNonWorkingDaysBackward(d time.Time, _, _ string) (time.Time, time.Time, bool) { return d, d, false } func (noOpHolidays) AdjustForNonWorkingDaysWithReason(d time.Time, _, _ string) (time.Time, time.Time, bool, *AdjustmentReason) { return d, d, false, nil } type fixedCourts struct{} func (fixedCourts) CountryRegime(_, _, _ string) (string, string, error) { return CountryDE, RegimeUPC, nil } func TestCalculate_BeforeChildOfCourtSetParent_OutOfOrderSequence(t *testing.T) { ctx := context.Background() // proceeding metadata jurisdiction := "UPC" procID := 1 pt := ProceedingType{ ID: procID, Code: "upc.inf.cfi", Name: "Verletzungsverfahren", NameEN: "Infringement", Jurisdiction: &jurisdiction, IsActive: true, } mkID := func() uuid.UUID { id, _ := uuid.NewRandom() return id } str := func(s string) *string { return &s } procIDPtr := &procID socID := mkID() oralID := mkID() transID := mkID() interpID := mkID() socCode := "upc.inf.cfi.soc" oralCode := "upc.inf.cfi.oral" transCode := "upc.inf.cfi.translation_request" interpCode := "upc.inf.cfi.interpreter_cost" rules := []Rule{ { ID: socID, ProceedingTypeID: procIDPtr, ParentID: nil, SubmissionCode: &socCode, Name: "Klageerhebung", NameEN: "Statement of Claim", PrimaryParty: str("claimant"), DurationValue: 0, DurationUnit: "months", Timing: str("after"), SequenceOrder: 0, IsCourtSet: false, IsActive: true, LifecycleState: "published", Priority: "mandatory", }, // Translation request: sequence_order BEFORE the oral hearing. // Reproduces the real corpus ordering (DB rows 45 < 50). { ID: transID, ProceedingTypeID: procIDPtr, ParentID: &oralID, SubmissionCode: &transCode, Name: "Antrag auf Simultanübersetzung", NameEN: "Translation request", PrimaryParty: str("both"), DurationValue: 1, DurationUnit: "months", Timing: str("before"), SequenceOrder: 45, IsCourtSet: false, IsActive: true, LifecycleState: "published", Priority: "optional", }, // Interpreter cost notice: sequence_order BEFORE the oral hearing. { ID: interpID, ProceedingTypeID: procIDPtr, ParentID: &oralID, SubmissionCode: &interpCode, Name: "Mitteilung Dolmetscherkosten", NameEN: "Interpreter cost notice", PrimaryParty: str("court"), DurationValue: 2, DurationUnit: "weeks", Timing: str("before"), SequenceOrder: 46, IsCourtSet: false, IsActive: true, LifecycleState: "published", Priority: "mandatory", }, // Oral hearing: court-set, no calculable date. Listed AFTER its // "before"-timed children in sequence_order. { ID: oralID, ProceedingTypeID: procIDPtr, ParentID: nil, SubmissionCode: &oralCode, Name: "Mündliche Verhandlung", NameEN: "Oral hearing", PrimaryParty: str("court"), DurationValue: 0, DurationUnit: "months", Timing: str("after"), SequenceOrder: 50, IsCourtSet: true, IsActive: true, LifecycleState: "published", Priority: "mandatory", }, } cat := &stubCatalog{pt: pt, rules: rules} // IncludeOptional=true because translation_request carries // priority='optional'; the test exercises the before-child-of- // court-set-parent flow, which is orthogonal to the optional-rule // suppression added in t-paliad-342. 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 } // The trigger event itself is unambiguous. if got := byCode[socCode]; got.DueDate != "2026-05-26" || !got.IsRootEvent { t.Errorf("SoC: DueDate=%q IsRootEvent=%v, want 2026-05-26 + IsRootEvent=true", got.DueDate, got.IsRootEvent) } // Oral hearing must surface as IsCourtSet (no date). oral := byCode[oralCode] if oral.DueDate != "" || !oral.IsCourtSet { t.Errorf("oral: DueDate=%q IsCourtSet=%v, want empty + IsCourtSet=true", oral.DueDate, oral.IsCourtSet) } // The two "before" children of the court-set oral hearing MUST surface // as conditional rows (no date, no fabricated arithmetic off the // trigger date). The buggy behaviour produces 2026-04-27 and 2026-05-12. trans := byCode[transCode] if trans.DueDate != "" { t.Errorf("translation_request: DueDate=%q, want empty (parent oral is court-set, no anchor known yet)", trans.DueDate) } if !trans.IsConditional && !trans.IsCourtSet { t.Errorf("translation_request: IsConditional=%v IsCourtSet=%v, want at least one true", trans.IsConditional, trans.IsCourtSet) } interp := byCode[interpCode] if interp.DueDate != "" { t.Errorf("interpreter_cost: DueDate=%q, want empty (parent oral is court-set, no anchor known yet)", interp.DueDate) } if !interp.IsConditional && !interp.IsCourtSet { t.Errorf("interpreter_cost: IsConditional=%v IsCourtSet=%v, want at least one true", interp.IsConditional, interp.IsCourtSet) } } // TestCalculate_BeforeChildOfCourtSetParent_WithOverride pins the // override semantics: when the user supplies an anchor override for // the court-set parent, the "before" children should compute against // that override date instead of remaining conditional. func TestCalculate_BeforeChildOfCourtSetParent_WithOverride(t *testing.T) { ctx := context.Background() jurisdiction := "UPC" procID := 1 pt := ProceedingType{ ID: procID, Code: "upc.inf.cfi", Name: "Verletzungsverfahren", Jurisdiction: &jurisdiction, IsActive: true, } mkID := func() uuid.UUID { id, _ := uuid.NewRandom() return id } str := func(s string) *string { return &s } procIDPtr := &procID socID := mkID() oralID := mkID() transID := mkID() socCode := "upc.inf.cfi.soc" oralCode := "upc.inf.cfi.oral" transCode := "upc.inf.cfi.translation_request" rules := []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", }, { ID: transID, ProceedingTypeID: procIDPtr, ParentID: &oralID, SubmissionCode: &transCode, Name: "Antrag auf Simultanübersetzung", NameEN: "Translation request", PrimaryParty: str("both"), DurationValue: 1, DurationUnit: "months", Timing: str("before"), SequenceOrder: 45, IsActive: true, LifecycleState: "published", Priority: "optional", }, { ID: oralID, ProceedingTypeID: procIDPtr, ParentID: nil, SubmissionCode: &oralCode, Name: "Mündliche Verhandlung", NameEN: "Oral hearing", PrimaryParty: str("court"), DurationValue: 0, DurationUnit: "months", Timing: str("after"), SequenceOrder: 50, IsCourtSet: true, IsActive: true, LifecycleState: "published", Priority: "mandatory", }, } cat := &stubCatalog{pt: pt, rules: rules} // User pins the oral hearing to 2026-10-15. IncludeOptional=true // because translation_request is priority='optional' (t-paliad-342). opts := CalcOptions{ IncludeOptional: true, AnchorOverrides: map[string]string{ oralCode: "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 } if got := byCode[oralCode].DueDate; got != "2026-10-15" { t.Errorf("oral: DueDate=%q, want 2026-10-15 (user override)", got) } // 1 month before 2026-10-15 = 2026-09-15 if got := byCode[transCode].DueDate; got != "2026-09-15" { t.Errorf("translation_request: DueDate=%q, want 2026-09-15 (1 month before oral override)", got) } }