Files
mesh-catalog/modules/messenger/cmd/messenger/tracking_test.go
T
jschoubben d00cc8baf4
mesh/merge-gate pass: builds messenger → novox; no bus step; every machine composes with the change as it did without (4 of 4 compose)
mesh/repo-check pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-check-typescript.sh (typescript)
mesh/delivery superseded: a newer head of the same pull request
messenger: the preview is the sending path's own walk (review of issue 422)
The review of #192 found three faults, all verified by running.

The tests did not prove the claim the change leads with: a probe that
sent, queued, recorded and wrote from inside Preview still passed,
because the condition they used was a warning needing nothing, which is
held rather than sent. They now hold an urgent condition, snapshot what
a send would move — the channels, the queue, the history, the refusals,
the stored record and the record held — assert none of it moved, and
tick once more. Every one of those probes now fails the tests.

`cleared` and `silenced` said wording no send would carry: an open
record has no clearing time, so a condition open four hours cleared
"after a moment", and no silence, so a silence read "until Mon 1 Jan
00:17". A clearing is now composed as if it cleared now, which the
answer says; a silence is refused unless the condition is under one.

The preview composed only its own words, while a send reduces a message
whose words the content rule refuses. The escalation walk is now one
function, allowedWords, which `words` calls for a send and the preview
calls alone — so the words read are the words that would leave, and
reading them records no refusal and states nothing on the bus.
withTracking hands back the line it put in, so the preview reports that
line instead of deciding the rule a second time; and a record that is
not a condition, or one carrying an open ask, is refused or says that a
channel's message carries more.
2026-10-11 00:43:40 +02:00

226 lines
12 KiB
Go

