- formatTime 改用固定 30 字节布局(9 位小数秒),让 TEXT 列的字典序与时间序一致(RFC3339Nano 会去掉尾随零产生变宽字符串,秒内会反序) - memory_items / project / project_chapter 的 ORDER BY 全部补 rowid 兜底,消除同 tick 打平时的顺序不确定 - 开库时幂等归一化变宽的历史值;无法解析的行原样保留,不阻塞开库 - 回归测试:变宽排序反例、同一时间戳插入序、归一化幂等与跳过
188 lines
6.3 KiB
Go
188 lines
6.3 KiB
Go
package projectindex
|
|
|
|
import (
|
|
"context"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// insertLegacyChapterSQL writes a project_chapter row with caller-supplied raw
|
|
// timestamps, standing in for a row persisted before the fixed-width layout.
|
|
const insertLegacyChapterSQL = `INSERT INTO project_chapter
|
|
(id, project_id, title, content, created_at, updated_at)
|
|
VALUES (?, 'proj.1', '旧章节', '旧内容', ?, ?)`
|
|
|
|
func TestFormatTime_定宽_正常(t *testing.T) {
|
|
t.Parallel()
|
|
tests := []struct {
|
|
name string
|
|
at time.Time
|
|
want string
|
|
}{
|
|
{"一位小数秒", time.Date(2026, 9, 7, 3, 20, 5, 830000000, time.UTC), "2026-09-07T03:20:05.830000000Z"},
|
|
{"纳秒", time.Date(2026, 9, 7, 3, 20, 5, 830978900, time.UTC), "2026-09-07T03:20:05.830978900Z"},
|
|
}
|
|
for _, tt := range tests {
|
|
tt := tt
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
got := formatTime(tt.at)
|
|
if got != tt.want {
|
|
t.Errorf("formatTime = %q, want %q", got, tt.want)
|
|
}
|
|
if len(got) != storedTimeLen {
|
|
t.Errorf("formatTime length = %d, want %d", len(got), storedTimeLen)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestStore_ListChapters_变宽时间戳_排序 is the projectindex regression for the
|
|
// variable-width timestamp bug: RFC3339Nano would store these as ":05.83" and
|
|
// ":05.8309789", whose text order is the reverse of their time order.
|
|
func TestStore_ListChapters_变宽时间戳_排序(t *testing.T) {
|
|
t.Parallel()
|
|
ctx := context.Background()
|
|
s := newTestStore(t)
|
|
|
|
sec := time.Date(2026, 9, 7, 3, 20, 5, 0, time.UTC)
|
|
older := sec.Add(830 * time.Millisecond)
|
|
newer := older.Add(978900 * time.Nanosecond)
|
|
|
|
// The newer chapter is inserted first, so a rowid-only tiebreak would put it
|
|
// last: only correctly ordered created_at text places it last-but-one.
|
|
if err := s.AddChapter(ctx, "proj.1", Chapter{
|
|
Title: "较晚", Content: "x", CreatedAt: newer, UpdatedAt: newer,
|
|
}); err != nil {
|
|
t.Fatalf("AddChapter(较晚): %v", err)
|
|
}
|
|
if err := s.AddChapter(ctx, "proj.1", Chapter{
|
|
Title: "较早", Content: "y", CreatedAt: older, UpdatedAt: older,
|
|
}); err != nil {
|
|
t.Fatalf("AddChapter(较早): %v", err)
|
|
}
|
|
|
|
var raw string
|
|
if err := s.db.QueryRowContext(ctx,
|
|
`SELECT created_at FROM project_chapter WHERE title = ?`, "较早").Scan(&raw); err != nil {
|
|
t.Fatalf("read stored created_at: %v", err)
|
|
}
|
|
if len(raw) != storedTimeLen {
|
|
t.Errorf("stored created_at = %q (%d bytes), want fixed width %d", raw, len(raw), storedTimeLen)
|
|
}
|
|
|
|
list, err := s.ListChapters(ctx, "proj.1")
|
|
if err != nil {
|
|
t.Fatalf("ListChapters: %v", err)
|
|
}
|
|
if len(list) != 2 {
|
|
t.Fatalf("ListChapters = %d chapters, want 2", len(list))
|
|
}
|
|
if list[0].Title != "较早" || list[1].Title != "较晚" {
|
|
t.Errorf("titles = [%q %q], want oldest first (较早, 较晚)", list[0].Title, list[1].Title)
|
|
}
|
|
}
|
|
|
|
// TestStore_ListChapters_同一时间戳_插入序 covers the tie case: consecutive
|
|
// AddChapter calls can share one clock tick, so rowid ASC must keep them in
|
|
// insertion order.
|
|
func TestStore_ListChapters_同一时间戳_插入序(t *testing.T) {
|
|
t.Parallel()
|
|
ctx := context.Background()
|
|
s := newTestStore(t)
|
|
|
|
at := time.Date(2026, 9, 7, 3, 20, 5, 830978900, time.UTC)
|
|
for _, title := range []string{"第一章", "第二章", "第三章"} {
|
|
if err := s.AddChapter(ctx, "proj.1", Chapter{
|
|
Title: title, Content: "内容", CreatedAt: at, UpdatedAt: at,
|
|
}); err != nil {
|
|
t.Fatalf("AddChapter(%s): %v", title, err)
|
|
}
|
|
}
|
|
|
|
list, err := s.ListChapters(ctx, "proj.1")
|
|
if err != nil {
|
|
t.Fatalf("ListChapters: %v", err)
|
|
}
|
|
want := []string{"第一章", "第二章", "第三章"}
|
|
if len(list) != len(want) {
|
|
t.Fatalf("ListChapters = %d chapters, want %d", len(list), len(want))
|
|
}
|
|
for i, w := range want {
|
|
if list[i].Title != w {
|
|
t.Errorf("chapters[%d].Title = %q, want %q (rowid ASC)", i, list[i].Title, w)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestNew_归一化历史时间戳_正常 writes legacy variable-width rows into both
|
|
// tables, re-opens the store to trigger normalization, and checks the repaired
|
|
// text plus idempotence and the unparseable-row skip.
|
|
func TestNew_归一化历史时间戳_正常(t *testing.T) {
|
|
t.Parallel()
|
|
ctx := context.Background()
|
|
s := newTestStore(t)
|
|
|
|
const legacy = "2026-09-07T03:20:05.83Z"
|
|
if _, err := s.db.ExecContext(ctx, `INSERT INTO project (id, title, desc, created_at, updated_at)
|
|
VALUES ('proj.legacy', 'T', '', ?, ?)`, legacy, legacy); err != nil {
|
|
t.Fatalf("insert legacy project: %v", err)
|
|
}
|
|
if _, err := s.db.ExecContext(ctx, insertLegacyChapterSQL, "ch.legacy", legacy, legacy); err != nil {
|
|
t.Fatalf("insert legacy chapter: %v", err)
|
|
}
|
|
if _, err := s.db.ExecContext(ctx, insertLegacyChapterSQL, "ch.broken", "not-a-time", "not-a-time"); err != nil {
|
|
t.Fatalf("insert unparseable chapter: %v", err)
|
|
}
|
|
|
|
// Re-opening normalizes both tables; an unparseable value must not block it.
|
|
if _, err := New(ctx, s.db, testLogger()); err != nil {
|
|
t.Fatalf("New (normalize): %v", err)
|
|
}
|
|
|
|
const want = "2026-09-07T03:20:05.830000000Z"
|
|
for _, tc := range []struct{ name, query, id string }{
|
|
{"project", `SELECT created_at FROM project WHERE id = ?`, "proj.legacy"},
|
|
{"project_chapter", `SELECT created_at FROM project_chapter WHERE id = ?`, "ch.legacy"},
|
|
} {
|
|
var raw string
|
|
if err := s.db.QueryRowContext(ctx, tc.query, tc.id).Scan(&raw); err != nil {
|
|
t.Fatalf("read %s created_at: %v", tc.name, err)
|
|
}
|
|
if raw != want {
|
|
t.Errorf("%s created_at = %q, want %q", tc.name, raw, want)
|
|
}
|
|
}
|
|
|
|
var broken string
|
|
if err := s.db.QueryRowContext(ctx,
|
|
`SELECT created_at FROM project_chapter WHERE id = ?`, "ch.broken").Scan(&broken); err != nil {
|
|
t.Fatalf("read unparseable created_at: %v", err)
|
|
}
|
|
if broken != "not-a-time" {
|
|
t.Errorf("unparseable created_at = %q, want it left as is", broken)
|
|
}
|
|
|
|
// A further open must be a no-op.
|
|
if _, err := New(ctx, s.db, testLogger()); err != nil {
|
|
t.Fatalf("New (second pass): %v", err)
|
|
}
|
|
var again string
|
|
if err := s.db.QueryRowContext(ctx,
|
|
`SELECT created_at FROM project WHERE id = ?`, "proj.legacy").Scan(&again); err != nil {
|
|
t.Fatalf("read created_at after second open: %v", err)
|
|
}
|
|
if again != want {
|
|
t.Errorf("second open changed created_at: %q -> %q", want, again)
|
|
}
|
|
|
|
got, err := s.GetProject(ctx, "proj.legacy")
|
|
if err != nil {
|
|
t.Fatalf("GetProject: %v", err)
|
|
}
|
|
wantTime := time.Date(2026, 9, 7, 3, 20, 5, 830000000, time.UTC)
|
|
if !got.CreatedAt.Equal(wantTime) {
|
|
t.Errorf("project created_at = %v, want %v", got.CreatedAt, wantTime)
|
|
}
|
|
}
|