]> git.giacomolli.uk - onechat/commitdiff
Move shared test helpers into helper files
authorAnderson A. Giacomolli <agiacomolli@gmail.com>
Sat, 7 Nov 2026 18:14:59 +0000 (15:14 -0300)
committerAnderson A. Giacomolli <agiacomolli@gmail.com>
Sat, 7 Nov 2026 18:14:59 +0000 (15:14 -0300)
Each helper that more than one test file uses moves out of the test file
that holds it into a file without tests, named for what it holds: the
fake Responses endpoint, the fake ChatGPT login service, the fake Bot
API, the terminal test client, the daemon start with a bot, and the tree
node helpers. A helper that only one test file uses stays in that file.
A reader then finds a shared helper from the file name, and a test file
holds only the tests and the helpers of its own feature.

The code of the helpers does not change.

Issue: 947bddb

12 files changed:
chatgpt_fake_helper_test.go [new file with mode: 0644]
chatgpt_test.go
daemon_start_helper_test.go [new file with mode: 0644]
daemon_test.go
model_fake_helper_test.go [new file with mode: 0644]
model_test.go
telegram_api_test.go
telegram_fake_helper_test.go [new file with mode: 0644]
terminal_client_helper_test.go [new file with mode: 0644]
terminal_test.go
tree_node_helper_test.go [new file with mode: 0644]
tree_test.go

