Files
felixm 682eb603fa 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>
2026-06-05 20:32:24 -04:00

96 lines
2.6 KiB
Go

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)
}
}