| 1 | package plancontract |
| 2 | |
| 3 | import ( |
| 4 | "strings" |
| 5 | "testing" |
| 6 | ) |
| 7 | |
| 8 | func TestNormalizeAssignsMissingIDsWithoutColliding(t *testing.T) { |
| 9 | p := Plan{Objective: "o", Steps: []Step{ |
| 10 | {Title: "first"}, |
| 11 | {ID: "s1", Title: "second"}, |
| 12 | {Title: "third"}, |
| 13 | }}.Normalize() |
| 14 | |
| 15 | got := []string{p.Steps[0].ID, p.Steps[1].ID, p.Steps[2].ID} |
| 16 | want := []string{"plan_step_01", "s1", "plan_step_02"} |
| 17 | for i := range want { |
| 18 | if got[i] != want[i] { |
| 19 | t.Fatalf("step ids = %v, want %v", got, want) |
| 20 | } |
| 21 | } |
| 22 | } |
| 23 | |
| 24 | func TestNormalizeReassignsDuplicateStepIDs(t *testing.T) { |
| 25 | p := Plan{Objective: "o", Steps: []Step{ |
| 26 | {ID: "a", Title: "first"}, |
| 27 | {ID: "a", Title: "second"}, |
| 28 | }}.Normalize() |
| 29 | |
| 30 | if p.Steps[0].ID != "a" { |
| 31 | t.Fatalf("first step id = %q, want the submitted %q", p.Steps[0].ID, "a") |
| 32 | } |
| 33 | if p.Steps[1].ID == "a" { |
| 34 | t.Fatal("duplicate step id survived normalization") |
| 35 | } |
| 36 | if err := p.Validate(); err != nil { |
| 37 | t.Fatalf("normalized plan must validate: %v", err) |
| 38 | } |
| 39 | } |
| 40 | |
| 41 | func TestNormalizeAssignsCriterionIDsAcrossSteps(t *testing.T) { |
| 42 | p := Plan{Objective: "o", Steps: []Step{ |
| 43 | {ID: "a", Title: "first", Acceptance: []Criterion{{Text: "one"}}}, |
| 44 | {ID: "b", Title: "second", Acceptance: []Criterion{{Text: "two"}, {Text: "three"}}}, |
| 45 | }}.Normalize() |
| 46 | |
| 47 | seen := map[string]bool{} |
| 48 | for _, step := range p.Steps { |
| 49 | for _, c := range step.Acceptance { |
| 50 | if c.ID == "" { |
| 51 | t.Fatalf("criterion %q has no id", c.Text) |
| 52 | } |
| 53 | if seen[c.ID] { |
| 54 | t.Fatalf("criterion id %q reused across steps", c.ID) |
| 55 | } |
| 56 | seen[c.ID] = true |
| 57 | } |
| 58 | } |
| 59 | } |
| 60 | |
| 61 | func TestNormalizeDropsEmptyEntriesAndDeduplicates(t *testing.T) { |
| 62 | p := Plan{ |
| 63 | Objective: " ship it ", |
| 64 | NonGoals: []string{" rewrite the world ", "", "rewrite the world"}, |
| 65 | Assumptions: []Assumption{{Text: " "}, {Text: "cache is warm", Confirm: " check hit rate "}}, |
| 66 | Steps: []Step{ |
| 67 | {Title: " "}, |
| 68 | {Title: " do it ", VerifiedFiles: []string{"a.go", "a.go", " b.go "}, Risks: []string{""}, |
| 69 | Acceptance: []Criterion{{Text: " "}}, |
| 70 | Verification: []Verification{{Command: " ", Expect: " "}}}, |
| 71 | }, |
| 72 | }.Normalize() |
| 73 | |
| 74 | if p.Objective != "ship it" { |
| 75 | t.Fatalf("objective = %q", p.Objective) |
| 76 | } |
| 77 | if len(p.NonGoals) != 1 || p.NonGoals[0] != "rewrite the world" { |
| 78 | t.Fatalf("non-goals = %v", p.NonGoals) |
| 79 | } |
| 80 | if len(p.Assumptions) != 1 || p.Assumptions[0].Confirm != "check hit rate" { |
| 81 | t.Fatalf("assumptions = %+v", p.Assumptions) |
| 82 | } |
| 83 | if len(p.Steps) != 1 || p.Steps[0].Title != "do it" { |
| 84 | t.Fatalf("steps = %+v", p.Steps) |
| 85 | } |
| 86 | step := p.Steps[0] |
| 87 | if len(step.VerifiedFiles) != 2 || step.VerifiedFiles[1] != "b.go" { |
| 88 | t.Fatalf("verified files = %v", step.VerifiedFiles) |
| 89 | } |
| 90 | if step.Risks != nil || step.Acceptance != nil || step.Verification != nil { |
| 91 | t.Fatalf("empty entries survived: %+v", step) |
| 92 | } |
| 93 | } |
| 94 | |
| 95 | func TestNormalizeRepairsParentReferences(t *testing.T) { |
| 96 | p := Plan{Objective: "o", Steps: []Step{ |
| 97 | {ID: "phase", Title: "phase"}, |
| 98 | {ID: "child", ParentID: "phase", Title: "child"}, |
| 99 | {ID: "grandchild", ParentID: "child", Title: "grandchild"}, |
| 100 | {ID: "orphan", ParentID: "nobody", Title: "orphan"}, |
| 101 | {ID: "self", ParentID: "self", Title: "self"}, |
| 102 | }}.Normalize() |
| 103 | |
| 104 | want := map[string]string{ |
| 105 | "phase": "", "child": "phase", "grandchild": "phase", "orphan": "", "self": "", |
| 106 | } |
| 107 | for _, step := range p.Steps { |
| 108 | if got := step.ParentID; got != want[step.ID] { |
| 109 | t.Errorf("step %q parent = %q, want %q", step.ID, got, want[step.ID]) |
| 110 | } |
| 111 | } |
| 112 | } |
| 113 | |
| 114 | func TestNormalizeDropsUnresolvableDependencies(t *testing.T) { |
| 115 | p := Plan{Objective: "o", Steps: []Step{ |
| 116 | {ID: "a", Title: "a", DependsOn: []string{"a", "ghost", "b", "b"}}, |
| 117 | {ID: "b", Title: "b"}, |
| 118 | }}.Normalize() |
| 119 | |
| 120 | got := p.Steps[0].DependsOn |
| 121 | if len(got) != 1 || got[0] != "b" { |
| 122 | t.Fatalf("depends-on = %v, want [b]", got) |
| 123 | } |
| 124 | } |
| 125 | |
| 126 | func TestNormalizeIsIdempotent(t *testing.T) { |
| 127 | once := Plan{Objective: "o", Steps: []Step{ |
| 128 | {Title: "phase"}, |
| 129 | {ParentID: "s1", Title: "child", DependsOn: []string{"s1"}}, |
| 130 | }}.Normalize() |
| 131 | twice := once.Normalize() |
| 132 | |
| 133 | if len(once.Steps) != len(twice.Steps) { |
| 134 | t.Fatalf("step count changed: %d then %d", len(once.Steps), len(twice.Steps)) |
| 135 | } |
| 136 | for i := range once.Steps { |
| 137 | if once.Steps[i].ID != twice.Steps[i].ID || once.Steps[i].ParentID != twice.Steps[i].ParentID { |
| 138 | t.Fatalf("step %d changed on re-normalize: %+v then %+v", i, once.Steps[i], twice.Steps[i]) |
| 139 | } |
| 140 | } |
| 141 | } |
| 142 | |
| 143 | func TestValidateRejectsMissingObjectiveAndSteps(t *testing.T) { |
| 144 | err := Plan{}.Validate() |
| 145 | if err == nil { |
| 146 | t.Fatal("empty plan must not validate") |
| 147 | } |
| 148 | for _, want := range []string{"no objective", "no steps"} { |
| 149 | if !strings.Contains(err.Error(), want) { |
| 150 | t.Errorf("error %q missing %q", err, want) |
| 151 | } |
| 152 | } |
| 153 | } |
| 154 | |
| 155 | func TestValidateRejectsOversizedPlan(t *testing.T) { |
| 156 | steps := make([]Step, MaxSteps+1) |
| 157 | for i := range steps { |
| 158 | steps[i] = Step{Title: "step"} |
| 159 | } |
| 160 | err := Plan{Objective: "o", Steps: steps}.Normalize().Validate() |
| 161 | if err == nil || !strings.Contains(err.Error(), "the limit is") { |
| 162 | t.Fatalf("oversized plan error = %v", err) |
| 163 | } |
| 164 | } |
| 165 | |
| 166 | func TestValidateRejectsRawDuplicateIDs(t *testing.T) { |
| 167 | err := Plan{Objective: "o", Steps: []Step{ |
| 168 | {ID: "a", Title: "one"}, |
| 169 | {ID: "a", Title: "two"}, |
| 170 | }}.Validate() |
| 171 | if err == nil || !strings.Contains(err.Error(), "used more than once") { |
| 172 | t.Fatalf("duplicate id error = %v", err) |
| 173 | } |
| 174 | } |
| 175 | |
| 176 | func TestValidateReportsDependencyCycle(t *testing.T) { |
| 177 | err := Plan{Objective: "o", Steps: []Step{ |
| 178 | {ID: "a", Title: "a", DependsOn: []string{"b"}}, |
| 179 | {ID: "b", Title: "b", DependsOn: []string{"a"}}, |
| 180 | }}.Normalize().Validate() |
| 181 | if err == nil || !strings.Contains(err.Error(), "dependency cycle") { |
| 182 | t.Fatalf("cycle error = %v", err) |
| 183 | } |
| 184 | } |
| 185 | |
| 186 | func TestValidateAcceptsWellFormedPlan(t *testing.T) { |
| 187 | p := Plan{ |
| 188 | Objective: "make the cache key model-aware", |
| 189 | Steps: []Step{ |
| 190 | {ID: "p1", Title: "thread the model ref through"}, |
| 191 | {ID: "s1", ParentID: "p1", Title: "extend cacheKey"}, |
| 192 | {ID: "s2", ParentID: "p1", Title: "update callers", DependsOn: []string{"s1"}}, |
| 193 | }, |
| 194 | }.Normalize() |
| 195 | if err := p.Validate(); err != nil { |
| 196 | t.Fatalf("well-formed plan must validate: %v", err) |
| 197 | } |
| 198 | } |
| 199 |