返回 DeepSeek-Reasonix
render_test.go
根目录 / internal / plancontract / render_test.go
1 package plancontract
2
3 import (
4 "slices"
5 "strings"
6 "testing"
7 )
8
9 // listItems returns the markdown list items in text, mirroring the rule a
10 // markdown task-list reader applies: a bullet or numbered marker after any
11 // indentation. Render's structural promise is that these are exactly the steps.
12 func listItems(text string) []string {
13 var out []string
14 for raw := range strings.SplitSeq(text, "\n") {
15 line := strings.TrimLeft(raw, " \t")
16 matched := false
17 for _, marker := range []string{"- ", "* ", "+ "} {
18 if rest, ok := strings.CutPrefix(line, marker); ok {
19 out = append(out, strings.TrimSpace(rest))
20 matched = true
21 break
22 }
23 }
24 if matched {
25 continue
26 }
27 digits := 0
28 for digits < len(line) && line[digits] >= '0' && line[digits] <= '9' {
29 digits++
30 }
31 if digits > 0 && digits+1 < len(line) && (line[digits] == '.' || line[digits] == ')') && line[digits+1] == ' ' {
32 out = append(out, strings.TrimSpace(line[digits+2:]))
33 }
34 }
35 return out
36 }
37
38 func richPlan() Plan {
39 return Plan{
40 Objective: "make the cache key model-aware",
41 Assumptions: []Assumption{{Text: "- warm caches are disposable", Confirm: "rg cacheKey internal/provider"}},
42 NonGoals: []string{"1. rewriting the retry loop"},
43 Steps: []Step{
44 {
45 ID: "p1", Title: "thread the model ref through",
46 VerifiedFiles: []string{"internal/provider/cache.go"},
47 CandidateFiles: []string{"internal/boot/boot.go"},
48 Risks: []string{"existing warm caches invalidate once"},
49 },
50 {
51 ID: "s1", ParentID: "p1", Title: "extend cacheKey",
52 Acceptance: []Criterion{{Text: "two model refs never share an entry"}, {Text: "existing hits keep hitting", Regression: true}},
53 Verification: []Verification{{Command: "go test ./internal/provider/", Expect: "all green"}},
54 },
55 {ID: "p2", Title: "record the hit rate"},
56 },
57 }
58 }
59
60 func TestRenderEmitsOnlyStepsAsListItems(t *testing.T) {
61 got := listItems(Render(richPlan()))
62 want := []string{
63 "thread the model ref through",
64 "extend cacheKey",
65 "record the hit rate",
66 }
67 if !slices.Equal(got, want) {
68 t.Fatalf("list items = %v, want exactly the steps %v", got, want)
69 }
70 }
71
72 func TestRenderKeepsStepDetailOffTheList(t *testing.T) {
73 out := Render(richPlan())
74 for _, want := range []string{
75 " verified: internal/provider/cache.go",
76 " candidate: internal/boot/boot.go",
77 " risk: existing warm caches invalidate once",
78 " accept [c1]: two model refs never share an entry",
79 " regression [c2]: existing hits keep hitting",
80 " verify: go test ./internal/provider/ — all green",
81 } {
82 if !strings.Contains(out, want+"\n") {
83 t.Errorf("rendered plan missing detail line %q:\n%s", want, out)
84 }
85 }
86 }
87
88 func TestRenderOmitsEmptySections(t *testing.T) {
89 out := Render(Plan{Objective: "just do it", Steps: []Step{{Title: "do it"}}})
90 for _, absent := range []string{"Assumptions", "Non-goals", "verified:", "verify:", "risk:"} {
91 if strings.Contains(out, absent) {
92 t.Errorf("rendered plan should omit %q:\n%s", absent, out)
93 }
94 }
95 if !strings.Contains(out, "**Objective** — just do it") || !strings.Contains(out, "1. do it") {
96 t.Fatalf("rendered plan lost its content:\n%s", out)
97 }
98 }
99
100 func TestRenderIsEmptyForAnEmptyPlan(t *testing.T) {
101 if got := Render(Plan{}); got != "" {
102 t.Fatalf("Render(empty) = %q, want empty", got)
103 }
104 }
105
106 func TestRenderNumbersPhasesInProjectionOrder(t *testing.T) {
107 out := Render(Plan{Objective: "o", Steps: []Step{
108 {ID: "late", Title: "second", DependsOn: []string{"early"}},
109 {ID: "early", Title: "first"},
110 }})
111 first := strings.Index(out, "1. first")
112 second := strings.Index(out, "2. second")
113 if first < 0 || second < 0 || first > second {
114 t.Fatalf("phases not renumbered in dependency order:\n%s", out)
115 }
116 }
117
117 lines GO