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 otherwise.`} 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 qk9j2n4b") 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, "

State after the 2026-08-12 round.

") assert.Contains(t, body, "

Owner is frequently on metered mobile internet.

") assert.NotContains(t, body, `\n`) // Stored text is text: it is escaped, never rendered as markup. assert.NotContains(t, body, "otherwise") 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))) }