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", BusStateWaitWords("keeper", "g14"): "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) } } }