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).
335 lines
12 KiB
Go
335 lines
12 KiB
Go
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)
|
|
}
|
|
}
|