306 lines
11 KiB
Go
306 lines
11 KiB
Go
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")
|
|
}
|
|
}
|