The ledger pipe asks the runtime through stdio.BusState (it answered ErrNoPipe before), an operation names the call the runtime proved (stdio.CallIDOf), and the page takes the runtime's word on its consumer (stdio.OnHeartbeat): a beat is every subscription heard, a missed heartbeat marks each subscribed source at once with the runtime's why. Rows in TestASourceThatDoesNotAnswerIsSaidAndAskedAgainOnABackoff and TestTheLedgerStoreRefusesWhatTheLedgerWouldHaveToTakeBack; each failed before the change. Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
241 lines
11 KiB
Go
241 lines
11 KiB
Go
package main
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"reflect"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
stdio "git.novox.be/novox/mesh-sdk/go"
|
|
)
|
|
|
|
// overviewWorld is a page model over the sources played here: what each verb answers, and what fails.
|
|
type overviewWorld struct {
|
|
o *Overview
|
|
now time.Time
|
|
answers map[string]string
|
|
fail map[string]bool
|
|
entries []*Entry
|
|
}
|
|
|
|
func newOverviewWorld(t *testing.T) *overviewWorld {
|
|
t.Helper()
|
|
w := &overviewWorld{now: time.Date(2026, 11, 4, 12, 0, 0, 0, time.UTC), fail: map[string]bool{}, answers: map[string]string{
|
|
TrackerMilestonesVerb: `{"milestones":[{"design":47,"id":"1","outcome":"A merge is never dropped","status":"open","issues":[973,525]},{"design":47,"id":"2","outcome":"Done thing","status":"done","issues":[1]}]}`,
|
|
TrackerListVerb: `[{"number":973,"title":"The window","status":"fixing"},{"number":525,"title":"The queue","status":"open"}]`,
|
|
DeliveryGroupsVerb: `[{"id":"fix/a","state":"held","why":"a member is held","members":["x@1"]}]`,
|
|
AsksVerb: `{"asks":[{"key":"mesh-knowledge.1","asker":"mesh-knowledge","headline":"Approve ADR 0301","state":"open","offered_on":["telegram"]},{"key":"ask-0","asker":"x","headline":"old","state":"answered"}]}`,
|
|
}}
|
|
w.o = NewOverview(func(key string, body any) (json.RawMessage, error) {
|
|
if w.fail[key] {
|
|
return nil, errors.New("no answer in time")
|
|
}
|
|
return json.RawMessage(w.answers[key]), nil
|
|
}, func() ([]*Entry, error) { return w.entries, nil }, func() time.Time { return w.now })
|
|
return w
|
|
}
|
|
|
|
func recordEntry(n int, status, date string, accepted time.Time, mut func(*Record)) *Entry {
|
|
r := &Record{Kind: KindDecision, Number: n, Title: "Record " + itoa(n), Status: status, Date: date, AcceptedAt: accepted}
|
|
if mut != nil {
|
|
mut(r)
|
|
}
|
|
return &Entry{Current: r, CurrentVersion: 1, Versions: 1}
|
|
}
|
|
|
|
// The page's model (ADR 0616 §1-3, AC P1): three blocks built from the ledger, the tracker's milestones and issues, the delivery
|
|
// groups and the operator channel's asks; events change the blocks and ask no verb; a record accepted eight days before the
|
|
// answer is not in the seven-day block, with no event between.
|
|
func TestThePageModelHasThreeBlocksKeptByEventsAndAsksNoVerbAgain(t *testing.T) {
|
|
w := newOverviewWorld(t)
|
|
w.entries = []*Entry{
|
|
recordEntry(301, StatusAccepted, "2026-11-02", w.now.Add(-2*24*time.Hour), nil),
|
|
recordEntry(302, StatusAccepted, "2026-10-20", w.now.Add(-6*24*time.Hour-23*time.Hour), nil),
|
|
recordEntry(303, StatusProposed, "2026-11-04", time.Time{}, func(r *Record) {
|
|
r.NeedsOperator = true
|
|
r.Ask = &OperatorAsk{ID: "a", Said: "waiting"}
|
|
}),
|
|
recordEntry(304, StatusProposed, "2026-11-04", time.Time{}, nil),
|
|
}
|
|
w.o.Start()
|
|
m, err := w.o.Model()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(m.Designs) != 1 || m.Designs[0].ID != "1" || len(m.Designs[0].Issues) != 2 || m.Designs[0].Issues[0].Title != "The window" {
|
|
t.Fatalf("the design block: %+v", m.Designs)
|
|
}
|
|
if len(m.Accepted) != 2 || len(m.Waiting) != 2 || len(m.Operator.Records) != 1 || m.Operator.Records[0].Record != "ADR 0303" ||
|
|
len(m.Operator.Asks) != 1 || m.Operator.Asks[0].Key != "mesh-knowledge.1" || len(m.Operator.Groups) != 1 {
|
|
t.Fatalf("%+v", m)
|
|
}
|
|
asked := map[string]int{}
|
|
for _, v := range []string{TrackerMilestonesVerb, TrackerListVerb, DeliveryGroupsVerb, AsksVerb} {
|
|
asked[v] = w.o.Calls(v)
|
|
}
|
|
// Events change the blocks.
|
|
w.o.Heard(SourceDelivery, "mesh-delivery-gate.group", json.RawMessage(`{"group":"fix/b","state":"checking","members":["y@2"]}`))
|
|
w.o.Heard(SourceDelivery, "mesh-delivery-gate.transition", json.RawMessage(`{"group":"fix/a","to":"approved"}`))
|
|
w.o.Heard(SourceAsks, "messenger.offered", json.RawMessage(`{"id":"2","asker":"mesh-delivery","headline":"Approve the group","channels":["telegram"]}`))
|
|
w.o.Heard(SourceTracker, "mesh-issues.opened", json.RawMessage(`{"number":1999,"title":"New","status":"open"}`))
|
|
w.o.Heard(SourceTracker, "mesh-issues.milestone-moved", json.RawMessage(`{"design":47,"id":"1","to":"done","issues":[973,525,1999]}`))
|
|
m, _ = w.o.Model()
|
|
if len(m.Operator.Groups) != 2 || m.Operator.Groups[0].State != "approved" || len(m.Operator.Asks) != 2 || len(m.Designs) != 0 {
|
|
t.Fatalf("after the events: %+v", m)
|
|
}
|
|
w.o.Heard(SourceDelivery, "mesh-delivery-gate.merged", json.RawMessage(`{"group":"fix/a"}`))
|
|
w.o.Heard(SourceAsks, "messenger.closed", json.RawMessage(`{"id":"1","asker":"mesh-knowledge","how":"answered"}`))
|
|
m, _ = w.o.Model()
|
|
if len(m.Operator.Groups) != 1 || m.Operator.Groups[0].ID != "fix/b" || len(m.Operator.Asks) != 1 || m.Operator.Asks[0].Key != "mesh-delivery.2" {
|
|
t.Fatalf("after merged and closed: %+v", m.Operator)
|
|
}
|
|
for v, n := range asked {
|
|
if w.o.Calls(v) != n {
|
|
t.Errorf("%s was asked again by an event", v)
|
|
}
|
|
}
|
|
// The seven days are counted when the answer is given: no event between, the record falls out of the block.
|
|
w.now = w.now.Add(2 * 24 * time.Hour)
|
|
m, _ = w.o.Model()
|
|
if len(m.Accepted) != 1 || m.Accepted[0].Record != "ADR 0301" {
|
|
t.Fatalf("the seven-day block: %+v", m.Accepted)
|
|
}
|
|
}
|
|
|
|
// A source that does not answer says so, never as an empty list (ADR 0616 §5, §6; AC P3): from its verb, on a bounded backoff
|
|
// asked again alone, its first answer clearing the mark; from its subscription, asking nothing while it is silent and the verb once on its
|
|
// next event; the model as a tool equals the page's model; the re-read tool asks one source and records the hand act.
|
|
func TestASourceThatDoesNotAnswerIsSaidAndAskedAgainOnABackoff(t *testing.T) {
|
|
w := newOverviewWorld(t)
|
|
w.fail[DeliveryGroupsVerb] = true
|
|
w.o.Start()
|
|
m, _ := w.o.Model()
|
|
var delivery SourceState
|
|
for _, s := range m.Sources {
|
|
if s.Name == SourceDelivery {
|
|
delivery = s
|
|
}
|
|
}
|
|
if delivery.Answered || delivery.Mark == nil || delivery.Mark.Cause != MarkVerb || len(m.Operator.Groups) != 0 || !strings.Contains(delivery.Mark.Why, "no answer in time") {
|
|
t.Fatalf("%+v", delivery)
|
|
}
|
|
if len(m.Designs) != 1 {
|
|
t.Fatalf("another source's block was lost: %+v", m.Designs)
|
|
}
|
|
// Asked again on the backoff alone: 10 s, then doubling, at most 5 minutes; no other source is asked.
|
|
calls := w.o.Calls(DeliveryGroupsVerb)
|
|
if got := w.o.Due(); len(got) != 0 {
|
|
t.Fatalf("asked before the backoff ran out: %v", got)
|
|
}
|
|
var waits []time.Duration
|
|
for i := 0; i < 8; i++ {
|
|
before := w.now
|
|
w.now = w.o.state[SourceDelivery].Mark.Next
|
|
waits = append(waits, w.now.Sub(before))
|
|
if got := w.o.Due(); len(got) != 1 || got[0] != SourceDelivery {
|
|
t.Fatalf("%v", got)
|
|
}
|
|
}
|
|
if waits[0] != BackoffFrom || waits[1] != 2*BackoffFrom || waits[7] != BackoffMost || w.o.Calls(DeliveryGroupsVerb) != calls+8 || w.o.Calls(AsksVerb) != 1 {
|
|
t.Fatalf("waits %v, delivery asked %d, asks asked %d", waits, w.o.Calls(DeliveryGroupsVerb), w.o.Calls(AsksVerb))
|
|
}
|
|
// Its first answer rebuilds the block and clears the mark.
|
|
w.fail[DeliveryGroupsVerb] = false
|
|
w.now = w.o.state[SourceDelivery].Mark.Next
|
|
w.o.Due()
|
|
m, _ = w.o.Model()
|
|
if len(w.o.Unanswered()) != 0 || len(m.Operator.Groups) != 1 {
|
|
t.Fatalf("%+v", m)
|
|
}
|
|
// A subscription that goes silent marks the source from the subscription and asks nothing while it is silent.
|
|
asked := w.o.Calls(AsksVerb)
|
|
w.now = w.now.Add(HeartbeatWithin + time.Second)
|
|
if got := w.o.Silent(); len(got) != 3 {
|
|
t.Fatalf("%v", got)
|
|
}
|
|
if got := w.o.Due(); len(got) != 0 || w.o.Calls(AsksVerb) != asked {
|
|
t.Fatalf("a silent subscription was asked: %v", got)
|
|
}
|
|
if u := w.o.Unanswered(); len(u) != 3 || u[0].Mark.Cause != MarkSubscription {
|
|
t.Fatalf("%+v", u)
|
|
}
|
|
// Its next event (or heartbeat) makes the module ask the verb once, and that answer clears the mark.
|
|
w.o.Heard(SourceAsks, "messenger.offered", json.RawMessage(`{"id":"9","asker":"x","headline":"x"}`))
|
|
if w.o.Calls(AsksVerb) != asked+1 || w.o.state[SourceAsks].Mark != nil {
|
|
t.Fatalf("asked %d, mark %+v", w.o.Calls(AsksVerb), w.o.state[SourceAsks].Mark)
|
|
}
|
|
// An unanswered verb then turns the mark into one from the verb.
|
|
w.fail[DeliveryGroupsVerb] = true
|
|
w.o.Heard(SourceDelivery, "", nil)
|
|
if mk := w.o.state[SourceDelivery].Mark; mk == nil || mk.Cause != MarkVerb {
|
|
t.Fatalf("%+v", mk)
|
|
}
|
|
// The runtime's word on the module's consumer (stdio.OnHeartbeat): a missed heartbeat marks every subscribed source
|
|
// at once, before the bound passes, with the runtime's why; the next beat asks each verb once and clears the marks.
|
|
w.fail[DeliveryGroupsVerb] = false
|
|
w.now = w.o.state[SourceDelivery].Mark.Next
|
|
w.o.Due()
|
|
asked = w.o.Calls(AsksVerb)
|
|
w.o.Beat(stdio.Heartbeat{At: w.now, Missed: true, Why: "the bus's heartbeat stopped arriving"})
|
|
if u := w.o.Unanswered(); len(u) != 3 || u[0].Mark.Cause != MarkSubscription || !strings.Contains(u[0].Mark.Why, "stopped arriving") {
|
|
t.Fatalf("a missed heartbeat marked %+v", u)
|
|
}
|
|
w.o.Beat(stdio.Heartbeat{At: w.now})
|
|
if len(w.o.Unanswered()) != 0 || w.o.Calls(AsksVerb) != asked+1 {
|
|
t.Fatalf("a beat left %+v, asks asked %d", w.o.Unanswered(), w.o.Calls(AsksVerb)-asked)
|
|
}
|
|
// A beat keeps a quiet source from being marked silent: it is heard, not silent.
|
|
w.now = w.now.Add(HeartbeatWithin - time.Second)
|
|
w.o.Beat(stdio.Heartbeat{At: w.now})
|
|
w.now = w.now.Add(2 * time.Second)
|
|
if got := w.o.Silent(); len(got) != 0 {
|
|
t.Fatalf("a source heard by its beat was marked silent: %v", got)
|
|
}
|
|
}
|
|
|
|
func TestThePagesModelIsAToolAndItsRereadIsAHandAct(t *testing.T) {
|
|
w := newOverviewWorld(t)
|
|
w.entries = []*Entry{recordEntry(301, StatusAccepted, "2026-11-02", w.now.Add(-24*time.Hour), nil)}
|
|
w.o.Start()
|
|
var acts []string
|
|
k := &KB{Overview: w.o, Seats: func(key string, body any) (json.RawMessage, error) {
|
|
acts = append(acts, key)
|
|
return json.RawMessage(`{}`), nil
|
|
}, Now: func() time.Time { return w.now }}
|
|
var tools []toolRun
|
|
for _, tool := range moduleTools(k, callerOf) {
|
|
tools = append(tools, toolRun{tool.Name, tool.Run})
|
|
}
|
|
got, err := callTool(t, tools, OverviewTool, map[string]any{})
|
|
want, _ := w.o.Model()
|
|
raw, _ := json.Marshal(want)
|
|
var wantMap map[string]any
|
|
_ = json.Unmarshal(raw, &wantMap)
|
|
if err != nil || !reflect.DeepEqual(got, wantMap) {
|
|
t.Fatalf("the tool's answer is not the page's model: %v", err)
|
|
}
|
|
// The re-read asks the one source and no other, and records itself.
|
|
before := map[string]int{}
|
|
for _, v := range []string{TrackerListVerb, DeliveryGroupsVerb, AsksVerb} {
|
|
before[v] = w.o.Calls(v)
|
|
}
|
|
w.fail[AsksVerb] = true
|
|
res, err := callTool(t, tools, RereadTool, map[string]any{"source": SourceAsks, "why": "the page showed it stale"})
|
|
if err != nil || res["answered"] != false || w.o.Calls(AsksVerb) != before[AsksVerb]+1 || w.o.Calls(DeliveryGroupsVerb) != before[DeliveryGroupsVerb] || w.o.Calls(TrackerListVerb) != before[TrackerListVerb] {
|
|
t.Fatalf("%v %v", err, res)
|
|
}
|
|
if len(acts) != 1 || acts[0] != "seat:mesh-hand-acts.hand-act" {
|
|
t.Fatalf("the hand act: %v", acts)
|
|
}
|
|
if _, err := callTool(t, tools, RereadTool, map[string]any{"source": "nobody"}); err == nil {
|
|
t.Fatal("a source the page lacks was re-read")
|
|
}
|
|
// The health carries the page: degraded while a source is unanswered, healthy.
|
|
h := k.pageHealth(HealthAnswer{State: "healthy", Healthy: true, Why: "ok"})
|
|
if !h.Healthy || !strings.Contains(h.Why, "overview page is degraded") || !strings.Contains(h.Why, SourceAsks) {
|
|
t.Fatalf("%+v", h)
|
|
}
|
|
}
|