返回 DeepSeek-Reasonix
rewind_test.go
根目录 / internal / cli / rewind_test.go
1 package cli
2
3 import (
4 "context"
5 "strings"
6 "testing"
7
8 tea "charm.land/bubbletea/v2"
9
10 "reasonix/internal/agent"
11 "reasonix/internal/checkpoint"
12 "reasonix/internal/control"
13 "reasonix/internal/i18n"
14 "reasonix/internal/provider"
15 )
16
17 type rewindConfirmationController struct {
18 control.SessionAPI
19 plan checkpoint.RewindPlan
20 commits []string
21 result checkpoint.RewindResult
22 switches []string
23 }
24
25 func (c *rewindConfirmationController) PrepareRewind(_ int, _ control.RewindScope) (checkpoint.RewindPlan, error) {
26 return c.plan, nil
27 }
28
29 func (c *rewindConfirmationController) CommitRewind(planID string) (checkpoint.RewindResult, error) {
30 c.commits = append(c.commits, planID)
31 if c.result.ConversationForked || c.result.Branch != "" {
32 return c.result, nil
33 }
34 return checkpoint.RewindResult{OK: true}, nil
35 }
36
37 func (c *rewindConfirmationController) CommitRewindInPlace(planID string) (checkpoint.RewindResult, error) {
38 return c.CommitRewind(planID)
39 }
40
41 func (c *rewindConfirmationController) SwitchBranch(ref string) (agent.BranchInfo, error) {
42 c.switches = append(c.switches, ref)
43 return agent.BranchInfo{Path: ref}, nil
44 }
45
46 func (c *rewindConfirmationController) SetPlanMode(bool) {}
47 func (c *rewindConfirmationController) History() []provider.Message { return nil }
48
49 func (c *rewindConfirmationController) SummarizeFrom(context.Context, int) error { return nil }
50 func (c *rewindConfirmationController) SummarizeUpTo(context.Context, int) error { return nil }
51
52 func TestOneLine(t *testing.T) {
53 i18n.DetectLanguage("en")
54 t.Cleanup(func() { i18n.DetectLanguage("en") })
55
56 if got := oneLine("", 10); got != "(empty)" {
57 t.Fatalf("empty -> %q", got)
58 }
59 if got := oneLine("a\nb\nc", 10); strings.Contains(got, "\n") {
60 t.Fatalf("oneLine kept a newline: %q", got)
61 }
62 if got := oneLine("this is a fairly long prompt", 8); len([]rune(got)) > 8 {
63 t.Fatalf("oneLine did not truncate to width: %q", got)
64 }
65 }
66
67 func TestRenderRewindSmoke(t *testing.T) {
68 i18n.DetectLanguage("en")
69 t.Cleanup(func() { i18n.DetectLanguage("en") })
70
71 metas := []checkpoint.Meta{
72 {Turn: 0, Prompt: "add the parser", Paths: []string{"a.go"}},
73 {Turn: 1, Prompt: "fix the bug", Paths: []string{"b.go", "c.go"}},
74 }
75 // Stage 0: turn list.
76 m := chatTUI{width: 80, rewind: &rewindPicker{metas: metas, sel: 1}}
77 out := m.renderRewind()
78 if out == "" || !strings.Contains(out, "Rewind") || !strings.Contains(out, "fix the bug") {
79 t.Fatalf("stage-0 render missing content:\n%s", out)
80 }
81 // Stage 1: scope menu.
82 m.rewind.stage = 1
83 out = m.renderRewind()
84 for _, want := range []string{"Restore to turn 2", "Code + conversation", "Conversation only", "Code only"} {
85 if !strings.Contains(out, want) {
86 t.Fatalf("stage-1 render missing %q:\n%s", want, out)
87 }
88 }
89 // Closed picker renders nothing.
90 m.rewind = nil
91 if out := m.renderRewind(); out != "" {
92 t.Fatalf("closed picker rendered %q", out)
93 }
94 }
95
96 func TestPartialCoverageRequiresExplicitConfirmation(t *testing.T) {
97 i18n.DetectLanguage("en")
98 t.Cleanup(func() { i18n.DetectLanguage("en") })
99
100 partial := checkpoint.RewindPlan{
101 PlanID: "plan-partial",
102 Scope: checkpoint.RewindBoth,
103 CanFiles: true, CanConversation: true,
104 Coverage: checkpoint.CoveragePartial,
105 CoverageGaps: []checkpoint.CoverageGap{{Reason: checkpoint.GapBashSideEffect}},
106 }
107 if !rewindPlanCanApply(partial) || !control.RewindPlanRequiresConfirmation(partial) {
108 t.Fatalf("partial plan should be applicable only after confirmation: %+v", partial)
109 }
110 complete := partial
111 complete.Coverage = checkpoint.CoverageComplete
112 complete.CoverageGaps = nil
113 if control.RewindPlanRequiresConfirmation(complete) {
114 t.Fatal("complete coverage should not require an extra confirmation")
115 }
116 conversationOnly := partial
117 conversationOnly.Scope = checkpoint.RewindConversation
118 if control.RewindPlanRequiresConfirmation(conversationOnly) {
119 t.Fatal("conversation-only rewind should not warn about file coverage")
120 }
121 scratchOnly := partial
122 scratchOnly.CoverageGaps = []checkpoint.CoverageGap{{Reason: checkpoint.GapScratch, Path: "/tmp/btc_klines.py"}}
123 if control.RewindPlanRequiresConfirmation(scratchOnly) {
124 t.Fatal("scratch-only coverage must not require extra confirmation")
125 }
126
127 m := chatTUI{width: 80, rewind: &rewindPicker{
128 metas: []checkpoint.Meta{{Turn: 0, Prompt: "change files"}},
129 stage: 2,
130 pendingPlan: partial,
131 }}
132 out := m.renderRewind()
133 if !strings.Contains(out, "Partial file coverage") || !strings.Contains(out, "1 coverage gap") || !strings.Contains(out, "Enter/y confirm") {
134 t.Fatalf("confirmation render missing coverage warning:\n%s", out)
135 }
136 next, _ := m.handleRewindKey(tea.KeyPressMsg{Code: tea.KeyEscape})
137 m = next.(chatTUI)
138 if m.rewind == nil || m.rewind.stage != 1 || m.rewind.pendingPlan.PlanID != "" {
139 t.Fatalf("Esc should return to scope selection and discard the prepared confirmation: %+v", m.rewind)
140 }
141 }
142
143 func TestApplyRewindDoesNotCommitPartialCoverageBeforeConfirmation(t *testing.T) {
144 plan := checkpoint.RewindPlan{
145 PlanID: "plan-partial", Scope: checkpoint.RewindCode,
146 CanFiles: true, Coverage: checkpoint.CoveragePartial,
147 CoverageGaps: []checkpoint.CoverageGap{{Reason: checkpoint.GapBashSideEffect}},
148 }
149 ctrl := &rewindConfirmationController{plan: plan}
150 m := chatTUI{
151 ctrl: ctrl,
152 width: 80,
153 rewind: &rewindPicker{
154 metas: []checkpoint.Meta{{Turn: 0, Prompt: "change files"}},
155 stage: 1,
156 scope: 2,
157 },
158 }
159
160 next, _ := m.handleRewindKey(tea.KeyPressMsg{Code: tea.KeyEnter})
161 m = next.(chatTUI)
162 if m.rewind == nil || m.rewind.stage != 2 || len(ctrl.commits) != 0 {
163 t.Fatalf("partial rewind should pause for confirmation: picker=%+v commits=%v", m.rewind, ctrl.commits)
164 }
165 next, _ = m.handleRewindKey(tea.KeyPressMsg{Code: 'y'})
166 m = next.(chatTUI)
167 if m.rewind != nil || len(ctrl.commits) != 1 || ctrl.commits[0] != plan.PlanID {
168 t.Fatalf("confirmed rewind should commit once: picker=%+v commits=%v", m.rewind, ctrl.commits)
169 }
170 }
171
172 func TestConversationRewindReplaysTheRewoundConversation(t *testing.T) {
173 plan := checkpoint.RewindPlan{
174 PlanID: "plan-conversation", Scope: checkpoint.RewindConversation,
175 CanConversation: true,
176 }
177 ctrl := &rewindConfirmationController{
178 plan: plan,
179 result: checkpoint.RewindResult{
180 OK: true, ConversationForked: true, ConversationOK: true,
181 Branch: "/sessions/fork.jsonl",
182 },
183 }
184 m := newTestChatTUI()
185 m.ctrl = ctrl
186 m.rewind = &rewindPicker{
187 metas: []checkpoint.Meta{{Turn: 0}},
188 pendingPlan: plan,
189 }
190
191 next, _ := m.commitPreparedRewind()
192 m = next.(chatTUI)
193 if len(ctrl.commits) != 1 || ctrl.commits[0] != plan.PlanID {
194 t.Fatalf("rewind commits = %v, want the prepared plan once", ctrl.commits)
195 }
196 if len(ctrl.switches) != 0 {
197 t.Fatalf("rewind switched explicitly to %v; the in-place commit already moved the controller", ctrl.switches)
198 }
199 if !m.sessionSwitch {
200 t.Fatal("conversation rewind did not replay the rewound conversation")
201 }
202 }
203
203 lines GO