返回 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/checkpoint"
11 "reasonix/internal/control"
12 "reasonix/internal/i18n"
13 )
14
15 type rewindConfirmationController struct {
16 control.SessionAPI
17 plan checkpoint.RewindPlan
18 commits []string
19 }
20
21 func (c *rewindConfirmationController) PrepareRewind(_ int, _ control.RewindScope) (checkpoint.RewindPlan, error) {
22 return c.plan, nil
23 }
24
25 func (c *rewindConfirmationController) CommitRewind(planID string) (checkpoint.RewindResult, error) {
26 c.commits = append(c.commits, planID)
27 return checkpoint.RewindResult{OK: true}, nil
28 }
29
30 func (c *rewindConfirmationController) SummarizeFrom(context.Context, int) error { return nil }
31 func (c *rewindConfirmationController) SummarizeUpTo(context.Context, int) error { return nil }
32
33 func TestOneLine(t *testing.T) {
34 i18n.DetectLanguage("en")
35 t.Cleanup(func() { i18n.DetectLanguage("en") })
36
37 if got := oneLine("", 10); got != "(empty)" {
38 t.Fatalf("empty -> %q", got)
39 }
40 if got := oneLine("a\nb\nc", 10); strings.Contains(got, "\n") {
41 t.Fatalf("oneLine kept a newline: %q", got)
42 }
43 if got := oneLine("this is a fairly long prompt", 8); len([]rune(got)) > 8 {
44 t.Fatalf("oneLine did not truncate to width: %q", got)
45 }
46 }
47
48 func TestRenderRewindSmoke(t *testing.T) {
49 i18n.DetectLanguage("en")
50 t.Cleanup(func() { i18n.DetectLanguage("en") })
51
52 metas := []checkpoint.Meta{
53 {Turn: 0, Prompt: "add the parser", Paths: []string{"a.go"}},
54 {Turn: 1, Prompt: "fix the bug", Paths: []string{"b.go", "c.go"}},
55 }
56 // Stage 0: turn list.
57 m := chatTUI{width: 80, rewind: &rewindPicker{metas: metas, sel: 1}}
58 out := m.renderRewind()
59 if out == "" || !strings.Contains(out, "Rewind") || !strings.Contains(out, "fix the bug") {
60 t.Fatalf("stage-0 render missing content:\n%s", out)
61 }
62 // Stage 1: scope menu.
63 m.rewind.stage = 1
64 out = m.renderRewind()
65 for _, want := range []string{"Restore to turn 2", "Code + conversation", "Conversation only", "Code only"} {
66 if !strings.Contains(out, want) {
67 t.Fatalf("stage-1 render missing %q:\n%s", want, out)
68 }
69 }
70 // Closed picker renders nothing.
71 m.rewind = nil
72 if out := m.renderRewind(); out != "" {
73 t.Fatalf("closed picker rendered %q", out)
74 }
75 }
76
77 func TestPartialCoverageRequiresExplicitConfirmation(t *testing.T) {
78 i18n.DetectLanguage("en")
79 t.Cleanup(func() { i18n.DetectLanguage("en") })
80
81 partial := checkpoint.RewindPlan{
82 PlanID: "plan-partial",
83 Scope: checkpoint.RewindBoth,
84 CanFiles: true, CanConversation: true,
85 Coverage: checkpoint.CoveragePartial,
86 CoverageGaps: []checkpoint.CoverageGap{{Reason: checkpoint.GapBashSideEffect}},
87 }
88 if !rewindPlanCanApply(partial) || !control.RewindPlanRequiresConfirmation(partial) {
89 t.Fatalf("partial plan should be applicable only after confirmation: %+v", partial)
90 }
91 complete := partial
92 complete.Coverage = checkpoint.CoverageComplete
93 complete.CoverageGaps = nil
94 if control.RewindPlanRequiresConfirmation(complete) {
95 t.Fatal("complete coverage should not require an extra confirmation")
96 }
97 conversationOnly := partial
98 conversationOnly.Scope = checkpoint.RewindConversation
99 if control.RewindPlanRequiresConfirmation(conversationOnly) {
100 t.Fatal("conversation-only rewind should not warn about file coverage")
101 }
102
103 m := chatTUI{width: 80, rewind: &rewindPicker{
104 metas: []checkpoint.Meta{{Turn: 0, Prompt: "change files"}},
105 stage: 2,
106 pendingPlan: partial,
107 }}
108 out := m.renderRewind()
109 if !strings.Contains(out, "Partial file coverage") || !strings.Contains(out, "1 coverage gap") || !strings.Contains(out, "Enter/y confirm") {
110 t.Fatalf("confirmation render missing coverage warning:\n%s", out)
111 }
112 next, _ := m.handleRewindKey(tea.KeyPressMsg{Code: tea.KeyEscape})
113 m = next.(chatTUI)
114 if m.rewind == nil || m.rewind.stage != 1 || m.rewind.pendingPlan.PlanID != "" {
115 t.Fatalf("Esc should return to scope selection and discard the prepared confirmation: %+v", m.rewind)
116 }
117 }
118
119 func TestApplyRewindDoesNotCommitPartialCoverageBeforeConfirmation(t *testing.T) {
120 plan := checkpoint.RewindPlan{
121 PlanID: "plan-partial", Scope: checkpoint.RewindCode,
122 CanFiles: true, Coverage: checkpoint.CoveragePartial,
123 CoverageGaps: []checkpoint.CoverageGap{{Reason: checkpoint.GapBashSideEffect}},
124 }
125 ctrl := &rewindConfirmationController{plan: plan}
126 m := chatTUI{
127 ctrl: ctrl,
128 width: 80,
129 rewind: &rewindPicker{
130 metas: []checkpoint.Meta{{Turn: 0, Prompt: "change files"}},
131 stage: 1,
132 scope: 2,
133 },
134 }
135
136 next, _ := m.handleRewindKey(tea.KeyPressMsg{Code: tea.KeyEnter})
137 m = next.(chatTUI)
138 if m.rewind == nil || m.rewind.stage != 2 || len(ctrl.commits) != 0 {
139 t.Fatalf("partial rewind should pause for confirmation: picker=%+v commits=%v", m.rewind, ctrl.commits)
140 }
141 next, _ = m.handleRewindKey(tea.KeyPressMsg{Code: 'y'})
142 m = next.(chatTUI)
143 if m.rewind != nil || len(ctrl.commits) != 1 || ctrl.commits[0] != plan.PlanID {
144 t.Fatalf("confirmed rewind should commit once: picker=%+v commits=%v", m.rewind, ctrl.commits)
145 }
146 }
147
147 lines GO