▍ humdrum codex / ticktock v0.0.2
license AGPL-3.0
5.9 KB raw
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
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)
	}
}