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}
|
||||
|
||||
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 {
|
||||
t.Fatalf("Calculate: %v", err)
|
||||
}
|
||||
@@ -301,8 +305,10 @@ func TestCalculate_BeforeChildOfCourtSetParent_WithOverride(t *testing.T) {
|
||||
|
||||
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{
|
||||
IncludeOptional: true,
|
||||
AnchorOverrides: map[string]string{
|
||||
oralCode: "2026-10-15",
|
||||
},
|
||||
|
||||
@@ -80,6 +80,21 @@ func Calculate(
|
||||
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.
|
||||
pickedProceeding, rules, err := catalog.LoadProceeding(ctx, proceedingCode, opts.ProjectHint)
|
||||
if err != nil {
|
||||
@@ -213,6 +228,7 @@ func Calculate(
|
||||
perCardAppellant := opts.PerCardAppellant
|
||||
skippedIDs := make(map[uuid.UUID]struct{}, len(skipRules))
|
||||
hiddenCount := 0
|
||||
rulesAwaitingAnchor := 0
|
||||
appellantContext := make(map[uuid.UUID]string, len(rules))
|
||||
|
||||
for _, r := range walkRules {
|
||||
@@ -227,6 +243,17 @@ func Calculate(
|
||||
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).
|
||||
// t-paliad-290 (m/paliad#122): when opts.IncludeHidden is set,
|
||||
// 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
|
||||
// trigger_event_id, that catalog event is the actual semantic
|
||||
// anchor — not the parent_id node, which is only the calc-time
|
||||
// arithmetic anchor. Only the user-facing wire fields shift;
|
||||
// parentRule (and the parent_id chain feeding parentIsCourtSet
|
||||
// and the calc-time arithmetic below) stays anchored on the
|
||||
// rule tree.
|
||||
// arithmetic anchor. Only the user-facing wire fields shift
|
||||
// here; the calc-time anchor logic for trigger_event_id rules
|
||||
// lives just below.
|
||||
var triggerEventAnchor time.Time
|
||||
var hasTriggerEventAnchor bool
|
||||
if r.TriggerEventID != nil {
|
||||
if te, ok := triggerEventByID[*r.TriggerEventID]; ok {
|
||||
d.ParentRuleCode = te.Code
|
||||
d.ParentRuleName = te.NameDE
|
||||
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 {
|
||||
// Bucket 1: timeline anchor.
|
||||
d.IsRootEvent = true
|
||||
@@ -457,11 +526,19 @@ func Calculate(
|
||||
continue
|
||||
}
|
||||
|
||||
// Anchor: prefer alt-anchor (e.g. priority_date for
|
||||
// epa.grant.exa publish) when supplied, then parent's computed
|
||||
// date (or user override), then trigger date.
|
||||
// Anchor priority:
|
||||
// 1. trigger_event_id semantic anchor (t-paliad-342) — when
|
||||
// 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
|
||||
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
|
||||
} else if r.ParentID != nil {
|
||||
for _, prev := range rules {
|
||||
@@ -635,12 +712,13 @@ func Calculate(
|
||||
}
|
||||
|
||||
resp := &Timeline{
|
||||
ProceedingType: pickedProceeding.Code,
|
||||
ProceedingName: pickedProceeding.Name,
|
||||
ProceedingNameEN: pickedProceeding.NameEN,
|
||||
TriggerDate: triggerDateStr,
|
||||
Deadlines: deadlines,
|
||||
HiddenCount: hiddenCount,
|
||||
ProceedingType: pickedProceeding.Code,
|
||||
ProceedingName: pickedProceeding.Name,
|
||||
ProceedingNameEN: pickedProceeding.NameEN,
|
||||
TriggerDate: triggerDateStr,
|
||||
Deadlines: deadlines,
|
||||
HiddenCount: hiddenCount,
|
||||
RulesAwaitingAnchor: rulesAwaitingAnchor,
|
||||
}
|
||||
// Sub-track routing keeps the user-picked proceeding's identity,
|
||||
// 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
|
||||
// timeline render — see IsValidAppealTarget.
|
||||
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
|
||||
@@ -375,6 +394,13 @@ type Timeline struct {
|
||||
TriggerEventLabel string `json:"triggerEventLabel,omitempty"`
|
||||
TriggerEventLabelEN string `json:"triggerEventLabelEN,omitempty"`
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user