diff --git a/chatgpt_fake_helper_test.go b/chatgpt_fake_helper_test.go
new file mode 100644 (file)
index 0000000..dfcf17d
--- /dev/null
@@ -0,0 +1,197 @@
+package main
+
+import (
+       "encoding/base64"
+       "encoding/json"
+       "fmt"
+       "net/http"
+       "net/http/httptest"
+       "net/url"
+       "path/filepath"
+       "sync"
+       "testing"
+       "time"
+
+       "github.com/stretchr/testify/require"
+)
+
+// chatgptTestAccount is the fake ChatGPT account ID in the fake tokens.
+const chatgptTestAccount = "account-test"
+
+// ChatGPTFake is an httptest login service. The device token endpoint answers
+// 403 pending times before it grants the code. Each token answer has a new
+// access token, kept in access.
+type ChatGPTFake struct {
+       *httptest.Server
+       mutex       sync.Mutex
+       pending     int
+       deny        bool
+       polls       int
+       exchange    url.Values
+       refreshes   int
+       refreshed   string
+       newRefresh  string
+       failRefresh bool
+       access      []string
+}
+
+// token returns a new access token that expires after ttl, for a token file
+// that a test writes.
+func (f *ChatGPTFake) token(ttl time.Duration) string {
+       f.mutex.Lock()
+       defer f.mutex.Unlock()
+
+       return f.newToken(ttl)
+}
+
+// newToken returns a new fake JWT with the expiry and the account claim, and
+// keeps it in access. The caller holds the mutex.
+func (f *ChatGPTFake) newToken(ttl time.Duration) string {
+       claims, err := json.Marshal(map[string]any{
+               "exp": time.Now().Add(ttl).Unix(),
+               chatgptAuthClaim: map[string]any{
+                       "chatgpt_account_id": chatgptTestAccount,
+               },
+               "serial": len(f.access),
+       })
+       if err != nil {
+               panic(err)
+       }
+
+       encode := base64.RawURLEncoding.EncodeToString
+       token := encode([]byte(`{"alg":"none"}`)) + "." + encode(claims) + "." + encode([]byte("fake"))
+       f.access = append(f.access, token)
+
+       return token
+}
+
+func (f *ChatGPTFake) userCode(w http.ResponseWriter, r *http.Request) {
+       var body map[string]string
+       err := json.NewDecoder(r.Body).Decode(&body)
+       if err != nil || body["client_id"] != chatgptClientID {
+               http.Error(w, "bad client", http.StatusBadRequest)
+               return
+       }
+
+       chatgptWriteJSON(w, map[string]any{
+               "device_auth_id": "device-1",
+               "user_code":      "CODE-1234",
+               "interval":       "0",
+       })
+}
+
+func (f *ChatGPTFake) deviceToken(w http.ResponseWriter, r *http.Request) {
+       f.mutex.Lock()
+       defer f.mutex.Unlock()
+
+       var body map[string]string
+       err := json.NewDecoder(r.Body).Decode(&body)
+       if err != nil || body["device_auth_id"] != "device-1" || body["user_code"] != "CODE-1234" {
+               http.Error(w, "bad device", http.StatusBadRequest)
+               return
+       }
+
+       f.polls++
+       if f.deny {
+               http.Error(w, "denied", http.StatusBadRequest)
+               return
+       }
+
+       if f.polls <= f.pending {
+               http.Error(w, "pending", http.StatusForbidden)
+               return
+       }
+
+       chatgptWriteJSON(w, map[string]any{
+               "authorization_code": "auth-code",
+               "code_challenge":     "challenge",
+               "code_verifier":      "verifier",
+       })
+}
+
+func (f *ChatGPTFake) oauthToken(w http.ResponseWriter, r *http.Request) {
+       f.mutex.Lock()
+       defer f.mutex.Unlock()
+
+       if r.Header.Get("Content-Type") == "application/x-www-form-urlencoded" {
+               err := r.ParseForm()
+               if err != nil {
+                       http.Error(w, err.Error(), http.StatusBadRequest)
+                       return
+               }
+
+               f.exchange = r.PostForm
+               chatgptWriteJSON(w, map[string]any{
+                       "access_token":  f.newToken(time.Hour),
+                       "refresh_token": "refresh-1",
+                       "id_token":      f.newToken(time.Hour),
+               })
+
+               return
+       }
+
+       var body map[string]string
+       err := json.NewDecoder(r.Body).Decode(&body)
+       if err != nil || body["grant_type"] != "refresh_token" || body["client_id"] != chatgptClientID {
+               http.Error(w, "bad refresh", http.StatusBadRequest)
+               return
+       }
+
+       f.refreshes++
+       f.refreshed = body["refresh_token"]
+       if f.failRefresh {
+               http.Error(w, `{"error": "invalid_grant", "token": "`+body["refresh_token"]+`"}`, http.StatusUnauthorized)
+               return
+       }
+
+       answer := map[string]any{
+               "access_token": f.newToken(time.Hour),
+               "id_token":     f.newToken(time.Hour),
+       }
+
+       if f.newRefresh != "" {
+               answer["refresh_token"] = f.newRefresh
+       }
+
+       chatgptWriteJSON(w, answer)
+}
+
+func newChatGPTFake(t *testing.T) *ChatGPTFake {
+       t.Helper()
+
+       f := &ChatGPTFake{}
+       mux := http.NewServeMux()
+       mux.HandleFunc("POST /api/accounts/deviceauth/usercode", f.userCode)
+       mux.HandleFunc("POST /api/accounts/deviceauth/token", f.deviceToken)
+       mux.HandleFunc("POST /oauth/token", f.oauthToken)
+       f.Server = httptest.NewServer(mux)
+       t.Cleanup(f.Close)
+
+       return f
+}
+
+func newTestChatGPTAuth(t *testing.T, server *ChatGPTFake) *ChatGPTAuth {
+       t.Helper()
+
+       return newChatGPTAuth(filepath.Join(t.TempDir(), chatgptTokenFileName), server.URL)
+}
+
+func chatgptWriteTestTokens(t *testing.T, auth *ChatGPTAuth, access, refresh string) {
+       t.Helper()
+
+       err := auth.write(ChatGPTTokenFile{
+               AccessToken:  access,
+               RefreshToken: refresh,
+               IDToken:      access,
+               AccountID:    chatgptTestAccount,
+       })
+       require.NoError(t, err)
+}
+
+func chatgptWriteJSON(w http.ResponseWriter, value any) {
+       w.Header().Set("Content-Type", "application/json")
+       err := json.NewEncoder(w).Encode(value)
+       if err != nil {
+               panic(fmt.Sprint("encode answer: ", err))
+       }
+}
index c6db9bd9bbbcc42ddc2f734aea77ec79552d01ed..a644daf638e7a517d1a8c3f0242a1182bd15b47e 100644 (file)
@@ -1,13 +1,9 @@
 package main
 
 import (
-       "encoding/base64"
        "encoding/json"
-       "fmt"
        "io"
        "net/http"
-       "net/http/httptest"
-       "net/url"
        "os"
        "path/filepath"
        "strings"
@@ -18,9 +14,6 @@ import (
        "github.com/stretchr/testify/require"
 )
 
-// chatgptTestAccount is the fake ChatGPT account ID in the fake tokens.
-const chatgptTestAccount = "account-test"
-
 func TestLoginWritesTheTokenFile(t *testing.T) {
        server := newChatGPTFake(t)
        server.pending = 2
@@ -203,164 +196,6 @@ func TestModelEndpointFollowsTheConfig(t *testing.T) {
        require.ErrorContains(t, err, "OPENAI_API_KEY")
 }
 
-// ChatGPTFake is an httptest login service. The device token endpoint answers
-// 403 pending times before it grants the code. Each token answer has a new
-// access token, kept in access.
-type ChatGPTFake struct {
-       *httptest.Server
-       mutex       sync.Mutex
-       pending     int
-       deny        bool
-       polls       int
-       exchange    url.Values
-       refreshes   int
-       refreshed   string
-       newRefresh  string
-       failRefresh bool
-       access      []string
-}
-
-// token returns a new access token that expires after ttl, for a token file
-// that a test writes.
-func (f *ChatGPTFake) token(ttl time.Duration) string {
-       f.mutex.Lock()
-       defer f.mutex.Unlock()
-
-       return f.newToken(ttl)
-}
-
-// newToken returns a new fake JWT with the expiry and the account claim, and
-// keeps it in access. The caller holds the mutex.
-func (f *ChatGPTFake) newToken(ttl time.Duration) string {
-       claims, err := json.Marshal(map[string]any{
-               "exp": time.Now().Add(ttl).Unix(),
-               chatgptAuthClaim: map[string]any{
-                       "chatgpt_account_id": chatgptTestAccount,
-               },
-               "serial": len(f.access),
-       })
-       if err != nil {
-               panic(err)
-       }
-
-       encode := base64.RawURLEncoding.EncodeToString
-       token := encode([]byte(`{"alg":"none"}`)) + "." + encode(claims) + "." + encode([]byte("fake"))
-       f.access = append(f.access, token)
-
-       return token
-}
-
-func (f *ChatGPTFake) userCode(w http.ResponseWriter, r *http.Request) {
-       var body map[string]string
-       err := json.NewDecoder(r.Body).Decode(&body)
-       if err != nil || body["client_id"] != chatgptClientID {
-               http.Error(w, "bad client", http.StatusBadRequest)
-               return
-       }
-
-       chatgptWriteJSON(w, map[string]any{
-               "device_auth_id": "device-1",
-               "user_code":      "CODE-1234",
-               "interval":       "0",
-       })
-}
-
-func (f *ChatGPTFake) deviceToken(w http.ResponseWriter, r *http.Request) {
-       f.mutex.Lock()
-       defer f.mutex.Unlock()
-
-       var body map[string]string
-       err := json.NewDecoder(r.Body).Decode(&body)
-       if err != nil || body["device_auth_id"] != "device-1" || body["user_code"] != "CODE-1234" {
-               http.Error(w, "bad device", http.StatusBadRequest)
-               return
-       }
-
-       f.polls++
-       if f.deny {
-               http.Error(w, "denied", http.StatusBadRequest)
-               return
-       }
-
-       if f.polls <= f.pending {
-               http.Error(w, "pending", http.StatusForbidden)
-               return
-       }
-
-       chatgptWriteJSON(w, map[string]any{
-               "authorization_code": "auth-code",
-               "code_challenge":     "challenge",
-               "code_verifier":      "verifier",
-       })
-}
-
-func (f *ChatGPTFake) oauthToken(w http.ResponseWriter, r *http.Request) {
-       f.mutex.Lock()
-       defer f.mutex.Unlock()
-
-       if r.Header.Get("Content-Type") == "application/x-www-form-urlencoded" {
-               err := r.ParseForm()
-               if err != nil {
-                       http.Error(w, err.Error(), http.StatusBadRequest)
-                       return
-               }
-
-               f.exchange = r.PostForm
-               chatgptWriteJSON(w, map[string]any{
-                       "access_token":  f.newToken(time.Hour),
-                       "refresh_token": "refresh-1",
-                       "id_token":      f.newToken(time.Hour),
-               })
-
-               return
-       }
-
-       var body map[string]string
-       err := json.NewDecoder(r.Body).Decode(&body)
-       if err != nil || body["grant_type"] != "refresh_token" || body["client_id"] != chatgptClientID {
-               http.Error(w, "bad refresh", http.StatusBadRequest)
-               return
-       }
-
-       f.refreshes++
-       f.refreshed = body["refresh_token"]
-       if f.failRefresh {
-               http.Error(w, `{"error": "invalid_grant", "token": "`+body["refresh_token"]+`"}`, http.StatusUnauthorized)
-               return
-       }
-
-       answer := map[string]any{
-               "access_token": f.newToken(time.Hour),
-               "id_token":     f.newToken(time.Hour),
-       }
-
-       if f.newRefresh != "" {
-               answer["refresh_token"] = f.newRefresh
-       }
-
-       chatgptWriteJSON(w, answer)
-}
-
-func newChatGPTFake(t *testing.T) *ChatGPTFake {
-       t.Helper()
-
-       f := &ChatGPTFake{}
-       mux := http.NewServeMux()
-       mux.HandleFunc("POST /api/accounts/deviceauth/usercode", f.userCode)
-       mux.HandleFunc("POST /api/accounts/deviceauth/token", f.deviceToken)
-       mux.HandleFunc("POST /oauth/token", f.oauthToken)
-       f.Server = httptest.NewServer(mux)
-       t.Cleanup(f.Close)
-
-       return f
-}
-
-func newTestChatGPTAuth(t *testing.T, server *ChatGPTFake) *ChatGPTAuth {
-       t.Helper()
-
-       return newChatGPTAuth(filepath.Join(t.TempDir(), chatgptTokenFileName), server.URL)
-}
-
 func chatgptReadTokenFile(t *testing.T, path string) ChatGPTTokenFile {
        t.Helper()
 
@@ -372,23 +207,3 @@ func chatgptReadTokenFile(t *testing.T, path string) ChatGPTTokenFile {
 
        return tokens
 }
-
-func chatgptWriteTestTokens(t *testing.T, auth *ChatGPTAuth, access, refresh string) {
-       t.Helper()
-
-       err := auth.write(ChatGPTTokenFile{
-               AccessToken:  access,
-               RefreshToken: refresh,
-               IDToken:      access,
-               AccountID:    chatgptTestAccount,
-       })
-       require.NoError(t, err)
-}
-
-func chatgptWriteJSON(w http.ResponseWriter, value any) {
-       w.Header().Set("Content-Type", "application/json")
-       err := json.NewEncoder(w).Encode(value)
-       if err != nil {
-               panic(fmt.Sprint("encode answer: ", err))
-       }
-}
diff --git a/daemon_start_helper_test.go b/daemon_start_helper_test.go
new file mode 100644 (file)
index 0000000..fd89b34
--- /dev/null
@@ -0,0 +1,44 @@
+package main
+
+import (
+       "context"
+       "path/filepath"
+       "testing"
+
+       "github.com/stretchr/testify/require"
+)
+
+// daemonStartTestWithBot is daemonStartTest with bot, which can be nil.
+func daemonStartTestWithBot(t *testing.T, server *ModelFake, bot *TelegramBot) string {
+       t.Helper()
+
+       dir := t.TempDir()
+       agent, err := openAgent(filepath.Join(dir, daemonAgentDirName))
+       require.NoError(t, err)
+
+       agent.config.Workdir = t.TempDir()
+       clock := newTestClock()
+       memory, err := openMemory(t.Context(), agent.dir, clock.Now, newFakeSummarizer())
+       require.NoError(t, err)
+
+       socket := filepath.Join(dir, socketFileName)
+       listener, err := daemonListenSocket(socket)
+       require.NoError(t, err)
+
+       client := newModelClient(server.URL, ModelAPIKey(modelTestKey), agent.CacheKey(), filepath.Join(agent.dir, "usage.jsonl"))
+       ctx, cancel := context.WithCancel(t.Context())
+       done := make(chan error)
+       go func() {
+               done <- daemonServe(ctx, listener, bot, func(notify func(Event)) (*TurnLoop, error) {
+                       return newTurnLoop(agent, memory, client, clock.Now, notify)
+               })
+       }()
+
+       t.Cleanup(func() {
+               cancel()
+               require.NoError(t, <-done)
+               require.NoError(t, memory.Close())
+       })
+
+       return socket
+}
index 2dfe7f1b2c89a0b9b5042f8d9ffcfb19398b9a10..4b0ba0a1238a6c4850dd6216825863e82836cec3 100644 (file)
@@ -1,7 +1,6 @@
 package main
 
 import (
-       "context"
        "io"
        "net/http"
        "os"
@@ -105,38 +104,3 @@ func daemonStartTest(t *testing.T, server *ModelFake) string {
 
        return daemonStartTestWithBot(t, server, nil)
 }
-
-// daemonStartTestWithBot is daemonStartTest with bot, which can be nil.
-func daemonStartTestWithBot(t *testing.T, server *ModelFake, bot *TelegramBot) string {
-       t.Helper()
-
-       dir := t.TempDir()
-       agent, err := openAgent(filepath.Join(dir, daemonAgentDirName))
-       require.NoError(t, err)
-
-       agent.config.Workdir = t.TempDir()
-       clock := newTestClock()
-       memory, err := openMemory(t.Context(), agent.dir, clock.Now, newFakeSummarizer())
-       require.NoError(t, err)
-
-       socket := filepath.Join(dir, socketFileName)
-       listener, err := daemonListenSocket(socket)
-       require.NoError(t, err)
-
-       client := newModelClient(server.URL, ModelAPIKey(modelTestKey), agent.CacheKey(), filepath.Join(agent.dir, "usage.jsonl"))
-       ctx, cancel := context.WithCancel(t.Context())
-       done := make(chan error)
-       go func() {
-               done <- daemonServe(ctx, listener, bot, func(notify func(Event)) (*TurnLoop, error) {
-                       return newTurnLoop(agent, memory, client, clock.Now, notify)
-               })
-       }()
-
-       t.Cleanup(func() {
-               cancel()
-               require.NoError(t, <-done)
-               require.NoError(t, memory.Close())
-       })
-
-       return socket
-}
diff --git a/model_fake_helper_test.go b/model_fake_helper_test.go
new file mode 100644 (file)
index 0000000..e3fb4c6
--- /dev/null
@@ -0,0 +1,187 @@
+package main
+
+import (
+       "bufio"
+       "encoding/json"
+       "fmt"
+       "io"
+       "net/http"
+       "net/http/httptest"
+       "os"
+       "sync"
+       "testing"
+
+       "github.com/stretchr/testify/require"
+)
+
+// modelTestKey is a fake API key.
+const modelTestKey = "sk-test-not-a-real-key"
+
+// ModelFakeCall is one call to a fake endpoint, numbered from 0.
+type ModelFakeCall struct {
+       number int
+       header http.Header
+       raw    []byte
+       body   map[string]any
+}
+
+// ModelFake is an httptest Responses endpoint. It keeps each call, and reply
+// writes the response. The default reply is one short message.
+type ModelFake struct {
+       *httptest.Server
+       mutex sync.Mutex
+       calls []ModelFakeCall
+       reply func(http.ResponseWriter, ModelFakeCall)
+}
+
+// Call returns call number n.
+func (f *ModelFake) Call(n int) ModelFakeCall {
+       f.mutex.Lock()
+       defer f.mutex.Unlock()
+
+       return f.calls[n]
+}
+
+// Calls returns the number of calls.
+func (f *ModelFake) Calls() int {
+       f.mutex.Lock()
+       defer f.mutex.Unlock()
+
+       return len(f.calls)
+}
+
+func (f *ModelFake) serve(w http.ResponseWriter, r *http.Request) {
+       raw, err := io.ReadAll(r.Body)
+       if err != nil {
+               http.Error(w, err.Error(), http.StatusBadRequest)
+               return
+       }
+
+       call := ModelFakeCall{
+               header: r.Header.Clone(),
+               raw:    raw,
+       }
+
+       err = json.Unmarshal(raw, &call.body)
+       if err != nil {
+               http.Error(w, err.Error(), http.StatusBadRequest)
+               return
+       }
+
+       f.mutex.Lock()
+       call.number = len(f.calls)
+       f.calls = append(f.calls, call)
+       reply := f.reply
+       f.mutex.Unlock()
+
+       reply(w, call)
+}
+
+func newModelFake(t *testing.T) *ModelFake {
+       t.Helper()
+
+       f := &ModelFake{
+               reply: func(w http.ResponseWriter, call ModelFakeCall) {
+                       modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem("a line")}, 100, 0, 5))
+               },
+       }
+
+       f.Server = httptest.NewServer(http.HandlerFunc(f.serve))
+       t.Cleanup(f.Close)
+
+       return f
+}
+
+func modelTestRequest() ModelRequest {
+       return ModelRequest{
+               Model:  "gpt-test",
+               Effort: "low",
+               Input:  []any{modelUserMessage("hello")},
+       }
+}
+
+// modelWriteEvents writes events as a server-sent event stream.
+func modelWriteEvents(w http.ResponseWriter, events ...any) {
+       w.Header().Set("Content-Type", "text/event-stream")
+       for _, event := range events {
+               data, err := json.Marshal(event)
+               if err != nil {
+                       panic(err)
+               }
+
+               _, err = fmt.Fprintf(w, "event: %s\ndata: %s\n\n", event.(map[string]any)["type"], data)
+               if err != nil {
+                       panic(err)
+               }
+       }
+}
+
+func modelMessageItem(text string) map[string]any {
+       return map[string]any{
+               "type": "message",
+               "role": "assistant",
+               "content": []any{
+                       map[string]any{"type": "output_text", "text": text},
+               },
+       }
+}
+
+func modelCompletedEvent(output []any, input, cached, out int) map[string]any {
+       if output == nil {
+               output = []any{}
+       }
+
+       return map[string]any{
+               "type": "response.completed",
+               "response": map[string]any{
+                       "output": output,
+                       "usage": map[string]any{
+                               "input_tokens":         input,
+                               "input_tokens_details": map[string]any{"cached_tokens": cached},
+                               "output_tokens":        out,
+                       },
+               },
+       }
+}
+
+func modelReadUsage(t *testing.T, path string) []ModelUsageRecord {
+       t.Helper()
+
+       file, err := os.Open(path)
+       require.NoError(t, err)
+
+       defer func() {
+               require.NoError(t, file.Close())
+       }()
+
+       var records []ModelUsageRecord
+       scanner := bufio.NewScanner(file)
+       for scanner.Scan() {
+               var record ModelUsageRecord
+               require.NoError(t, json.Unmarshal(scanner.Bytes(), &record))
+               records = append(records, record)
+       }
+
+       require.NoError(t, scanner.Err())
+
+       return records
+}
+
+func modelReasoningItem(id, encrypted string) map[string]any {
+       return map[string]any{
+               "type":              "reasoning",
+               "id":                id,
+               "encrypted_content": encrypted,
+               "summary":           []any{},
+       }
+}
+
+func modelFunctionCallItem(id, callID, name, arguments string) map[string]any {
+       return map[string]any{
+               "type":      "function_call",
+               "id":        id,
+               "call_id":   callID,
+               "name":      name,
+               "arguments": arguments,
+       }
+}
index 7807400117825b89a8818f7e78b047cb519251f2..84b767eb451ded852d25f6468c026adfb615c587 100644 (file)
@@ -1,24 +1,16 @@
 package main
 
 import (
-       "bufio"
-       "encoding/json"
-       "fmt"
        "io"
        "net/http"
-       "net/http/httptest"
        "os"
        "path/filepath"
-       "sync"
        "testing"
        "time"
 
        "github.com/stretchr/testify/require"
 )
 
-// modelTestKey is a fake API key.
-const modelTestKey = "sk-test-not-a-real-key"
-
 func TestClientSendsTheFixedRequestFields(t *testing.T) {
        server := newModelFake(t)
        client := newTestModelClient(t, server)
@@ -261,177 +253,8 @@ func TestClientFailsWhenTheResponseFails(t *testing.T) {
        }
 }
 
-// ModelFakeCall is one call to a fake endpoint, numbered from 0.
-type ModelFakeCall struct {
-       number int
-       header http.Header
-       raw    []byte
-       body   map[string]any
-}
-
-// ModelFake is an httptest Responses endpoint. It keeps each call, and reply
-// writes the response. The default reply is one short message.
-type ModelFake struct {
-       *httptest.Server
-       mutex sync.Mutex
-       calls []ModelFakeCall
-       reply func(http.ResponseWriter, ModelFakeCall)
-}
-
-// Call returns call number n.
-func (f *ModelFake) Call(n int) ModelFakeCall {
-       f.mutex.Lock()
-       defer f.mutex.Unlock()
-
-       return f.calls[n]
-}
-
-// Calls returns the number of calls.
-func (f *ModelFake) Calls() int {
-       f.mutex.Lock()
-       defer f.mutex.Unlock()
-
-       return len(f.calls)
-}
-
-func (f *ModelFake) serve(w http.ResponseWriter, r *http.Request) {
-       raw, err := io.ReadAll(r.Body)
-       if err != nil {
-               http.Error(w, err.Error(), http.StatusBadRequest)
-               return
-       }
-
-       call := ModelFakeCall{
-               header: r.Header.Clone(),
-               raw:    raw,
-       }
-
-       err = json.Unmarshal(raw, &call.body)
-       if err != nil {
-               http.Error(w, err.Error(), http.StatusBadRequest)
-               return
-       }
-
-       f.mutex.Lock()
-       call.number = len(f.calls)
-       f.calls = append(f.calls, call)
-       reply := f.reply
-       f.mutex.Unlock()
-
-       reply(w, call)
-}
-
-func newModelFake(t *testing.T) *ModelFake {
-       t.Helper()
-
-       f := &ModelFake{
-               reply: func(w http.ResponseWriter, call ModelFakeCall) {
-                       modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem("a line")}, 100, 0, 5))
-               },
-       }
-
-       f.Server = httptest.NewServer(http.HandlerFunc(f.serve))
-       t.Cleanup(f.Close)
-
-       return f
-}
-
 func newTestModelClient(t *testing.T, server *ModelFake) *ModelClient {
        t.Helper()
 
        return newModelClient(server.URL, ModelAPIKey(modelTestKey), "agent-key", filepath.Join(t.TempDir(), "usage.jsonl"))
 }
