fix(litigationplanner): respect trigger_event_id + suppress optional from default (yoUPC#178 + #2568/#2570)
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).
This commit is contained in:
@@ -205,7 +205,11 @@ func TestCalculate_BeforeChildOfCourtSetParent_OutOfOrderSequence(t *testing.T)
|
|||||||
|
|
||||||
cat := &stubCatalog{pt: pt, rules: rules}
|
cat := &stubCatalog{pt: pt, rules: rules}
|
||||||
|
|
||||||
timeline, err := Calculate(ctx, "upc.inf.cfi", "2026-05-26", CalcOptions{}, cat, noOpHolidays{}, fixedCourts{})
|
// 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 {
|
if err != nil {
|
||||||
t.Fatalf("Calculate: %v", err)
|
t.Fatalf("Calculate: %v", err)
|
||||||
}
|
}
|
||||||
@@ -301,8 +305,10 @@ func TestCalculate_BeforeChildOfCourtSetParent_WithOverride(t *testing.T) {
|
|||||||
|
|
||||||
cat := &stubCatalog{pt: pt, rules: rules}
|
cat := &stubCatalog{pt: pt, rules: rules}
|
||||||
|
|
||||||
// User pins the oral hearing to 2026-10-15.
|
// User pins the oral hearing to 2026-10-15. IncludeOptional=true
|
||||||
|
// because translation_request is priority='optional' (t-paliad-342).
|
||||||
opts := CalcOptions{
|
opts := CalcOptions{
|
||||||
|
IncludeOptional: true,
|
||||||
AnchorOverrides: map[string]string{
|
AnchorOverrides: map[string]string{
|
||||||
oralCode: "2026-10-15",
|
oralCode: "2026-10-15",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -80,6 +80,21 @@ func Calculate(
|
|||||||
overrideDates[code] = od
|
overrideDates[code] = od
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Trigger-event anchors keyed by paliad.trigger_events.code
|
||||||
|
// (t-paliad-342). Parsed up-front so malformed dates error before
|
||||||
|
// the rule walk. When a rule has trigger_event_id set, the engine
|
||||||
|
// looks up triggerAnchorByCode[trigger_event.code] for the
|
||||||
|
// semantic anchor instead of falling back to the proceeding's
|
||||||
|
// trigger date.
|
||||||
|
triggerAnchorByCode := make(map[string]time.Time, len(opts.TriggerEventAnchors))
|
||||||
|
for code, dateStr := range opts.TriggerEventAnchors {
|
||||||
|
td, err := time.Parse("2006-01-02", dateStr)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid trigger event anchor for %q (%q): %w", code, dateStr, err)
|
||||||
|
}
|
||||||
|
triggerAnchorByCode[code] = td
|
||||||
|
}
|
||||||
|
|
||||||
// Look up proceeding type metadata.
|
// Look up proceeding type metadata.
|
||||||
pickedProceeding, rules, err := catalog.LoadProceeding(ctx, proceedingCode, opts.ProjectHint)
|
pickedProceeding, rules, err := catalog.LoadProceeding(ctx, proceedingCode, opts.ProjectHint)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -213,6 +228,7 @@ func Calculate(
|
|||||||
perCardAppellant := opts.PerCardAppellant
|
perCardAppellant := opts.PerCardAppellant
|
||||||
skippedIDs := make(map[uuid.UUID]struct{}, len(skipRules))
|
skippedIDs := make(map[uuid.UUID]struct{}, len(skipRules))
|
||||||
hiddenCount := 0
|
hiddenCount := 0
|
||||||
|
rulesAwaitingAnchor := 0
|
||||||
appellantContext := make(map[uuid.UUID]string, len(rules))
|
appellantContext := make(map[uuid.UUID]string, len(rules))
|
||||||
|
|
||||||
for _, r := range walkRules {
|
for _, r := range walkRules {
|
||||||
@@ -227,6 +243,17 @@ func Calculate(
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Optional-rule suppression (t-paliad-342 / youpcorg#2570).
|
||||||
|
// Rules tagged priority='optional' don't auto-fire in the
|
||||||
|
// default timeline; the caller opts in via
|
||||||
|
// CalcOptions.IncludeOptional. Cascade through skippedIDs so
|
||||||
|
// children chaining off the suppressed rule also drop — they
|
||||||
|
// can't compute a date against a missing parent.
|
||||||
|
if r.Priority == "optional" && !opts.IncludeOptional {
|
||||||
|
skippedIDs[r.ID] = struct{}{}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
// SkipRules suppression (t-paliad-265).
|
// SkipRules suppression (t-paliad-265).
|
||||||
// t-paliad-290 (m/paliad#122): when opts.IncludeHidden is set,
|
// t-paliad-290 (m/paliad#122): when opts.IncludeHidden is set,
|
||||||
// we re-surface the directly-skipped row (faded via IsHidden)
|
// we re-surface the directly-skipped row (faded via IsHidden)
|
||||||
@@ -327,15 +354,43 @@ func Calculate(
|
|||||||
// (m/paliad#126 / t-paliad-294). When a rule has a real
|
// (m/paliad#126 / t-paliad-294). When a rule has a real
|
||||||
// trigger_event_id, that catalog event is the actual semantic
|
// trigger_event_id, that catalog event is the actual semantic
|
||||||
// anchor — not the parent_id node, which is only the calc-time
|
// anchor — not the parent_id node, which is only the calc-time
|
||||||
// arithmetic anchor. Only the user-facing wire fields shift;
|
// arithmetic anchor. Only the user-facing wire fields shift
|
||||||
// parentRule (and the parent_id chain feeding parentIsCourtSet
|
// here; the calc-time anchor logic for trigger_event_id rules
|
||||||
// and the calc-time arithmetic below) stays anchored on the
|
// lives just below.
|
||||||
// rule tree.
|
var triggerEventAnchor time.Time
|
||||||
|
var hasTriggerEventAnchor bool
|
||||||
if r.TriggerEventID != nil {
|
if r.TriggerEventID != nil {
|
||||||
if te, ok := triggerEventByID[*r.TriggerEventID]; ok {
|
if te, ok := triggerEventByID[*r.TriggerEventID]; ok {
|
||||||
d.ParentRuleCode = te.Code
|
d.ParentRuleCode = te.Code
|
||||||
d.ParentRuleName = te.NameDE
|
d.ParentRuleName = te.NameDE
|
||||||
d.ParentRuleNameEN = te.Name
|
d.ParentRuleNameEN = te.Name
|
||||||
|
if td, ok := triggerAnchorByCode[te.Code]; ok {
|
||||||
|
triggerEventAnchor = td
|
||||||
|
hasTriggerEventAnchor = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trigger-event semantic-anchor suppression (t-paliad-342 /
|
||||||
|
// youpcorg#2568). When a rule has an explicit trigger_event_id
|
||||||
|
// but the caller hasn't supplied a date for that event via
|
||||||
|
// CalcOptions.TriggerEventAnchors, the engine refuses to
|
||||||
|
// fabricate a date off the proceeding's trigger date — the
|
||||||
|
// rule's semantic anchor is the event itself, not the SoC.
|
||||||
|
// Render IsConditional with empty dates and propagate via
|
||||||
|
// courtSet so descendants chaining off this rule also surface
|
||||||
|
// as conditional rather than projecting fictional dates.
|
||||||
|
if !hasTriggerEventAnchor {
|
||||||
|
d.IsConditional = true
|
||||||
|
d.IsCourtSet = true
|
||||||
|
d.DueDate = ""
|
||||||
|
d.OriginalDate = ""
|
||||||
|
courtSet[r.ID] = true
|
||||||
|
rulesAwaitingAnchor++
|
||||||
|
if r.SubmissionCode != nil {
|
||||||
|
skippedIDs[r.ID] = struct{}{}
|
||||||
|
}
|
||||||
|
deadlines = append(deadlines, d)
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -379,6 +434,20 @@ func Calculate(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Trigger-event anchor wins over the bucket logic below: a
|
||||||
|
// zero-duration rule with trigger_event_id is "occurs on the
|
||||||
|
// trigger event's date". Anchor missing was already caught
|
||||||
|
// above (suppression branch).
|
||||||
|
if hasTriggerEventAnchor {
|
||||||
|
d.DueDate = triggerEventAnchor.Format("2006-01-02")
|
||||||
|
d.OriginalDate = d.DueDate
|
||||||
|
if r.SubmissionCode != nil {
|
||||||
|
computed[*r.SubmissionCode] = triggerEventAnchor
|
||||||
|
}
|
||||||
|
deadlines = append(deadlines, d)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
if r.ParentID == nil && !r.IsCourtSet {
|
if r.ParentID == nil && !r.IsCourtSet {
|
||||||
// Bucket 1: timeline anchor.
|
// Bucket 1: timeline anchor.
|
||||||
d.IsRootEvent = true
|
d.IsRootEvent = true
|
||||||
@@ -457,11 +526,19 @@ func Calculate(
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Anchor: prefer alt-anchor (e.g. priority_date for
|
// Anchor priority:
|
||||||
// epa.grant.exa publish) when supplied, then parent's computed
|
// 1. trigger_event_id semantic anchor (t-paliad-342) — when
|
||||||
// date (or user override), then trigger date.
|
// the rule has trigger_event_id and the caller supplied a
|
||||||
|
// date in TriggerEventAnchors, that date wins over the
|
||||||
|
// parent chain AND the priority_date alt-anchor. The
|
||||||
|
// missing-anchor case was already short-circuited above.
|
||||||
|
// 2. priority_date alt-anchor (epa.grant.exa publish).
|
||||||
|
// 3. parent's computed date (or user override).
|
||||||
|
// 4. proceeding trigger date (default fallback).
|
||||||
baseDate := triggerDate
|
baseDate := triggerDate
|
||||||
if r.AnchorAlt != nil && *r.AnchorAlt == "priority_date" && priorityDate != nil {
|
if hasTriggerEventAnchor {
|
||||||
|
baseDate = triggerEventAnchor
|
||||||
|
} else if r.AnchorAlt != nil && *r.AnchorAlt == "priority_date" && priorityDate != nil {
|
||||||
baseDate = *priorityDate
|
baseDate = *priorityDate
|
||||||
} else if r.ParentID != nil {
|
} else if r.ParentID != nil {
|
||||||
for _, prev := range rules {
|
for _, prev := range rules {
|
||||||
@@ -641,6 +718,7 @@ func Calculate(
|
|||||||
TriggerDate: triggerDateStr,
|
TriggerDate: triggerDateStr,
|
||||||
Deadlines: deadlines,
|
Deadlines: deadlines,
|
||||||
HiddenCount: hiddenCount,
|
HiddenCount: hiddenCount,
|
||||||
|
RulesAwaitingAnchor: rulesAwaitingAnchor,
|
||||||
}
|
}
|
||||||
// Sub-track routing keeps the user-picked proceeding's identity,
|
// Sub-track routing keeps the user-picked proceeding's identity,
|
||||||
// so the trigger-event label rides on `pickedProceeding`.
|
// so the trigger-event label rides on `pickedProceeding`.
|
||||||
|
|||||||
379
pkg/litigationplanner/optional_and_trigger_anchor_test.go
Normal file
379
pkg/litigationplanner/optional_and_trigger_anchor_test.go
Normal file
@@ -0,0 +1,379 @@
|
|||||||
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -334,6 +334,25 @@ type CalcOptions struct {
|
|||||||
// filter applied) so a stale frontend chip doesn't break the
|
// filter applied) so a stale frontend chip doesn't break the
|
||||||
// timeline render — see IsValidAppealTarget.
|
// timeline render — see IsValidAppealTarget.
|
||||||
AppealTarget string
|
AppealTarget string
|
||||||
|
|
||||||
|
// IncludeOptional surfaces rules with priority='optional' in the
|
||||||
|
// default timeline (t-paliad-342 / youpcorg#2570). Default false:
|
||||||
|
// optional rules don't auto-fire alongside mandatory ones. The
|
||||||
|
// caller (paliad /tools/procedures, youpc.org/deadlines) wires this
|
||||||
|
// to a user-facing "show optional applications" toggle.
|
||||||
|
IncludeOptional bool
|
||||||
|
|
||||||
|
// TriggerEventAnchors supplies concrete dates for procedural events
|
||||||
|
// referenced by rules' trigger_event_id (t-paliad-342 / youpcorg#2568).
|
||||||
|
// Key = paliad.trigger_events.code (e.g. "oral_hearing"), value =
|
||||||
|
// YYYY-MM-DD. When a rule carries an explicit trigger_event_id, that
|
||||||
|
// catalog event is the authoritative semantic anchor: arithmetic
|
||||||
|
// resolves against TriggerEventAnchors[code] if set, otherwise the
|
||||||
|
// rule is suppressed as IsConditional (no fabricated date off the
|
||||||
|
// user's trigger date). Empty map = engine never anchors on a
|
||||||
|
// trigger event, so every rule with trigger_event_id surfaces as
|
||||||
|
// conditional.
|
||||||
|
TriggerEventAnchors map[string]string
|
||||||
}
|
}
|
||||||
|
|
||||||
// ProjectHint scopes a Catalog call to a specific project. Paliad's
|
// ProjectHint scopes a Catalog call to a specific project. Paliad's
|
||||||
@@ -375,6 +394,13 @@ type Timeline struct {
|
|||||||
TriggerEventLabel string `json:"triggerEventLabel,omitempty"`
|
TriggerEventLabel string `json:"triggerEventLabel,omitempty"`
|
||||||
TriggerEventLabelEN string `json:"triggerEventLabelEN,omitempty"`
|
TriggerEventLabelEN string `json:"triggerEventLabelEN,omitempty"`
|
||||||
HiddenCount int `json:"hiddenCount"`
|
HiddenCount int `json:"hiddenCount"`
|
||||||
|
// RulesAwaitingAnchor counts rules suppressed because their
|
||||||
|
// trigger_event_id anchor date wasn't supplied via
|
||||||
|
// CalcOptions.TriggerEventAnchors (t-paliad-342). Such rules still
|
||||||
|
// render in the timeline as IsConditional (no date) — the field
|
||||||
|
// gives the caller a single integer for "N rules waiting on an
|
||||||
|
// anchor" UI affordances + telemetry.
|
||||||
|
RulesAwaitingAnchor int `json:"rulesAwaitingAnchor,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// TimelineEntry matches the frontend's CalculatedDeadline TypeScript
|
// TimelineEntry matches the frontend's CalculatedDeadline TypeScript
|
||||||
|
|||||||
Reference in New Issue
Block a user