package main
import (
"encoding/json"
"errors"
"strings"
"testing"
"time"
)
// A condition's message says which issue tracks it and where that issue stands (novox/hq issue 400, ADR 0285):
// one case per wording, each through the message as composed, and the content rule passes every line.
var trackNow = time.Date(2026, 10, 10, 19, 30, 0, 0, time.UTC)
func at(h, m int) *time.Time {
t := time.Date(2026, 10, 10, h, m, 0, 0, time.UTC)
return &t
}
// trackedMessage is the message of a warning that needs nothing, as the controller words a hand repair, with the
// issues given tracking it.
func trackedMessage(issues []TrackedIssue, known bool) Message {
r := &Record{Key: "mesh.hand-acts.rolled-back.healer-wanted", Origin: "condition", Severity: Warning,
Headline: "Rolled-back keeps being fixed by hand",
Explanation: "Nothing for you to do. A person repaired rolled-back by hand 4 times in 14 days, so an automatic repair is wanted for it. Nothing is broken now.",
Raised: trackNow.Add(-26 * time.Hour)}
m := compose(r, "quiet-reminder", trackNow, time.UTC, 0)
out, _ := withTracking(m, r, "quiet-reminder", issues, known, trackNow, time.UTC)
return out
}
func TestAConditionsMessageSaysWhereTheIssueThatTracksItStands(t *testing.T) {
cases := []struct {
name string
issues []TrackedIssue
known bool
line string
verdict string // what the body opens with
}{
{"no issue", nil, true,
"No issue tracks this yet.", "Nothing for you to do."},
{"the tracker gave no answer", nil, false,
"Whether an issue tracks this could not be checked.", "Nothing for you to do."},
{"nobody works on it", []TrackedIssue{{Number: 352, Status: "located"}}, true,
"Tracked as issue 352: nobody is working on it.", "A person repaired"},
{"an agent is fixing it", []TrackedIssue{{Number: 386, Status: "fixing", Worker: "fix-386@g14", Since: at(21, 10)}}, true,
"Tracked as issue 386: fix-386 on g14 is fixing it since 21:10.", "Nothing for you to do."},
{"an agent looks for its cause", []TrackedIssue{{Number: 386, Status: "diagnosing", Worker: "jochen@shanks", Since: at(18, 5)}}, true,
"Tracked as issue 386: jochen on shanks is looking for its cause since 18:05.", "Nothing for you to do."},
{"its worker went quiet", []TrackedIssue{{Number: 386, Status: "fixing", Worker: "fix-386@g14", Since: at(9, 0), Active: at(9, 30), Stale: true}}, true,
"Tracked as issue 386: nobody has worked on it since 09:30.", "A person repaired"},
{"a fix waits to be merged", []TrackedIssue{{Number: 386, Status: "located", Fix: "not-merged"}}, true,
"Tracked as issue 386: a fix is written and waits to be merged.", "Nothing for you to do."},
{"its fix is on its way", []TrackedIssue{{Number: 386, Status: "fixing", Worker: "fix-386@g14", Since: at(9, 0), Fix: "merged"}}, true,
"Tracked as issue 386: its fix is merged and on its way.", "Nothing for you to do."},
{"the delivery was not heard yet", []TrackedIssue{{Number: 386, Status: "fixing", Fix: "unheard"}}, true,
"Tracked as issue 386: a fix is named, and nothing is heard yet of its delivery.", "Nothing for you to do."},
{"its fix is held", []TrackedIssue{{Number: 386, Status: "fixing", Fix: "held"}}, true,
"Tracked as issue 386: its fix is merged and held before it is delivered.", "Nothing for you to do."},
{"its fix's delivery failed", []TrackedIssue{{Number: 386, Status: "fixing", Fix: "failed"}}, true,
"Tracked as issue 386: its fix is merged, but its delivery failed.", "Nothing for you to do."},
{"its fix is delivered", []TrackedIssue{{Number: 386, Status: "fixing", Fix: "delivered"}}, true,
"Tracked as issue 386: its fix is delivered and waits to be checked.", "Nothing for you to do."},
{"it was resolved", []TrackedIssue{{Number: 386, Status: "resolved", Fix: "delivered"}}, true,
"Tracked as issue 386: it was resolved, yet this still happens.", "A person repaired"},
{"it will not be fixed", []TrackedIssue{{Number: 386, Status: "wontfix"}}, true,
"Tracked as issue 386: it will not be fixed.", "A person repaired"},
{"one not fixed after one open", []TrackedIssue{{Number: 100, Status: "wontfix"}, {Number: 200, Status: "open"}}, true,
"Tracked as issue 200: nobody is working on it. Also as issue 100: it will not be fixed.", "A person repaired"},
{"its worker went quiet on one that waits for a decision", []TrackedIssue{{Number: 400, Status: "fixing", Needs: "decision",
Worker: "fix-400@g14", Since: at(9, 0), Active: at(9, 30), Stale: true}}, true,
"Tracked as issue 400: nobody has worked on it since 09:30.", "A person repaired"},
{"it waits for the operator", []TrackedIssue{{Number: 336, Status: "open", Needs: "operator"}}, true,
"Tracked as issue 336: it waits for your answer.", "Needs you: answer issue 336. A person repaired"},
{"it waits for a decision", []TrackedIssue{{Number: 400, Status: "open", Needs: "decision"}}, true,
"Tracked as issue 400: nobody is working on it; it waits for a decision first.", "A person repaired"},
{"two issues, one worked on", []TrackedIssue{
{Number: 386, Status: "fixing", Worker: "fix-386@g14", Since: at(21, 10)},
{Number: 379, Status: "located"}}, true,
"Tracked as issue 379: nobody is working on it. Also as issue 386: fix-386 on g14 is fixing it since 21:10.",
"Nothing for you to do."},
{"two issues, nobody on either", []TrackedIssue{{Number: 386, Status: "open"}, {Number: 379, Status: "located"}}, true,
"Tracked as issue 379: nobody is working on it. Also as issue 386: nobody is working on it.", "A person repaired"},
{"one of two waits for the operator", []TrackedIssue{
{Number: 386, Status: "fixing", Worker: "fix-386@g14", Since: at(21, 10)},
{Number: 379, Status: "open", Needs: "operator"}}, true,
"Tracked as issue 379: it waits for your answer. Also as issue 386: fix-386 on g14 is fixing it since 21:10.",
"Needs you: answer issue 379."},
{"more than three", []TrackedIssue{{Number: 1, Status: "open"}, {Number: 2, Status: "open"}, {Number: 3, Status: "open"},
{Number: 4, Status: "open"}, {Number: 5, Status: "resolved"}}, true,
"Tracked as issue 1: nobody is working on it. Also as issue 2: nobody is working on it. Also as issue 3: nobody is " +
"working on it. And 2 more issues.", "A person repaired"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
m := trackedMessage(c.issues, c.known)
lines := strings.Split(m.Body, "\n")
if len(lines) != 3 || lines[1] != c.line {
t.Fatalf("the issue line:\n got %q\nwant %q", m.Body, c.line)
}
if !strings.HasPrefix(lines[0], c.verdict) {
t.Errorf("the body opens %q, want %q", lines[0], c.verdict)
}
if !strings.HasPrefix(lines[2], "Open since ") {
t.Errorf("since is not last: %q", m.Body)
}
if r, ok := Check(m.Text()); !ok {
t.Errorf("the content rule refuses it: %v", r)
}
})
}
}
// Never "Nothing for you to do." while an issue tracking it waits for the operator, nor while nobody works on any.
func TestNothingForYouToDoIsNeverSaidWhileTheIssueWaitsOnTheOperatorOrNobodyWorksOnIt(t *testing.T) {
for _, issues := range [][]TrackedIssue{
{{Number: 336, Status: "open", Needs: "operator"}},
{{Number: 336, Status: "fixing", Worker: "fix-336@g14", Since: at(9, 0), Needs: "operator"}},
{{Number: 352, Status: "located"}},
{{Number: 352, Status: "fixing", Worker: "fix-352@g14", Since: at(9, 0), Stale: true}},
} {
if m := trackedMessage(issues, true); strings.Contains(m.Body, "Nothing for you to do") {
t.Errorf("%+v: %q", issues, m.Body)
}
}
}
// A verdict that already needs the operator is left as it is; the line is added below it.
func TestAVerdictThatNeedsTheOperatorStands(t *testing.T) {
r := &Record{Key: "module.mounts.shanks.unhealthy", Origin: "condition", Severity: Urgent, Headline: "mounts not working on shanks",
Explanation: "Needs you: give the share's login. mounts on shanks is not healthy.", Raised: trackNow.Add(-time.Hour)}
m, _ := withTracking(compose(r, "raised", trackNow, time.UTC, 0), r, "raised",
[]TrackedIssue{{Number: 386, Status: "open", Needs: "operator"}}, true, trackNow, time.UTC)
want := "Needs you: give the share's login. mounts on shanks is not healthy.\nTracked as issue 386: it waits for your answer.\nSince 18:30."
if m.Body != want {
t.Fatalf("got %q\nwant %q", m.Body, want)
}
}
// A clearing, a silence and a notice from a module say nothing of issues.
func TestAClearingASilenceAndANoticeSayNoIssue(t *testing.T) {
issues := []TrackedIssue{{Number: 352, Status: "located"}}
r := &Record{Key: "k", Origin: "condition", Headline: "h", Explanation: "Nothing for you to do. x.", Raised: trackNow.Add(-time.Hour),
Cleared: trackNow, SilencedTill: trackNow.Add(time.Hour)}
for _, what := range []string{"cleared", "silenced"} {
m := compose(r, what, trackNow, time.UTC, 0)
got, line := withTracking(m, r, what, issues, true, trackNow, time.UTC)
if got.Body != m.Body || line != "" {
t.Errorf("%s: %q, line %q", what, got.Body, line)
}
}
n := &Record{Key: "n", Origin: "notify", Headline: "a notice", Raised: trackNow}
m := compose(n, "raised", trackNow, time.UTC, 0)
got, line := withTracking(m, n, "raised", issues, true, trackNow, time.UTC)
if got.Body != m.Body || line != "" {
t.Errorf("notice: %q, line %q", got.Body, line)
}
}
// Through the router: the tracker is asked outside the work, its word is kept, a word older than TrackingKept is
// not used, and a tracker that fails is said as not checked.
func TestTheRouterAsksTheTrackerAndSaysItsWordInTheMessage(t *testing.T) {
h, tg, dt, c, _ := newHolder(t)
h.Zone = func() *time.Location { return time.UTC }
asked := 0
fail := false
h.Ask = func(address string, body any) (json.RawMessage, error) {
if address != TrackingVerb {
return json.RawMessage(`{"ok":true}`), nil
}
asked++
if fail {
return nil, errors.New("no responders")
}
return json.RawMessage(`{"conditions":{"module.mounts.shanks.unhealthy":[` +
`{"number":379,"status":"located"},` +
`{"number":386,"status":"fixing","worker":"fix-386@g14","since":"2026-10-06T11:10:00Z"}]}}`), nil
}
k := cond("module.mounts.shanks.unhealthy", Urgent, "mounts on shanks is not healthy")
k.Explanation, k.Needs = "Nothing for you to do. mounts on shanks is not healthy.", ""
k.Raised = c.now()
h.Condition(EventRaised, k)
settle(h, c)
if len(tg.sends) != 1 || len(dt.sends) != 1 {
t.Fatalf("urgent: telegram %d, desktop %d", len(tg.sends), len(dt.sends))
}
body := tg.sends[0].Body
if !strings.Contains(body, "\nTracked as issue 379: nobody is working on it. Also as issue 386: fix-386 on g14 is fixing it since 11:10.\n") {
t.Fatalf("body: %q", body)
}
if asked != 1 {
t.Errorf("asked the tracker %d times within a minute, want once", asked)
}
keyless(t, tg.sends...)
// The tracker stops answering: its word is used until it is TrackingKept old, then said as not checked.
fail = true
c.pass(TrackingKept + time.Minute)
h.Tick()
h.mu.Lock()
r := h.open["module.mounts.shanks.unhealthy"]
h.mu.Unlock()
if m := h.say(r, "reminder"); !strings.Contains(m.Body, "\nWhether an issue tracks this could not be checked.\n") {
t.Fatalf("an old word was used: %q", m.Body)
}
if asked < 2 {
t.Errorf("the tracker was not asked again")
}
}
// A router that cannot ask the tracker (a test's) adds nothing, so a missing word is never confused with a router
// built without it.
func TestARouterThatCannotAskAddsNothing(t *testing.T) {
h, _, _, c, _ := newHolder(t)
k := cond("machine.ace.silent", Urgent, "ace is silent")
k.Raised = c.now()
h.Condition(EventRaised, k)
h.mu.Lock()
r := h.open["machine.ace.silent"]
h.mu.Unlock()
if m := h.say(r, "raised"); strings.Contains(m.Body, "issue") {
t.Fatalf("%q", m.Body)
}
}