1// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
2//
3// SPDX-License-Identifier: AGPL-3.0-or-later
4
5package cli_test
6
7import (
8 "strings"
9 "testing"
10 "time"
11
12 "git.secluded.site/np/internal/cli"
13 "git.secluded.site/np/internal/goal"
14 "git.secluded.site/np/internal/i18n"
15 "git.secluded.site/np/internal/task"
16)
17
18func TestRenderPlanWithGoalAndTasks(t *testing.T) {
19 localizer, err := i18n.Load("en")
20 if err != nil {
21 t.Fatalf("failed to load localizer: %v", err)
22 }
23
24 title := "Build reliable planning workflow"
25 description := "Capture context from ticket and operator input.\nPrioritise determinism."
26 goalDoc := goal.Document{
27 Title: title,
28 Description: description,
29 UpdatedAt: time.Now(),
30 }
31
32 tasks := []task.Task{
33 {
34 ID: "alpha1",
35 Title: "Add initial goal",
36 Description: "Use `np g s` to capture summary.",
37 Status: task.StatusCompleted,
38 CreatedAt: time.Now(),
39 UpdatedAt: time.Now(),
40 CreatedSeq: 1,
41 },
42 {
43 ID: "beta2",
44 Title: "Implement task list",
45 Description: "Track progress for each major change.\nRemember to cite files.",
46 Status: task.StatusInProgress,
47 CreatedAt: time.Now(),
48 UpdatedAt: time.Now(),
49 CreatedSeq: 2,
50 },
51 {
52 ID: "gamma3",
53 Title: "Polish output",
54 Description: "Ensure legend only includes relevant statuses.",
55 Status: task.StatusPending,
56 CreatedAt: time.Now(),
57 UpdatedAt: time.Now(),
58 CreatedSeq: 3,
59 },
60 {
61 ID: "delta4",
62 Title: "Document edge cases",
63 Description: "Explain handling for cancelled operations.",
64 Status: task.StatusCancelled,
65 CreatedAt: time.Now(),
66 UpdatedAt: time.Now(),
67 CreatedSeq: 4,
68 },
69 }
70
71 result := cli.RenderPlan(cli.PlanState{
72 Goal: &goalDoc,
73 Tasks: tasks,
74 }, localizer)
75
76 expectedLegend := "Legend: ☐ pending ⟳ in progress ☑ completed ⊗ cancelled"
77 if !strings.Contains(result, expectedLegend) {
78 t.Fatalf("expected legend %q in output:\n%s", expectedLegend, result)
79 }
80
81 if !strings.Contains(result, title) {
82 t.Fatalf("expected goal title in output")
83 }
84
85 if !strings.Contains(result, "Add initial goal [alpha1]") {
86 t.Fatalf("expected completed task line")
87 }
88
89 if !strings.Contains(result, " Track progress for each major change.") {
90 t.Fatalf("expected indented description line")
91 }
92
93 if strings.Contains(result, "☒ failed") {
94 t.Fatalf("failed legend entry should not appear without failed tasks")
95 }
96}
97
98func TestRenderPlanWithoutGoalOrTasks(t *testing.T) {
99 localizer, err := i18n.Load("en")
100 if err != nil {
101 t.Fatalf("failed to load localizer: %v", err)
102 }
103
104 result := cli.RenderPlan(cli.PlanState{}, localizer)
105
106 if !strings.Contains(result, "No goal set yet") {
107 t.Fatalf("expected placeholder goal")
108 }
109 if !strings.Contains(result, "No tasks yet.") {
110 t.Fatalf("expected placeholder task message")
111 }
112}
113
114func TestRenderPlanGoalWithoutTasks(t *testing.T) {
115 localizer, err := i18n.Load("en")
116 if err != nil {
117 t.Fatalf("failed to load localizer: %v", err)
118 }
119
120 goalDoc := goal.Document{
121 Title: "Standalone goal",
122 Description: "Goals can exist without tasks.",
123 UpdatedAt: time.Now(),
124 }
125
126 result := cli.RenderPlan(cli.PlanState{
127 Goal: &goalDoc,
128 }, localizer)
129
130 if !strings.Contains(result, "Standalone goal") {
131 t.Fatalf("expected goal title in output")
132 }
133 if !strings.Contains(result, "No tasks yet.") {
134 t.Fatalf("expected placeholder task message")
135 }
136}
137
138func TestRenderPlanTasksWithoutGoal(t *testing.T) {
139 localizer, err := i18n.Load("en")
140 if err != nil {
141 t.Fatalf("failed to load localizer: %v", err)
142 }
143
144 tasks := []task.Task{
145 {
146 ID: "task1",
147 Title: "First task",
148 Status: task.StatusPending,
149 CreatedAt: time.Now(),
150 UpdatedAt: time.Now(),
151 CreatedSeq: 1,
152 },
153 }
154
155 result := cli.RenderPlan(cli.PlanState{
156 Tasks: tasks,
157 }, localizer)
158
159 if !strings.Contains(result, "No goal set yet") {
160 t.Fatalf("expected placeholder goal")
161 }
162 if !strings.Contains(result, "First task [task1]") {
163 t.Fatalf("expected task in output")
164 }
165}
166
167func TestRenderPlanFailedTasksInLegend(t *testing.T) {
168 localizer, err := i18n.Load("en")
169 if err != nil {
170 t.Fatalf("failed to load localizer: %v", err)
171 }
172
173 tasks := []task.Task{
174 {
175 ID: "task1",
176 Title: "Failed operation",
177 Status: task.StatusFailed,
178 CreatedAt: time.Now(),
179 UpdatedAt: time.Now(),
180 CreatedSeq: 1,
181 },
182 {
183 ID: "task2",
184 Title: "Pending task",
185 Status: task.StatusPending,
186 CreatedAt: time.Now(),
187 UpdatedAt: time.Now(),
188 CreatedSeq: 2,
189 },
190 }
191
192 result := cli.RenderPlan(cli.PlanState{
193 Tasks: tasks,
194 }, localizer)
195
196 expectedLegend := "Legend: ☐ pending ⟳ in progress ☑ completed ☒ failed"
197 if !strings.Contains(result, expectedLegend) {
198 t.Fatalf("expected legend with failed status:\n%s", result)
199 }
200 if strings.Contains(result, "⊗ cancelled") {
201 t.Fatalf("cancelled legend entry should not appear without cancelled tasks")
202 }
203}
204
205func TestRenderPlanTaskSorting(t *testing.T) {
206 localizer, err := i18n.Load("en")
207 if err != nil {
208 t.Fatalf("failed to load localizer: %v", err)
209 }
210
211 now := time.Now()
212 earlier := now.Add(-1 * time.Hour)
213
214 tasks := []task.Task{
215 {
216 ID: "zulu",
217 Title: "Third by ID",
218 Status: task.StatusPending,
219 CreatedAt: now,
220 UpdatedAt: now,
221 CreatedSeq: 2,
222 },
223 {
224 ID: "alpha",
225 Title: "First by ID",
226 Status: task.StatusPending,
227 CreatedAt: now,
228 UpdatedAt: now,
229 CreatedSeq: 2,
230 },
231 {
232 ID: "beta",
233 Title: "Second by ID",
234 Status: task.StatusPending,
235 CreatedAt: now,
236 UpdatedAt: now,
237 CreatedSeq: 2,
238 },
239 {
240 ID: "task1",
241 Title: "Earlier seq",
242 Status: task.StatusCompleted,
243 CreatedAt: earlier,
244 UpdatedAt: now,
245 CreatedSeq: 1,
246 },
247 }
248
249 result := cli.RenderPlan(cli.PlanState{
250 Tasks: tasks,
251 }, localizer)
252
253 lines := strings.Split(result, "\n")
254 var taskLines []string
255 for _, line := range lines {
256 trimmed := strings.TrimSpace(line)
257 if strings.HasPrefix(trimmed, "☐") || strings.HasPrefix(trimmed, "☑") {
258 taskLines = append(taskLines, trimmed)
259 }
260 }
261
262 if len(taskLines) != 4 {
263 t.Fatalf("expected 4 task lines, got %d", len(taskLines))
264 }
265
266 if !strings.Contains(taskLines[0], "[task1]") {
267 t.Fatalf("first task should be task1 (seq=1), got: %s", taskLines[0])
268 }
269 if !strings.Contains(taskLines[1], "[alpha]") {
270 t.Fatalf("second task should be alpha (seq=2, sorted by ID), got: %s", taskLines[1])
271 }
272 if !strings.Contains(taskLines[2], "[beta]") {
273 t.Fatalf("third task should be beta (seq=2, sorted by ID), got: %s", taskLines[2])
274 }
275 if !strings.Contains(taskLines[3], "[zulu]") {
276 t.Fatalf("fourth task should be zulu (seq=2, sorted by ID), got: %s", taskLines[3])
277 }
278}