package litigationplanner import ( "context" "testing" "github.com/google/uuid" ) // TestAppealFilerRole pins the rule-semantic mapping that drives // column-bucketing on the unified upc.apl Berufung timeline // (t-paliad-307 / m/paliad#136 Bug 1). Every appeal filing rule has // primary_party='both' in the catalog so the bucketer can't decide // between Berufungskläger and Berufungsbeklagter columns from // primary_party alone — the appeal role fills that gap. func TestAppealFilerRole(t *testing.T) { cases := []struct { code string want string }{ // Appellant filings (Berufungskläger initiates / replies to cross). {"upc.apl.merits.notice", AppealRoleAppellant}, {"upc.apl.merits.grounds", AppealRoleAppellant}, {"upc.apl.merits.cross_a_reply", AppealRoleAppellant}, {"upc.apl.cost.leave_app", AppealRoleAppellant}, {"upc.apl.order.with_leave", AppealRoleAppellant}, {"upc.apl.order.grounds_orders", AppealRoleAppellant}, {"upc.apl.order.discretion", AppealRoleAppellant}, {"upc.apl.order.cross_reply", AppealRoleAppellant}, // Appellee filings (Berufungsbeklagter responds + cross-appeals). {"upc.apl.merits.response", AppealRoleAppellee}, {"upc.apl.merits.cross_a", AppealRoleAppellee}, {"upc.apl.order.response_orders", AppealRoleAppellee}, {"upc.apl.order.cross", AppealRoleAppellee}, // Court-issued events stay empty — they route on party='court'. {"upc.apl.merits.decision", ""}, {"upc.apl.merits.oral", ""}, {"upc.apl.cost.decision", ""}, {"upc.apl.order.order", ""}, // Unmapped codes are empty (defensive — never silently picks a // side for a new appeal rule we forgot to map). {"upc.inf.cfi.soc", ""}, {"", ""}, {"foo.bar", ""}, } for _, c := range cases { if got := AppealFilerRole(c.code); got != c.want { t.Errorf("AppealFilerRole(%q) = %q, want %q", c.code, got, c.want) } } } // TestCalculate_AppealSyntheticTriggerRow exercises the synthetic root // row the engine prepends when CalcOptions.AppealTarget is set // (t-paliad-307 / m/paliad#136 Bug 2). The row carries the // per-appeal-target label, the trigger date as DueDate, IsRootEvent= // IsTriggerEvent=true, and party=court. Without the appeal_target // filter, no synthetic row is emitted (regression guard). func TestCalculate_AppealSyntheticTriggerRow(t *testing.T) { ctx := context.Background() jurisdiction := "UPC" procID := 1 pt := ProceedingType{ ID: procID, Code: "upc.apl.unified", Name: "Berufung", NameEN: "Appeal", Jurisdiction: &jurisdiction, IsActive: true, } mkID := func() uuid.UUID { id, _ := uuid.NewRandom() return id } str := func(s string) *string { return &s } procIDPtr := &procID noticeCode := "upc.apl.merits.notice" groundsCode := "upc.apl.merits.grounds" rules := []Rule{ { ID: mkID(), ProceedingTypeID: procIDPtr, SubmissionCode: ¬iceCode, Name: "Berufungseinlegung", NameEN: "Notice of Appeal", PrimaryParty: str(PrimaryPartyBoth), DurationValue: 2, DurationUnit: "months", Timing: str("after"), SequenceOrder: 0, IsActive: true, LifecycleState: "published", Priority: "mandatory", AppliesToTarget: []string{AppealTargetEndentscheidung, AppealTargetSchadensbemessung}, }, { ID: mkID(), ProceedingTypeID: procIDPtr, SubmissionCode: &groundsCode, Name: "Berufungsbegründung", NameEN: "Statement of Grounds", PrimaryParty: str(PrimaryPartyBoth), DurationValue: 4, DurationUnit: "months", Timing: str("after"), SequenceOrder: 1, IsActive: true, LifecycleState: "published", Priority: "mandatory", AppliesToTarget: []string{AppealTargetEndentscheidung, AppealTargetSchadensbemessung}, }, } cat := &stubCatalog{pt: pt, rules: rules} t.Run("with appeal_target — synthetic row prepended + appeal_role stamped", func(t *testing.T) { opts := CalcOptions{AppealTarget: AppealTargetEndentscheidung} timeline, err := Calculate(ctx, "upc.apl.unified", "2026-05-26", opts, cat, noOpHolidays{}, fixedCourts{}) if err != nil { t.Fatalf("Calculate: %v", err) } if len(timeline.Deadlines) < 3 { t.Fatalf("expected synthetic row + 2 rules, got %d rows", len(timeline.Deadlines)) } // Synthetic row first. first := timeline.Deadlines[0] if !first.IsTriggerEvent { t.Errorf("first row IsTriggerEvent=%v, want true", first.IsTriggerEvent) } if !first.IsRootEvent { t.Errorf("first row IsRootEvent=%v, want true", first.IsRootEvent) } if first.Name != "Endentscheidung (R.118)" { t.Errorf("first row Name=%q, want %q", first.Name, "Endentscheidung (R.118)") } if first.NameEN != "Final decision (R.118)" { t.Errorf("first row NameEN=%q, want %q", first.NameEN, "Final decision (R.118)") } if first.DueDate != "2026-05-26" { t.Errorf("first row DueDate=%q, want 2026-05-26", first.DueDate) } if first.Party != PrimaryPartyCourt { t.Errorf("first row Party=%q, want court", first.Party) } // Real rules should carry AppealRole. byCode := map[string]TimelineEntry{} for _, d := range timeline.Deadlines { byCode[d.Code] = d } if got := byCode[noticeCode].AppealRole; got != AppealRoleAppellant { t.Errorf("notice AppealRole=%q, want appellant", got) } if got := byCode[groundsCode].AppealRole; got != AppealRoleAppellant { t.Errorf("grounds AppealRole=%q, want appellant", got) } }) t.Run("without appeal_target — no synthetic row, no appeal_role", func(t *testing.T) { opts := CalcOptions{} timeline, err := Calculate(ctx, "upc.apl.unified", "2026-05-26", opts, cat, noOpHolidays{}, fixedCourts{}) if err != nil { t.Fatalf("Calculate: %v", err) } for _, d := range timeline.Deadlines { if d.IsTriggerEvent { t.Errorf("unexpected synthetic trigger row when appeal_target is unset: %+v", d) } if d.AppealRole != "" { t.Errorf("unexpected AppealRole=%q when appeal_target is unset (rule %q)", d.AppealRole, d.Code) } } }) t.Run("unknown appeal_target — short-circuits to no-op", func(t *testing.T) { opts := CalcOptions{AppealTarget: "bogus"} timeline, err := Calculate(ctx, "upc.apl.unified", "2026-05-26", opts, cat, noOpHolidays{}, fixedCourts{}) if err != nil { t.Fatalf("Calculate: %v", err) } // IsValidAppealTarget("bogus") = false, so the engine skips // both the rule filter AND the synthetic trigger emission. for _, d := range timeline.Deadlines { if d.IsTriggerEvent { t.Errorf("unexpected synthetic trigger row for unknown target: %+v", d) } } }) }