Files
mesh-delivery/sdk/graph/graph_test.go
T
jschoubben 702b19fdb1 view_tiers names each wait by its edge, a verb, a reader capability or bus state, and every verb edge left out (ADR 0614 §2-§5; issue 774)
The words of a want held on a verb or a reader capability are written and read in one place, the module graph's
package: the assigner writes them, the view names the edge from them. A bus-state wait is named bus state, and each
module's verb edges the graph leaves out on a cycle are listed beside its waits.

Claude-Session: https://claude.ai/code/session_01JG73KG7AfSdh6FP8VrBqCn
2026-11-03 23:26:25 +01:00

254 lines
12 KiB
Go

package graph
import (
"errors"
"maps"
"reflect"
"slices"
"strings"
"testing"
"git.novox.be/novox/mesh-sdk/go/catalogue"
"git.novox.be/novox/mesh-sdk/go/delivery"
)
// TestGraphEdges: the module graph's cycle, dependents and providers, each from the edges alone (ADR 0603 §2, ADR 0604 §1).
func TestGraphEdges(t *testing.T) {
t.Run("a node's value", nodeValues)
fp := "src2:" + strings.Repeat("b", 64)
node := func(repository, path string, edit func(*Node)) Node {
n := Node{FP: fp, Repository: repository, Path: path}
if edit != nil {
edit(&n)
}
return n
}
gotool := map[string]Toolchain{"go": {Module: "build-go", Version: "go1", Recipe: "1"}}
// A base chain, a package and a build module: app stands on base, imports lib's package, is built by build-go.
g := Graph{
"build-go": node("novox/mesh-catalog", "modules/build-go", func(n *Node) { n.Toolchains = gotool }),
"lib": node("novox/lib", delivery.RootPath, nil),
"base": node("novox/mesh-catalog", "modules/base", nil),
"app": node("novox/mesh-catalog", "modules/app", func(n *Node) {
n.Bases, n.Packages, n.Toolchains = []string{"base"}, []string{"git.novox.be/novox/lib"}, gotool
}),
"top": node("novox/mesh-catalog", "modules/top", func(n *Node) { n.Bases = []string{"app"} }),
// A package published from inside a module's directory belongs to that module (ADR 0604 §1): users imports build-go's.
"users": node("novox/other", "modules/users", func(n *Node) {
n.Packages = []string{"git.novox.be/novox/mesh-catalog/modules/build-go/buildrun"}
}),
"db": node("novox/mesh-catalog", "modules/db", func(n *Node) { n.Provides = []string{"database"} }),
"web": node("novox/mesh-catalog", "modules/web", func(n *Node) {
n.Requires, n.Awaits = []string{"database", "db"}, []string{"database"}
}),
}
for module, want := range map[string][]string{
"base": {"app", "top"},
"lib": {"app", "top"},
"build-go": {"app", "top", "users"}, // build-go's own toolchain edge reaches itself, which is left out
"top": nil,
"db": nil, // a provision edge is no build edge
} {
if got := g.Dependents(module); !reflect.DeepEqual(got, want) {
t.Errorf("dependents of %s = %v, want %v", module, got, want)
}
}
if got := g.Providers("database"); !reflect.DeepEqual(got, []string{"db"}) {
t.Errorf("providers of database = %v", got)
}
if got := g.Providers("db"); !reflect.DeepEqual(got, []string{"db"}) { // a module provides its own name
t.Errorf("providers of db = %v", got)
}
if got := g.Before("web"); !reflect.DeepEqual(got, []string{"db"}) { // web awaits database, which db provides
t.Errorf("web waits for %v", got)
}
if got := g.Before("top"); !reflect.DeepEqual(got, []string{"app"}) {
t.Errorf("top waits for %v", got)
}
if got := g.Requirers("db"); !reflect.DeepEqual(got, []string{"web"}) {
t.Errorf("requirers of db = %v", got)
}
for pkg, want := range map[string]string{
"git.novox.be/novox/mesh-catalog/modules/build-go/buildrun": "build-go", // inside its directory
"git.novox.be/novox/mesh-catalog/modules/build-go": "build-go", // its directory
"git.novox.be/novox/mesh-catalog/modules/build-gone": "", // beside it, not inside
"git.novox.be/novox/lib/sub": "lib", // a root module publishes what is below it
} {
if got := g.Publisher(pkg); got != want {
t.Errorf("publisher of %s = %q, want %q", pkg, got, want)
}
}
if g.Publisher("git.novox.be/novox/lib") != "lib" || g.Publisher("git.novox.be/novox/mesh-sdk/go") != "" {
t.Error("a package's publisher is not the module at its repository and directory")
}
// From manifests alone (the check's graph): a requirement answered by a helper at mesh scope is awaited, an optional
// one is not, and two modules awaiting each other's provision are a cycle.
offer := func(name string) []catalogue.Offer {
return []catalogue.Offer{{Name: name, Scope: catalogue.ScopeMesh, AnsweredBy: catalogue.AnsweredByHelper}}
}
ms := map[string]catalogue.Manifest{
"db": {Module: "db", Provides: offer("database"), Requires: []string{"site", "logs"}, OptionalRequires: map[string]bool{"logs": true}},
"web": {Module: "web", Provides: offer("site"), Requires: []string{"database"}, Build: &catalogue.Build{On: []catalogue.BuildsOn{{Module: "base"}}}},
}
mg := OfManifests(ms)
if db := mg["db"]; !reflect.DeepEqual(db.Requires, []string{"site"}) || !reflect.DeepEqual(db.Awaits, []string{"site"}) ||
!reflect.DeepEqual(mg["web"].Bases, []string{"base"}) || !reflect.DeepEqual(db.Provides, []string{"database"}) {
t.Errorf("the graph of manifests = %+v", mg)
}
if got := mg.Cycle(); !reflect.DeepEqual(got, []string{"db", "web", "db"}) {
t.Errorf("the cycle of manifests = %v", got)
}
// The verb and bus-state edges (ADR 0614 §2, §3, §5, §6), from manifests alone: a caller waits for the module serving
// what it invokes, a bus-state entry requires bus-state of the bus module offering it, the builder's remove and the
// holder's builder_versions close a cycle and both are left out, read from either side, a package edge gives no order,
// and a module invoking its own tool is no wait.
builder := catalogue.Manifest{Module: "builder", Tools: []string{"builder_versions"}, Invokes: []string{"seat:artifact-store.remove"}}
store := catalogue.Manifest{Module: "store", Claims: []catalogue.Claim{{Name: "artifact-store", Scope: catalogue.ScopeMesh,
Serves: []string{"remove"}}}, Invokes: []string{"builder.builder_versions", "*"}}
verbs := map[string]catalogue.Manifest{
"builder": builder, "store": store,
"bus": {Module: "bus", Provides: []catalogue.Offer{{Name: BusState, Scope: catalogue.ScopeMesh}}},
"keeper": {Module: "keeper", BusState: []catalogue.BusStateEntry{{Name: "cache", Storage: "key-value"}}},
"caller": {Module: "caller", Invokes: []string{"seat:artifact-store.remove", "caller.own", "nobody.serves"}, Tools: []string{"own"}},
}
vg := OfManifests(verbs)
for name, want := range map[string][]string{"caller": {"store"}, "keeper": {"bus"}, "builder": nil, "store": nil} {
if got := vg.Before(name); !reflect.DeepEqual(got, want) {
t.Errorf("%s waits for %v, want %v", name, got, want)
}
}
if b, s := vg["builder"], vg["store"]; !reflect.DeepEqual(b.LeftOut, []string{"seat:artifact-store.remove"}) ||
!reflect.DeepEqual(s.LeftOut, []string{"builder.builder_versions"}) ||
!reflect.DeepEqual(s.Serves, []string{"seat:artifact-store.remove"}) || !reflect.DeepEqual(s.Invokes, []string{"builder.builder_versions"}) {
t.Errorf("the builder's cycle is not left out from either side: %+v %+v", b, s)
}
if k := vg["keeper"]; !reflect.DeepEqual(k.Requires, []string{BusState}) || !reflect.DeepEqual(k.Awaits, []string{BusState}) {
t.Errorf("a bus-state entry is no requirement of bus-state: %+v", k)
}
if c := vg["caller"]; !reflect.DeepEqual(c.LeftOut, []string{"caller.own"}) || vg.Cycle() != nil {
t.Errorf("a module's own tool is a wait, or a left-out verb edge a cycle: %+v %v", c, vg.Cycle())
}
if got := OfManifests(map[string]catalogue.Manifest{"keeper": verbs["keeper"]})["keeper"]; got.Awaits != nil {
t.Errorf("bus-state with no module offering it is awaited: %+v", got)
}
// The words of a want held on a verb or a reader capability name their edge, and no other words do.
for words, want := range map[string]string{
VerbWaitWords("seat:mesh-catalog.modules", "artifact-store-tools", "g14"): "verb seat:mesh-catalog.modules",
ReaderWaitWords("g14", []string{"tool-runner", "bus-state"}, "node-tools", "src2:abcdef"): "reader capability tool-runner, bus-state",
"the verb is served": "",
"the node-engine of g14 has not started": "",
} {
if got := EdgeOfWords(words); got != want {
t.Errorf("the edge of %q = %q, want %q", words, got, want)
}
}
for name, c := range map[string]struct {
edit func(Graph)
want []string
}{
"none: a toolchain on itself and a requirement not awaited order nothing": {func(Graph) {}, nil},
"bases": {func(g Graph) {
b := g["base"]
b.Bases = []string{"top"}
g["base"] = b
}, []string{"app", "base", "top", "app"}},
"provisions a -> b -> a": {func(g Graph) {
d := g["db"]
d.Requires, d.Awaits = []string{"site"}, []string{"site"}
g["db"] = d
w := g["web"]
w.Provides = []string{"site"}
g["web"] = w
}, []string{"db", "web", "db"}},
"a package cycle orders nothing": {func(g Graph) {
l := g["lib"]
l.Packages = []string{"git.novox.be/novox/mesh-catalog/modules/app"}
g["lib"] = l
}, nil},
"a verb edge with a provision, before LeaveOut leaves it out": {func(g Graph) {
d := g["db"]
d.Invokes = []string{"web.page"}
g["db"] = d
w := g["web"]
w.Serves = []string{"web.page"}
g["web"] = w
}, []string{"db", "web", "db"}},
"a requirement not awaited is no edge": {func(g Graph) {
d := g["db"]
d.Requires = []string{"site"}
g["db"] = d
w := g["web"]
w.Provides = []string{"site"}
g["web"] = w
}, nil},
} {
h := Graph{}
for k, v := range g {
h[k] = v
}
c.edit(h)
if got := h.Cycle(); !reflect.DeepEqual(got, c.want) {
t.Errorf("%s: cycle = %v, want %v", name, got, c.want)
}
if h.LeaveOut(); h.Cycle() != nil && slices.ContainsFunc(slices.Collect(maps.Values(h)), func(n Node) bool { return len(n.Invokes) > 0 }) {
t.Errorf("%s: a cycle through a verb edge is left once its verb edges are left out: %v", name, h.Cycle())
}
}
}
// sampleNode is a graph node with every kind of edge, its lists unsorted.
func sampleNode() Node {
return Node{FP: "src2:" + strings.Repeat("a", 64), Repository: "novox/mesh-catalog", Path: "modules/gitea",
Bases: []string{"z-base", "a-base"}, Packages: []string{"git.novox.be/novox/mesh-sdk/go"},
Toolchains: map[string]Toolchain{"go": {Module: "build-go", Version: "go1.27.1", Recipe: "3"}},
Provides: []string{"forge"}, Requires: []string{"postgres-database", "dns"}, Awaits: []string{"postgres-database"},
Invokes: []string{"seat:mesh-catalog.modules", "builder.builder_versions"}, Serves: []string{"seat:forge.pulls"},
LeftOut: []string{"builder.builder_versions"}}
}
// nodeValues: a node round-trips with its lists sorted, an unknown version is refused, a node that does not hold is
// refused naming why, and the state is the indexer's graph (ADR 0603 §2).
func nodeValues(t *testing.T) {
if Bucket != "mesh-delivery-indexer_graph" || Read != "mesh-delivery-indexer.graph" || History != 5 || State != "graph" {
t.Error("the graph's state differs from the decided layout")
}
g := sampleNode()
data, err := Encode(g)
if err != nil || !strings.Contains(string(data), `"v":1`) {
t.Fatalf("encode: %s %v", data, err)
}
if got, err := Decode(data); err != nil || !Same(got, g) || !slices.IsSorted(got.Bases) {
t.Errorf("round trip: %+v %v", got, err)
}
if Same(g, Node{None: "x", Repository: "a/b"}) {
t.Error("different graph nodes are the same")
}
for _, in := range []string{`{"none":"x","repository":"a/b"}`, `{"v":2,"none":"x","repository":"a/b"}`} {
if _, err := Decode([]byte(in)); !errors.Is(err, ErrUnknownVersion) {
t.Errorf("decode of %s = %v, want ErrUnknownVersion", in, err)
}
}
for name, c := range map[string]struct {
change func(*Node)
word string
}{
"both": {func(g *Node) { g.None = "why" }, "both"},
"neither": {func(g *Node) { g.FP = "" }, "neither"},
"an old form": {func(g *Node) { g.FP = "src1:x" }, "src2:"},
"a dotted base": {func(g *Node) { g.Bases = []string{"a.b"} }, "a.b"},
"a foreign package": {func(g *Node) { g.Packages = []string{"example.com/x"} }, "example.com/x"},
"a toolchain of no module": {func(g *Node) { g.Toolchains = map[string]Toolchain{"go": {Version: "go1", Recipe: "1"}} }, "build module"},
"a toolchain of no recipe": {func(g *Node) { g.Toolchains = map[string]Toolchain{"go": {Module: "b", Version: "go1"}} }, "recipe"},
"awaiting what it does not require": {func(g *Node) { g.Awaits = []string{"other"} }, "other"},
"no repository": {func(g *Node) { g.Repository = "" }, "repositor"},
"leaving out what it does not invoke": {func(g *Node) { g.LeftOut = []string{"x.y"} }, "x.y"},
"an invoke of no verb": {func(g *Node) { g.Invokes = []string{"seat:nothing"} }, "seat:nothing"},
} {
g := sampleNode()
c.change(&g)
if _, err := Encode(g); err == nil || !strings.Contains(err.Error(), c.word) {
t.Errorf("a node with %s: %v, want it refused naming %q", name, err, c.word)
}
}
}