package firstview import ( "context" "encoding/json" "errors" "strings" "sync" "testing" "time" "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 } func newOwners() *owners { return &owners{state: map[string]map[string]string{"placement": {"anchor": `{"p":1}`}, "seat-holders": {"seat": `{"h":1}`}, "manifests": {"d1": `{"m":1}`}, "modules": {"web": `{"w":1}`}}, rev: map[string]uint64{"placement": 4, "seat-holders": 2, "manifests": 7, "modules": 3}, asked: map[string]int{}, finds: map[string]bool{}} } 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") } 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 }, PutFinding: func(key string, _ json.RawMessage) error { o.mu.Lock() o.finds[key] = true o.mu.Unlock() return nil }, DeleteFinding: func(key string) error { o.mu.Lock() delete(o.finds, key) o.mu.Unlock() return nil }} 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.placement"); 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-indexer.manifests"); 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-indexer.modules", "web"); string(got) != `{"w":1}` { t.Errorf("the modules picture holds %q", got) } if got, _ := v.Entry("mesh-delivery-indexer.modules", "none"); got != nil { t.Errorf("an absent key is %q", got) } // 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("placement") o.say(t, "placement", "laptop", `{"p":2}`, 5, false) o.say(t, "placement", "laptop", `{"p":0}`, 5, false) o.say(t, "placement", "anchor", "", 6, false) // (a deleted mark carries no value: Deleted is the encoding's, set below) if got, _ := v.Entry("mesh-delivery-assigner.placement", "laptop"); string(got) != `{"p":2}` { t.Errorf("the next revision is not applied: %q", got) } if n := asked("placement"); 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, "placement", "x", `{"p":9}`, 9, false) eventually(t, "a gap did not ask the first view again", func() bool { return asked("placement") > before }) // The status says owner, tool, revision and the asks; the hand ask asks every view again. var placement views.Status for _, s := range v.Status() { if s.State == "placement" { placement = s } } if placement.Owner != "mesh-delivery-assigner" || placement.Tool != "placement" || placement.Asks < 2 || placement.Answered.IsZero() { t.Errorf("the status of placement is %+v", placement) } manifests := asked("manifests") if errs := v.Again(context.Background(), "doubted"); len(errs) != 0 { t.Fatalf("the hand ask failed: %v", errs) } if asked("manifests") != manifests+1 { t.Errorf("the hand ask did not ask the manifests again") } for _, st := range v.Status() { if st.State == "manifests" && 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) } }