feat(04-01): integrate notifications and focus mode into daemon

- Notifier + FocusTimer fields in Daemon, initialized in NewDaemon
- processHookEvent notifies on Working -> Needs Input only (D-01)
- Focus mode suppresses notifications when active (D-05)
- FocusArgs in protocol, "focus" handler in daemon
- CLI "vmux focus <minutes>" command
- Tests for all notification transitions and focus handler
This commit is contained in:
Pierre Martin
2026-03-23 21:26:02 +01:00
parent b96c6d05be
commit efbe31928e
5 changed files with 183 additions and 3 deletions

View File

@@ -432,6 +432,122 @@ func TestDaemonLabelOverSocket(t *testing.T) {
}
}
// spyNotifier records calls to Notify for test assertions.
type spyNotifier struct {
calls []struct{ title, body string }
}
func (s *spyNotifier) Notify(title, body string) error {
s.calls = append(s.calls, struct{ title, body string }{title, body})
return nil
}
func TestNotification_WorkingToNeedsInput(t *testing.T) {
d := newTestDaemon(t)
spy := &spyNotifier{}
d.notifier = spy
// Seed session as Working
d.registry.UpdateFromHook("sess-1", "Working", "", "/tmp/proj")
// Transition to Needs Input
d.processHookEvent(HookEvent{
SessionID: "sess-1",
HookEventName: "Stop",
Cwd: "/tmp/proj",
})
if len(spy.calls) != 1 {
t.Fatalf("notify calls = %d, want 1", len(spy.calls))
}
if spy.calls[0].title != "vmux: proj" {
t.Errorf("title = %q, want %q", spy.calls[0].title, "vmux: proj")
}
}
func TestNotification_IdleToNeedsInput(t *testing.T) {
d := newTestDaemon(t)
spy := &spyNotifier{}
d.notifier = spy
// Seed session as Idle (not Working)
d.registry.UpdateFromHook("sess-1", "Idle", "", "/tmp/proj")
// Transition to Needs Input from Idle
d.processHookEvent(HookEvent{
SessionID: "sess-1",
HookEventName: "Stop",
Cwd: "/tmp/proj",
})
if len(spy.calls) != 0 {
t.Errorf("notify calls = %d, want 0 (Idle -> Needs Input should not notify)", len(spy.calls))
}
}
func TestNotification_FocusActive(t *testing.T) {
d := newTestDaemon(t)
spy := &spyNotifier{}
d.notifier = spy
d.focus.Set(30 * time.Minute)
// Seed session as Working
d.registry.UpdateFromHook("sess-1", "Working", "", "/tmp/proj")
// Transition to Needs Input (but focus active)
d.processHookEvent(HookEvent{
SessionID: "sess-1",
HookEventName: "Stop",
Cwd: "/tmp/proj",
})
if len(spy.calls) != 0 {
t.Errorf("notify calls = %d, want 0 (focus active should suppress)", len(spy.calls))
}
}
func TestNotification_FocusExpired(t *testing.T) {
d := newTestDaemon(t)
spy := &spyNotifier{}
d.notifier = spy
d.focus.Set(0) // expired immediately
// Seed session as Working
d.registry.UpdateFromHook("sess-1", "Working", "", "/tmp/proj")
// Transition to Needs Input (focus expired)
d.processHookEvent(HookEvent{
SessionID: "sess-1",
HookEventName: "Stop",
Cwd: "/tmp/proj",
})
if len(spy.calls) != 1 {
t.Fatalf("notify calls = %d, want 1 (focus expired should notify)", len(spy.calls))
}
}
func TestFocusHandler(t *testing.T) {
d := newTestDaemon(t)
if err := d.Start(); err != nil {
t.Fatalf("start: %v", err)
}
defer d.Stop()
args, _ := json.Marshal(FocusArgs{Minutes: 30})
resp := sendRequest(t, d.sockPath, Request{Action: "focus", Args: args})
if !resp.OK {
t.Fatalf("focus resp.OK = false, error = %q", resp.Error)
}
if !d.focus.IsActive() {
t.Error("focus should be active after handler")
}
if resp.FocusRemaining <= 0 {
t.Errorf("FocusRemaining = %v, want > 0", resp.FocusRemaining)
}
}
func TestDaemonUnknownAction(t *testing.T) {
d := newTestDaemon(t)