345 lines
13 KiB
Go
345 lines
13 KiB
Go
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"errors"
|
|
"slices"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
stdio "git.novox.be/novox/mesh-sdk/go"
|
|
"git.novox.be/novox/mesh-sdk/go/asks"
|
|
)
|
|
|
|
// **The gate acts only while it holds the seat, and carries today's state over once** (novox/hq ADR 0450 §6 with its
|
|
// insight of 2026-10-20, ADR 0437 §7, issue 1347). Fixture sources: the keys of mesh-controller.assignments and
|
|
// node-engine.have are `<node>.<module>` (mesh-controller internal/inventory/chain_assignments.go assignmentKey,
|
|
// mesh-sdk go/delivery HaveKey); a delivery is the module's own Delivery, written as ports.go kvStore writes it.
|
|
|
|
// fakeOccupancy is where today's module stands, as the test says.
|
|
type fakeOccupancy struct {
|
|
mu sync.Mutex
|
|
who []string
|
|
err error
|
|
}
|
|
|
|
func (o *fakeOccupancy) set(who ...string) { o.mu.Lock(); o.who = who; o.mu.Unlock() }
|
|
func (o *fakeOccupancy) Occupants() ([]string, error) {
|
|
o.mu.Lock()
|
|
defer o.mu.Unlock()
|
|
return slices.Clone(o.who), o.err
|
|
}
|
|
|
|
// mapBucket is a bucket over a map, shared with the store a test reads.
|
|
type mapBucket struct {
|
|
mu *sync.Mutex
|
|
m map[string][]byte
|
|
}
|
|
|
|
func (b mapBucket) Keys() ([]string, error) {
|
|
b.mu.Lock()
|
|
defer b.mu.Unlock()
|
|
var keys []string
|
|
for k := range b.m {
|
|
keys = append(keys, k)
|
|
}
|
|
return keys, nil
|
|
}
|
|
func (b mapBucket) Get(key string) ([]byte, error) {
|
|
b.mu.Lock()
|
|
defer b.mu.Unlock()
|
|
return b.m[key], nil
|
|
}
|
|
func (b mapBucket) Put(key string, raw []byte) error {
|
|
b.mu.Lock()
|
|
defer b.mu.Unlock()
|
|
b.m[key] = raw
|
|
return nil
|
|
}
|
|
|
|
// seatTest is a world whose holder asks where today's module stands.
|
|
func seatTest(t *testing.T, who ...string) (*world, *fakeOccupancy) {
|
|
t.Helper()
|
|
w := newWorld(t)
|
|
occ := &fakeOccupancy{who: who}
|
|
w.h.Seat = occ
|
|
return w, occ
|
|
}
|
|
|
|
// anEvent is the runtime handing the holder a pull request's head, as handle takes it.
|
|
func anEvent(t *testing.T, w *world) {
|
|
t.Helper()
|
|
hear(t, w, "gitea.pull.updated", pr("novox/app", 7, readyHead, "feat/x", "src/a.go"))
|
|
}
|
|
|
|
// nothingWritten says the forge, the controller and the store were left alone.
|
|
func nothingWritten(t *testing.T, w *world) {
|
|
t.Helper()
|
|
w.forge.mu.Lock()
|
|
forge := len(w.forge.notes) + len(w.forge.views) + len(w.forge.statuses) + w.forge.statusWrites + len(w.forge.calls) + len(w.forge.comments)
|
|
w.forge.mu.Unlock()
|
|
w.ctl.mu.Lock()
|
|
ctl := len(w.ctl.checks) + w.ctl.asked + len(w.ctl.delivers) + len(w.ctl.stops)
|
|
w.ctl.mu.Unlock()
|
|
w.store.mu.Lock()
|
|
keys := len(w.store.deliveries) + len(w.store.groups)
|
|
w.store.mu.Unlock()
|
|
if forge != 0 || ctl != 0 || keys != 0 {
|
|
t.Fatalf("a gate that does not hold the seat wrote: %d on the forge, %d to the controller, %d keys", forge, ctl, keys)
|
|
}
|
|
if n := len(w.h.deliveries); n != 0 {
|
|
t.Fatalf("a gate that does not hold the seat took the event: %d deliveries", n)
|
|
}
|
|
}
|
|
|
|
func toolNamed(t *testing.T, h *Holder, name string) stdio.Tool {
|
|
t.Helper()
|
|
for _, tool := range tools(h, &listening{}) {
|
|
if tool.Name == name {
|
|
return tool
|
|
}
|
|
}
|
|
t.Fatalf("no tool %s", name)
|
|
return stdio.Tool{}
|
|
}
|
|
|
|
// AC1: with the module assigned on a machine, or its have key present, an event changes no key and a verb answers that
|
|
// the gate is not the holder.
|
|
func TestTheGateIsSilentWhileTodaysModuleIsAssignedOrHasAHaveKey(t *testing.T) {
|
|
for _, who := range []string{"assigned on novox", "the node-engine of ace has it"} {
|
|
t.Run(who, func(t *testing.T) {
|
|
w, _ := seatTest(t, who)
|
|
if err := w.h.SeatChanged(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if w.h.Holding() {
|
|
t.Fatalf("the gate holds the seat while %s", who)
|
|
}
|
|
anEvent(t, w)
|
|
nothingWritten(t, w)
|
|
// Every verb of the seat answers that it is not the holder, and none changes anything.
|
|
for _, name := range []string{"mesh-delivery.deliveries", "mesh-delivery.release", "mesh-delivery.stop", "mesh-delivery.recheck"} {
|
|
_, err := toolNamed(t, w.h, name).Run(map[string]any{"id": "novox/app@111111111111", "why": "a test"})
|
|
if err == nil || !strings.Contains(err.Error(), "not the holder") || !strings.Contains(err.Error(), who) {
|
|
t.Fatalf("%s answered %v, wanted not the holder, naming %q", name, err, who)
|
|
}
|
|
}
|
|
nothingWritten(t, w)
|
|
// Its own status still answers, and says why it is silent.
|
|
out, err := toolNamed(t, w.h, "delivery_status").Run(nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
status := out.(map[string]any)
|
|
if status["holds-the-seat"] != false || !strings.Contains(status["seat-waits"].(string), who) {
|
|
t.Fatalf("status %v", status)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// The seat free, the gate holds it and acts; asked again with the module back, it is silent again.
|
|
func TestTheGateActsOnceTodaysModuleIsGoneAndStopsIfItComesBack(t *testing.T) {
|
|
w, occ := seatTest(t)
|
|
if err := w.h.SeatChanged(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !w.h.Holding() {
|
|
t.Fatal("the seat is free and the gate does not hold it")
|
|
}
|
|
anEvent(t, w)
|
|
if len(w.h.deliveries) != 1 {
|
|
t.Fatalf("the gate that holds the seat took %d deliveries, wanted 1", len(w.h.deliveries))
|
|
}
|
|
if _, err := toolNamed(t, w.h, "mesh-delivery.deliveries").Run(map[string]any{}); err != nil {
|
|
t.Fatalf("a verb of the holder: %v", err)
|
|
}
|
|
occ.set("assigned on novox")
|
|
if err := w.h.SeatChanged(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if w.h.Holding() {
|
|
t.Fatal("the module is assigned again and the gate still holds")
|
|
}
|
|
hear(t, w, "gitea.pull.updated", pr("novox/app", 8, "2222222222222222222222222222222222222222", "feat/y", "src/b.go"))
|
|
if len(w.h.deliveries) != 1 {
|
|
t.Fatalf("an event was taken while the gate was silent: %d deliveries", len(w.h.deliveries))
|
|
}
|
|
}
|
|
|
|
// Nothing owed is flushed, and no look is taken, while the gate is silent.
|
|
func TestASilentGateFlushesNothingItOwes(t *testing.T) {
|
|
w, occ := seatTest(t)
|
|
if err := w.h.SeatChanged(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
w.h.PullUpdated(pr("novox/app", 7, readyHead, "feat/x", "src/a.go")) // owes a note, a view and a status
|
|
occ.set("assigned on novox")
|
|
if err := w.h.SeatChanged(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
counts := func() [3]int {
|
|
w.forge.mu.Lock()
|
|
defer w.forge.mu.Unlock()
|
|
w.store.mu.Lock()
|
|
defer w.store.mu.Unlock()
|
|
return [3]int{len(w.forge.notes) + len(w.forge.views) + len(w.forge.statuses) + w.forge.statusWrites + len(w.forge.calls), w.store.puts, len(w.said)}
|
|
}
|
|
before := counts()
|
|
w.h.React()
|
|
if after := counts(); after != before {
|
|
t.Fatalf("a silent gate flushed what it owes: forge, store and events %v before, %v after", before, after)
|
|
}
|
|
}
|
|
|
|
// A seat that cannot be read leaves a gate that did not hold silent, and says so.
|
|
func TestTheGateStaysSilentWhenTheSeatCannotBeRead(t *testing.T) {
|
|
w, occ := seatTest(t)
|
|
occ.err = errors.New("the bus is away")
|
|
if err := w.h.SeatChanged(); err == nil {
|
|
t.Fatal("a seat that cannot be read was taken as free")
|
|
}
|
|
if w.h.Holding() {
|
|
t.Fatal("holding without knowing")
|
|
}
|
|
}
|
|
|
|
// --- AC2: the carry ---------------------------------------------------------------------------------
|
|
|
|
func aDelivery(id string, number int, ready *ReadyRun) []byte {
|
|
return mustJSON(&Delivery{ID: id, Repository: "novox/app", Commit: strings.Repeat("1", 40), Number: number, Base: "main",
|
|
State: Proposed, Ready: ready})
|
|
}
|
|
|
|
type carryFixture struct {
|
|
oldD, oldG, oldP map[string][]byte
|
|
mu sync.Mutex
|
|
own map[string]mapBucket
|
|
w *world
|
|
occ *fakeOccupancy
|
|
approvedAt time.Time
|
|
}
|
|
|
|
// aCarry is a world whose holder carries from these buckets into its store's: the old module's deliveries, one with an ask
|
|
// waiting, one approved and one the gate already has under another value; one group; one pull key.
|
|
func aCarry(t *testing.T) *carryFixture {
|
|
f := &carryFixture{oldD: map[string][]byte{}, oldG: map[string][]byte{}, oldP: map[string][]byte{},
|
|
approvedAt: time.Date(2026, 10, 20, 9, 0, 0, 0, time.UTC)}
|
|
f.w, f.occ = seatTest(t)
|
|
ask := &asks.Ask{ID: "merge-7-aaaaaaaaaaaa-1"}
|
|
f.oldD["novox/app@aaaaaaaaaaaa"] = aDelivery("novox/app@aaaaaaaaaaaa", 7, &ReadyRun{Head: strings.Repeat("a", 40), AskID: "merge-7-aaaaaaaaaaaa-1",
|
|
Ask: ask, Heads: map[string]string{"novox/app@aaaaaaaaaaaa": strings.Repeat("a", 40)}, Asks: 1})
|
|
f.oldD["novox/app@bbbbbbbbbbbb"] = aDelivery("novox/app@bbbbbbbbbbbb", 8, &ReadyRun{Head: strings.Repeat("b", 40), AskID: "merge-8-bbbbbbbbbbbb-1",
|
|
Ask: &asks.Ask{ID: "merge-8-bbbbbbbbbbbb-1"}, Heads: map[string]string{"novox/app@bbbbbbbbbbbb": strings.Repeat("b", 40)},
|
|
Approved: &f.approvedAt, Warrant: "the operator on the phone"})
|
|
f.oldD["novox/app@cccccccccccc"] = aDelivery("novox/app@cccccccccccc", 9, &ReadyRun{Head: strings.Repeat("c", 40), AskID: "merge-9-cccccccccccc-1", Ask: ask})
|
|
f.oldG["feat_x"] = []byte(`{"id":"feat/x","members":["novox/app@aaaaaaaaaaaa"]}`)
|
|
f.oldP["novox_app_7"] = []byte(`{"v":1,"number":7,"head":"` + strings.Repeat("a", 40) + `"}`)
|
|
// The gate already has one of the old module's deliveries, otherwise.
|
|
f.w.store.deliveries["novox/app@cccccccccccc"] = aDelivery("novox/app@cccccccccccc", 9, &ReadyRun{Head: strings.Repeat("c", 40)})
|
|
ownG, ownP := map[string][]byte{}, map[string][]byte{}
|
|
f.own = map[string]mapBucket{
|
|
"deliveries": {mu: &f.w.store.mu, m: f.w.store.deliveries},
|
|
"groups": {mu: &f.mu, m: ownG},
|
|
"pulls": {mu: &f.mu, m: ownP},
|
|
}
|
|
old := map[string]mapBucket{"deliveries": {mu: &f.mu, m: f.oldD}, "groups": {mu: &f.mu, m: f.oldG}, "pulls": {mu: &f.mu, m: f.oldP}}
|
|
f.w.h.Carry = func() (CarryReport, error) {
|
|
return CarryOver(func(b string) Bucket { return old[b] }, func(b string) Bucket { return f.own[b] }, f.approvedAt)
|
|
}
|
|
return f
|
|
}
|
|
|
|
// AC2: when both go, the gate carries once (old ask ids cleared, keys it already has untouched) and then acts; a restart
|
|
// copies nothing.
|
|
func TestTheGateCarriesTodaysStateOnceWhenItFirstHoldsTheSeat(t *testing.T) {
|
|
f := aCarry(t)
|
|
before := slices.Clone(f.w.store.deliveries["novox/app@cccccccccccc"])
|
|
f.occ.set("assigned on novox")
|
|
if err := f.w.h.SeatChanged(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(f.w.store.deliveries) != 1 || f.w.h.Holding() {
|
|
t.Fatalf("the gate carried while the module was still assigned: %d keys", len(f.w.store.deliveries))
|
|
}
|
|
f.occ.set()
|
|
if err := f.w.h.SeatChanged(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !f.w.h.Holding() {
|
|
t.Fatal("the gate does not hold the seat after the module went")
|
|
}
|
|
d := f.w.h.deliveries
|
|
for _, id := range []string{"novox/app@aaaaaaaaaaaa", "novox/app@bbbbbbbbbbbb", "novox/app@cccccccccccc"} {
|
|
if d[id] == nil {
|
|
t.Fatalf("%s was not carried and read back: %v", id, f.w.store.deliveries)
|
|
}
|
|
}
|
|
// The ask nobody answered is cleared, so the gate asks again; the one answered keeps its answer.
|
|
if r := d["novox/app@aaaaaaaaaaaa"].Ready; r.AskID != "" || r.Ask != nil || r.Heads != nil || r.Asks != 1 {
|
|
t.Fatalf("the unanswered ask was not cleared: %+v", r)
|
|
}
|
|
if r := d["novox/app@bbbbbbbbbbbb"].Ready; r.Approved == nil || r.AskID == "" || r.Warrant == "" {
|
|
t.Fatalf("an answered Approve lost its answer: %+v", r)
|
|
}
|
|
// A key the gate had is untouched.
|
|
if !bytes.Equal(f.w.store.deliveries["novox/app@cccccccccccc"], before) {
|
|
t.Fatal("a key the gate already had was overwritten")
|
|
}
|
|
for bucket, key := range map[string]string{"groups": "feat_x", "pulls": "novox_app_7"} {
|
|
if got, _ := f.own[bucket].Get(key); got == nil {
|
|
t.Fatalf("%s %s was not carried", bucket, key)
|
|
}
|
|
}
|
|
if got, _ := f.own["deliveries"].Get(carriedKey); got == nil {
|
|
t.Fatal("the key carried was not written")
|
|
}
|
|
// A restart (a holder that holds again, or starts again) copies nothing.
|
|
f.oldD["novox/app@dddddddddddd"] = aDelivery("novox/app@dddddddddddd", 10, nil)
|
|
report, err := f.w.h.Carry()
|
|
if err != nil || !report.Already {
|
|
t.Fatalf("a second carry: %+v %v", report, err)
|
|
}
|
|
if got, _ := f.own["deliveries"].Get("novox/app@dddddddddddd"); got != nil {
|
|
t.Fatal("a second carry copied a key")
|
|
}
|
|
}
|
|
|
|
// A carry that fails leaves the gate silent and writes no mark, so it is tried again.
|
|
func TestAFailedCarryLeavesTheGateSilentAndUnmarked(t *testing.T) {
|
|
f := aCarry(t)
|
|
f.w.h.Carry = func() (CarryReport, error) { return CarryReport{}, errors.New("the old bucket cannot be read") }
|
|
if err := f.w.h.SeatChanged(); err == nil {
|
|
t.Fatal("a failed carry was not returned")
|
|
}
|
|
if f.w.h.Holding() {
|
|
t.Fatal("the gate holds the seat on state it did not carry")
|
|
}
|
|
if got, _ := f.own["deliveries"].Get(carriedKey); got != nil {
|
|
t.Fatal("a failed carry wrote its mark")
|
|
}
|
|
}
|
|
|
|
// Review round 1, try failure: the carry copies a group, a pull key and a delivery without an unanswered ask byte for byte; what
|
|
// the gate writes after it holds the seat and acts is its own (a group's said state), not the carry's.
|
|
func TestTheCarryCopiesWhatItDoesNotClearByteForByte(t *testing.T) {
|
|
f := aCarry(t)
|
|
group := []byte(`{"id":"feat/x","members":["novox/app@aaaaaaaaaaaa"],"created":"2026-10-09T07:00:00Z","updated":"2026-10-09T07:00:00Z","said":"delivering"}`)
|
|
f.oldG["feat_x"] = group
|
|
plain := aDelivery("novox/app@eeeeeeeeeeee", 11, nil)
|
|
f.oldD["novox/app@eeeeeeeeeeee"] = plain
|
|
if err := f.w.h.SeatChanged(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, c := range []struct {
|
|
bucket, key string
|
|
want []byte
|
|
}{{"groups", "feat_x", group}, {"pulls", "novox_app_7", f.oldP["novox_app_7"]}, {"deliveries", "novox/app@eeeeeeeeeeee", plain}} {
|
|
if got, _ := f.own[c.bucket].Get(c.key); !bytes.Equal(got, c.want) {
|
|
t.Fatalf("%s %s changed in the carry:\n got %s\nwant %s", c.bucket, c.key, got, c.want)
|
|
}
|
|
}
|
|
}
|