Main reverted G's half merge (#189, reverted by8afc3ebin #191) and then took the reader release's S1-S5, which copied G's code. G's form holds where they differ (process_bus.go, seats.go, state.go and their tests); the release's kept names stay so main's callers keep building: SeatViewPrefix beside SeatReadPrefix, and QueueInvoke's empty strings on not-ok (with its test TestQueueInvoke). The tree differs from G's headf30dee54only by those and the release's two tests (objects cap option, seat states and stream kinds). Builds: design 47 G (the milestone's sdk part, as atf30dee54) Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h
659 lines
24 KiB
Go
659 lines
24 KiB
Go
package views_test
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"runtime"
|
|
"slices"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
stdio "git.novox.be/novox/mesh-sdk/go"
|
|
"git.novox.be/novox/mesh-sdk/go/membership"
|
|
"git.novox.be/novox/mesh-sdk/go/views"
|
|
)
|
|
|
|
// fakeState is an observer's state `findings` in memory.
|
|
type fakeState struct {
|
|
mu sync.Mutex
|
|
m map[string][]byte
|
|
}
|
|
|
|
func (s *fakeState) Get(key string) (*stdio.StateEntry, error) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if v, ok := s.m[key]; ok {
|
|
return &stdio.StateEntry{Key: key, Value: v}, nil
|
|
}
|
|
return nil, nil
|
|
}
|
|
func (s *fakeState) Put(key string, value any) (uint64, error) {
|
|
raw, _ := json.Marshal(value)
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
s.m[key] = raw
|
|
return 1, nil
|
|
}
|
|
func (s *fakeState) Delete(key string) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
delete(s.m, key)
|
|
return nil
|
|
}
|
|
func (s *fakeState) Keys() ([]string, error) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
var k []string
|
|
for key := range s.m {
|
|
k = append(k, key)
|
|
}
|
|
return k, nil
|
|
}
|
|
|
|
type said struct {
|
|
subject string
|
|
data []byte
|
|
}
|
|
|
|
type rig struct {
|
|
t *testing.T
|
|
state *fakeState
|
|
now time.Time
|
|
mu sync.Mutex
|
|
sent []said
|
|
fail int // publishes refused before one is taken
|
|
waits []time.Duration
|
|
logs []string
|
|
observe func(context.Context) (testObserved, error)
|
|
handle func([]byte)
|
|
listens int
|
|
stopped int
|
|
w *testWriter
|
|
ch chan struct{}
|
|
}
|
|
|
|
// subjects are the four subjects the writer forms itself in §383's form (design 47 G A-13); the literal is the form.
|
|
var subjects = views.FindingSubjects("m", "n1")
|
|
|
|
// asked is the subject the module gives the writer for findings-asked, as its seat's generated contract has it (design 47 G A-17).
|
|
const asked = "mesh.seat.s.event.findings-asked"
|
|
|
|
func subFindingSubjectsAreTheEventsStreamForm(t *testing.T) {
|
|
want := views.WriterSubjects{
|
|
Written: "mesh.stream.m.event.finding-written.n1", Cleared: "mesh.stream.m.event.finding-cleared.n1",
|
|
Stated: "mesh.stream.m.event.findings-stated.n1",
|
|
}
|
|
if subjects != want {
|
|
t.Fatalf("the subjects: %+v, want %+v", subjects, want)
|
|
}
|
|
}
|
|
|
|
// fakeBindings is a process's binding of the entries it asks.
|
|
type fakeBindings struct {
|
|
asks bool
|
|
names membership.BusBinding
|
|
err error
|
|
}
|
|
|
|
func (f fakeBindings) Asks(local string) bool { return f.asks && local == views.FindingEventsLocal }
|
|
func (f fakeBindings) Names(string) (membership.BusBinding, error) {
|
|
return f.names, f.err
|
|
}
|
|
|
|
// Design 47 G A-274: a part handed the binding `finding-events` says its three events under that binding's subject, a part
|
|
// with none on the old stream; a binding not answered yet is sent again until it is.
|
|
func subWriterSaysUnderItsFindingEventsBindingOrOnTheOldStream(t *testing.T) {
|
|
say := func(b views.FindingBindings) (*rig, []said) {
|
|
r := newRig(t)
|
|
w, err := views.NewWriter(testConfig{Module: "m", State: r.state, Node: "n1", Asked: asked, Bindings: b,
|
|
Observe: func(context.Context) (testObserved, error) { return testObserved{}, nil },
|
|
Wait: func(ctx context.Context, d time.Duration) bool {
|
|
r.mu.Lock()
|
|
r.now = r.now.Add(d)
|
|
r.mu.Unlock()
|
|
return ctx.Err() == nil
|
|
},
|
|
Now: func() time.Time { r.mu.Lock(); defer r.mu.Unlock(); return r.now },
|
|
Publish: func(_ context.Context, subject string, data []byte, _ string) error {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
r.sent = append(r.sent, said{subject, data})
|
|
return nil
|
|
},
|
|
Listen: func(context.Context, string, func([]byte)) (func(), error) { return func() {}, nil }})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := w.Write(context.Background(), finding("a", "one")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := w.Clear(context.Background(), "module", "a", "k", "gone"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := w.State(context.Background(), ""); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return r, r.events()
|
|
}
|
|
subjectsOf := func(ev []said) []string {
|
|
var out []string
|
|
for _, e := range ev {
|
|
out = append(out, e.subject)
|
|
}
|
|
return out
|
|
}
|
|
bound := membership.BusBinding{Local: views.FindingEventsLocal, Subject: "mesh.bus.m.finding-events"}
|
|
_, ev := say(fakeBindings{asks: true, names: bound})
|
|
if got := subjectsOf(ev); !slices.Equal(got, []string{"mesh.bus.m.finding-events.finding-written.n1",
|
|
"mesh.bus.m.finding-events.finding-cleared.n1", "mesh.bus.m.finding-events.findings-stated.n1"}) {
|
|
t.Errorf("under the binding: %v", got)
|
|
}
|
|
for _, b := range []views.FindingBindings{nil, fakeBindings{asks: false}} {
|
|
if _, ev := say(b); !slices.Equal(subjectsOf(ev), []string{subjects.Written, subjects.Cleared, subjects.Stated}) {
|
|
t.Errorf("with no binding: %v", subjectsOf(ev))
|
|
}
|
|
}
|
|
if _, ev := say(fakeBindings{asks: true, err: errors.New("not bound yet")}); len(ev) != 0 {
|
|
t.Errorf("an unbound entry said %v", subjectsOf(ev))
|
|
}
|
|
}
|
|
|
|
func newRig(t *testing.T) *rig { return newRigKinds(t) }
|
|
|
|
func newRigKinds(t *testing.T, kinds ...string) *rig {
|
|
r := &rig{t: t, state: &fakeState{m: map[string][]byte{}}, now: time.Date(2026, 11, 11, 12, 0, 0, 0, time.UTC), ch: make(chan struct{}, 100)}
|
|
r.observe = func(context.Context) (testObserved, error) { return testObserved{}, nil }
|
|
w, err := views.NewWriter(testConfig{
|
|
Module: "m", State: r.state, Kinds: kinds, Node: "n1", Asked: asked,
|
|
Observe: func(ctx context.Context) (testObserved, error) { return r.observe(ctx) },
|
|
Logf: func(f string, a ...any) {
|
|
r.mu.Lock()
|
|
r.logs = append(r.logs, fmt.Sprintf(f, a...))
|
|
r.mu.Unlock()
|
|
},
|
|
Now: func() time.Time { r.mu.Lock(); defer r.mu.Unlock(); return r.now },
|
|
Wait: func(ctx context.Context, d time.Duration) bool {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
r.waits = append(r.waits, d)
|
|
r.now = r.now.Add(d)
|
|
return ctx.Err() == nil
|
|
},
|
|
Publish: func(_ context.Context, subject string, data []byte, _ string) error {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
if r.fail > 0 {
|
|
r.fail--
|
|
return errors.New("no ack")
|
|
}
|
|
r.sent = append(r.sent, said{subject, data})
|
|
r.ch <- struct{}{}
|
|
return nil
|
|
},
|
|
Listen: func(_ context.Context, subject string, handle func([]byte)) (func(), error) {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
if subject != asked {
|
|
t.Errorf("listening on %s", subject)
|
|
}
|
|
r.listens++
|
|
r.handle = handle
|
|
return func() { r.mu.Lock(); r.stopped++; r.mu.Unlock() }, nil
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
r.w = w
|
|
return r
|
|
}
|
|
|
|
func (r *rig) events() []said {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
return slices.Clone(r.sent)
|
|
}
|
|
|
|
func finding(id, summary string) testFinding {
|
|
return testFinding{Kind: "k", Scope: "module", ID: id, Severity: "warning", Summary: summary, Source: "probe",
|
|
Subject: testSubject{Scope: "module", ID: id}}
|
|
}
|
|
|
|
// A finding written says its event with the layout whole after the key, keeps the key's since, and says again only when
|
|
// what it says changed; a finding cleared says its event, also of a key that does not exist (design 47 G §383, §485).
|
|
func subWriterWritesAndClearsFindingsAsEvents(t *testing.T) {
|
|
r := newRig(t)
|
|
ctx := context.Background()
|
|
first := r.now
|
|
if err := r.w.Write(ctx, finding("a", "one")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
r.now = r.now.Add(time.Hour)
|
|
f := finding("a", "one")
|
|
f.Run = "other run"
|
|
if err := r.w.Write(ctx, f); err != nil { // nothing said changed
|
|
t.Fatal(err)
|
|
}
|
|
if err := r.w.Write(ctx, finding("a", "two")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
ev := r.events()
|
|
if len(ev) != 2 || ev[0].subject != subjects.Written || ev[1].subject != subjects.Written {
|
|
t.Fatalf("events: %+v", ev)
|
|
}
|
|
var got testFinding
|
|
if err := json.Unmarshal(ev[1].data, &got); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got.Summary != "two" || !got.Since.Equal(first) || got.Observer != "m" || views.FindingKey(got.Scope, got.ID, got.Kind) != "module.a.k" || !got.At.Equal(r.now) {
|
|
t.Fatalf("body: %+v", got)
|
|
}
|
|
if e, _ := r.state.Get("module.a.k"); e == nil {
|
|
t.Fatal("the key was not written")
|
|
}
|
|
if err := r.w.Clear(ctx, "module", "a", "k", "gone"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := r.w.Clear(ctx, "module", "never", "k", "held by the keeper"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
ev = r.events()
|
|
var cl testCleared
|
|
if len(ev) != 4 || ev[3].subject != subjects.Cleared || json.Unmarshal(ev[3].data, &cl) != nil || cl.ID != "never" || cl.Why != "held by the keeper" {
|
|
t.Fatalf("clear events: %+v", ev)
|
|
}
|
|
if e, _ := r.state.Get("module.a.k"); e != nil {
|
|
t.Fatal("the key was not deleted")
|
|
}
|
|
}
|
|
|
|
// An event the bus does not acknowledge is sent again for one minute, then written in the journal as an error naming the
|
|
// finding (design 47 G §383).
|
|
func subWriterSendsAgainForAMinuteThenLogs(t *testing.T) {
|
|
r := newRig(t)
|
|
r.fail = 2
|
|
if err := r.w.Write(context.Background(), finding("a", "one")); err != nil || len(r.events()) != 1 {
|
|
t.Fatalf("resent: %v %+v", err, r.events())
|
|
}
|
|
r.fail = 1000
|
|
start := r.now
|
|
if err := r.w.Write(context.Background(), finding("b", "one")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if r.now.Sub(start) < views.PublishRetryFor || r.now.Sub(start) > views.PublishRetryFor+10*time.Second {
|
|
t.Fatalf("resent for %s", r.now.Sub(start))
|
|
}
|
|
if len(r.logs) != 1 || !strings.Contains(r.logs[0], "module.b.k") || !strings.Contains(r.logs[0], "error") {
|
|
t.Fatalf("journal: %v", r.logs)
|
|
}
|
|
}
|
|
|
|
// A start states the findings observed now, writes their keys, deletes the keys neither stated nor pending, and pends the
|
|
// rest; an ask naming another module is not answered, one naming none or this one is, with its id; an observe that fails
|
|
// says no set and is called again once a minute until a set is said; a connection made again makes the consumer again and
|
|
// states again (design 47 G §384, §461, §485).
|
|
func subWriterStatesAtStartAndOnAsksAndRecovers(t *testing.T) {
|
|
r := newRig(t)
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
r.state.Put("module.old.k", finding("old", "x"))
|
|
r.state.Put("module.wait.k", finding("wait", "x"))
|
|
first := r.now
|
|
r.state.Put("module.a.k", func() testFinding { f := finding("a", "before"); f.Since = first.Add(-time.Hour); return f }())
|
|
calls := 0
|
|
r.observe = func(context.Context) (testObserved, error) {
|
|
calls++
|
|
if calls <= 2 {
|
|
return testObserved{}, errors.New("not yet")
|
|
}
|
|
return testObserved{Findings: []testFinding{finding("a", "now")},
|
|
Pending: []views.PendingKey{{Key: "module.wait.k", Since: first, Bound: "5m"}}}, nil
|
|
}
|
|
if err := r.w.Start(ctx); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer r.w.Close()
|
|
stated := waitEvent(t, r, 1)
|
|
if calls != 3 || len(r.waits) != 2 || r.waits[0] != views.ObserveRetryEvery {
|
|
t.Fatalf("observe called %d times, waits %v", calls, r.waits)
|
|
}
|
|
var st testStated
|
|
if err := json.Unmarshal(stated.data, &st); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if stated.subject != subjects.Stated || st.Asked != "" || len(st.Findings) != 1 || st.Findings[0].Summary != "now" ||
|
|
!st.Findings[0].Since.Equal(first.Add(-time.Hour)) || len(st.Pending) != 1 || st.Pending[0].Key != "module.wait.k" {
|
|
t.Fatalf("statement: %s", stated.data)
|
|
}
|
|
keys, _ := r.state.Keys()
|
|
slices.Sort(keys)
|
|
if !slices.Equal(keys, []string{"module.a.k", "module.wait.k"}) {
|
|
t.Fatalf("keys: %v", keys)
|
|
}
|
|
r.handle([]byte(`{"ask":"a1","at":"2026-11-11T12:00:00Z","by":"keeper","module":"other"}`))
|
|
r.handle([]byte(`{"ask":"a2","at":"2026-11-11T12:00:00Z","by":"keeper","module":"m"}`))
|
|
st = FindingsStatedOf(t, waitEvent(t, r, 2))
|
|
if st.Asked != "a2" || len(r.events()) != 2 {
|
|
t.Fatalf("ask answered: %+v %d", st, len(r.events()))
|
|
}
|
|
r.w.Reconnected()
|
|
waitEvent(t, r, 3)
|
|
if r.listens != 2 || r.stopped != 1 {
|
|
t.Fatalf("consumers made %d, stopped %d", r.listens, r.stopped)
|
|
}
|
|
}
|
|
|
|
func FindingsStatedOf(t *testing.T, e said) testStated {
|
|
var st testStated
|
|
if err := json.Unmarshal(e.data, &st); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return st
|
|
}
|
|
|
|
// waitEvent is the n-th event said, woken by each event said (nothing sleeps for time).
|
|
func waitEvent(t *testing.T, r *rig, n int) said {
|
|
t.Helper()
|
|
for len(r.events()) < n {
|
|
select {
|
|
case <-r.ch:
|
|
case <-time.After(10 * time.Second):
|
|
t.Fatalf("event %d never said", n)
|
|
}
|
|
}
|
|
return r.events()[n-1]
|
|
}
|
|
|
|
// A write asked during a statement waits for it, so the statement never deletes the key the write made (design 47 G §485).
|
|
func subWriterRunsOneActAtATime(t *testing.T) {
|
|
r := newRig(t)
|
|
inside, release := make(chan struct{}), make(chan struct{})
|
|
r.observe = func(context.Context) (testObserved, error) {
|
|
close(inside)
|
|
<-release
|
|
return testObserved{}, nil
|
|
}
|
|
done := make(chan error, 1)
|
|
go func() { done <- r.w.State(context.Background(), "") }()
|
|
<-inside
|
|
wrote, started := make(chan error, 1), make(chan struct{})
|
|
go func() { close(started); wrote <- r.w.Write(context.Background(), finding("a", "one")) }()
|
|
<-started
|
|
for range 1000 {
|
|
runtime.Gosched()
|
|
}
|
|
close(release)
|
|
if err := <-done; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := <-wrote; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if e, _ := r.state.Get("module.a.k"); e == nil {
|
|
t.Fatal("the statement deleted the key the write made")
|
|
}
|
|
}
|
|
|
|
// A writer owns its kinds (design 47 G §580), states them, deletes no key of another kind, and covers a views reader's
|
|
// findings written through it beside observe's (§639); an owner or waiting-on of no form is refused, naming the field (§678).
|
|
func subWriterKindsAndReaderFindings(t *testing.T) {
|
|
ctx := context.Background()
|
|
r := newRigKinds(t, "k")
|
|
other := finding("x", "another bundle's")
|
|
other.Kind = "other"
|
|
otherKey := views.FindingKey(other.Scope, other.ID, other.Kind)
|
|
r.state.m[otherKey] = []byte(`{}`)
|
|
if err := r.w.Write(ctx, other); err == nil || !strings.Contains(err.Error(), `"other"`) {
|
|
t.Errorf("a write of another kind: %v", err)
|
|
}
|
|
if err := r.w.Clear(ctx, "module", "x", "other", "why"); err == nil || !strings.Contains(err.Error(), `"other"`) {
|
|
t.Errorf("a clear of another kind: %v", err)
|
|
}
|
|
for field, f := range map[string]testFinding{"owner": {Owner: "Not A Form"}, "waiting-on": {WaitingOn: "x y"}} {
|
|
b := finding("o", "s")
|
|
b.Owner, b.WaitingOn = f.Owner, f.WaitingOn
|
|
if err := r.w.Write(ctx, b); err == nil || !strings.Contains(err.Error(), field) {
|
|
t.Errorf("%s of no form: %v", field, err)
|
|
}
|
|
}
|
|
for _, v := range []string{"me@node-1", "mesh-catalog", "forge:jochen"} {
|
|
b := finding("ok", "s")
|
|
b.Owner = v
|
|
if err := r.w.Write(ctx, b); err != nil {
|
|
t.Errorf("owner %q: %v", v, err)
|
|
}
|
|
}
|
|
// a views reader writes through the part's writer, and the statement keeps its open findings
|
|
if err := views.PutViewFinding(r.w, views.ViewWaitsKind, "m.o.s", "waits", ""); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
r.observe = func(context.Context) (testObserved, error) {
|
|
return testObserved{Findings: []testFinding{finding("a", "observed")}}, nil
|
|
}
|
|
if err := r.w.State(ctx, ""); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, ok := r.state.m["module.m=2eo=2es.view-waits"]; !ok {
|
|
t.Errorf("the statement deleted the reader's finding: %v", r.state.m)
|
|
}
|
|
if _, ok := r.state.m[otherKey]; !ok {
|
|
t.Errorf("the statement deleted another bundle's key")
|
|
}
|
|
if _, ok := r.state.m["module.ok.k"]; ok {
|
|
t.Errorf("a key of its own kind neither stated nor pending stays")
|
|
}
|
|
var stated testStated
|
|
for _, e := range r.events() {
|
|
if e.subject == subjects.Stated {
|
|
_ = json.Unmarshal(e.data, &stated)
|
|
}
|
|
}
|
|
if !slices.Contains(stated.Kinds, "k") || !slices.Contains(stated.Kinds, views.ViewWaitsKind) || len(stated.Findings) != 2 {
|
|
t.Errorf("the statement: kinds %v, %d findings", stated.Kinds, len(stated.Findings))
|
|
}
|
|
if err := views.DropViewFinding(r.w, views.ViewWaitsKind, "m.o.s"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, ok := r.state.m["module.m=2eo=2es.view-waits"]; ok {
|
|
t.Errorf("the reader's finding was not cleared")
|
|
}
|
|
}
|
|
|
|
// A views reader's four findings are laid out by mesh-sdk through the body's setters (design 47 G §687): the finding's
|
|
// kind, the scope module, the id escaped as a key's name is, a warning but urgent for view-too-large, the source views, the
|
|
// subject the module on its node; and CheckFinding refuses a finding breaking a rule before it is written, naming the field
|
|
// (§689).
|
|
func subReaderFindingsAreLaidOutAndCheckFindingRefuses(t *testing.T) {
|
|
r := newRig(t)
|
|
for _, kind := range views.ViewKinds {
|
|
if err := views.PutViewFinding(r.w, kind, "o.s.main", "sum "+kind, "head"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
e, _ := r.state.Get("module.o=2es=2emain." + kind)
|
|
var got testFinding
|
|
if e == nil || json.Unmarshal(e.Value, &got) != nil {
|
|
t.Fatalf("%s not kept: %v", kind, r.state.m)
|
|
}
|
|
sev := "warning"
|
|
if kind == views.TooLargeKind {
|
|
sev = "urgent"
|
|
}
|
|
if got.Kind != kind || got.Scope != "module" || got.ID != "o=2es=2emain" || got.Severity != sev || got.Source != "views" ||
|
|
got.Subject.Scope != "module" || got.Subject.ID != "m" || got.Subject.Machine != "n1" || got.Summary != "sum "+kind || got.Headline != "head" || got.Observer != "m" {
|
|
t.Errorf("%s laid out as %+v", kind, got)
|
|
}
|
|
}
|
|
for name, c := range map[string]struct {
|
|
f testFinding
|
|
want string
|
|
}{
|
|
"scope": {testFinding{ID: "a", Kind: "k"}, "scope"},
|
|
"id": {testFinding{Scope: "module", Kind: "k"}, "id"},
|
|
"kind": {testFinding{Scope: "module", ID: "a"}, "kind"},
|
|
"dotted": {testFinding{Scope: "module", ID: "a", Kind: "k.x"}, "kind"},
|
|
"owner": {testFinding{Scope: "module", ID: "a", Kind: "k", Owner: "Not A Form"}, "owner"},
|
|
"waiting-on": {testFinding{Scope: "module", ID: "a", Kind: "k", WaitingOn: "x y"}, "waiting-on"},
|
|
} {
|
|
if err := views.CheckFinding(c.f); err == nil || !strings.Contains(err.Error(), c.want) {
|
|
t.Errorf("%s: %v", name, err)
|
|
}
|
|
if err := r.w.Write(context.Background(), c.f); err == nil {
|
|
t.Errorf("%s: written", name)
|
|
}
|
|
}
|
|
if err := views.CheckFinding(finding("a", "s")); err != nil {
|
|
t.Error(err)
|
|
}
|
|
}
|
|
|
|
// A finding body whose wire form lacks a field the contract names is refused when the writer is made, naming the field
|
|
// (design 47 G A-3).
|
|
func subWriterRefusesABodyWithoutAContractField(t *testing.T) {
|
|
type noSince struct {
|
|
Kind, Scope, ID, Observer string
|
|
At time.Time `json:"at"`
|
|
Subject testSubject
|
|
}
|
|
_, err := views.NewWriter(views.WriterConfig[noSince, testCleared, testStated, testAsked]{Module: "m", State: &fakeState{m: map[string][]byte{}}, Node: "n1", Asked: asked,
|
|
Observe: func(context.Context) (views.Observed[noSince], error) { return views.Observed[noSince]{}, nil },
|
|
Publish: func(context.Context, string, []byte, string) error { return nil },
|
|
Listen: func(context.Context, string, func([]byte)) (func(), error) { return func() {}, nil }})
|
|
if err == nil || !strings.Contains(err.Error(), `"kind"`) {
|
|
t.Fatalf("a body of other wire names: %v", err)
|
|
}
|
|
// the subject of findings-asked is the seat's and the module's to give; none given is refused (design 47 G A-17)
|
|
_, err = views.NewWriter(testConfig{Module: "m", State: &fakeState{m: map[string][]byte{}}, Node: "n1",
|
|
Observe: func(context.Context) (testObserved, error) { return testObserved{}, nil },
|
|
Publish: func(context.Context, string, []byte, string) error { return nil },
|
|
Listen: func(context.Context, string, func([]byte)) (func(), error) { return func() {}, nil }})
|
|
if err == nil || !strings.Contains(err.Error(), "findings-asked") {
|
|
t.Fatalf("a writer with no subject for findings-asked: %v", err)
|
|
}
|
|
}
|
|
|
|
// A part whose findings are raised as they happen holds them in a writer that observes from its own state (design 47 G A-29):
|
|
// the findings standing in the state are seeded before it starts, a statement says what is held, Write and Clear keep the held
|
|
// set, and an Observe of the part's own is refused.
|
|
func subHeldWriterStatesWhatItHoldsFromItsOwnState(t *testing.T) {
|
|
state := &fakeState{m: map[string][]byte{}}
|
|
raw, _ := json.Marshal(finding("seeded", "stood"))
|
|
state.m["module.seeded.k"] = raw
|
|
state.m["module.junk.k"] = []byte(`"not a finding"`)
|
|
var sent []said
|
|
cfg := testConfig{Module: "m", State: state, Node: "n1", Asked: asked,
|
|
Publish: func(_ context.Context, subject string, data []byte, _ string) error {
|
|
sent = append(sent, said{subject, data})
|
|
return nil
|
|
},
|
|
Listen: func(context.Context, string, func([]byte)) (func(), error) { return func() {}, nil }}
|
|
cfg.Observe = func(context.Context) (testObserved, error) { return testObserved{}, nil }
|
|
if _, err := views.NewHeldWriter(cfg); err == nil {
|
|
t.Fatal("a held writer took an Observe of its own")
|
|
}
|
|
cfg.Observe = nil
|
|
h, err := views.NewHeldWriter(cfg)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
ctx := context.Background()
|
|
if err := h.Write(ctx, finding("raised", "now")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := h.Write(ctx, testFinding{Kind: "k", ID: "no-scope"}); err == nil {
|
|
t.Fatal("a finding that breaks a rule was held")
|
|
}
|
|
// a views reader writes its findings in the wire form: they are held as well (design 47 G A-37)
|
|
var wire map[string]any
|
|
rawWire, _ := json.Marshal(finding("wired", "from a reader"))
|
|
_ = json.Unmarshal(rawWire, &wire)
|
|
if err := h.WriteWire(ctx, wire); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := h.Clear(ctx, "module", "seeded", "k", "no longer"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// a key the part cannot judge yet is named in the statement's pending until judged (design 47 G §384), keeping its since
|
|
since := time.Date(2026, 11, 12, 9, 0, 0, 0, time.UTC)
|
|
h.Pend(views.PendingKey{Key: "machine.n1.a2.call-unanswered", Since: since, Bound: "5m"})
|
|
h.Pend(views.PendingKey{Key: "machine.n1.a2.call-unanswered", Since: since.Add(time.Hour), Bound: "5m"})
|
|
h.Pend(views.PendingKey{Key: "machine.n1.a3.call-unanswered", Since: since, Bound: "5m"})
|
|
h.Judged("machine.n1.a3.call-unanswered")
|
|
sent = nil
|
|
if err := h.State(ctx, "a1"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var stated testStated
|
|
if len(sent) != 1 || sent[0].subject != subjects.Stated || json.Unmarshal(sent[0].data, &stated) != nil ||
|
|
len(stated.Findings) != 2 || stated.Asked != "a1" {
|
|
t.Fatalf("the statement says %+v after %d events", stated, len(sent))
|
|
}
|
|
if p := stated.Pending; len(p) != 1 || p[0].Key != "machine.n1.a2.call-unanswered" || !p[0].Since.Equal(since) || p[0].Bound != "5m" {
|
|
t.Fatalf("the statement's pending is %+v", p)
|
|
}
|
|
if held := h.Held(); len(held) != 2 {
|
|
t.Fatalf("Held answers %v", held)
|
|
} else if held[0].ID = "mutated"; len(h.Held()) != 2 {
|
|
t.Fatal("Held is not a copy")
|
|
}
|
|
ids := []string{stated.Findings[0].ID, stated.Findings[1].ID}
|
|
slices.Sort(ids)
|
|
if ids[0] != "raised" || ids[1] != "wired" {
|
|
t.Fatalf("the statement says %v", ids)
|
|
}
|
|
}
|
|
|
|
// A held writer reads its state at Start, not when made: what was written before Start is held and written at Start, over
|
|
// what stood in the state (design 47 G A-40).
|
|
func subHeldWriterSeedsAtStart(t *testing.T) {
|
|
state := &fakeState{m: map[string][]byte{}}
|
|
raw, _ := json.Marshal(finding("seeded", "stood"))
|
|
state.m["module.seeded.k"] = raw
|
|
var mu sync.Mutex
|
|
cfg := testConfig{Module: "m", State: state, Node: "n1", Asked: asked,
|
|
Publish: func(context.Context, string, []byte, string) error { mu.Lock(); mu.Unlock(); return nil },
|
|
Listen: func(context.Context, string, func([]byte)) (func(), error) { return func() {}, nil }}
|
|
h, err := views.NewHeldWriter(cfg)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
ctx := context.Background()
|
|
if err := h.Write(ctx, finding("early", "before")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if held := h.Held(); len(held) != 1 || held[0].ID != "early" {
|
|
t.Fatalf("before Start Held answers what was written so far: %v", held)
|
|
}
|
|
if len(state.m) != 1 {
|
|
t.Fatalf("a write before Start touched the state: %v", state.m)
|
|
}
|
|
if err := h.Start(ctx); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer h.Close()
|
|
if held := h.Held(); len(held) != 2 {
|
|
t.Fatalf("after Start Held answers the seeded and the early: %v", held)
|
|
}
|
|
if _, ok := state.m["module.early.k"]; !ok {
|
|
t.Fatalf("the early finding was written at Start: %v", state.m)
|
|
}
|
|
}
|
|
|
|
// TestFindingSubjectsAreTheEventsStreamForm runs the tests of this package that were merged to keep the module within its test budget (design 47 G §419), each as a subtest.
|
|
func TestFindingSubjectsAreTheEventsStreamForm(t *testing.T) {
|
|
t.Run("FindingSubjectsAreTheEventsStreamForm", subFindingSubjectsAreTheEventsStreamForm)
|
|
t.Run("WriterSaysUnderItsFindingEventsBindingOrOnTheOldStream", subWriterSaysUnderItsFindingEventsBindingOrOnTheOldStream)
|
|
t.Run("WriterWritesAndClearsFindingsAsEvents", subWriterWritesAndClearsFindingsAsEvents)
|
|
t.Run("WriterSendsAgainForAMinuteThenLogs", subWriterSendsAgainForAMinuteThenLogs)
|
|
t.Run("WriterStatesAtStartAndOnAsksAndRecovers", subWriterStatesAtStartAndOnAsksAndRecovers)
|
|
t.Run("WriterRunsOneActAtATime", subWriterRunsOneActAtATime)
|
|
t.Run("WriterKindsAndReaderFindings", subWriterKindsAndReaderFindings)
|
|
t.Run("ReaderFindingsAreLaidOutAndCheckFindingRefuses", subReaderFindingsAreLaidOutAndCheckFindingRefuses)
|
|
t.Run("WriterRefusesABodyWithoutAContractField", subWriterRefusesABodyWithoutAContractField)
|
|
t.Run("HeldWriterStatesWhatItHoldsFromItsOwnState", subHeldWriterStatesWhatItHoldsFromItsOwnState)
|
|
t.Run("HeldWriterSeedsAtStart", subHeldWriterSeedsAtStart)
|
|
}
|