package statusfile import ( "context" "os" "path/filepath" "sync" "testing" "time" "antidrift/internal/domain" "antidrift/internal/session" ) func activeState(deadline int64, driftStatus, nudge string) session.State { st := session.State{ RuntimeState: domain.RuntimeActive, Commitment: &session.CommitmentView{DeadlineUnixSecs: deadline}, } if driftStatus != "" || nudge != "" { st.Drift = &session.DriftView{Status: driftStatus, Nudge: nudge} } return st } func TestRender(t *testing.T) { now := time.Unix(1_000_000, 0) in24m := now.Add(24 * time.Minute).Unix() cases := []struct { name string st session.State want string }{ {"locked", session.State{RuntimeState: domain.RuntimeLocked}, ""}, {"empty runtime", session.State{}, ""}, {"planning", session.State{RuntimeState: domain.RuntimePlanning}, "◔ planning"}, {"review", session.State{RuntimeState: domain.RuntimeReview}, "✓ review"}, {"active ontask", activeState(in24m, "ontask", ""), "● 24m"}, {"active no drift view", activeState(in24m, "", ""), "● 24m"}, {"active drifting", activeState(in24m, "drifting", ""), "⚠ DRIFT 24m"}, {"active nudge", activeState(in24m, "ontask", "wandered off"), "● 24m ·?"}, {"drift outranks nudge", activeState(in24m, "drifting", "wandered off"), "⚠ DRIFT 24m"}, {"active no deadline", session.State{RuntimeState: domain.RuntimeActive}, "● 0m"}, {"active past deadline", activeState(now.Add(-5*time.Minute).Unix(), "ontask", ""), "● 0m"}, {"active partial minute rounds up", activeState(now.Add(10*time.Second).Unix(), "ontask", ""), "● 1m"}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { if got := Render(tc.st, now); got != tc.want { t.Errorf("Render() = %q, want %q", got, tc.want) } }) } } func TestWriterWritesAndDedups(t *testing.T) { path := filepath.Join(t.TempDir(), "status") now := time.Unix(1_000_000, 0) state := session.State{RuntimeState: domain.RuntimePlanning} w := NewWriter(path, func() session.State { return state }) w.now = func() time.Time { return now } w.write() if got := readFile(t, path); got != "◔ planning" { t.Fatalf("first write = %q", got) } // Same state: file untouched. Detect by clearing it and confirming the // deduped write does not recreate content. if err := os.WriteFile(path, []byte("sentinel"), 0o644); err != nil { t.Fatal(err) } w.write() if got := readFile(t, path); got != "sentinel" { t.Errorf("deduped write should not rewrite, got %q", got) } // Changed state: rewrites. state = session.State{RuntimeState: domain.RuntimeLocked} w.write() if got := readFile(t, path); got != "" { t.Errorf("changed write = %q, want empty", got) } } func TestWriterRemovesFileOnCancel(t *testing.T) { path := filepath.Join(t.TempDir(), "status") w := NewWriter(path, func() session.State { return session.State{RuntimeState: domain.RuntimePlanning} }) ctx, cancel := context.WithCancel(context.Background()) done := make(chan struct{}) go func() { w.Run(ctx); close(done) }() // Wait for the immediate write. waitFor(t, func() bool { _, err := os.Stat(path); return err == nil }) cancel() <-done if _, err := os.Stat(path); !os.IsNotExist(err) { t.Errorf("file should be removed on cancel, stat err = %v", err) } } // Wake makes the writer re-render on a state change instead of waiting for the // coarse minute tick, so the status bar tracks drift transitions promptly // instead of lagging the web UI by up to a minute. func TestWriterWakeRendersPromptly(t *testing.T) { path := filepath.Join(t.TempDir(), "status") now := time.Unix(1_000_000, 0) in10m := now.Add(10 * time.Minute).Unix() var mu sync.Mutex st := activeState(in10m, "ontask", "") w := NewWriter(path, func() session.State { mu.Lock(); defer mu.Unlock(); return st }) w.now = func() time.Time { return now } ctx, cancel := context.WithCancel(context.Background()) defer cancel() go w.Run(ctx) waitFor(t, func() bool { return fileEquals(path, "● 10m") }) mu.Lock() st = activeState(in10m, "drifting", "") mu.Unlock() w.Wake() waitFor(t, func() bool { return fileEquals(path, "⚠ DRIFT 10m") }) } func fileEquals(path, want string) bool { b, err := os.ReadFile(path) return err == nil && string(b) == want } func readFile(t *testing.T, path string) string { t.Helper() b, err := os.ReadFile(path) if err != nil { t.Fatal(err) } return string(b) } func waitFor(t *testing.T, cond func() bool) { t.Helper() deadline := time.Now().Add(2 * time.Second) for time.Now().Before(deadline) { if cond() { return } time.Sleep(5 * time.Millisecond) } t.Fatal("condition not met within timeout") }