package index
import (
"testing"
)
// eventRow reads one event's kind and label.
func eventRow(t *testing.T, ix *Index, hash string) (kind, ref, comment string) {
t.Helper()
if err := ix.db.QueryRow(`SELECT kind, ref, comment FROM event WHERE hash = ?`,
hash).Scan(&kind, &ref, &comment); err != nil {
t.Fatalf("no event for %s: %v", hash, err)
}
return
}
// Landings are built on forum artifacts, so the forum trigger filed
// every comment and every verdict as kind 'f' with a "Forum: " prefix.
// Three landings produced rows reading "Forum: zit-landing-state:
// accepted Accepted. The forge now tells y" — a prefix over a marker
// over a truncated body.
func TestLandingPostsAreNotForumEvents(t *testing.T) {
ix := openIndex(t)
foldForumBody(t, ix, "aaaa1111", "", "Ship the drilldown", "2026-01-01T10:00:00",
landingBody("feat/x", "trunk", "ready"))
foldForumBody(t, ix, "bbbb2222", "aaaa1111", "", "2026-01-02T10:00:00",
"just a remark")
ci(t, ix, "t1", "trunk", "2026-01-01T09:00:00", "base")
ci(t, ix, "f1", "feat/x", "2026-01-01T11:00:00", "work", "t1")
ci(t, ix, "m1", "trunk", "2026-01-03T09:59:00", "land feat/x", "t1", "f1")
foldForumBody(t, ix, "cccc3333", "aaaa1111", "", "2026-01-03T10:00:00",
landingState("accepted", "landed"))
kind, ref, comment := eventRow(t, ix, "aaaa1111")
if kind != "l" {
t.Errorf("the landing root is kind %q, want l", kind)
}
if ref != "aaaa1111" {
t.Errorf("root ref %q, want the landing id so the row can link to it", ref)
}
if comment != "Landing: Ship the drilldown" {
t.Errorf("root label %q", comment)
}
kind, ref, comment = eventRow(t, ix, "cccc3333")
if kind != "l" {
t.Errorf("a verdict is kind %q, want l — it changes the target branch", kind)
}
if comment != "Landing accepted: Ship the drilldown" {
t.Errorf("verdict label %q", comment)
}
if ref != "aaaa1111" {
t.Errorf("verdict ref %q, want the landing id", ref)
}
kind, _, comment = eventRow(t, ix, "bbbb2222")
if kind != "lc" {
t.Errorf("a landing comment is kind %q, want lc", kind)
}
if comment != "Comment on landing: Ship the drilldown" {
t.Errorf("comment label %q", comment)
}
// Nothing in a landing may still read as forum content.
var forum int
if err := ix.db.QueryRow(`SELECT count(*) FROM event WHERE kind = 'f'`).Scan(&forum); err != nil {
t.Fatal(err)
}
if forum != 0 {
t.Errorf("%d landing posts still filed as forum events", forum)
}
}
// A verdict is labelled by ITS OWN state, not the thread's final one: a
// timeline row says what happened at that moment.
func TestEachVerdictKeepsItsOwnState(t *testing.T) {
ix := openIndex(t)
foldForumBody(t, ix, "aaaa1111", "", "Two minds", "2026-01-01T10:00:00",
landingBody("feat/x", "trunk", "ready"))
foldForumBody(t, ix, "bbbb2222", "aaaa1111", "", "2026-01-02T10:00:00",
landingState("rejected", "not yet"))
ci(t, ix, "t1", "trunk", "2026-01-01T09:00:00", "base")
ci(t, ix, "f1", "feat/x", "2026-01-01T11:00:00", "work", "t1")
ci(t, ix, "m1", "trunk", "2026-01-03T09:59:00", "land feat/x", "t1", "f1")
foldForumBody(t, ix, "cccc3333", "aaaa1111", "", "2026-01-03T10:00:00",
landingState("accepted", "now"))
if _, _, c := eventRow(t, ix, "bbbb2222"); c != "Landing rejected: Two minds" {
t.Errorf("the earlier verdict reads %q — relabelled by how the story ended", c)
}
if _, _, c := eventRow(t, ix, "cccc3333"); c != "Landing accepted: Two minds" {
t.Errorf("the settling verdict reads %q", c)
}
}
// Ordinary forum threads must be untouched: the classification keys on
// the landing header, not on being a thread.
func TestOrdinaryForumPostsStayForumEvents(t *testing.T) {
ix := openIndex(t)
foldForumBody(t, ix, "dddd4444", "", "How do I build this", "2026-01-01T10:00:00",
"a real question")
foldForumBody(t, ix, "eeee5555", "dddd4444", "", "2026-01-02T10:00:00", "an answer")
for _, h := range []string{"dddd4444", "eeee5555"} {
if k, _, _ := eventRow(t, ix, h); k != "f" {
t.Errorf("forum post %s became kind %q", h, k)
}
}
}
// A reply folded before its root is the order-independence rule the
// fold already keeps for the landing record itself; the events have to
// converge the same way.
func TestLandingEventsConvergeWhateverTheOrder(t *testing.T) {
ix := openIndex(t)
ci(t, ix, "t1", "trunk", "2026-01-01T09:00:00", "base")
ci(t, ix, "f1", "feat/x", "2026-01-01T11:00:00", "work", "t1")
ci(t, ix, "m1", "trunk", "2026-01-03T09:59:00", "land feat/x", "t1", "f1")
foldForumBody(t, ix, "cccc3333", "aaaa1111", "", "2026-01-03T10:00:00",
landingState("accepted", "landed"))
// The reply arrived first: with no root there is no landing yet.
if k, _, _ := eventRow(t, ix, "cccc3333"); k != "f" {
t.Fatalf("precondition: orphan reply is kind %q, expected f", k)
}
foldForumBody(t, ix, "aaaa1111", "", "Late root", "2026-01-01T10:00:00",
landingBody("feat/x", "trunk", "ready"))
if k, _, c := eventRow(t, ix, "cccc3333"); k != "l" || c != "Landing accepted: Late root" {
t.Errorf("after the root arrived the verdict is %q/%q — it did not converge", k, c)
}
}
// The classification moved out of the trigger and into the fold, so a
// store folded before that change carries "Forum:" rows for landings it
// already holds. Derived data that is only correct after a manual
// reindex is a chore, not derived data.
func TestBackfillRefilesLandingEventsInAnUpgradedIndex(t *testing.T) {
ix := openIndex(t)
foldForumBody(t, ix, "aaaa1111", "", "Older store", "2026-01-01T10:00:00",
landingBody("feat/x", "trunk", "ready"))
ci(t, ix, "t1", "trunk", "2026-01-01T09:00:00", "base")
ci(t, ix, "f1", "feat/x", "2026-01-01T11:00:00", "work", "t1")
ci(t, ix, "m1", "trunk", "2026-01-03T09:59:00", "land feat/x", "t1", "f1")
foldForumBody(t, ix, "cccc3333", "aaaa1111", "", "2026-01-03T10:00:00",
landingState("accepted", "landed"))
// Simulate the old shape: the trigger's classification, unfixed.
if _, err := ix.db.Exec(`UPDATE event SET kind = 'f', comment = 'Forum: stale'
WHERE hash IN ('aaaa1111', 'cccc3333')`); err != nil {
t.Fatal(err)
}
if err := ix.backfillLandingEvents(); err != nil {
t.Fatal(err)
}
if k, _, c := eventRow(t, ix, "aaaa1111"); k != "l" || c != "Landing: Older store" {
t.Errorf("root not refiled: %q/%q", k, c)
}
if k, _, c := eventRow(t, ix, "cccc3333"); k != "l" || c != "Landing accepted: Older store" {
t.Errorf("verdict not refiled: %q/%q", k, c)
}
// Idempotent: a store with nothing to fix must not be rewritten.
if _, err := ix.db.Exec(`UPDATE event SET comment = 'sentinel' WHERE hash = 'aaaa1111'`); err != nil {
t.Fatal(err)
}
if err := ix.backfillLandingEvents(); err != nil {
t.Fatal(err)
}
if _, _, c := eventRow(t, ix, "aaaa1111"); c != "sentinel" {
t.Error("backfill rewrote a store that had nothing stale in it")
}
}
// kindsIn runs the timeline with a filter and reports the kinds it returned.
func kindsIn(t *testing.T, ix *Index, y string) map[string]int {
t.Helper()
es, _, err := ix.Timeline(TimelineFilter{Kind: y, Limit: 50})
if err != nil {
t.Fatal(err)
}
got := map[string]int{}
for _, e := range es {
got[e.Kind]++
}
return got
}
// A verdict changes the target branch and belongs in a mixed timeline.
// A landing comment is conversation — three landings produced six rows
// of it — so it shows only where someone asked for landings.
func TestMixedTimelineCarriesVerdictsButNotChatter(t *testing.T) {
ix := openIndex(t)
foldForumBody(t, ix, "aaaa1111", "", "Ship it", "2026-01-01T10:00:00",
landingBody("feat/x", "trunk", "ready"))
foldForumBody(t, ix, "bbbb2222", "aaaa1111", "", "2026-01-02T10:00:00", "a remark")
ci(t, ix, "t1", "trunk", "2026-01-01T09:00:00", "base")
ci(t, ix, "f1", "feat/x", "2026-01-01T11:00:00", "work", "t1")
ci(t, ix, "m1", "trunk", "2026-01-03T09:59:00", "land feat/x", "t1", "f1")
foldForumBody(t, ix, "cccc3333", "aaaa1111", "", "2026-01-03T10:00:00",
landingState("accepted", "landed"))
foldForumBody(t, ix, "dddd4444", "", "A real thread", "2026-01-04T10:00:00", "hello")
all := kindsIn(t, ix, "all")
if all["lc"] != 0 {
t.Errorf("y=all carried %d landing comments", all["lc"])
}
if all["l"] != 2 {
t.Errorf("y=all carried %d landing rows, want the root and the verdict", all["l"])
}
if all["f"] != 1 {
t.Errorf("y=all carried %d forum rows, want the one real thread", all["f"])
}
// The landing view is the whole record, conversation included.
l := kindsIn(t, ix, "l")
if l["l"] != 2 || l["lc"] != 1 {
t.Errorf("y=l gave %v, want 2 landing rows and 1 comment", l)
}
if l["f"] != 0 {
t.Error("y=l carried ordinary forum posts")
}
// ...and the forum view is unaffected by any of it.
if f := kindsIn(t, ix, "f"); f["f"] != 1 || len(f) != 1 {
t.Errorf("y=f gave %v, want only the one real thread", f)
}
}
// An unfiltered call is what other surfaces use; chatter must not leak
// into those either.
func TestUnfilteredTimelineAlsoOmitsLandingChatter(t *testing.T) {
ix := openIndex(t)
foldForumBody(t, ix, "aaaa1111", "", "Ship it", "2026-01-01T10:00:00",
landingBody("feat/x", "trunk", "ready"))
foldForumBody(t, ix, "bbbb2222", "aaaa1111", "", "2026-01-02T10:00:00", "a remark")
if got := kindsIn(t, ix, ""); got["lc"] != 0 {
t.Errorf("an unfiltered timeline carried %d landing comments", got["lc"])
}
}
// zit#152: an update reply records the merge that brought the target
// into the branch. Housekeeping on the proposal, not a decision — filed
// as conversation on the timeline, and never rendered as the marker.
func TestUpdateReplyIsHousekeepingNotAVerdict(t *testing.T) {
ix := openIndex(t)
landingOn(t, ix, "aaaa1111", "feat-x", "2026-01-01T10:00:00", "Ship it")
foldForumBody(t, ix, "bbbb2222", "aaaa1111", "", "2026-01-02T10:00:00",
"zit-landing-updated: 0123456789abcdef\n\nbrought trunk in\n")
if m, ok := LandingUpdate("zit-landing-updated: 0123456789abcdef\n\nx"); !ok || m != "0123456789abcdef" {
t.Fatalf("LandingUpdate = %q %v", m, ok)
}
if _, ok := LandingUpdate("zit-landing-updated:\n"); ok {
t.Error("an empty update marker parsed")
}
if k, _, c := eventRow(t, ix, "bbbb2222"); k != "lc" || c != "Landing updated from its target: Ship it" {
t.Errorf("update reply filed as %q/%q", k, c)
}
if l := onlyOne(t, ix, "aaaa1111"); l.State != "open" || l.ClaimedBy != "" {
t.Errorf("an update changed the record's state: %+v", l)
}
}