package autotrack import ( "testing" "time" ) func TestDomain(t *testing.T) { cases := []struct{ url, want string }{ {"https://www.github.com/foo", "github.com"}, {"https://mail.google.com/mail", "mail.google.com"}, {"HTTPS://WWW.Example.COM/x", "example.com"}, // lowercased, www stripped {"", ""}, {"://not-a-url", ""}, } for _, c := range cases { if got := Domain(c.url); got != c.want { t.Errorf("Domain(%q)=%q, want %q", c.url, got, c.want) } } } func TestClassify(t *testing.T) { active := &Ctx{App: "Trace", Title: "doc"} away := &Ctx{App: "loginwindow"} if got := Classify(0, active, 900, true); got == nil || !got.Idle { t.Errorf("locked ⇒ idle, got %+v", got) } if got := Classify(0, away, 900, false); got == nil || !got.Idle { t.Errorf("away app ⇒ idle, got %+v", got) } if got := Classify(900, active, 900, false); got == nil || !got.Idle { t.Errorf("idle >= grace ⇒ idle, got %+v", got) } if got := Classify(899, active, 900, false); got != active { t.Errorf("present-but-passive under grace passes through, got %+v", got) } if got := Classify(0, nil, 900, false); got != nil { t.Errorf("no foreground app, not locked ⇒ nil, got %+v", got) } if got := Classify(0, nil, 900, true); got == nil || !got.Idle { t.Errorf("locked with nil ctx still idle, got %+v", got) } } func TestCtxKey(t *testing.T) { if CtxKey(nil) != (Key{}) { t.Error("nil ctx should give zero key") } idle1, idle2 := CtxKey(&Ctx{Idle: true}), CtxKey(&Ctx{Idle: true, App: "x"}) if idle1 != idle2 { t.Error("idle is its own key regardless of app") } browser := CtxKey(&Ctx{App: "Safari", Title: "GitHub", Domain: "github.com"}) if browser != (Key{Valid: true, App: "Safari", Rest: "github.com"}) { t.Errorf("browser key should use domain, got %+v", browser) } editor := CtxKey(&Ctx{App: "Trace", Title: "notes.md"}) if editor != (Key{Valid: true, App: "Trace", Rest: "notes.md"}) { t.Errorf("non-browser key should use title, got %+v", editor) } if CtxKey(&Ctx{App: "Trace", Title: "a"}) == CtxKey(&Ctx{App: "Trace", Title: "b"}) { t.Error("different titles should be different activities") } } func tt3(h, m, s int) time.Time { return time.Date(2026, 7, 7, h, m, s, 0, time.Local) } func TestStepSameContextExtends(t *testing.T) { ctx := &Ctx{App: "Trace", Title: "doc"} cur := &Segment{Ctx: *ctx, Start: tt3(9, 0, 0)} now := tt3(9, 0, 5) got, key, emitted := Step(cur, CtxKey(ctx), now, 0, ctx, 900, false) if emitted != nil { t.Fatalf("same context should not emit, got %+v", emitted) } if got == nil || !got.End.Equal(now) || !got.Start.Equal(tt3(9, 0, 0)) { t.Fatalf("open segment should extend to now, got %+v", got) } if key != CtxKey(ctx) { t.Fatalf("key should be unchanged") } } func TestStepContextChangeEmitsAndOpens(t *testing.T) { a := &Ctx{App: "Trace", Title: "doc"} b := &Ctx{App: "Safari", Title: "GitHub", Domain: "github.com"} cur := &Segment{Ctx: *a, Start: tt3(9, 0, 0)} now := tt3(9, 5, 0) got, key, emitted := Step(cur, CtxKey(a), now, 0, b, 900, false) if emitted == nil || emitted.App != "Trace" || !emitted.End.Equal(now) { t.Fatalf("context change should close old segment at now, got %+v", emitted) } if got == nil || got.App != "Safari" || !got.Start.Equal(now) { t.Fatalf("new segment should open at now, got %+v", got) } if key != CtxKey(b) { t.Fatalf("key should follow new ctx") } } func TestStepSoftIdleCreditsGrace(t *testing.T) { a := &Ctx{App: "Trace", Title: "doc"} cur := &Segment{Ctx: *a, Start: tt3(9, 0, 0)} now := tt3(10, 0, 0) // 1000s HID gap, 900s grace, soft ⇒ boundary = now - (1000-900) = now-100s got, _, emitted := Step(cur, CtxKey(a), now, 1000, &Ctx{Idle: true}, 900, false) wantBoundary := now.Add(-100 * time.Second) if emitted == nil || !emitted.End.Equal(wantBoundary) { t.Fatalf("soft idle should close at now-(idle-grace), got %+v", emitted) } if got == nil || !got.Idle || !got.Start.Equal(wantBoundary) { t.Fatalf("idle segment should open at boundary, got %+v", got) } } func TestStepHardIdleSnapsToLastInputAndClamps(t *testing.T) { a := &Ctx{App: "Trace", Title: "doc"} // segment opened 500s ago; idle says last input was 1000s ago ⇒ clamp to Start now := tt3(10, 0, 0) cur := &Segment{Ctx: *a, Start: now.Add(-500 * time.Second)} got, _, emitted := Step(cur, CtxKey(a), now, 1000, &Ctx{Idle: true}, 900, true) if emitted == nil || !emitted.End.Equal(cur.Start) { t.Fatalf("hard-idle boundary must clamp to cur.Start, got %+v", emitted) } if got == nil || !got.Start.Equal(cur.Start) { t.Fatalf("idle segment starts at clamped boundary, got %+v", got) } } func TestStepHardIdleUnclamped(t *testing.T) { a := &Ctx{App: "Trace", Title: "doc"} now := tt3(10, 0, 0) cur := &Segment{Ctx: *a, Start: now.Add(-2000 * time.Second)} // hard idle, 1000s since last input ⇒ boundary = now-1000s (inside segment) _, _, emitted := Step(cur, CtxKey(a), now, 1000, &Ctx{Idle: true}, 900, true) if emitted == nil || !emitted.End.Equal(now.Add(-1000*time.Second)) { t.Fatalf("hard idle should close at now-idle, got %+v", emitted) } } func TestStepNilCtxClosesWithoutOpening(t *testing.T) { a := &Ctx{App: "Trace", Title: "doc"} cur := &Segment{Ctx: *a, Start: tt3(9, 0, 0)} now := tt3(9, 5, 0) got, key, emitted := Step(cur, CtxKey(a), now, 0, nil, 900, false) if emitted == nil || !emitted.End.Equal(now) { t.Fatalf("nil ctx should close open segment at now, got %+v", emitted) } if got != nil || key != (Key{}) { t.Fatalf("nothing should be open after nil ctx, got %+v / %+v", got, key) } } func TestStepFromNothingOpensWithoutEmitting(t *testing.T) { a := &Ctx{App: "Trace", Title: "doc"} now := tt3(9, 0, 0) got, key, emitted := Step(nil, Key{}, now, 0, a, 900, false) if emitted != nil { t.Fatalf("nothing to close, got %+v", emitted) } if got == nil || got.App != "Trace" || !got.Start.Equal(now) || key != CtxKey(a) { t.Fatalf("should open new segment at now, got %+v", got) } }