fix(evidence): X11 sensor polls so tab/title changes are tracked
The X11 source only re-read the active window on _NET_ACTIVE_WINDOW changes, so switching a browser tab — which changes the window title but not the active window — was invisible. All the time on the new tab was credited to the stale title (e.g. reading a Consume article showed up as time on the Keel tab). Mirror the Windows sensor: poll the active window every 750ms and emit only when the window or its title changes. A shared, display-free pollLoop carries the change-dedup and is unit-tested for the exact regression (a title-only change must emit). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,95 @@
|
||||
package evidence
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestPollLoopEmitsOnTitleChangeWithinSameWindow is the regression for the bug
|
||||
// where the X11 sensor only re-read the window on _NET_ACTIVE_WINDOW changes and
|
||||
// so missed a browser tab switch — same window class, new title — crediting all
|
||||
// the time to the stale title. The poll loop must emit when only the title
|
||||
// changes.
|
||||
func TestPollLoopEmitsOnTitleChangeWithinSameWindow(t *testing.T) {
|
||||
// Same window (class) throughout; the title changes mid-stream, like a tab
|
||||
// switch. The repeated identical reads must NOT re-emit; the title change must.
|
||||
titles := []string{"Keel - Brave", "Keel - Brave", "Consume - Brave", "Consume - Brave"}
|
||||
var i int
|
||||
read := func() WindowSnapshot {
|
||||
idx := i
|
||||
if idx >= len(titles) {
|
||||
idx = len(titles) - 1
|
||||
}
|
||||
i++
|
||||
return WindowSnapshot{Class: "Brave-browser", Title: titles[idx], Health: EvidenceHealth{Available: true}}
|
||||
}
|
||||
|
||||
var mu sync.Mutex
|
||||
var got []WindowSnapshot
|
||||
onChange := func(s WindowSnapshot) {
|
||||
mu.Lock()
|
||||
got = append(got, s)
|
||||
mu.Unlock()
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
done := make(chan struct{})
|
||||
go func() { pollLoop(ctx, time.Millisecond, read, onChange); close(done) }()
|
||||
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for {
|
||||
mu.Lock()
|
||||
n := len(got)
|
||||
mu.Unlock()
|
||||
if n >= 2 {
|
||||
break
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
cancel()
|
||||
<-done
|
||||
t.Fatalf("expected >=2 emits (one per distinct title), got %d", n)
|
||||
}
|
||||
time.Sleep(time.Millisecond)
|
||||
}
|
||||
cancel()
|
||||
<-done
|
||||
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if got[0].Title != "Keel - Brave" {
|
||||
t.Fatalf("first emit title = %q, want %q", got[0].Title, "Keel - Brave")
|
||||
}
|
||||
if got[1].Title != "Consume - Brave" {
|
||||
t.Fatalf("second emit title = %q, want %q (a title-only change must emit)", got[1].Title, "Consume - Brave")
|
||||
}
|
||||
}
|
||||
|
||||
// TestPollLoopDedupesIdenticalObservations confirms an unchanged observation is
|
||||
// not re-emitted, so redundant samples never churn the consumer.
|
||||
func TestPollLoopDedupesIdenticalObservations(t *testing.T) {
|
||||
read := func() WindowSnapshot {
|
||||
return WindowSnapshot{Class: "code", Title: "main.go", Health: EvidenceHealth{Available: true}}
|
||||
}
|
||||
var mu sync.Mutex
|
||||
var n int
|
||||
onChange := func(WindowSnapshot) {
|
||||
mu.Lock()
|
||||
n++
|
||||
mu.Unlock()
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
done := make(chan struct{})
|
||||
go func() { pollLoop(ctx, time.Millisecond, read, onChange); close(done) }()
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
cancel()
|
||||
<-done
|
||||
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if n != 1 {
|
||||
t.Fatalf("identical observations must emit once, got %d emits", n)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user