youpc.org/deadlines was rolling a deadline "from 2027-01-02 (UPC Winter Vacation)" — i.e. across the UPC judicial vacation as if it were a public holiday. Paliad-side t-paliad-121 already decided vacations are informational only (the Court keeps running through them, RoP / UPC AC decision-on-judicial-vacation 2023-05-26), and `HolidayService.Is NonWorkingDay` in `internal/services/holidays.go` is correct. The embedded snapshot consumed by youpc.org via Go-module replace had drifted: `pkg/litigationplanner/embedded/upc/holidays.go:74` blocked on both `isClosure()` AND `isVacation()`. This commit aligns the embedded calendar with the paliad-side semantics and ships a fresh holiday set so the existing 2026/2027 fix actually takes effect downstream. Code changes (`holidays.go`): - `IsNonWorkingDay`: drop the `|| h.isVacation()` branch — only weekends and `isClosure()` rows trigger the roll. Godoc rewritten to mirror the paliad-side rationale (Court keeps operating, RoP cites, vacation rows kept for informational labels). - `isClosure()`: accept both `"public_holiday"` and `"closure"`. Live paliad DB rows use the `public_holiday` value; the placeholder snapshot shipped with the original Slice C used `closure` as a hand-crafted synonym. Reconciles with `internal/services/holidays.go:132` which already does the same union. Required to make the regenerated JSON (full of `public_holiday`) keep blocking DE national holidays after the regeneration in this commit. - Type-level godoc updated: `SnapshotHolidayCalendar` now documents vacation-is-informational, and the `AdjustForNonWorkingDaysWithReason` precedence note explains that `vacation` kind only fires when a vacation row overlaps a weekend or closure that's already doing the rolling. Data refresh (`holidays.json`): - Regenerated from paliad prod (postgres @ 100.99.98.201:11833, paliad schema). 55 rows for 2026 + 2027: 22 DE public_holiday + 33 UPC vacation (25 Summer Vacation Jul 27–Aug 28, 8 Winter Vacation Dec 24/28–31 + Jan 4–6). The previous placeholder shipped only 5 rows (3 Sommerpause + Neujahr + Tag der Arbeit, no Winter Vacation at all) — which is why a date landing in late Dec / early Jan landed inside an unmodeled gap on the consumer side. - `meta.json` bumped: version → `2026-05-27-1-holidays-only`, `holiday_count` 5 → 55, `source_db_label` flags that only holidays.json was refreshed (see friction note below). Regression test (`snapshot_test.go::TestSnapshotHolidayCalendar`): - 2026-08-04 (Tue, UPC Summer Vacation) — `IsNonWorkingDay` must be false; `AdjustForNonWorkingDays` must NOT mutate the date. - 2027-01-02 (Sat, m's flagged scenario) — must roll forward through Sat/Sun, then STOP on Mon 2027-01-04 (UPC Winter Vacation, no longer blocking). Pre-fix this rolled all the way to Thu 2027-01-07. Cross-repo: youpc.org imports `pkg/litigationplanner` via Go-module replace; the regenerated snapshot ships on its next rebuild. No separate youpc.org commit needed — paliad is the source of truth. Friction note: `cmd/gen-upc-snapshot/main.go` itself is incompatible with the current paliad schema. Migration 140 (`140_drop_deadline_rules`) dropped `paliad.deadline_rules`, but the generator still SELECTs from it (main.go ~L162). Running the tool against prod fails on the rules step. I bypassed the broken path and generated `holidays.json` directly from the DB via psql + jq (same JSON shape that `EmbeddedHoliday` expects, nulls filtered for `omitempty`). The other snapshot files (rules.json, proceeding_types.json, trigger_events.json, courts.json) remain at their pre-existing placeholder state — re-flagged in meta.json's `source_db_label`. Refitting the generator for the post- mig-140 schema is a separate task. go vet + go test ./... clean (256+ Go tests pass, including the new regression cases).
258 lines
8.4 KiB
Go
258 lines
8.4 KiB
Go
package upc
|
|
|
|
import (
|
|
"context"
|
|
"testing"
|
|
"time"
|
|
|
|
lp "mgit.msbls.de/m/paliad/pkg/litigationplanner"
|
|
)
|
|
|
|
// TestSnapshotMeta loads + parses meta.json and asserts the version
|
|
// + non-zero counts. Until the operator regenerates the snapshot the
|
|
// placeholder shipped with Slice C must still parse cleanly.
|
|
func TestSnapshotMeta(t *testing.T) {
|
|
meta, err := LoadMeta()
|
|
if err != nil {
|
|
t.Fatalf("LoadMeta: %v", err)
|
|
}
|
|
if meta.Version == "" {
|
|
t.Error("meta.Version is empty")
|
|
}
|
|
if meta.ProceedingCount <= 0 {
|
|
t.Errorf("meta.ProceedingCount = %d, want > 0", meta.ProceedingCount)
|
|
}
|
|
if meta.RuleCount <= 0 {
|
|
t.Errorf("meta.RuleCount = %d, want > 0", meta.RuleCount)
|
|
}
|
|
}
|
|
|
|
// TestSnapshotCatalog smoke-tests the embedded catalog's lookups
|
|
// against the shipped placeholder. After operator regeneration the
|
|
// asserts on per-row content still hold because they pin the wire
|
|
// shape (proceedingType.Code, rule resolution by code, lookup-events
|
|
// jurisdiction filter).
|
|
func TestSnapshotCatalog(t *testing.T) {
|
|
cat, err := NewCatalog()
|
|
if err != nil {
|
|
t.Fatalf("NewCatalog: %v", err)
|
|
}
|
|
ctx := context.Background()
|
|
|
|
t.Run("LoadProceeding upc.inf.cfi", func(t *testing.T) {
|
|
pt, rules, err := cat.LoadProceeding(ctx, "upc.inf.cfi", lp.ProjectHint{})
|
|
if err != nil {
|
|
t.Fatalf("LoadProceeding: %v", err)
|
|
}
|
|
if pt.Code != "upc.inf.cfi" {
|
|
t.Errorf("pt.Code = %q, want upc.inf.cfi", pt.Code)
|
|
}
|
|
if pt.Jurisdiction == nil || *pt.Jurisdiction != "UPC" {
|
|
t.Errorf("pt.Jurisdiction = %v, want UPC", pt.Jurisdiction)
|
|
}
|
|
if len(rules) == 0 {
|
|
t.Error("LoadProceeding returned zero rules — snapshot empty?")
|
|
}
|
|
})
|
|
|
|
t.Run("LoadProceeding unknown code returns ErrUnknownProceedingType", func(t *testing.T) {
|
|
_, _, err := cat.LoadProceeding(ctx, "no.such.code", lp.ProjectHint{})
|
|
if err != lp.ErrUnknownProceedingType {
|
|
t.Errorf("got %v, want ErrUnknownProceedingType", err)
|
|
}
|
|
})
|
|
|
|
t.Run("LookupEvents UPC all-following returns the whole UPC corpus", func(t *testing.T) {
|
|
matches, err := cat.LookupEvents(ctx, lp.EventLookupAxes{
|
|
Jurisdiction: "UPC",
|
|
}, lp.EventLookupDepthAllFollowing)
|
|
if err != nil {
|
|
t.Fatalf("LookupEvents: %v", err)
|
|
}
|
|
if len(matches) == 0 {
|
|
t.Fatal("expected non-empty UPC corpus")
|
|
}
|
|
for _, m := range matches {
|
|
if m.ProceedingType.Jurisdiction == nil || *m.ProceedingType.Jurisdiction != "UPC" {
|
|
t.Errorf("non-UPC row leaked: %v", m.ProceedingType.Code)
|
|
}
|
|
if m.DepthFromAnchor < 1 {
|
|
t.Errorf("depth = %d, want >= 1", m.DepthFromAnchor)
|
|
}
|
|
}
|
|
})
|
|
|
|
t.Run("LookupEvents party=defendant scopes anchors", func(t *testing.T) {
|
|
matches, err := cat.LookupEvents(ctx, lp.EventLookupAxes{
|
|
Jurisdiction: "UPC",
|
|
Party: "defendant",
|
|
}, lp.EventLookupDepthNext)
|
|
if err != nil {
|
|
t.Fatalf("LookupEvents: %v", err)
|
|
}
|
|
// Anchor rows (depth=1) must all be defendant.
|
|
anyDefendant := false
|
|
for _, m := range matches {
|
|
if m.DepthFromAnchor != 1 {
|
|
continue
|
|
}
|
|
if m.Rule.PrimaryParty == nil || *m.Rule.PrimaryParty != "defendant" {
|
|
t.Errorf("anchor row %s is not defendant: %v", m.Rule.Name, m.Rule.PrimaryParty)
|
|
}
|
|
anyDefendant = true
|
|
}
|
|
if !anyDefendant {
|
|
t.Log("no defendant rules in the placeholder corpus — operator should regenerate the snapshot")
|
|
}
|
|
})
|
|
}
|
|
|
|
// TestSnapshotEngineCompute runs the litigationplanner engine against
|
|
// the embedded snapshot end-to-end. Ensures the wiring between the
|
|
// snapshot Catalog / HolidayCalendar / CourtRegistry + the engine
|
|
// produces a non-empty timeline.
|
|
func TestSnapshotEngineCompute(t *testing.T) {
|
|
cat, err := NewCatalog()
|
|
if err != nil {
|
|
t.Fatalf("NewCatalog: %v", err)
|
|
}
|
|
hc, err := NewHolidayCalendar()
|
|
if err != nil {
|
|
t.Fatalf("NewHolidayCalendar: %v", err)
|
|
}
|
|
cr, err := NewCourtRegistry()
|
|
if err != nil {
|
|
t.Fatalf("NewCourtRegistry: %v", err)
|
|
}
|
|
|
|
ctx := context.Background()
|
|
timeline, err := lp.Calculate(ctx, "upc.inf.cfi", "2026-01-15", lp.CalcOptions{}, cat, hc, cr)
|
|
if err != nil {
|
|
t.Fatalf("Calculate: %v", err)
|
|
}
|
|
if timeline == nil {
|
|
t.Fatal("Calculate returned nil timeline")
|
|
}
|
|
if timeline.ProceedingType != "upc.inf.cfi" {
|
|
t.Errorf("timeline.ProceedingType = %q, want upc.inf.cfi", timeline.ProceedingType)
|
|
}
|
|
if len(timeline.Deadlines) == 0 {
|
|
t.Error("timeline has zero deadlines — snapshot empty?")
|
|
}
|
|
}
|
|
|
|
// TestSnapshotHolidayCalendar smoke-tests the embedded calendar.
|
|
// Pins core semantics: weekends are non-working; holidays at
|
|
// matching country/regime are non-working; mismatches don't fire.
|
|
func TestSnapshotHolidayCalendar(t *testing.T) {
|
|
hc, err := NewHolidayCalendar()
|
|
if err != nil {
|
|
t.Fatalf("NewHolidayCalendar: %v", err)
|
|
}
|
|
|
|
// 2026-01-03 is a Saturday — weekend, non-working regardless of
|
|
// country/regime.
|
|
sat := time.Date(2026, 1, 3, 0, 0, 0, 0, time.UTC)
|
|
if !hc.IsNonWorkingDay(sat, "DE", "UPC") {
|
|
t.Error("Saturday should be non-working")
|
|
}
|
|
|
|
// 2026-01-01 is Neujahr (DE closure) — non-working when country=DE.
|
|
newYear := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
|
|
if !hc.IsNonWorkingDay(newYear, "DE", "UPC") {
|
|
t.Error("Neujahr should be non-working for DE")
|
|
}
|
|
|
|
// 2026-01-05 is a Monday — working (not in holidays, not weekend).
|
|
mon := time.Date(2026, 1, 5, 0, 0, 0, 0, time.UTC)
|
|
if hc.IsNonWorkingDay(mon, "DE", "UPC") {
|
|
t.Error("Monday 2026-01-05 should be working")
|
|
}
|
|
|
|
// AdjustForNonWorkingDays from a Saturday should land on Monday.
|
|
adj, _, was := hc.AdjustForNonWorkingDays(sat, "DE", "UPC")
|
|
if !was {
|
|
t.Error("expected adjustment for Saturday")
|
|
}
|
|
if adj.Weekday() != time.Monday {
|
|
t.Errorf("adjusted weekday = %v, want Monday", adj.Weekday())
|
|
}
|
|
|
|
// t-paliad-332: UPC vacations are informational only — a deadline
|
|
// landing on a vacation day must NOT be rolled forward. Mirrors
|
|
// the paliad-side policy fixed in t-paliad-121 (the Court keeps
|
|
// running through judicial vacations, so vacation rows live in
|
|
// the snapshot for label payloads but don't extend deadlines).
|
|
//
|
|
// 2026-08-04 is a Tuesday inside UPC Summer Vacation — must stay
|
|
// put on the (DE, UPC) calendar.
|
|
sommerpauseDay := time.Date(2026, 8, 4, 0, 0, 0, 0, time.UTC)
|
|
if sommerpauseDay.Weekday() == time.Saturday || sommerpauseDay.Weekday() == time.Sunday {
|
|
t.Fatalf("test premise broken: 2026-08-04 should not be a weekend (got %v)",
|
|
sommerpauseDay.Weekday())
|
|
}
|
|
if hc.IsNonWorkingDay(sommerpauseDay, "DE", "UPC") {
|
|
t.Error("UPC Summer Vacation weekday must not be non-working (t-paliad-332)")
|
|
}
|
|
adjV, _, wasV := hc.AdjustForNonWorkingDays(sommerpauseDay, "DE", "UPC")
|
|
if wasV {
|
|
t.Error("expected NO adjustment for vacation-only day (t-paliad-332)")
|
|
}
|
|
if !adjV.Equal(sommerpauseDay) {
|
|
t.Errorf("adjusted = %v, want %v (vacation must not roll, t-paliad-332)",
|
|
adjV.Format("2006-01-02"), sommerpauseDay.Format("2006-01-02"))
|
|
}
|
|
|
|
// Sanity-pin: a UPC Winter Vacation date that is ALSO adjacent
|
|
// to weekend + Neujahr (the scenario m flagged on youpc.org —
|
|
// "rolled from 2027-01-02 (UPC Winter Vacation)"). 2027-01-02 is
|
|
// a Saturday; the roll must cross Sat/Sun → Mon 2027-01-04, which
|
|
// is in UPC Winter Vacation but no longer blocks → stops there.
|
|
// Pre-fix this rolled all the way to Thu 2027-01-07.
|
|
jan2 := time.Date(2027, 1, 2, 0, 0, 0, 0, time.UTC)
|
|
adjW, _, wasW := hc.AdjustForNonWorkingDays(jan2, "DE", "UPC")
|
|
if !wasW {
|
|
t.Error("Sat 2027-01-02 must roll forward (weekend)")
|
|
}
|
|
want := time.Date(2027, 1, 4, 0, 0, 0, 0, time.UTC)
|
|
if !adjW.Equal(want) {
|
|
t.Errorf("Sat 2027-01-02 adjusted to %v, want %v (vacation no longer rolls, t-paliad-332)",
|
|
adjW.Format("2006-01-02"), want.Format("2006-01-02"))
|
|
}
|
|
}
|
|
|
|
// TestSnapshotCourtRegistry pins (country, regime) resolution.
|
|
func TestSnapshotCourtRegistry(t *testing.T) {
|
|
cr, err := NewCourtRegistry()
|
|
if err != nil {
|
|
t.Fatalf("NewCourtRegistry: %v", err)
|
|
}
|
|
|
|
t.Run("empty courtID falls back to defaults", func(t *testing.T) {
|
|
c, r, err := cr.CountryRegime("", "DE", "UPC")
|
|
if err != nil {
|
|
t.Fatalf("CountryRegime: %v", err)
|
|
}
|
|
if c != "DE" || r != "UPC" {
|
|
t.Errorf("got (%q, %q), want (DE, UPC)", c, r)
|
|
}
|
|
})
|
|
|
|
t.Run("known UPC court resolves", func(t *testing.T) {
|
|
c, r, err := cr.CountryRegime("upc-ld-munich", "DE", "")
|
|
if err != nil {
|
|
t.Fatalf("CountryRegime: %v", err)
|
|
}
|
|
if c != "DE" || r != "UPC" {
|
|
t.Errorf("got (%q, %q), want (DE, UPC)", c, r)
|
|
}
|
|
})
|
|
|
|
t.Run("unknown court returns error", func(t *testing.T) {
|
|
_, _, err := cr.CountryRegime("not-a-court", "DE", "UPC")
|
|
if err == nil {
|
|
t.Error("expected error for unknown court")
|
|
}
|
|
})
|
|
}
|