Engine side of the four Verfahrensablauf appeal bugs in m/paliad#136. Bug 2 — Missing trigger event row. When CalcOptions.AppealTarget is set, Calculate now prepends a synthetic TimelineEntry to the deadlines slice dated to the trigger date, carrying the per-appeal-target label from TriggerEventLabelForAppealTarget (Endentscheidung (R.118), Kosten- entscheidung, Anordnung, Schadensbemessung, Bucheinsicht). Marked IsRootEvent + IsTriggerEvent + party=court + priority=informational so the frontend renders it as a dimmed anchor card without a save button / choices caret / click-to-edit affordance. Empty Code so it doesn't collide with real rule UUIDs downstream. Bug 1 (engine half) — Side selector dead on appeal. Every appeal filing rule carries primary_party='both' in the catalog, so the column bucketer couldn't distinguish Berufungskläger vs Berufungs- beklagter filings from primary_party alone. Engine now stamps the new TimelineEntry.AppealRole field with appellant/appellee from the rule-semantic AppealFilerRole mapping (appeal_role.go) when an appeal_target is in scope. The frontend half of the fix (next commit) consumes this to route each "both" rule into the user-perspective column once the user picks a side. Mapping covers all 12 appeal filing rules across the three applies_to_target tracks (endentscheidung/schadensbemessung, kostenentscheidung, anordnung/bucheinsicht). Court-issued events (merits.decision, merits.oral, cost.decision, order.order) stay empty — they continue to route on Party='court'. Unmapped submission_codes return empty so a new appeal rule we forgot to map falls through to the bucketer's legacy path rather than silently picking a side. Tests: TestAppealFilerRole pins the mapping; TestCalculate_Appeal SyntheticTriggerRow covers (a) synthetic row prepended + AppealRole stamped when target is set, (b) no synthetic row + no AppealRole when target is unset (regression guard), (c) unknown target short-circuits to no-op. Existing tests untouched — both behaviours gate on opts.AppealTarget != "". No DB migration — the bugs are calc-side. deadline_rules untouched.
193 lines
6.6 KiB
Go
193 lines
6.6 KiB
Go
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)
|
|
}
|
|
}
|
|
})
|
|
}
|