plan_test.go

  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}