-
-func modelTestRequest() ModelRequest {
-       return ModelRequest{
-               Model:  "gpt-test",
-               Effort: "low",
-               Input:  []any{modelUserMessage("hello")},
-       }
-}
-
-// modelWriteEvents writes events as a server-sent event stream.
-func modelWriteEvents(w http.ResponseWriter, events ...any) {
-       w.Header().Set("Content-Type", "text/event-stream")
-       for _, event := range events {
-               data, err := json.Marshal(event)
-               if err != nil {
-                       panic(err)
-               }
-
-               _, err = fmt.Fprintf(w, "event: %s\ndata: %s\n\n", event.(map[string]any)["type"], data)
-               if err != nil {
-                       panic(err)
-               }
-       }
-}
-
-func modelMessageItem(text string) map[string]any {
-       return map[string]any{
-               "type": "message",
-               "role": "assistant",
-               "content": []any{
-                       map[string]any{"type": "output_text", "text": text},
-               },
-       }
-}
-
-func modelCompletedEvent(output []any, input, cached, out int) map[string]any {
-       if output == nil {
-               output = []any{}
-       }
-
-       return map[string]any{
-               "type": "response.completed",
-               "response": map[string]any{
-                       "output": output,
-                       "usage": map[string]any{
-                               "input_tokens":         input,
-                               "input_tokens_details": map[string]any{"cached_tokens": cached},
-                               "output_tokens":        out,
-                       },
-               },
-       }
-}
-
-func modelReadUsage(t *testing.T, path string) []ModelUsageRecord {
-       t.Helper()
-
-       file, err := os.Open(path)
-       require.NoError(t, err)
-
-       defer func() {
-               require.NoError(t, file.Close())
-       }()
-
-       var records []ModelUsageRecord
-       scanner := bufio.NewScanner(file)
-       for scanner.Scan() {
-               var record ModelUsageRecord
-               require.NoError(t, json.Unmarshal(scanner.Bytes(), &record))
-               records = append(records, record)
-       }
-
-       require.NoError(t, scanner.Err())
-
-       return records
-}
-
-func modelReasoningItem(id, encrypted string) map[string]any {
-       return map[string]any{
-               "type":              "reasoning",
-               "id":                id,
-               "encrypted_content": encrypted,
-               "summary":           []any{},
-       }
-}
-
-func modelFunctionCallItem(id, callID, name, arguments string) map[string]any {
-       return map[string]any{
-               "type":      "function_call",
-               "id":        id,
-               "call_id":   callID,
-               "name":      name,
-               "arguments": arguments,
-       }
-}
index bbfd93e8614d742ec15c057c3fbab2d1b77d5134..7cdee660314dca0df947d07d02188c8f28c70172 100644 (file)
@@ -2,28 +2,15 @@ package main
 
 import (
        "context"
-       "encoding/json"
-       "fmt"
        "io"
        "net/http"
        "net/http/httptest"
-       "strings"
-       "sync"
        "testing"
        "time"
 
        "github.com/stretchr/testify/require"
 )
 
