225 lines
11 KiB
Go
225 lines
11 KiB
Go
package main
|
|
|
|
import (
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/novox/mesh-controller/internal/conditions"
|
|
"github.com/novox/mesh-controller/internal/inventory"
|
|
"github.com/novox/mesh-controller/internal/link"
|
|
)
|
|
|
|
// The conditions the operator read on 2026-10-08, raised again from the same facts (novox/hq ADR 0253):
|
|
// each keeps its summary for whoever looks closer, and now carries a headline, an explanation and a
|
|
// resolved line the operator reads at a glance — no plan id, commit, key, verb or clock time in them.
|
|
|
|
// plainExample checks one finding's words as the keeper will keep them: plain, within their bounds,
|
|
// opened by the verdict, and saying what the operator needs; and its actions by label.
|
|
func plainExample(t *testing.T, o conditions.Observation, headline, explanation string, actions ...string) {
|
|
t.Helper()
|
|
machines := append([]string{o.Machine}, o.Also...)
|
|
w := conditions.Words{Headline: o.Headline, Explanation: o.Explanation, Resolved: o.Resolved, Needs: o.Needs,
|
|
Actions: o.Actions}
|
|
if why, ok := conditions.PlainWords(w, machines...); !ok {
|
|
t.Errorf("%s: not plain: %s\n %+v", o.Key(), why, w)
|
|
}
|
|
said := conditions.Verdict(o.Needs, o.Explanation)
|
|
if o.Headline != headline || said != explanation {
|
|
t.Errorf("%s: says\n %q\n %q\nwant\n %q\n %q", o.Key(), o.Headline, said, headline, explanation)
|
|
}
|
|
var labels []string
|
|
for _, a := range o.Actions {
|
|
labels = append(labels, a.Label)
|
|
}
|
|
if strings.Join(labels, ",") != strings.Join(actions, ",") {
|
|
t.Errorf("%s: actions %v, want %v", o.Key(), labels, actions)
|
|
}
|
|
quiet := ""
|
|
if o.Needs == "" && o.Severity == conditions.Warning {
|
|
quiet = " (needs nothing and is a warning: no popup; it stays in conditions and the history)"
|
|
}
|
|
t.Logf("\nBEFORE: %s\nAFTER: %s%s\n %s\n actions %v\nCLEARS: %s", o.Summary, o.Headline, quiet,
|
|
said, labels, o.Resolved)
|
|
}
|
|
|
|
// **A delivery waiting**: the popup read "the walk of novox/mesh-catalog a6385479 has waited 56m0s for
|
|
// mesh-delivery's word to start: `mesh-delivery.show` for the delivery that landed as a6385479 says why;
|
|
// `plans go plan-1791454185265004861 --why …` starts it by hand". Under four hours it needs nothing — no
|
|
// popup; past them, the operator starts or stops it from the notification.
|
|
func TestADeliveryWaitingNeedsNothingUntilItsBoundThenOffersStartAndStop(t *testing.T) {
|
|
now := time.Date(2026, 10, 8, 11, 36, 0, 0, time.UTC)
|
|
f := calm(now)
|
|
f.waits = []waitFacts{{id: "plan-1791454185265004861", repository: "novox/mesh-catalog", commit: "a6385479c0ffee",
|
|
awaits: "mesh-delivery", since: now.Add(-56 * time.Minute), modules: []string{"openrazer"}}}
|
|
got := watchWaits(f)
|
|
if len(got) != 1 {
|
|
t.Fatalf("%+v", got)
|
|
}
|
|
plainExample(t, got[0], "openrazer delivery waiting to start",
|
|
"Nothing for you to do. The change to openrazer is merged and built, and has waited 56 minutes for "+
|
|
"mesh-delivery (the module that decides when a delivery goes out) to let it start. It becomes a question "+
|
|
"for you if it still waits after 4 hours.")
|
|
if !strings.Contains(got[0].Summary, "plans go plan-1791454185265004861") {
|
|
t.Errorf("the summary lost the way on for whoever looks closer: %q", got[0].Summary)
|
|
}
|
|
|
|
// Past four hours it is urgent, and offers the controller's own answers.
|
|
f.waits[0].since = now.Add(-5 * time.Hour)
|
|
got = watchWaits(f)
|
|
plainExample(t, got[0], "openrazer delivery waiting to start",
|
|
"Needs you: start it, or stop it, from the mesh MCP server; this notification cannot do it. The change to openrazer is merged and built, and mesh-delivery (the "+
|
|
"module that decides when a delivery goes out) has not let it start for 5 hours, so mesh-delivery may "+
|
|
"be stuck.")
|
|
|
|
// Many modules are counted, not listed in the headline.
|
|
f.waits[0].modules = []string{"a", "b", "c", "d"}
|
|
got = watchWaits(f)
|
|
if got[0].Headline != "mesh-catalog delivery waiting to start" || !strings.Contains(got[0].Explanation, "4 modules of mesh-catalog") {
|
|
t.Errorf("%+v", got[0])
|
|
}
|
|
}
|
|
|
|
// **A module unhealthy**: "openrazer on g14 is not healthy: its unit openrazer-daemon.service failed in the
|
|
// account's own service manager (exit-code)". Restarting is not an acknowledgement, so it is said in words
|
|
// and offered as no answer (ADR 0258).
|
|
func TestAModuleUnhealthyAsksForARestartInWords(t *testing.T) {
|
|
o := moduleUnhealthyObservation("openrazer", "g14", []inventory.ResourceHealth{{Kind: link.KindUnit,
|
|
Resource: "openrazer-daemon", Target: "openrazer-daemon.service",
|
|
Reason: "failed in the account's own service manager (exit-code)", Since: time.Now()}})
|
|
plainExample(t, o, "openrazer not working on g14",
|
|
"Needs you: restart its service openrazer-daemon on g14 from the mesh MCP server; this notification cannot do it. "+
|
|
"openrazer on g14 is not healthy: its service openrazer-daemon stopped with an error. It clears as soon "+
|
|
"as it runs again.")
|
|
// An account waiting for a new login (ADR 0252) asks for the login, held to the plain rule.
|
|
o = moduleUnhealthyObservation("openrazer", "g14", []inventory.ResourceHealth{{Kind: "account",
|
|
Resource: "operator-in-group", Target: "jochen", Reason: "relogin needed: the account is in the group"}})
|
|
if o.Needs != "log out of g14 completely and log in again, or restart it." || len(o.Actions) != 0 {
|
|
t.Errorf("relogin: %q %+v", o.Needs, o.Actions)
|
|
}
|
|
w := conditions.Words{Headline: o.Headline, Explanation: o.Explanation, Resolved: o.Resolved, Needs: o.Needs}
|
|
if why, ok := conditions.PlainWords(w, "g14"); !ok {
|
|
t.Errorf("the relogin words are not plain: %s", why)
|
|
}
|
|
// And the kind issue 318 raises for it (ADR 0254).
|
|
rw := plainWordings["relogin-needed"](conditions.Observation{Scope: conditions.ScopeModule, ID: "openrazer.g14",
|
|
Machine: "g14", Kind: "relogin-needed", Severity: conditions.Warning})
|
|
if why, ok := conditions.PlainWords(rw, "g14"); !ok || rw.Needs == "" || len(rw.Actions) != 0 {
|
|
t.Errorf("relogin-needed: %s %+v", why, rw)
|
|
}
|
|
if strings.Contains(o.Summary, "session") || !strings.Contains(o.Summary, "waits for a new login of jochen") {
|
|
t.Errorf("relogin summary: %q", o.Summary)
|
|
}
|
|
}
|
|
|
|
// **Failed units on a machine**: "shanks's service manager is degraded: 3 failed unit(s) no module places —
|
|
// mnt-recalbox.mount, storage-media.mount, greenclip.service (the account's own manager). …"
|
|
func TestFailedUnitsOnAMachineAreNamedWithoutTheirSuffixes(t *testing.T) {
|
|
o, raise := machineUnitsObservation("shanks", &link.UnitsHealth{State: "degraded", Failed: []link.FailedUnit{
|
|
{Unit: "mnt-recalbox.mount", Result: "exit-code"}, {Unit: "storage-media.mount", Result: "exit-code"},
|
|
{Unit: "greenclip.service", Scope: "user", Result: "exit-code"}}})
|
|
if !raise {
|
|
t.Fatal("not raised")
|
|
}
|
|
plainExample(t, o, "3 failed services on shanks",
|
|
"Needs you: mend or remove them on shanks, or silence this if they do not matter. On shanks, "+
|
|
"mnt-recalbox (a mount), storage-media (a mount) and greenclip failed. No module manages them, so the "+
|
|
"mesh does not repair them.", "Silence for a week")
|
|
}
|
|
|
|
// **A healer wanted**: "\"push\" was repaired by hand 35 times in 14 days, the last by g14.node-tools, through
|
|
// the mesh-controller seat: a healer is wanted for it". Nothing is broken: no popup.
|
|
func TestAHealerWantedNeedsNothingFromTheOperator(t *testing.T) {
|
|
now := time.Date(2026, 10, 8, 12, 0, 0, 0, time.UTC)
|
|
f := calm(now)
|
|
for i := 0; i < 35; i++ {
|
|
a := actByHand(now.Add(-time.Duration(i+1)*time.Hour), "push")
|
|
a.Verb, a.By = "push", "g14.node-tools, through the mesh-controller seat"
|
|
f.handActs = append(f.handActs, a)
|
|
}
|
|
got := watchHandActs(f)
|
|
if len(got) != 1 {
|
|
t.Fatalf("%+v", got)
|
|
}
|
|
plainExample(t, got[0], "Push keeps being fixed by hand",
|
|
"Nothing for you to do. A person repaired push by hand 35 times in 14 days, so an automatic repair is "+
|
|
"wanted for it. Nothing is broken now.")
|
|
}
|
|
|
|
// **A delivery held past its bound**, as mesh-delivery says it: "the delivery novox/hq@055550802096 has been
|
|
// held for 36h2m6s, past its bound of 24h0m0s (it waits for the operator): healer H2 may none: …".
|
|
func TestADeliveryHeldAsksForReleaseOrStopInWords(t *testing.T) {
|
|
got := stalledObservations([]stalledLine{{ID: "novox/hq@055550802096", State: "held", For: "36h2m6s",
|
|
Bound: "24h0m0s", H2: "none: the state is the operator's", Says: "it waits for the operator"}})
|
|
plainExample(t, got[0], "Delivery of hq held for 36 hours",
|
|
"Needs you: release it, or stop it, from the mesh MCP server; this notification cannot do it. A delivery of hq has been held for 36 hours, past its limit.",
|
|
)
|
|
}
|
|
|
|
// **Every kind the controller raises has plain words**, and its words are plain for a subject of every
|
|
// shape it is raised with.
|
|
func TestEveryWordingIsPlain(t *testing.T) {
|
|
subjects := []conditions.Observation{
|
|
{Scope: conditions.ScopeMachine, ID: "ace", Machine: "ace"},
|
|
{Scope: conditions.ScopeMachine, ID: "ace.immich.library", Machine: "ace"},
|
|
{Scope: conditions.ScopeModule, ID: "openrazer.g14", Machine: "g14"},
|
|
{Scope: conditions.ScopeProvider, ID: "postgres.anchor.app_db", Machine: "anchor"},
|
|
{Scope: conditions.ScopePlan, ID: "plan-1791454185265004861"},
|
|
{Scope: conditions.ScopeDelivery, ID: "novox/hq@055550802096"},
|
|
{Scope: conditions.ScopeCore, ID: "controller.anchor", Machine: "anchor"},
|
|
{Scope: conditions.ScopeBus, ID: "mesh"},
|
|
{Scope: conditions.ScopeSeat, ID: "node-backup.ace", Machine: "ace"},
|
|
{Scope: conditions.ScopeMesh, ID: "hand-acts.disk-load"},
|
|
{Scope: conditions.ScopeBuild, ID: "openrazer.g14", Machine: "g14"},
|
|
}
|
|
for kind, fn := range plainWordings {
|
|
if !conditions.Worded(kind) {
|
|
t.Errorf("%s is not registered", kind)
|
|
}
|
|
for _, s := range subjects {
|
|
s.Kind = kind
|
|
w := fn(s)
|
|
w.Explanation = conditions.Verdict(w.Needs, w.Explanation)
|
|
if why, ok := conditions.PlainWords(w, s.Machine); !ok {
|
|
t.Errorf("%s about %s %s: %s\n %+v", kind, s.Scope, s.ID, why, w)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// **An answer on a notification is no repair** (novox/hq ADR 0258): silencing chosen by the operator on the
|
|
// desk does not count toward a healer wanted; the same silence by hand for another cause still does.
|
|
func TestAnOperatorsAnswerIsNoHandRepair(t *testing.T) {
|
|
now := time.Date(2026, 10, 8, 12, 0, 0, 0, time.UTC)
|
|
f := calm(now)
|
|
for i := 0; i < 3; i++ {
|
|
a := actByHand(now.Add(-time.Duration(i+1)*time.Hour), conditions.CauseOperatorAnswer)
|
|
a.Verb = "conditions silence"
|
|
f.handActs = append(f.handActs, a)
|
|
}
|
|
if got := watchHandActs(f); len(got) != 0 {
|
|
t.Fatalf("an answer counted as a repair: %+v", got)
|
|
}
|
|
for i := range f.handActs {
|
|
f.handActs[i].Cause = "machine-units"
|
|
}
|
|
if got := watchHandActs(f); len(got) != 1 {
|
|
t.Fatalf("a silence by hand stopped counting: %+v", got)
|
|
}
|
|
}
|
|
|
|
// **Data loss is never silenced from a notification** (ADR 0258): data missing or shrunk offers no answer,
|
|
// and says where it is silenced.
|
|
func TestDataLossOffersNoSilence(t *testing.T) {
|
|
for _, kind := range []string{kindDataMissing, kindDataShrank} {
|
|
w := plainWordings[kind](conditions.Observation{Scope: conditions.ScopeMachine, ID: "ace.immich.library",
|
|
Machine: "ace", Kind: kind, Severity: conditions.Urgent})
|
|
if len(w.Actions) != 0 || !strings.HasSuffix(w.Needs, FromMeshMCPServer) {
|
|
t.Errorf("%s: %+v", kind, w)
|
|
}
|
|
if why, ok := conditions.PlainWords(w, "ace"); !ok {
|
|
t.Errorf("%s: %s", kind, why)
|
|
}
|
|
}
|
|
}
|