4d97280d74
The correction is the only supervised signal in the box, so the cost of giving one has to be near zero. That means the surface needs the trace id of the turn it is showing, which it had no way to learn: handleText returns one string and the trace was written after the reply left. The id rides back on ChatReply through the same context sink querySource uses, so the mic, telegram and the web keep the one signature they share. CorrectTurn takes a trace id and an optional target, which is deliberately reach-agnostic: nothing about it assumes a browser. store.ErrNoSuchTrace gets a wire twin. A turn past the retention bound is gone, and that is the expected outcome of correcting an old turn, not a broken database.
159 lines
5.7 KiB
Go
159 lines
5.7 KiB
Go
package ipc
|
|
|
|
import (
|
|
"fmt"
|
|
"go/ast"
|
|
"go/parser"
|
|
"go/token"
|
|
"io/fs"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/kami/maven/internal/store"
|
|
)
|
|
|
|
// mapErr turns a store sentinel into its wire twin so a module can errors.Is
|
|
// without importing internal/store. The design is right and the failure mode is
|
|
// quiet: add a sentinel to store, forget the switch, and the client gets an
|
|
// untyped error that no caller can branch on. These two tests are the alarm.
|
|
|
|
// mapErrPairs — every store sentinel that has a wire twin, and the twin.
|
|
var mapErrPairs = []struct {
|
|
name string // the store identifier, for the coverage test below
|
|
from error
|
|
wants error
|
|
}{
|
|
{"ErrNoFact", store.ErrNoFact, ErrNoFact},
|
|
{"ErrConfidence", store.ErrConfidence, ErrConfidence},
|
|
{"ErrVoidsMissing", store.ErrVoidsMissing, ErrVoidsMissing},
|
|
{"ErrNudgeNotFound", store.ErrNudgeNotFound, ErrNudgeNotFound},
|
|
{"ErrNudgeOutcome", store.ErrNudgeOutcome, ErrNudgeOutcome},
|
|
{"ErrReminderNotFound", store.ErrReminderNotFound, ErrReminderNotFound},
|
|
{"ErrReminderState", store.ErrReminderState, ErrReminderState},
|
|
{"ErrToolNotFound", store.ErrToolNotFound, ErrToolNotFound},
|
|
{"ErrNoSuchTrace", store.ErrNoSuchTrace, ErrNoSuchTrace},
|
|
{"ErrTaskNoDoneWhen", store.ErrTaskNoDoneWhen, ErrTaskNoDoneWhen},
|
|
{"ErrTaskDuplicate", store.ErrTaskDuplicate, ErrTaskDuplicate},
|
|
{"ErrTaskResolved", store.ErrTaskResolved, ErrTaskResolved},
|
|
}
|
|
|
|
// unmappedStoreErrors — store sentinels that deliberately have no wire twin,
|
|
// each with the reason it stays store-side. A new sentinel is in neither list
|
|
// and fails TestMapErrCoversEveryStoreSentinel, which is the point: whether a
|
|
// module can branch on an error is a decision, not a default.
|
|
var unmappedStoreErrors = map[string]string{
|
|
"ErrKeyLen": "unlock path — the key never crosses CoreAPI",
|
|
"ErrDecrypt": "unlock path — the key never crosses CoreAPI",
|
|
|
|
"ErrToolCmd": "write-side validation of an allowlist mutation; the caller is the owner at a step-up, not a module branching on the verdict",
|
|
|
|
"ErrProposedRoutineNotFound": "no module branches on a routine id that vanished; accept and dismiss are owner clicks",
|
|
"ErrProposedRoutineExists": "the propose path already reports 'nothing new' through its bool return",
|
|
"ErrRoutineStatus": "an unknown status is a caller bug, not a state a module recovers from",
|
|
|
|
"ErrTaskNotFound": "the task surfaces re-list rather than branch",
|
|
"ErrTaskEmpty": "input validation — the surface refuses empty text before it gets here",
|
|
"ErrTaskStatus": "an illegal status move is a caller bug; the surface offers only legal ones",
|
|
|
|
// The shopping and packing lists follow the task verdicts exactly, and for
|
|
// the same reasons. No module branches on any of the three today.
|
|
"ErrListItemNotFound": "the surface re-lists rather than branch on an item id that vanished",
|
|
"ErrListItemEmpty": "input validation — an empty item is refused before it reaches the store",
|
|
"ErrListItemStatus": "an unknown status is a caller bug, not a state a module recovers from",
|
|
}
|
|
|
|
// The mapping itself, through a wrap, because every real caller wraps.
|
|
func TestMapErrMapsEveryPair(t *testing.T) {
|
|
for _, p := range mapErrPairs {
|
|
got := mapErr(fmt.Errorf("storeapi: %w", p.from))
|
|
if got != p.wants {
|
|
t.Errorf("mapErr(store.%s) = %v, want %v", p.name, got, p.wants)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Anything mapErr does not recognise passes through untouched. A module that
|
|
// cannot branch on an error must still see the original text.
|
|
func TestMapErrPassesUnknownThrough(t *testing.T) {
|
|
if mapErr(nil) != nil {
|
|
t.Error("mapErr(nil) must stay nil")
|
|
}
|
|
own := fmt.Errorf("socket closed")
|
|
if got := mapErr(own); got != own {
|
|
t.Errorf("mapErr(%v) = %v, want the same error back", own, got)
|
|
}
|
|
}
|
|
|
|
// The parity half: every exported sentinel in internal/store is either mapped
|
|
// or listed with a reason. Read off the source, so a sentinel added in a file
|
|
// this package never touches still trips it.
|
|
func TestMapErrCoversEveryStoreSentinel(t *testing.T) {
|
|
mapped := map[string]bool{}
|
|
for _, p := range mapErrPairs {
|
|
mapped[p.name] = true
|
|
}
|
|
|
|
for _, name := range storeSentinelNames(t) {
|
|
if mapped[name] || unmappedStoreErrors[name] != "" {
|
|
continue
|
|
}
|
|
t.Errorf("store.%s is a new sentinel with no verdict: add it to mapErr and mapErrPairs, "+
|
|
"or to unmappedStoreErrors with the reason a module cannot branch on it", name)
|
|
}
|
|
}
|
|
|
|
// storeSentinelNames reads internal/store for exported package-level error
|
|
// values: `var ErrX = errors.New(...)`, inside a block or on its own.
|
|
func storeSentinelNames(t *testing.T) []string {
|
|
t.Helper()
|
|
fset := token.NewFileSet()
|
|
pkgs, err := parser.ParseDir(fset, "../store", func(fi fs.FileInfo) bool {
|
|
return !strings.HasSuffix(fi.Name(), "_test.go")
|
|
}, 0)
|
|
if err != nil {
|
|
t.Fatalf("parse internal/store: %v", err)
|
|
}
|
|
var out []string
|
|
for _, pkg := range pkgs {
|
|
for _, f := range pkg.Files {
|
|
for _, d := range f.Decls {
|
|
gd, ok := d.(*ast.GenDecl)
|
|
if !ok || gd.Tok != token.VAR {
|
|
continue
|
|
}
|
|
for _, spec := range gd.Specs {
|
|
vs, ok := spec.(*ast.ValueSpec)
|
|
if !ok {
|
|
continue
|
|
}
|
|
for i, n := range vs.Names {
|
|
if !strings.HasPrefix(n.Name, "Err") || !n.IsExported() {
|
|
continue
|
|
}
|
|
if i < len(vs.Values) && isErrorsNew(vs.Values[i]) {
|
|
out = append(out, n.Name)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if len(out) < len(mapErrPairs) {
|
|
t.Fatalf("found %d sentinels in internal/store, fewer than the %d already mapped — the scan is broken, not the store", len(out), len(mapErrPairs))
|
|
}
|
|
return out
|
|
}
|
|
|
|
func isErrorsNew(e ast.Expr) bool {
|
|
call, ok := e.(*ast.CallExpr)
|
|
if !ok {
|
|
return false
|
|
}
|
|
sel, ok := call.Fun.(*ast.SelectorExpr)
|
|
if !ok || sel.Sel.Name != "New" {
|
|
return false
|
|
}
|
|
id, ok := sel.X.(*ast.Ident)
|
|
return ok && id.Name == "errors"
|
|
}
|