| 1 | package plancontract |
| 2 | |
| 3 | import ( |
| 4 | "slices" |
| 5 | "strings" |
| 6 | "testing" |
| 7 | ) |
| 8 | |
| 9 | func diffIDs(steps []Step) []string { |
| 10 | out := make([]string, 0, len(steps)) |
| 11 | for _, s := range steps { |
| 12 | out = append(out, s.ID) |
| 13 | } |
| 14 | return out |
| 15 | } |
| 16 | |
| 17 | func basePlan() Plan { |
| 18 | return Plan{ |
| 19 | Objective: "make the cache key model-aware", |
| 20 | Revision: 1, |
| 21 | Steps: []Step{ |
| 22 | {ID: "s1", Title: "change the DB"}, |
| 23 | {ID: "s2", Title: "change the API", CandidateFiles: []string{"api.go"}}, |
| 24 | {ID: "s3", Title: "write tests"}, |
| 25 | }, |
| 26 | }.Normalize() |
| 27 | } |
| 28 | |
| 29 | // The whole reason identity is host-assigned and never regenerated: a diff that |
| 30 | // paired by position would call every step below an insertion "changed". |
| 31 | func TestCompareParesByIdentityNotPosition(t *testing.T) { |
| 32 | after := basePlan() |
| 33 | after.Revision = 2 |
| 34 | after.Steps = []Step{ |
| 35 | after.Steps[0], |
| 36 | {ID: "s4", Title: "add the migration"}, |
| 37 | after.Steps[1], |
| 38 | after.Steps[2], |
| 39 | } |
| 40 | |
| 41 | d := Compare(basePlan(), after.Normalize()) |
| 42 | if !slices.Equal(diffIDs(d.Added), []string{"s4"}) { |
| 43 | t.Errorf("added = %v, want only the inserted step", diffIDs(d.Added)) |
| 44 | } |
| 45 | if len(d.Changed) != 0 { |
| 46 | t.Errorf("changed = %+v; an insertion must not mark its neighbours changed", d.Changed) |
| 47 | } |
| 48 | if !slices.Equal(diffIDs(d.Preserved), []string{"s1", "s2", "s3"}) { |
| 49 | t.Errorf("preserved = %v, want every untouched step", diffIDs(d.Preserved)) |
| 50 | } |
| 51 | } |
| 52 | |
| 53 | // "The title was reworded" and "the acceptance criteria were rewritten" carry |
| 54 | // different risk, so the diff names which part moved. |
| 55 | func TestCompareNamesWhichFieldsMoved(t *testing.T) { |
| 56 | after := basePlan() |
| 57 | after.Revision = 2 |
| 58 | after.Steps[1].Title = "change the API and its schema" |
| 59 | after.Steps[1].CandidateFiles = []string{"api.go", "schema.go"} |
| 60 | |
| 61 | d := Compare(basePlan(), after.Normalize()) |
| 62 | if len(d.Changed) != 1 { |
| 63 | t.Fatalf("changed = %+v, want one step", d.Changed) |
| 64 | } |
| 65 | if got := d.Changed[0].Fields; !slices.Equal(got, []string{"title", "candidate_files"}) { |
| 66 | t.Fatalf("fields = %v, want the two that moved", got) |
| 67 | } |
| 68 | } |
| 69 | |
| 70 | func TestCompareReportsRemoval(t *testing.T) { |
| 71 | after := basePlan() |
| 72 | after.Revision = 2 |
| 73 | after.Steps = after.Steps[:2] |
| 74 | |
| 75 | d := Compare(basePlan(), after.Normalize()) |
| 76 | if !slices.Equal(diffIDs(d.Removed), []string{"s3"}) { |
| 77 | t.Fatalf("removed = %v", diffIDs(d.Removed)) |
| 78 | } |
| 79 | } |
| 80 | |
| 81 | // Re-asking for a narrowing trains the user to approve without reading, which |
| 82 | // costs more than the gate saves. |
| 83 | func TestNeedsApprovalOnlyOnExpansion(t *testing.T) { |
| 84 | base := basePlan() |
| 85 | widen := func(mutate func(*Plan)) Diff { |
| 86 | after := basePlan() |
| 87 | after.Revision = 2 |
| 88 | mutate(&after) |
| 89 | return Compare(base, after.Normalize()) |
| 90 | } |
| 91 | |
| 92 | expansions := map[string]func(*Plan){ |
| 93 | "a new step": func(p *Plan) { p.Steps = append(p.Steps, Step{ID: "s4", Title: "extra"}) }, |
| 94 | "a new objective": func(p *Plan) { p.Objective = "something else entirely" }, |
| 95 | "a new risk": func(p *Plan) { p.Steps[0].Risks = []string{"data loss"} }, |
| 96 | "a new criterion": func(p *Plan) { p.Steps[0].Acceptance = []Criterion{{Text: "must hold"}} }, |
| 97 | "a wider surface": func(p *Plan) { p.Steps[1].CandidateFiles = []string{"api.go", "schema.go"} }, |
| 98 | } |
| 99 | for name, mutate := range expansions { |
| 100 | if !widen(mutate).NeedsApproval() { |
| 101 | t.Errorf("%s must ask again", name) |
| 102 | } |
| 103 | } |
| 104 | |
| 105 | narrowings := map[string]func(*Plan){ |
| 106 | "a dropped step": func(p *Plan) { p.Steps = p.Steps[:2] }, |
| 107 | "a reworded title": func(p *Plan) { p.Steps[0].Title = "change the database" }, |
| 108 | "a reorder": func(p *Plan) { p.Steps[0], p.Steps[2] = p.Steps[2], p.Steps[0] }, |
| 109 | } |
| 110 | for name, mutate := range narrowings { |
| 111 | if widen(mutate).NeedsApproval() { |
| 112 | t.Errorf("%s must not ask again", name) |
| 113 | } |
| 114 | } |
| 115 | } |
| 116 | |
| 117 | func TestRenderDiffOmitsEmptySectionsAndSaysNothingWhenNothingMoved(t *testing.T) { |
| 118 | if got := RenderDiff(Compare(basePlan(), basePlan())); got != "" { |
| 119 | t.Fatalf("an unchanged plan rendered %q", got) |
| 120 | } |
| 121 | |
| 122 | after := basePlan() |
| 123 | after.Revision = 2 |
| 124 | after.Steps = append(after.Steps, Step{ID: "s4", Title: "add the migration"}) |
| 125 | out := RenderDiff(Compare(basePlan(), after.Normalize())) |
| 126 | for _, want := range []string{"Revision 1 → 2", "**Added**", "s4 add the migration", "**Preserved**"} { |
| 127 | if !strings.Contains(out, want) { |
| 128 | t.Errorf("diff missing %q:\n%s", want, out) |
| 129 | } |
| 130 | } |
| 131 | for _, absent := range []string{"**Removed**", "**Changed**", "**Objective**"} { |
| 132 | if strings.Contains(out, absent) { |
| 133 | t.Errorf("diff should omit %q:\n%s", absent, out) |
| 134 | } |
| 135 | } |
| 136 | } |
| 137 |