The indexer's recompute and merge failed on listing a graph bucket not yet on the bus, and a package published from inside a module's directory had no publisher, so the dependents of a build module missed what imports its packages (review findings 3 and 4 of L3). Claude-Session: https://claude.ai/code/session_01JG73KG7AfSdh6FP8VrBqCn
189 lines
8.4 KiB
Go
189 lines
8.4 KiB
Go
package graph
|
|
|
|
import (
|
|
"errors"
|
|
"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)
|
|
}
|
|
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 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)
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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"}}
|
|
}
|
|
|
|
// 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"},
|
|
} {
|
|
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)
|
|
}
|
|
}
|
|
}
|