package store import ( "database/sql" "fmt" "path/filepath" "strings" "testing" ) // TestTheThinkingColumnKeepsSilenceApartFromZero is the durable half of the distinction the adapter // makes: a row whose provider reported a thinking count of 0 and a row whose provider reported nothing // must not read the same way out of the database. They did for the whole life of the project — the // column that was supposed to answer «did thinking eat max_tokens» is NOT NULL DEFAULT 0, so every row // said zero and no reader could tell a measured zero from a question nobody asked. func TestTheThinkingColumnKeepsSilenceApartFromZero(t *testing.T) { s, _ := openTemp(t) zero, eightK := 0, 8496 for _, row := range []RequestLog{ {BookID: "b", Chapter: 1, ChunkIdx: 0, Stage: "draft", Role: "translator", CompletionTokens: 8496, ReasoningInCompletion: &eightK, OK: false}, {BookID: "b", Chapter: 1, ChunkIdx: 1, Stage: "draft", Role: "translator", CompletionTokens: 1617, ReasoningInCompletion: &zero, OK: true}, {BookID: "b", Chapter: 1, ChunkIdx: 2, Stage: "draft", Role: "translator", CompletionTokens: 1617, OK: true}, } { if err := s.InsertRequestLog(row); err != nil { t.Fatal(err) } } rows, err := s.RequestLogRows("b") if err != nil { t.Fatal(err) } if len(rows) != 3 { t.Fatalf("rows = %d, want 3", len(rows)) } // say renders the column the way a reader has to read it: a number, or the absence of one. Printing // the pointer itself put an ADDRESS in the failure text, which tells whoever broke this nothing // about what the column now says. say := func(n *int) string { if n == nil { return "no answer" } return fmt.Sprintf("%d", *n) } spent, measuredZero, unasked := rows[0], rows[1], rows[2] if spent.ReasoningInCompletion == nil || *spent.ReasoningInCompletion != 8496 { t.Fatalf("a call that spent its whole budget thinking must say so, got %s", say(spent.ReasoningInCompletion)) } if measuredZero.ReasoningInCompletion == nil { t.Fatal("a MEASURED zero must survive the round trip as an answer, not as silence") } if *measuredZero.ReasoningInCompletion != 0 { t.Fatalf("a measured zero must stay zero, got %d", *measuredZero.ReasoningInCompletion) } if unasked.ReasoningInCompletion != nil { t.Fatalf("a row whose provider reported nothing must stay unanswered, got %s", say(unasked.ReasoningInCompletion)) } } // TestRowsWrittenBeforeTheThinkingColumnReadAsUnanswered covers the books already on disk, and it does // so by actually MIGRATING one: the database is built at the schema an older binary wrote, a row is // inserted through that older INSERT, and only then is the column added. A `DEFAULT 0` would have made // that row claim its call did not think — a claim nobody measured, about the very runs whose money this // column exists to explain. // // ⚠ THE OLD VINTAGE IS BUILT, NOT SIMULATED. Inserting a nil pointer into a database that already has // the column proves only that nil writes NULL — it never runs the ALTER over a populated table, which // is the whole subject. Measured: the earlier version of this test passed with the migration removed. func TestRowsWrittenBeforeTheThinkingColumnReadAsUnanswered(t *testing.T) { path := filepath.Join(t.TempDir(), "old.db") // The vintage is the one JUST BEFORE THIS COLUMN, located by looking for the migration that adds it — // not `len(migrations)-1`. // // ⛔ IT USED TO BE «one back from the head», and that was correct only while this column happened to BE // the head. The first migration to land after it broke the fixture: head-1 already carried the column, // so the premise guard below fired and the test stopped being about anything. Worse is the version of // that failure where the guard is absent — the fixture then silently measures «a column that already // exists survives an ALTER», passes, and the class it was written for goes unwatched. A fixture must // name its own subject, not borrow the tree's current shape to express it. before := -1 for i, m := range migrations { if strings.Contains(m, "reasoning_in_completion") { before = i break } } if before < 0 { t.Fatal("no migration adds reasoning_in_completion — this test names a column the schema no longer grows, and its subject is gone rather than green") } db, err := sql.Open("sqlite", "file:"+path) if err != nil { t.Fatal(err) } if _, err := db.Exec(`CREATE TABLE IF NOT EXISTS schema_version (version INTEGER PRIMARY KEY)`); err != nil { t.Fatal(err) } for v := 0; v < before; v++ { if _, err := db.Exec(migrations[v]); err != nil { t.Fatalf("build vintage %d: %v", v+1, err) } if _, err := db.Exec(`INSERT INTO schema_version (version) VALUES (?)`, v+1); err != nil { t.Fatal(err) } } // The column must NOT exist yet, or the fixture is not standing before it and the test is about // nothing. if columnExists(t, db, "request_log", "reasoning_in_completion") { t.Fatalf("the fixture is meant to stand at the vintage BEFORE this column (migration %d of %d), and request_log already carries it", before+1, len(migrations)) } // A row written by that older binary: the INSERT it had, without the column. if _, err := db.Exec(`INSERT INTO request_log (book_id, chapter, chunk_idx, stage, role, completion_tokens, ok) VALUES ('old', 1, 0, 'edit', 'editor', 16000, 0)`); err != nil { t.Fatal(err) } if err := db.Close(); err != nil { t.Fatal(err) } // The real migrator, over a populated table. s, err := Open(path) if err != nil { t.Fatalf("migrate the old project: %v", err) } defer s.Close() rows, err := s.RequestLogRows("old") if err != nil { t.Fatal(err) } if len(rows) != 1 { t.Fatalf("rows = %d, want 1 — the row written before the column must survive the migration", len(rows)) } if n := rows[0].ReasoningInCompletion; n != nil { t.Fatalf("a row from before the column must read as unanswered, got %d", *n) } if rows[0].CompletionTokens != 16000 { t.Fatalf("the rest of the row must be untouched, got completion=%d", rows[0].CompletionTokens) } } // columnExists asks the database itself, so the fixture's premise is checked against sqlite rather than // against what the test author believed the migration list contained. func columnExists(t *testing.T, db *sql.DB, table, column string) bool { t.Helper() rows, err := db.Query(`SELECT name FROM pragma_table_info(?)`, table) if err != nil { t.Fatal(err) } defer rows.Close() seen := 0 for rows.Next() { var name string if err := rows.Scan(&name); err != nil { t.Fatal(err) } seen++ if name == column { return true } } if err := rows.Err(); err != nil { t.Fatal(err) } if seen == 0 { t.Fatalf("table %q has no columns at all — the fixture never built it", table) } return false }