Two paired engine semantics fixes:
1. trigger_event_id is now the authoritative semantic anchor. When a
rule carries trigger_event_id, the engine no longer falls back to
the proceeding's trigger date — it resolves the anchor via
CalcOptions.TriggerEventAnchors keyed by paliad.trigger_events.code.
Missing anchor renders the rule as IsConditional (empty date) and
propagates via courtSet so descendants also surface as conditional.
Fixes the RoP.109.5 bug where the engine fabricated a date 2 weeks
before the user's SoC instead of waiting for the oral_hearing date.
2. priority='optional' rules are suppressed from the default
Calculate output. Callers (paliad /tools/procedures,
youpc.org/deadlines) opt in via CalcOptions.IncludeOptional=true to
restore the legacy "show optional applications" behaviour. The
suppression cascades through skippedIDs so child rules drop too.
Wire shape additions:
- CalcOptions.IncludeOptional bool
- CalcOptions.TriggerEventAnchors map[string]string
- Timeline.RulesAwaitingAnchor int (count of suppressed-by-missing-
anchor rules, for caller telemetry / "N rules need an anchor" UX)
Existing before-court-set-anchor tests opt in to IncludeOptional=true
to preserve their non-optional-related test intent.
Refs: youpcorg/head delegations #2568 + #2570, m/paliad#153 (Litigation
Builder PRD path).
380 lines
12 KiB
Go
380 lines
12 KiB
Go
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")
|
|
}
|
|
}
|