1524991adc
ECOSYSTEM-SPEC §2.6 requires list_attention to distinguish "nothing needs attention" from "I cannot currently tell", and to say so when a source is failed or stale. Maven said the first one unconditionally: ListAttention decoded into []map[string]any, the word degraded appeared nowhere, and an empty list answered "ничего не требует внимания". A Praxis with every source dead read as calm. Two halves, because the spec's mechanism does not exist server-side yet. The deployed Praxis answers /api/v1/tools/attention with a bare array and no envelope, so praxisAttention now decodes either shape and believes a degraded array when one arrives. Until one does, an empty list triggers one read of /api/v1/sources, and anything that is not reporting health "ok" is named instead of the all-clear. Zero sources is the same answer: a Praxis that polls nothing knows nothing, which is the state of this box today. A sources read that fails is deliberately not a hedge. The attention call succeeded, and not being able to ask about health is not evidence of a fault. Both hedges also cover the entity-scoped digest, where a per-entity all-clear is the more convincing of the two. New keys attention_degraded and attention_no_sources, in acts_ru_v1.json and the floor. The fake Praxis serves one healthy source by default, so the existing attention tests still assert an all-clear on purpose rather than by omission.
126 lines
4.7 KiB
Go
126 lines
4.7 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// An empty attention list used to be answered "ничего не требует внимания"
|
|
// unconditionally, which is an all-clear Maven had no way to know was true
|
|
// (ECOSYSTEM-SPEC §2.6, Vikunja #540).
|
|
func TestAttentionEmptyWithHealthySourcesIsAllClear(t *testing.T) {
|
|
praxis := newFakePraxisWithSources(t, `[]`, `[{"source_id":"src_ntfy","health":"ok"}]`)
|
|
h := newPraxisTestHandler(t, praxis)
|
|
|
|
reply := h.handlePraxisAct(context.Background(), praxisActDec("list_attention"))
|
|
if !strings.Contains(reply, "ничего не требует внимания") {
|
|
t.Fatalf("healthy and quiet should be an all-clear, got %q", reply)
|
|
}
|
|
}
|
|
|
|
func TestAttentionEmptyWithAFailedSourceHedges(t *testing.T) {
|
|
praxis := newFakePraxisWithSources(t, `[]`, `[
|
|
{"source_id":"src_ntfy","health":"ok"},
|
|
{"source_id":"src_llamacpp","health":"failed"},
|
|
{"source_id":"src_imap","health":"stale"}
|
|
]`)
|
|
h := newPraxisTestHandler(t, praxis)
|
|
|
|
reply := h.handlePraxisAct(context.Background(), praxisActDec("list_attention"))
|
|
if strings.Contains(reply, "ничего не требует внимания") {
|
|
t.Fatalf("a failed source must not read as all-clear, got %q", reply)
|
|
}
|
|
for _, want := range []string{"src_llamacpp", "src_imap"} {
|
|
if !strings.Contains(reply, want) {
|
|
t.Errorf("reply names no %s: %q", want, reply)
|
|
}
|
|
}
|
|
if strings.Contains(reply, "src_ntfy") {
|
|
t.Errorf("the healthy source is named as a problem: %q", reply)
|
|
}
|
|
}
|
|
|
|
// A Praxis that polls nothing knows nothing, which is the state the box is in.
|
|
func TestAttentionEmptyWithNoSourcesHedges(t *testing.T) {
|
|
praxis := newFakePraxisWithSources(t, `[]`, `[]`)
|
|
h := newPraxisTestHandler(t, praxis)
|
|
|
|
reply := h.handlePraxisAct(context.Background(), praxisActDec("list_attention"))
|
|
if strings.Contains(reply, "ничего не требует внимания") {
|
|
t.Fatalf("a Praxis with no sources must not answer all-clear, got %q", reply)
|
|
}
|
|
if !strings.Contains(reply, "источник") {
|
|
t.Errorf("reply does not say why she cannot tell: %q", reply)
|
|
}
|
|
}
|
|
|
|
// The spec's own mechanism, which the deployed Praxis does not send yet: the
|
|
// envelope's degraded array is believed without a second call.
|
|
func TestAttentionDegradedEnvelopeIsReadWithoutASourcesCall(t *testing.T) {
|
|
praxis := newFakePraxisWithSources(t,
|
|
`{"items":[],"degraded":["src_metrics"]}`,
|
|
`[{"source_id":"src_ntfy","health":"ok"}]`)
|
|
h := newPraxisTestHandler(t, praxis)
|
|
|
|
reply := h.handlePraxisAct(context.Background(), praxisActDec("list_attention"))
|
|
if !strings.Contains(reply, "src_metrics") {
|
|
t.Fatalf("the envelope's degraded source is not named: %q", reply)
|
|
}
|
|
for _, r := range praxis.Requests() {
|
|
if r.Path == "/api/v1/sources" {
|
|
t.Error("sources was read even though the response carried degraded")
|
|
}
|
|
}
|
|
}
|
|
|
|
// A sources endpoint that errors is not evidence of a fault: the attention call
|
|
// itself succeeded, and hedging on it would make her permanently uncertain.
|
|
func TestAttentionKeepsAllClearWhenSourcesCannotBeRead(t *testing.T) {
|
|
praxis := newFakePraxisWithSources(t, `[]`, `[]`)
|
|
praxis.SetRouteFault("/api/v1/sources", 500)
|
|
h := newPraxisTestHandler(t, praxis)
|
|
|
|
reply := h.handlePraxisAct(context.Background(), praxisActDec("list_attention"))
|
|
if !strings.Contains(reply, "ничего не требует внимания") {
|
|
t.Fatalf("an unreadable sources list should leave the answer alone, got %q", reply)
|
|
}
|
|
}
|
|
|
|
// Both response shapes decode, because the spec says one and the box sends the
|
|
// other.
|
|
func TestPraxisAttentionDecodesBothShapes(t *testing.T) {
|
|
var bare praxisAttention
|
|
if err := json.Unmarshal([]byte(`[{"id":"item_1"}]`), &bare); err != nil {
|
|
t.Fatalf("bare array: %v", err)
|
|
}
|
|
if len(bare.Items) != 1 || len(bare.Degraded) != 0 {
|
|
t.Errorf("bare array decoded as %+v", bare)
|
|
}
|
|
|
|
var env praxisAttention
|
|
if err := json.Unmarshal([]byte(`{"items":[{"id":"item_2"}],"degraded":["src_a"]}`), &env); err != nil {
|
|
t.Fatalf("envelope: %v", err)
|
|
}
|
|
if len(env.Items) != 1 || len(env.Degraded) != 1 || env.Degraded[0] != "src_a" {
|
|
t.Errorf("envelope decoded as %+v", env)
|
|
}
|
|
}
|
|
|
|
// A source that reports no health at all counts as healthy. A Praxis that never
|
|
// fills the field would otherwise make every quiet turn a hedge.
|
|
func TestUnhealthySourcesTreatsAnAbsentHealthFieldAsHealthy(t *testing.T) {
|
|
praxis := newFakePraxisWithSources(t, `[]`, `[{"source_id":"src_a"},{"id":"src_b","health":"stale"}]`)
|
|
bad, total, err := newPraxisClient(praxis.URL).UnhealthySources(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("UnhealthySources: %v", err)
|
|
}
|
|
if total != 2 {
|
|
t.Errorf("total = %d, want 2", total)
|
|
}
|
|
if len(bad) != 1 || bad[0] != "src_b" {
|
|
t.Errorf("bad = %v, want [src_b]", bad)
|
|
}
|
|
}
|