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.