savepoint 1.0.2 → 1.0.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/settings.local.json +12 -1
- package/.golangci.yml +11 -0
- package/.savepoint/Design.md +37 -36
- package/.savepoint/{audit/v1.1/E02-cross-platform-compatibility/proposals.md → releases/v1.1/epics/E02-cross-platform-compatibility/E02-Audit.md} +48 -38
- package/.savepoint/releases/v1.1/epics/E03-ui-visual-refinement/E03-Audit.md +195 -0
- package/.savepoint/releases/v1.1/epics/E03-ui-visual-refinement/E03-Detail.md +14 -1
- package/.savepoint/releases/v1.1/epics/E03-ui-visual-refinement/tasks/T006-forced-256-color-profile.md +3 -3
- package/.savepoint/{audit/v1.1/E04-epic-navigation/proposals.md → releases/v1.1/epics/E04-epic-navigation/E04-Audit.md} +65 -54
- package/.savepoint/releases/v1.1/epics/E05-tasking-permissions/E05-Audit.md +237 -0
- package/.savepoint/releases/v1.1/epics/E05-tasking-permissions/E05-Detail.md +25 -16
- package/.savepoint/releases/v1.1/epics/E05-tasking-permissions/tasks/T001-update-agents-md.md +17 -6
- package/.savepoint/releases/v1.1/epics/E05-tasking-permissions/tasks/T002-update-router-md.md +15 -5
- package/.savepoint/releases/v1.1/epics/E05-tasking-permissions/tasks/T003-update-design-md.md +19 -5
- package/.savepoint/releases/v1.1/epics/E05-tasking-permissions/tasks/T004-implement-m-hotkey.md +11 -1
- package/.savepoint/releases/v1.1/epics/E05-tasking-permissions/tasks/T005-update-help-overlay.md +9 -6
- package/.savepoint/releases/v1.1/epics/E05-tasking-permissions/tasks/T006-tests-and-quality-gates.md +29 -13
- package/.savepoint/releases/v1.1/epics/E06-audit-command/E06-Audit.md +56 -0
- package/.savepoint/releases/v1.1/epics/E06-audit-command/E06-Detail.md +63 -0
- package/.savepoint/releases/v1.1/epics/E06-audit-command/tasks/T005-proposals.md +44 -0
- package/.savepoint/releases/v1.1/epics/E06-audit-command/tasks/T007-apply-close.md +35 -0
- package/.savepoint/releases/v1.1/epics/E06-audit-command/tasks/T009-integration.md +40 -0
- package/.savepoint/releases/v1.1/epics/E06-audit-command/tasks/T010-audit-file-migration.md +45 -0
- package/.savepoint/releases/v1.1/epics/E06-audit-command/tasks/T011-model-tab-state.md +26 -0
- package/.savepoint/releases/v1.1/epics/E06-audit-command/tasks/T012-epic-audit-render.md +33 -0
- package/.savepoint/releases/v1.1/epics/E06-audit-command/tasks/T013-handle-tab-keys.md +34 -0
- package/.savepoint/releases/v1.1/epics/E06-audit-command/tasks/T014-tab-indicator.md +33 -0
- package/.savepoint/releases/v1.1/epics/E07-init-command/E07-Audit.md +336 -0
- package/.savepoint/releases/v1.1/epics/E07-init-command/E07-Detail.md +61 -0
- package/.savepoint/releases/v1.1/epics/E07-init-command/tasks/T001-cli-entrypoint.md +37 -0
- package/.savepoint/releases/v1.1/epics/E07-init-command/tasks/T002-target-validation.md +28 -0
- package/.savepoint/releases/v1.1/epics/E07-init-command/tasks/T003-scaffold-writer.md +46 -0
- package/.savepoint/releases/v1.1/epics/E07-init-command/tasks/T004-atomic-writes.md +27 -0
- package/.savepoint/releases/v1.1/epics/E07-init-command/tasks/T005-magic-prompt.md +25 -0
- package/.savepoint/releases/v1.1/epics/E07-init-command/tasks/T006-clipboard.md +26 -0
- package/.savepoint/releases/v1.1/epics/E07-init-command/tasks/T007-integration-test.md +26 -0
- package/.savepoint/releases/v1.1/epics/E08-board-command/E08-Audit.md +333 -0
- package/.savepoint/releases/v1.1/epics/E08-board-command/E08-Detail.md +68 -0
- package/.savepoint/releases/v1.1/epics/E08-board-command/tasks/T001-cli-entrypoint.md +26 -0
- package/.savepoint/releases/v1.1/epics/E08-board-command/tasks/T002-non-tty-fallback.md +27 -0
- package/.savepoint/releases/v1.1/epics/E08-board-command/tasks/T003-tui-app-shell.md +28 -0
- package/.savepoint/releases/v1.1/epics/E08-board-command/tasks/T004-board-model.md +29 -0
- package/.savepoint/releases/v1.1/epics/E08-board-command/tasks/T005-detail-pane.md +27 -0
- package/.savepoint/releases/v1.1/epics/E08-board-command/tasks/T006-status-transitions.md +29 -0
- package/.savepoint/releases/v1.1/epics/E08-board-command/tasks/T007-theme-fallbacks.md +29 -0
- package/.savepoint/releases/v1.1/epics/E08-board-command/tasks/T008-integration-test.md +27 -0
- package/.savepoint/releases/v1.1/epics/E09-doctor-command/E09-Audit.md +207 -0
- package/.savepoint/releases/v1.1/epics/E09-doctor-command/E09-Detail.md +65 -0
- package/.savepoint/releases/v1.1/epics/E09-doctor-command/tasks/T001-cli-entrypoint.md +24 -0
- package/.savepoint/releases/v1.1/epics/E09-doctor-command/tasks/T002-config-router-validation.md +28 -0
- package/.savepoint/releases/v1.1/epics/E09-doctor-command/tasks/T003-structure-checks.md +29 -0
- package/.savepoint/releases/v1.1/epics/E09-doctor-command/tasks/T004-dependency-checks.md +27 -0
- package/.savepoint/releases/v1.1/epics/E09-doctor-command/tasks/T005-audit-orphan-checks.md +28 -0
- package/.savepoint/releases/v1.1/epics/E09-doctor-command/tasks/T006-quality-gates-report.md +31 -0
- package/.savepoint/releases/v1.1/epics/E11-board-refresh-fix/E11-Detail.md +36 -0
- package/.savepoint/releases/v1.1/epics/E11-board-refresh-fix/tasks/T001-debug-logging.md +25 -0
- package/.savepoint/releases/v1.1/epics/E11-board-refresh-fix/tasks/T002-increase-debounce.md +21 -0
- package/.savepoint/releases/v1.1/epics/E11-board-refresh-fix/tasks/T003-error-handling.md +22 -0
- package/.savepoint/releases/v1.1/epics/E11-board-refresh-fix/tasks/T004-test-verify.md +29 -0
- package/.savepoint/releases/v1.1/epics/E12-validation-fix/E12-Audit.md +444 -0
- package/.savepoint/releases/v1.1/epics/E12-validation-fix/E12-Detail.md +45 -0
- package/.savepoint/releases/v1.1/epics/E12-validation-fix/tasks/T001-default-phase.md +35 -0
- package/.savepoint/releases/v1.1/epics/E12-validation-fix/tasks/T002-default-status.md +19 -0
- package/.savepoint/releases/v1.1/epics/E12-validation-fix/tasks/T003-better-errors.md +29 -0
- package/.savepoint/releases/v1.1/epics/E12-validation-fix/tasks/T004-validate-on-write.md +25 -0
- package/.savepoint/releases/v1.1/epics/E12-validation-fix/tasks/T005-tests.md +37 -0
- package/.savepoint/releases/v1.1/epics/E13-audit-remediation/E13-Audit.md +118 -0
- package/.savepoint/releases/v1.1/epics/E13-audit-remediation/E13-Detail.md +73 -0
- package/.savepoint/releases/v1.1/epics/E13-audit-remediation/tasks/T001-safe-cleanup.md +66 -0
- package/.savepoint/releases/v1.1/epics/E13-audit-remediation/tasks/T002-bug-fixes.md +35 -0
- package/.savepoint/releases/v1.1/epics/E13-audit-remediation/tasks/T003-centralize-duplication.md +60 -0
- package/.savepoint/releases/v1.1/epics/E13-audit-remediation/tasks/T004-infrastructure.md +33 -0
- package/.savepoint/releases/v1.1/epics/E13-audit-remediation/tasks/T005-decompose-update.md +37 -0
- package/.savepoint/releases/v1.1/epics/E13-audit-remediation/tasks/T006-async-io.md +40 -0
- package/.savepoint/releases/v1.1/epics/E13-audit-remediation/tasks/T007-test-coverage.md +37 -0
- package/.savepoint/releases/v1.1/epics/E14-structural-improvements/E14-Audit.md +267 -0
- package/.savepoint/releases/v1.1/epics/E14-structural-improvements/E14-Detail.md +54 -0
- package/.savepoint/releases/v1.1/epics/E14-structural-improvements/tasks/T001-group-model.md +39 -0
- package/.savepoint/releases/v1.1/epics/E14-structural-improvements/tasks/T002-data-interfaces.md +42 -0
- package/.savepoint/releases/v1.1/epics/E14-structural-improvements/tasks/T003-discover-orphans.md +33 -0
- package/.savepoint/releases/v1.1/epics/E14-structural-improvements/tasks/T004-epic-panel-headings.md +35 -0
- package/.savepoint/releases/v1.1/epics/E14-structural-improvements/tasks/T005-shell-tokenization.md +27 -0
- package/.savepoint/releases/v1.1/epics/E14-structural-improvements/tasks/T006-unify-enums.md +29 -0
- package/.savepoint/releases/v1.1/epics/E14-structural-improvements/tasks/T007-testutil-package.md +28 -0
- package/.savepoint/releases/v1.1/epics/E15-hardening/E15-Detail.md +43 -0
- package/.savepoint/releases/v1.1/epics/E15-hardening/tasks/T001-benchmarks.md +31 -0
- package/.savepoint/releases/v1.1/epics/E15-hardening/tasks/T002-fuzz-targets.md +28 -0
- package/.savepoint/releases/v1.1/epics/E15-hardening/tasks/T003-debug-flag.md +30 -0
- package/.savepoint/releases/v1.1/epics/E15-hardening/tasks/T004-dist-checksums.md +27 -0
- package/.savepoint/releases/v1.1/epics/E15-hardening/tasks/T005-windows-targets.md +28 -0
- package/.savepoint/releases/v1.1/epics/E15-hardening/tasks/T006-abbreviation-splitting.md +26 -0
- package/.savepoint/releases/v1.1/epics/E15-hardening/tasks/T007-root-test-allowlist.md +28 -0
- package/.savepoint/releases/v1.1/epics/_archived/T001-cli-entrypoint.md +25 -0
- package/.savepoint/releases/v1.1/epics/_archived/T002-quality-gates.md +27 -0
- package/.savepoint/releases/v1.1/epics/_archived/T003-snapshot.md +27 -0
- package/.savepoint/releases/v1.1/epics/_archived/T004-ai-reconcile.md +29 -0
- package/.savepoint/releases/v1.1/epics/_archived/T006-tui-review.md +31 -0
- package/.savepoint/releases/v1.1/epics/_archived/T008-skip-handling.md +34 -0
- package/.savepoint/releases/v1.1/v1.1-PRD.md +67 -7
- package/.savepoint/router.md +9 -16
- package/AGENTS.md +38 -23
- package/README.md +0 -1
- package/agent-skills/savepoint-audit/SKILL.md +86 -34
- package/agent-skills/savepoint-build-task/SKILL.md +7 -2
- package/agent-skills/savepoint-create-plan/SKILL.md +7 -2
- package/agent-skills/savepoint-create-task/SKILL.md +44 -31
- package/agent-skills/savepoint-draft-prd/SKILL.md +7 -2
- package/agent-skills/savepoint-system-design/SKILL.md +7 -2
- package/agent_skills_test.go +91 -0
- package/cmd/board.go +59 -0
- package/cmd/board_test.go +137 -0
- package/cmd/doctor.go +53 -0
- package/cmd/doctor_test.go +146 -0
- package/cmd/init.go +63 -0
- package/cmd/init_test.go +104 -0
- package/internal/board/board.go +40 -36
- package/internal/board/board_test.go +27 -82
- package/internal/board/card.go +43 -23
- package/internal/board/card_test.go +41 -5
- package/internal/board/column.go +44 -13
- package/internal/board/column_test.go +5 -2
- package/internal/board/detail.go +0 -47
- package/internal/board/epic_panel.go +118 -22
- package/internal/board/epic_panel_test.go +302 -17
- package/internal/board/help.go +1 -0
- package/internal/board/help_test.go +1 -0
- package/internal/board/integration_test.go +266 -0
- package/internal/board/interfaces.go +65 -0
- package/internal/board/interfaces_test.go +114 -0
- package/internal/board/io.go +93 -0
- package/internal/board/model.go +79 -118
- package/internal/board/plain.go +88 -0
- package/internal/board/plain_test.go +117 -0
- package/internal/board/release.go +1 -9
- package/internal/board/release_test.go +6 -6
- package/internal/board/status.go +4 -4
- package/internal/board/theme.go +24 -0
- package/internal/board/theme_test.go +31 -0
- package/internal/board/transitions.go +113 -88
- package/internal/board/transitions_test.go +164 -141
- package/internal/board/tui.go +32 -0
- package/internal/board/update.go +325 -215
- package/internal/board/update_test.go +299 -18
- package/internal/board/util.go +76 -0
- package/internal/board/view.go +31 -28
- package/internal/board/view_test.go +12 -2
- package/internal/board/watch.go +35 -5
- package/internal/buildtool/main.go +2 -10
- package/internal/buildtool/main_test.go +46 -0
- package/internal/data/config.go +17 -3
- package/internal/data/config_test.go +49 -0
- package/internal/data/discover.go +26 -0
- package/internal/data/discover_test.go +34 -10
- package/internal/data/errors.go +4 -0
- package/internal/data/lifecycle.go +13 -6
- package/internal/data/lifecycle_test.go +14 -11
- package/internal/data/parser.go +21 -6
- package/internal/data/parser_test.go +31 -7
- package/internal/data/task.go +0 -9
- package/internal/data/write.go +85 -11
- package/internal/data/write_test.go +167 -0
- package/internal/doctor/checks.go +567 -0
- package/internal/doctor/checks_test.go +716 -0
- package/internal/doctor/gates.go +193 -0
- package/internal/doctor/gates_test.go +166 -0
- package/internal/doctor/interfaces.go +64 -0
- package/internal/doctor/interfaces_test.go +104 -0
- package/internal/doctor/repairs.go +80 -0
- package/internal/doctor/repairs_test.go +81 -0
- package/internal/doctor/report.go +157 -0
- package/internal/doctor/report_test.go +89 -0
- package/internal/init/clipboard.go +146 -0
- package/internal/init/clipboard_test.go +74 -0
- package/internal/init/install.go +16 -0
- package/internal/init/integration_test.go +197 -0
- package/internal/init/prompt.go +14 -0
- package/internal/init/prompt_test.go +77 -0
- package/internal/init/scaffold.go +59 -0
- package/internal/init/scaffold_test.go +179 -0
- package/internal/init/template_freshness_test.go +56 -0
- package/internal/init/validate.go +85 -0
- package/internal/init/validate_test.go +141 -0
- package/internal/init/write.go +73 -0
- package/internal/init/write_test.go +91 -0
- package/internal/styles/styles_test.go +133 -0
- package/internal/testutil/fixture.go +113 -0
- package/internal/testutil/fs.go +26 -0
- package/main.go +101 -4
- package/package.json +2 -2
- package/project-audit/audit_report_glm_5.1.md +411 -0
- package/project-audit/audit_report_opus_4.6 +406 -0
- package/project-audit/consolidated-audit-report.md +456 -0
- package/savepoint +0 -0
- package/templates/project/.savepoint/Design.md +2 -2
- package/templates/project/.savepoint/router.md +10 -10
- package/templates/project/AGENTS.md +33 -21
- package/templates/project/agent-skills/savepoint-audit/SKILL.md +87 -0
- package/templates/project/agent-skills/savepoint-build-task/SKILL.md +44 -0
- package/templates/project/agent-skills/savepoint-create-plan/SKILL.md +33 -0
- package/templates/project/agent-skills/savepoint-create-task/SKILL.md +44 -0
- package/templates/project/agent-skills/savepoint-draft-prd/SKILL.md +37 -0
- package/templates/project/agent-skills/savepoint-system-design/SKILL.md +38 -0
- package/templates/prompts/audit-reconciliation.prompt.md +33 -28
- package/templates/prompts/design.prompt.md +3 -1
- package/.savepoint/audit/v1/E01/proposals.md +0 -168
- package/.savepoint/audit/v1/E01/snapshot.md +0 -78
- package/.savepoint/audit/v1/E01-go-setup/proposals.md +0 -166
- package/.savepoint/audit/v1/E01-go-setup/snapshot.md +0 -71
- package/.savepoint/audit/v1/E01-scaffolding/proposals/AGENTS.md +0 -66
- package/.savepoint/audit/v1/E01-scaffolding/proposals/Design.md +0 -210
- package/.savepoint/audit/v1/E01-scaffolding/proposals/epic-Design.md +0 -117
- package/.savepoint/audit/v1/E01-scaffolding/proposals/quality-review.md +0 -101
- package/.savepoint/audit/v1/E01-scaffolding/snapshot.md +0 -54
- package/.savepoint/audit/v1/E02-data-model/snapshot.md +0 -128
- package/.savepoint/audit/v1/E02-data-readers/proposals.md +0 -123
- package/.savepoint/audit/v1/E02-data-readers/snapshot.md +0 -54
- package/.savepoint/audit/v1/E03-board-tui-core/proposals.md +0 -146
- package/.savepoint/audit/v1/E03-board-tui-core/snapshot.md +0 -57
- package/.savepoint/audit/v1/E03-cli-foundation/snapshot.md +0 -106
- package/.savepoint/audit/v1/E04-board-components/proposals.md +0 -118
- package/.savepoint/audit/v1/E04-board-components/snapshot.md +0 -77
- package/.savepoint/audit/v1/E04-templates-and-prompts/snapshot.md +0 -115
- package/.savepoint/audit/v1/E05-init-command/snapshot.md +0 -125
- package/.savepoint/audit/v1/E05-phase-transitions/proposals.md +0 -83
- package/.savepoint/audit/v1/E05-phase-transitions/snapshot.md +0 -36
- package/.savepoint/audit/v1/E06-atari-noir-layout/proposals.md +0 -130
- package/.savepoint/audit/v1/E06-atari-noir-layout/snapshot.md +0 -84
- package/.savepoint/audit/v1/E06-tui-board/snapshot.md +0 -64
- package/.savepoint/audit/v1/E07-audit-pipeline/snapshot.md +0 -165
- package/.savepoint/audit/v1/E08-board-workflow-cleanup/snapshot.md +0 -65
- package/.savepoint/audit/v1.1/E02-cross-platform-compatibility/snapshot.md +0 -41
- package/.savepoint/audit/v1.1/E04-epic-navigation/snapshot.md +0 -48
- package/ink-cli-ui-design.zip +0 -0
- package/savepoint.exe +0 -0
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
package board
|
|
2
2
|
|
|
3
3
|
import (
|
|
4
|
+
"os"
|
|
5
|
+
"path/filepath"
|
|
4
6
|
"strings"
|
|
5
7
|
"testing"
|
|
6
8
|
|
|
@@ -9,21 +11,21 @@ import (
|
|
|
9
11
|
)
|
|
10
12
|
|
|
11
13
|
func TestRenderEpicSidebar_containsEpicsHeader(t *testing.T) {
|
|
12
|
-
got := RenderEpicSidebar([]string{"E01", "E02"}, "E01", 28, false, 0, nil)
|
|
14
|
+
got := RenderEpicSidebar([]string{"E01", "E02"}, "E01", 28, false, 0, nil, 999)
|
|
13
15
|
if !strings.Contains(got, "EPICS") {
|
|
14
16
|
t.Error("RenderEpicSidebar missing EPICS header")
|
|
15
17
|
}
|
|
16
18
|
}
|
|
17
19
|
|
|
18
20
|
func TestRenderEpicSidebar_activeEpicMarked(t *testing.T) {
|
|
19
|
-
got := RenderEpicSidebar([]string{"E01", "E02"}, "E01", 28, false, 0, nil)
|
|
21
|
+
got := RenderEpicSidebar([]string{"E01", "E02"}, "E01", 28, false, 0, nil, 999)
|
|
20
22
|
if !strings.Contains(got, epicActiveMarker) {
|
|
21
23
|
t.Errorf("RenderEpicSidebar missing active marker %q", epicActiveMarker)
|
|
22
24
|
}
|
|
23
25
|
}
|
|
24
26
|
|
|
25
27
|
func TestRenderEpicSidebar_focusedCursorMarked(t *testing.T) {
|
|
26
|
-
got := RenderEpicSidebar([]string{"E01", "E02"}, "E01", 28, true, 1, nil)
|
|
28
|
+
got := RenderEpicSidebar([]string{"E01", "E02"}, "E01", 28, true, 1, nil, 999)
|
|
27
29
|
if !strings.Contains(got, epicActiveMarker+" E02") {
|
|
28
30
|
t.Errorf("RenderEpicSidebar focused cursor missing marker, got %q", got)
|
|
29
31
|
}
|
|
@@ -31,7 +33,7 @@ func TestRenderEpicSidebar_focusedCursorMarked(t *testing.T) {
|
|
|
31
33
|
|
|
32
34
|
func TestRenderEpicSidebar_allEpicsPresent(t *testing.T) {
|
|
33
35
|
epics := []string{"E01-foo", "E02-bar", "E03-baz"}
|
|
34
|
-
got := RenderEpicSidebar(epics, "E01-foo", 32, false, 0, nil)
|
|
36
|
+
got := RenderEpicSidebar(epics, "E01-foo", 32, false, 0, nil, 999)
|
|
35
37
|
for _, e := range epics {
|
|
36
38
|
if !strings.Contains(got, e) {
|
|
37
39
|
t.Errorf("RenderEpicSidebar missing epic %q", e)
|
|
@@ -40,14 +42,14 @@ func TestRenderEpicSidebar_allEpicsPresent(t *testing.T) {
|
|
|
40
42
|
}
|
|
41
43
|
|
|
42
44
|
func TestRenderEpicSidebar_emptyEpicsFallback(t *testing.T) {
|
|
43
|
-
got := RenderEpicSidebar(nil, "E03", 28, false, 0, nil)
|
|
45
|
+
got := RenderEpicSidebar(nil, "E03", 28, false, 0, nil, 999)
|
|
44
46
|
if !strings.Contains(got, "E03") {
|
|
45
47
|
t.Error("RenderEpicSidebar with empty list should show selected epic")
|
|
46
48
|
}
|
|
47
49
|
}
|
|
48
50
|
|
|
49
51
|
func TestRenderEpicSidebar_emptyBothShowsNone(t *testing.T) {
|
|
50
|
-
got := RenderEpicSidebar(nil, "", 28, false, 0, nil)
|
|
52
|
+
got := RenderEpicSidebar(nil, "", 28, false, 0, nil, 999)
|
|
51
53
|
if !strings.Contains(got, "(none)") {
|
|
52
54
|
t.Error("RenderEpicSidebar with no epics and no selected should show (none)")
|
|
53
55
|
}
|
|
@@ -81,22 +83,22 @@ func TestRenderEpicDropdown_emptyShowsNone(t *testing.T) {
|
|
|
81
83
|
}
|
|
82
84
|
}
|
|
83
85
|
|
|
84
|
-
func
|
|
86
|
+
func TestSliceIndex_found(t *testing.T) {
|
|
85
87
|
epics := []string{"E01", "E02", "E03"}
|
|
86
|
-
if got :=
|
|
87
|
-
t.Errorf("
|
|
88
|
+
if got := sliceIndex(epics, "E02"); got != 1 {
|
|
89
|
+
t.Errorf("sliceIndex = %d, want 1", got)
|
|
88
90
|
}
|
|
89
91
|
}
|
|
90
92
|
|
|
91
|
-
func
|
|
92
|
-
if got :=
|
|
93
|
-
t.Errorf("
|
|
93
|
+
func TestSliceIndex_notFound(t *testing.T) {
|
|
94
|
+
if got := sliceIndex([]string{"E01"}, "E99"); got != 0 {
|
|
95
|
+
t.Errorf("sliceIndex not-found = %d, want 0", got)
|
|
94
96
|
}
|
|
95
97
|
}
|
|
96
98
|
|
|
97
|
-
func
|
|
98
|
-
if got :=
|
|
99
|
-
t.Errorf("
|
|
99
|
+
func TestSliceIndex_empty(t *testing.T) {
|
|
100
|
+
if got := sliceIndex(nil, "E01"); got != 0 {
|
|
101
|
+
t.Errorf("sliceIndex empty = %d, want 0", got)
|
|
100
102
|
}
|
|
101
103
|
}
|
|
102
104
|
|
|
@@ -567,7 +569,7 @@ func TestView_epicDetailOverlayNoContent(t *testing.T) {
|
|
|
567
569
|
|
|
568
570
|
func TestRenderEpicDetail_stripsMarkdownHeadings(t *testing.T) {
|
|
569
571
|
content := "---\ntype: epic-design\n---\n# Epic E01\n\n## Purpose\nDoes things."
|
|
570
|
-
got := RenderEpicDetail("E01-test", content, 60, 40, 0)
|
|
572
|
+
got := RenderEpicDetail("E01-test", content, 60, 40, 0, 0)
|
|
571
573
|
if !strings.Contains(got, "EPIC DETAIL") {
|
|
572
574
|
t.Error("RenderEpicDetail missing EPIC DETAIL header")
|
|
573
575
|
}
|
|
@@ -577,8 +579,291 @@ func TestRenderEpicDetail_stripsMarkdownHeadings(t *testing.T) {
|
|
|
577
579
|
}
|
|
578
580
|
|
|
579
581
|
func TestRenderEpicDetail_noDetailFallback(t *testing.T) {
|
|
580
|
-
got := RenderEpicDetail("E01-test", "(no detail available)", 60, 40, 0)
|
|
582
|
+
got := RenderEpicDetail("E01-test", "(no detail available)", 60, 40, 0, 0)
|
|
581
583
|
if !strings.Contains(got, "no detail available") {
|
|
582
584
|
t.Error("RenderEpicDetail fallback message missing")
|
|
583
585
|
}
|
|
584
586
|
}
|
|
587
|
+
|
|
588
|
+
func TestRenderEpicDetail_tabIndicatorDetailActive(t *testing.T) {
|
|
589
|
+
got := RenderEpicDetail("E01-test", "content", 60, 40, 0, 0)
|
|
590
|
+
if !strings.Contains(got, "DETAIL [1]") {
|
|
591
|
+
t.Error("RenderEpicDetail tab=0: missing DETAIL [1] indicator")
|
|
592
|
+
}
|
|
593
|
+
if !strings.Contains(got, "AUDIT [2]") {
|
|
594
|
+
t.Error("RenderEpicDetail tab=0: missing AUDIT [2] indicator")
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
func TestRenderEpicDetail_tabIndicatorAuditActive(t *testing.T) {
|
|
599
|
+
got := RenderEpicDetail("E01-test", "content", 60, 40, 0, 1)
|
|
600
|
+
if !strings.Contains(got, "DETAIL [1]") {
|
|
601
|
+
t.Error("RenderEpicDetail tab=1: missing DETAIL [1] indicator")
|
|
602
|
+
}
|
|
603
|
+
if !strings.Contains(got, "AUDIT [2]") {
|
|
604
|
+
t.Error("RenderEpicDetail tab=1: missing AUDIT [2] indicator")
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
func TestRenderEpicAuditTab_header(t *testing.T) {
|
|
609
|
+
got := RenderEpicAuditTab("E06-test", "# Audit\n\n## Main Findings\nAll good.", 60, 40, 0, 1)
|
|
610
|
+
if !strings.Contains(got, "EPIC AUDIT") {
|
|
611
|
+
t.Error("RenderEpicAuditTab missing EPIC AUDIT header")
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
func TestRenderEpicAuditTab_noContent(t *testing.T) {
|
|
616
|
+
got := RenderEpicAuditTab("E06-test", "(no audit available)", 60, 40, 0, 1)
|
|
617
|
+
if !strings.Contains(got, "no audit available") {
|
|
618
|
+
t.Error("RenderEpicAuditTab fallback message missing")
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
func TestRenderEpicAuditTab_emptyContent(t *testing.T) {
|
|
623
|
+
got := RenderEpicAuditTab("E06-test", "", 60, 40, 0, 1)
|
|
624
|
+
if !strings.Contains(got, "no audit available") {
|
|
625
|
+
t.Error("RenderEpicAuditTab empty content should show fallback")
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
func TestRenderEpicAuditTab_stripsFrontmatter(t *testing.T) {
|
|
630
|
+
content := "---\ntype: audit\n---\n# E06 Audit\n\n## Main Findings\nLooks good."
|
|
631
|
+
got := RenderEpicAuditTab("E06-test", content, 60, 40, 0, 1)
|
|
632
|
+
if strings.Contains(got, "type: audit") {
|
|
633
|
+
t.Error("RenderEpicAuditTab should strip frontmatter")
|
|
634
|
+
}
|
|
635
|
+
if !strings.Contains(got, "EPIC AUDIT") {
|
|
636
|
+
t.Error("RenderEpicAuditTab missing header after frontmatter strip")
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
func TestRenderEpicAuditTab_checkboxDonePresent(t *testing.T) {
|
|
641
|
+
content := "## Code Style Review\n- [x] One job per file\n- [ ] One-sentence functions"
|
|
642
|
+
got := RenderEpicAuditTab("E06-test", content, 60, 40, 0, 1)
|
|
643
|
+
if !strings.Contains(got, "One job per file") {
|
|
644
|
+
t.Error("RenderEpicAuditTab missing done checkbox text")
|
|
645
|
+
}
|
|
646
|
+
if !strings.Contains(got, "One-sentence functions") {
|
|
647
|
+
t.Error("RenderEpicAuditTab missing undone checkbox text")
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
func TestRenderEpicAuditTab_scrollFooter(t *testing.T) {
|
|
652
|
+
got := RenderEpicAuditTab("E06-test", "# Audit", 60, 40, 0, 1)
|
|
653
|
+
if !strings.Contains(got, "esc:close") {
|
|
654
|
+
t.Error("RenderEpicAuditTab missing esc:close footer")
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
func TestRenderEpicAuditTab_tabIndicator(t *testing.T) {
|
|
659
|
+
got := RenderEpicAuditTab("E06-test", "# Audit", 60, 40, 0, 1)
|
|
660
|
+
if !strings.Contains(got, "DETAIL [1]") {
|
|
661
|
+
t.Error("RenderEpicAuditTab missing DETAIL [1] indicator")
|
|
662
|
+
}
|
|
663
|
+
if !strings.Contains(got, "AUDIT [2]") {
|
|
664
|
+
t.Error("RenderEpicAuditTab missing AUDIT [2] indicator")
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
func TestRenderEpicAuditTab_mainFindingsVisible(t *testing.T) {
|
|
669
|
+
content := "## Main Findings\nAudit summary is visible.\n\n## Proposed Changes\n### Target File\nAGENTS.md\n"
|
|
670
|
+
got := RenderEpicAuditTab("E06-test", content, 80, 50, 0, 1)
|
|
671
|
+
if !strings.Contains(got, "Audit summary is visible") {
|
|
672
|
+
t.Error("RenderEpicAuditTab should render Main Findings body")
|
|
673
|
+
}
|
|
674
|
+
if strings.Contains(got, "Target File") || strings.Contains(got, "AGENTS.md") {
|
|
675
|
+
t.Error("RenderEpicAuditTab should not render Proposed Changes admin blocks")
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
func TestRenderEpicAuditTab_qualityReviewHidden(t *testing.T) {
|
|
680
|
+
content := "## Quality Review\nOld quality section.\n\n## Code Style Review\n- [ ] One job per file\n"
|
|
681
|
+
got := RenderEpicAuditTab("E06-test", content, 80, 50, 0, 1)
|
|
682
|
+
if strings.Contains(got, "Old quality section") {
|
|
683
|
+
t.Error("RenderEpicAuditTab should not render superseded Quality Review section")
|
|
684
|
+
}
|
|
685
|
+
if !strings.Contains(got, "One job per file") {
|
|
686
|
+
t.Error("RenderEpicAuditTab should render Code Style Review")
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
func TestRenderEpicAuditTab_hiddenHeadingsRequireExactMatch(t *testing.T) {
|
|
691
|
+
content := "## Proposed Changes Appendix\nNear-match section is visible.\n\n## Proposed Changes\nHidden admin section.\n"
|
|
692
|
+
got := RenderEpicAuditTab("E06-test", content, 80, 50, 0, 1)
|
|
693
|
+
if !strings.Contains(got, "Near-match section is visible") {
|
|
694
|
+
t.Error("RenderEpicAuditTab should render headings that only partially match hidden headings")
|
|
695
|
+
}
|
|
696
|
+
if strings.Contains(got, "Hidden admin section") {
|
|
697
|
+
t.Error("RenderEpicAuditTab should hide exact Proposed Changes section")
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
func TestRenderEpicAuditTab_allCodeStyleRules(t *testing.T) {
|
|
702
|
+
rules := []string{
|
|
703
|
+
"One job per file",
|
|
704
|
+
"One-sentence functions",
|
|
705
|
+
"Test branches",
|
|
706
|
+
"Types are documentation",
|
|
707
|
+
"Build, don't speculate",
|
|
708
|
+
"Errors at boundaries",
|
|
709
|
+
"One source of truth",
|
|
710
|
+
"Comments explain WHY",
|
|
711
|
+
"Content in data files",
|
|
712
|
+
"Small diffs",
|
|
713
|
+
}
|
|
714
|
+
content := "## Code Style Review\n"
|
|
715
|
+
for _, r := range rules {
|
|
716
|
+
content += "- [ ] " + r + "\n"
|
|
717
|
+
}
|
|
718
|
+
got := RenderEpicAuditTab("E06-test", content, 80, 50, 0, 1)
|
|
719
|
+
for _, r := range rules {
|
|
720
|
+
if !strings.Contains(got, r) {
|
|
721
|
+
t.Errorf("RenderEpicAuditTab missing code style rule %q", r)
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
// TestView_epicAuditTabRendered verifies View() uses RenderEpicAuditTab when EpicDetailTab=1.
|
|
727
|
+
func TestView_epicAuditTabRendered(t *testing.T) {
|
|
728
|
+
m := NewModel(nil, "v1.1", "E06-audit-command")
|
|
729
|
+
m.Width = 120
|
|
730
|
+
m.Height = 30
|
|
731
|
+
m.Epics = []string{"E06-audit-command"}
|
|
732
|
+
m.Overlay = OverlayEpicDetail
|
|
733
|
+
m.EpicDetailTab = 1
|
|
734
|
+
m.EpicAuditContent = "# Audit Findings: E06\n\n## Main Findings\nAll good.\n\n## Code Style Review\n- [x] One job per file\n"
|
|
735
|
+
|
|
736
|
+
got := m.View()
|
|
737
|
+
if !strings.Contains(got, "EPIC AUDIT") {
|
|
738
|
+
t.Error("View() with EpicDetailTab=1 missing EPIC AUDIT header")
|
|
739
|
+
}
|
|
740
|
+
if strings.Contains(got, "EPIC DETAIL") {
|
|
741
|
+
t.Error("View() with EpicDetailTab=1 should not render EPIC DETAIL header")
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
// TestAuditWorkflow_fullEndToEnd exercises the full audit workflow:
|
|
746
|
+
// create E##-Audit.md on disk, open overlay, press 2, verify content loads and renders.
|
|
747
|
+
func TestAuditWorkflow_fullEndToEnd(t *testing.T) {
|
|
748
|
+
root := t.TempDir()
|
|
749
|
+
epicSlug := "E06-audit-command"
|
|
750
|
+
epicDir := filepath.Join(root, "releases", "v1.1", "epics", epicSlug)
|
|
751
|
+
if err := os.MkdirAll(epicDir, 0755); err != nil {
|
|
752
|
+
t.Fatal(err)
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
auditContent := `---
|
|
756
|
+
type: audit-findings
|
|
757
|
+
audited: 2026-05-03
|
|
758
|
+
---
|
|
759
|
+
# Audit Findings: E06 Agent Audit + Audit Tab
|
|
760
|
+
|
|
761
|
+
## Main Findings
|
|
762
|
+
All acceptance criteria met.
|
|
763
|
+
|
|
764
|
+
## Code Style Review
|
|
765
|
+
- [x] One job per file
|
|
766
|
+
- [x] One-sentence functions
|
|
767
|
+
- [x] Test branches
|
|
768
|
+
- [x] Types are documentation
|
|
769
|
+
- [x] Build, don't speculate
|
|
770
|
+
- [x] Errors at boundaries
|
|
771
|
+
- [x] One source of truth
|
|
772
|
+
- [x] Comments explain WHY
|
|
773
|
+
- [x] Content in data files
|
|
774
|
+
- [x] Small diffs
|
|
775
|
+
`
|
|
776
|
+
if err := os.WriteFile(filepath.Join(epicDir, "E06-Audit.md"), []byte(auditContent), 0644); err != nil {
|
|
777
|
+
t.Fatal(err)
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
tasks := []data.Task{
|
|
781
|
+
{ID: "E06-audit-command/T009-integration", Release: "v1.1", Epic: epicSlug, Column: data.ColumnPlanned},
|
|
782
|
+
}
|
|
783
|
+
m := NewModel(tasks, "v1.1", epicSlug)
|
|
784
|
+
m.Root = root
|
|
785
|
+
m.Epics = []string{epicSlug}
|
|
786
|
+
m.EpicPanelCursor = 0
|
|
787
|
+
m.Width = 120
|
|
788
|
+
m.Height = 40
|
|
789
|
+
|
|
790
|
+
// Open detail overlay (tab=0)
|
|
791
|
+
m.openEpicDetailOverlay()
|
|
792
|
+
if m.Overlay != OverlayEpicDetail {
|
|
793
|
+
t.Fatal("overlay not opened")
|
|
794
|
+
}
|
|
795
|
+
if m.EpicDetailTab != 0 {
|
|
796
|
+
t.Errorf("EpicDetailTab = %d, want 0 on open", m.EpicDetailTab)
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
// Press 2 → switch to audit tab, load content
|
|
800
|
+
got, cmd := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("2")})
|
|
801
|
+
updated := requireModel(t, got)
|
|
802
|
+
|
|
803
|
+
if updated.EpicDetailTab != 1 {
|
|
804
|
+
t.Errorf("EpicDetailTab = %d, want 1 after pressing 2", updated.EpicDetailTab)
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
msg := cmd()
|
|
808
|
+
got2, _ := updated.Update(msg)
|
|
809
|
+
updated2 := requireModel(t, got2)
|
|
810
|
+
if updated2.EpicAuditContent == "" || updated2.EpicAuditContent == "(no audit available)" {
|
|
811
|
+
t.Errorf("EpicAuditContent not loaded: %q", updated2.EpicAuditContent)
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
// Verify View() renders audit content
|
|
815
|
+
view := updated2.View()
|
|
816
|
+
if !strings.Contains(view, "EPIC AUDIT") {
|
|
817
|
+
t.Error("View() after tab switch missing EPIC AUDIT")
|
|
818
|
+
}
|
|
819
|
+
if !strings.Contains(view, "One job per file") {
|
|
820
|
+
t.Error("View() after tab switch missing code style rule")
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
// Press 1 → switch back to detail tab
|
|
824
|
+
got, _ = updated2.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("1")})
|
|
825
|
+
updated = requireModel(t, got)
|
|
826
|
+
if updated.EpicDetailTab != 0 {
|
|
827
|
+
t.Errorf("EpicDetailTab = %d, want 0 after pressing 1", updated.EpicDetailTab)
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
// Press esc → overlay closes
|
|
831
|
+
got, _ = updated.Update(tea.KeyMsg{Type: tea.KeyEsc})
|
|
832
|
+
updated = requireModel(t, got)
|
|
833
|
+
if updated.Overlay != OverlayNone {
|
|
834
|
+
t.Errorf("Overlay = %q, want none after esc", updated.Overlay)
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
func TestRenderEpicAuditTab_v11AuditFiles(t *testing.T) {
|
|
839
|
+
files := []struct {
|
|
840
|
+
path string
|
|
841
|
+
want string
|
|
842
|
+
}{
|
|
843
|
+
{filepath.Join("..", "..", ".savepoint", "releases", "v1.1", "epics", "E02-cross-platform-compatibility", "E02-Audit.md"), "cross-platform build work"},
|
|
844
|
+
{filepath.Join("..", "..", ".savepoint", "releases", "v1.1", "epics", "E03-ui-visual-refinement", "E03-Audit.md"), "visual refinement work"},
|
|
845
|
+
{filepath.Join("..", "..", ".savepoint", "releases", "v1.1", "epics", "E04-epic-navigation", "E04-Audit.md"), "wide-screen epic navigation"},
|
|
846
|
+
{filepath.Join("..", "..", ".savepoint", "releases", "v1.1", "epics", "E05-tasking-permissions", "E05-Audit.md"), "tasking-permissions shift"},
|
|
847
|
+
{filepath.Join("..", "..", ".savepoint", "releases", "v1.1", "epics", "E06-audit-command", "E06-Audit.md"), "agent-led"},
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
for _, tt := range files {
|
|
851
|
+
content, err := os.ReadFile(tt.path)
|
|
852
|
+
if err != nil {
|
|
853
|
+
t.Fatalf("read %s: %v", tt.path, err)
|
|
854
|
+
}
|
|
855
|
+
if !strings.Contains(string(content), tt.want) {
|
|
856
|
+
t.Fatalf("fixture %s missing %q", tt.path, tt.want)
|
|
857
|
+
}
|
|
858
|
+
got := RenderEpicAuditTab(filepath.Base(filepath.Dir(tt.path)), string(content), 80, 40, 0, 1)
|
|
859
|
+
if !strings.Contains(got, tt.want) {
|
|
860
|
+
t.Errorf("RenderEpicAuditTab(%s) missing %q", tt.path, tt.want)
|
|
861
|
+
}
|
|
862
|
+
if strings.Contains(got, "Target File") {
|
|
863
|
+
t.Errorf("RenderEpicAuditTab(%s) should not render Proposed Changes", tt.path)
|
|
864
|
+
}
|
|
865
|
+
if strings.Contains(got, "Boundaries") || strings.Contains(got, "Implemented as") || strings.Contains(got, "Implemented As") {
|
|
866
|
+
t.Errorf("RenderEpicAuditTab(%s) should only render visible audit sections", tt.path)
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
}
|
package/internal/board/help.go
CHANGED
|
@@ -23,6 +23,7 @@ func RenderHelp(width int) string {
|
|
|
23
23
|
helpRow("enter", "open task detail / select item"),
|
|
24
24
|
helpRow("e", "open epic selector on narrow screens"),
|
|
25
25
|
helpRow("r", "open release selector"),
|
|
26
|
+
helpRow("p", "mark focused task as priority"),
|
|
26
27
|
helpRow("up / k", "move selector up"),
|
|
27
28
|
helpRow("down / j", "move selector down"),
|
|
28
29
|
helpRow("?", "open help"),
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
package board
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"os"
|
|
5
|
+
"path/filepath"
|
|
6
|
+
"strings"
|
|
7
|
+
"testing"
|
|
8
|
+
"time"
|
|
9
|
+
|
|
10
|
+
tea "github.com/charmbracelet/bubbletea"
|
|
11
|
+
"github.com/opencode/savepoint/internal/data"
|
|
12
|
+
"github.com/opencode/savepoint/internal/testutil"
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
// writeTaskWithBody creates a task file with a body section to verify content preservation.
|
|
16
|
+
func writeTaskWithBody(t *testing.T, root, release, epic, taskSlug string, column data.ColumnType, body string) string {
|
|
17
|
+
t.Helper()
|
|
18
|
+
tf := testutil.TaskFixture{
|
|
19
|
+
Slug: taskSlug,
|
|
20
|
+
Release: release,
|
|
21
|
+
Status: string(column),
|
|
22
|
+
Objective: "Test task",
|
|
23
|
+
Body: body,
|
|
24
|
+
}
|
|
25
|
+
if column == data.ColumnInProgress {
|
|
26
|
+
tf.Phase = "build"
|
|
27
|
+
}
|
|
28
|
+
testutil.WriteTask(t, root, release, epic, tf)
|
|
29
|
+
return filepath.Join(root, "releases", release, "epics", epic, "tasks", taskSlug+".md")
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// TestBoardPipeline_endToEnd loads a real project from disk and renders the full board view.
|
|
33
|
+
func TestBoardPipeline_endToEnd(t *testing.T) {
|
|
34
|
+
projectRoot := t.TempDir()
|
|
35
|
+
savepointRoot := filepath.Join(projectRoot, ".savepoint")
|
|
36
|
+
testutil.WriteRouter(t, savepointRoot, "task-building", "v1", "E01-init", "", "test")
|
|
37
|
+
writeTask(t, savepointRoot, "v1", "E01-init", "T001-scaffold", data.ColumnPlanned)
|
|
38
|
+
writeTask(t, savepointRoot, "v1", "E01-init", "T002-validate", data.ColumnInProgress)
|
|
39
|
+
writeTask(t, savepointRoot, "v1", "E01-init", "T003-done-task", data.ColumnDone)
|
|
40
|
+
|
|
41
|
+
model, err := newProjectModel(projectRoot, "", "")
|
|
42
|
+
if err != nil {
|
|
43
|
+
t.Fatalf("newProjectModel: %v", err)
|
|
44
|
+
}
|
|
45
|
+
if model.Watcher != nil {
|
|
46
|
+
t.Cleanup(func() { model.Watcher.Close() })
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
model.Width = 120
|
|
50
|
+
model.Height = 40
|
|
51
|
+
view := model.View()
|
|
52
|
+
|
|
53
|
+
for _, want := range []string{"PLANNED", "IN PROGRESS", "DONE", "T001", "T002", "T003"} {
|
|
54
|
+
if !strings.Contains(view, want) {
|
|
55
|
+
t.Errorf("board view missing %q", want)
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// TestRunPlainOutput_endToEnd calls runPlainOutput against a real temp project root.
|
|
61
|
+
func TestRunPlainOutput_endToEnd(t *testing.T) {
|
|
62
|
+
projectRoot := t.TempDir()
|
|
63
|
+
savepointRoot := filepath.Join(projectRoot, ".savepoint")
|
|
64
|
+
testutil.WriteRouter(t, savepointRoot, "task-building", "v1", "E01-init", "", "test")
|
|
65
|
+
writeTask(t, savepointRoot, "v1", "E01-init", "T001-scaffold", data.ColumnPlanned)
|
|
66
|
+
writeTask(t, savepointRoot, "v1", "E01-init", "T002-validate", data.ColumnDone)
|
|
67
|
+
|
|
68
|
+
model, err := newProjectModel(projectRoot, "", "")
|
|
69
|
+
if err != nil {
|
|
70
|
+
t.Fatalf("newProjectModel: %v", err)
|
|
71
|
+
}
|
|
72
|
+
if model.Watcher != nil {
|
|
73
|
+
t.Cleanup(func() { model.Watcher.Close() })
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
out := RenderPlainTable(model)
|
|
77
|
+
|
|
78
|
+
if !strings.Contains(out, plainNonTTYWarning) {
|
|
79
|
+
t.Error("plain output missing non-TTY warning")
|
|
80
|
+
}
|
|
81
|
+
for _, want := range []string{"PLANNED", "DONE", "T001-scaffold", "T002-validate"} {
|
|
82
|
+
if !strings.Contains(out, want) {
|
|
83
|
+
t.Errorf("plain output missing %q", want)
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// TestStatusWrite_preservesTaskBody advances a task via space key and verifies the body text is unchanged.
|
|
89
|
+
func TestStatusWrite_preservesTaskBody(t *testing.T) {
|
|
90
|
+
root := t.TempDir()
|
|
91
|
+
body := "## Acceptance Criteria\n\n- [ ] thing one\n- [ ] thing two\n"
|
|
92
|
+
path := writeTaskWithBody(t, root, "v1", "E01-init", "T001-scaffold", data.ColumnPlanned, body)
|
|
93
|
+
|
|
94
|
+
fi, err := os.Stat(path)
|
|
95
|
+
if err != nil {
|
|
96
|
+
t.Fatal(err)
|
|
97
|
+
}
|
|
98
|
+
task := data.Task{
|
|
99
|
+
ID: "E01-init/T001-scaffold",
|
|
100
|
+
Column: data.ColumnPlanned,
|
|
101
|
+
Path: path,
|
|
102
|
+
Mtime: fi.ModTime(),
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
m := NewModel([]data.Task{task}, "v1", "E01-init")
|
|
106
|
+
m.FocusedColumn = data.ColumnPlanned
|
|
107
|
+
|
|
108
|
+
got, cmd := m.Update(tea.KeyMsg{Type: tea.KeySpace})
|
|
109
|
+
msg := cmd()
|
|
110
|
+
got2, _ := got.Update(msg)
|
|
111
|
+
updated := requireModel(t, got2)
|
|
112
|
+
|
|
113
|
+
if updated.AllTasks[0].Column != data.ColumnInProgress {
|
|
114
|
+
t.Errorf("Column = %q, want in_progress", updated.AllTasks[0].Column)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
raw, err := os.ReadFile(path)
|
|
118
|
+
if err != nil {
|
|
119
|
+
t.Fatal(err)
|
|
120
|
+
}
|
|
121
|
+
if !strings.Contains(string(raw), body) {
|
|
122
|
+
t.Errorf("task body was altered after status write; got:\n%s", raw)
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// TestMtimeConflict_directDetection verifies WriteTaskStatus returns ErrMtimeConflict on mtime mismatch.
|
|
127
|
+
func TestMtimeConflict_directDetection(t *testing.T) {
|
|
128
|
+
dir := t.TempDir()
|
|
129
|
+
path := filepath.Join(dir, "T001.md")
|
|
130
|
+
content := "---\nid: E01/T001\nstatus: planned\nphase: build\n---\n\n# Task\n"
|
|
131
|
+
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
|
|
132
|
+
t.Fatal(err)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
task := &data.Task{
|
|
136
|
+
ID: "E01/T001",
|
|
137
|
+
Column: data.ColumnInProgress,
|
|
138
|
+
Stage: data.StageBuild,
|
|
139
|
+
}
|
|
140
|
+
staleTime := time.Now().Add(-time.Hour)
|
|
141
|
+
err := data.WriteTaskStatus(path, task, staleTime)
|
|
142
|
+
if err != data.ErrMtimeConflict {
|
|
143
|
+
t.Errorf("WriteTaskStatus with stale mtime = %v, want ErrMtimeConflict", err)
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// TestMtimeConflict_boardWarns verifies the board surfaces an mtime conflict instead of overwriting external edits.
|
|
148
|
+
func TestMtimeConflict_boardWarns(t *testing.T) {
|
|
149
|
+
path := filepath.Join(t.TempDir(), "T001.md")
|
|
150
|
+
content := "---\nid: E01/T001\nstatus: in_progress\nphase: build\n---\n\n# Task\n"
|
|
151
|
+
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
|
|
152
|
+
t.Fatal(err)
|
|
153
|
+
}
|
|
154
|
+
fi, err := os.Stat(path)
|
|
155
|
+
if err != nil {
|
|
156
|
+
t.Fatal(err)
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
task := data.Task{
|
|
160
|
+
ID: "E01/T001",
|
|
161
|
+
Column: data.ColumnInProgress,
|
|
162
|
+
Stage: data.StageBuild,
|
|
163
|
+
Path: path,
|
|
164
|
+
Mtime: fi.ModTime().Add(-time.Minute), // intentionally stale
|
|
165
|
+
}
|
|
166
|
+
m := NewModel([]data.Task{task}, "v1", "E01")
|
|
167
|
+
m.FocusedColumn = data.ColumnInProgress
|
|
168
|
+
|
|
169
|
+
got, cmd := m.Update(tea.KeyMsg{Type: tea.KeySpace})
|
|
170
|
+
msg := cmd()
|
|
171
|
+
got2, _ := got.Update(msg)
|
|
172
|
+
updated := requireModel(t, got2)
|
|
173
|
+
|
|
174
|
+
if !strings.Contains(updated.StatusMessage, "mtime conflict") {
|
|
175
|
+
t.Errorf("StatusMessage = %q, want mtime conflict warning", updated.StatusMessage)
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
raw, err := os.ReadFile(path)
|
|
179
|
+
if err != nil {
|
|
180
|
+
t.Fatal(err)
|
|
181
|
+
}
|
|
182
|
+
if !strings.Contains(string(raw), "phase: build") {
|
|
183
|
+
t.Errorf("task file was overwritten despite mtime conflict:\n%s", raw)
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// TestReleaseFilter_showsOnlyMatchingRelease verifies the --release flag filters tasks.
|
|
188
|
+
func TestReleaseFilter_showsOnlyMatchingRelease(t *testing.T) {
|
|
189
|
+
projectRoot := t.TempDir()
|
|
190
|
+
savepointRoot := filepath.Join(projectRoot, ".savepoint")
|
|
191
|
+
testutil.WriteRouter(t, savepointRoot, "task-building", "v1", "E01-init", "", "test")
|
|
192
|
+
writeTask(t, savepointRoot, "v1", "E01-init", "T001-v1-task", data.ColumnPlanned)
|
|
193
|
+
writeTask(t, savepointRoot, "v2", "E01-init", "T001-v2-task", data.ColumnPlanned)
|
|
194
|
+
|
|
195
|
+
model, err := newProjectModel(projectRoot, "v2", "")
|
|
196
|
+
if err != nil {
|
|
197
|
+
t.Fatalf("newProjectModel: %v", err)
|
|
198
|
+
}
|
|
199
|
+
if model.Watcher != nil {
|
|
200
|
+
t.Cleanup(func() { model.Watcher.Close() })
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
if model.SelectedRelease != "v2" {
|
|
204
|
+
t.Errorf("SelectedRelease = %q, want v2", model.SelectedRelease)
|
|
205
|
+
}
|
|
206
|
+
planned := model.Tasks[data.ColumnPlanned]
|
|
207
|
+
for _, task := range planned {
|
|
208
|
+
if task.Release != "v2" {
|
|
209
|
+
t.Errorf("task %q has release %q, want v2 only", task.ID, task.Release)
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// TestEpicFilter_showsOnlyMatchingEpic verifies the --epic flag filters tasks.
|
|
215
|
+
func TestEpicFilter_showsOnlyMatchingEpic(t *testing.T) {
|
|
216
|
+
projectRoot := t.TempDir()
|
|
217
|
+
savepointRoot := filepath.Join(projectRoot, ".savepoint")
|
|
218
|
+
testutil.WriteRouter(t, savepointRoot, "task-building", "v1", "E01-init", "", "test")
|
|
219
|
+
writeTask(t, savepointRoot, "v1", "E01-init", "T001-alpha", data.ColumnPlanned)
|
|
220
|
+
writeTask(t, savepointRoot, "v1", "E02-build", "T001-beta", data.ColumnPlanned)
|
|
221
|
+
|
|
222
|
+
model, err := newProjectModel(projectRoot, "v1", "E02-build")
|
|
223
|
+
if err != nil {
|
|
224
|
+
t.Fatalf("newProjectModel: %v", err)
|
|
225
|
+
}
|
|
226
|
+
if model.Watcher != nil {
|
|
227
|
+
t.Cleanup(func() { model.Watcher.Close() })
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
if model.SelectedEpic != "E02-build" {
|
|
231
|
+
t.Errorf("SelectedEpic = %q, want E02-build", model.SelectedEpic)
|
|
232
|
+
}
|
|
233
|
+
planned := model.Tasks[data.ColumnPlanned]
|
|
234
|
+
for _, task := range planned {
|
|
235
|
+
if task.Epic != "E02-build" {
|
|
236
|
+
t.Errorf("task %q has epic %q, want E02-build only", task.ID, task.Epic)
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// TestDetailPane_opensOnEnter verifies enter key opens the detail overlay.
|
|
242
|
+
func TestDetailPane_opensOnEnter(t *testing.T) {
|
|
243
|
+
tasks := []data.Task{{ID: "E01/T001", Title: "Scaffold init", Column: data.ColumnPlanned}}
|
|
244
|
+
m := NewModel(tasks, "v1", "E01")
|
|
245
|
+
m.FocusedColumn = data.ColumnPlanned
|
|
246
|
+
|
|
247
|
+
got, _ := m.Update(tea.KeyMsg{Type: tea.KeyEnter})
|
|
248
|
+
updated := requireModel(t, got)
|
|
249
|
+
|
|
250
|
+
if updated.Overlay != OverlayDetail {
|
|
251
|
+
t.Errorf("Overlay = %q, want %q", updated.Overlay, OverlayDetail)
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// TestDetailPane_escClosesOverlay verifies esc dismisses the detail overlay.
|
|
256
|
+
func TestDetailPane_escClosesOverlay(t *testing.T) {
|
|
257
|
+
m := NewModel(nil, "v1", "E01")
|
|
258
|
+
m.Overlay = OverlayDetail
|
|
259
|
+
|
|
260
|
+
got, _ := m.Update(tea.KeyMsg{Type: tea.KeyEsc})
|
|
261
|
+
updated := requireModel(t, got)
|
|
262
|
+
|
|
263
|
+
if updated.Overlay != OverlayNone {
|
|
264
|
+
t.Errorf("Overlay = %q after esc, want none", updated.Overlay)
|
|
265
|
+
}
|
|
266
|
+
}
|