Files
mesh-host/internal/seatwait/seatwait_test.go
T
jschoubben 732def66b0 H-21: the manifest invokes revision (none-yet) and withheld, and config view-waits-minutes
The long-form invokes entry is read by mesh-sdk's second reader release (09d6070e): packaging's manifest
tests pass with it placed and fail before it is joined.

Builds: design 47 G §558, §617, §633, §635
2026-11-12 05:00:14 +01:00

266 lines
11 KiB
Go

package seatwait
import (
"context"
"errors"
"strings"
"testing"
"time"
condseat "git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshconditions"
"git.novox.be/novox/mesh-sdk/go/keys"
"git.novox.be/novox/mesh-sdk/go/seatlist"
)
type fakeReader struct{ st seatlist.Status }
func (f *fakeReader) Status() seatlist.Status { return f.st }
type fakeTimer struct {
c *fakeClock
due time.Time
f func()
stopped bool
}
func (t *fakeTimer) Stop() bool { was := !t.stopped; t.stopped = true; return was }
type fakeClock struct {
now time.Time
timers []*fakeTimer
}
func (c *fakeClock) Now() time.Time { return c.now }
func (c *fakeClock) AfterFunc(d time.Duration, f func()) Timer {
t := &fakeTimer{c: c, due: c.now.Add(d), f: f}
c.timers = append(c.timers, t)
return t
}
// advance moves the clock and fires the timers that fell due and were not stopped.
func (c *fakeClock) advance(d time.Duration) {
c.now = c.now.Add(d)
for _, t := range c.timers {
if !t.stopped && !t.due.After(c.now) {
t.stopped = true
t.f()
}
}
}
// fakeFindings is a held writer: what it holds is what stands in the state, seeded by the test for an earlier process's.
type fakeFindings struct {
put []condseat.Finding
cleared []condseat.Finding // as held when cleared
why []string
err error
held []condseat.Finding
}
func (f *fakeFindings) Held() []condseat.Finding { return append([]condseat.Finding(nil), f.held...) }
func (f *fakeFindings) Write(_ context.Context, x condseat.Finding) error {
if f.err != nil {
return f.err
}
f.put = append(f.put, x)
f.held = append(f.held, x)
return nil
}
func (f *fakeFindings) Clear(_ context.Context, scope, id, kind, why string) error {
for i, h := range f.held {
if h.Scope == scope && h.ID == id && h.Kind == kind {
f.held = append(f.held[:i:i], f.held[i+1:]...)
f.cleared = append(f.cleared, h)
f.why = append(f.why, why)
return nil
}
}
f.cleared = append(f.cleared, condseat.Finding{Scope: scope, ID: id, Kind: kind}) // cleared of nothing, said all the same
f.why = append(f.why, why)
return nil
}
// ADR 0636 §1: a reader that waited five minutes makes the node write one finding, which the first full read clears.
func caseTheNodeWritesOneFindingAfterFiveMinutesAndClearsItAtTheFirstFullRead(t *testing.T) {
reader := &fakeReader{st: seatlist.Status{Heard: false, LastError: "no answer"}}
clock := &fakeClock{now: time.Date(2026, 11, 5, 12, 0, 0, 0, time.UTC)}
findings := &fakeFindings{}
var said []string
w := &Waiter{Node: "ace", Observer: "node-engine", Reader: reader, Writer: findings, Clock: clock, Say: func(s string) { said = append(said, s) }}
w.Changed()
clock.advance(Bound - time.Second)
if len(findings.put) != 0 {
t.Fatalf("a finding was written before the bound: %+v", findings.put)
}
w.Changed() // another change while waiting starts no second bound
clock.advance(time.Second)
if len(findings.put) != 1 || findings.put[0].Kind != Kind || findings.put[0].ID != "ace" {
t.Fatalf("after five minutes the node wrote %+v, want one %s finding for ace", findings.put, Kind)
}
clock.advance(time.Hour)
if len(findings.put) != 1 {
t.Fatalf("the finding was written again: %d", len(findings.put))
}
reader.st = seatlist.Status{Heard: true}
w.Changed()
if len(findings.cleared) != 1 || findings.cleared[0].ID != "ace" {
t.Fatalf("the first full read left the finding: deleted %+v", findings.cleared)
}
if on, _ := w.Waiting(); on {
t.Error("the waiter still waits after the full read")
}
}
// A read that ends the wait stops the bound; a timer that fires after the list was read writes nothing.
func caseAReadWithinTheBoundWritesNothing(t *testing.T) {
reader := &fakeReader{st: seatlist.Status{Heard: false}}
clock := &fakeClock{now: time.Date(2026, 11, 5, 12, 0, 0, 0, time.UTC)}
findings := &fakeFindings{}
w := &Waiter{Node: "ace", Observer: "node-engine", Reader: reader, Writer: findings, Clock: clock}
w.Changed()
clock.advance(time.Minute)
reader.st = seatlist.Status{Heard: true}
w.Changed()
clock.advance(time.Hour)
if len(findings.put) != 0 || len(findings.cleared) != 0 {
t.Fatalf("a list read in time wrote %+v and cleared %+v", findings.put, findings.cleared)
}
// The seat answered the list already held: no change was said, so the timer is the one to look again.
reader.st = seatlist.Status{Heard: false}
w.Changed()
reader.st = seatlist.Status{Heard: true}
clock.advance(Bound)
if len(findings.put) != 0 {
t.Fatalf("a timer that found the list read wrote %+v", findings.put)
}
}
// A finding that cannot be written is said and tried again at the next change, not lost.
func caseAFindingThatCannotBeWrittenIsSaidAndTriedAgain(t *testing.T) {
reader := &fakeReader{st: seatlist.Status{Heard: false}}
clock := &fakeClock{now: time.Date(2026, 11, 5, 12, 0, 0, 0, time.UTC)}
findings := &fakeFindings{err: errors.New("bus down")}
var said []string
w := &Waiter{Node: "ace", Observer: "node-engine", Reader: reader, Writer: findings, Clock: clock, Say: func(s string) { said = append(said, s) }}
w.Changed()
clock.advance(Bound)
if len(said) != 1 || len(findings.put) != 0 {
t.Fatalf("said %v, wrote %v", said, findings.put)
}
findings.err = nil
w.Changed()
clock.advance(Bound)
if len(findings.put) != 1 {
t.Fatalf("the finding was not written at the next bound: %v", findings.put)
}
}
// A finding an earlier process wrote stands in the bus state: the new process clears it at its first full read with no
// write first, and while the list stays unread it neither starts a bound nor writes it again (ADR 0636 §1, issue 1966).
func caseAFindingOfAnEarlierProcessIsClearedAtTheFirstFullRead(t *testing.T) {
reader := &fakeReader{st: seatlist.Status{Heard: false}}
clock := &fakeClock{now: time.Date(2026, 11, 11, 12, 0, 0, 0, time.UTC)}
findings := &fakeFindings{held: []condseat.Finding{{Scope: "machine", ID: "ace", Kind: Kind}}}
w := &Waiter{Node: "ace", Observer: "node-engine", Reader: reader, Writer: findings, Clock: clock}
w.Changed()
clock.advance(Bound)
if len(findings.put) != 0 || len(clock.timers) != 0 {
t.Fatalf("a standing finding was written again (%+v) or a bound started (%d)", findings.put, len(clock.timers))
}
if on, _ := w.Waiting(); !on {
t.Error("the waiter does not say the finding stands")
}
reader.st = seatlist.Status{Heard: true}
w.Changed()
if len(findings.cleared) != 1 || findings.cleared[0].ID != "ace" || len(findings.put) != 0 {
t.Fatalf("the first full read left the earlier process's finding: deleted %+v, put %+v", findings.cleared, findings.put)
}
}
// Design 47, test 55: a module held for its claim is a finding that names the node, the module and why, written once,
// and cleared when the module is composed again or no longer wanted, also one an earlier run of the engine left.
func caseAHeldModuleIsAFindingClearedWhenItIsComposedOrNoLongerWanted(t *testing.T) {
// the writer holds what an earlier run of the engine left (seeded at its Start), and what another node's keeper wrote
findings := &fakeFindings{held: []condseat.Finding{
{Scope: "module", ID: HeldID("gone", "ace"), Kind: HeldKind},
{Scope: "module", ID: HeldID("elsewhere", "g14"), Kind: HeldKind}}}
k := &HeldKeeper{Node: "ace", Observer: "node-engine", Writer: findings}
k.Reconcile(map[string]string{"mesh-grants": "its seat is refused", "other": "no claim"})
if len(findings.put) != 2 || findings.put[0].ID != "mesh-grants.ace" || len(findings.cleared) != 1 || findings.cleared[0].ID != "gone.ace" {
t.Fatalf("wrote %v, cleared %v", findings.put, findings.cleared)
}
// the finding is of the module on its machine, in the contract's layout (design 47, test 55)
if f := findings.put[0]; f.Scope != "module" || f.Subject.Scope != "module" || f.Subject.Machine != "ace" || f.Subject.ID != "mesh-grants.ace" ||
f.Severity != condseat.FindingSeverityWarning || f.Source != HeldKind || f.Observer != "node-engine" {
t.Fatalf("a held module's finding is %+v", f)
}
if got := findings.held[0]; got.ID != "elsewhere.g14" {
t.Fatalf("another node's finding was touched: %+v", findings.held)
}
k.Reconcile(map[string]string{"mesh-grants": "its seat is refused", "other": "no claim"})
if len(findings.put) != 2 {
t.Fatalf("a module held for the same words was written again: %v", findings.put)
}
k.Reconcile(map[string]string{"mesh-grants": "its seat is refused"})
if len(findings.cleared) != 2 || findings.cleared[1].ID != "other.ace" {
t.Fatalf("a module composed again kept its finding: %v", findings.cleared)
}
k.Reconcile(nil)
if len(findings.cleared) != 3 {
t.Fatalf("a module no longer wanted kept its finding: %v", findings.cleared)
}
}
// Design 47 G §614, A-117: the finding entry-not-found is of scope module with the fingerprint, escaped, as its id, and cleared by it.
func caseAnEntryNotFoundIsAFindingOfTheFingerprint(t *testing.T) {
w := &fakeFindings{}
fp := "src2:" + strings.Repeat("a", 64)
now := func() time.Time { return time.Unix(1, 0) }
if err := SayEntryNotFound(context.Background(), w, "n1", "mesh-host", fp, true, now); err != nil {
t.Fatal(err)
}
if len(w.put) != 1 || w.put[0].Kind != EntryNotFoundKind || w.put[0].Scope != "module" || w.put[0].ID != keys.EscapeName(fp) || strings.Contains(w.put[0].ID, ":") {
t.Fatalf("written %+v", w.put)
}
if err := SayEntryNotFound(context.Background(), w, "n1", "mesh-host", fp, false, now); err != nil || len(w.cleared) != 1 || w.cleared[0].ID != w.put[0].ID {
t.Fatalf("cleared %+v, %v", w.cleared, err)
}
}
// ADR 0636 §1, the node's finding of a seat list unread.
// §633: the bound is the manifest's `view-waits-minutes` when one is given: a finding at two minutes, none before.
func caseAGivenBoundReplacesTheFiveMinutes(t *testing.T) {
reader := &fakeReader{st: seatlist.Status{Heard: false, LastError: "no answer"}}
clock := &fakeClock{now: time.Date(2026, 11, 5, 12, 0, 0, 0, time.UTC)}
findings := &fakeFindings{}
w := &Waiter{Node: "ace", Observer: "node-engine", Reader: reader, Writer: findings, Clock: clock, Bound: 2 * time.Minute}
w.Changed()
clock.advance(2*time.Minute - time.Second)
if len(findings.put) != 0 {
t.Fatalf("a finding was written before the given bound: %+v", findings.put)
}
clock.advance(time.Second)
if len(findings.put) != 1 {
t.Fatalf("no finding at the given bound: %+v", findings.put)
}
}
func TestTheSeatListWait(t *testing.T) {
for name, run := range map[string]func(*testing.T){
"five minutes write one finding and the first full read clears it": caseTheNodeWritesOneFindingAfterFiveMinutesAndClearsItAtTheFirstFullRead,
"a given bound replaces the five minutes": caseAGivenBoundReplacesTheFiveMinutes,
"a read within the bound writes nothing": caseAReadWithinTheBoundWritesNothing,
"a finding that cannot be written is said and tried again": caseAFindingThatCannotBeWrittenIsSaidAndTriedAgain,
"a finding of an earlier process is cleared at the first full read": caseAFindingOfAnEarlierProcessIsClearedAtTheFirstFullRead,
"an entry not found is a finding of the fingerprint": caseAnEntryNotFoundIsAFindingOfTheFingerprint,
"a held module is a finding cleared when it is composed or no longer wanted": caseAHeldModuleIsAFindingClearedWhenItIsComposedOrNoLongerWanted,
} {
t.Run(name, run)
}
}