Files
mesh-controller/cmd/mesh-controller/related_test.go
T

223 lines
10 KiB
Go

package main
import (
"encoding/json"
"strings"
"testing"
"time"
"github.com/novox/mesh-controller/internal/inventory"
"github.com/novox/mesh-controller/internal/link"
)
// heardMerge is a kept merge of a repository from a head branch that moved modules.
func heardMerge(repository, head string, moves ...string) inventory.BatchedMerge {
m := inventory.BatchedMerge{Repository: repository, Branch: "main", Commit: "c-" + repository}
m.Event, _ = json.Marshal(keptMerge{SourceMoved: link.SourceMoved{Head: head}, Moves: moves})
return m
}
// walkOf is a walk carrying one repository's commit and planning modules.
func walkOf(repository string, modules ...string) inventory.Plan {
p := inventory.Plan{Repository: repository, Commit: "w-" + repository, Modules: map[string]*inventory.PlanModule{}}
for _, name := range modules {
p.Modules[name] = &inventory.PlanModule{}
}
return p
}
// TestRelatedChanges: two changes are related by a repository, a module, a group head or a graph edge between
// their modules, and by a module reached only through dependents (novox/hq ADR 0304 decision 1, ADR 0306
// decision 3); otherwise they are not.
func TestRelatedChanges(t *testing.T) {
edges := []inventory.Edge{
{From: "nzbget", To: "base-image", Kind: inventory.EdgeStandsOn},
{From: "app", To: "build-machine", Kind: inventory.EdgeBuiltBy},
}
cases := []struct {
name string
a, b footprint
why string
}{
{"repository, case aside",
footprintOfWalk(walkOf("novox/Mesh-Catalog", "notes"), nil),
footprintOfBatch([]inventory.BatchedMerge{heardMerge("NOVOX/mesh-catalog", "fix/a", "app")}, nil),
"both change novox/mesh-catalog"},
{"module",
footprintOfWalk(walkOf("novox/one", "nzbget"), nil),
footprintOfBatch([]inventory.BatchedMerge{heardMerge("novox/two", "fix/b", "nzbget")}, nil),
"both move nzbget"},
{"edge, walk depends on batch",
footprintOfWalk(walkOf("novox/one", "nzbget"), nil),
footprintOfBatch([]inventory.BatchedMerge{heardMerge("novox/two", "fix/b", "base-image")}, nil),
"nzbget depends on base-image (stands-on)"},
{"edge, batch depends on walk",
footprintOfWalk(walkOf("novox/one", "build-machine"), nil),
footprintOfBatch([]inventory.BatchedMerge{heardMerge("novox/two", "fix/b", "app")}, nil),
"app depends on build-machine (built-by)"},
{"group head",
footprintOfWalk(walkOf("novox/one", "notes"), []inventory.BatchedMerge{heardMerge("novox/one", "feat/x")}),
footprintOfBatch([]inventory.BatchedMerge{heardMerge("novox/two", "feat/x", "app")}, nil),
"both are members of group feat/x"},
{"module reached through dependents",
footprintOfWalk(walkOf("novox/one", "nzbget"), nil),
footprintOfBatch([]inventory.BatchedMerge{heardMerge("novox/two", "fix/b", "base-image")}, edges),
"both move nzbget"},
{"unrelated",
footprintOfWalk(walkOf("novox/one", "notes"), []inventory.BatchedMerge{heardMerge("novox/one", "feat/x")}),
footprintOfBatch([]inventory.BatchedMerge{heardMerge("novox/two", "feat/y", "app")}, edges),
""},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
for _, pair := range [][2]footprint{{c.a, c.b}, {c.b, c.a}} {
is, why := related(pair[0], pair[1], edges)
if is != (c.why != "") || why != c.why {
t.Errorf("related = %v, %q; want %q", is, why, c.why)
}
}
})
}
}
// TestFootprintOfBatchWidensToDependents: a batch's modules are what its merges moved and every module that
// depends on them, along the edges that widen a walk.
func TestFootprintOfBatchWidensToDependents(t *testing.T) {
edges := []inventory.Edge{
{From: "nzbget", To: "base-image", Kind: inventory.EdgeStandsOn},
{From: "base-image", To: "build-machine", Kind: inventory.EdgeBuiltBy},
}
f := footprintOfBatch([]inventory.BatchedMerge{heardMerge("novox/two", "", "base-image")}, edges)
if !f.modules["base-image"] || !f.modules["nzbget"] || f.modules["build-machine"] || len(f.heads) != 0 {
t.Errorf("footprint = %+v; want base-image and nzbget, no head", f)
}
}
// A batch holding a merge kept with no moves (one owed a later look, judged again at the cut) is related to
// every change, in both orders: what it will move is not known, and an unknown change is never unrelated
// (issue 525 review).
func TestABatchWhoseMovesAreNotKnownIsRelatedToEverything(t *testing.T) {
unknown := footprintOfBatch([]inventory.BatchedMerge{heardMerge("novox/one", "fix/a", "app"), heardMerge("novox/two", "fix/b")}, nil)
other := footprintOfWalk(walkOf("novox/three", "nzbget"), nil)
for _, pair := range [][2]footprint{{unknown, other}, {other, unknown}} {
if ok, why := related(pair[0], pair[1], nil); !ok || !strings.Contains(why, "not known") {
t.Fatalf("an unknown batch read as %v (%q)", ok, why)
}
}
known := footprintOfBatch([]inventory.BatchedMerge{heardMerge("novox/one", "fix/a", "app")}, nil)
if ok, why := related(known, other, nil); ok {
t.Fatalf("a known, unrelated batch read as related: %q", why)
}
}
// TestRelatedOpenNamesTheFirstOpenWalkAChangeIsRelatedTo: a change that shares nothing with any open walk is
// held behind none (issue 973, ADR 0407 decision 4); one that shares a repository, a module, a graph edge or a
// group head is held behind the first walk it shares it with, with the reason; one whose moves are not known
// is held behind the first; a walk or change on the controller's own path is related to every other.
func TestRelatedOpenNamesTheFirstOpenWalkAChangeIsRelatedTo(t *testing.T) {
edges := []inventory.Edge{{From: "nzbget", To: "base-image", Kind: inventory.EdgeStandsOn}}
walk := func(id, repo string, own bool, modules ...string) openWalk {
return openWalk{id: id, fp: footprintOfWalk(walkOf(repo, modules...), nil), own: own}
}
one, two := walk("plan-1", "novox/one", false, "notes"), walk("plan-2", "novox/two", false, "nzbget")
cand := func(repo, head string, moves ...string) footprint {
return footprintOfBatch([]inventory.BatchedMerge{heardMerge(repo, head, moves...)}, edges)
}
cases := []struct {
name string
fp footprint
own bool
open []openWalk
behind string
why string
}{
{"nothing open", cand("novox/x", "fix/a", "app"), false, nil, "", ""},
{"unrelated to every open walk", cand("novox/x", "fix/a", "app"), false, []openWalk{one, two}, "", ""},
{"a repository", cand("novox/ONE", "fix/a", "app"), false, []openWalk{two, one}, "plan-1", "both change novox/one"},
{"a module through its dependents", cand("novox/x", "fix/a", "base-image"), false, []openWalk{one, two},
"plan-2", "both move nzbget"},
{"a graph edge", cand("novox/x", "fix/a", "nzbget"),
false, []openWalk{one, walk("plan-3", "novox/y", false, "base-image")}, "plan-3",
"nzbget depends on base-image (stands-on)"},
{"moves not known", cand("novox/x", "fix/a"), false, []openWalk{one, two}, "plan-1",
"the modules a merge of novox/x moves are not known until its batch is cut"},
{"the candidate on the own path", cand("novox/x", "fix/a", "app"), true, []openWalk{one}, "plan-1",
"it is on the controller's own path"},
{"an open walk on the own path", cand("novox/x", "fix/a", "app"), false,
[]openWalk{one, walk("plan-4", "novox/mesh-controller", true, "mesh-controller")}, "plan-4",
"plan-4 is on the controller's own path"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
behind, why := relatedOpen(c.fp, c.own, c.open, edges)
if behind != c.behind || why != c.why {
t.Errorf("relatedOpen = %q, %q; want %q, %q", behind, why, c.behind, c.why)
}
})
}
}
// A walk is on the controller's own path when its batch says so or it plans a module of that path (issue 973).
func TestAWalkOnTheControllersPathIsKnownByItsFlagOrItsModules(t *testing.T) {
if ownWalk(walkOf("novox/one", "notes")) {
t.Error("an ordinary walk read as on the controller's own path")
}
flagged := walkOf("novox/one", "notes")
flagged.Delivery = &inventory.PlanDelivery{Batch: &inventory.PlanBatch{Own: true}}
if !ownWalk(flagged) {
t.Error("a walk whose batch is own-path read as ordinary")
}
var module string
for m := range onTheControllersPath {
module = m
}
if !ownWalk(walkOf("novox/one", "notes", module)) {
t.Errorf("a walk planning %s read as ordinary", module)
}
}
// A walk about to ask its first tier waits only for a started walk it is related to (issue 973): started is a
// tier asked, or a walk made before it; a waiting walk, the backlog's and itself are not.
func TestWalksStartedBesideAreTheOpenOnesBeforeIt(t *testing.T) {
at := time.Date(2026, 10, 17, 12, 0, 0, 0, time.UTC)
mk := func(id string, created time.Time, tier int) inventory.Plan {
p := walkOf("novox/"+id, id)
p.ID, p.Created, p.Tier, p.State = id, created, tier, inventory.PlanBuilding
return p
}
older, newer, ticking := mk("older", at, 0), mk("newer", at.Add(time.Minute), 0), mk("ticking", at.Add(2*time.Minute), 1)
backlog := mk("backlog", at, 1)
backlog.Release = &inventory.PlanRelease{}
got := startedWalks([]inventory.Plan{older, newer, ticking, backlog, mk("me", at.Add(time.Minute), 0)}, "me", at.Add(time.Minute))
var ids []string
for _, p := range got {
ids = append(ids, p.ID)
}
if strings.Join(ids, ",") != "older,ticking" {
t.Errorf("started beside me = %v; want older, ticking", ids)
}
}
// A retry is refused beside an open walk it is related to and allowed beside one it shares nothing with
// (issue 973); a retry on the controller's own path is refused beside any open walk.
func TestARetryWaitsOnlyForAnOpenWalkItIsRelatedTo(t *testing.T) {
failed := walkOf("novox/one", "notes")
failed.ID, failed.State = "plan-1", inventory.PlanFailed
open := func(id, repo string, modules ...string) inventory.Plan {
p := walkOf(repo, modules...)
p.ID, p.State = id, inventory.PlanBuilding
return p
}
if err := oneWalkAtATime(failed, []inventory.Plan{open("plan-2", "novox/two", "app")}); err != nil {
t.Errorf("refused beside an unrelated walk: %v", err)
}
err := oneWalkAtATime(failed, []inventory.Plan{open("plan-3", "novox/three", "notes")})
if err == nil || !strings.Contains(err.Error(), "plan-3") || !strings.Contains(err.Error(), "both move notes") {
t.Errorf("a retry beside a walk moving the same module: %v", err)
}
failed.Delivery = &inventory.PlanDelivery{Batch: &inventory.PlanBatch{Own: true}}
if err := oneWalkAtATime(failed, []inventory.Plan{open("plan-2", "novox/two", "app")}); err == nil {
t.Error("an own-path retry went beside an open walk")
}
}