682eb603fa
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>
96 lines
2.6 KiB
Go
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)
|
|
}
|
|
}
|