Rules anchored on uncertain triggers (R.109 backward-anchor without oral-hearing date; R.118(4) without validity decision; R.262(2) without recorded Vertraulichkeitsantrag) previously rendered concrete dates fabricated off the trigger date. Add IsConditional projection flag so the SmartTimeline + Verfahrensablauf surfaces "abhängig von <parent>" instead of a misleading date. Backend (fristenrechner.go): - Add IsConditional + ParentRuleCode/Name/NameEN to UIDeadline. - Pre-pass populates courtSet from rule.is_court_set=true BEFORE the main loop, so order-of-evaluation in sequence_order no longer matters for the parent-court-set check. Fixes R.109(1) "Antrag auf Simultanübersetzung" (sequence_order=45 < Mündliche Verhandlung's sequence_order=50): the timing='before' backward arithmetic was computing 1 month before the trigger date because the court-set parent hadn't been classified yet. - Set IsConditional=true on every IsCourtSetIndirect branch (catches R.109 backward + R.118(4) cons_orders chain off the decision). - Set IsConditional=true for priority='optional' + primary_party='both' rules whose data-model parent is the trigger anchor (covers R.262(2) confidentiality_response: the data anchors on SoC, but the real trigger is the opposing party's confidentiality motion which may never happen). Suppressed by IsOverridden so user anchors win. Backend (projection_service.go): - Add IsConditional to TimelineEvent + propagate from UIDeadline. - New Status="conditional" for projected rows; clears Date, populates DependsOnRuleCode/Name from UIDeadline.ParentRule* so the row carries the "abhängig von <parent>" payload even when the parent has no computed date for annotateDependsOn to discover. Frontend (verfahrensablauf-core.ts + CSS + i18n): - CalculatedDeadline gains isConditional + parentRule* fields. - deadlineCardHtml renders "abhängig von <parent>" chip with click-to-edit affordance in place of the date column when isConditional=true. IsConditional wins over IsCourtSet for the date column (they overlap; "abhängig von <parent>" names the specific blocker). - .timeline-item--conditional / .fr-col-item--conditional CSS: dotted border + faded text so the conditional state reads at glance. - Replaced escHtml's DOM-backed implementation with a pure-JS regex escape so the module is testable in bun test without jsdom (the old form forced fixtures to leave several fields empty just to avoid the DOM dependency). Tests: - TestApplyLookaheadCap_ConditionalRowsPassThrough: pure-function lock that conditional rows pass through applyLookaheadCap untouched (don't count against ProjectedTotal/Shown, don't get capped). - TestUIDeadline_IsConditional_UncertainAnchors (TEST_DATABASE_URL): asserts R.109(1)/(4), R.118(4) chain, and R.262(2) all render IsConditional=true with empty DueDate + populated ParentRule*; SoD stays non-conditional; override on the oral hearing flips R.109(1) back to concrete date. - 4 new bun tests for the conditional rendering branches in deadlineCardHtml. UX path verified by tests + manual review of the live rule corpus: opening a UPC inf project without oral-hearing date now surfaces R.109(1) + R.109(4) as conditional; recording the Vertraulichkeitsantrag (anchoring R.262(2) via the existing "Datum setzen" flow) flips it back to a concrete date. go build / go test / bun test / bun run build all clean.
383 lines
12 KiB
Go
383 lines
12 KiB
Go
package services
|
|
|
|
// Pure-function tests for ProjectionService Slice 2 (t-paliad-173) —
|
|
// no DB required. Validates lookahead cap behaviour, anchor-kind
|
|
// dispatch, and extractMetadataString. The live integration test in
|
|
// projection_service_test.go covers SQL paths; this file covers the
|
|
// pure helpers a future refactor most likely to break.
|
|
|
|
import (
|
|
"encoding/json"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
|
|
"mgit.msbls.de/m/paliad/internal/models"
|
|
)
|
|
|
|
func TestApplyLookaheadDefault(t *testing.T) {
|
|
cases := []struct {
|
|
in, want int
|
|
}{
|
|
{0, DefaultLookaheadCap},
|
|
{-5, DefaultLookaheadCap},
|
|
{1, 1},
|
|
{7, 7},
|
|
{50, 50},
|
|
{51, MaxLookaheadCap},
|
|
{1000, MaxLookaheadCap},
|
|
}
|
|
for _, c := range cases {
|
|
if got := applyLookaheadDefault(c.in); got != c.want {
|
|
t.Errorf("applyLookaheadDefault(%d) = %d, want %d", c.in, got, c.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestApplyLookaheadCap_DropsBeyondCap_ExemptsOverdueAndCourtSet(t *testing.T) {
|
|
mar1 := time.Date(2026, 3, 1, 0, 0, 0, 0, time.UTC)
|
|
apr1 := time.Date(2026, 4, 1, 0, 0, 0, 0, time.UTC)
|
|
may1 := time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC)
|
|
jun1 := time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC)
|
|
jul1 := time.Date(2026, 7, 1, 0, 0, 0, 0, time.UTC)
|
|
|
|
rows := []TimelineEvent{
|
|
// Two predicted_overdue (past) — must survive uncapped.
|
|
{Kind: "projected", Status: "predicted_overdue", Date: &mar1, RuleCode: "rule.past1", Title: "Past 1"},
|
|
{Kind: "projected", Status: "predicted_overdue", Date: &apr1, RuleCode: "rule.past2", Title: "Past 2"},
|
|
// One court_set future — exempt from cap.
|
|
{Kind: "projected", Status: "court_set", Date: &jul1, RuleCode: "rule.hearing", Title: "Hearing"},
|
|
// Three predicted future — cap=2 means the third drops.
|
|
{Kind: "projected", Status: "predicted", Date: &may1, RuleCode: "rule.fut1", Title: "Fut 1"},
|
|
{Kind: "projected", Status: "predicted", Date: &jun1, RuleCode: "rule.fut2", Title: "Fut 2"},
|
|
{Kind: "projected", Status: "predicted", Date: &jul1, RuleCode: "rule.fut3", Title: "Fut 3"},
|
|
}
|
|
|
|
kept, total, shown, overdue := applyLookaheadCap(rows, 2)
|
|
if total != 3 {
|
|
t.Errorf("ProjectedTotal = %d, want 3", total)
|
|
}
|
|
if shown != 2 {
|
|
t.Errorf("ProjectedShown = %d, want 2", shown)
|
|
}
|
|
if overdue != 2 {
|
|
t.Errorf("PredictedOverdue = %d, want 2", overdue)
|
|
}
|
|
// kept must include both overdue + court_set + first 2 predicted = 5 rows.
|
|
if len(kept) != 5 {
|
|
t.Errorf("kept rows = %d, want 5", len(kept))
|
|
}
|
|
// Past + court_set must remain.
|
|
pastTitles := map[string]bool{}
|
|
for _, r := range kept {
|
|
pastTitles[r.Title] = true
|
|
}
|
|
for _, want := range []string{"Past 1", "Past 2", "Hearing", "Fut 1", "Fut 2"} {
|
|
if !pastTitles[want] {
|
|
t.Errorf("expected kept row %q missing", want)
|
|
}
|
|
}
|
|
if pastTitles["Fut 3"] {
|
|
t.Errorf("Fut 3 should have been dropped (cap=2)")
|
|
}
|
|
}
|
|
|
|
func TestApplyLookaheadCap_NoCapWhenUnderLimit(t *testing.T) {
|
|
may1 := time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC)
|
|
rows := []TimelineEvent{
|
|
{Kind: "projected", Status: "predicted", Date: &may1, RuleCode: "r1", Title: "1"},
|
|
}
|
|
kept, total, shown, _ := applyLookaheadCap(rows, 7)
|
|
if total != 1 || shown != 1 {
|
|
t.Errorf("counts = (%d, %d), want (1, 1)", total, shown)
|
|
}
|
|
if len(kept) != 1 {
|
|
t.Errorf("kept = %d, want 1", len(kept))
|
|
}
|
|
}
|
|
|
|
// t-paliad-289: conditional rows (Status="conditional", Date=nil) must
|
|
// pass through applyLookaheadCap untouched — they're not "future
|
|
// predicted" rows by either Status or Date semantics, so they belong in
|
|
// the pass-through bucket alongside court_set / undated rows. The cap
|
|
// must NOT consume one of its slots for a conditional row, and the
|
|
// row must survive even when projTotal exceeds the cap.
|
|
func TestApplyLookaheadCap_ConditionalRowsPassThrough(t *testing.T) {
|
|
may1 := time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC)
|
|
jun1 := time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC)
|
|
jul1 := time.Date(2026, 7, 1, 0, 0, 0, 0, time.UTC)
|
|
|
|
rows := []TimelineEvent{
|
|
// Three predicted future — cap=2 means the third drops.
|
|
{Kind: "projected", Status: "predicted", Date: &may1, RuleCode: "f1", Title: "F1"},
|
|
{Kind: "projected", Status: "predicted", Date: &jun1, RuleCode: "f2", Title: "F2"},
|
|
{Kind: "projected", Status: "predicted", Date: &jul1, RuleCode: "f3", Title: "F3"},
|
|
// Two conditional — must survive uncapped, must NOT count
|
|
// against projTotal / projShown.
|
|
{Kind: "projected", Status: "conditional", IsConditional: true, RuleCode: "c1", Title: "C1",
|
|
DependsOnRuleCode: "p1", DependsOnRuleName: "Parent 1"},
|
|
{Kind: "projected", Status: "conditional", IsConditional: true, RuleCode: "c2", Title: "C2",
|
|
DependsOnRuleCode: "p2", DependsOnRuleName: "Parent 2"},
|
|
}
|
|
|
|
kept, total, shown, overdue := applyLookaheadCap(rows, 2)
|
|
if total != 3 {
|
|
t.Errorf("ProjectedTotal = %d, want 3 (conditionals must not count)", total)
|
|
}
|
|
if shown != 2 {
|
|
t.Errorf("ProjectedShown = %d, want 2", shown)
|
|
}
|
|
if overdue != 0 {
|
|
t.Errorf("PredictedOverdue = %d, want 0", overdue)
|
|
}
|
|
// 2 predicted (capped) + 2 conditional pass-through = 4 rows.
|
|
if len(kept) != 4 {
|
|
t.Errorf("kept rows = %d, want 4", len(kept))
|
|
}
|
|
keptTitles := map[string]bool{}
|
|
for _, r := range kept {
|
|
keptTitles[r.Title] = true
|
|
}
|
|
for _, want := range []string{"F1", "F2", "C1", "C2"} {
|
|
if !keptTitles[want] {
|
|
t.Errorf("expected kept row %q missing", want)
|
|
}
|
|
}
|
|
if keptTitles["F3"] {
|
|
t.Errorf("F3 should have been dropped (cap=2)")
|
|
}
|
|
}
|
|
|
|
func TestRuleAnchorKind(t *testing.T) {
|
|
hearing := "hearing"
|
|
decision := "decision"
|
|
order := "order"
|
|
filing := "filing"
|
|
|
|
cases := []struct {
|
|
name string
|
|
rule *models.DeadlineRule
|
|
want string
|
|
}{
|
|
{"hearing → appointment", &models.DeadlineRule{EventType: &hearing}, "appointment"},
|
|
{"decision → appointment", &models.DeadlineRule{EventType: &decision}, "appointment"},
|
|
{"order → appointment", &models.DeadlineRule{EventType: &order}, "appointment"},
|
|
{"filing → deadline", &models.DeadlineRule{EventType: &filing}, "deadline"},
|
|
{"nil event_type → deadline", &models.DeadlineRule{}, "deadline"},
|
|
{"nil rule → deadline", nil, "deadline"},
|
|
}
|
|
for _, c := range cases {
|
|
t.Run(c.name, func(t *testing.T) {
|
|
if got := ruleAnchorKind(c.rule); got != c.want {
|
|
t.Errorf("ruleAnchorKind = %q, want %q", got, c.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestExtractMetadataString(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
raw string
|
|
key string
|
|
want string
|
|
}{
|
|
{"present", `{"rule_code":"inf.sod","reason":"foo"}`, "rule_code", "inf.sod"},
|
|
{"missing key", `{"foo":"bar"}`, "rule_code", ""},
|
|
{"empty json", `{}`, "rule_code", ""},
|
|
{"empty raw", ``, "rule_code", ""},
|
|
{"non-string value", `{"rule_code":123}`, "rule_code", ""},
|
|
{"malformed", `{`, "rule_code", ""},
|
|
}
|
|
for _, c := range cases {
|
|
t.Run(c.name, func(t *testing.T) {
|
|
got := extractMetadataString(json.RawMessage(c.raw), c.key)
|
|
if got != c.want {
|
|
t.Errorf("extractMetadataString = %q, want %q", got, c.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestLang(t *testing.T) {
|
|
cases := []struct {
|
|
in, want string
|
|
}{
|
|
{"", "de"},
|
|
{"de", "de"},
|
|
{"DE", "de"},
|
|
{"en", "en"},
|
|
{"EN", "en"},
|
|
{" en ", "en"},
|
|
{"fr", "de"},
|
|
}
|
|
for _, c := range cases {
|
|
if got := lang(c.in); got != c.want {
|
|
t.Errorf("lang(%q) = %q, want %q", c.in, got, c.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestRuleNameInLang(t *testing.T) {
|
|
r := models.DeadlineRule{Name: "Klageerwiderung", NameEN: "Statement of Defence"}
|
|
if got := ruleNameInLang(r, "de"); got != "Klageerwiderung" {
|
|
t.Errorf("de = %q", got)
|
|
}
|
|
if got := ruleNameInLang(r, "en"); got != "Statement of Defence" {
|
|
t.Errorf("en = %q", got)
|
|
}
|
|
rNoEN := models.DeadlineRule{Name: "Klageerwiderung"}
|
|
if got := ruleNameInLang(rNoEN, "en"); got != "Klageerwiderung" {
|
|
t.Errorf("missing EN should fall back to DE, got %q", got)
|
|
}
|
|
}
|
|
|
|
func TestCrossProceedingAnchorError(t *testing.T) {
|
|
parentID := uuid.New()
|
|
cpe := &CrossProceedingAnchorError{
|
|
RequestedRuleCode: "upc.inf.cfi.soc",
|
|
RequestedRuleNameDE: "Klageschrift",
|
|
RequestedRuleNameEN: "Statement of Claim",
|
|
ParentProjectID: parentID,
|
|
ParentProjectTitle: "UPC-CFI München — Klage",
|
|
}
|
|
got, ok := IsCrossProceedingAnchor(cpe)
|
|
if !ok {
|
|
t.Fatal("IsCrossProceedingAnchor on direct error should return ok")
|
|
}
|
|
if got != cpe {
|
|
t.Errorf("unwrapped pointer mismatch")
|
|
}
|
|
wrapped := wrap(cpe, "context")
|
|
got2, ok2 := IsCrossProceedingAnchor(wrapped)
|
|
if !ok2 {
|
|
t.Fatal("IsCrossProceedingAnchor on wrapped error should return ok")
|
|
}
|
|
if got2 != cpe {
|
|
t.Errorf("unwrapped wrapped pointer mismatch")
|
|
}
|
|
if _, ok := IsCrossProceedingAnchor(errOther{}); ok {
|
|
t.Error("non-CPE should not unwrap as CPE")
|
|
}
|
|
// And the inverse: a PredecessorMissingError must NOT match the
|
|
// cross-proceeding helper (the two coexist on the 409 response and a
|
|
// mistaken unwrap would render the wrong UI).
|
|
pme := &PredecessorMissingError{RequestedRuleCode: "x"}
|
|
if _, ok := IsCrossProceedingAnchor(pme); ok {
|
|
t.Error("PredecessorMissingError must not unwrap as CrossProceedingAnchorError")
|
|
}
|
|
}
|
|
|
|
func TestPredecessorMissingError(t *testing.T) {
|
|
pme := &PredecessorMissingError{
|
|
MissingRuleCode: "upc.inf.cfi.soc",
|
|
MissingRuleNameDE: "Klageschrift",
|
|
MissingRuleNameEN: "Statement of Claim",
|
|
RequestedRuleCode: "upc.inf.cfi.sod",
|
|
RequestedRuleNameDE: "Klageerwiderung",
|
|
RequestedRuleNameEN: "Statement of Defence",
|
|
}
|
|
got, ok := IsPredecessorMissing(pme)
|
|
if !ok {
|
|
t.Fatal("IsPredecessorMissing on direct error should return ok")
|
|
}
|
|
if got != pme {
|
|
t.Errorf("unwrapped pointer mismatch")
|
|
}
|
|
// Wrapping with errors.Errorf-style fmt should still unwrap.
|
|
wrapped := wrap(pme, "context")
|
|
got2, ok2 := IsPredecessorMissing(wrapped)
|
|
if !ok2 {
|
|
t.Fatal("IsPredecessorMissing on wrapped error should return ok")
|
|
}
|
|
if got2 != pme {
|
|
t.Errorf("unwrapped wrapped pointer mismatch")
|
|
}
|
|
// Random other error must not unwrap.
|
|
if _, ok := IsPredecessorMissing(errOther{}); ok {
|
|
t.Error("non-PME should not unwrap as PME")
|
|
}
|
|
}
|
|
|
|
// wrap is a tiny test helper that mimics fmt.Errorf("%w") wrapping.
|
|
func wrap(err error, msg string) error {
|
|
return wrappedErr{msg: msg, inner: err}
|
|
}
|
|
|
|
type wrappedErr struct {
|
|
msg string
|
|
inner error
|
|
}
|
|
|
|
func (w wrappedErr) Error() string { return w.msg + ": " + w.inner.Error() }
|
|
func (w wrappedErr) Unwrap() error { return w.inner }
|
|
|
|
type errOther struct{}
|
|
|
|
func (errOther) Error() string { return "other" }
|
|
|
|
func TestAnnotateDependsOn(t *testing.T) {
|
|
socID := uuid.New()
|
|
sodID := uuid.New()
|
|
replyID := uuid.New()
|
|
socCode := "upc.inf.cfi.soc"
|
|
sodCode := "upc.inf.cfi.sod"
|
|
replyCode := "upc.inf.cfi.reply"
|
|
|
|
rules := []models.DeadlineRule{
|
|
{ID: socID, SubmissionCode: &socCode, Name: "Klageschrift", NameEN: "Statement of Claim"},
|
|
{ID: sodID, ParentID: &socID, SubmissionCode: &sodCode, Name: "Klageerwiderung", NameEN: "Statement of Defence"},
|
|
{ID: replyID, ParentID: &sodID, SubmissionCode: &replyCode, Name: "Replik", NameEN: "Reply"},
|
|
}
|
|
|
|
socDate := time.Date(2026, 4, 1, 0, 0, 0, 0, time.UTC)
|
|
sodDate := time.Date(2026, 7, 1, 0, 0, 0, 0, time.UTC)
|
|
|
|
rows := []TimelineEvent{
|
|
// SoC actual.
|
|
{Kind: "deadline", Status: "done", Date: &socDate, RuleCode: socCode,
|
|
DeadlineRuleID: ptrUUID(socID)},
|
|
// SoD projected.
|
|
{Kind: "projected", Status: "predicted", Date: &sodDate, RuleCode: sodCode,
|
|
DeadlineRuleID: ptrUUID(sodID)},
|
|
// Reply projected — depends on SoD.
|
|
{Kind: "projected", Status: "predicted", RuleCode: replyCode,
|
|
DeadlineRuleID: ptrUUID(replyID)},
|
|
}
|
|
|
|
svc := &ProjectionService{}
|
|
svc.annotateDependsOn(rows, rules, "de")
|
|
|
|
// SoC has no parent — depends_on stays empty.
|
|
if rows[0].DependsOnRuleCode != "" {
|
|
t.Errorf("SoC should have no depends_on, got %q", rows[0].DependsOnRuleCode)
|
|
}
|
|
// SoD's depends_on is SoC, dated.
|
|
if rows[1].DependsOnRuleCode != socCode {
|
|
t.Errorf("SoD depends_on = %q, want %q", rows[1].DependsOnRuleCode, socCode)
|
|
}
|
|
if rows[1].DependsOnRuleName != "Klageschrift" {
|
|
t.Errorf("SoD depends_on name = %q (de)", rows[1].DependsOnRuleName)
|
|
}
|
|
if rows[1].DependsOnDate == nil || !rows[1].DependsOnDate.Equal(socDate) {
|
|
t.Errorf("SoD depends_on_date = %v, want %v", rows[1].DependsOnDate, socDate)
|
|
}
|
|
// Reply's depends_on is SoD, dated (from SoD's projected date).
|
|
if rows[2].DependsOnRuleCode != sodCode {
|
|
t.Errorf("Reply depends_on = %q", rows[2].DependsOnRuleCode)
|
|
}
|
|
if rows[2].DependsOnDate == nil || !rows[2].DependsOnDate.Equal(sodDate) {
|
|
t.Errorf("Reply depends_on_date = %v, want %v (SoD's projected date)",
|
|
rows[2].DependsOnDate, sodDate)
|
|
}
|
|
|
|
// English mode flips the name.
|
|
svc.annotateDependsOn(rows, rules, "en")
|
|
if rows[1].DependsOnRuleName != "Statement of Claim" {
|
|
t.Errorf("SoD depends_on name (en) = %q", rows[1].DependsOnRuleName)
|
|
}
|
|
}
|
|
|
|
func ptrUUID(u uuid.UUID) *uuid.UUID { return &u }
|