Files
mesh-tools/node-tools/internal/firstview/firstview_test.go
T
jschoubben 0b0f879e86 E-4: a misspelt event name is refused, not waited on for ever (issue 1966)
Before it subscribes, mesh_wait checks each name's emitter against the catalog
half the tool runner holds (its seats' definitions and its modules' entries),
refusing one neither knows and naming the half and its revisions; a known
emitter's undeclared event is waited on and its timed-out answer says so; while
the half is not yet read the answer says the names were unchecked. The first
views answer the halves by branch for it.

Builds: design 34 every-event §14, §19 (rows of the table of refusals)

Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h
2026-11-12 05:11:52 +01:00

250 lines
10 KiB
Go

package firstview
import (
"context"
"encoding/json"
"errors"
"os"
"slices"
"strings"
"sync"
"testing"
"time"
condseat "git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshconditions"
"git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient"
catalogseat "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog"
delivery "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshdelivery"
"git.novox.be/novox/mesh-sdk/go/views"
"github.com/novox/mesh-tools/node-tools/internal/bus"
)
// owners is the four owners' first-view tools as a test answers them: a state's values at a revision, or nothing while down.
type owners struct {
mu sync.Mutex
down bool
state map[string]map[string]string // tool → key → value
rev map[string]uint64
asked map[string]int
deliver func(bus.Envelope) error
finds map[string]bool
// catalog is the revision of the watched branch main the seat mesh-catalog answers, and modules its entries
catalog int64
modules []string
}
func newOwners() *owners {
return &owners{state: map[string]map[string]string{"assignments": {"anchor": `{"p":1}`}, "seat-holders": {"seat": `{"h":1}`}},
rev: map[string]uint64{"assignments": 4, "seat-holders": 2}, asked: map[string]int{}, finds: map[string]bool{},
catalog: 3, modules: []string{"web"}}
}
// Write and Clear are the runner's finding writer in a test: the findings written and not cleared, by their key.
func (o *owners) Write(_ context.Context, f condseat.Finding) error {
o.mu.Lock()
defer o.mu.Unlock()
o.finds[views.FindingKey(f.Scope, f.ID, f.Kind)] = true
return nil
}
func (o *owners) Clear(_ context.Context, scope, id, kind, _ string) error {
o.mu.Lock()
defer o.mu.Unlock()
delete(o.finds, views.FindingKey(scope, id, kind))
return nil
}
func (o *owners) ask(address string, _ any) (json.RawMessage, error) {
o.mu.Lock()
defer o.mu.Unlock()
tool := address[strings.LastIndex(address, ".")+1:]
o.asked[tool]++
if o.down {
return nil, errors.New("503 no responders")
}
switch address {
case "seat:mesh-delivery.source":
return json.Marshal(delivery.SourceAnswer{Branches: []string{"main"}})
case "seat:mesh-catalog.registrations":
return json.Marshal(catalogseat.RegistrationsAnswer{Registrations: []catalogseat.Registration{}})
case "seat:mesh-catalog.revision":
a := catalogseat.RevisionAnswer{Revision: &catalogseat.CatalogRevision{Revision: o.catalog, Kind: meshcatalogclient.KindTrunk, Branch: "main"}}
for _, m := range o.modules {
a.Entries = append(a.Entries, catalogseat.CatalogEntry{Module: m, Manifest: map[string]any{"module": m, "tools": []string{m + "_check"}}})
}
return json.Marshal(a)
}
vals := map[string]json.RawMessage{}
for k, v := range o.state[tool] {
vals[k] = json.RawMessage(v)
}
return json.Marshal(views.Answer{V: 1, Owner: "o", State: tool, Revision: o.rev[tool], Created: time.Unix(1000, 0).UTC(), Values: vals})
}
func (o *owners) say(t *testing.T, state, key, value string, revision uint64, gap bool) {
t.Helper()
ev := views.Event{V: 1, Owner: owner(state), State: state, Key: key, Revision: revision, Created: time.Unix(1000, 0).UTC(), Gap: gap}
if value != "" {
ev.Value = json.RawMessage(value)
}
raw, _ := json.Marshal(ev)
if err := o.deliver(bus.Envelope{Key: ev.Owner + ".views." + state, Body: raw}); err != nil {
t.Fatal(err)
}
}
func owner(state string) string {
for _, s := range Specs {
if s.State == state {
return s.Owner
}
}
return ""
}
func eventually(t *testing.T, what string, ok func() bool) {
t.Helper()
for end := time.Now().Add(3 * time.Second); !ok(); time.Sleep(5 * time.Millisecond) {
if time.Now().After(end) {
t.Fatal(what)
}
}
}
// issue 1966 TLS-5 (ADR 0615 §2, §4, §5, §6): the runner holds the four states from the owners' first views and their
// events alone; nothing is claimed of a state not yet answered; a gap asks the first view once more; an owner that stays down
// past the bound is a finding, cleared by the answer; the status and the hand ask show them.
func TestTheRunnerHoldsTheFourStatesFromTheOwnersFirstViewsAndTheirEvents(t *testing.T) {
o := newOwners()
o.down = true
var logs []string
var lmu sync.Mutex
deps := Deps{Module: "node-tools", Ask: o.ask, Bound: 30 * time.Millisecond, Wait: 10 * time.Millisecond,
Consume: func(deliver func(bus.Envelope) error) (func(), error) { o.deliver = deliver; return func() {}, nil },
Findings: o}
v, stop := StartWith(context.Background(), deps, func(f string, a ...any) { lmu.Lock(); logs = append(logs, f); lmu.Unlock() })
defer stop()
// Down: nothing is claimed, and past the bound each unanswered view is a finding.
if _, err := v.State("mesh-delivery-assigner.assignments"); err == nil || !strings.Contains(err.Error(), "not been answered") {
t.Fatalf("a state its owner has not answered is claimed: %v", err)
}
eventually(t, "no finding past the bound", func() bool { o.mu.Lock(); defer o.mu.Unlock(); return len(o.finds) == 4 })
// Up: answered, the findings are cleared, and the pictures hold the owners' values.
o.mu.Lock()
o.down = false
o.mu.Unlock()
eventually(t, "not answered", func() bool { _, err := v.State("mesh-delivery-assigner.seat-holders"); return err == nil })
eventually(t, "findings not cleared", func() bool { o.mu.Lock(); defer o.mu.Unlock(); return len(o.finds) == 0 })
if got, _ := v.Entry("mesh-delivery-assigner.seat-holders", "none"); got != nil {
t.Errorf("an absent key is %q", got)
}
// The catalog half is followed by the shared branch follower on the runner's own consumer: the gate's source names main,
// main's view is adopted, and its entries are the modules, each with its manifest (design 47 G §196, §614).
eventually(t, "the catalog half holds no modules", func() bool { m, err := v.Modules(); return err == nil && len(m) == 1 })
m, _ := v.Modules()
if m["web"].Manifest["module"] != "web" {
t.Errorf("the module web is %+v", m["web"])
}
// A catalog-revision of the branch is heard on the same consumer and asks the view once more.
o.mu.Lock()
o.catalog, o.modules = 4, []string{"web", "db"}
o.mu.Unlock()
ev, _ := json.Marshal(catalogseat.CatalogRevisionEvent{V: 1, Branch: "main", Kind: meshcatalogclient.KindTrunk, Revision: 4})
if err := o.deliver(bus.Envelope{Key: catalogseat.Seat + "." + catalogseat.EventCatalogRevision, Body: ev}); err != nil {
t.Fatal(err)
}
eventually(t, "the catalog-revision was not taken", func() bool { m, err := v.Modules(); return err == nil && len(m) == 2 })
// And the halves by branch, with their revision, which mesh_wait names checking an emitter (design 34 every-event §14).
if h, err := v.Halves(); err != nil || h["main"].Revision.Revision != 4 || len(h["main"].Entries) != 2 {
t.Errorf("the halves: %+v, %v", h, err)
}
// Events: the next revision is applied; an old one passed over; a deletion removes; none of it asks again.
asked := func(tool string) int { o.mu.Lock(); defer o.mu.Unlock(); return o.asked[tool] }
before := asked("assignments")
o.say(t, "assignments", "laptop", `{"p":2}`, 5, false)
o.say(t, "assignments", "laptop", `{"p":0}`, 5, false)
o.say(t, "assignments", "anchor", "", 6, false)
// (a deleted mark carries no value: Deleted is the encoding's, set below)
if got, _ := v.Entry("mesh-delivery-assigner.assignments", "laptop"); string(got) != `{"p":2}` {
t.Errorf("the next revision is not applied: %q", got)
}
if n := asked("assignments"); n != before {
t.Errorf("an event in order asked the first view again (%d asks, was %d)", n, before)
}
// A gap (revision 9 where 7 was next) asks the first view once more, said in the log.
o.say(t, "assignments", "x", `{"p":9}`, 9, false)
eventually(t, "a gap did not ask the first view again", func() bool { return asked("assignments") > before })
// The status says owner, tool, revision and the asks; the hand ask asks every view again.
var assignments views.Status
for _, s := range v.Status() {
if s.State == "assignments" {
assignments = s
}
}
if assignments.Owner != "mesh-delivery-assigner" || assignments.Tool != "assignments" || assignments.Asks < 2 || assignments.Answered.IsZero() {
t.Errorf("the status of assignments is %+v", assignments)
}
manifests := asked("seat-holders")
if errs := v.Again(context.Background(), "doubted"); len(errs) != 0 {
t.Fatalf("the hand ask failed: %v", errs)
}
if asked("seat-holders") != manifests+1 {
t.Errorf("the hand ask did not ask the seat-holders again")
}
for _, st := range v.Status() {
if st.State == "seat-holders" && st.ByHand != 1 {
t.Errorf("the hand ask is not counted: %+v", st)
}
}
lmu.Lock()
defer lmu.Unlock()
if !strings.Contains(strings.Join(logs, "\n"), "a gap in") {
t.Errorf("the gap was not said in the log: %v", logs)
}
}
// The four first views are the manifest's `consumes` families, and the console reads each by the name here.
func TestTheFirstViewsAreTheManifestsViews(t *testing.T) {
raw, err := os.ReadFile("../../module.json")
if err != nil {
t.Fatal(err)
}
var m struct {
Consumes []string `json:"consumes"`
}
if err := json.Unmarshal(raw, &m); err != nil {
t.Fatal(err)
}
want := map[string]bool{AssignmentsRead: true, SeatHoldersRead: true}
for _, s := range Specs {
if !want[s.Name()] {
t.Errorf("the first view %s is read by no name here", s.Name())
}
delete(want, s.Name())
if view := s.Owner + ".views." + s.Tool; !slices.Contains(m.Consumes, view) {
t.Errorf("the first view %s has no family in the manifest's consumes %v", view, m.Consumes)
}
}
if len(want) > 0 {
t.Errorf("no first view reads %v", want)
}
// the catalog half's follower hears these events and asks these verbs, and the manifest says so (design 47 G §614)
for _, ev := range []string{"mesh-catalog.catalog-revision", "mesh-catalog.registration-changed", "mesh-delivery.source-moved"} {
if !slices.Contains(m.Consumes, ev) {
t.Errorf("the catalog half's event %s is not in the manifest's consumes %v", ev, m.Consumes)
}
}
for _, ev := range []string{"mesh-delivery-indexer.views.modules", "mesh-delivery-indexer.views.manifests"} {
if slices.Contains(m.Consumes, ev) {
t.Errorf("the manifest still consumes the indexer's %s", ev)
}
}
}