package web
import (
"net/http"
"strings"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"sourcecraft.dev/bigbes/sr-ht-dolt/browse"
"sourcecraft.dev/bigbes/sr-ht-dolt/core"
)
// The projection — the prefix filter, the newline spellings, ?q=, the sort
// orders and the revision walk — is tested in the beads package. What is left
// here is the tab, the page, and the one thing this view renders that no other
// does: how old a memory is, in days.
// memoryCommits are the three commits the fixture's history has. Only the head
// and midHash ever touched config; rootHash predates the memories.
const (
memoryMidHash = "9f8e7d6c5b4a3928374655647382910a"
memoryRootHash = "0a1b2c3d4e5f60718293a4b5c6d7e8f9"
)
// memoryFixture is a tracker with two memories, written at two different
// commits, over a three-commit history:
//
// head (4 minutes ago) handoff + metered hash h2 wrote handoff
// mid (midAge ago) metered hash h1 wrote metered
// root (200 days ago) no memories hash h0
//
// midAge is a parameter because the stale? marker is a question about exactly
// that number.
func memoryFixture(midAge time.Duration) *fakeSession {
config := func(pairs ...[2]string) *browse.RowPage {
page := &browse.RowPage{Columns: []string{"key", "value"}}
for _, p := range pairs {
page.Rows = append(page.Rows, []string{p[0], p[1]})
}
page.Total = len(page.Rows)
return page
}
// The handoff memory carries the literal "\n" escape agents type into shell
// strings, and a fragment that must not reach the page as markup.
handoff := [2]string{"kv.memory.handoff-2026-08-12",
`State after the 2026-08-12 round.\n\nSupersedes the two 2026-08-10 handoff memories, which asserted <b>otherwise</b>.`}
metered := [2]string{"kv.memory.metered-link-calibration",
"Owner is frequently on metered mobile internet.\n\nCalibrate downloads accordingly."}
prefix := [2]string{"issue_prefix", "demo"}
return &fakeSession{
branches: []browse.Branch{{Name: "main", Head: headHash}},
tables: append(beadsTables(), memoryConfigTable()),
commits: []browse.CommitInfo{
{Hash: headHash, Author: "bigbes", Date: testNow.Add(-4 * time.Minute), Message: "bd: remember (auto-commit) by bigbes"},
{Hash: memoryMidHash, Author: "bigbes", Date: testNow.Add(-midAge)},
{Hash: memoryRootHash, Author: "alice", Date: testNow.Add(-200 * 24 * time.Hour)},
},
rowsByRef: map[string]map[string]*browse.RowPage{
"main": {"config": config(handoff, metered, prefix)},
headHash: {"config": config(handoff, metered, prefix)},
memoryMidHash: {"config": config(metered, prefix)},
memoryRootHash: {"config": config(prefix)},
},
tableHashes: map[string]string{
"main/config": "h2", headHash + "/config": "h2",
memoryMidHash + "/config": "h1", memoryRootHash + "/config": "h0",
},
}
}
// memoryConfigTable is the shape AppliesMemories looks for on top of the beads
// fingerprint.
func memoryConfigTable() browse.TableInfo {
return browse.TableInfo{Name: "config", Columns: []browse.ColumnInfo{
{Name: "key", PrimaryKey: true}, {Name: "value"},
}}
}
// The tab bar is registration order (views.go), and the design puts Memory after
// Milestones. This asserts the registry the process actually builds — not a
// hand-ordered slice a test injected — because init order is lexical file order
// and "memory.go" sorts before "milestones.go".
func TestMemoryTabFollowsMilestones(t *testing.T) {
pos := map[string]int{}
for i, v := range registeredViews {
pos[v.Name()] = i
}
require.Contains(t, pos, "memory")
require.Contains(t, pos, "milestones")
assert.Less(t, pos["milestones"], pos["memory"], "the Memory tab comes after Milestones")
assert.Less(t, pos["beads"], pos["milestones"], "and both after Beads")
// And the same order on the rendered page, through the real registry.
pinClock(t, testNow)
h := newHarness(t)
h.store.add(&core.Repo{Name: "db", OwnerID: 1, OwnerName: "alice", Path: "/d", Visibility: core.VisibilityPublic})
h.browse.sess = memoryFixture(71 * 24 * time.Hour)
rec := h.do("GET", "/~alice/db/view/memory", nil, nil)
require.Equal(t, http.StatusOK, rec.Code, "memory view: %s", rec.Body.String())
body := rec.Body.String()
iBeads := strings.Index(body, "/view/beads\"")
iMilestones := strings.Index(body, "/view/milestones\"")
iMemory := strings.Index(body, "/view/memory\"")
require.NotEqual(t, -1, iBeads)
require.NotEqual(t, -1, iMilestones)
require.NotEqual(t, -1, iMemory)
assert.Less(t, iBeads, iMilestones)
assert.Less(t, iMilestones, iMemory, "the Memory tab is rendered after Milestones")
}
func TestMemoryAppliesNeedsAConfigTable(t *testing.T) {
v := &memoryView{}
assert.True(t, v.Applies(append(beadsTables(), memoryConfigTable())))
assert.False(t, v.Applies(beadsTables()), "no config table, no Memory tab")
assert.False(t, v.Applies([]browse.TableInfo{memoryConfigTable()}), "config alone is not a beads DB")
}
func TestMemoryRenders(t *testing.T) {
pinClock(t, testNow)
h := newHarness(t)
h.store.add(&core.Repo{Name: "db", OwnerID: 1, OwnerName: "alice", Path: "/d", Visibility: core.VisibilityPublic})
h.browse.sess = memoryFixture(71 * 24 * time.Hour)
rec := h.do("GET", "/~alice/db/view/memory", nil, nil)
require.Equal(t, http.StatusOK, rec.Code, "memory view: %s", rec.Body.String())
body := rec.Body.String()
// Both memories, by slug, with the prefix stripped and the tracker's own
// settings left where they belong.
assert.Contains(t, body, ">handoff-2026-08-12<")
assert.Contains(t, body, ">metered-link-calibration<")
assert.NotContains(t, body, "kv.memory.")
assert.NotContains(t, body, "issue_prefix")
assert.Contains(t, body, "2 entries")
// The revision of each, from the history: the head wrote the handoff, and the
// short hash links to the commit page.
assert.Contains(t, body, "written <span")
assert.Contains(t, body, "4 minutes ago")
assert.Contains(t, body, `href="/~alice/db/commit/`+headHash+`"`)
assert.Contains(t, body, "<code>qk9j2n4b</code>")
assert.Contains(t, body, "71 days ago", "the day-resolution spelling, not 2 months ago")
assert.NotContains(t, body, "months ago")
assert.Contains(t, body, `href="/~alice/db/commit/`+memoryMidHash+`"`)
// Paragraphs, from both newline spellings: the literal escape is a break and
// never reaches the page as backslash-n.
assert.Contains(t, body, "<p>State after the 2026-08-12 round.</p>")
assert.Contains(t, body, "<p>Owner is frequently on metered mobile internet.</p>")
assert.NotContains(t, body, `\n`)
// Stored text is text: it is escaped, never rendered as markup.
assert.NotContains(t, body, "<b>otherwise</b>")
assert.Contains(t, body, "<b>otherwise</b>")
// The freshness line the beads family shares.
assert.Contains(t, body, `class="beads-freshness"`)
assert.Contains(t, body, "main · last commit")
}
// The stale? marker is a question asked at exactly 60 days, and it is a constant
// rather than a per-request knob: the only way to move it is to edit it.
func TestMemoryStaleMarkerAtTheBoundary(t *testing.T) {
render := func(t *testing.T, midAge time.Duration) string {
t.Helper()
pinClock(t, testNow)
h := newHarness(t)
h.store.add(&core.Repo{Name: "db", OwnerID: 1, OwnerName: "alice", Path: "/d", Visibility: core.VisibilityPublic})
h.browse.sess = memoryFixture(midAge)
rec := h.do("GET", "/~alice/db/view/memory?key=metered-link-calibration", nil, nil)
require.Equal(t, http.StatusOK, rec.Code, "memory view: %s", rec.Body.String())
return rec.Body.String()
}
exactly := render(t, 60*24*time.Hour)
assert.Contains(t, exactly, "metered-link-calibration")
assert.NotContains(t, exactly, "stale?", "at the mark itself the question is not asked")
past := render(t, 60*24*time.Hour+time.Second)
assert.Contains(t, past, "stale?")
assert.Contains(t, past, "60 days ago")
fresh := render(t, 3*time.Hour)
assert.NotContains(t, fresh, "stale?")
assert.Contains(t, fresh, "3 hours ago")
}
// ?key= renders one memory and not the rest; an unknown slug says so rather than
// falling back to the whole list.
func TestMemorySingleKey(t *testing.T) {
pinClock(t, testNow)
h := newHarness(t)
h.store.add(&core.Repo{Name: "db", OwnerID: 1, OwnerName: "alice", Path: "/d", Visibility: core.VisibilityPublic})
h.browse.sess = memoryFixture(71 * 24 * time.Hour)
one := h.do("GET", "/~alice/db/view/memory?key=handoff-2026-08-12", nil, nil)
require.Equal(t, http.StatusOK, one.Code)
assert.Contains(t, one.Body.String(), "State after the 2026-08-12 round.")
assert.NotContains(t, one.Body.String(), "metered mobile internet")
missing := h.do("GET", "/~alice/db/view/memory?key=nosuch", nil, nil)
require.Equal(t, http.StatusOK, missing.Code)
assert.Contains(t, missing.Body.String(), "No memory named")
assert.NotContains(t, missing.Body.String(), "State after the 2026-08-12 round.")
}
// A tracker whose config holds no memory still gets the tab (Applies sees shapes
// and never rows) and renders an empty state that says what writes one.
func TestMemoryEmptyState(t *testing.T) {
pinClock(t, testNow)
h := newHarness(t)
h.store.add(&core.Repo{Name: "db", OwnerID: 1, OwnerName: "alice", Path: "/d", Visibility: core.VisibilityPublic})
sess := memoryFixture(71 * 24 * time.Hour)
sess.rowsByRef["main"] = map[string]*browse.RowPage{"config": {
Columns: []string{"key", "value"},
Rows: [][]string{{"issue_prefix", "demo"}},
Total: 1,
}}
h.browse.sess = sess
rec := h.do("GET", "/~alice/db/view/memory", nil, nil)
require.Equal(t, http.StatusOK, rec.Code, "memory view: %s", rec.Body.String())
body := rec.Body.String()
assert.Contains(t, body, "No memories.")
assert.Contains(t, body, "bd remember")
assert.Contains(t, body, "0 entries")
// The tab is there either way, and so is the freshness line.
assert.Contains(t, body, "/view/memory\"")
assert.Contains(t, body, `class="beads-freshness"`)
}
// The sort toggle is a link that keeps the rest of the query, and the review
// queue is oldest first.
func TestMemorySortToggle(t *testing.T) {
pinClock(t, testNow)
h := newHarness(t)
h.store.add(&core.Repo{Name: "db", OwnerID: 1, OwnerName: "alice", Path: "/d", Visibility: core.VisibilityPublic})
h.browse.sess = memoryFixture(71 * 24 * time.Hour)
bySlug := h.do("GET", "/~alice/db/view/memory?ref=main", nil, nil)
require.Equal(t, http.StatusOK, bySlug.Code)
assert.Contains(t, bySlug.Body.String(), "/view/memory?ref=main&sort=age",
"the toggle keeps ?ref= rather than re-listing the parameters it knows")
byAge := h.do("GET", "/~alice/db/view/memory?sort=age", nil, nil)
require.Equal(t, http.StatusOK, byAge.Code)
body := byAge.Body.String()
assert.Less(t, strings.Index(body, "metered-link-calibration"), strings.Index(body, "handoff-2026-08-12"),
"age order is oldest first")
}
// agoDays is the Memory page's spelling of a duration: the same ladder as ago up
// to a day, and then days all the way — "71 days ago", never "2 months ago",
// because 60 days is the number this page marks memories at.
func TestAgoDaysStopsAtDays(t *testing.T) {
pinClock(t, testNow)
cases := []struct {
name string
d time.Duration
want string
}{
{"the instant itself", 0, "just now"},
{"seconds", 45 * time.Second, "just now"},
{"exactly a minute", time.Minute, "1 minute ago"},
{"minutes", 4 * time.Minute, "4 minutes ago"},
{"exactly an hour", time.Hour, "1 hour ago"},
{"hours", 3 * time.Hour, "3 hours ago"},
{"one second short of a day", 24*time.Hour - time.Second, "23 hours ago"},
{"exactly a day", 24 * time.Hour, "1 day ago"},
{"the stale mark", 60 * 24 * time.Hour, "60 days ago"},
{"where ago says two months", 71 * 24 * time.Hour, "71 days ago"},
{"where ago says a year", 365 * 24 * time.Hour, "365 days ago"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
assert.Equal(t, c.want, agoDays(testNow.Add(-c.d)))
})
}
// A commit stamped ahead of this host's clock is skew, not a scheduled event.
assert.Equal(t, "just now", agoDays(testNow.Add(48*time.Hour)))
}