package view import ( "errors" "reflect" "strings" "testing" "time" "git.novox.be/novox/mesh-sdk/go/delivery" ) var t0 = time.Date(2026, 10, 18, 12, 0, 0, 0, time.UTC) func have(t *testing.T, build string, phase delivery.Phase, health delivery.Health, detail string) []byte { t.Helper() b, err := delivery.EncodeHave(delivery.Have{Build: build, Phase: phase, Health: health, Detail: detail}) if err != nil { t.Fatal(err) } return b } func haveE(t *testing.T, node, module string, v []byte, at time.Time) Entry { return Entry{Bucket: delivery.HaveBucket, Key: node + "." + module, Value: v, At: at} } func assign(node, module, build, extra string) Entry { v := `{"node":"` + node + `","module":"` + module + `","build":"` + build + `","commit":"c","since":"2026-10-18T11:00:00Z"` + extra + `}` return Entry{Bucket: AssignmentsBucket, Key: node + "." + module, Value: []byte(v), At: t0} } func buildRec(module, id, extra string) []Entry { rec := `{"id":"` + id + `","module":"` + module + `","commit":"c"` + extra + `}` return []Entry{ {Bucket: BuildsBucket, Key: module + ".build." + id, Value: []byte(rec), At: t0}, {Bucket: BuildsBucket, Key: module + ".newest", Value: []byte(id), At: t0}, } } func find(t *testing.T, s Snapshot, node, module string) Row { t.Helper() for _, r := range s.Rows { if r.Node == node && r.Module == module { return r } } t.Fatalf("no row for %s.%s in %+v", node, module, s.Rows) return Row{} } func fill(m *Model, es ...Entry) { for _, e := range es { m.Apply(e) } } // base: two nodes (a, b), three modules (x, y, z). func base(t *testing.T) []Entry { es := []Entry{ assign("a", "x", "b1", ""), assign("a", "y", "b2", ""), assign("b", "x", "b1", ""), assign("b", "z", "b3", ""), haveE(t, "a", "x", have(t, "b1", delivery.PhaseRunning, delivery.HealthOK, ""), t0.Add(1*time.Minute)), haveE(t, "b", "x", have(t, "b1", delivery.PhaseChecking, delivery.HealthWaits, "starting"), t0.Add(2*time.Minute)), haveE(t, "b", "z", have(t, "b3", delivery.PhaseRunning, delivery.HealthOK, ""), t0.Add(3*time.Minute)), } es = append(es, buildRec("x", "b1", "")...) es = append(es, buildRec("y", "b2", "")...) es = append(es, buildRec("z", "b3", "")...) return es } func TestRowsPerAssignedModuleAndNode(t *testing.T) { // 1134/AC1 m := New() fill(m, base(t)...) s := m.Snapshot() if len(s.Rows) != 4 || s.Counted() != 4 { t.Fatalf("want 4 rows, got %d counted %d: %+v", len(s.Rows), s.Counted(), s.Rows) } r := find(t, s, "b", "x") want := Row{Node: "b", Module: "x", Status: StatusAssigned, Assigned: "b1", Have: "b1", Phase: "checking", Health: "waits", Since: t0.Add(2 * time.Minute), Detail: "starting"} if !reflect.DeepEqual(r, want) { t.Fatalf("row = %+v, want %+v", r, want) } y := find(t, s, "a", "y") if y.Waits != "no have yet" || y.Assigned != "b2" || y.Have != "" { t.Fatalf("a.y = %+v, want 'no have yet'", y) } } func TestWaitsForTheNodeAndForANewerBuild(t *testing.T) { // 1134/AC2 m := New() fill(m, base(t)...) fill(m, haveE(t, "a", "x", have(t, "b0", delivery.PhaseRunning, delivery.HealthOK, ""), t0)) if w := find(t, m.Snapshot(), "a", "x").Waits; w != "the node has not applied b1" { t.Fatalf("waits = %q", w) } fill(m, buildRec("z", "b4", "")...) if w := find(t, m.Snapshot(), "b", "z").Waits; w != "z has a newer build b4 not yet assigned" { t.Fatalf("waits = %q", w) } // a newest build that is not done is not a newer build to assign fill(m, buildRec("z", "b5", `,"failed":"boom"`)...) if w := find(t, m.Snapshot(), "b", "z").Waits; w != "" { t.Fatalf("a failed newest build must not be waited on, waits = %q", w) } fill(m, buildRec("z", "b6", `,"state":"asked"`)...) if w := find(t, m.Snapshot(), "b", "z").Waits; w != "" { t.Fatalf("an asked newest build must not be waited on, waits = %q", w) } } func TestLeftBehindIsNotRunning(t *testing.T) { // 1134/AC3 for name, extra := range map[string]string{"ended": `,"ended":"2026-10-18T11:30:00Z"`, "state": `,"state":"pending"`} { m := New() fill(m, base(t)...) fill(m, assign("b", "z", "b3", extra)) s := m.Snapshot() r := find(t, s, "b", "z") if r.Status != StatusLeftBehind { t.Errorf("%s: status = %q, want %q", name, r.Status, StatusLeftBehind) } if s.Counted() != 3 { t.Errorf("%s: counted = %d, want 3", name, s.Counted()) } } m := New() fill(m, base(t)...) fill(m, haveE(t, "a", "w", have(t, "b9", delivery.PhaseRunning, delivery.HealthOK, ""), t0)) // no assignment at all s := m.Snapshot() if find(t, s, "a", "w").Status != StatusLeftBehind || s.Counted() != 4 { t.Fatalf("a have with no assignment: %+v counted %d", find(t, s, "a", "w"), s.Counted()) } } func TestPulledInIsRunning(t *testing.T) { // 1134/AC3 m := New() fill(m, base(t)...) fill(m, assign("b", "z", "b3", `,"pulled-in":true,"required-by":"x"`)) s := m.Snapshot() r := find(t, s, "b", "z") if r.Status != StatusAssigned || r.RequiredBy != "x" || r.Have != "b3" || s.Counted() != 4 { t.Fatalf("pulled-in row = %+v counted %d, want shown and counted with required-by x", r, s.Counted()) } } func TestUnknownVersionSaysSo(t *testing.T) { // 1134/AC4 m := New() fill(m, base(t)...) before := m.Snapshot() fill(m, haveE(t, "a", "x", []byte(`{"v":9,"phase":"running","health":"ok"}`), t0)) s := m.Snapshot() r := find(t, s, "a", "x") _, sdkErr := delivery.DecodeHave([]byte(`{"v":9,"phase":"running","health":"ok"}`)) if !errors.Is(sdkErr, delivery.ErrUnknownVersion) { t.Fatal("test premise: the SDK must refuse version 9") } if r.Error != sdkErr.Error() || !strings.Contains(r.Error, "version 9") { t.Fatalf("error = %q, want the SDK's %q", r.Error, sdkErr) } if r.Have != "" || r.Phase != "" || r.Health != "" { t.Fatalf("an unreadable have must not be guessed: %+v", r) } for _, n := range [][2]string{{"a", "y"}, {"b", "x"}, {"b", "z"}} { if !reflect.DeepEqual(find(t, s, n[0], n[1]), find(t, before, n[0], n[1])) { t.Errorf("row %s.%s changed", n[0], n[1]) } } } func TestOnlyTheAffectedRowChanges(t *testing.T) { // 1134/AC5 m := New() fill(m, base(t)...) before := m.Snapshot() fill(m, haveE(t, "b", "x", have(t, "b1", delivery.PhaseRunning, delivery.HealthOK, ""), t0.Add(9*time.Minute))) after := m.Snapshot() for i := range before.Rows { changed := !reflect.DeepEqual(before.Rows[i], after.Rows[i]) if changed != (before.Rows[i].Node == "b" && before.Rows[i].Module == "x") { t.Errorf("row %s.%s changed = %v", before.Rows[i].Node, before.Rows[i].Module, changed) } } if r := find(t, after, "b", "x"); r.Phase != "running" || r.Health != "ok" || !r.Since.Equal(t0.Add(9*time.Minute)) { t.Fatalf("changed row = %+v", r) } // an assignment change and a build change touch only their rows fill(m, assign("a", "y", "b7", "")) if r := find(t, m.Snapshot(), "a", "y"); r.Assigned != "b7" { t.Fatalf("assignment change not seen: %+v", r) } if !reflect.DeepEqual(find(t, m.Snapshot(), "b", "z"), find(t, after, "b", "z")) { t.Fatal("an assignment of another row changed b.z") } fill(m, buildRec("x", "b8", "")...) s := m.Snapshot() if find(t, s, "a", "x").Waits == "" || find(t, s, "b", "x").Waits == "" || find(t, s, "b", "z").Waits != "" { t.Fatalf("a build of x must touch x's rows only: %+v", s.Rows) } // a deleted key (a bucket's expiry) removes its value m.Apply(Entry{Bucket: delivery.HaveBucket, Key: "a.x", Deleted: true}) if !strings.HasPrefix(find(t, m.Snapshot(), "a", "x").Waits, "no have yet") { t.Fatal("a deleted have must read as no have") } // a restart rebuilds the same rows from last values a, b := New(), New() es := base(t) fill(a, es...) fill(b, es...) if !reflect.DeepEqual(a.Snapshot(), b.Snapshot()) { t.Fatal("the same last values must give the same rows") } } func TestNodeRow(t *testing.T) { // 1134/AC1 (the node's own key gives a node row) m := New() v, _ := delivery.EncodeHaveNode(delivery.HaveNode{Windows: []byte(`[1,2]`), Busy: []byte(`{"a":1}`), Strays: []byte(`[]`)}) fill(m, Entry{Bucket: delivery.HaveBucket, Key: "a.node", Value: v, At: t0}) n := m.Snapshot().Nodes if len(n) != 1 || n[0].Node != "a" || n[0].Windows != 2 || n[0].Busy != 1 || n[0].Strays != 0 { t.Fatalf("nodes = %+v", n) } } func presenceE(t *testing.T, key string, online bool, at time.Time) Entry { t.Helper() b, err := delivery.EncodePresence(delivery.Presence{Online: online}) if err != nil { t.Fatal(err) } return Entry{Bucket: delivery.PresenceBucket, Key: key, Value: b, At: at} } func stuckE(t *testing.T, bucket, key, waited string, since time.Time, moved bool) Entry { t.Helper() b, err := delivery.EncodeStuck(delivery.Stuck{Bucket: bucket, Key: key, Waited: waited, Since: since, Moved: moved}) if err != nil { t.Fatal(err) } return Entry{Bucket: delivery.StuckBucket, Key: bucket + "." + key, Value: b, At: since} } func TestOfflineAndStuckAreListed(t *testing.T) { // 1168/AC2 m := New() fill(m, base(t)...) fill(m, presenceE(t, "a", false, t0), presenceE(t, "b", true, t0), presenceE(t, "part.mesh-delivery-view.i1", false, t0.Add(time.Minute)), stuckE(t, "node-engine_have", "a.x", "a report from a", t0.Add(2*time.Minute), false), stuckE(t, "node-engine_have", "b.x", "a report from b", t0, true), ) s := m.Snapshot() want := []OfflineRow{{Kind: "node", Node: "a", Since: t0}, {Kind: "part", Part: "mesh-delivery-view", Instance: "i1", Since: t0.Add(time.Minute)}} if !reflect.DeepEqual(s.Offline, want) { t.Fatalf("offline = %+v, want %+v", s.Offline, want) } wantStuck := []StuckRow{{Bucket: "node-engine_have", Key: "a.x", Waited: "a report from a", Since: t0.Add(2 * time.Minute)}} if !reflect.DeepEqual(s.Stuck, wantStuck) { t.Fatalf("stuck = %+v, want %+v", s.Stuck, wantStuck) } // back online and moved leave the lists fill(m, presenceE(t, "a", true, t0.Add(5*time.Minute)), presenceE(t, "part.mesh-delivery-view.i1", true, t0.Add(5*time.Minute)), stuckE(t, "node-engine_have", "a.x", "a report from a", t0.Add(2*time.Minute), true)) s = m.Snapshot() if len(s.Offline) != 0 || len(s.Stuck) != 0 { t.Fatalf("offline %+v stuck %+v, want both empty", s.Offline, s.Stuck) } } func TestUnknownWatchmanVersionIsUnreadableNotDropped(t *testing.T) { // 1168/AC3 m := New() fill(m, Entry{Bucket: delivery.PresenceBucket, Key: "c", Value: []byte(`{"v":9,"online":false}`), At: t0}, Entry{Bucket: delivery.StuckBucket, Key: "node-engine_have.c.x", Value: []byte(`{"v":9}`), At: t0}, presenceE(t, "a", false, t0), ) s := m.Snapshot() if len(s.Unreadable) != 2 || len(s.Offline) != 1 { t.Fatalf("unreadable %+v offline %+v", s.Unreadable, s.Offline) } _, sdkErr := delivery.DecodePresence([]byte(`{"v":9,"online":false}`)) var got *UnreadableRow for i := range s.Unreadable { if s.Unreadable[i].Key == "c" { got = &s.Unreadable[i] } } if got == nil || got.Bucket != delivery.PresenceBucket || got.Error != sdkErr.Error() { t.Fatalf("unreadable = %+v, want key c with the SDK's error %q", s.Unreadable, sdkErr) } // a good value later replaces the unreadable one fill(m, presenceE(t, "c", true, t0)) if len(m.Snapshot().Unreadable) != 1 { t.Fatal("a readable value must clear the unreadable mark") } }