294 lines
14 KiB
Go
294 lines
14 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"os"
|
|
"slices"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/novox/mesh-controller/internal/catalogue"
|
|
"github.com/novox/mesh-controller/internal/inventory"
|
|
"github.com/novox/mesh-controller/internal/link"
|
|
)
|
|
|
|
// The suite's tests of a merge, but for the merge window's own (this file's), plan the merge as it is heard: a
|
|
// window of nothing closes at once, so a merge with no walk open is cut into its walk at once, as every merge
|
|
// was planned before novox/hq ADR 0276. The window's tests set it through the controller's settings.
|
|
func init() {
|
|
mergeWindowDefault = 0
|
|
// No forge in a test: no manifest is found at any commit, so a merge draws no edge of the manifests' change
|
|
// (delivery_edges.go). A test that wants some replaces these (forgeHolding).
|
|
manifestAt = func(context.Context, string, string, string, string) (catalogue.Manifest, bool, error) {
|
|
return catalogue.Manifest{}, false, nil
|
|
}
|
|
parentOf = func(context.Context, string, string, string) (string, error) { return "", nil }
|
|
}
|
|
|
|
// windowed is a mesh whose controller's settings give a merge window of 90 seconds and at most 10 minutes, as its
|
|
// settings file says them, and
|
|
// modules built from the catalogue (app, notes) and from three repositories of their own.
|
|
|
|
// t0 is the moment a test's clock starts: merges are dated by it, and heard after it.
|
|
var t0 = time.Now().UTC().Truncate(time.Second)
|
|
|
|
// catalogueMerge is a merge of the catalogue changing one module's directory, made at a moment, from a pull
|
|
// request's head branch of its own, as the forge announces every merge. Whether its head names a delivery group
|
|
// with a member still to merge is mesh-delivery's word, which windowed stubs as no group at all (noGroups); a test
|
|
// whose merges wait for the window as a group's says so (groupsWaiting).
|
|
func catalogueMerge(commit, module string, made time.Time) link.SourceMoved {
|
|
return link.SourceMoved{Owner: "novox", Repo: "mesh-catalog", Base: "main", Head: "pr/" + commit, Commit: commit,
|
|
MergedAt: made.Format(time.RFC3339Nano), Paths: []string{"modules/" + module + "/module.json"},
|
|
ModuleDirs: []string{"modules/" + module}, ModuleDirsSaid: true}
|
|
}
|
|
|
|
// repoMerge is a merge of a repository of one module's own, from a head branch of its own (catalogueMerge).
|
|
func repoMerge(repo, commit string, made time.Time) link.SourceMoved {
|
|
return link.SourceMoved{Owner: "novox", Repo: repo, Base: "main", Head: "pr/" + commit, Commit: commit,
|
|
MergedAt: made.Format(time.RFC3339Nano), Paths: []string{"main.go"}}
|
|
}
|
|
|
|
// noGroups stubs mesh-delivery's word on groups for a test: no head names a group.
|
|
func noGroups(t *testing.T) {
|
|
t.Helper()
|
|
was := deliveryGroupOf
|
|
deliveryGroupOf = func(context.Context, bool, string) ([]groupMember, bool, error) { return nil, false, nil }
|
|
t.Cleanup(func() { deliveryGroupOf = was })
|
|
}
|
|
|
|
// groupsWaiting stubs mesh-delivery's word on groups for a test of the merge window's own timing: each head named
|
|
// is one whose group mesh-delivery cannot say, so a merge from it waits for the window (ADR 0306 decision 2) and
|
|
// nothing holds the batch open; every other head names no group. A group with a member still to merge, which holds
|
|
// its batch open however long it takes (ADR 0418 clause 3), is groupStates'.
|
|
func groupsWaiting(t *testing.T, heads ...string) {
|
|
t.Helper()
|
|
was := deliveryGroupOf
|
|
deliveryGroupOf = func(_ context.Context, _ bool, head string) ([]groupMember, bool, error) {
|
|
if slices.Contains(heads, head) {
|
|
return nil, false, errors.New("the test's mesh-delivery does not say")
|
|
}
|
|
return nil, false, nil
|
|
}
|
|
t.Cleanup(func() { deliveryGroupOf = was })
|
|
}
|
|
|
|
// everyHeadWaits stubs mesh-delivery's word on groups for a test whose merges each wait for the window as they did
|
|
// before novox/hq ADR 0304: mesh-delivery cannot say any head's group.
|
|
func everyHeadWaits(t *testing.T) {
|
|
t.Helper()
|
|
was := deliveryGroupOf
|
|
deliveryGroupOf = func(context.Context, bool, string) ([]groupMember, bool, error) {
|
|
return nil, false, errors.New("the test's mesh-delivery does not say")
|
|
}
|
|
t.Cleanup(func() { deliveryGroupOf = was })
|
|
}
|
|
|
|
// groupStates stubs mesh-delivery's word on one group: its members and their states, which a test changes as the
|
|
// members merge.
|
|
func groupStates(t *testing.T, head string, states map[string]string) {
|
|
t.Helper()
|
|
groupStatesFrom(t, head, states, true)
|
|
}
|
|
|
|
// groupStatesFrom is groupStates for a mesh-delivery that does (composed) or does not yet say the group's gate is the
|
|
// composed check's: only the first holds the group's batch open for its last member.
|
|
func groupStatesFrom(t *testing.T, head string, states map[string]string, composed bool) {
|
|
t.Helper()
|
|
was := deliveryGroupOf
|
|
deliveryGroupOf = func(_ context.Context, _ bool, asked string) ([]groupMember, bool, error) {
|
|
if asked != head {
|
|
return nil, false, nil
|
|
}
|
|
var members []groupMember
|
|
for repository, state := range states {
|
|
members = append(members, groupMember{Repository: repository, State: state, Composed: composed})
|
|
}
|
|
return members, true, nil
|
|
}
|
|
t.Cleanup(func() { deliveryGroupOf = was })
|
|
}
|
|
|
|
// fromHead is a merge from a head branch a test names: merges from one head are members of one delivery group,
|
|
// related by it (novox/hq ADR 0304 decision 1), so they share a batch whatever else they share.
|
|
func fromHead(m link.SourceMoved, head string) link.SourceMoved {
|
|
m.Head = head
|
|
return m
|
|
}
|
|
|
|
// lone is a merge that names no head: known to be no delivery group's, it waits for no window unless it is
|
|
// related to a batch assembling (ADR 0304 decision 1).
|
|
func lone(m link.SourceMoved) link.SourceMoved {
|
|
m.Head = ""
|
|
return m
|
|
}
|
|
|
|
// hear is a merge heard at a moment.
|
|
|
|
// cutAt is the cutter looking at a moment.
|
|
|
|
// walks is every plan record that is a walk, newest first, and the batches not yet cut.
|
|
func walks(t *testing.T, open *stores) (walks, batches []inventory.Plan) {
|
|
t.Helper()
|
|
recent, err := open.inventory.RecentPlans(t.Context(), 50)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, p := range recent {
|
|
if p.Batch() {
|
|
batches = append(batches, p)
|
|
} else {
|
|
walks = append(walks, p)
|
|
}
|
|
}
|
|
return walks, batches
|
|
}
|
|
|
|
// The replay of issue 362: two catalogue merges 18 seconds apart are one batch, cut once into one walk at the
|
|
// later commit, which names both merges — the earlier carried by the later.
|
|
|
|
// Merges of three repositories in one window are one walk, one commit for each.
|
|
|
|
// Each merge restarts the window: a merge at second 80 keeps the batch open until second 170; the window
|
|
// closes at most ten minutes after its first merge however busy.
|
|
|
|
// `plans` lists the batch being assembled first, in the operator's words: how long is left, when at the latest,
|
|
// what is grouped, and that the plan is not yet calculated. It says so on the bus as plan-moved, too.
|
|
|
|
// captured is what a command printed.
|
|
|
|
// A merge heard during a walk it is related to (the same repository, issue 973) joins the next batch, which is
|
|
// cut when the walk ends; the walk that started is never superseded (ADR 0276 decision 4, ADR 0407 decision 4).
|
|
|
|
// A walk waiting for its delivery's word is folded into the next batch's walk, which answers its merges; it
|
|
// names the walk that took it over.
|
|
|
|
// A merge heard after a later merge of its branch was walked is answered by that walk when it builds all it
|
|
// moves — named at once on a walk done — and joins the next batch when it does not.
|
|
|
|
// A failed walk marks nothing delivered: its earlier merges are walked alone, on their own commit, the newest
|
|
// first; the first delivered answers the older ones, and the search stops.
|
|
|
|
// A delivery group's order holds inside the walk (ADR 0249 unchanged): merges of two repositories from one head
|
|
// branch name, the node-engine's before the controller's, are tiered so; without the group, one tier.
|
|
|
|
// A restarted controller resumes the window where it stood: the batch, its merges and their times are in the
|
|
// store, a merge handed over again is not doubled, and the batch is cut when its window closes.
|
|
|
|
// The window's settings reach the controller in its settings file, read at every look; one that says no
|
|
// duration, or no file, is the default.
|
|
func TestTheWindowIsTheControllersSetting(t *testing.T) {
|
|
file := t.TempDir() + "/merge-window.json"
|
|
t.Setenv(mergeWindowFileVar, file)
|
|
for _, c := range []struct {
|
|
content string
|
|
window, atMost time.Duration
|
|
}{
|
|
{`{"merge-window": "60s", "merge-window-at-most": "5m"}`, time.Minute, 5 * time.Minute},
|
|
{`{"merge-window": 30, "merge-window-at-most": ""}`, 30 * time.Second, mergeWindowAtMostDefault},
|
|
{`{"merge-window": "soon"}`, mergeWindowDefault, mergeWindowAtMostDefault},
|
|
} {
|
|
if err := os.WriteFile(file, []byte(c.content), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if w, m := mergeWindowOf(); w != c.window || m != c.atMost {
|
|
t.Errorf("%s reads as %s and %s, want %s and %s", c.content, w, m, c.window, c.atMost)
|
|
}
|
|
}
|
|
t.Setenv(mergeWindowFileVar, file+".gone")
|
|
if w, m := mergeWindowOf(); w != mergeWindowDefault || m != mergeWindowAtMostDefault {
|
|
t.Errorf("no file reads as %s and %s", w, m)
|
|
}
|
|
}
|
|
|
|
// S16 names every merge a waiting walk answers, never one commit a supersession may have ended (issue 362).
|
|
func TestAWaitingWalkNamesTheMergesItAnswers(t *testing.T) {
|
|
f := calm(t0)
|
|
f.waits = []waitFacts{{id: "plan-2", repository: "novox/mesh-catalog", commit: "48bda475dunst", awaits: "mesh-delivery",
|
|
since: t0.Add(-31 * time.Minute), merges: []inventory.PlanMerge{
|
|
{Repository: "novox/mesh-catalog", Commit: "553b7191claude", Carried: "48bda475dunst"},
|
|
{Repository: "novox/mesh-catalog", Commit: "48bda475dunst"}}}}
|
|
got := watchWaits(f)
|
|
if len(got) != 1 || !strings.Contains(got[0].Summary, "novox/mesh-catalog@553b7191") ||
|
|
!strings.Contains(got[0].Summary, "novox/mesh-catalog@48bda475") {
|
|
t.Fatalf("the waiting walk does not name both merges: %+v", got)
|
|
}
|
|
}
|
|
|
|
// A merge heard after a later merge of its branch was cut is named by that walk however its modules read since
|
|
// the cut, in a repository of one module (review of this change); a walk that never built what it moves names
|
|
// nothing, and the merge joins the next batch.
|
|
|
|
// A related walk waits for the delivery's word too: a waiting walk let go beside a started one it is related to
|
|
// (the same repository, issue 973) takes the word and starts once that one ended, asking nothing before.
|
|
|
|
// A file a merge of the batch removed and a later one brought back is not removed; one removed last is.
|
|
func TestARemovedFileIsTheLastMergesWord(t *testing.T) {
|
|
ev := func(commit string, paths, removed []string) inventory.BatchedMerge {
|
|
m := link.SourceMoved{Owner: "novox", Repo: "one", Base: "main", Commit: commit, Paths: paths, Removed: removed}
|
|
b, _ := json.Marshal(m)
|
|
return inventory.BatchedMerge{Repository: "novox/one", Branch: "main", Commit: commit, Event: b,
|
|
Merged: t0.Add(time.Duration(len(commit)) * time.Second)}
|
|
}
|
|
got := combinedMerges([]inventory.BatchedMerge{
|
|
ev("a", []string{"x/module.json", "y/module.json"}, []string{"x/module.json", "y/module.json"}),
|
|
ev("bb", []string{"x/module.json"}, nil),
|
|
ev("ccc", []string{"z.go"}, nil)})
|
|
if len(got) != 1 || got[0].Commit != "ccc" || !slices.Equal(got[0].Removed, []string{"y/module.json"}) ||
|
|
len(got[0].Paths) != 3 {
|
|
t.Fatalf("combined as %+v", got)
|
|
}
|
|
}
|
|
|
|
// A merge on the controller's own path never shares a batch with one that waits for mesh-delivery's word
|
|
// (decided during the build, 2026-10-10): the two are batches of their own, cut one after the other; a walk on
|
|
// the own path folds no waiting catalogue walk but batches its merges again behind it; the catalogue's walk
|
|
// still waits for the word, so no catalogue delivery skips its turn.
|
|
|
|
// `plans` names a walk by what it moves (asked by the operator, 2026-10-10): the repository's pull request and
|
|
// title, the modules it moves and the machines running them, then the state words; a record naming no pull
|
|
// request is named by its commit, and a title is cut at fifty runes.
|
|
func TestAPlanLineNamesWhatItMoves(t *testing.T) {
|
|
now := time.Date(2026, 10, 10, 12, 0, 0, 0, time.UTC)
|
|
p := inventory.Plan{ID: "plan-1", Repository: "novox/mesh-catalog", Branch: "main", Commit: "c1c1c1c1c1",
|
|
State: inventory.PlanBuilding, Tiers: [][]string{{"messenger", "telegram"}}, TierEntered: now.Add(-2 * time.Minute),
|
|
Modules: map[string]*inventory.PlanModule{"messenger": {State: "asked"}, "telegram": {State: "asked"}},
|
|
Commits: []inventory.PlanCommit{{Repository: "novox/mesh-catalog", Branch: "main", Commit: "c1c1c1c1c1"}},
|
|
Delivery: &inventory.PlanDelivery{Merges: []inventory.PlanMerge{{Repository: "novox/mesh-catalog",
|
|
Commit: "c1c1c1c1c1", Number: 175, Title: "a tap shows its outcome", Moves: []string{"telegram", "messenger"}}}}}
|
|
running := func(module string) []string {
|
|
if module == "messenger" || module == "telegram" {
|
|
return []string{"novox"}
|
|
}
|
|
return nil
|
|
}
|
|
if got, want := planLineOn(p, now, pauseView{}, tierAtLeast, running),
|
|
"mesh-catalog #175 a tap shows its outcome · messenger, telegram → novox · tier 1 of 1, building for 2m0s"; got != want {
|
|
t.Fatalf("the line reads %q, want %q", got, want)
|
|
}
|
|
if got := planLine(p, now); !strings.HasPrefix(got, "mesh-catalog #175 a tap shows its outcome · messenger, telegram · tier") {
|
|
t.Fatalf("without the machines the line reads %q", got)
|
|
}
|
|
old := p
|
|
old.Commits, old.Delivery = nil, nil
|
|
if got := planLine(old, now); !strings.HasPrefix(got, "mesh-catalog c1c1c1c1 · tier 1 of 1") {
|
|
t.Fatalf("a record naming no pull request reads %q", got)
|
|
}
|
|
long := p
|
|
long.Delivery.Merges[0].Title = strings.Repeat("abcdefghij", 6)
|
|
if got := planHeadline(long, nil); !strings.Contains(got, "#175 "+strings.Repeat("abcdefghij", 4)+"abcdefghi…") {
|
|
t.Fatalf("a long title is not cut at fifty runes: %q", got)
|
|
}
|
|
}
|
|
|
|
// sameMerge compares what a walk names of a merge: its repository, commit and the commit carrying it.
|
|
func sameMerge(a, b inventory.PlanMerge) bool {
|
|
return a.Repository == b.Repository && a.Commit == b.Commit && a.Carried == b.Carried
|
|
}
|
|
|
|
// A catalogue merge heard after a later merge of its branch was walked without the word (a merge that touched
|
|
// the bus too, on the controller's own path) is not answered by that walk while the delivery seat has a holder:
|
|
// its delivery would skip its turn. It joins the next catalogue batch.
|