a6b17ada8b
SetTaskStatus was the only mutation on a task row, so a typo in a dictated task was permanent and a deadline could not move. EditTask rewrites the three fields capture set — text, due date and weight — and nothing else. Status stays the one-way ladder SetTaskStatus owns. Two things the task asked to settle. A text edit re-normalises the dedupe key and can collide with another live row. That is ErrTaskDuplicate, a refusal rather than a merge: two live rows carry two provenances, two capture times and possibly two external identities, and merging picks a winner for all three with nobody asked. The surface names the row that holds the text. A resolved task is refused outright (ErrTaskResolved). Its text is the record of what was finished, and rewriting it rewrites history. due nil clears the date, because clearing has to be sayable — an absent date and "remove the date" cannot be one argument.
158 lines
5.7 KiB
Go
158 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},
|
|
{"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"
|
|
}
|