package main import ( "encoding/json" "fmt" "strings" "sync/atomic" "testing" "time" "mesh-delivery-view/internal/view" ) type fake map[string]string func (f fake) reader() view.Reader { return view.Reader{State: view.StateRead{ Keys: func() ([]string, error) { var k []string for key := range f { k = append(k, key) } return k, nil }, Get: func(key string) (json.RawMessage, error) { if v, ok := f[key]; ok { return json.RawMessage(v), nil } return nil, nil }, }} } func run(t *testing.T, r view.Reader, name string, args map[string]any) (any, error) { t.Helper() for _, tl := range Tools(r) { if tl.Name == name { return tl.Run(args) } } t.Fatalf("no tool %s", name) return nil, nil } const ( withFP2 = `{"v":3,"fp":"a1","fp2":"b2","manifest":"m1","inputs2":{"tree":"t1","bases":["x"]},"repository":"novox/r","commit":"c1","path":".","toolchains":{"go":{"version":"go1.27.1","recipe":"2"}}}` withNone = `{"v":3,"none":"no successful trunk build recorded its inputs","none2":"builder's fingerprint not yet known","repository":"novox/r","commit":"c2","path":"modules/b"}` ) // 1562/AC1: indexer_module answers the key's value as stored: FP, FP2, None2, Inputs2, Path and Toolchains are all there. func TestModuleToolAnswersTheStoredValue(t *testing.T) { r := fake{"a": withFP2, "b": withNone}.reader() for key, want := range map[string]string{"a": withFP2, "b": withNone} { out, err := run(t, r, view.ModuleReadTool, map[string]any{"module": key}) if err != nil { t.Fatal(err) } if got := string(out.(json.RawMessage)); got != want { t.Errorf("%s = %s, want %s", key, got, want) } } out, _ := run(t, r, view.ModuleReadTool, map[string]any{"module": "a"}) var m map[string]any if err := json.Unmarshal(out.(json.RawMessage), &m); err != nil || m["fp"] != "a1" || m["fp2"] != "b2" || m["path"] != "." || m["inputs2"] == nil || m["toolchains"] == nil { t.Errorf("stored fields are not all there: %v %v", m, err) } } // A module with no key, and a name that is no key, are said so. func TestModuleToolRefusesWhatIsNotThere(t *testing.T) { r := fake{"a": withFP2}.reader() if _, err := run(t, r, view.ModuleReadTool, map[string]any{"module": "gone"}); err == nil || !strings.Contains(err.Error(), "no module gone") { t.Errorf("a missing module: %v", err) } if _, err := run(t, r, view.ModuleReadTool, map[string]any{"module": ""}); err == nil { t.Errorf("an empty name was answered") } } // 1562/AC1: indexer_modules answers every key as stored, and with none2 only those that say why they have no FP2. func TestModulesToolAnswersEveryKeyAsStored(t *testing.T) { r := fake{"a": withFP2, "b": withNone}.reader() out, err := run(t, r, view.ModulesReadTool, map[string]any{}) if err != nil { t.Fatal(err) } all := out.(map[string]any)["modules"].(map[string]json.RawMessage) if len(all) != 2 || string(all["a"]) != withFP2 || string(all["b"]) != withNone { t.Errorf("modules = %v", all) } out, err = run(t, r, view.ModulesReadTool, map[string]any{"none2": true}) if err != nil { t.Fatal(err) } only := out.(map[string]any)["modules"].(map[string]json.RawMessage) if len(only) != 1 || string(only["b"]) != withNone { t.Errorf("none2 modules = %v", only) } } const ( fp2A = "src2:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" fp2B = "src2:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" keyA = "netcheck.aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" keyB = "zsh.bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" doneBuild = `{"v":1,"state":"done","by":"novox.mesh-delivery-builder","step":"built on shanks, ace","manifest":"m","artifacts":[{"name":"program","kind":"bundle","digest":"sha256:d1"}]}` failBuild = `{"v":1,"state":"failed","by":"novox.mesh-delivery-builder","error":"refused kind: package"}` ) func both(builds, modules fake) view.Reader { r := modules.reader() r.Builds = builds.reader().State return r } // 1723/AC1: builder_builds lists every key as stored (module, fingerprint, state, digests, error), and narrows by module and state. func TestBuilderBuildsListsKeysAsStored(t *testing.T) { r := both(fake{keyA: doneBuild, keyB: failBuild, "junk": "{}"}, fake{}) out, err := run(t, r, view.BuilderBuildsReadTool, map[string]any{}) if err != nil { t.Fatal(err) } rows := out.(view.BuildsAnswer).Builds if u := out.(view.BuildsAnswer).Unreadable; len(u) != 1 || u[0] != "junk" { t.Errorf("unreadable = %v, want [junk] (issue 1818)", u) } if len(rows) != 2 || rows[0].Key != keyA || rows[0].Module != "netcheck" || rows[0].Fingerprint != fp2A || string(rows[0].Build) != doneBuild || string(rows[1].Build) != failBuild { t.Errorf("rows = %+v", rows) } for args, want := range map[string]string{"state": keyB, "module": keyA} { out, _ := run(t, r, view.BuilderBuildsReadTool, map[string]any{args: map[string]string{keyB: "failed", keyA: "netcheck"}[want]}) got := out.(view.BuildsAnswer).Builds if len(got) != 1 || got[0].Key != want { t.Errorf("%s filter = %+v, want %s", args, got, want) } } } // 1723/AC2: builder_build answers one key by module and fp2, and by module alone through the indexer's fp2 (joined at its commit). func TestBuilderBuildJoinsTheIndexersFingerprint(t *testing.T) { idx := `{"v":3,"fp2":"` + fp2A + `","commit":"c1","path":"."}` r := both(fake{keyA: doneBuild}, fake{"netcheck": idx, "zsh": `{"v":3,"none2":"not known","commit":"c9"}`}) for _, args := range []map[string]any{{"module": "netcheck", "fp2": fp2A}, {"module": "netcheck"}, {"module": "netcheck", "commit": "c1"}} { out, err := run(t, r, view.BuilderBuildReadTool, args) if err != nil { t.Fatalf("%v: %v", args, err) } if row := out.(view.BuilderRow); row.Key != keyA || string(row.Build) != doneBuild { t.Errorf("%v = %+v", args, row) } } for args, want := range map[string]string{"netcheck/c2": "not c2", "zsh": "not known", "gone": "no module gone"} { a := map[string]any{"module": strings.Split(args, "/")[0]} if strings.Contains(args, "/") { a["commit"] = "c2" } if _, err := run(t, r, view.BuilderBuildReadTool, a); err == nil || !strings.Contains(err.Error(), want) { t.Errorf("%v: err = %v, want %q", a, err, want) } } if _, err := run(t, r, view.BuilderBuildReadTool, map[string]any{"module": "netcheck", "fp2": fp2B}); err == nil || !strings.Contains(err.Error(), "holds no build") { t.Errorf("an unbuilt fingerprint: %v", err) } } // bigBuilds is a bucket of n keys over 100 modules that counts the values read. type bigBuilds struct { fake fake reads *atomic.Int64 } func newBig(n int) bigBuilds { f := fake{} for i := 0; i < n; i++ { f[fmt.Sprintf("mod%02d.%064x", i%100, i)] = doneBuild } return bigBuilds{fake: f, reads: new(atomic.Int64)} } func (b bigBuilds) reader() view.Reader { r := b.fake.reader() get := r.State.Get r.Builds = view.StateRead{Keys: r.State.Keys, Get: func(k string) (json.RawMessage, error) { b.reads.Add(1); return get(k) }, GetEntry: func(k string) (json.RawMessage, uint64, error) { b.reads.Add(1) raw, err := get(k) var rev uint64 fmt.Sscanf(k[strings.Index(k, ".")+1:], "%x", &rev) // the fake's revision is the key's number return raw, rev + 1, err }} return r } // 1827/AC1: with 2,000 keys, a module's builds are found by key prefix, so only that module's values are read (20, not 2,000), // newest revision first, within the limit, and the answer says it was cut. func TestBuilderBuildsFiltersByPrefixBeforeAnyGet(t *testing.T) { b := newBig(2000) out, err := run(t, b.reader(), view.BuilderBuildsReadTool, map[string]any{"module": "mod07", "limit": float64(5)}) if err != nil { t.Fatal(err) } a := out.(view.BuildsAnswer) if b.reads.Load() != 20 { t.Errorf("read %d values for a module of 20 keys, want 20", b.reads.Load()) } if len(a.Builds) != 5 || !a.Cut || !strings.Contains(a.Note, "5") { t.Errorf("answer = %d rows, cut %v, note %q", len(a.Builds), a.Cut, a.Note) } for i := 1; i < len(a.Builds); i++ { if a.Builds[i-1].Revision < a.Builds[i].Revision { t.Errorf("not newest first: %d before %d", a.Builds[i-1].Revision, a.Builds[i].Revision) } } if a.Builds[0].Revision != 1908 { t.Errorf("newest revision = %d, want 1908", a.Builds[0].Revision) } } // 1827/AC2: with no module the answer reads at most the limit's worth in key order (default 50), never the 2,000. func TestBuilderBuildsWithoutAModuleIsBounded(t *testing.T) { b := newBig(2000) out, err := run(t, b.reader(), view.BuilderBuildsReadTool, map[string]any{}) if err != nil { t.Fatal(err) } a := out.(view.BuildsAnswer) if len(a.Builds) != view.DefaultBuildsLimit || !a.Cut || b.reads.Load() > view.DefaultBuildsLimit { t.Errorf("%d rows, cut %v, %d reads", len(a.Builds), a.Cut, b.reads.Load()) } b = newBig(2000) out, _ = run(t, b.reader(), view.BuilderBuildsReadTool, map[string]any{"state": "failed"}) if a := out.(view.BuildsAnswer); b.reads.Load() != view.MaxBuildReads || !a.Cut || len(a.Builds) != 0 { t.Errorf("a state matching nothing: %d reads (want the bound %d), cut %v", b.reads.Load(), view.MaxBuildReads, a.Cut) } } // 1818: fp2 together with commit is refused, naming both. func TestBuilderBuildRefusesFP2WithCommit(t *testing.T) { r := both(fake{keyA: doneBuild}, fake{"netcheck": `{"v":3,"fp2":"` + fp2A + `","commit":"c1","path":"."}`}) _, err := run(t, r, view.BuilderBuildReadTool, map[string]any{"module": "netcheck", "fp2": fp2A, "commit": "c1"}) if err == nil || !strings.Contains(err.Error(), "fp2") || !strings.Contains(err.Error(), "commit") || !strings.Contains(err.Error(), "not both") { t.Errorf("err = %v", err) } } // 1681: indexer_candidates and indexer_candidate answer the stored value; a pull request's fp2 per touched module shows. func TestCandidateToolsAnswerTheStoredValue(t *testing.T) { cand := `{"v":1,"group":"feat/x","base":3,"heads":"h","pulls":[{"modules":["zsh"],"fps":{"zsh":{"fp2":"` + fp2B + `","manifest":"m"}}}]}` other := `{"v":1,"group":"fix","base":3,"heads":"h","pulls":[{"modules":["netcheck"]}]}` r := fake{"feat/x": cand, "fix": other, "bad": "{"}.reader() r.CandidatesState = r.State out, err := run(t, r, view.CandidateReadTool, map[string]any{"group": "feat/x"}) if err != nil || string(out.(json.RawMessage)) != cand || !strings.Contains(string(out.(json.RawMessage)), fp2B) { t.Fatalf("candidate = %v, %v", out, err) } if _, err := run(t, r, view.CandidateReadTool, map[string]any{"group": "gone"}); err == nil || !strings.Contains(err.Error(), "no candidate gone") { t.Errorf("err = %v", err) } out, err = run(t, r, view.CandidatesReadTool, map[string]any{}) m := out.(map[string]any) if err != nil || len(m["candidates"].(map[string]json.RawMessage)) != 2 || len(m["unreadable"].([]string)) != 1 { t.Errorf("all = %v, %v", m, err) } out, _ = run(t, r, view.CandidatesReadTool, map[string]any{"module": "zsh"}) if c := out.(map[string]any)["candidates"].(map[string]json.RawMessage); len(c) != 1 || c["feat/x"] == nil { t.Errorf("by module = %v", c) } } // 1906/D1 AC1: assigner_wants, assigner_want, assigner_differences and node_haves answer as stored, narrowed by node and module. func keptReader() view.Reader { st := func(f fake) view.StateRead { return f.reader().State } return view.Reader{ WantsState: st(fake{ "want.ace.zsh": `{"fp":"` + fp2A + `"}`, "want.novox.zsh": `{"fp":"` + fp2B + `"}`, "want.ace.netcheck": `{"fp":"` + fp2A + `","rule":3}`, "held." + keyB: `{"node":"ace","words":"failed"}`, }), DifferencesState: st(fake{"ace.zsh": `{"unexplained":true}`, "novox.zsh": `{"rule":3}`}), HavesState: st(fake{"ace.zsh": `{"fp":"x"}`, "ace.node": `{"facts":1}`, "novox.zsh": `{"fp":"y"}`}), } } // slowBucket is a bucket of the live shape (2026-10-25: node_haves and assigner_wants timed out at 30 s): keys over 4 nodes // and perNode modules each, written by key(node, module), whose Get takes delay and counts the values read, the lists of // the bucket and the most Gets in flight at once. type slowBucket struct { fake fake delay time.Duration reads, lists atomic.Int64 inFlight, maxFlight atomic.Int64 } func newSlow(perNode int, delay time.Duration, key func(node string, i int) string) *slowBucket { b := &slowBucket{fake: fake{}, delay: delay} for _, n := range []string{"ace", "g14", "novox", "shanks"} { for i := 0; i < perNode; i++ { b.fake[key(n, i)] = `{"fp":"x"}` } } return b } func (b *slowBucket) state() view.StateRead { f := b.fake.reader().State return view.StateRead{ Keys: func() ([]string, error) { b.lists.Add(1); return f.Keys() }, Get: func(k string) (json.RawMessage, error) { b.reads.Add(1) now := b.inFlight.Add(1) for m := b.maxFlight.Load(); now > m && !b.maxFlight.CompareAndSwap(m, now); m = b.maxFlight.Load() { } time.Sleep(b.delay) b.inFlight.Add(-1) return f.Get(k) }} } func TestAssignerWantsToolAnswersAsStoredAndNarrows(t *testing.T) { r := keptReader() out, err := run(t, r, view.WantsReadTool, map[string]any{}) if err != nil { t.Fatal(err) } a := out.(view.WantsAnswer) if len(a.Wants) != 3 || len(a.Held) != 1 || string(a.Wants["want.ace.zsh"]) != `{"fp":"`+fp2A+`"}` { t.Errorf("all = %+v", a) } out, _ = run(t, r, view.WantsReadTool, map[string]any{"node": "ace"}) if got := out.(view.WantsAnswer).Wants; len(got) != 2 { t.Errorf("node ace = %v", got) } out, _ = run(t, r, view.WantsReadTool, map[string]any{"module": "zsh"}) if got := out.(view.WantsAnswer).Wants; len(got) != 2 { t.Errorf("module zsh = %v", got) } // On the live shape (4 nodes, 150 wants each, 1 ms a Get): a node and a module read that one key and list nothing; // a node reads only its own keys; the whole answer reads its values in parallel. slow := newSlow(150, time.Millisecond, func(n string, i int) string { return fmt.Sprintf("want.%s.m%03d", n, i) }) r = view.Reader{WantsState: slow.state()} for _, row := range []struct { args map[string]any wants int reads, lists int64 }{ {map[string]any{"node": "ace", "module": "m007"}, 1, 1, 0}, {map[string]any{"node": "ace", "module": "gone"}, 0, 1, 0}, {map[string]any{"node": "g14"}, 150, 150, 1}, {map[string]any{"module": "m007"}, 4, 4, 1}, {map[string]any{}, 600, 600, 1}, } { slow.reads.Store(0) slow.lists.Store(0) start := time.Now() out, err := run(t, r, view.WantsReadTool, row.args) took := time.Since(start) if err != nil { t.Fatalf("%v: %v", row.args, err) } a := out.(view.WantsAnswer) if len(a.Wants) != row.wants || slow.reads.Load() != row.reads || slow.lists.Load() != row.lists || a.Cut { t.Errorf("%v: %d wants, %d reads, %d lists, cut %v; want %d, %d, %d", row.args, len(a.Wants), slow.reads.Load(), slow.lists.Load(), a.Cut, row.wants, row.reads, row.lists) } t.Logf("assigner_wants %v: %d values in %v (one after the other: %v)", row.args, slow.reads.Load(), took, time.Duration(slow.reads.Load())*slow.delay) } if slow.maxFlight.Load() < 2 { t.Errorf("the values were read one at a time (at most %d in flight)", slow.maxFlight.Load()) } } func TestAssignerWantToolAnswersOneAndSaysWhenThereIsNone(t *testing.T) { r := keptReader() out, err := run(t, r, view.WantReadTool, map[string]any{"node": "ace", "module": "zsh"}) if err != nil || string(out.(json.RawMessage)) != `{"fp":"`+fp2A+`"}` { t.Errorf("want = %v %v", out, err) } if _, err := run(t, r, view.WantReadTool, map[string]any{"node": "ace", "module": "gone"}); err == nil || !strings.Contains(err.Error(), "no want") { t.Errorf("a missing want: %v", err) } } func TestAssignerDifferencesAndNodeHavesToolsAnswerAsStored(t *testing.T) { r := keptReader() out, err := run(t, r, view.DifferencesReadTool, map[string]any{"node": "novox"}) if err != nil { t.Fatal(err) } if got := out.(view.KeptAnswer).Keys; len(got) != 1 || string(got["novox.zsh"]) != `{"rule":3}` { t.Errorf("differences = %v", got) } out, _ = run(t, r, view.HavesReadTool, map[string]any{"module": "zsh"}) if got := out.(view.KeptAnswer).Keys; len(got) != 2 { t.Errorf("haves of zsh = %v", got) } out, _ = run(t, r, view.HavesReadTool, map[string]any{"node": "ace"}) if got := out.(view.KeptAnswer).Keys; len(got) != 2 || got["ace.node"] == nil { t.Errorf("haves of ace (the node's own key too) = %v", got) } // More haves than one call reads (4 nodes, 300 each, 1 ms a Get): a node and a module read that one key and list // nothing; the whole answer reads MaxStateReads values in parallel and says it was cut. slow := newSlow(300, time.Millisecond, func(n string, i int) string { return fmt.Sprintf("%s.m%03d", n, i) }) r = view.Reader{HavesState: slow.state(), DifferencesState: slow.state()} for _, row := range []struct { tool string args map[string]any keys int reads, lists int64 cut bool }{ {view.HavesReadTool, map[string]any{"node": "shanks", "module": "m042"}, 1, 1, 0, false}, {view.DifferencesReadTool, map[string]any{"node": "shanks", "module": "m042"}, 1, 1, 0, false}, {view.HavesReadTool, map[string]any{"node": "ace"}, 300, 300, 1, false}, {view.HavesReadTool, map[string]any{"module": "m042"}, 4, 4, 1, false}, {view.HavesReadTool, map[string]any{}, view.MaxStateReads, view.MaxStateReads, 1, true}, } { slow.reads.Store(0) slow.lists.Store(0) start := time.Now() out, err := run(t, r, row.tool, row.args) took := time.Since(start) if err != nil { t.Fatalf("%s %v: %v", row.tool, row.args, err) } a := out.(view.KeptAnswer) if len(a.Keys) != row.keys || slow.reads.Load() != row.reads || slow.lists.Load() != row.lists || a.Cut != row.cut || (a.Cut && !strings.Contains(a.Note, "narrow")) { t.Errorf("%s %v: %d keys, %d reads, %d lists, cut %v %q; want %d, %d, %d, %v", row.tool, row.args, len(a.Keys), slow.reads.Load(), slow.lists.Load(), a.Cut, a.Note, row.keys, row.reads, row.lists, row.cut) } t.Logf("%s %v: %d values in %v (one after the other: %v)", row.tool, row.args, slow.reads.Load(), took, time.Duration(slow.reads.Load())*slow.delay) } if slow.maxFlight.Load() < 2 { t.Errorf("the values were read one at a time (at most %d in flight)", slow.maxFlight.Load()) } }