diff --git a/analytics.go b/analytics.go
index 40b4d14..aeb501b 100644
--- a/analytics.go
+++ b/analytics.go
@@ -6,7 +6,7 @@ import (
"slices" // Note: intrinsic — slices.Sorted orders analytics rows deterministically; no core equivalent
"time" // Note: intrinsic — time.Duration arithmetic for session, active-time, and latency metrics; no core equivalent
- core "dappco.re/go/core"
+ core "dappco.re/go"
)
// SessionAnalytics holds computed metrics for a parsed session.
diff --git a/ax7_test.go b/ax7_test.go
new file mode 100644
index 0000000..c9e6dee
--- /dev/null
+++ b/ax7_test.go
@@ -0,0 +1,537 @@
+// SPDX-Licence-Identifier: EUPL-1.2
+package session
+
+import (
+ "bytes"
+ "time"
+
+ . "dappco.re/go"
+)
+
+// --- Analyse ---
+
+func TestAX7_Analyse_Good(t *T) {
+ sess := &Session{Events: []Event{{Type: "tool_use", Tool: "Bash", Duration: 2 * time.Second, Success: true}}}
+ a := Analyse(sess)
+
+ AssertEqual(t, 1, a.EventCount)
+ AssertEqual(t, 1.0, a.SuccessRate)
+ AssertEqual(t, 2*time.Second, a.AvgLatency["Bash"])
+}
+
+func TestAX7_Analyse_Bad(t *T) {
+ a := Analyse(nil)
+
+ AssertNotNil(t, a)
+ AssertEqual(t, 0, a.EventCount)
+ AssertEmpty(t, a.ToolCounts)
+}
+
+func TestAX7_Analyse_Ugly(t *T) {
+ sess := &Session{Events: []Event{{Type: "tool_use", Tool: "Read", Input: "abcd", Output: "abcdefgh", Success: false}}}
+ a := Analyse(sess)
+
+ AssertEqual(t, 1, a.EventCount)
+ AssertEqual(t, 0.0, a.SuccessRate)
+ AssertEqual(t, 1, a.EstimatedInputTokens)
+ AssertEqual(t, 2, a.EstimatedOutputTokens)
+}
+
+// --- FormatAnalytics ---
+
+func TestAX7_FormatAnalytics_Good(t *T) {
+ a := &SessionAnalytics{
+ Duration: 2 * time.Minute,
+ ActiveTime: 30 * time.Second,
+ EventCount: 3,
+ SuccessRate: 1,
+ ToolCounts: map[string]int{"Bash": 1},
+ ErrorCounts: map[string]int{},
+ AvgLatency: map[string]time.Duration{"Bash": time.Second},
+ MaxLatency: map[string]time.Duration{"Bash": time.Second},
+ }
+ output := FormatAnalytics(a)
+
+ AssertContains(t, output, "Session Analytics")
+ AssertContains(t, output, "100.0%")
+ AssertContains(t, output, "Bash")
+}
+
+func TestAX7_FormatAnalytics_Bad(t *T) {
+ var a *SessionAnalytics
+
+ AssertPanics(t, func() {
+ _ = FormatAnalytics(a)
+ })
+ AssertNil(t, a)
+}
+
+func TestAX7_FormatAnalytics_Ugly(t *T) {
+ a := &SessionAnalytics{}
+ output := FormatAnalytics(a)
+
+ AssertContains(t, output, "0.0%")
+ AssertNotContains(t, output, "Tool Breakdown")
+ AssertContains(t, output, "Events:")
+}
+
+// --- Session.EventsSeq ---
+
+func TestAX7_Session_EventsSeq_Good(t *T) {
+ sess := &Session{Events: []Event{{Type: "user", Input: "hello"}, {Type: "assistant", Input: "hi"}}}
+ var got []string
+ for evt := range sess.EventsSeq() {
+ got = append(got, evt.Type)
+ }
+
+ AssertEqual(t, []string{"user", "assistant"}, got)
+ AssertLen(t, sess.Events, 2)
+}
+
+func TestAX7_Session_EventsSeq_Bad(t *T) {
+ sess := &Session{}
+ var got []Event
+ for evt := range sess.EventsSeq() {
+ got = append(got, evt)
+ }
+
+ AssertEmpty(t, got)
+ AssertEmpty(t, sess.Events)
+}
+
+func TestAX7_Session_EventsSeq_Ugly(t *T) {
+ sess := &Session{Events: []Event{{Type: ""}}}
+ var got []Event
+ for evt := range sess.EventsSeq() {
+ got = append(got, evt)
+ }
+
+ AssertLen(t, got, 1)
+ AssertEqual(t, "", got[0].Type)
+}
+
+// --- Session.IsExpired ---
+
+func TestAX7_Session_IsExpired_Good(t *T) {
+ sess := &Session{EndTime: time.Now().Add(-2 * time.Hour)}
+ expired := sess.IsExpired(time.Hour)
+
+ AssertTrue(t, expired)
+ AssertFalse(t, sess.EndTime.IsZero())
+}
+
+func TestAX7_Session_IsExpired_Bad(t *T) {
+ sess := &Session{}
+ expired := sess.IsExpired(time.Hour)
+
+ AssertFalse(t, expired)
+ AssertTrue(t, sess.EndTime.IsZero())
+}
+
+func TestAX7_Session_IsExpired_Ugly(t *T) {
+ sess := &Session{EndTime: time.Now().Add(time.Hour)}
+ expired := sess.IsExpired(time.Nanosecond)
+
+ AssertFalse(t, expired)
+ AssertTrue(t, sess.EndTime.After(time.Now()))
+}
+
+// --- ListSessions ---
+
+func TestAX7_ListSessions_Good(t *T) {
+ dir := t.TempDir()
+ writeJSONL(t, dir, "alpha.jsonl", userTextEntry(ts(0), "hello"))
+ sessions, err := ListSessions(dir)
+
+ RequireNoError(t, err)
+ AssertLen(t, sessions, 1)
+ AssertEqual(t, "alpha", sessions[0].ID)
+}
+
+func TestAX7_ListSessions_Bad(t *T) {
+ dir := t.TempDir()
+ sessions, err := ListSessions(dir)
+
+ RequireNoError(t, err)
+ AssertEmpty(t, sessions)
+ AssertLen(t, sessions, 0)
+}
+
+func TestAX7_ListSessions_Ugly(t *T) {
+ dir := t.TempDir()
+ writeResult := hostFS.Write(Path(dir, "notes.txt"), "not a transcript")
+ RequireTrue(t, writeResult.OK)
+ sessions, err := ListSessions(dir)
+
+ RequireNoError(t, err)
+ AssertEmpty(t, sessions)
+ AssertTrue(t, writeResult.OK)
+}
+
+// --- ListSessionsSeq ---
+
+func TestAX7_ListSessionsSeq_Good(t *T) {
+ dir := t.TempDir()
+ writeJSONL(t, dir, "alpha.jsonl", userTextEntry(ts(0), "hello"))
+ var sessions []Session
+ for sess := range ListSessionsSeq(dir) {
+ sessions = append(sessions, sess)
+ }
+
+ AssertLen(t, sessions, 1)
+ AssertEqual(t, "alpha", sessions[0].ID)
+}
+
+func TestAX7_ListSessionsSeq_Bad(t *T) {
+ var sessions []Session
+ for sess := range ListSessionsSeq(Path(t.TempDir(), "missing")) {
+ sessions = append(sessions, sess)
+ }
+
+ AssertEmpty(t, sessions)
+ AssertLen(t, sessions, 0)
+}
+
+func TestAX7_ListSessionsSeq_Ugly(t *T) {
+ dir := t.TempDir()
+ writeJSONL(t, dir, "alpha.jsonl", userTextEntry(ts(0), "alpha"))
+ writeJSONL(t, dir, "bravo.jsonl", userTextEntry(ts(1), "bravo"))
+ var first Session
+ for sess := range ListSessionsSeq(dir) {
+ first = sess
+ break
+ }
+
+ AssertNotEqual(t, "", first.ID)
+ AssertTrue(t, first.StartTime.After(time.Time{}))
+}
+
+// --- PruneSessions ---
+
+func TestAX7_PruneSessions_Good(t *T) {
+ dir := t.TempDir()
+ oldPath := writeJSONL(t, dir, "old.jsonl", userTextEntry(ts(0), "old"))
+ oldTime := time.Now().Add(-2 * time.Hour)
+ RequireNoError(t, setFileTimes(oldPath, oldTime, oldTime))
+ deleted, err := PruneSessions(dir, time.Hour)
+
+ RequireNoError(t, err)
+ AssertEqual(t, 1, deleted)
+ AssertFalse(t, hostFS.Stat(oldPath).OK)
+}
+
+func TestAX7_PruneSessions_Bad(t *T) {
+ dir := t.TempDir()
+ recentPath := writeJSONL(t, dir, "recent.jsonl", userTextEntry(ts(0), "recent"))
+ deleted, err := PruneSessions(dir, time.Hour)
+
+ RequireNoError(t, err)
+ AssertEqual(t, 0, deleted)
+ AssertTrue(t, hostFS.Stat(recentPath).OK)
+}
+
+func TestAX7_PruneSessions_Ugly(t *T) {
+ dir := t.TempDir()
+ writeJSONL(t, dir, "now.jsonl", userTextEntry(ts(0), "now"))
+ deleted, err := PruneSessions(dir, -time.Nanosecond)
+
+ RequireNoError(t, err)
+ AssertEqual(t, 1, deleted)
+ sessions, err := ListSessions(dir)
+ RequireNoError(t, err)
+ AssertEmpty(t, sessions)
+}
+
+// --- FetchSession ---
+
+func TestAX7_FetchSession_Good(t *T) {
+ dir := t.TempDir()
+ writeJSONL(t, dir, "alpha.jsonl", userTextEntry(ts(0), "hello"))
+ sess, stats, err := FetchSession(dir, "alpha")
+
+ RequireNoError(t, err)
+ AssertEqual(t, "alpha", sess.ID)
+ AssertEqual(t, 1, stats.TotalLines)
+}
+
+func TestAX7_FetchSession_Bad(t *T) {
+ dir := t.TempDir()
+ sess, stats, err := FetchSession(dir, "missing")
+
+ AssertError(t, err)
+ AssertNil(t, sess)
+ AssertNil(t, stats)
+}
+
+func TestAX7_FetchSession_Ugly(t *T) {
+ dir := t.TempDir()
+ sess, stats, err := FetchSession(dir, "../outside")
+
+ AssertError(t, err)
+ AssertNil(t, sess)
+ AssertNil(t, stats)
+ AssertContains(t, err.Error(), "invalid session id")
+}
+
+// --- ParseTranscript ---
+
+func TestAX7_ParseTranscript_Good(t *T) {
+ dir := t.TempDir()
+ filePath := writeJSONL(t, dir, "alpha.jsonl", userTextEntry(ts(0), "hello"))
+ sess, stats, err := ParseTranscript(filePath)
+
+ RequireNoError(t, err)
+ AssertEqual(t, "alpha", sess.ID)
+ AssertEqual(t, 1, stats.TotalLines)
+}
+
+func TestAX7_ParseTranscript_Bad(t *T) {
+ sess, stats, err := ParseTranscript(Path(t.TempDir(), "missing.jsonl"))
+
+ AssertError(t, err)
+ AssertNil(t, sess)
+ AssertNil(t, stats)
+}
+
+func TestAX7_ParseTranscript_Ugly(t *T) {
+ dir := t.TempDir()
+ filePath := writeJSONL(t, dir, "mixed.jsonl", "{bad json", userTextEntry(ts(0), "good"))
+ sess, stats, err := ParseTranscript(filePath)
+
+ RequireNoError(t, err)
+ AssertLen(t, sess.Events, 1)
+ AssertEqual(t, 1, stats.SkippedLines)
+}
+
+// --- ParseTranscriptReader ---
+
+func TestAX7_ParseTranscriptReader_Good(t *T) {
+ reader := bytes.NewBufferString(userTextEntry(ts(0), "hello") + "\n")
+ sess, stats, err := ParseTranscriptReader(reader, "reader")
+
+ RequireNoError(t, err)
+ AssertEqual(t, "reader", sess.ID)
+ AssertEqual(t, 1, stats.TotalLines)
+}
+
+func TestAX7_ParseTranscriptReader_Bad(t *T) {
+ reader := bytes.NewBufferString("{bad json\n")
+ sess, stats, err := ParseTranscriptReader(reader, "bad")
+
+ RequireNoError(t, err)
+ AssertNotNil(t, sess)
+ AssertEqual(t, 1, stats.SkippedLines)
+}
+
+func TestAX7_ParseTranscriptReader_Ugly(t *T) {
+ reader := bytes.NewBufferString("")
+ sess, stats, err := ParseTranscriptReader(reader, "")
+
+ RequireNoError(t, err)
+ AssertEqual(t, "", sess.ID)
+ AssertEqual(t, 0, stats.TotalLines)
+}
+
+// --- Search ---
+
+func TestAX7_Search_Good(t *T) {
+ dir := t.TempDir()
+ writeJSONL(t, dir, "alpha.jsonl", toolUseEntry(ts(0), "Bash", "t1", map[string]any{"command": "go test"}), toolResultEntry(ts(1), "t1", "PASS", false))
+ results, err := Search(dir, "go test")
+
+ RequireNoError(t, err)
+ AssertLen(t, results, 1)
+ AssertEqual(t, "alpha", results[0].SessionID)
+}
+
+func TestAX7_Search_Bad(t *T) {
+ dir := t.TempDir()
+ results, err := Search(dir, "missing")
+
+ RequireNoError(t, err)
+ AssertEmpty(t, results)
+ AssertLen(t, results, 0)
+}
+
+func TestAX7_Search_Ugly(t *T) {
+ dir := t.TempDir()
+ writeJSONL(t, dir, "alpha.jsonl", toolUseEntry(ts(0), "Bash", "t1", map[string]any{"command": ""}), toolResultEntry(ts(1), "t1", "needle in output", false))
+ results, err := Search(dir, "NEEDLE")
+
+ RequireNoError(t, err)
+ AssertLen(t, results, 1)
+ AssertContains(t, results[0].Match, "needle")
+}
+
+// --- SearchSeq ---
+
+func TestAX7_SearchSeq_Good(t *T) {
+ dir := t.TempDir()
+ writeJSONL(t, dir, "alpha.jsonl", toolUseEntry(ts(0), "Bash", "t1", map[string]any{"command": "go vet"}), toolResultEntry(ts(1), "t1", "ok", false))
+ var results []SearchResult
+ for result := range SearchSeq(dir, "go vet") {
+ results = append(results, result)
+ }
+
+ AssertLen(t, results, 1)
+ AssertEqual(t, "Bash", results[0].Tool)
+}
+
+func TestAX7_SearchSeq_Bad(t *T) {
+ var results []SearchResult
+ for result := range SearchSeq(t.TempDir(), "absent") {
+ results = append(results, result)
+ }
+
+ AssertEmpty(t, results)
+ AssertLen(t, results, 0)
+}
+
+func TestAX7_SearchSeq_Ugly(t *T) {
+ dir := t.TempDir()
+ writeJSONL(t, dir, "alpha.jsonl", toolUseEntry(ts(0), "Bash", "t1", map[string]any{"command": "go test"}), toolResultEntry(ts(1), "t1", "ok", false))
+ var first SearchResult
+ for result := range SearchSeq(dir, "") {
+ first = result
+ break
+ }
+
+ AssertEqual(t, "alpha", first.SessionID)
+ AssertEqual(t, "Bash", first.Tool)
+}
+
+// --- RenderHTML ---
+
+func TestAX7_RenderHTML_Good(t *T) {
+ dir := t.TempDir()
+ outputPath := Path(dir, "session.html")
+ sess := &Session{ID: "alpha-session", StartTime: time.Date(2026, 2, 20, 10, 0, 0, 0, time.UTC), EndTime: time.Date(2026, 2, 20, 10, 0, 1, 0, time.UTC), Events: []Event{{Type: "tool_use", Tool: "Bash", Input: "echo ok", Output: "ok", Success: true}}}
+ err := RenderHTML(sess, outputPath)
+
+ RequireNoError(t, err)
+ readResult := hostFS.Read(outputPath)
+ AssertTrue(t, readResult.OK)
+ AssertContains(t, readResult.Value.(string), "alpha-s")
+}
+
+func TestAX7_RenderHTML_Bad(t *T) {
+ sess := &Session{ID: "bad"}
+ err := RenderHTML(sess, Path(t.TempDir(), "missing", "session.html"))
+
+ AssertError(t, err)
+ AssertContains(t, err.Error(), "parent directory")
+}
+
+func TestAX7_RenderHTML_Ugly(t *T) {
+ dir := t.TempDir()
+ outputPath := Path(dir, "escaped.html")
+ sess := &Session{ID: "ugly", Events: []Event{{Type: "tool_use", Tool: "Bash", Input: ``, Output: ``, Success: true}}}
+ err := RenderHTML(sess, outputPath)
+
+ RequireNoError(t, err)
+ html := hostFS.Read(outputPath).Value.(string)
+ AssertNotContains(t, html, "