-const (
-       // telegramTestToken is the bot token of the tests. It has the form of a
-       // real token.
-       telegramTestToken = "123456:TEST-secret_token"
-
-       // telegramTestOwner is the user ID of the owner of the test bot.
-       telegramTestOwner = 4242
-)
-
 func TestGetUpdatesAsksForMessagesFromTheOffset(t *testing.T) {
        server := newTelegramFake(t)
        server.Add(TelegramUpdate{
@@ -257,203 +244,3 @@ func TestBotErrorsDoNotHoldTheToken(t *testing.T) {
        require.ErrorIs(t, err, errTelegramNetwork)
        require.NotContains(t, err.Error(), telegramTestToken)
 }
-
-// TelegramFakeCall is one call of the fake Bot API. OK tells if the call got a
-// success answer.
-type TelegramFakeCall struct {
-       method string
-       params map[string]any
-       ok     bool
-}
-
-// TelegramFake is a fake Bot API server for telegramTestToken. getUpdates
-// returns the added updates from the offset on, or waits a short time when
-// there are none. The other methods succeed, unless fail gives an error answer
-// for a call. An answer with HTTP 429 asks for a wait of one second. When stall
-// is true for a call, the call gets no answer until the client gives it up or
-// the test ends. Fail and stall run with the mutex held.
-type TelegramFake struct {
-       *httptest.Server
-
-       mutex   sync.Mutex
-       updates []TelegramUpdate
-       calls   []TelegramFakeCall
-       fail    func(call TelegramFakeCall) (int, string)
-       stall   func(call TelegramFakeCall) bool
-       added   chan struct{}
-       stopped chan struct{}
-}
-
-// Add makes update available to getUpdates.
-func (f *TelegramFake) Add(update TelegramUpdate) {
-       f.mutex.Lock()
-       f.updates = append(f.updates, update)
-       f.mutex.Unlock()
-
-       select {
-       case f.added <- struct{}{}:
-       default:
-       }
-}
-
-// Calls returns the calls so far.
-func (f *TelegramFake) Calls() []TelegramFakeCall {
-       f.mutex.Lock()
-       defer f.mutex.Unlock()
-
-       return append([]TelegramFakeCall(nil), f.calls...)
-}
-
-// Sent returns the texts of the sendMessage calls and the Markdown of the
-// sendRichMessage calls that succeeded, in order.
-func (f *TelegramFake) Sent() []string {
-       var texts []string
-       for _, call := range f.Calls() {
-               switch {
-               case !call.ok:
-               case call.method == "sendMessage":
-                       texts = append(texts, call.params["text"].(string))
-               case call.method == "sendRichMessage":
-                       texts = append(texts, call.params["rich_message"].(map[string]any)["markdown"].(string))
-               }
-       }
-
-       return texts
-}
-
-func (f *TelegramFake) serve(w http.ResponseWriter, r *http.Request) {
-       method, found := strings.CutPrefix(r.URL.Path, "/bot"+telegramTestToken+"/")
-       if !found {
-               http.Error(w, `{"ok":false,"error_code":404,"description":"Not Found"}`, http.StatusNotFound)
-               return
-       }
-
-       var params map[string]any
-       err := json.NewDecoder(r.Body).Decode(&params)
-       if err != nil {
-               http.Error(w, err.Error(), http.StatusBadRequest)
-               return
-       }
-
-       call := TelegramFakeCall{
-               method: method,
-               params: params,
-       }
-
-       f.mutex.Lock()
-       status, description := 0, ""
-       if f.fail != nil {
-               status, description = f.fail(call)
-       }
-
-       stall := f.stall != nil && f.stall(call)
-       f.mutex.Unlock()
-
-       if stall {
-               f.record(call)
-               select {
-               case <-r.Context().Done():
-               case <-f.stopped:
-               }
-
-               return
-       }
-
-       if status != 0 {
-               f.record(call)
-               w.WriteHeader(status)
-               wait := 0
-               if status == http.StatusTooManyRequests {
-                       wait = 1
-               }
-
-               _, _ = fmt.Fprintf(w, `{"ok":false,"error_code":%d,"description":%q,"parameters":{"retry_after":%d}}`, status, description, wait)
-
-               return
-       }
-
-       call.ok = true
-       f.record(call)
-       if method != "getUpdates" {
-               _, _ = io.WriteString(w, `{"ok":true,"result":true}`)
-               return
-       }
-
-       offset := int64(params["offset"].(float64))
-       updates := f.from(offset)
-       if len(updates) == 0 {
-               select {
-               case <-f.added:
-               case <-time.After(50 * time.Millisecond):
-               case <-r.Context().Done():
-               }
-
-               updates = f.from(offset)
-       }
-
-       _ = json.NewEncoder(w).Encode(map[string]any{
-               "ok":     true,
-               "result": updates,
-       })
-}
-
-// record keeps call.
-func (f *TelegramFake) record(call TelegramFakeCall) {
-       f.mutex.Lock()
-       defer f.mutex.Unlock()
-
-       f.calls = append(f.calls, call)
-}
-
-// from returns the updates from offset on, and forgets the ones before it, as
-// Telegram does.
-func (f *TelegramFake) from(offset int64) []TelegramUpdate {
-       f.mutex.Lock()
-       defer f.mutex.Unlock()
-
-       var updates []TelegramUpdate
-       kept := f.updates[:0]
-       for _, update := range f.updates {
-               if update.UpdateID >= offset {
-                       updates = append(updates, update)
-                       kept = append(kept, update)
-               }
-       }
-
-       f.updates = kept
-
-       return updates
-}
-
-// newTelegramFake starts a fake Bot API server that stops at the end of the
-// test.
-func newTelegramFake(t *testing.T) *TelegramFake {
-       t.Helper()
-
-       f := &TelegramFake{
-               added:   make(chan struct{}, 1),
-               stopped: make(chan struct{}),
-       }
-
-       f.Server = httptest.NewServer(http.HandlerFunc(f.serve))
-       t.Cleanup(f.Close)
-       t.Cleanup(func() {
-               close(f.stopped)
-       })
-
-       return f
-}
-
-// telegramOwnerMessage returns a text message of the owner in the private chat.
-func telegramOwnerMessage(text string) *TelegramMessage {
-       return &TelegramMessage{
-               Chat: TelegramChat{
-                       ID:   telegramTestOwner,
-                       Type: "private",
-               },
-               From: &TelegramUser{
-                       ID: telegramTestOwner,
-               },
-               Text: text,
-       }
-}
diff --git a/telegram_fake_helper_test.go b/telegram_fake_helper_test.go
new file mode 100644 (file)
index 0000000..a70a199
--- /dev/null
@@ -0,0 +1,222 @@
+package main
+
+import (
+       "encoding/json"
+       "fmt"
+       "io"
+       "net/http"
+       "net/http/httptest"
+       "strings"
+       "sync"
+       "testing"
+       "time"
+)
+
+const (
+       // telegramTestToken is the bot token of the tests. It has the form of a
+       // real token.
+       telegramTestToken = "123456:TEST-secret_token"
+
+       // telegramTestOwner is the user ID of the owner of the test bot.
+       telegramTestOwner = 4242
+)
+
+// TelegramFakeCall is one call of the fake Bot API. OK tells if the call got a
+// success answer.
+type TelegramFakeCall struct {
+       method string
+       params map[string]any
+       ok     bool
+}
+
+// TelegramFake is a fake Bot API server for telegramTestToken. getUpdates
+// returns the added updates from the offset on, or waits a short time when
+// there are none. The other methods succeed, unless fail gives an error answer
+// for a call. An answer with HTTP 429 asks for a wait of one second. When stall
+// is true for a call, the call gets no answer until the client gives it up or
+// the test ends. Fail and stall run with the mutex held.
+type TelegramFake struct {
+       *httptest.Server
+
+       mutex   sync.Mutex
+       updates []TelegramUpdate
+       calls   []TelegramFakeCall
+       fail    func(call TelegramFakeCall) (int, string)
+       stall   func(call TelegramFakeCall) bool
+       added   chan struct{}
+       stopped chan struct{}
+}
+
+// Add makes update available to getUpdates.
+func (f *TelegramFake) Add(update TelegramUpdate) {
+       f.mutex.Lock()
+       f.updates = append(f.updates, update)
+       f.mutex.Unlock()
+
+       select {
+       case f.added <- struct{}{}:
+       default:
+       }
+}
+
+// Calls returns the calls so far.
+func (f *TelegramFake) Calls() []TelegramFakeCall {
+       f.mutex.Lock()
+       defer f.mutex.Unlock()
+
+       return append([]TelegramFakeCall(nil), f.calls...)
+}
+
+// Sent returns the texts of the sendMessage calls and the Markdown of the
+// sendRichMessage calls that succeeded, in order.
+func (f *TelegramFake) Sent() []string {
+       var texts []string
+       for _, call := range f.Calls() {
+               switch {
+               case !call.ok:
+               case call.method == "sendMessage":
+                       texts = append(texts, call.params["text"].(string))
+               case call.method == "sendRichMessage":
+                       texts = append(texts, call.params["rich_message"].(map[string]any)["markdown"].(string))
+               }
+       }
+
+       return texts
+}
+
+func (f *TelegramFake) serve(w http.ResponseWriter, r *http.Request) {
+       method, found := strings.CutPrefix(r.URL.Path, "/bot"+telegramTestToken+"/")
+       if !found {
+               http.Error(w, `{"ok":false,"error_code":404,"description":"Not Found"}`, http.StatusNotFound)
+               return
+       }
+
+       var params map[string]any
+       err := json.NewDecoder(r.Body).Decode(&params)
+       if err != nil {
+               http.Error(w, err.Error(), http.StatusBadRequest)
+               return
+       }
+
+       call := TelegramFakeCall{
+               method: method,
+               params: params,
+       }
+
+       f.mutex.Lock()
+       status, description := 0, ""
+       if f.fail != nil {
+               status, description = f.fail(call)
+       }
+
+       stall := f.stall != nil && f.stall(call)
+       f.mutex.Unlock()
+
+       if stall {
+               f.record(call)
+               select {
+               case <-r.Context().Done():
+               case <-f.stopped:
+               }
+
+               return
+       }
+
+       if status != 0 {
+               f.record(call)
+               w.WriteHeader(status)
+               wait := 0
+               if status == http.StatusTooManyRequests {
+                       wait = 1
+               }
+
+               _, _ = fmt.Fprintf(w, `{"ok":false,"error_code":%d,"description":%q,"parameters":{"retry_after":%d}}`, status, description, wait)
+
+               return
+       }
+
+       call.ok = true
+       f.record(call)
+       if method != "getUpdates" {
+               _, _ = io.WriteString(w, `{"ok":true,"result":true}`)
+               return
+       }
+
+       offset := int64(params["offset"].(float64))
+       updates := f.from(offset)
+       if len(updates) == 0 {
+               select {
+               case <-f.added:
+               case <-time.After(50 * time.Millisecond):
+               case <-r.Context().Done():
+               }
+
+               updates = f.from(offset)
+       }
+
+       _ = json.NewEncoder(w).Encode(map[string]any{
+               "ok":     true,
+               "result": updates,
+       })
+}
+
+// record keeps call.
+func (f *TelegramFake) record(call TelegramFakeCall) {
+       f.mutex.Lock()
+       defer f.mutex.Unlock()
+
+       f.calls = append(f.calls, call)
+}
+
+// from returns the updates from offset on, and forgets the ones before it, as
+// Telegram does.
+func (f *TelegramFake) from(offset int64) []TelegramUpdate {
+       f.mutex.Lock()
+       defer f.mutex.Unlock()
+
+       var updates []TelegramUpdate
+       kept := f.updates[:0]
+       for _, update := range f.updates {
+               if update.UpdateID >= offset {
+                       updates = append(updates, update)
+                       kept = append(kept, update)
+               }
+       }
+
+       f.updates = kept
+
+       return updates
+}
+
+// newTelegramFake starts a fake Bot API server that stops at the end of the
+// test.
+func newTelegramFake(t *testing.T) *TelegramFake {
+       t.Helper()
+
+       f := &TelegramFake{
+               added:   make(chan struct{}, 1),
+               stopped: make(chan struct{}),
+       }
+
+       f.Server = httptest.NewServer(http.HandlerFunc(f.serve))
+       t.Cleanup(f.Close)
+       t.Cleanup(func() {
+               close(f.stopped)
+       })
+
+       return f
+}
+
+// telegramOwnerMessage returns a text message of the owner in the private chat.
+func telegramOwnerMessage(text string) *TelegramMessage {
+       return &TelegramMessage{
+               Chat: TelegramChat{
+                       ID:   telegramTestOwner,
+                       Type: "private",
+               },
+               From: &TelegramUser{
+                       ID: telegramTestOwner,
+               },
+               Text: text,
+       }
+}
diff --git a/terminal_client_helper_test.go b/terminal_client_helper_test.go
new file mode 100644 (file)
index 0000000..077d90c
--- /dev/null
@@ -0,0 +1,76 @@
+package main
+
+import (
+       "io"
+       "net"
+       "strings"
+       "testing"
+       "time"
+
+       "github.com/stretchr/testify/require"
+)
+
+// TerminalTestClient is a terminal client of a test daemon, with a pipe as its
+// input.
+type TerminalTestClient struct {
+       input *io.PipeWriter
+       done  chan error
+       out   *SyncBuffer
+}
+
+// Type writes one line to the input of the client.
+func (c *TerminalTestClient) Type(line string) {
+       _, err := io.WriteString(c.input, line+"\n")
+       if err != nil {
+               panic(err)
+       }
+}
+
+// Output returns what the client showed.
+func (c *TerminalTestClient) Output() string {
+       return c.out.String()
+}
+
+// WaitFor waits until the client showed text.
+func (c *TerminalTestClient) WaitFor(t *testing.T, text string) {
+       t.Helper()
+
+       require.Eventually(t, func() bool {
+               return strings.Contains(c.Output(), text)
+       }, 10*time.Second, time.Millisecond, "output so far: %q", c.Output())
+}
+
+// Close ends the input and waits for the client.
+func (c *TerminalTestClient) Close() error {
+       err := c.input.Close()
+       if err != nil {
+               return err
+       }
+
+       return <-c.done
+}
+
+// terminalStartTestClient connects a client to the daemon at socket.
+func terminalStartTestClient(t *testing.T, socket string) *TerminalTestClient {
+       t.Helper()
+
+       conn, err := net.Dial("unix", socket)
+       require.NoError(t, err)
+
+       reader, writer := io.Pipe()
+       client := &TerminalTestClient{
+               input: writer,
+               done:  make(chan error, 1),
+               out:   &SyncBuffer{},
+       }
+
+       go func() {
+               client.done <- terminalSession(t.Context(), conn, reader, client.out)
+       }()
+
+       t.Cleanup(func() {
+               _ = writer.Close()
+       })
+
+       return client
+}
index d86f88aaba229fea6f216a4e59623d4440cf67ef..91e43d2d84812e966eb8a35d00353449d739e33b 100644 (file)
@@ -6,7 +6,6 @@ import (
        "path/filepath"
        "strings"
        "testing"
-       "time"
 
        "github.com/stretchr/testify/require"
 )
@@ -72,68 +71,3 @@ func TestEventText(t *testing.T) {
                })
        }
 }
