package main // The week's round (novox/hq ADR 0508 §5, §6; acceptance 1841/AC6 and 1841/AC7), for three made-up nodes over // states in memory. import ( "encoding/json" "strings" "testing" "time" stdio "git.novox.be/novox/mesh-sdk/go" "git.novox.be/novox/mesh-sdk/go/asks" ) // roundBed is three keepers over one bed's states, each with the order node-a, node-b, node-c. type roundBed struct { *keeperBed nodes map[string]*Upgrades } func newRoundBed(t *testing.T, order ...string) *roundBed { k := newKeeperBed(t) k.now = time.Date(2026, 10, 24, 3, 0, 0, 0, time.UTC) // a Saturday b := &roundBed{keeperBed: k, nodes: map[string]*Upgrades{}} var updates UpdatesValue k.updates.value("node-a", &updates) for _, n := range []string{"node-a", "node-b", "node-c"} { u := k.keeper(k.u.Machine) u.Node, u.Config.UpgradeOrder, u.Config.RoundStartMinutes = n, order, 120 if _, err := k.installed.Put(n, nodeAPackages); err != nil { t.Fatal(err) } if _, err := k.updates.Put(n, updates); err != nil { t.Fatal(err) } b.nodes[n] = u } return b } func (b *roundBed) step(t *testing.T, node string) time.Time { t.Helper() next, err := b.nodes[node].RoundStep() if err != nil { t.Fatalf("%s: %v", node, err) } return next } // end ends node's running transaction as unitState says and follows it. func (b *roundBed) end(t *testing.T, node, unitState string) { t.Helper() b.unitState = unitState if err := b.nodes[node].Follow(); err != nil { t.Fatal(err) } b.now = b.now.Add(5 * time.Minute) } func (b *roundBed) runsOf(node string) []Run { keys, _ := b.runs.Keys() var out []Run for _, k := range keys { if strings.HasPrefix(k, node+".") { var r Run b.runs.value(k, &r) out = append(out, r) } } return out } func (b *roundBed) state(t *testing.T) map[string]NodeInRound { t.Helper() v, err := readRound(b.rounds, b.runs, "2026-W43") if err != nil || v == nil { t.Fatalf("round: %v", err) } out := map[string]NodeInRound{} for _, s := range RoundState(v, b.now, 120) { out[s.Node] = s } return out } func TestTheRoundOpensSaturdayAtFourAndThePassedCheckGivesTheNextItsTurn(t *testing.T) { if o := RoundOpening(time.Date(2026, 10, 22, 15, 0, 0, 0, time.UTC)); !o.Equal(time.Date(2026, 10, 24, 4, 0, 0, 0, time.UTC)) { t.Fatalf("the opening of the week of Thursday 22 October: %s", o) } b := newRoundBed(t, "node-a", "node-b", "node-c") if next := b.step(t, "node-a"); !next.Equal(time.Date(2026, 10, 24, 4, 0, 0, 0, time.UTC)) { t.Errorf("before 04:00 the first node waits for 04:00: %s", next) } if keys, _ := b.rounds.Keys(); len(keys) != 0 { t.Fatalf("a round before 04:00: %v", keys) } b.step(t, "node-b") if keys, _ := b.rounds.Keys(); len(keys) != 0 { t.Fatalf("only the first of the order opens the round: %v", keys) } b.now = time.Date(2026, 10, 24, 4, 0, 0, 0, time.UTC) if next := b.step(t, "node-a"); !next.Equal(time.Date(2026, 10, 31, 4, 0, 0, 0, time.UTC)) { t.Errorf("after opening, next Saturday: %s", next) } var round Round if !b.rounds.value("2026-W43", &round) || round.OpenedBy != "node-a" || strings.Join(round.Order, " ") != "node-a node-b node-c" { t.Fatalf("the round: %+v", round) } if r := b.runsOf("node-a"); len(r) != 1 || r[0].Round != "2026-W43" || r[0].From != FromRound || lastStep(r[0]) != StepStarted { t.Fatalf("the first node starts its run in the round: %+v", r) } b.now = b.now.Add(time.Minute) b.step(t, "node-a") if w := b.rounds.writesTo("2026-W43"); len(w) != 2 || len(b.runsOf("node-a")) != 1 { t.Errorf("the round is created once and the run started once: %v", w) } b.step(t, "node-b") if len(b.runsOf("node-b")) != 0 || b.state(t)["node-b"].State != InRoundWaiting { t.Fatalf("node-b waits while node-a runs: %+v", b.state(t)["node-b"]) } b.end(t, "node-a", "LoadState=not-found\n") if s := b.state(t)["node-a"]; s.State != InRoundPassed { t.Fatalf("node-a: %+v", s) } b.step(t, "node-c") if len(b.runsOf("node-c")) != 0 { t.Fatal("node-c started before node-b") } b.step(t, "node-b") if r := b.runsOf("node-b"); len(r) != 1 || r[0].Round != "2026-W43" { t.Fatalf("node-b's turn after node-a's check passed: %+v", r) } // node-b's transaction fails: the round stops at node-b, and node-c waits. b.end(t, "node-b", "LoadState=loaded\nActiveState=failed\nResult=exit-code\nExecMainStatus=1\n") b.step(t, "node-c") st := b.state(t) if len(b.runsOf("node-c")) != 0 || st["node-b"].State != InRoundFailed || st["node-c"].State != InRoundWaiting || st["node-c"].WaitsFor != "node-b" { t.Errorf("a failed check stops the round: %+v %+v", st["node-b"], st["node-c"]) } } func TestNoOrderNoRound(t *testing.T) { b := newRoundBed(t) b.now = time.Date(2026, 10, 24, 5, 0, 0, 0, time.UTC) if next := b.step(t, "node-a"); !next.IsZero() { t.Errorf("no wake without an order: %s", next) } if keys, _ := b.rounds.Keys(); len(keys) != 0 || len(b.runsOf("node-a")) != 0 { t.Errorf("a round without an order: %v", keys) } } func TestANodeOffAtItsTurnRunsAtItsNextStartAndAStalledTurnIsSaid(t *testing.T) { b := newRoundBed(t, "node-a", "node-b", "node-c") b.now = time.Date(2026, 10, 24, 4, 0, 0, 0, time.UTC) b.step(t, "node-a") b.end(t, "node-a", "LoadState=not-found\n") passed := b.now.Add(-5 * time.Minute) b.now = passed.Add(121 * time.Minute) // node-b is off st := b.state(t) if st["node-b"].State != InRoundStalled || st["node-b"].Since == nil || !st["node-b"].Since.Equal(passed) { t.Errorf("node-b's turn came at %s and it has not started: %+v", passed, st["node-b"]) } if st["node-c"].WaitsFor != "node-b" || st["node-c"].State != InRoundWaiting { t.Errorf("node-c names the node it waits for: %+v", st["node-c"]) } b.step(t, "node-b") // node-b starts again if len(b.runsOf("node-b")) != 1 { t.Error("a node off at its turn runs at its next start") } } // warrant is the operator channel's record of an ask answered with option. func warrant(a asks.Ask, option string, level asks.Level, at time.Time) json.RawMessage { w := asks.Warrant{Ask: a.ID, Asker: Module, Outcome: asks.OutcomeChosen, Option: option, Level: level, By: &asks.Person{Who: asks.Operator, Kind: "made-up-channel", Identity: "1", Verified: "user id"}, At: at, AskDigest: a.Digest()} raw, _ := json.Marshal(map[string]any{"state": "chosen", "warrant": w}) return raw } func TestOnlyTheOperatorsApprovePassesANodeAndAPassNeedsAReason(t *testing.T) { b := newRoundBed(t, "node-a", "node-b", "node-c") b.now = time.Date(2026, 10, 24, 4, 0, 0, 0, time.UTC) b.step(t, "node-a") b.end(t, "node-a", "LoadState=not-found\n") b.step(t, "node-b") b.end(t, "node-b", "LoadState=loaded\nActiveState=failed\nResult=exit-code\nExecMainStatus=1\n") var published []asks.Ask var subjects []string saved := seatPublish seatPublish = func(subject string, body any, id string) error { subjects, published = append(subjects, subject), append(published, body.(asks.Ask)) return nil } defer func() { seatPublish = saved }() if _, err := AskPass(b.rounds, b.runs, b.asks, "", "node-b", " ", b.now); err == nil || len(published) != 0 { t.Fatalf("a pass without a reason: %v", err) } out, err := AskPass(b.rounds, b.runs, b.asks, "", "node-b", "its disk is being replaced", b.now) if err != nil { t.Fatal(err) } if len(published) != 1 || subjects[0] != "mesh.seat.operator-channel.accept.ask.pacman" || out["waiting"] != "node-c" { t.Fatalf("the operator is asked on their channel: %v %v", subjects, out) } opAsk := published[0] if err := opAsk.Check(b.now); err != nil || !strings.Contains(opAsk.Explanation, "its disk is being replaced") { t.Errorf("the ask: %v %+v", err, opAsk) } keys, _ := b.asks.Keys() if len(keys) != 1 || keys[0] != NodeKey("node-c", opAsk.ID) { t.Fatalf("the ask for node-c's keeper: %v", keys) } var a Ask b.asks.value(keys[0], &a) raw, _ := json.Marshal(a) if err := b.nodes["node-c"].TakeAsk(stdio.StateChange{State: StateAsks, Key: keys[0], Op: "put", Value: raw}); err != nil { t.Fatal(err) } if st := b.state(t)["node-b"]; st.Pass == nil || st.Pass.Reason != "its disk is being replaced" || st.Pass.Answer != "" { t.Fatalf("the pass waits in the round: %+v", st.Pass) } record := map[string]json.RawMessage{} b.nodes["node-c"].AskRecord = func(id string) (*stdio.StateEntry, error) { if v, ok := record[id]; ok { return &stdio.StateEntry{Key: Module + "." + id, Value: v}, nil } return nil, nil } if next := b.step(t, "node-c"); len(b.runsOf("node-c")) != 0 || !next.Equal(b.now.Add(passCheckEvery)) { t.Fatalf("no answer yet: node-c waits and looks again in a minute (%s)", next) } // An answer to another ask, or to this one as it was not shown, passes nothing. other := opAsk other.Explanation = "something else" record[opAsk.ID] = warrant(other, "pass", asks.Approve, b.now) if _, err := b.nodes["node-c"].RoundStep(); err == nil || len(b.runsOf("node-c")) != 0 { t.Fatalf("a warrant for another ask: %v", err) } record[opAsk.ID] = warrant(opAsk, "pass", asks.Approve, b.now) b.step(t, "node-c") if st := b.state(t)["node-b"]; st.State != InRoundPassed || st.Pass.Answer != PassApproved { t.Errorf("the operator's Approve passes node-b: %+v", st) } if r := b.runsOf("node-c"); len(r) != 1 || r[0].Round != "2026-W43" { t.Errorf("node-c's turn after the pass: %+v", r) } } func TestAPassTheOperatorKeepsWaitingPassesNothing(t *testing.T) { b := newRoundBed(t, "node-a", "node-b") b.now = time.Date(2026, 10, 24, 4, 0, 0, 0, time.UTC) b.step(t, "node-a") // node-a runs and never ends saved := seatPublish var opAsk asks.Ask seatPublish = func(_ string, body any, _ string) error { opAsk = body.(asks.Ask); return nil } defer func() { seatPublish = saved }() if _, err := AskPass(b.rounds, b.runs, b.asks, "2026-W43", "node-a", "it hangs", b.now); err != nil { t.Fatal(err) } keys, _ := b.asks.Keys() var a Ask b.asks.value(keys[0], &a) raw, _ := json.Marshal(a) if err := b.nodes["node-b"].TakeAsk(stdio.StateChange{State: StateAsks, Key: keys[0], Op: "put", Value: raw}); err != nil { t.Fatal(err) } b.nodes["node-b"].AskRecord = func(string) (*stdio.StateEntry, error) { return &stdio.StateEntry{Value: warrant(opAsk, "keep", asks.Acknowledge, b.now)}, nil } b.step(t, "node-b") if st := b.state(t); st["node-a"].Pass.Answer != PassDeclined || len(b.runsOf("node-b")) != 0 { t.Errorf("kept waiting: %+v", st["node-a"]) } if _, err := AskPass(b.rounds, b.runs, b.asks, "2026-W43", "node-b", "x", b.now); err == nil { t.Error("the last of the order has no node waiting on it") } if _, err := AskPass(b.rounds, b.runs, b.asks, "2026-W43", "node-z", "x", b.now); err == nil { t.Error("a node not in the order") } } func TestPacmanRoundReadsTheRounds(t *testing.T) { b := newRoundBed(t, "node-a", "node-b") b.now = time.Date(2026, 10, 24, 4, 0, 0, 0, time.UTC) b.step(t, "node-a") out, err := ListRounds(b.rounds, b.runs, "", b.now, 120, 4) if err != nil { t.Fatal(err) } listed := out["rounds"].([]map[string]any) nodes := listed[0]["nodes"].([]NodeInRound) if len(listed) != 1 || listed[0]["round"] != "2026-W43" || nodes[0].State != InRoundRunning || nodes[1].WaitsFor != "node-a" { t.Errorf("%+v", listed) } if _, err := ListRounds(b.rounds, b.runs, "2026-W40", b.now, 120, 4); err == nil { t.Error("a round that is not there") } }