Compare commits
28 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4f6dec0cf2 | |||
| 23ad5c0247 | |||
| 0445693a16 | |||
| c7d22858ba | |||
| 12ecc30c57 | |||
| 190cf0c794 | |||
| 6b3749f5a2 | |||
| d156be3442 | |||
| f29bc107d4 | |||
| 10975eff07 | |||
| cc48309c7c | |||
| 4534101d10 | |||
| 5b8707e21e | |||
| 76d123edf3 | |||
| 62675e8fe4 | |||
| 46acf3cba0 | |||
| 373229ab7a | |||
| 2dbf476c45 | |||
| 13cb1903a9 | |||
| 8d20efcfbb | |||
| c661f7bd1a | |||
| e5158d8828 | |||
| 07f7550931 | |||
| 8c1e457150 | |||
| 6923a983aa | |||
| 1d10c9535c | |||
| 3b3660da9a | |||
| dd699b706f |
@@ -235,7 +235,13 @@ func (h *reactiveHandler) queryFactByKey(ctx context.Context, t *queryTurn) (str
|
||||
//
|
||||
// Read-only by construction — the plan is assembled and rendered core-side and
|
||||
// nothing here schedules or announces. "что дальше?" asks for the rest of the
|
||||
// day, so that phrasing trims what has already passed.
|
||||
// day, so that phrasing trims what has already passed and reads only the next
|
||||
// morning.NextSpoken entries. Trimming alone was not enough: asked early it cuts
|
||||
// nothing, and she read 43 entries aloud in one sentence (V-618).
|
||||
//
|
||||
// "что у меня сегодня?" is a different question and is not narrowed here — it
|
||||
// carries no plan word, so IsDayPlanQuery declines it and the calendar source
|
||||
// answers the whole day.
|
||||
//
|
||||
// What surface this belongs on is still open, tracked as Vikunja #431 ("Board
|
||||
// surface: Maven holds the work board, runs the intake form, never argues").
|
||||
@@ -254,16 +260,23 @@ func (h *reactiveHandler) queryDayPlan(ctx context.Context, t *queryTurn) (strin
|
||||
}
|
||||
// Rebuild the pure plan so the rest-of-day rendering is the same code that
|
||||
// rendered the whole day — one formatter, one persona.
|
||||
p := morning.Plan{Date: plan.Date}
|
||||
//
|
||||
// The instants are put back in the asking clock's zone on the way in. They
|
||||
// arrive carrying whatever zone the core read them in — a calendar fact's Ts
|
||||
// and a reminder's FireTs are UTC out of the store — and FormatRU reads the
|
||||
// hours in the plan's own frame, so setting that frame here is what makes
|
||||
// the recital name his clock rather than the store's (V-614).
|
||||
zone := h.now().Location()
|
||||
p := morning.Plan{Date: plan.Date.In(zone)}
|
||||
for _, it := range plan.Items {
|
||||
p.Items = append(p.Items, morning.PlanEntry{
|
||||
At: it.At,
|
||||
At: it.At.In(zone),
|
||||
Text: it.Text,
|
||||
Kind: morning.PlanKind(it.Kind),
|
||||
Uncertain: it.Uncertain,
|
||||
})
|
||||
}
|
||||
return p.After(h.now()).FormatRU(), true
|
||||
return p.Next(h.now(), morning.NextSpoken).FormatRU(), true
|
||||
}
|
||||
|
||||
// habitFactWindow — how many recent SELF facts the behaviour profile is counted
|
||||
|
||||
@@ -19,9 +19,10 @@ func (h *reactiveHandler) actionReminder(ctx context.Context, dec router.Decisio
|
||||
// time wasn't parsed. Run the parser as a fallback.
|
||||
if dec.Stage == 0 && h.timeParser != nil {
|
||||
t, ok, err := h.timeParser.Parse(ctx, dec.Utterance, h.now())
|
||||
// Same gate as the extractor (V-577, V-579): a request that named
|
||||
// no hour gets asked about, never completed from the clock.
|
||||
if err == nil && ok && router.NamesAnHour(dec.Utterance) {
|
||||
// Same gate as the extractor (V-577, V-579, V-610): a request whose
|
||||
// hour was not spoken, or was spoken and not read, gets asked about
|
||||
// and is never completed from the clock.
|
||||
if err == nil && ok && router.ResolvedTheHour(dec.Utterance, t) {
|
||||
dec.Slots.Time = t
|
||||
dec.Slots.HasTime = true
|
||||
}
|
||||
|
||||
+13
-1
@@ -24,6 +24,14 @@ type pendingHexisExec struct {
|
||||
entityID string
|
||||
displayName string
|
||||
expiry time.Time
|
||||
|
||||
// correlationID — the id the proposing turn minted for this action. A
|
||||
// confirm arrives on a later turn with a context of its own, so without
|
||||
// carrying it here the execution recorded a fresh id and no causation at
|
||||
// all, and the resolve, the discovery and the thing they authorised sat in
|
||||
// the trace as unrelated calls. The contract mints one id per action, and
|
||||
// the action began when she asked.
|
||||
correlationID string
|
||||
}
|
||||
|
||||
// pendingRoutineConfirm — a proposed routine awaiting a spoken y/n to become
|
||||
@@ -144,7 +152,11 @@ func (h *reactiveHandler) confirmResolvers(ctx context.Context) []confirmResolve
|
||||
return hx != nil && !h.now().After(hx.expiry)
|
||||
},
|
||||
yes: func() string {
|
||||
return h.execHexis(ctx, hx.capabilityID, hx.capName, hx.entityID, hx.displayName)
|
||||
execCtx := ctx
|
||||
if hx.correlationID != "" {
|
||||
execCtx = withCorrelationID(execCtx, hx.correlationID)
|
||||
}
|
||||
return h.execHexis(execCtx, hx.capabilityID, hx.capName, hx.entityID, hx.displayName)
|
||||
},
|
||||
no: func() string { return phraser.C(phraser.ConfirmCancelled, nil) },
|
||||
},
|
||||
|
||||
@@ -4,12 +4,14 @@ import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/calendar"
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
"github.com/kami/maven/internal/morning"
|
||||
"github.com/kami/maven/internal/phraser"
|
||||
"github.com/kami/maven/internal/router"
|
||||
"github.com/kami/maven/internal/store"
|
||||
@@ -111,6 +113,88 @@ func TestQueryDayPlanRestOfDayWhenNothingIsLeft(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// "что дальше?" rebuilds the plan off the wire and renders it here, and the
|
||||
// instants on it carry the zone the core read them in — a calendar fact's Ts
|
||||
// and a reminder's FireTs are UTC out of the store. Read raw, the recital named
|
||||
// the store's clock instead of his (V-614). The asking clock is three hours off
|
||||
// whatever this machine runs in, so the assertion holds under TZ=UTC too.
|
||||
func TestQueryDayPlanRestOfDayReadsHisClock(t *testing.T) {
|
||||
_, off := time.Now().Zone()
|
||||
away := time.FixedZone("away", off+3*60*60)
|
||||
stored := time.Date(2026, 8, 3, 8, 0, 0, 0, time.UTC)
|
||||
|
||||
h := &reactiveHandler{
|
||||
api: &planAPI{plan: ipc.DayPlan{
|
||||
Date: time.Date(2026, 8, 3, 0, 0, 0, 0, time.UTC),
|
||||
Items: []ipc.DayPlanItem{{At: stored, Text: "позвонить маме", Kind: "reminder"}},
|
||||
}},
|
||||
now: func() time.Time { return time.Date(2026, 8, 3, 9, 0, 0, 0, away) },
|
||||
}
|
||||
reply, ok := h.queryDayPlan(context.Background(), &queryTurn{
|
||||
dec: router.Decision{Intent: router.IntentQuery, Utterance: "что дальше?"},
|
||||
})
|
||||
if !ok {
|
||||
t.Fatal("expected the plan source to claim it")
|
||||
}
|
||||
if want := stored.In(away).Format("15:04"); !strings.Contains(reply, want) {
|
||||
t.Errorf("the reminder is not read in his clock (%s): %q", want, reply)
|
||||
}
|
||||
if bad := stored.Format("15:04"); strings.Contains(reply, bad) {
|
||||
t.Errorf("the reminder is read in the store's zone (%s): %q", bad, reply)
|
||||
}
|
||||
}
|
||||
|
||||
// The defect V-618 fixes, at the handler: asked at 04:45 the trim removes
|
||||
// nothing, because the whole day is still ahead. She read 43 entries aloud as
|
||||
// one sentence. The zone is three hours off UTC so the test also fails under
|
||||
// TZ=UTC if the rendering ever slips zones.
|
||||
func TestQueryDayPlanCapsWhatItReadsAloud(t *testing.T) {
|
||||
zone := time.FixedZone("MSK", 3*60*60)
|
||||
mid := time.Date(2026, 8, 3, 0, 0, 0, 0, zone)
|
||||
plan := ipc.DayPlan{Date: mid, Spoken: "план на 03.08.2026: …"}
|
||||
for i := 0; i < 43; i++ {
|
||||
plan.Items = append(plan.Items, ipc.DayPlanItem{
|
||||
At: mid.Add(time.Duration(345+i*20) * time.Minute), // 05:45 onward
|
||||
Text: fmt.Sprintf("пункт %d", i),
|
||||
Kind: "event",
|
||||
})
|
||||
}
|
||||
h := &reactiveHandler{api: &planAPI{plan: plan}, now: func() time.Time {
|
||||
return time.Date(2026, 8, 3, 4, 45, 0, 0, zone)
|
||||
}}
|
||||
reply, ok := h.queryDayPlan(context.Background(), &queryTurn{
|
||||
dec: router.Decision{Intent: router.IntentQuery, Utterance: "что дальше?"},
|
||||
})
|
||||
if !ok {
|
||||
t.Fatal("expected the plan source to claim it")
|
||||
}
|
||||
if n := strings.Count(reply, "пункт "); n != morning.NextSpoken {
|
||||
t.Errorf("read %d entries aloud, want %d: %q", n, morning.NextSpoken, reply)
|
||||
}
|
||||
if !strings.HasPrefix(reply, "дальше: 05:45 — пункт 0;") {
|
||||
t.Errorf("the next thing is not first: %q", reply)
|
||||
}
|
||||
// The rest is counted, not silently dropped.
|
||||
if !strings.Contains(reply, "и ещё 40 дел до конца дня.") {
|
||||
t.Errorf("the sentence hides that the day goes on: %q", reply)
|
||||
}
|
||||
}
|
||||
|
||||
// "что у меня сегодня?" is the whole day and is not narrowed. It carries no
|
||||
// plan word, so the plan source declines it and the calendar listing answers —
|
||||
// asserted here beside the cap so the two questions cannot drift together.
|
||||
func TestWholeDayQuestionIsNotTheRestOfTheDay(t *testing.T) {
|
||||
if router.IsDayPlanQuery("что у меня сегодня?") {
|
||||
t.Error("the plan source claims the whole-day question")
|
||||
}
|
||||
if !router.IsDayPlanQuery("что дальше?") {
|
||||
t.Error("the plan source stopped claiming the rest-of-day question")
|
||||
}
|
||||
if router.IsRestOfDayQuery("какие планы на сегодня?") {
|
||||
t.Error("the whole-day plan question got narrowed to the rest of the day")
|
||||
}
|
||||
}
|
||||
|
||||
// A question that is not about the plan must fall through, or the plan buries
|
||||
// the calendar listing and the weather behind it.
|
||||
func TestQueryDayPlanPassesOnEverythingElse(t *testing.T) {
|
||||
|
||||
@@ -139,9 +139,9 @@ func (h *reactiveHandler) handlePraxisAct(ctx context.Context, dec router.Decisi
|
||||
// praxisItemAction is the shared shape of the item-lifecycle capabilities: take
|
||||
// an item id from the value slot, call one Praxis endpoint, trace the result.
|
||||
type praxisItemAction struct {
|
||||
verbs []string
|
||||
ask string // reply when no item id was given
|
||||
op string // trace + log name of the operation
|
||||
verbs []string
|
||||
ask string // reply when no item id was given
|
||||
op string // trace + log name of the operation
|
||||
// failure is the first half of the reply when the Praxis call errors: which
|
||||
// operation did not happen. ecosystemGap supplies the second half, which
|
||||
// names Praxis and splits a refused token from an outage — those two used to
|
||||
@@ -346,14 +346,24 @@ func (entityAttentionCapability) handle(ctx context.Context, h *reactiveHandler,
|
||||
})
|
||||
|
||||
var parts []string
|
||||
var spoken []string
|
||||
for _, item := range items {
|
||||
title, _ := item["title"].(string)
|
||||
if title == "" {
|
||||
continue
|
||||
}
|
||||
parts = append(parts, title)
|
||||
surfaceSpoken(ctx, px, item)
|
||||
if id := surfaceSpoken(ctx, px, item); id != "" {
|
||||
spoken = append(spoken, id)
|
||||
}
|
||||
}
|
||||
// The scoped digest is a list she read out, so it replaces the positional
|
||||
// memory exactly as the unscoped one does. It used to surface these items
|
||||
// and remember none of them, which left the previous digest live: "отметь
|
||||
// второй как сделанное" then indexed into a list he had not just heard and
|
||||
// transitioned somebody else's item (docs/ecosystem.md — a wrong guess here
|
||||
// transitions the wrong item).
|
||||
h.rememberSurfaced(spoken)
|
||||
if known := h.localFactsForEntity(ctx, entityID); known != "" {
|
||||
parts = append(parts, known)
|
||||
}
|
||||
@@ -768,6 +778,9 @@ func (h *reactiveHandler) handleHexisAct(ctx context.Context, dec router.Decisio
|
||||
entityID: entityID,
|
||||
displayName: displayName,
|
||||
expiry: h.now().Add(confirmTTL),
|
||||
// This action's id, so the execution the confirm authorises is
|
||||
// joined to the resolve and the discovery that proposed it.
|
||||
correlationID: correlationIDFromCtx(ctx),
|
||||
}
|
||||
h.mu.Unlock()
|
||||
h.recordEcosystemTrace(ctx, "hexis", "confirmation", tracePending, started,
|
||||
|
||||
@@ -115,3 +115,83 @@ func TestFakeNexus_FaultInjectionThenRecovery(t *testing.T) {
|
||||
t.Fatalf("expected success once nexus recovers, got %q", reply)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPraxisEntityAttention_RemembersWhatItReadOut: the scoped digest is a list
|
||||
// she read out, so a positional follow-up must land on one of ITS items. It
|
||||
// surfaced them and remembered none, which left the previous digest live and
|
||||
// sent "отметь второй" at somebody else's item.
|
||||
func TestPraxisEntityAttention_RemembersWhatItReadOut(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
nexus := newFakeNexus(t, fixtureNexusResolved("ent_muzick", "Muzick indexer", "service"))
|
||||
scoped := fixturePraxisAttentionScoped("ent_muzick",
|
||||
map[string]any{"id": "item_scoped_1", "title": "indexer wedged"})
|
||||
praxis := newFakePraxis(t, scoped)
|
||||
h := ecoHandler(t, nexus, praxis, nil)
|
||||
|
||||
// A digest from an earlier turn, still the positional memory.
|
||||
h.rememberSurfaced([]string{"item_stale"})
|
||||
|
||||
reply := h.handlePraxisAct(ctx, router.Decision{
|
||||
Intent: router.IntentAct,
|
||||
Slots: router.Slots{Fn: "entity_attention", HasFn: true, Value: "muzick indexer"},
|
||||
})
|
||||
if !strings.Contains(reply, "indexer wedged") {
|
||||
t.Fatalf("expected the scoped item to be read out, got %q", reply)
|
||||
}
|
||||
|
||||
h.mu.Lock()
|
||||
surfaced := append([]string(nil), h.surfacedItems...)
|
||||
h.mu.Unlock()
|
||||
if len(surfaced) != 1 || surfaced[0] != "item_scoped_1" {
|
||||
t.Fatalf("scoped digest must replace the positional memory, got %v", surfaced)
|
||||
}
|
||||
|
||||
// The follow-up resolves against what he just heard, not the stale list.
|
||||
if reply := h.handlePraxisAct(ctx, praxisItemDec("resolve_item", "last")); reply == "" {
|
||||
t.Fatal("positional follow-up should have been claimed by praxis")
|
||||
}
|
||||
var body string
|
||||
for _, r := range praxis.Requests() {
|
||||
if r.Method == "POST" && r.Path == "/api/v1/tools/resolve" {
|
||||
body = string(r.Body)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(body, "item_scoped_1") {
|
||||
t.Fatalf("resolve must transition the item she read out, posted %q", body)
|
||||
}
|
||||
if strings.Contains(body, "item_stale") {
|
||||
t.Fatal("resolve transitioned an item from a previous digest")
|
||||
}
|
||||
}
|
||||
|
||||
// TestHexisConfirm_KeepsOneCorrelationIDPerAction: the confirm arrives on a
|
||||
// later turn with a context of its own. The contract mints one id per action,
|
||||
// so the execution it authorises must still be joinable to the resolve and the
|
||||
// discovery that proposed it — it recorded a fresh id and no causation at all.
|
||||
func TestHexisConfirm_KeepsOneCorrelationIDPerAction(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
nexus := newFakeNexus(t, fixtureNexusResolved("ent_muzick", "Muzick indexer", "service"))
|
||||
caps := fixtureHexisCapabilities(map[string]any{"id": "cap_restart", "name": "restart", "read_only": false})
|
||||
hexis := newFakeHexis(t, caps, fixtureHexisExecuted("exec_1", "succeeded"))
|
||||
h := ecoHandler(t, nexus, nil, hexis)
|
||||
|
||||
if reply := h.handleHexisAct(ctx, actDec("restart")); !strings.Contains(reply, "да") {
|
||||
t.Fatalf("mutating capability must ask for confirmation, got %q", reply)
|
||||
}
|
||||
resolve := findTrace(t, h, "nexus", "resolve")
|
||||
if resolve == nil || resolve.CorrelationID == "" {
|
||||
t.Fatalf("expected a nexus resolve trace carrying a correlation id, got %+v", resolve)
|
||||
}
|
||||
|
||||
if _, handled := h.resolveConfirm(ctx, "да"); !handled {
|
||||
t.Fatal("confirm should have been claimed")
|
||||
}
|
||||
exec := findTrace(t, h, "hexis", "execute")
|
||||
if exec == nil {
|
||||
t.Fatal("expected a hexis execute trace")
|
||||
}
|
||||
if exec.CausationID != resolve.CorrelationID {
|
||||
t.Fatalf("confirmed execution must cite the action that proposed it: causation %q, action %q",
|
||||
exec.CausationID, resolve.CorrelationID)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -154,6 +154,11 @@ func hasDurationWords(u string) bool {
|
||||
// Used by the query handler when answering "когда я это сделал?"-style questions.
|
||||
func formatTime(t time.Time) string {
|
||||
now := time.Now()
|
||||
// The argument is a fact's Ts, which the store hands back as UTC. Only the
|
||||
// last branch names a wall clock, and it named the store's until V-614: an
|
||||
// answer to "когда я это сделал?" read hours off, in the same sentence
|
||||
// shape the plan reads a day in.
|
||||
t = t.Local()
|
||||
if t.After(now.Add(-2*time.Minute)) && t.Before(now.Add(2*time.Minute)) {
|
||||
return "только что"
|
||||
}
|
||||
|
||||
@@ -1,6 +1,28 @@
|
||||
package main
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// "когда я это сделал?" answers off a fact's Ts, which the store hands back as
|
||||
// UTC, and the branch that names a wall clock printed it in whatever zone it
|
||||
// arrived in (V-614). The instant here is built three hours off this machine's
|
||||
// zone, so the assertion holds under TZ=UTC as well.
|
||||
func TestFormatTimeReadsHisClock(t *testing.T) {
|
||||
_, off := time.Now().Zone()
|
||||
away := time.FixedZone("away", off+3*60*60)
|
||||
stored := time.Now().Add(-72 * time.Hour).In(away)
|
||||
|
||||
got := formatTime(stored)
|
||||
if want := stored.Local().Format("15:04"); !strings.Contains(got, want) {
|
||||
t.Errorf("formatTime = %q, want the hour on his clock (%s)", got, want)
|
||||
}
|
||||
if bad := stored.Format("15:04"); strings.Contains(got, bad) {
|
||||
t.Errorf("formatTime = %q reads the zone the fact arrived in (%s)", got, bad)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMentionsUnknownDayReadsWordsNotStems — the defect V-581 found. The
|
||||
// weekday half of this guard was a list of stems matched with strings.Contains,
|
||||
|
||||
+34
-1
@@ -77,6 +77,21 @@ func run(args []string) error {
|
||||
if *netdataURL == "" && *kumaURL == "" && *wgIface == "" && *zenTokenFile == "" {
|
||||
return fmt.Errorf("nothing to poll: set -netdata, -kuma, -wg and/or -zenmoney-token-file")
|
||||
}
|
||||
// A bad duration or an empty -wg-cmd used to get past start and kill the
|
||||
// poller on the first tick — time.NewTicker panics on a non-positive
|
||||
// interval, and pollWg indexed field 0 of an empty command. A zero -timeout
|
||||
// is worse than a crash: http.Client reads it as "no deadline", so one
|
||||
// wedged source stalls every other source behind it forever. Refuse all
|
||||
// three here, where the operator sees the message.
|
||||
if *interval <= 0 {
|
||||
return fmt.Errorf("-interval must be positive, got %s", *interval)
|
||||
}
|
||||
if *timeout <= 0 {
|
||||
return fmt.Errorf("-timeout must be positive, got %s", *timeout)
|
||||
}
|
||||
if *wgIface != "" && strings.TrimSpace(*wgCmd) == "" {
|
||||
return fmt.Errorf("-wg-cmd is empty but -wg is set")
|
||||
}
|
||||
|
||||
zen, err := newZenClient(*zenTokenFile, *zenURL, *timeout)
|
||||
if err != nil {
|
||||
@@ -283,9 +298,19 @@ const (
|
||||
// `wg show` needs CAP_NET_ADMIN; run mavpoll with the cap or set -wg-cmd "sudo wg".
|
||||
func (p *poller) pollWg(ctx context.Context) error {
|
||||
fields := strings.Fields(p.wgCmd)
|
||||
if len(fields) == 0 {
|
||||
return fmt.Errorf("wg command is empty")
|
||||
}
|
||||
args := append(fields[1:], "show", p.wgIface, "latest-handshakes")
|
||||
out, err := exec.CommandContext(ctx, fields[0], args...).Output()
|
||||
if err != nil {
|
||||
// wg says why it refused on stderr — usually a missing CAP_NET_ADMIN or
|
||||
// an interface that does not exist. Output() drops that, leaving a log
|
||||
// line that reads "exit status 1" and diagnoses nothing.
|
||||
var ee *exec.ExitError
|
||||
if errors.As(err, &ee) && len(ee.Stderr) > 0 {
|
||||
return fmt.Errorf("run %s: %w: %s", p.wgCmd, err, strings.TrimSpace(string(ee.Stderr)))
|
||||
}
|
||||
return fmt.Errorf("run %s: %w", p.wgCmd, err)
|
||||
}
|
||||
maxTs := parseMaxHandshake(string(out))
|
||||
@@ -552,6 +577,11 @@ func isNoFact(err error) bool {
|
||||
|
||||
// maxBodyBytes caps what a source can make the poller hold. Kuma's whole
|
||||
// metrics page is a few hundred kilobytes, so 4 MiB is slack, not a budget.
|
||||
//
|
||||
// Hitting the cap is an error, not a shorter body. A truncated kuma page parses
|
||||
// cleanly right up to the cut, and every monitor past it reads as deleted — the
|
||||
// poller would write "unknown" over live services and the down-rule would go
|
||||
// quiet. Reading one byte past the cap is how we tell full from truncated.
|
||||
const maxBodyBytes = 4 << 20
|
||||
|
||||
func (p *poller) get(ctx context.Context, url, basicUser string) ([]byte, error) {
|
||||
@@ -567,12 +597,15 @@ func (p *poller) get(ctx context.Context, url, basicUser string) ([]byte, error)
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, maxBodyBytes))
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, maxBodyBytes+1))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("GET %s: %s", url, resp.Status)
|
||||
}
|
||||
if len(body) > maxBodyBytes {
|
||||
return nil, fmt.Errorf("GET %s: body over %d bytes", url, maxBodyBytes)
|
||||
}
|
||||
return body, nil
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package main
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
@@ -212,3 +213,59 @@ func TestRunRequiresSomethingToPoll(t *testing.T) {
|
||||
t.Errorf("err = %v, want a 'nothing to poll' refusal", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A flag value that would kill the poller later is refused at start, before it
|
||||
// dials core: a non-positive interval panics time.NewTicker on the first tick, a
|
||||
// zero timeout means http.Client waits forever, and an empty wg command used to
|
||||
// index field 0 of an empty slice.
|
||||
func TestRunRefusesFlagsThatCrashLater(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
args []string
|
||||
want string
|
||||
}{
|
||||
{"zero interval", []string{"-interval", "0"}, "-interval must be positive"},
|
||||
{"negative interval", []string{"-interval", "-5s"}, "-interval must be positive"},
|
||||
{"zero timeout", []string{"-timeout", "0"}, "-timeout must be positive"},
|
||||
{"empty wg command", []string{"-wg", "wg0", "-wg-cmd", " "}, "-wg-cmd is empty"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
args := append([]string{"-socket", "/tmp/nope.sock"}, c.args...)
|
||||
err := run(args)
|
||||
if err == nil || !strings.Contains(err.Error(), c.want) {
|
||||
t.Errorf("err = %v, want %q", err, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// pollWg refuses an empty command rather than panicking on fields[0].
|
||||
func TestPollWgEmptyCommand(t *testing.T) {
|
||||
p := &poller{core: &factCore{}, wgIface: "wg0", wgCmd: ""}
|
||||
if err := p.pollWg(context.Background()); err == nil {
|
||||
t.Error("want an error, got a poll that ran something")
|
||||
}
|
||||
}
|
||||
|
||||
// A body at the cap is a truncated body, and a truncated kuma page reads as
|
||||
// "every monitor past the cut was deleted". Refuse it instead of parsing it.
|
||||
func TestGetRefusesTruncatedBody(t *testing.T) {
|
||||
big := strings.Repeat("x", maxBodyBytes+64)
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
io.WriteString(w, big)
|
||||
}))
|
||||
defer srv.Close()
|
||||
p := &poller{http: srv.Client()}
|
||||
if _, err := p.get(context.Background(), srv.URL, ""); err == nil {
|
||||
t.Error("want an over-size refusal, got a silently truncated body")
|
||||
}
|
||||
small := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
io.WriteString(w, "ok")
|
||||
}))
|
||||
defer small.Close()
|
||||
body, err := p.get(context.Background(), small.URL, "")
|
||||
if err != nil || string(body) != "ok" {
|
||||
t.Errorf("get = %q, %v; want the whole small body", body, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,8 +14,13 @@ memory only, so a restart empties this.</div>
|
||||
<div class=scroll><table class=mono>
|
||||
<tr><th>noticed<th>happened<th>source<th>kind<th>pri<th>what<th>detail</tr>
|
||||
{{range .Events}}<tr>
|
||||
<td>{{.NoticedAt.Format "02.01 15:04:05"}}</td>
|
||||
<td class=gray>{{.OccurredAt.Format "02.01 15:04:05"}}</td>
|
||||
<!-- Both columns in his clock (V-469 on /reminders, same rule here). NoticedAt
|
||||
is the bus's local instant, OccurredAt is whatever zone the source used —
|
||||
the store hands back UTC and internal/rss parses a pubDate to UTC — so
|
||||
rendering them raw put two zones side by side in the same row and made a
|
||||
feed item look hours older than it was. -->
|
||||
<td>{{.NoticedAt.Local.Format "02.01 15:04:05"}}</td>
|
||||
<td class=gray>{{.OccurredAt.Local.Format "02.01 15:04:05"}}</td>
|
||||
<td class=gray>{{.Source}}</td>
|
||||
<td class=gray>{{.Kind}}</td>
|
||||
<td class=gray>{{.Priority}}</td>
|
||||
|
||||
@@ -46,7 +46,8 @@ func TestEventsPageRendersTheJournal(t *testing.T) {
|
||||
t.Fatalf("status = %d, want 200", w.Code)
|
||||
}
|
||||
body := w.Body.String()
|
||||
for _, want := range []string{"rss:tech", "Вышло ядро 6.19", "ambient:notif", "10:00-11:00 планёрка", "01.08 10:00:00"} {
|
||||
occurred := time.Date(2026, 8, 1, 10, 0, 0, 0, time.UTC).Local().Format("02.01 15:04:05")
|
||||
for _, want := range []string{"rss:tech", "Вышло ядро 6.19", "ambient:notif", "10:00-11:00 планёрка", occurred} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("page does not mention %q", want)
|
||||
}
|
||||
@@ -89,6 +90,38 @@ func TestEventsPageWithoutCore(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// awayFromLocal returns a zone three hours off whatever this machine runs in,
|
||||
// so a test can tell "rendered in his clock" apart from "rendered in whatever
|
||||
// zone the value arrived in" without depending on TZ.
|
||||
func awayFromLocal() *time.Location {
|
||||
_, off := time.Now().Zone()
|
||||
return time.FixedZone("away", off+3*60*60)
|
||||
}
|
||||
|
||||
func TestEventsPageRendersBothTimesInLocalZone(t *testing.T) {
|
||||
// OccurredAt carries the source's zone — the store hands back UTC and
|
||||
// internal/rss parses a pubDate to UTC — while NoticedAt is the bus's local
|
||||
// instant. Rendered raw, the two columns of one row were in two zones and a
|
||||
// feed item read hours older than it was.
|
||||
away := awayFromLocal()
|
||||
occurred := time.Date(2026, 8, 1, 7, 15, 0, 0, time.UTC).In(away)
|
||||
noticed := occurred.Add(2 * time.Minute)
|
||||
core := &eventsCore{events: []ipc.IntakeEvent{{
|
||||
Source: "rss:tech", Kind: "note", Title: "Вышло ядро 6.19", Priority: "low",
|
||||
OccurredAt: occurred, NoticedAt: noticed,
|
||||
}}}
|
||||
body := getEvents(t, core).Body.String()
|
||||
const layout = "02.01 15:04:05"
|
||||
for _, ts := range []time.Time{occurred, noticed} {
|
||||
if !strings.Contains(body, ts.Local().Format(layout)) {
|
||||
t.Errorf("page does not render %s in his clock (%s)", ts, ts.Local().Format(layout))
|
||||
}
|
||||
if strings.Contains(body, ts.In(away).Format(layout)) {
|
||||
t.Errorf("page rendered %s in the source's zone", ts)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEventsPageEscapesIntakeText(t *testing.T) {
|
||||
// Titles come from outside — a feed headline, a notification. They are shown
|
||||
// on a page and must never be able to inject markup into it.
|
||||
|
||||
@@ -8,7 +8,10 @@
|
||||
<div class=scroll><table class=mono>
|
||||
<tr><th>at<th>kind<th>what</tr>
|
||||
{{range .Items}}<tr>
|
||||
<td>{{.At.Format "15:04"}}</td>
|
||||
<!-- In his clock. A plan item's At is a calendar fact's Ts or a reminder's
|
||||
FireTs, and the store hands both back as UTC, so the raw hour printed a
|
||||
reminder here at an hour /reminders did not agree with (V-469). -->
|
||||
<td>{{.At.Local.Format "15:04"}}</td>
|
||||
<td class=gray>{{.Kind}}</td>
|
||||
<td>{{if .Uncertain}}<span class=hint title="relayed notification, not a calendar read">похоже,</span> {{end}}{{.Text}}</td>
|
||||
</tr>{{end}}
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
)
|
||||
|
||||
// morningCore serves a canned checklist and day plan.
|
||||
type morningCore struct {
|
||||
ipc.UnimplementedCoreAPI
|
||||
status []ipc.MorningRoutineStatus
|
||||
plan ipc.DayPlan
|
||||
}
|
||||
|
||||
func (c *morningCore) MorningStatus(context.Context) ([]ipc.MorningRoutineStatus, error) {
|
||||
return c.status, nil
|
||||
}
|
||||
|
||||
func (c *morningCore) DayPlan(context.Context) (ipc.DayPlan, error) { return c.plan, nil }
|
||||
|
||||
func TestMorningRendersPlanTimesInLocalZone(t *testing.T) {
|
||||
// A plan item's At is a calendar fact's Ts or a reminder's FireTs, and the
|
||||
// store hands both back as UTC. Printed raw, /morning named an hour for a
|
||||
// reminder that /reminders — which does call Local — disagreed with.
|
||||
away := awayFromLocal()
|
||||
at := time.Date(2026, 8, 1, 9, 0, 0, 0, time.UTC).In(away)
|
||||
core := &morningCore{plan: ipc.DayPlan{
|
||||
Date: at,
|
||||
Items: []ipc.DayPlanItem{{At: at, Text: "выпить таблетки", Kind: "reminder"}},
|
||||
}}
|
||||
w := httptest.NewRecorder()
|
||||
handleMorning(w, httptest.NewRequest(http.MethodGet, "/morning", nil), core)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", w.Code)
|
||||
}
|
||||
body := w.Body.String()
|
||||
if !strings.Contains(body, at.Local().Format("15:04")) {
|
||||
t.Errorf("plan item not rendered in his clock (%s): %s", at.Local().Format("15:04"), body)
|
||||
}
|
||||
if strings.Contains(body, at.In(away).Format("15:04")) {
|
||||
t.Errorf("plan item rendered in the stored zone: %s", body)
|
||||
}
|
||||
}
|
||||
+9
-5
@@ -300,11 +300,15 @@ func promoteCandidate(ctx context.Context, core ipc.CoreAPI, r *http.Request, id
|
||||
if err := core.SetTaskFields(ctx, id, doneWhen, blockedOn); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if due != nil {
|
||||
wgt, err := formWeight(r)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
wgt, err := formWeight(r)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
// The importance select is posted whether or not a date is. This ran under
|
||||
// `if due != nil`, so confirming a candidate as "срочно" with no deadline
|
||||
// dropped the word on the floor — the row came back normal and nothing said
|
||||
// why. A promote with neither field set still writes nothing.
|
||||
if due != nil || wgt != 0 {
|
||||
if err := core.EditTask(ctx, id, text, due, wgt); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
@@ -32,6 +32,31 @@ type fakeTaskCore struct {
|
||||
statusErr error
|
||||
|
||||
promoted bool
|
||||
|
||||
// The promote path's two extra writes.
|
||||
fields []any
|
||||
edits []editCall
|
||||
editErr error
|
||||
fieldErr error
|
||||
}
|
||||
|
||||
// editCall records one EditTask, so a test can say what the form actually sent
|
||||
// down rather than only that the promotion succeeded.
|
||||
type editCall struct {
|
||||
ID int64
|
||||
Text string
|
||||
Due *time.Time
|
||||
Weight int
|
||||
}
|
||||
|
||||
func (f *fakeTaskCore) EditTask(_ context.Context, id int64, text string, due *time.Time, weight int) error {
|
||||
f.edits = append(f.edits, editCall{id, text, due, weight})
|
||||
return f.editErr
|
||||
}
|
||||
|
||||
func (f *fakeTaskCore) SetTaskFields(_ context.Context, id int64, doneWhen, blockedOn string) error {
|
||||
f.fields = append(f.fields, []any{id, doneWhen, blockedOn})
|
||||
return f.fieldErr
|
||||
}
|
||||
|
||||
func (f *fakeTaskCore) ListTasks(_ context.Context, status string) ([]ipc.Task, error) {
|
||||
@@ -220,6 +245,49 @@ func TestApplyTaskPostCarriesWeight(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Confirming a candidate posts the importance select whether or not a date is
|
||||
// set. The weight write hung off `if due != nil`, so "срочно" with no deadline
|
||||
// was read off the form and thrown away, and the row came back normal.
|
||||
func TestPromoteCandidateCarriesWeightWithoutADueDate(t *testing.T) {
|
||||
core := &fakeTaskCore{}
|
||||
form := url.Values{
|
||||
"action": {"promote"}, "id": {"4"}, "text": {"продлить страховку"},
|
||||
"done_when": {"полис на руках"}, "weight": {"3"},
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodPost, "/tasks", strings.NewReader(form.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
handleTasks(httptest.NewRecorder(), req, core)
|
||||
|
||||
if len(core.edits) != 1 {
|
||||
t.Fatalf("edits = %+v, want the weight written once", core.edits)
|
||||
}
|
||||
if core.edits[0].Weight != 3 || core.edits[0].ID != 4 {
|
||||
t.Errorf("edit = %+v, want id 4 at weight 3", core.edits[0])
|
||||
}
|
||||
if core.edits[0].Due != nil {
|
||||
t.Errorf("edit invented a due date: %v", core.edits[0].Due)
|
||||
}
|
||||
if core.statusVal != "open" {
|
||||
t.Errorf("status = %q, want the candidate promoted", core.statusVal)
|
||||
}
|
||||
}
|
||||
|
||||
// A promote with neither field set still writes nothing: the row is unchanged
|
||||
// apart from its status, and an EditTask here would be a no-op that can fail.
|
||||
func TestPromoteCandidateWithNoDateAndNoWeightDoesNotEdit(t *testing.T) {
|
||||
core := &fakeTaskCore{}
|
||||
form := url.Values{
|
||||
"action": {"promote"}, "id": {"4"}, "text": {"продлить страховку"},
|
||||
"done_when": {"полис на руках"}, "weight": {"0"},
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodPost, "/tasks", strings.NewReader(form.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
handleTasks(httptest.NewRecorder(), req, core)
|
||||
if len(core.edits) != 0 {
|
||||
t.Errorf("edits = %+v, want none", core.edits)
|
||||
}
|
||||
}
|
||||
|
||||
// Out of range clamps rather than 400s; a non-number is a real client error.
|
||||
func TestApplyTaskPostClampsWeight(t *testing.T) {
|
||||
core := &fakeTaskCore{created: true}
|
||||
|
||||
@@ -172,21 +172,49 @@ func (s *Sink) Send(ctx context.Context, d delivery.Sendable) error {
|
||||
return fmt.Errorf("telegramsink: sendMessage: %w", s.redact(err))
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
rb, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
||||
rb, _ := io.ReadAll(io.LimitReader(resp.Body, maxRespBytes))
|
||||
|
||||
// telegram returns 200 with ok=true on success; non-2xx with ok=false +
|
||||
// error_code + description on failure. parse the body either way so a 200
|
||||
// with ok=false (shouldn't happen, but the API reserves that) still surfaces.
|
||||
var tr telegramResp
|
||||
if jsonErr := json.Unmarshal(rb, &tr); jsonErr == nil && !tr.Ok {
|
||||
jsonErr := json.Unmarshal(rb, &tr)
|
||||
if jsonErr == nil && !tr.Ok {
|
||||
return fmt.Errorf("telegramsink: telegram returned error %d: %s", tr.ErrorCode, strings.TrimSpace(tr.Description))
|
||||
}
|
||||
if resp.StatusCode/100 != 2 {
|
||||
return fmt.Errorf("telegramsink: telegram returned %d: %s", resp.StatusCode, strings.TrimSpace(string(rb)))
|
||||
return fmt.Errorf("telegramsink: telegram returned %d: %s", resp.StatusCode, snippet(rb))
|
||||
}
|
||||
// A 2xx whose body is not the bot API's envelope did not come from the bot
|
||||
// API. The normal path here is the relay: this box reaches telegram through
|
||||
// an HTTP/SOCKS5 proxy, and a proxy that is up but cannot reach
|
||||
// api.telegram.org answers 200 with an HTML page of its own. Reading that as
|
||||
// a delivered message is the worst outcome the sink has — the dispatcher
|
||||
// writes a 'sent' outbox row, MarkSent restarts the repeat clock, and the
|
||||
// sev4 alarm that never arrived goes quiet for a whole interval. Only
|
||||
// ok=true is a send.
|
||||
if jsonErr != nil {
|
||||
return fmt.Errorf("telegramsink: telegram returned %d with a body that is not the bot API envelope (not a confirmed send): %s", resp.StatusCode, snippet(rb))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// maxRespBytes caps the response read — the body is wire-controlled and the
|
||||
// relay in front of it is not telegram. It is far above any sendMessage
|
||||
// envelope (a few hundred bytes; the result echoes one short away message),
|
||||
// because a truncated body no longer parses and now reads as a failed send.
|
||||
const maxRespBytes = 64 << 10
|
||||
|
||||
// snippet trims a response body down to something an error line can carry. A
|
||||
// relay's HTML page is measured in kilobytes and none of it belongs in the log.
|
||||
func snippet(rb []byte) string {
|
||||
s := strings.TrimSpace(string(rb))
|
||||
if len(s) > 200 {
|
||||
return s[:200] + "…"
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// sendMessageURL — the bot API path. the token is in the URL path
|
||||
// (https://api.telegram.org/bot<token>/sendMessage); telegram does not accept
|
||||
// it anywhere else. the URL is built per-send from the resolved base and never
|
||||
|
||||
@@ -291,6 +291,66 @@ func TestSendReturnsErrorOnTelegramError(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// A relay that is up but cannot reach api.telegram.org answers 200 with a page
|
||||
// of its own. That is not a delivered message, and calling it one silences a
|
||||
// sev4 alarm for a full repeat interval.
|
||||
func TestSendRefusesA200ThatIsNotTheBotAPIEnvelope(t *testing.T) {
|
||||
rs := newRecordingServer(t, http.StatusOK, `<html><body>proxy: upstream unreachable</body></html>`)
|
||||
srv := httptest.NewServer(rs.handler())
|
||||
defer srv.Close()
|
||||
|
||||
sink, _ := New(sinkCfg(srv.URL))
|
||||
err := sink.Send(context.Background(), nudgeSendable(loop.Sev4, "down"))
|
||||
if err == nil {
|
||||
t.Fatal("want error on a 200 that is not the bot API envelope")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "not a confirmed send") {
|
||||
t.Fatalf("error should say the send is unconfirmed, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// The success path must stay a success: ok=true on 200 is a send.
|
||||
func TestSendAcceptsOkTrue(t *testing.T) {
|
||||
rs := newRecordingServer(t, http.StatusOK, `{"ok":true,"result":{"message_id":7}}`)
|
||||
srv := httptest.NewServer(rs.handler())
|
||||
defer srv.Close()
|
||||
|
||||
sink, _ := New(sinkCfg(srv.URL))
|
||||
if err := sink.Send(context.Background(), nudgeSendable(loop.Sev4, "down")); err != nil {
|
||||
t.Fatalf("want success on ok=true, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A body long enough to have been truncated by the old 4096-byte cap still
|
||||
// parses, so a real send is not reported as a failure.
|
||||
func TestSendAcceptsAnOversizedButValidEnvelope(t *testing.T) {
|
||||
rs := newRecordingServer(t, http.StatusOK,
|
||||
`{"ok":true,"result":{"message_id":7,"text":"`+strings.Repeat("x", 8000)+`"}}`)
|
||||
srv := httptest.NewServer(rs.handler())
|
||||
defer srv.Close()
|
||||
|
||||
sink, _ := New(sinkCfg(srv.URL))
|
||||
if err := sink.Send(context.Background(), nudgeSendable(loop.Sev4, "down")); err != nil {
|
||||
t.Fatalf("want success on a large ok=true envelope, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// The error line carries a snippet, not the relay's whole page.
|
||||
func TestSendErrorDoesNotCarryTheWholeBody(t *testing.T) {
|
||||
rs := newRecordingServer(t, http.StatusBadGateway, strings.Repeat("z", 5000))
|
||||
srv := httptest.NewServer(rs.handler())
|
||||
defer srv.Close()
|
||||
|
||||
sink, _ := New(sinkCfg(srv.URL))
|
||||
err := sink.Send(context.Background(), nudgeSendable(loop.Sev4, "down"))
|
||||
if err == nil {
|
||||
t.Fatal("want error on 502")
|
||||
}
|
||||
if len(err.Error()) > 500 {
|
||||
t.Fatalf("error line should be trimmed, got %d bytes", len(err.Error()))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendReturnsErrorOnNon2xx(t *testing.T) {
|
||||
rs := newRecordingServer(t, http.StatusBadGateway, "bad gateway")
|
||||
srv := httptest.NewServer(rs.handler())
|
||||
|
||||
+10
-1
@@ -257,7 +257,16 @@ func (c *Client) call(ctx context.Context, method string, params any, out any) e
|
||||
if resp.Error != nil {
|
||||
return fmt.Errorf("mcp: %s: %s: %w", c.name, method, resp.Error)
|
||||
}
|
||||
if out == nil || len(resp.Result) == 0 {
|
||||
// A frame carrying our id and neither result nor error is not an answer.
|
||||
// The HTTP transport already refuses one; the stdio transport does not, and
|
||||
// without this check the refusal depended on which door the server was
|
||||
// behind. Letting it through is the one failure that lies: tools/call
|
||||
// returns an empty string and a nil error, so the act is recorded as done
|
||||
// and the tool never ran.
|
||||
if len(resp.Result) == 0 {
|
||||
return fmt.Errorf("mcp: %s: %s: response carries neither result nor error", c.name, method)
|
||||
}
|
||||
if out == nil {
|
||||
return nil
|
||||
}
|
||||
if err := json.Unmarshal(resp.Result, out); err != nil {
|
||||
|
||||
@@ -525,10 +525,16 @@ func (m *Manager) Resources(ctx context.Context) []Resource {
|
||||
|
||||
// ReadResource reads one resource from one server.
|
||||
func (m *Manager) ReadResource(ctx context.Context, server, uri string) (string, error) {
|
||||
cl, cfg, _, _, _ := m.lookup(server, "")
|
||||
if cl == nil {
|
||||
cl, cfg, _, configured, _ := m.lookup(server, "")
|
||||
if !configured {
|
||||
return "", fmt.Errorf("%w: %s", ErrNoServer, server)
|
||||
}
|
||||
// A configured server that is merely down is not an unknown server. Call
|
||||
// already keeps the two apart; reporting ErrNoServer here tells a caller
|
||||
// the resource can never exist, when the truth is "not right now".
|
||||
if cl == nil {
|
||||
return "", fmt.Errorf("%w: %s", ErrNotConnected, server)
|
||||
}
|
||||
cctx, cancel := context.WithTimeout(ctx, cfg.Timeout)
|
||||
defer cancel()
|
||||
return cl.ReadResource(cctx, uri)
|
||||
|
||||
@@ -641,3 +641,59 @@ func TestReconnectBackoffGrows(t *testing.T) {
|
||||
t.Fatalf("first retry = %v, want %v", c.backoff(), DefaultReconnectEvery)
|
||||
}
|
||||
}
|
||||
|
||||
// emptyFrameTransport answers the handshake normally and then replies to every
|
||||
// later call with a well-formed frame carrying our id and nothing else — no
|
||||
// result, no error. That is the answer a partially-implemented server gives,
|
||||
// and it is the one that lies: without a check it reads as success.
|
||||
type emptyFrameTransport struct{ handshaken bool }
|
||||
|
||||
func (t *emptyFrameTransport) Call(ctx context.Context, req *rpcRequest) (*rpcResponse, error) {
|
||||
id := req.ID
|
||||
if !t.handshaken {
|
||||
t.handshaken = true
|
||||
raw, _ := json.Marshal(map[string]any{
|
||||
"protocolVersion": ProtocolVersion,
|
||||
"serverInfo": map[string]any{"name": "empty", "version": "0"},
|
||||
})
|
||||
return &rpcResponse{JSONRPC: "2.0", ID: &id, Result: raw}, nil
|
||||
}
|
||||
return &rpcResponse{JSONRPC: "2.0", ID: &id}, nil
|
||||
}
|
||||
|
||||
func (t *emptyFrameTransport) Notify(context.Context, string, any) error { return nil }
|
||||
func (t *emptyFrameTransport) Close() error { return nil }
|
||||
|
||||
func TestResultlessResponseIsNotSuccess(t *testing.T) {
|
||||
c := newClient("empty", &emptyFrameTransport{})
|
||||
if err := c.Initialize(context.Background()); err != nil {
|
||||
t.Fatalf("initialize: %v", err)
|
||||
}
|
||||
out, err := c.CallTool(context.Background(), "break_thing", map[string]any{"q": "x"})
|
||||
if err == nil {
|
||||
t.Fatalf("a frame with neither result nor error must not read as success (got %q)", out)
|
||||
}
|
||||
if out != "" {
|
||||
t.Fatalf("out = %q", out)
|
||||
}
|
||||
if _, err := c.ListTools(context.Background()); err == nil {
|
||||
t.Fatal("tools/list with no result must be an error, not an empty catalogue")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadResourceOnDownServerIsNotConnected(t *testing.T) {
|
||||
// A url server with no poster factory: configured, validated, never dialed.
|
||||
m, err := NewManager(nil, []ServerConfig{{
|
||||
Name: "down", URL: "http://example.test/mcp", Enabled: true,
|
||||
}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m.Connect(context.Background())
|
||||
if _, err := m.ReadResource(context.Background(), "down", "note://one"); !errors.Is(err, ErrNotConnected) {
|
||||
t.Fatalf("err = %v, want ErrNotConnected", err)
|
||||
}
|
||||
if _, err := m.ReadResource(context.Background(), "nosuch", "note://one"); !errors.Is(err, ErrNoServer) {
|
||||
t.Fatalf("err = %v, want ErrNoServer", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,10 +52,13 @@ type PlanEntry struct {
|
||||
// Plan — the ordered day. Date is the calendar day it describes. Rest marks a
|
||||
// plan trimmed by After, which changes what an empty one means: a day with
|
||||
// nothing on it and a day whose last item has passed are different answers.
|
||||
// More counts what Next dropped off the end, so the sentence can say that more
|
||||
// remains instead of implying the day ends after the third line.
|
||||
type Plan struct {
|
||||
Date time.Time
|
||||
Items []PlanEntry
|
||||
Rest bool
|
||||
More int
|
||||
}
|
||||
|
||||
// BuildPlan orders everything known about the day Now falls on: calendar
|
||||
@@ -142,13 +145,24 @@ func checklistEntries(routines []Routine, facts map[string]store.Fact, now time.
|
||||
return out
|
||||
}
|
||||
|
||||
// NextSpoken — how many entries "что дальше?" reads aloud. Three, for the same
|
||||
// reason the feed reads three headlines: the answer is spoken once and cannot be
|
||||
// scrolled back, and a list longer than a breath is not an answer, it is a
|
||||
// recital. Asked at 04:45 on a day with 43 entries, the trim below removes
|
||||
// nothing — everything is still ahead — so the cap is what makes "дальше" mean
|
||||
// next rather than today (V-618).
|
||||
const NextSpoken = 3
|
||||
|
||||
// After returns the part of the plan that has not happened yet — the answer to
|
||||
// "что дальше?" as opposed to "какие планы на сегодня?". The Date is kept, so an
|
||||
// empty result still knows which day it is empty for.
|
||||
//
|
||||
// Strictly after: an entry at exactly now is the thing happening, not the thing
|
||||
// next.
|
||||
func (p Plan) After(now time.Time) Plan {
|
||||
out := Plan{Date: p.Date, Rest: true}
|
||||
for _, it := range p.Items {
|
||||
if it.At.Before(now) {
|
||||
if !it.At.After(now) {
|
||||
continue
|
||||
}
|
||||
out.Items = append(out.Items, it)
|
||||
@@ -156,10 +170,30 @@ func (p Plan) After(now time.Time) Plan {
|
||||
return out
|
||||
}
|
||||
|
||||
// Next is After with a spoken cap — what "что дальше?" actually answers with.
|
||||
// The overflow is counted rather than dropped, because "дальше: 10:00 …" with
|
||||
// forty entries hidden behind it is a false picture of the day.
|
||||
func (p Plan) Next(now time.Time, n int) Plan {
|
||||
out := p.After(now)
|
||||
if n > 0 && len(out.Items) > n {
|
||||
out.More = len(out.Items) - n
|
||||
out.Items = out.Items[:n]
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// FormatRU renders the plan as maven says it. Feminine self-reference,
|
||||
// informal address, no pet names — and no exhortation: she reads the day back,
|
||||
// she does not tell him to get on with it.
|
||||
//
|
||||
// Every hour is read in the plan's own zone — Date's, which BuildPlan sets from
|
||||
// the asking clock. Printed raw, an hour read whatever zone its instant arrived
|
||||
// in: an event or a reminder comes off the store as UTC, while a checklist line
|
||||
// is built local, so one spoken sentence named two zones. This is the voice
|
||||
// path, so that is what the owner heard (V-614); the same defect on the two web
|
||||
// pages was V-612.
|
||||
func (p Plan) FormatRU() string {
|
||||
zone := p.Date.Location()
|
||||
if len(p.Items) == 0 {
|
||||
// "что дальше?" after the last item of the day. The day was not empty,
|
||||
// it is over, and saying it was empty is a false statement about a day
|
||||
@@ -171,14 +205,28 @@ func (p Plan) FormatRU() string {
|
||||
}
|
||||
parts := make([]string, len(p.Items))
|
||||
for i, it := range p.Items {
|
||||
line := fmt.Sprintf("%s — %s", it.At.Format("15:04"), it.Text)
|
||||
line := fmt.Sprintf("%s — %s", it.At.In(zone).Format("15:04"), it.Text)
|
||||
if it.Uncertain {
|
||||
line = say.S(say.PlanUncertain, map[string]string{"line": line})
|
||||
}
|
||||
parts[i] = line
|
||||
}
|
||||
items := strings.Join(parts, "; ")
|
||||
// The rest of the day is a different sentence, not a shorter day plan. It
|
||||
// carries no date — he asked what is next, and he knows which day he is in —
|
||||
// and it says out loud when there is more behind the cap.
|
||||
if p.Rest {
|
||||
if p.More > 0 {
|
||||
return say.S(say.PlanNextMore, map[string]string{
|
||||
"items": items,
|
||||
"n": fmt.Sprint(p.More),
|
||||
"word": say.CountWord(p.More, "дело", "дела", "дел"),
|
||||
})
|
||||
}
|
||||
return say.S(say.PlanNext, map[string]string{"items": items})
|
||||
}
|
||||
return say.S(say.PlanDay, map[string]string{
|
||||
"date": p.Date.Format("02.01.2006"),
|
||||
"items": strings.Join(parts, "; "),
|
||||
"items": items,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package morning
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -151,6 +152,37 @@ func TestPlanFormatRU(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// One spoken sentence names one clock. An event and a reminder come off the
|
||||
// store as UTC and a checklist line is built in the asking clock's zone, so the
|
||||
// raw Format printed the two halves of one sentence in two zones (V-614). The
|
||||
// zones here are three hours off whatever this machine runs in, so the test
|
||||
// tells "read in his clock" apart from "read in the zone the instant arrived
|
||||
// in" under TZ=UTC as well.
|
||||
func TestPlanFormatRUReadsEveryHourInThePlansZone(t *testing.T) {
|
||||
_, off := time.Now().Zone()
|
||||
away := time.FixedZone("away", off+3*60*60)
|
||||
|
||||
stored := time.Date(2026, 8, 3, 14, 0, 0, 0, time.UTC)
|
||||
p := Plan{
|
||||
Date: time.Date(2026, 8, 3, 0, 0, 0, 0, away),
|
||||
Items: []PlanEntry{
|
||||
{At: stored, Text: "Планёрка", Kind: PlanEvent},
|
||||
{At: planAt(time.Date(2026, 8, 3, 0, 0, 0, 0, away), 10, 30),
|
||||
Text: "утро — осталось: витамины", Kind: PlanChecklist},
|
||||
},
|
||||
}
|
||||
got := p.FormatRU()
|
||||
if want := stored.In(away).Format("15:04"); !strings.Contains(got, want) {
|
||||
t.Errorf("the event is not read in the plan's zone (%s): %q", want, got)
|
||||
}
|
||||
if bad := stored.Format("15:04"); strings.Contains(got, bad) {
|
||||
t.Errorf("the event is read in the zone it was stored in (%s): %q", bad, got)
|
||||
}
|
||||
if !strings.Contains(got, "10:30") {
|
||||
t.Errorf("the checklist line moved zone: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanAfter(t *testing.T) {
|
||||
p, now := planFixture(t)
|
||||
rest := p.After(planAt(now, 11, 0))
|
||||
@@ -171,6 +203,84 @@ func TestPlanAfter(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// nextFixture — a day with more entries than the cap, built in a zone three
|
||||
// hours off UTC so the test fails under TZ=UTC as well as under the machine's
|
||||
// own zone if the plan ever renders in the wrong one.
|
||||
func nextFixture(t *testing.T) (Plan, time.Time) {
|
||||
t.Helper()
|
||||
zone := time.FixedZone("MSK", 3*60*60)
|
||||
now := time.Date(2026, 8, 3, 4, 45, 0, 0, zone)
|
||||
var events []PlanEntry
|
||||
for _, hhmm := range [][2]int{{5, 45}, {10, 0}, {14, 0}, {18, 30}, {21, 12}} {
|
||||
events = append(events, PlanEntry{
|
||||
At: planAt(now, hhmm[0], hhmm[1]),
|
||||
Text: fmt.Sprintf("событие %02d:%02d", hhmm[0], hhmm[1]),
|
||||
Kind: PlanEvent,
|
||||
})
|
||||
}
|
||||
return BuildPlan(nil, nil, events, nil, now), now
|
||||
}
|
||||
|
||||
// "что дальше?" asked at 04:45 on a day with everything still ahead. The trim
|
||||
// removes nothing there, so before V-618 she read the whole day out loud.
|
||||
func TestPlanNextCapsWhatIsSpoken(t *testing.T) {
|
||||
p, now := nextFixture(t)
|
||||
got := p.Next(now, NextSpoken).FormatRU()
|
||||
want := "дальше: 05:45 — событие 05:45; 10:00 — событие 10:00; " +
|
||||
"14:00 — событие 14:00. и ещё 2 дела до конца дня."
|
||||
if got != want {
|
||||
t.Errorf("got %q\nwant %q", got, want)
|
||||
}
|
||||
// No date: he asked what is next, not what day it is.
|
||||
if strings.Contains(got, "03.08.2026") {
|
||||
t.Errorf("rest-of-day answer stamps a date: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Nothing hidden means nothing claimed hidden.
|
||||
func TestPlanNextWithinTheCapSaysNoMore(t *testing.T) {
|
||||
p, now := nextFixture(t)
|
||||
got := p.Next(planAt(now, 15, 0), NextSpoken).FormatRU()
|
||||
want := "дальше: 18:30 — событие 18:30; 21:12 — событие 21:12"
|
||||
if got != want {
|
||||
t.Errorf("got %q\nwant %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// The whole-day question is not narrowed: same plan, no trim, no cap.
|
||||
func TestPlanWholeDayIsNotNarrowed(t *testing.T) {
|
||||
p, _ := nextFixture(t)
|
||||
got := p.FormatRU()
|
||||
if n := strings.Count(got, "событие"); n != 5 {
|
||||
t.Errorf("whole day read %d of 5 entries: %q", n, got)
|
||||
}
|
||||
if !strings.HasPrefix(got, "план на 03.08.2026: ") {
|
||||
t.Errorf("whole day lost its date: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// The empty case says the day is over rather than returning an empty sentence,
|
||||
// and it does not say the day was empty.
|
||||
func TestPlanNextEmptySaysSo(t *testing.T) {
|
||||
p, now := nextFixture(t)
|
||||
got := p.Next(planAt(now, 23, 30), NextSpoken).FormatRU()
|
||||
if got != "на сегодня больше ничего не запланировано." {
|
||||
t.Errorf("got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// An entry at exactly the asking minute is what is happening, not what is next.
|
||||
func TestPlanNextIsStrictlyAfterNow(t *testing.T) {
|
||||
p, now := nextFixture(t)
|
||||
rest := p.Next(planAt(now, 5, 45), NextSpoken)
|
||||
if len(rest.Items) != 3 || rest.Items[0].At.Hour() != 10 {
|
||||
t.Errorf("got %+v", rest.Items)
|
||||
}
|
||||
if rest.More != 1 {
|
||||
t.Errorf("More = %d, want 1", rest.More)
|
||||
}
|
||||
}
|
||||
|
||||
// The plan says what today still has not got done, and a closed window does not
|
||||
// make a skipped routine untrue. Evaluate reports Active only inside the
|
||||
// window, so keying the checklist line off it meant the one thing the plan can
|
||||
|
||||
@@ -78,3 +78,36 @@ func TestEmptyKnowledgeAnswerIsAnError(t *testing.T) {
|
||||
t.Errorf("error = %v; want it to name the empty response", err)
|
||||
}
|
||||
}
|
||||
|
||||
// The other two paths, which had no such guard. The evidence branch of
|
||||
// PhraseQuery and PhraseChat both returned ("", nil) off a server that produced
|
||||
// no tokens — an empty answer reported as a successful phrasing. The daemon's
|
||||
// callers check for the empty string and paper over it; the eval does not, and
|
||||
// scored a silent model as bad phrasing rather than as a failure.
|
||||
func TestEmptyAnswerIsAnErrorOnEveryPath(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write([]byte(`{"choices":[{"message":{"content":""}}]}`))
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
p := NewLLMPhraserAt(srv.URL, Config{})
|
||||
|
||||
t.Run("evidence", func(t *testing.T) {
|
||||
got, err := p.PhraseQuery(context.Background(), "сколько воды я выпил", []string{"два литра"})
|
||||
if err == nil {
|
||||
t.Fatal("an empty response scored as an answer")
|
||||
}
|
||||
if !isFallback(t, fbQuerySources, "два литра", got) {
|
||||
t.Errorf("fallback text = %q, want a %q variant", got, fbQuerySources)
|
||||
}
|
||||
})
|
||||
t.Run("chat", func(t *testing.T) {
|
||||
got, err := p.PhraseChat(context.Background(), "как дела", nil)
|
||||
if err == nil {
|
||||
t.Fatal("an empty response scored as an answer")
|
||||
}
|
||||
if !isFallback(t, fbChat, "", got) {
|
||||
t.Errorf("fallback text = %q, want a %q variant", got, fbChat)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -299,6 +299,16 @@ func (p *LLMPhraser) PhraseQuery(ctx context.Context, utterance string, notes []
|
||||
if text != "" {
|
||||
return text, nil
|
||||
}
|
||||
if raw == "" {
|
||||
// Same guard the knowledge branch above has had since it was written,
|
||||
// and this branch did not: the server answered and the model wrote
|
||||
// nothing, which returned ("", nil) — an empty answer reported as a
|
||||
// successful phrasing. The daemon's callers happen to check for the
|
||||
// empty string, so it read as a silent fallback there; the eval scored
|
||||
// it as bad phrasing rather than as the failure it is, and nothing on
|
||||
// either path logged that the model had produced no tokens.
|
||||
return SourcesFallback(strings.Join(notes, "; ")), errEmptyResponse
|
||||
}
|
||||
return raw, nil
|
||||
}
|
||||
|
||||
@@ -375,7 +385,13 @@ func (p *LLMPhraser) PhraseChat(ctx context.Context, utterance string, history [
|
||||
if i := strings.IndexByte(resp, '\n'); i >= 0 {
|
||||
resp = resp[:i]
|
||||
}
|
||||
return strings.TrimSpace(resp), nil
|
||||
if resp = strings.TrimSpace(resp); resp == "" {
|
||||
// The model was up and wrote nothing. Same rule as PhraseQuery: the
|
||||
// fallback keeps the turn alive and the failure stays visible, rather
|
||||
// than ("", nil) telling the caller the chat path succeeded.
|
||||
return ChatFallback(), fmt.Errorf("phrase chat: %w", errEmptyResponse)
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func (p *LLMPhraser) PhraseReminder(ctx context.Context, d loop.ReminderDecision) (delivery.PhrasedReminder, error) {
|
||||
@@ -414,11 +430,9 @@ func (p *LLMPhraser) PhraseReminder(ctx context.Context, d loop.ReminderDecision
|
||||
if mood == "" {
|
||||
mood = "neutral"
|
||||
}
|
||||
summary := body
|
||||
if len(summary) > 60 {
|
||||
summary = summary[:57] + "..."
|
||||
}
|
||||
return delivery.PhrasedReminder{Decision: d, Body: body, Summary: summary, Mood: mood}, nil
|
||||
return delivery.PhrasedReminder{
|
||||
Decision: d, Body: body, Summary: reminderSummary(body), Mood: mood,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (p *LLMPhraser) chat(ctx context.Context, userPrompt string) (string, error) {
|
||||
|
||||
@@ -0,0 +1,316 @@
|
||||
package phraser
|
||||
|
||||
// The persona guard for the Go floor strings.
|
||||
//
|
||||
// internal/phraser/eval/fallbacks_test.go already scores everything Variants()
|
||||
// returns — that is the JSON decks. What it cannot see is the floor UNDER those
|
||||
// decks: the hardFloor/ackFloor/queryFloor/actFloor/confirmFloor maps and the
|
||||
// literals in nudge_llm.go, which are what she says when the JSON is unusable or
|
||||
// when the model is unreachable. Those are exactly the moments the model is not
|
||||
// doing the talking, so leaving them unscored left the persona unchecked when it
|
||||
// was most load-bearing (Vikunja #621).
|
||||
//
|
||||
// Two tests here, and the second one is the point:
|
||||
//
|
||||
// - TestGoFloorPersona scores the floor corpus on the same checks.
|
||||
// - TestGoFloorCoverage walks the package source with go/ast and fails on any
|
||||
// Russian string literal that neither reached the corpus nor sits inside a
|
||||
// declaration declared prompt-side. A hand-written list of strings would rot
|
||||
// the first time somebody adds one; a hand-written list of PROMPT BUILDERS
|
||||
// does not, because the default for a new literal is "must be scored".
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"go/ast"
|
||||
"go/parser"
|
||||
"go/token"
|
||||
"io/fs"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
"github.com/kami/maven/internal/loop"
|
||||
"github.com/kami/maven/internal/phraser/eval"
|
||||
"github.com/kami/maven/internal/store"
|
||||
)
|
||||
|
||||
// personaChecks — the checks that apply to a floor line.
|
||||
//
|
||||
// The same three the non-goals section names (feminine self-reference, how she
|
||||
// addresses him, no pet names), plus lang: an English floor line is unusable out
|
||||
// loud. No hisgender, for the reason eval/fallbacks_test.go gives — her own
|
||||
// feminine verb near "тебе" is correct and that check reads it as addressing him
|
||||
// as a woman. No length, because a floor line composed from his own data has no
|
||||
// bounded length, and no ontopic/mood, which need a fixture case.
|
||||
var personaChecks = map[string]bool{
|
||||
eval.CheckLang: true,
|
||||
eval.CheckFeminine: true,
|
||||
eval.CheckAddress: true,
|
||||
eval.CheckCringe: true,
|
||||
}
|
||||
|
||||
var floorPlaceholderRE = regexp.MustCompile(`\{[a-z_]+\}`)
|
||||
|
||||
// formatVerbRE — the fmt verbs a floor line is composed with, so the coverage
|
||||
// test compares the Russian either side of them and not the verb.
|
||||
var formatVerbRE = regexp.MustCompile(`%[+\-# 0-9.]*[a-zA-Z]`)
|
||||
|
||||
// floorLine — one scored string and where it came from, so a failure names the
|
||||
// map or the function to go and edit.
|
||||
type floorLine struct {
|
||||
origin string
|
||||
text string
|
||||
}
|
||||
|
||||
// floorCorpus — every line the Go floor can produce. Maps are read whole, so a
|
||||
// new entry in one is scored without touching this file; the composing functions
|
||||
// are CALLED rather than scraped, so their glue text is scored in place.
|
||||
func floorCorpus() []floorLine {
|
||||
var out []floorLine
|
||||
add := func(origin, text string) {
|
||||
if strings.TrimSpace(text) != "" {
|
||||
out = append(out, floorLine{origin, text})
|
||||
}
|
||||
}
|
||||
for name, m := range map[string]map[string]string{
|
||||
"fallbacks.go hardFloor": hardFloor,
|
||||
"acks.go ackFloor": ackFloor,
|
||||
"query.go queryFloor": queryFloor,
|
||||
"acts.go actFloor": actFloor,
|
||||
"confirm.go confirmFloor": confirmFloor,
|
||||
"nudge_llm.go fallbackNudges": fallbackNudges,
|
||||
} {
|
||||
for key, text := range m {
|
||||
add(name+"["+key+"]", text)
|
||||
}
|
||||
}
|
||||
|
||||
// fallbackNudge composes three of its four arms in Go. Drive every rule name
|
||||
// the maps know, one it does not, and the down-services arm.
|
||||
rules := map[string]bool{"": true, "unknown_rule": true}
|
||||
for name := range fallbackNudges {
|
||||
rules[name] = true
|
||||
}
|
||||
for name := range ruleTopics {
|
||||
rules[name] = true
|
||||
}
|
||||
for name := range ruleKeywords {
|
||||
rules[name] = true
|
||||
}
|
||||
for name := range rules {
|
||||
c := loop.Candidate{}
|
||||
c.Rule.Name = name
|
||||
add(fmt.Sprintf("nudge_llm.go fallbackNudge(%q)", name), fallbackNudge(c))
|
||||
}
|
||||
// The keyword arm again, through a rule name shaped "family:keyword", which
|
||||
// is where ruleKeyword's second branch lives.
|
||||
c := loop.Candidate{}
|
||||
c.Rule.Name = "custom:зарядку"
|
||||
add("nudge_llm.go fallbackNudge(custom)", fallbackNudge(c))
|
||||
|
||||
// The keywords themselves. A rule that has both a keyword and a fallback
|
||||
// line never reaches the keyword arm, but the map is edited as one thing and
|
||||
// the next rule may have only the keyword, so score every value.
|
||||
for rule, kw := range ruleKeywords {
|
||||
add("nudge_llm.go ruleKeywords["+rule+"]", "Напоминаю: "+kw+".")
|
||||
}
|
||||
|
||||
// The down-services arm, which needs a service actually reading down.
|
||||
down := loop.Candidate{}
|
||||
down.Rule.Name = "service_down"
|
||||
down.State.Facts = map[string]store.Fact{
|
||||
loop.ServiceDownPrefix + "gitea": {
|
||||
Key: loop.ServiceDownPrefix + "gitea",
|
||||
Value: `"down"`,
|
||||
Source: loop.ServiceDownSource,
|
||||
Ts: time.Now(),
|
||||
},
|
||||
}
|
||||
add("nudge_llm.go fallbackNudge(down services)", fallbackNudge(down))
|
||||
|
||||
// The spoken duration words. Both functions are pure and bounded, so scoring
|
||||
// their whole range beats scraping the literals out of the switch.
|
||||
for m := 0; m <= 60*30; m += 7 {
|
||||
d := time.Duration(m) * time.Minute
|
||||
add("nudge_llm.go ruDur", ruDur(d))
|
||||
add("nudge_templates.go ruSinceWords", ruSinceWords(d))
|
||||
}
|
||||
for h := 0; h <= hoursSpoken; h++ {
|
||||
add("nudge_templates.go hourPlural", hourPlural(h))
|
||||
add("nudge_templates.go hourWord", hourWord(h))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// TestGoFloorPersona scores every line the Go floor can say.
|
||||
func TestGoFloorPersona(t *testing.T) {
|
||||
corpus := floorCorpus()
|
||||
if len(corpus) == 0 {
|
||||
t.Fatal("no floor lines — the corpus builder found nothing to score")
|
||||
}
|
||||
for _, line := range corpus {
|
||||
// A placeholder stands for his own words and carries no persona.
|
||||
body := floorPlaceholderRE.ReplaceAllString(line.text, "вода")
|
||||
for _, r := range eval.RunChecks(eval.Case{}, body, "neutral") {
|
||||
if personaChecks[r.Name] && !r.Pass {
|
||||
t.Errorf("%s: %q fails %s: %s", line.origin, line.text, r.Name, r.Detail)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// promptDecls — declarations whose Russian is written FOR the model, not for
|
||||
// him. They are excluded by name, not by string, so adding a line inside one of
|
||||
// them stays excluded and adding a line anywhere else fails the coverage test.
|
||||
//
|
||||
// Every name here is asserted to still exist, so a rename fails loudly instead
|
||||
// of silently widening the exemption.
|
||||
var promptDecls = map[string]string{
|
||||
"ReplySystemPrompt": "the system prompt for the reply model",
|
||||
"replyContext": "renders the decision FOR the model, never spoken",
|
||||
"ruleTopics": "situation descriptions fed to the nudge prompt",
|
||||
"ruleTopic": "same, plus the two prefixes it composes",
|
||||
"buildNudgePrompt": "the nudge prompt itself",
|
||||
"chatUserMessage": "the history block handed to the model",
|
||||
"PhraseReminder": "the reminder prompt; its reply is scored, its prompt is not",
|
||||
}
|
||||
|
||||
// promptFiles — files whose whole job is prompt text. Asserted to exist, same
|
||||
// reason as promptDecls.
|
||||
var promptFiles = map[string]string{
|
||||
"prompts.go": "every literal in it is a prompt",
|
||||
}
|
||||
|
||||
func hasCyrillic(s string) bool {
|
||||
for _, r := range s {
|
||||
if unicode.Is(unicode.Cyrillic, r) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// TestGoFloorCoverage — the guard that survives the next person.
|
||||
//
|
||||
// It reads the package source and requires every Russian string literal to be
|
||||
// one of two things: reachable in floorCorpus (so TestGoFloorPersona scored it),
|
||||
// or inside a declaration named above as prompt-side. There is no third answer
|
||||
// and no way to add a floor string that quietly gets neither.
|
||||
func TestGoFloorCoverage(t *testing.T) {
|
||||
var scored []string
|
||||
for _, line := range floorCorpus() {
|
||||
scored = append(scored, line.text)
|
||||
}
|
||||
// A literal is covered when every Russian piece of it shows up in something
|
||||
// the corpus scored. Pieces, not the whole string, because a format string
|
||||
// ("%d ч") and a concatenation fragment ("Не отвечает: ") only ever reach him
|
||||
// with the surrounding value filled in.
|
||||
covered := func(lit string) bool {
|
||||
for _, part := range formatVerbRE.Split(lit, -1) {
|
||||
part = strings.TrimSpace(part)
|
||||
if part == "" || !hasCyrillic(part) {
|
||||
continue
|
||||
}
|
||||
found := false
|
||||
for _, s := range scored {
|
||||
if strings.Contains(s, part) {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
fset := token.NewFileSet()
|
||||
pkgs, err := parser.ParseDir(fset, ".", func(fi fs.FileInfo) bool {
|
||||
return !strings.HasSuffix(fi.Name(), "_test.go")
|
||||
}, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("parse package: %v", err)
|
||||
}
|
||||
pkg, ok := pkgs["phraser"]
|
||||
if !ok {
|
||||
t.Fatal("package phraser did not parse — the coverage guard cannot run")
|
||||
}
|
||||
|
||||
seenDecl := map[string]bool{}
|
||||
seenFile := map[string]bool{}
|
||||
for path, file := range pkg.Files {
|
||||
base := path[strings.LastIndexByte(path, '/')+1:]
|
||||
if _, exempt := promptFiles[base]; exempt {
|
||||
seenFile[base] = true
|
||||
continue
|
||||
}
|
||||
for _, decl := range file.Decls {
|
||||
names := declNames(decl)
|
||||
skip := false
|
||||
for _, n := range names {
|
||||
if _, ok := promptDecls[n]; ok {
|
||||
seenDecl[n] = true
|
||||
skip = true
|
||||
}
|
||||
}
|
||||
if skip {
|
||||
continue
|
||||
}
|
||||
ast.Inspect(decl, func(n ast.Node) bool {
|
||||
bl, ok := n.(*ast.BasicLit)
|
||||
if !ok || bl.Kind != token.STRING {
|
||||
return true
|
||||
}
|
||||
lit, err := strconv.Unquote(bl.Value)
|
||||
if err != nil || !hasCyrillic(lit) {
|
||||
return true
|
||||
}
|
||||
if !covered(lit) {
|
||||
t.Errorf("%s: Russian literal %q is spoken by nothing the persona guard scores.\n"+
|
||||
"Either reach it from floorCorpus in persona_floor_test.go, or — if it is written "+
|
||||
"for the model rather than for him — name its declaration in promptDecls.",
|
||||
fset.Position(bl.Pos()), lit)
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
}
|
||||
for name, why := range promptDecls {
|
||||
if !seenDecl[name] {
|
||||
t.Errorf("promptDecls names %q (%s) and no such declaration exists — "+
|
||||
"a rename left the exemption open", name, why)
|
||||
}
|
||||
}
|
||||
for base, why := range promptFiles {
|
||||
if !seenFile[base] {
|
||||
t.Errorf("promptFiles names %q (%s) and no such file exists", base, why)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// declNames — the names a top-level declaration binds, so a prompt-side var,
|
||||
// const or func can be matched whatever kind it is.
|
||||
func declNames(decl ast.Decl) []string {
|
||||
switch d := decl.(type) {
|
||||
case *ast.FuncDecl:
|
||||
return []string{d.Name.Name}
|
||||
case *ast.GenDecl:
|
||||
var out []string
|
||||
for _, spec := range d.Specs {
|
||||
switch s := spec.(type) {
|
||||
case *ast.ValueSpec:
|
||||
for _, n := range s.Names {
|
||||
out = append(out, n.Name)
|
||||
}
|
||||
case *ast.TypeSpec:
|
||||
out = append(out, s.Name.Name)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -115,11 +115,32 @@ func (s *Stub) PhraseReminder(_ context.Context, d loop.ReminderDecision) (deliv
|
||||
if text == "" {
|
||||
text = "reminder"
|
||||
}
|
||||
summary := text
|
||||
if len(summary) > 60 {
|
||||
summary = summary[:57] + "..."
|
||||
// Mood, for the same reason PhraseNudge sets it: the Stub is a production
|
||||
// fallback, so it owes the output contract a value. This one was left at the
|
||||
// zero value, which is not one of the five moods.
|
||||
return delivery.PhrasedReminder{
|
||||
Decision: d, Body: text, Summary: reminderSummary(text), Mood: "neutral",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// summaryLimit — how much of a reminder goes to the away channels.
|
||||
const summaryLimit = 60
|
||||
|
||||
// reminderSummary shortens a reminder body to the away-channel summary.
|
||||
//
|
||||
// Counted in runes. Both copies of this counted bytes — `len(s) > 60` and
|
||||
// `s[:57]` — and on a Russian reminder that is wrong twice. A Cyrillic letter is
|
||||
// two bytes, so the cut fell at about 28 letters rather than 60; and byte 57
|
||||
// lands inside a letter about half the time, so the summary ended in half a
|
||||
// rune. That is not cosmetic: Sendable.Summary is the text voicesink hands to
|
||||
// piper and the text the telegram sink posts, so the broken byte was spoken and
|
||||
// sent.
|
||||
func reminderSummary(body string) string {
|
||||
r := []rune(body)
|
||||
if len(r) <= summaryLimit {
|
||||
return body
|
||||
}
|
||||
return delivery.PhrasedReminder{Decision: d, Body: text, Summary: summary}, nil
|
||||
return string(r[:summaryLimit-3]) + "..."
|
||||
}
|
||||
|
||||
// phraseNudge — the per-rule templates. each reads the context the predicate
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/kami/maven/internal/delivery"
|
||||
"github.com/kami/maven/internal/dialogue"
|
||||
@@ -185,6 +186,34 @@ func TestPhraseReminderTruncatesLongSummary(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// The same truncation, in the language she actually speaks. The test above is
|
||||
// ASCII, which is what let the byte arithmetic stand: `len(s) > 60` and `s[:57]`
|
||||
// cut a Russian reminder at about 28 letters instead of 60, and landed inside a
|
||||
// letter about half the time. Summary is what voicesink hands to piper and what
|
||||
// the telegram sink posts, so half a rune was spoken and sent.
|
||||
func TestPhraseReminderSummaryCountsRunesNotBytes(t *testing.T) {
|
||||
long := "позвонить маме и забрать посылку из пункта выдачи на соседней улице до восьми вечера"
|
||||
rd := loop.ReminderDecision{
|
||||
Reminder: store.Reminder{Payload: `{"text":"` + long + `"}`},
|
||||
State: loop.State{Now: time.Now().UTC()},
|
||||
}
|
||||
pr, _ := NewStub().PhraseReminder(context.Background(), rd)
|
||||
if !utf8.ValidString(pr.Summary) {
|
||||
t.Fatalf("summary is not valid UTF-8, it was cut mid-letter: %q", pr.Summary)
|
||||
}
|
||||
if n := utf8.RuneCountInString(pr.Summary); n > summaryLimit {
|
||||
t.Fatalf("summary = %d runes, want at most %d: %q", n, summaryLimit, pr.Summary)
|
||||
}
|
||||
// The cut must be near the limit, not near half of it. A byte count would
|
||||
// stop at 28 letters here.
|
||||
if n := utf8.RuneCountInString(pr.Summary); n < summaryLimit-5 {
|
||||
t.Fatalf("summary = %d runes, cut far too early — counted in bytes? %q", n, pr.Summary)
|
||||
}
|
||||
if pr.Mood == "" {
|
||||
t.Error("Mood is empty; the Stub is a production fallback and owes the contract a mood")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPhraseReminderNonJSONPayload(t *testing.T) {
|
||||
// a payload that isn't JSON → the phraser falls back to the raw string.
|
||||
rd := loop.ReminderDecision{
|
||||
|
||||
@@ -58,8 +58,15 @@ try:
|
||||
# read at all until V-579: "в 9" set the reminder and "на 9" did not.
|
||||
# "к двум часам" is a third preposition and the dative that goes with it,
|
||||
# and it was read as no time at all until V-609.
|
||||
# The preposition is normalised as well as the hour (V-610). dateparser
|
||||
# joins a day word to a clock through "в" and through no other Russian
|
||||
# preposition, so "завтра к 03:00 pm" loses the clock and resolves to
|
||||
# tomorrow at the CURRENT minute. "на" was silently losing it the same way.
|
||||
def _at(m):
|
||||
prep = 'at' if m.group(1).lower() in ('at', 'by') else 'в'
|
||||
return '%s %02d:00' % (prep, int(m.group(2)))
|
||||
text = re.sub(r'(?<![\w:])(в|во|на|к|ко|at|by)\s+([01]?\d|2[0-3])(?:\s+час(?:а|ов|у|ам)?)?(?![\d:.\w])',
|
||||
lambda m: '%s %02d:00' % (m.group(1), int(m.group(2))), text, flags=re.IGNORECASE)
|
||||
_at, text, flags=re.IGNORECASE)
|
||||
settings = {'PREFER_DATES_FROM': 'future', 'RELATIVE_BASE': now}
|
||||
# Two-step: search_dates finds the date substring in text,
|
||||
# parse() gets the time right (search_dates mishandles AM/PM).
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os/exec"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// probeNow is the clock the five sentences below were measured against on the
|
||||
// box at 03:53 on 2026-08-06, right after #252 deployed. Three of them wrote a
|
||||
// reminder for 03:53 itself, which is the current minute and not an hour anyone
|
||||
// said (V-610).
|
||||
var probeNow = time.Date(2026, 8, 6, 3, 53, 0, 0, time.Local)
|
||||
|
||||
// kProbe — the five sentences, with what each must resolve to. The two that
|
||||
// already worked are here so that fixing "к" cannot cost "в".
|
||||
var kProbe = []struct {
|
||||
text string
|
||||
// day is the offset from probeNow's date, hour is the fire hour.
|
||||
day int
|
||||
hour int
|
||||
whyItIs string
|
||||
}{
|
||||
{
|
||||
text: "напомни завтра в три часа дня позвонить врачу",
|
||||
day: 1,
|
||||
hour: 15,
|
||||
whyItIs: "в plus a spoken hour and a qualifier resolves, and did before #252",
|
||||
},
|
||||
{
|
||||
text: "напомни завтра в 15:00 позвонить врачу",
|
||||
day: 1,
|
||||
hour: 15,
|
||||
whyItIs: "a written clock resolves, and did before #252",
|
||||
},
|
||||
{
|
||||
text: "напомни завтра к трём часам дня позвонить врачу",
|
||||
day: 1,
|
||||
hour: 15,
|
||||
whyItIs: "к names the same hour в does, and wrote 03:53 after #252",
|
||||
},
|
||||
{
|
||||
text: "напомни сегодня к пяти часам вечера позвонить врачу",
|
||||
day: 0,
|
||||
hour: 17,
|
||||
whyItIs: "the same defect on today and on the oblique пяти",
|
||||
},
|
||||
{
|
||||
text: "напомни завтра к трём часам позвонить врачу",
|
||||
day: 1,
|
||||
hour: 3,
|
||||
whyItIs: "no qualifier, so the hour reads as spoken and the daemon asks which half",
|
||||
},
|
||||
}
|
||||
|
||||
// TestKPrepositionHourStub — the probe against the floor parser, which answers
|
||||
// on every box whether or not python is installed.
|
||||
func TestKPrepositionHourStub(t *testing.T) {
|
||||
ex := Extractor{Time: StubDateTimeParser{}}
|
||||
for _, c := range kProbe {
|
||||
t.Run(c.text, func(t *testing.T) {
|
||||
got := ex.Extract(context.Background(), IntentReminder, c.text, probeNow)
|
||||
if !got.HasTime {
|
||||
t.Fatalf("time slot empty: %s", c.whyItIs)
|
||||
}
|
||||
checkFire(t, got.Time, c.day, c.hour, c.whyItIs)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestKPrepositionHourPython — the same probe against the production parser.
|
||||
// Skips where python3 or dateparser is missing, as the rest of this package's
|
||||
// python tests do.
|
||||
func TestKPrepositionHourPython(t *testing.T) {
|
||||
requirePython(t)
|
||||
p := NewPythonDateParser()
|
||||
ex := Extractor{Time: p}
|
||||
for _, c := range kProbe {
|
||||
t.Run(c.text, func(t *testing.T) {
|
||||
got := ex.Extract(context.Background(), IntentReminder, c.text, probeNow)
|
||||
if !got.HasTime {
|
||||
t.Fatalf("time slot empty: %s", c.whyItIs)
|
||||
}
|
||||
checkFire(t, got.Time, c.day, c.hour, c.whyItIs)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func checkFire(t *testing.T, fire time.Time, dayOffset, hour int, why string) {
|
||||
t.Helper()
|
||||
if fire.Hour() != hour || fire.Minute() != 0 {
|
||||
t.Errorf("fire = %s, want %02d:00 — %s", fire.Format("2006-01-02 15:04"), hour, why)
|
||||
}
|
||||
if fire.Minute() == probeNow.Minute() && fire.Hour() == probeNow.Hour() {
|
||||
t.Errorf("fire = %s, which is the clock at the moment of the turn and not an hour he said", fire.Format("15:04"))
|
||||
}
|
||||
want := probeNow.AddDate(0, 0, dayOffset)
|
||||
if fire.Year() != want.Year() || fire.Month() != want.Month() || fire.Day() != want.Day() {
|
||||
t.Errorf("fire = %s, want the %s — %s", fire.Format("2006-01-02"), want.Format("2006-01-02"), why)
|
||||
}
|
||||
}
|
||||
|
||||
// TestUnresolvedHourLeavesTheSlotEmpty — the durable half. A parser that read
|
||||
// the day and took the minute off the clock must not fill the time slot, no
|
||||
// matter which preposition lost the hour. This is the whole class the "к" case
|
||||
// was one member of.
|
||||
func TestUnresolvedHourLeavesTheSlotEmpty(t *testing.T) {
|
||||
ex := Extractor{Time: clockEchoParser{}}
|
||||
for _, s := range []string{
|
||||
"напомни завтра к трём часам дня позвонить врачу",
|
||||
"напомни завтра в три часа дня позвонить врачу",
|
||||
"напомни завтра на девять позвонить врачу",
|
||||
"напомни сегодня к пяти часам вечера позвонить врачу",
|
||||
} {
|
||||
got := ex.Extract(context.Background(), IntentReminder, s, probeNow)
|
||||
if got.HasTime {
|
||||
t.Errorf("Extract(%q) filled the slot with %s, which is the current minute; she has to ask", s, got.Time.Format("15:04"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSpokenMinutesSurviveTheRefusal — the three shapes that name a minute of
|
||||
// their own, and an hour that happens to be the hour it is spoken in. Refusing
|
||||
// on the whole instant instead of on the minute would cost every one of these,
|
||||
// and ru-rem-006 in the routing fixture is the case that says so.
|
||||
func TestSpokenMinutesSurviveTheRefusal(t *testing.T) {
|
||||
noon := time.Date(2026, 7, 30, 12, 0, 0, 0, time.UTC)
|
||||
ex := Extractor{Time: StubDateTimeParser{}}
|
||||
for _, c := range []struct {
|
||||
text string
|
||||
hour int
|
||||
min int
|
||||
}{
|
||||
{"напомни послезавтра в 12 забрать заказ", 12, 0},
|
||||
{"разбуди меня в 6:30", 6, 30},
|
||||
{"напомни в половине восьмого выпить таблетку", 7, 30},
|
||||
{"напомни без четверти восемь выходить", 7, 45},
|
||||
} {
|
||||
got := ex.Extract(context.Background(), IntentReminder, c.text, noon)
|
||||
if !got.HasTime {
|
||||
t.Errorf("Extract(%q) left the slot empty; he said the time", c.text)
|
||||
continue
|
||||
}
|
||||
if got.Time.Hour() != c.hour || got.Time.Minute() != c.min {
|
||||
t.Errorf("Extract(%q) = %s, want %02d:%02d", c.text, got.Time.Format("15:04"), c.hour, c.min)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestIntervalKeepsTheCurrentMinute — an interval is measured from now and may
|
||||
// land on now's own minute, so the refusal above must not reach it.
|
||||
func TestIntervalKeepsTheCurrentMinute(t *testing.T) {
|
||||
ex := Extractor{Time: StubDateTimeParser{}}
|
||||
got := ex.Extract(context.Background(), IntentReminder, "напомни через час позвонить врачу", probeNow)
|
||||
if !got.HasTime {
|
||||
t.Fatal("через час names one instant and answers the hour and the day together")
|
||||
}
|
||||
if want := probeNow.Add(time.Hour); !got.Time.Equal(want) {
|
||||
t.Errorf("fire = %s, want %s", got.Time.Format("15:04"), want.Format("15:04"))
|
||||
}
|
||||
}
|
||||
|
||||
func requirePython(t *testing.T) {
|
||||
t.Helper()
|
||||
if _, err := exec.LookPath("python3"); err != nil {
|
||||
t.Skip("python3 not on PATH — skipping dateparser tests")
|
||||
}
|
||||
if err := exec.Command("python3", "-c", "import dateparser").Run(); err != nil {
|
||||
t.Skip("python dateparser not installed — skipping dateparser tests")
|
||||
}
|
||||
}
|
||||
@@ -55,11 +55,12 @@ func (e Extractor) Extract(ctx context.Context, intent Intent, utterance string,
|
||||
switch intent {
|
||||
case IntentReminder:
|
||||
if e.Time != nil {
|
||||
// NamesAnHour is the gate, not the parser's ok (V-577, V-579). A
|
||||
// sentence that names a day and no hour parses to that day at the
|
||||
// current minute, and filling the slot with it invents the answer
|
||||
// she asked for. Left empty, the daemon asks.
|
||||
if t, ok, err := e.Time.Parse(ctx, utterance, now); err == nil && ok && NamesAnHour(utterance) {
|
||||
// ResolvedTheHour is the gate, not the parser's ok (V-577, V-579,
|
||||
// V-610). A sentence that names a day and no hour parses to that day
|
||||
// at the current minute, and so does one whose hour the parser could
|
||||
// not read. Filling the slot with either invents the answer she asked
|
||||
// for. Left empty, the daemon asks.
|
||||
if t, ok, err := e.Time.Parse(ctx, utterance, now); err == nil && ok && ResolvedTheHour(utterance, t) {
|
||||
s.Time = t
|
||||
s.HasTime = true
|
||||
}
|
||||
|
||||
@@ -119,6 +119,55 @@ func NamesAnHour(text string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// ResolvedTheHour reports whether a parse read the hour the sentence names,
|
||||
// rather than inheriting the clock it was handed as its relative base.
|
||||
//
|
||||
// It is the second half of the gate NamesAnHour opens (V-610). NamesAnHour asks
|
||||
// whether an hour was spoken and cannot ask whether it was read, so a
|
||||
// preposition the parser half knew wrote a reminder at 03:53 for "напомни
|
||||
// завтра к трём часам дня" and confirmed it as if it were the hour he said. A
|
||||
// wrong instant she states as fact is worse than a question, because he stops
|
||||
// thinking about it.
|
||||
//
|
||||
// The tell is the minute. A spoken hour lands on the hour, and the only three
|
||||
// shapes that name a minute of their own are a written clock, a half hour and a
|
||||
// quarter to. A parse that came back with any other minute took it from the
|
||||
// clock it was handed, whatever hour it put in front of it. An interval is
|
||||
// exempt, because it is measured from now and lands wherever the arithmetic
|
||||
// says.
|
||||
//
|
||||
// Comparing the whole instant to now would be the obvious test and it is the
|
||||
// wrong one: "напомни послезавтра в 12" resolves to 12:00 and the fixture's
|
||||
// reference clock is 12:00, so an hour he did say would read as an hour nobody
|
||||
// did.
|
||||
func ResolvedTheHour(text string, t time.Time) bool {
|
||||
if !NamesAnHour(text) {
|
||||
return false
|
||||
}
|
||||
if NamesAnInterval(text) || t.Minute() == 0 {
|
||||
return true
|
||||
}
|
||||
return namesTheMinute(text)
|
||||
}
|
||||
|
||||
// namesTheMinute reports whether the sentence says which minute of the hour it
|
||||
// means, in any of the three ways it can.
|
||||
func namesTheMinute(text string) bool {
|
||||
toks := strings.Fields(strings.ToLower(text))
|
||||
for i, raw := range toks {
|
||||
if isDigitClock(cleanWord(raw)) {
|
||||
return true
|
||||
}
|
||||
if _, _, ok := halfPastAt(toks, i); ok {
|
||||
return true
|
||||
}
|
||||
if _, _, _, ok := quarterToAt(toks, i); ok {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// NamesAnInterval reports whether the sentence measures the time from now
|
||||
// instead of naming it: "через час", "через 10 минут", "in 30 minutes".
|
||||
//
|
||||
|
||||
@@ -93,8 +93,17 @@ func TestReminderSlotRefusesAnHourNobodySaid(t *testing.T) {
|
||||
if got := ex.Extract(context.Background(), IntentReminder, "на завтра", now); got.HasTime {
|
||||
t.Errorf("«на завтра» filled the time slot with %s, which is the clock", got.Time.Format("15:04"))
|
||||
}
|
||||
if got := ex.Extract(context.Background(), IntentReminder, "на 9", now); !got.HasTime {
|
||||
t.Error("«на 9» names an hour and must still fill the slot")
|
||||
// "на 9" names an hour, and a parser that answered with the clock did not
|
||||
// read it (V-610). Naming one is necessary and reading it is what fills the
|
||||
// slot, so this echo is refused too and the daemon asks.
|
||||
if got := ex.Extract(context.Background(), IntentReminder, "на 9", now); got.HasTime {
|
||||
t.Errorf("«на 9» took %s from the clock; the parser never read the nine", got.Time.Format("15:04"))
|
||||
}
|
||||
// A parser that does read it fills the slot, which is the other half of the
|
||||
// same rule.
|
||||
real := Extractor{Time: StubDateTimeParser{}}
|
||||
if got := real.Extract(context.Background(), IntentReminder, "на 9", now); !got.HasTime || got.Time.Hour() != 9 {
|
||||
t.Errorf("«на 9» must fill the slot with nine o'clock, got %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -32,6 +32,12 @@ const (
|
||||
PlanDay = "plan_day"
|
||||
PlanUncertain = "plan_uncertain"
|
||||
|
||||
// "что дальше?" — the next few entries, not the day. PlanNextMore is the
|
||||
// same sentence when the cap hid something, so the count it states is the
|
||||
// only signal that the day is not over after the last line read.
|
||||
PlanNext = "plan_next"
|
||||
PlanNextMore = "plan_next_more"
|
||||
|
||||
TasksNone = "tasks_none"
|
||||
TasksFirst = "tasks_first"
|
||||
TasksCandidates = "tasks_candidates"
|
||||
@@ -70,6 +76,7 @@ const (
|
||||
|
||||
var summaryKeys = []string{
|
||||
PlanRestEmpty, PlanDayEmpty, PlanDay, PlanUncertain,
|
||||
PlanNext, PlanNextMore,
|
||||
TasksNone, TasksFirst, TasksCandidates,
|
||||
StallOverdue, StallSitting, StallUnconfirmed,
|
||||
ReasonOverdue, ReasonOverdueDays, ReasonToday, ReasonTomorrow,
|
||||
@@ -89,6 +96,8 @@ var summaryFloor = map[string]string{
|
||||
PlanDayEmpty: "на {date} ничего не запланировано.",
|
||||
PlanDay: "план на {date}: {items}",
|
||||
PlanUncertain: "похоже, {line}",
|
||||
PlanNext: "дальше: {items}",
|
||||
PlanNextMore: "дальше: {items}. и ещё {n} {word} до конца дня.",
|
||||
|
||||
TasksNone: "задач нет.",
|
||||
TasksFirst: "сначала: {items}",
|
||||
@@ -139,6 +148,8 @@ func LoadSummaries(src rand.Source) (*Summaries, error) {
|
||||
for _, req := range []struct{ key, ph string }{
|
||||
{PlanDayEmpty, "{date}"}, {PlanDay, "{date}"}, {PlanDay, "{items}"},
|
||||
{PlanUncertain, "{line}"},
|
||||
{PlanNext, "{items}"},
|
||||
{PlanNextMore, "{items}"}, {PlanNextMore, "{n}"}, {PlanNextMore, "{word}"},
|
||||
{TasksFirst, "{items}"}, {TasksCandidates, "{items}"},
|
||||
{StallOverdue, "{n}"}, {StallOverdue, "{word}"},
|
||||
{StallSitting, "{n}"}, {StallSitting, "{word}"},
|
||||
|
||||
@@ -30,6 +30,14 @@
|
||||
"fixed": true,
|
||||
"variants": ["похоже, {line}"]
|
||||
},
|
||||
"plan_next": {
|
||||
"fixed": true,
|
||||
"variants": ["дальше: {items}"]
|
||||
},
|
||||
"plan_next_more": {
|
||||
"fixed": true,
|
||||
"variants": ["дальше: {items}. и ещё {n} {word} до конца дня."]
|
||||
},
|
||||
|
||||
"tasks_none": {
|
||||
"fixed": true,
|
||||
|
||||
@@ -2,6 +2,7 @@ package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
@@ -38,16 +39,21 @@ func (s *Store) MarkSent(ctx context.Context, key string, ts time.Time) error {
|
||||
// send goes through RecordNudge, not MarkSent, so the first MarkSent comes on
|
||||
// the repeat path — LastSent may legitimately be zero until then).
|
||||
func (s *Store) LastSent(ctx context.Context, key string) (time.Time, error) {
|
||||
var millis int64
|
||||
// MAX over an empty set is one row holding NULL, not zero rows, so this
|
||||
// scans into a NullInt64 — the same trap OldestPendingTelegram spells out.
|
||||
// A bare int64 turned the ordinary "nothing sent yet" case into a scan
|
||||
// error, and RepeatUnacked aborts its whole sweep on one, so the first
|
||||
// repeat could never go out for any rule.
|
||||
var millis sql.NullInt64
|
||||
err := s.db.QueryRowContext(ctx,
|
||||
`SELECT MAX(sent_at) FROM ack_sends WHERE rule = ?`, key).Scan(&millis)
|
||||
if err != nil {
|
||||
return time.Time{}, fmt.Errorf("last sent %s: %w", key, err)
|
||||
}
|
||||
if millis == 0 {
|
||||
if !millis.Valid {
|
||||
return time.Time{}, nil
|
||||
}
|
||||
return time.UnixMilli(millis).UTC(), nil
|
||||
return time.UnixMilli(millis.Int64).UTC(), nil
|
||||
}
|
||||
|
||||
// MarkAcked marks ALL pending telegram nudges for the rule as "acted" —
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestLastSentOnEmptyTableIsZero pins the aggregate-over-nothing trap that
|
||||
// nudges.go's OldestPendingTelegram already documents: MAX over an empty set is
|
||||
// one row holding NULL, not zero rows. Scanning that into a bare int64 is an
|
||||
// error, and the doc on LastSent promises a zero time instead.
|
||||
//
|
||||
// It is not a cosmetic promise. ack_sends is written only by MarkSent, and
|
||||
// MarkSent is called only after a repeat has already gone out, so the first
|
||||
// repeat for every rule reads an empty table. delivery.Dispatcher.RepeatUnacked
|
||||
// aborts the whole sweep on that error, which means the repeat-til-ack loop can
|
||||
// never take its first step for any rule.
|
||||
func TestLastSentOnEmptyTableIsZero(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
last, err := s.LastSent(ctx, "service_down")
|
||||
if err != nil {
|
||||
t.Fatalf("last sent on an empty table must not error: %v", err)
|
||||
}
|
||||
if !last.IsZero() {
|
||||
t.Fatalf("want the zero time before anything was sent, got %v", last)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLastSentIsScopedToItsRule — a send for another rule must not answer for
|
||||
// this one, or the repeat interval is clocked off somebody else's alarm.
|
||||
func TestLastSentIsScopedToItsRule(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
at := time.UnixMilli(1_700_000_000_000).UTC()
|
||||
|
||||
if err := s.MarkSent(ctx, "other_rule", at); err != nil {
|
||||
t.Fatalf("mark sent: %v", err)
|
||||
}
|
||||
last, err := s.LastSent(ctx, "service_down")
|
||||
if err != nil {
|
||||
t.Fatalf("last sent: %v", err)
|
||||
}
|
||||
if !last.IsZero() {
|
||||
t.Fatalf("want zero for a rule with no sends, got %v", last)
|
||||
}
|
||||
|
||||
if err := s.MarkSent(ctx, "service_down", at); err != nil {
|
||||
t.Fatalf("mark sent: %v", err)
|
||||
}
|
||||
last, err = s.LastSent(ctx, "service_down")
|
||||
if err != nil {
|
||||
t.Fatalf("last sent: %v", err)
|
||||
}
|
||||
if !last.Equal(at) {
|
||||
t.Fatalf("want %v, got %v", at, last)
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,9 @@ package store
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
@@ -44,19 +46,33 @@ func DigestBodyHash(rule, body string) string {
|
||||
}
|
||||
|
||||
// EnqueueDigestEntry durably records a suppressed care candidate worth
|
||||
// resurfacing later. If a pending entry with the same rule+body already
|
||||
// resurfacing later. If a LIVE pending entry with the same rule+body already
|
||||
// exists, this is a no-op that returns the existing id and deduped=true —
|
||||
// the same suppressed nudge repeating across ticks must not pile up into
|
||||
// several copies of itself in the eventual bundle.
|
||||
//
|
||||
// "Live" carries the same expiry test PendingDigestEntries reads with, and for
|
||||
// the same reason: a row past its expires_ts is still status='pending' until
|
||||
// the sweep gets to it, and the tick enqueues before it sweeps. Deduping
|
||||
// against one meant reporting deduped=true against an entry that will never be
|
||||
// spoken — the caller drops the phrasing it just paid the LLM for and nothing
|
||||
// reaches the bundle. Not yet swept must not mean still deliverable on the
|
||||
// write side either.
|
||||
func (s *Store) EnqueueDigestEntry(ctx context.Context, rule string, severity int, body string, now, expiresAt time.Time) (id int64, deduped bool, err error) {
|
||||
hash := DigestBodyHash(rule, body)
|
||||
var existing int64
|
||||
err = s.db.QueryRowContext(ctx,
|
||||
`SELECT id FROM digest_entries WHERE status = ? AND rule = ? AND body_hash = ? LIMIT 1`,
|
||||
DigestPending, rule, hash).Scan(&existing)
|
||||
`SELECT id FROM digest_entries
|
||||
WHERE status = ? AND rule = ? AND body_hash = ? AND expires_ts > ? LIMIT 1`,
|
||||
DigestPending, rule, hash, now.UnixMilli()).Scan(&existing)
|
||||
if err == nil {
|
||||
return existing, true, nil
|
||||
}
|
||||
if !errors.Is(err, sql.ErrNoRows) {
|
||||
// A real read failure is not "nothing there". Inserting anyway would
|
||||
// duplicate an entry whose existence we never established.
|
||||
return 0, false, fmt.Errorf("enqueue digest entry: dedupe lookup: %w", err)
|
||||
}
|
||||
|
||||
res, err := s.db.ExecContext(ctx,
|
||||
`INSERT INTO digest_entries (rule, severity, body, body_hash, status, created_ts, expires_ts)
|
||||
|
||||
@@ -200,3 +200,46 @@ func TestDigestEntryDrainMarksDrainedNotDeleted(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestDigestEnqueueDoesNotDedupeAgainstAnExpiredEntry — an entry past its
|
||||
// expires_ts is still status='pending' until the sweep runs, and the tick
|
||||
// enqueues before it sweeps. Deduping against one reports deduped=true for a
|
||||
// row PendingDigestEntries will never hand back, so the suppressed nudge is
|
||||
// dropped instead of held: the entry says the thing was recorded when nothing
|
||||
// was.
|
||||
func TestDigestEnqueueDoesNotDedupeAgainstAnExpiredEntry(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
created := time.Now()
|
||||
expiresAt := created.Add(time.Hour)
|
||||
|
||||
first, deduped, err := s.EnqueueDigestEntry(ctx, "break", 2, "ты долго не отдыхала", created, expiresAt)
|
||||
if err != nil {
|
||||
t.Fatalf("enqueue: %v", err)
|
||||
}
|
||||
if deduped {
|
||||
t.Fatal("first enqueue must not report deduped")
|
||||
}
|
||||
|
||||
// A tick after the expiry, with the sweep not yet run: the same suppressed
|
||||
// nudge comes round again and must be recorded afresh.
|
||||
after := expiresAt.Add(time.Minute)
|
||||
second, deduped, err := s.EnqueueDigestEntry(ctx, "break", 2, "ты долго не отдыхала", after, after.Add(time.Hour))
|
||||
if err != nil {
|
||||
t.Fatalf("re-enqueue: %v", err)
|
||||
}
|
||||
if deduped {
|
||||
t.Fatal("an expired entry must not swallow a fresh one")
|
||||
}
|
||||
if second == first {
|
||||
t.Fatalf("want a new row, got the expired one back: id=%d", second)
|
||||
}
|
||||
|
||||
entries, err := s.PendingDigestEntries(ctx, after)
|
||||
if err != nil {
|
||||
t.Fatalf("pending: %v", err)
|
||||
}
|
||||
if len(entries) != 1 || entries[0].ID != second {
|
||||
t.Fatalf("want the fresh entry %d pending, got %+v", second, entries)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -205,8 +205,30 @@ func (s *Store) ListReminders(ctx context.Context, n int) ([]Reminder, error) {
|
||||
|
||||
// RescheduleReminder computes the next fire time for a recurring reminder and
|
||||
// updates next_fire_ts. Returns ErrReminderState if the reminder is not
|
||||
// recurring or not pending. If no more valid fire times exist, marks it fired.
|
||||
// recurring or not pending. If the schedule yields no further fire time at all,
|
||||
// marks it fired.
|
||||
//
|
||||
// Two things the first version got wrong, both fixed 06-08-2026 (V-616).
|
||||
//
|
||||
// The cron expression is a WALL CLOCK statement — "0 9 * * *" is nine in the
|
||||
// morning where the owner stands — but scanReminder hands back instants in UTC,
|
||||
// and robfig's Next walks the calendar in the location of the time it is given.
|
||||
// Computing from a UTC instant therefore produced the next 09:00 UTC, so the
|
||||
// second occurrence of a daily reminder landed one UTC offset late and stayed
|
||||
// there: 12:00 for a Moscow owner. Everything is converted to loc first, which
|
||||
// also makes the walk DST-correct — the schedule keeps its wall-clock hour
|
||||
// across a changeover instead of drifting an hour with the offset.
|
||||
//
|
||||
// And a missed occurrence used to KILL the reminder: any next fire earlier than
|
||||
// now marked it fired, so a daemon down overnight ended a daily standup forever.
|
||||
// Occurrences in the past are skipped instead, so the reminder rolls forward to
|
||||
// the first one strictly after now. Skipping and not replaying is deliberate:
|
||||
// the same no-backlog rule routine.DueAccepted follows.
|
||||
func (s *Store) RescheduleReminder(ctx context.Context, id int64, now time.Time) error {
|
||||
return s.rescheduleReminderIn(ctx, id, now, time.Local)
|
||||
}
|
||||
|
||||
func (s *Store) rescheduleReminderIn(ctx context.Context, id int64, now time.Time, loc *time.Location) error {
|
||||
row := s.db.QueryRowContext(ctx, `
|
||||
SELECT id, created_ts, fire_ts, next_fire_ts, payload, status, cron
|
||||
FROM reminders WHERE id = ?`, id)
|
||||
@@ -225,8 +247,14 @@ func (s *Store) RescheduleReminder(ctx context.Context, id int64, now time.Time)
|
||||
if err != nil {
|
||||
return fmt.Errorf("parse cron %q: %w", r.Cron, err)
|
||||
}
|
||||
next := sched.Next(r.NextFireTs.Add(time.Minute))
|
||||
if next.IsZero() || next.Before(now) {
|
||||
// Next is strictly after the time it is given, so the last fire cannot be
|
||||
// returned again and no fudge minute is needed. The bound stops a schedule
|
||||
// that somehow yields a non-advancing time from spinning here.
|
||||
next := sched.Next(r.NextFireTs.In(loc))
|
||||
for i := 0; i < 4096 && !next.IsZero() && !next.After(now); i++ {
|
||||
next = sched.Next(next)
|
||||
}
|
||||
if next.IsZero() || !next.After(now) {
|
||||
_, err = s.db.ExecContext(ctx, "UPDATE reminders SET status = 'fired' WHERE id = ?", id)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// A recurring reminder keeps its wall-clock hour in the owner's zone. The cron
|
||||
// walk used to run on the UTC instant scanReminder returns, so "0 9 * * *"
|
||||
// created for 09:00 Moscow rescheduled to 09:00 UTC — noon, and noon every day
|
||||
// after that (V-616).
|
||||
func TestRescheduleKeepsWallClockHour(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
msk := time.FixedZone("MSK", 3*60*60)
|
||||
|
||||
fire := time.Date(2026, 7, 1, 9, 0, 0, 0, msk)
|
||||
id, err := s.CreateReminder(ctx, fire, `{"text":"стендап"}`, "0 9 * * *")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.rescheduleReminderIn(ctx, id, fire, msk); err != nil {
|
||||
t.Fatalf("reschedule: %v", err)
|
||||
}
|
||||
|
||||
want := time.Date(2026, 7, 2, 9, 0, 0, 0, msk)
|
||||
got := nextFire(t, s, id)
|
||||
if !got.Equal(want) {
|
||||
t.Fatalf("next fire: want %s, got %s", want, got.In(msk))
|
||||
}
|
||||
}
|
||||
|
||||
// A daily reminder survives a daemon that was down for days. The past
|
||||
// occurrences are skipped, not replayed, and the reminder stays pending.
|
||||
func TestRescheduleRollsForwardAfterDowntime(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
msk := time.FixedZone("MSK", 3*60*60)
|
||||
|
||||
fire := time.Date(2026, 7, 1, 9, 0, 0, 0, msk)
|
||||
id, err := s.CreateReminder(ctx, fire, `{"text":"стендап"}`, "0 9 * * *")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// The box comes back three days later, mid-afternoon.
|
||||
now := time.Date(2026, 7, 4, 15, 0, 0, 0, msk)
|
||||
if err := s.rescheduleReminderIn(ctx, id, now, msk); err != nil {
|
||||
t.Fatalf("reschedule: %v", err)
|
||||
}
|
||||
|
||||
rs, err := s.ListReminders(ctx, 10)
|
||||
if err != nil || len(rs) != 1 {
|
||||
t.Fatalf("list: %d reminders, %v", len(rs), err)
|
||||
}
|
||||
if rs[0].Status != ReminderPending {
|
||||
t.Fatalf("status: want %s, got %s — downtime killed the recurrence", ReminderPending, rs[0].Status)
|
||||
}
|
||||
want := time.Date(2026, 7, 5, 9, 0, 0, 0, msk)
|
||||
if got := nextFire(t, s, id); !got.Equal(want) {
|
||||
t.Fatalf("next fire: want %s, got %s", want, got.In(msk))
|
||||
}
|
||||
// And exactly one fire is owed, not a backlog of four.
|
||||
due, err := s.DueReminders(ctx, want.Add(time.Second))
|
||||
if err != nil || len(due) != 1 {
|
||||
t.Fatalf("due after roll-forward: want 1, got %d (%v)", len(due), err)
|
||||
}
|
||||
}
|
||||
|
||||
// A daily 03:00 reminder does not drift across the spring-forward changeover:
|
||||
// the hour is wall clock, so the interval is 23 hours that day, not 24.
|
||||
func TestRescheduleAcrossDSTKeepsHour(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
berlin, err := time.LoadLocation("Europe/Berlin")
|
||||
if err != nil {
|
||||
t.Skipf("tzdata unavailable: %v", err)
|
||||
}
|
||||
|
||||
fire := time.Date(2027, 3, 27, 3, 0, 0, 0, berlin) // CET, day before the change
|
||||
id, err := s.CreateReminder(ctx, fire, `{"text":"бэкап"}`, "0 3 * * *")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.rescheduleReminderIn(ctx, id, fire, berlin); err != nil {
|
||||
t.Fatalf("reschedule: %v", err)
|
||||
}
|
||||
got := nextFire(t, s, id).In(berlin)
|
||||
if got.Hour() != 3 || got.Day() != 28 {
|
||||
t.Fatalf("next fire: want 2027-03-28 03:00 local, got %s", got)
|
||||
}
|
||||
if d := got.Sub(fire); d != 23*time.Hour {
|
||||
t.Fatalf("gap across spring forward: want 23h, got %s", d)
|
||||
}
|
||||
}
|
||||
|
||||
func nextFire(t *testing.T, s *Store, id int64) time.Time {
|
||||
t.Helper()
|
||||
var ms int64
|
||||
if err := s.db.QueryRow("SELECT next_fire_ts FROM reminders WHERE id = ?", id).Scan(&ms); err != nil {
|
||||
t.Fatalf("read next_fire_ts: %v", err)
|
||||
}
|
||||
return time.UnixMilli(ms).UTC()
|
||||
}
|
||||
Reference in New Issue
Block a user