package beads
import (
"context"
"fmt"
"net/url"
"sort"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"sourcecraft.dev/bigbes/sr-ht-dolt/browse"
)
// --- fixture -----------------------------------------------------------------
// memoryNow is the instant the memory tests measure staleness against. Nothing
// about it is special beyond being fixed: staleness is a function of the
// fixture's dates and this clock, never of when the suite ran.
var memoryNow = time.Date(2026, 8, 12, 12, 0, 0, 0, time.UTC)
// fakeHistory is a MemorySession over canned per-ref content: the config table
// at each commit, that table's content hash there, and a commit log. It records
// every ref a row read was issued at, which is how the walk's central claim —
// that a commit whose table hash is unchanged is skipped without reading a
// single row — is asserted rather than assumed.
type fakeHistory struct {
commits []browse.CommitInfo // newest first, as Log returns them
config map[string]map[string]string // ref → key → value; a missing ref has no config table
hashes map[string]string // ref → config's content hash; "" = table absent
reads []string // refs Rows was called at, in order
hashCalls int
logCalls int
}
func (f *fakeHistory) Rows(_ context.Context, ref, table string, _, _ int) (*browse.RowPage, error) {
f.reads = append(f.reads, ref)
rows, ok := f.config[ref]
if !ok || table != "config" {
return nil, fmt.Errorf("%w: %s", browse.ErrTableNotFound, table)
}
page := &browse.RowPage{Columns: []string{"key", "value"}}
keys := make([]string, 0, len(rows))
for k := range rows {
keys = append(keys, k)
}
sort.Strings(keys) // a store returns rows in key order; so does this
for _, k := range keys {
page.Rows = append(page.Rows, []string{k, rows[k]})
}
page.Total = len(page.Rows)
return page, nil
}
func (f *fakeHistory) Log(_ context.Context, _, _ string, limit int) ([]browse.CommitInfo, string, error) {
f.logCalls++
if limit < len(f.commits) {
return f.commits[:limit], f.commits[limit].Hash, nil
}
return f.commits, "", nil
}
func (f *fakeHistory) TableHash(_ context.Context, ref, table string) (string, bool, error) {
f.hashCalls++
if table != "config" {
return "", false, nil
}
h, ok := f.hashes[ref]
if !ok || h == "" {
return "", false, nil
}
return h, true, nil
}
// memoryHistory is the fixture the walk is asserted against: five commits, and a
// config table that changes at only two of them.
//
// c4 (3 hours ago) alpha=v2 beta=b gamma=g hash h-c ← head
// c3 (2 days ago) alpha=v2 beta=b gamma=g hash h-c wrote alpha
// c2 (71 days ago) alpha=v1 beta=b gamma=g hash h-b
// c1 (99 days ago) alpha=v1 beta=b gamma=g hash h-b wrote beta
// c0 (200 days ago) gamma=g hash h-a the root
//
// So alpha resolves to c3, beta to c1, and gamma — never changed anywhere in the
// history — to the root commit that introduced it.
func memoryHistory() *fakeHistory {
at := func(d time.Duration) time.Time { return memoryNow.Add(-d) }
head := map[string]string{
"kv.memory.alpha": "second version",
"kv.memory.beta": "beta text",
"kv.memory.gamma": "gamma text",
"issue_prefix": "demo", // not a memory: the prefix filter drops it
}
older := map[string]string{
"kv.memory.alpha": "first version",
"kv.memory.beta": "beta text",
"kv.memory.gamma": "gamma text",
"issue_prefix": "demo",
}
root := map[string]string{
"kv.memory.gamma": "gamma text",
"issue_prefix": "demo",
}
return &fakeHistory{
commits: []browse.CommitInfo{
{Hash: "c4", Author: "bigbes", Date: at(3 * time.Hour)},
{Hash: "c3", Author: "bigbes", Date: at(2 * 24 * time.Hour)},
{Hash: "c2", Author: "bigbes", Date: at(71 * 24 * time.Hour)},
{Hash: "c1", Author: "alice", Date: at(99 * 24 * time.Hour)},
{Hash: "c0", Author: "alice", Date: at(200 * 24 * time.Hour)},
},
config: map[string]map[string]string{
"main": head, "c4": head, "c3": head,
"c2": older, "c1": older,
"c0": root,
},
hashes: map[string]string{
"main": "h-c", "c4": "h-c", "c3": "h-c",
"c2": "h-b", "c1": "h-b",
"c0": "h-a",
},
}
}
func buildMemories(t *testing.T, sess MemorySession, query string) *MemoryView {
t.Helper()
q, err := url.ParseQuery(query)
require.NoError(t, err)
v, err := BuildMemories(context.Background(), sess, "main", q, memoryNow)
require.NoError(t, err)
return v
}
func slugsOf(v *MemoryView) []string {
out := make([]string, 0, len(v.Memories))
for _, m := range v.Memories {
out = append(out, m.Slug)
}
return out
}
// --- the projection ----------------------------------------------------------
// Only the kv.memory.* rows are memories, and the slug is the key without that
// prefix. The rest of config is the tracker's settings and must not appear.
func TestMemoriesPrefixFiltering(t *testing.T) {
v := buildMemories(t, memoryHistory(), "")
assert.Equal(t, []string{"alpha", "beta", "gamma"}, slugsOf(v))
assert.Equal(t, 3, v.Total, "issue_prefix is not a memory")
assert.Equal(t, MemorySortSlug, v.Sort)
assert.False(t, v.WalkTruncated)
}
// A value carries both newline spellings — real ones when it was written from a
// file, literal backslash-n when it was typed into a shell string — and both
// have to become the same text. It is the one shaping this projection does: the
// markdown itself is the renderer's to read, and it cannot read a value whose
// line breaks are still two characters.
func TestMemoriesNormaliseBothNewlineSpellings(t *testing.T) {
const escaped = `First paragraph.\n\nSecond paragraph, line one.\nLine two.`
real := "First paragraph.\n\nSecond paragraph, line one.\nLine two."
crlf := "First paragraph.\r\n\r\nSecond paragraph, line one.\r\nLine two."
sess := memoryHistory()
for _, ref := range []string{"main", "c4", "c3"} {
sess.config[ref] = map[string]string{
"kv.memory.escaped": escaped,
"kv.memory.real": real,
"kv.memory.crlf": crlf,
}
}
v := buildMemories(t, sess, "")
require.Len(t, v.Memories, 3)
for _, m := range v.Memories {
assert.Equal(t, "First paragraph.\n\nSecond paragraph, line one.\nLine two.", m.Text,
"memory %q", m.Slug)
assert.NotContains(t, m.Text, `\n`, "the literal escape must not survive into the text")
}
}
// ?q= is a substring of the slug or of the text, case-insensitively.
func TestMemoriesSearchFilter(t *testing.T) {
bySlug := buildMemories(t, memoryHistory(), "q=ALPH")
assert.Equal(t, []string{"alpha"}, slugsOf(bySlug))
assert.Equal(t, 3, bySlug.Total, "Total counts the tracker, not the page")
assert.Equal(t, "ALPH", bySlug.Search)
byText := buildMemories(t, memoryHistory(), "q=second+version")
assert.Equal(t, []string{"alpha"}, slugsOf(byText))
none := buildMemories(t, memoryHistory(), "q=nothing+matches+this")
assert.Empty(t, none.Memories)
assert.Equal(t, 3, none.Total)
}
// ?key= renders one memory; an unknown slug renders none rather than everything.
func TestMemoriesSingleKey(t *testing.T) {
one := buildMemories(t, memoryHistory(), "key=beta")
require.Len(t, one.Memories, 1)
assert.Equal(t, "beta", one.Memories[0].Slug)
assert.Equal(t, "beta", one.Key)
missing := buildMemories(t, memoryHistory(), "key=nosuch")
assert.Empty(t, missing.Memories)
assert.Equal(t, 3, missing.Total)
}
// Slug order is the default; age order is oldest first, which is the review
// queue. An unknown ?sort= value falls back to slug rather than to nothing.
func TestMemoriesSortOrders(t *testing.T) {
assert.Equal(t, []string{"alpha", "beta", "gamma"}, slugsOf(buildMemories(t, memoryHistory(), "")))
assert.Equal(t, []string{"alpha", "beta", "gamma"}, slugsOf(buildMemories(t, memoryHistory(), "sort=slug")))
assert.Equal(t, []string{"alpha", "beta", "gamma"}, slugsOf(buildMemories(t, memoryHistory(), "sort=sideways")))
// gamma (root, 200 days) → beta (c1, 99 days) → alpha (c3, 2 days).
byAge := buildMemories(t, memoryHistory(), "sort=age")
assert.Equal(t, []string{"gamma", "beta", "alpha"}, slugsOf(byAge))
assert.Equal(t, MemorySortAge, byAge.Sort)
}
// A tracker with no memories at all — and one with no config table — is an empty
// view, not an error: the tab exists wherever the beads fingerprint does.
func TestMemoriesEmptyTracker(t *testing.T) {
noMemories := memoryHistory()
for ref := range noMemories.config {
noMemories.config[ref] = map[string]string{"issue_prefix": "demo"}
}
v := buildMemories(t, noMemories, "")
assert.Empty(t, v.Memories)
assert.Equal(t, 0, v.Total)
assert.Empty(t, noMemories.reads[1:], "with nothing to date, the walk must not run")
noTable := memoryHistory()
noTable.config = map[string]map[string]string{}
empty := buildMemories(t, noTable, "")
assert.Empty(t, empty.Memories)
assert.Equal(t, 0, empty.Total)
}
// AppliesMemories is the beads fingerprint plus a config table carrying key and
// value. It sees shapes and never rows, so an empty config still gets the tab.
func TestAppliesMemories(t *testing.T) {
configTable := browse.TableInfo{Name: "config", Columns: []browse.ColumnInfo{
{Name: "key", PrimaryKey: true}, {Name: "value"},
}}
full := append(beadsTables(), configTable)
assert.True(t, AppliesMemories(full))
assert.False(t, AppliesMemories(beadsTables()), "no config table")
assert.False(t, AppliesMemories([]browse.TableInfo{configTable}), "config without the beads fingerprint")
assert.False(t, AppliesMemories(append(beadsTables(),
browse.TableInfo{Name: "config", Columns: []browse.ColumnInfo{{Name: "key"}}})),
"a config table without a value column is somebody else's config")
}
// --- the revision walk -------------------------------------------------------
// The walk attributes a memory to the commit that changed its value, skips the
// commits that did not touch config without reading a row, and — when the
// history itself runs out — attributes what never changed to the root commit.
func TestMemoryRevisionWalk(t *testing.T) {
sess := memoryHistory()
v := buildMemories(t, sess, "")
require.Len(t, v.Memories, 3)
byslug := map[string]Memory{}
for _, m := range v.Memories {
byslug[m.Slug] = m
}
// alpha changed between c2 and c3, so c3 wrote it — not c4, which merely has
// the same value, and not c2, which has the older one.
alpha := byslug["alpha"]
require.NotNil(t, alpha.Revision)
assert.Equal(t, "c3", alpha.Revision.Commit)
assert.Equal(t, memoryNow.Add(-2*24*time.Hour), alpha.Revision.Date)
assert.Equal(t, "bigbes", alpha.Revision.Author)
assert.False(t, alpha.Stale, "two days old")
// beta appeared at c1 (absent at the root) and was left alone since.
beta := byslug["beta"]
require.NotNil(t, beta.Revision)
assert.Equal(t, "c1", beta.Revision.Commit)
assert.Equal(t, "alice", beta.Revision.Author)
assert.True(t, beta.Stale, "99 days old")
// gamma never changed anywhere in the history: the root is what wrote it.
gamma := byslug["gamma"]
require.NotNil(t, gamma.Revision)
assert.Equal(t, "c0", gamma.Revision.Commit)
assert.True(t, gamma.Stale, "200 days old")
assert.False(t, v.WalkTruncated, "the whole history fits inside the walk")
// The cost claim: rows were read at the head and at the two commits whose
// config hash differs from their newer neighbour's. c3 and c1 are byte-equal
// to the commit above them and were skipped without a read; c4 is the head
// itself, already read once.
assert.Equal(t, []string{"main", "c2", "c0"}, sess.reads)
assert.Equal(t, 5, sess.hashCalls, "one O(1) table hash per commit")
assert.Equal(t, 1, sess.logCalls, "one log call for the whole walk")
}
// A key that never changes inside the walk's budget gets no date at all rather
// than a date the walk cannot support — and the view says the walk was cut off,
// which is the only way a memory can carry no revision.
func TestMemoryRevisionUnresolvedWithinWalk(t *testing.T) {
sess := &fakeHistory{
config: map[string]map[string]string{"main": {"kv.memory.ancient": "unchanged"}},
hashes: map[string]string{"main": "h"},
}
// One commit more than the walk examines, none of which touched config.
for i := 0; i <= memoryWalkMax; i++ {
h := fmt.Sprintf("k%03d", i)
sess.commits = append(sess.commits, browse.CommitInfo{
Hash: h, Author: "bigbes", Date: memoryNow.Add(-time.Duration(i) * time.Hour),
})
sess.config[h] = map[string]string{"kv.memory.ancient": "unchanged"}
sess.hashes[h] = "h"
}
v := buildMemories(t, sess, "")
require.Len(t, v.Memories, 1)
assert.Nil(t, v.Memories[0].Revision, "no date the walk cannot support")
assert.True(t, v.WalkTruncated)
assert.Equal(t, memoryWalkMax, v.WalkMax)
// Still older than the threshold? The walk's own oldest commit is 500 hours
// back, which is short of 60 days, so the question is not asked either.
assert.False(t, v.Memories[0].Stale)
assert.Equal(t, []string{"main"}, sess.reads,
"500 commits that did not touch config must cost no row read at all")
assert.Equal(t, memoryWalkMax, sess.hashCalls)
}
// The undated arm of staleness: with no revision, the walk's oldest commit is a
// floor on the memory's age, and a floor already past the threshold supports the
// question.
func TestMemoryStaleWithoutARevision(t *testing.T) {
sess := &fakeHistory{
config: map[string]map[string]string{"main": {"kv.memory.ancient": "unchanged"}},
hashes: map[string]string{"main": "h"},
}
for i := 0; i <= memoryWalkMax; i++ {
h := fmt.Sprintf("k%03d", i)
sess.commits = append(sess.commits, browse.CommitInfo{
Hash: h, Date: memoryNow.Add(-time.Duration(i) * 24 * time.Hour),
})
sess.config[h] = map[string]string{"kv.memory.ancient": "unchanged"}
sess.hashes[h] = "h"
}
v := buildMemories(t, sess, "")
require.Len(t, v.Memories, 1)
assert.Nil(t, v.Memories[0].Revision)
assert.True(t, v.Memories[0].Stale, "the oldest commit walked is 499 days back")
}
// A commit that created the config table is attributed correctly: walking past
// it, the table is simply absent, and absent is an ordinary answer rather than
// an error.
func TestMemoryWalkPastTheTablesCreation(t *testing.T) {
sess := &fakeHistory{
commits: []browse.CommitInfo{
{Hash: "b2", Author: "bigbes", Date: memoryNow.Add(-time.Hour)},
{Hash: "b1", Author: "bigbes", Date: memoryNow.Add(-48 * time.Hour)},
{Hash: "b0", Author: "bigbes", Date: memoryNow.Add(-72 * time.Hour)},
},
config: map[string]map[string]string{
"main": {"kv.memory.first": "hello"},
"b2": {"kv.memory.first": "hello"},
"b1": {"kv.memory.first": "hello"},
// b0 predates the config table entirely: no entry at all.
},
hashes: map[string]string{"main": "h1", "b2": "h1", "b1": "h1"},
}
v := buildMemories(t, sess, "")
require.Len(t, v.Memories, 1)
require.NotNil(t, v.Memories[0].Revision)
assert.Equal(t, "b1", v.Memories[0].Revision.Commit,
"the commit that created config is the one that wrote the memory")
assert.False(t, v.WalkTruncated)
}
// A history that cannot be read is an error, not a page of memories with every
// date quietly missing — that page is indistinguishable from a tracker whose
// memories are all older than the walk.
func TestMemoriesFailWhenTheHistoryCannotBeRead(t *testing.T) {
sess := &failingLog{fakeHistory: memoryHistory()}
_, err := BuildMemories(context.Background(), sess, "main", url.Values{}, memoryNow)
require.Error(t, err)
assert.Contains(t, err.Error(), "read history")
}
type failingLog struct{ *fakeHistory }
func (*failingLog) Log(context.Context, string, string, int) ([]browse.CommitInfo, string, error) {
return nil, "", fmt.Errorf("browse: walk commits: corrupt chunk")
}