13633ffabf
Three independent defects made the focus state feel flaky and let the OS
status bar disagree with the web UI:
- Status file lagged the web by up to a minute: it rendered only on a 60s
ticker with no hook into state changes. notify() now fans out to multiple
listeners (AddOnChange) and the writer has a coalesced Wake() so drift
reaches the bar as promptly as the browser.
- A brief off-task visit could latch drift for the whole session. Sibling
windows of one app (a browser's reading tab vs its chat tab) share a window
class, but the judge verdict was cached by class alone, so one tab's verdict
poisoned the rest and never re-judged. Cache is now keyed by class + scrubbed
title (judgedWindows) so siblings are judged independently.
- Allowed-class matching was exact equality, so a short token ("brave") never
matched the real WM_CLASS ("Brave-browser") and every window was routed to
the LLM. Matching is now substring-based, and planning surfaces the live
window class with a click-to-add chip so users pick a token that matches.
Also fix the planning checkbox alignment: the global full-width input rule was
stretching the "Enforce focus" checkbox; scope it out for checkboxes.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
157 lines
4.6 KiB
Go
157 lines
4.6 KiB
Go
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")
|
|
}
|