返回 DeepSeek-Reasonix
diff_test.go
根目录 / internal / plancontract / diff_test.go
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
137 lines GO