summaryrefslogtreecommitdiff
path: root/internal/store/chains_test.go
diff options
context:
space:
mode:
authorPeter Stone <thepeterstone@gmail.com>2026-07-18 00:14:45 +0000
committerPeter Stone <thepeterstone@gmail.com>2026-07-18 00:14:45 +0000
commitf08f06bef47aac2c9effb4cec650d99c2deb2dd7 (patch)
treeae06bd2e140c678e67f2879f21b07a4114a41f73 /internal/store/chains_test.go
parentbe4d606e9a1f5b068abcc21bbac58d1e4705ea1f (diff)
Rework Tasks tab: Chains section, checklist modal, project visibility
The flat Tasks-tab atom list was silently dumping every chain step (locked and unlocked) and dormant bucket-pool items in as ordinary undated cards, with no chain/project context and no protection against completing a locked step out of order. - CompleteNativeTask now rejects completing a locked chain task (ErrChainTaskLocked), mapped to 400 in both the widget and web complete-atom handlers. - Chain tasks and dormant bucket items are excluded from the flat atom list; a new "Chains" section shows one card per active/paused chain with the current step and N/M progress. - New chain checklist modal (GET /chains/{id}) lists every position in order with pause/resume/abandon -- the web view originally deferred as Android-only. - Fixed a real bug this surfaced: resuming a paused chain only flipped the status flag, never unlocking the deferred successor, so a chain paused right after a completion stayed stuck forever. SetChainStatus now catches up the deferred advancement on resume, idempotently. - Atom cards gained a project-name chip for general visibility. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EZ7ikw2ukGJFTHE3bJS7zL
Diffstat (limited to 'internal/store/chains_test.go')
-rw-r--r--internal/store/chains_test.go68
1 files changed, 67 insertions, 1 deletions
diff --git a/internal/store/chains_test.go b/internal/store/chains_test.go
index 1d16508..6359d11 100644
--- a/internal/store/chains_test.go
+++ b/internal/store/chains_test.go
@@ -132,10 +132,19 @@ func TestCompleteNativeTask_PausedChain_DoesNotAdvance(t *testing.T) {
t.Error("position 1 should still be locked while chain is paused")
}
- // Resuming re-enables advancement on the *next* completion.
+ // Resuming performs the deferred unlock itself -- position 1 becomes
+ // completable immediately, not only after some future completion.
if err := s.SetChainStatus(chain.ID, "active"); err != nil {
t.Fatal(err)
}
+ resumed, err := s.GetChainTasks(chain.ID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !resumed[1].ChainUnlocked {
+ t.Fatal("expected resume to unlock position 1 immediately (deferred advancement catch-up)")
+ }
+
if err := s.CompleteNativeTask(tasks[1].ID); err != nil {
t.Fatalf("CompleteNativeTask after resume: %v", err)
}
@@ -148,6 +157,63 @@ func TestCompleteNativeTask_PausedChain_DoesNotAdvance(t *testing.T) {
}
}
+func TestCompleteNativeTask_LockedChainTask_ReturnsErrChainTaskLocked(t *testing.T) {
+ s := newNativeTasksTestStore(t)
+
+ chain, err := s.CreateChain("Track", chainTasks("Step 1", "Step 2"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ tasks, err := s.GetChainTasks(chain.ID)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ if err := s.CompleteNativeTask(tasks[1].ID); err != ErrChainTaskLocked {
+ t.Errorf("err = %v, want ErrChainTaskLocked", err)
+ }
+
+ // Confirm nothing was mutated -- still locked, still not completed.
+ after, err := s.GetChainTasks(chain.ID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if after[1].Completed || after[1].ChainUnlocked {
+ t.Errorf("locked task should be untouched by the rejected completion attempt: %+v", after[1])
+ }
+}
+
+func TestSetChainStatus_ResumeWithNothingStuck_DoesNotResetDueDate(t *testing.T) {
+ s := newNativeTasksTestStore(t)
+
+ chain, err := s.CreateChain("Track", chainTasks("Step 1", "Step 2"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ tasks, err := s.GetChainTasks(chain.ID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ originalDueDate := *tasks[0].DueDate
+
+ // Pause and resume with nothing completed yet -- position 0 is already
+ // unlocked and should be left untouched by the resume catch-up.
+ if err := s.SetChainStatus(chain.ID, "paused"); err != nil {
+ t.Fatal(err)
+ }
+ if err := s.SetChainStatus(chain.ID, "active"); err != nil {
+ t.Fatal(err)
+ }
+
+ after, err := s.GetChainTasks(chain.ID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !after[0].DueDate.Equal(originalDueDate) {
+ t.Errorf("DueDate = %v, want unchanged %v (resume catch-up should be a no-op when nothing was stuck)", after[0].DueDate, originalDueDate)
+ }
+}
+
func TestGetUndatedNativeTasks_ExcludesLockedChainTasks(t *testing.T) {
s := newNativeTasksTestStore(t)