Merge: t-paliad-304 — R.109 anchor + columns-view duplicate fix (topo walk + 'both'→ours collapse) (m/paliad#135)
This commit is contained in:
@@ -327,6 +327,29 @@ describe("bucketDeadlinesIntoColumns — side+appellant column routing (m/paliad
|
||||
expect(rows[0].ours).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("side=defendant collapses 'both' rules into ours (no mirror) — m/paliad#135", () => {
|
||||
// When the user has committed to a perspective via `?side=`, the
|
||||
// mirror is visual noise: the same card renders twice on one row,
|
||||
// once in 'Unsere Seite' and once in 'Gegnerseite'. The card's
|
||||
// '↔ beide Seiten' indicator already conveys the both-parties
|
||||
// semantic, so collapsing into ours is sufficient.
|
||||
const rows = bucketDeadlinesIntoColumns(
|
||||
[both("Antrag auf Simultanübersetzung", "2026-04-27")],
|
||||
{ side: "defendant" },
|
||||
);
|
||||
expect(rows[0].ours.map((d) => d.name)).toEqual(["Antrag auf Simultanübersetzung"]);
|
||||
expect(rows[0].opponent).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("side=claimant collapses 'both' rules into ours (no mirror) — m/paliad#135", () => {
|
||||
const rows = bucketDeadlinesIntoColumns(
|
||||
[both("Antrag auf Simultanübersetzung", "2026-04-27")],
|
||||
{ side: "claimant" },
|
||||
);
|
||||
expect(rows[0].ours.map((d) => d.name)).toEqual(["Antrag auf Simultanübersetzung"]);
|
||||
expect(rows[0].opponent).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("rows align across columns by dueDate so same-day events stay on one grid row", () => {
|
||||
const sameDate = "2026-07-23";
|
||||
const rows = bucketDeadlinesIntoColumns([
|
||||
|
||||
@@ -764,7 +764,18 @@ export function bucketDeadlinesIntoColumns(
|
||||
// Role-swap collapse: appellant initiated → both → one row
|
||||
// in appellant's column. Mirror suppressed.
|
||||
row[appellantColumn].push(dl);
|
||||
} else if (userSide !== null) {
|
||||
// Side picked but no appellant axis (first-instance Inf, Rev,
|
||||
// …): the user has committed to a perspective, so the mirror
|
||||
// is visual noise — the same card appears twice on the same
|
||||
// row, once in "Unsere Seite" and once in "Gegnerseite".
|
||||
// Collapse into ours; the "↔ beide Seiten" indicator on the
|
||||
// card already conveys that the rule applies to both parties.
|
||||
// (m/paliad#135 / t-paliad-304)
|
||||
row.ours.push(dl);
|
||||
} else {
|
||||
// No perspective picked → keep the legacy mirror so neither
|
||||
// axis is privileged. Pinned by the "default (no opts)" test.
|
||||
row.ours.push(dl);
|
||||
row.opponent.push(dl);
|
||||
}
|
||||
@@ -799,8 +810,14 @@ export function renderColumnsBody(data: DeadlineResponse, opts: ColumnsBodyOpts
|
||||
|
||||
// Collapsed "both" rows lose their mirror tag — there's no longer
|
||||
// a sibling row to mirror to, so the "↔ beide Seiten" hint would
|
||||
// be misleading. Keep it for the legacy mirror path.
|
||||
const showMirrorTag = !appellantPinned;
|
||||
// be misleading. Both collapse paths suppress it:
|
||||
// - appellantPinned: role-swap collapse into appellant's column
|
||||
// - userSide !== null without appellantPinned: perspective-locked
|
||||
// collapse into ours (m/paliad#135 / t-paliad-304).
|
||||
// Legacy mirror path (no side, no appellant) keeps the tag — both
|
||||
// sibling rows still render so the tag has a visual referent.
|
||||
const sideCollapse = userSide !== null;
|
||||
const showMirrorTag = !appellantPinned && !sideCollapse;
|
||||
|
||||
const renderCell = (items: CalculatedDeadline[]): string => {
|
||||
if (items.length === 0) {
|
||||
|
||||
322
pkg/litigationplanner/before_court_set_anchor_test.go
Normal file
322
pkg/litigationplanner/before_court_set_anchor_test.go
Normal file
@@ -0,0 +1,322 @@
|
||||
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
|
||||
}
|
||||
|
||||
// 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}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// 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.
|
||||
opts := CalcOptions{
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
@@ -183,10 +184,27 @@ func Calculate(
|
||||
return nil, fmt.Errorf("load trigger events for conditional labels: %w", err)
|
||||
}
|
||||
|
||||
// Walk the rule list in sequence_order (already sorted by the
|
||||
// catalog query) and compute each entry, keeping a code→date map so
|
||||
// RelativeTo / parent_id references resolve to the adjusted
|
||||
// predecessor date.
|
||||
// Walk the rule list in TOPOLOGICAL order (parents before children),
|
||||
// not the raw sequence_order order from the catalog. The catalog
|
||||
// returns rules sorted by sequence_order, which is the chronological/
|
||||
// display order. That order is parent-first for the common
|
||||
// timing='after' case but parent-LAST for timing='before' children
|
||||
// (e.g. upc.inf.cfi.translation_request at seq=45 vs its parent
|
||||
// upc.inf.cfi.oral at seq=50 — m/paliad#135). Without topological
|
||||
// ordering the parent-state checks below (courtSet[parent] /
|
||||
// computed[parent_code]) read stale empty maps when a child appears
|
||||
// before its parent, and the engine falls back to the trigger date
|
||||
// → fabricates dates before the SoC.
|
||||
//
|
||||
// Original sequence_order is restored at the end of the walk so the
|
||||
// wire shape and the timeline view's render order stay identical to
|
||||
// the legacy behaviour modulo the bug fix.
|
||||
sequenceIndex := make(map[uuid.UUID]int, len(rules))
|
||||
for i, r := range rules {
|
||||
sequenceIndex[r.ID] = i
|
||||
}
|
||||
walkRules := topoSortByParentDepth(rules)
|
||||
|
||||
computed := make(map[string]time.Time, len(rules))
|
||||
courtSet := make(map[uuid.UUID]bool, len(rules))
|
||||
deadlines := make([]TimelineEntry, 0, len(rules))
|
||||
@@ -197,7 +215,7 @@ func Calculate(
|
||||
hiddenCount := 0
|
||||
appellantContext := make(map[uuid.UUID]string, len(rules))
|
||||
|
||||
for _, r := range rules {
|
||||
for _, r := range walkRules {
|
||||
// Phase-3 unified gate: evaluate condition_expr (jsonb).
|
||||
// Suppression semantic preserved: when the gate fires false
|
||||
// AND no alt_* values exist, the rule is dropped from the
|
||||
@@ -554,6 +572,20 @@ func Calculate(
|
||||
deadlines = append(deadlines, d)
|
||||
}
|
||||
|
||||
// Restore sequence_order on the output slice. The compute walk
|
||||
// re-ordered rules topologically (parent-first) so the parent-state
|
||||
// checks resolved correctly; the wire shape and the linear timeline
|
||||
// view both rely on sequence_order being the surface render order.
|
||||
// (m/paliad#135)
|
||||
sort.SliceStable(deadlines, func(i, j int) bool {
|
||||
a, errA := uuid.Parse(deadlines[i].RuleID)
|
||||
b, errB := uuid.Parse(deadlines[j].RuleID)
|
||||
if errA != nil || errB != nil {
|
||||
return false
|
||||
}
|
||||
return sequenceIndex[a] < sequenceIndex[b]
|
||||
})
|
||||
|
||||
// t-paliad-296: within consecutive runs of rules sharing the same
|
||||
// trigger group (parent_id + trigger_event_id), reorder by duration
|
||||
// ascending so optional events following the same anchor render in
|
||||
@@ -950,3 +982,60 @@ func AllFlagsSet(required []string, set map[string]struct{}) bool {
|
||||
func WireFlagsFromPriority(priority string) (isMandatory, isOptional bool) {
|
||||
return wireFlagsFromPriority(priority)
|
||||
}
|
||||
|
||||
// topoSortByParentDepth returns a copy of `rules` ordered so every rule
|
||||
// appears after its parent_id ancestor. Ties (rules at the same depth)
|
||||
// preserve their input order — which the catalog returns in
|
||||
// sequence_order. Used by Calculate to ensure the parent-state checks
|
||||
// (courtSet[parent], computed[parent_code]) see populated entries even
|
||||
// when sequence_order lists a "before"-timed child BEFORE its parent
|
||||
// (e.g. upc.inf.cfi.translation_request at seq=45 with parent
|
||||
// upc.inf.cfi.oral at seq=50 — m/paliad#135).
|
||||
//
|
||||
// Rules whose parent_id is missing from the rule slice (cross-tree
|
||||
// references that the per-proceeding filter dropped) are treated as
|
||||
// depth 0 — they walk in their original sequence position.
|
||||
//
|
||||
// The algorithm is depth-via-memoised-recursion. Cycle protection: a
|
||||
// rule chain that revisits a node is broken at depth 0; production
|
||||
// data shouldn't contain cycles, but a corrupted catalog mustn't hang
|
||||
// the calculator.
|
||||
func topoSortByParentDepth(rules []Rule) []Rule {
|
||||
byID := make(map[uuid.UUID]Rule, len(rules))
|
||||
inSlice := make(map[uuid.UUID]bool, len(rules))
|
||||
for _, r := range rules {
|
||||
byID[r.ID] = r
|
||||
inSlice[r.ID] = true
|
||||
}
|
||||
|
||||
depth := make(map[uuid.UUID]int, len(rules))
|
||||
var resolve func(id uuid.UUID, seen map[uuid.UUID]bool) int
|
||||
resolve = func(id uuid.UUID, seen map[uuid.UUID]bool) int {
|
||||
if d, ok := depth[id]; ok {
|
||||
return d
|
||||
}
|
||||
if seen[id] {
|
||||
depth[id] = 0
|
||||
return 0
|
||||
}
|
||||
seen[id] = true
|
||||
r, ok := byID[id]
|
||||
if !ok || r.ParentID == nil || !inSlice[*r.ParentID] {
|
||||
depth[id] = 0
|
||||
return 0
|
||||
}
|
||||
d := resolve(*r.ParentID, seen) + 1
|
||||
depth[id] = d
|
||||
return d
|
||||
}
|
||||
for _, r := range rules {
|
||||
resolve(r.ID, map[uuid.UUID]bool{})
|
||||
}
|
||||
|
||||
out := make([]Rule, len(rules))
|
||||
copy(out, rules)
|
||||
sort.SliceStable(out, func(i, j int) bool {
|
||||
return depth[out[i].ID] < depth[out[j].ID]
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user