-
-// TerminalTestClient is a terminal client of a test daemon, with a pipe as its
-// input.
-type TerminalTestClient struct {
-       input *io.PipeWriter
-       done  chan error
-       out   *SyncBuffer
-}
-
-// Type writes one line to the input of the client.
-func (c *TerminalTestClient) Type(line string) {
-       _, err := io.WriteString(c.input, line+"\n")
-       if err != nil {
-               panic(err)
-       }
-}
-
-// Output returns what the client showed.
-func (c *TerminalTestClient) Output() string {
-       return c.out.String()
-}
-
-// WaitFor waits until the client showed text.
-func (c *TerminalTestClient) WaitFor(t *testing.T, text string) {
-       t.Helper()
-
-       require.Eventually(t, func() bool {
-               return strings.Contains(c.Output(), text)
-       }, 10*time.Second, time.Millisecond, "output so far: %q", c.Output())
-}
-
-// Close ends the input and waits for the client.
-func (c *TerminalTestClient) Close() error {
-       err := c.input.Close()
-       if err != nil {
-               return err
-       }
-
-       return <-c.done
-}
-
-// terminalStartTestClient connects a client to the daemon at socket.
-func terminalStartTestClient(t *testing.T, socket string) *TerminalTestClient {
-       t.Helper()
-
-       conn, err := net.Dial("unix", socket)
-       require.NoError(t, err)
-
-       reader, writer := io.Pipe()
-       client := &TerminalTestClient{
-               input: writer,
-               done:  make(chan error, 1),
-               out:   &SyncBuffer{},
-       }
-
-       go func() {
-               client.done <- terminalSession(t.Context(), conn, reader, client.out)
-       }()
-
-       t.Cleanup(func() {
-               _ = writer.Close()
-       })
-
-       return client
-}
diff --git a/tree_node_helper_test.go b/tree_node_helper_test.go
new file mode 100644 (file)
index 0000000..90ba72c
--- /dev/null
@@ -0,0 +1,37 @@
+package main
+
+import (
+       "testing"
+
+       "github.com/stretchr/testify/require"
+)
+
+func treeTestNode(level, index int, text string) Node {
+       return Node{
+               Level: level,
+               Index: index,
+               Text:  text,
+               Size:  len(text),
+       }
+}
+
+// treeReadNode reads the node (l, i) of tree, which must be built.
+func treeReadNode(t *testing.T, tree *Tree, level, index int) Node {
+       t.Helper()
+
+       node, built, err := tree.Node(level, index)
+       require.NoError(t, err)
+       require.True(t, built, "node (%d, %d)", level, index)
+
+       return node
+}
+
+// treeIsBuilt tells if the node (l, i) of tree is built.
+func treeIsBuilt(t *testing.T, tree *Tree, level, index int) bool {
+       t.Helper()
+
+       built, err := tree.Built(level, index)
+       require.NoError(t, err)
+
+       return built
+}
index 146ab07351e8cddbbac134934805006c7c586f0e..46c17c728125f326c8ae451669b73f03fc6271c0 100644 (file)
@@ -156,33 +156,3 @@ func TestTreeIndexesTheNodesAfterACrash(t *testing.T) {
        require.Equal(t, "reply: b", treeReadNode(t, tree, 0, 1).Text)
        require.NoError(t, tree.Add(treeTestNode(1, 0, "user: a\nreply: b")))
 }
-
-func treeTestNode(level, index int, text string) Node {
-       return Node{
-               Level: level,
-               Index: index,
-               Text:  text,
-               Size:  len(text),
-       }
-}
-
-// treeReadNode reads the node (l, i) of tree, which must be built.
-func treeReadNode(t *testing.T, tree *Tree, level, index int) Node {
-       t.Helper()
-
-       node, built, err := tree.Node(level, index)
-       require.NoError(t, err)
-       require.True(t, built, "node (%d, %d)", level, index)
-
-       return node
-}
-
-// treeIsBuilt tells if the node (l, i) of tree is built.
-func treeIsBuilt(t *testing.T, tree *Tree, level, index int) bool {
-       t.Helper()
-
-       built, err := tree.Built(level, index)
-       require.NoError(t, err)
-
-       return built
-}