Merge branch 'fix/g04' into fix/integrated
# Conflicts: # cmd/mavend/actions_query.go # cmd/mavend/dayplan_test.go
This commit is contained in:
+25
-13
@@ -66,7 +66,7 @@ func run(args []string) error {
|
||||
if *url == "" || *user == "" || *pass == "" {
|
||||
return fmt.Errorf("-url, -user, -pass are required")
|
||||
}
|
||||
if err := checkRenderTarget(*url, *renderURL); err != nil {
|
||||
if err := checkRenderTarget([]string{*url}, *renderURL); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -122,17 +122,26 @@ func run(args []string) error {
|
||||
}
|
||||
}
|
||||
|
||||
// checkRenderTarget refuses a render URL that is also a read URL. This is the
|
||||
// structural half of #127's "cannot write to your work calendar": the write
|
||||
// credential and the write URL are separate flags, and the one calendar maven
|
||||
// is known to only read is rejected as a target at startup rather than trusted
|
||||
// at runtime.
|
||||
func checkRenderTarget(readURL, renderURL string) error {
|
||||
// checkRenderTarget refuses a render URL that is also one of the read URLs.
|
||||
// This is the structural half of #127's "cannot write to your work calendar":
|
||||
// the write credential and the write URL are separate flags, and a calendar
|
||||
// maven is known to only read is rejected as a target at startup rather than
|
||||
// trusted at runtime.
|
||||
//
|
||||
// It takes the whole read set, not one URL. The guarantee in the package
|
||||
// comment is about every calendar maven reads, and a second read target added
|
||||
// later must not quietly fall outside the check.
|
||||
func checkRenderTarget(readURLs []string, renderURL string) error {
|
||||
if renderURL == "" {
|
||||
return nil
|
||||
}
|
||||
if sameCollection(readURL, renderURL) {
|
||||
return fmt.Errorf("-render-url must differ from -url: maven renders into a calendar she owns, never into one she reads")
|
||||
for _, read := range readURLs {
|
||||
if read == "" {
|
||||
continue
|
||||
}
|
||||
if sameCollection(read, renderURL) {
|
||||
return fmt.Errorf("-render-url must differ from the read URL %s: maven renders into a calendar she owns, never into one she reads", read)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -163,7 +172,7 @@ func (p *poller) pollOnce(ctx context.Context) {
|
||||
}
|
||||
|
||||
// Write calendar_busy on change.
|
||||
if err := p.writeIfChanged(ctx, "calendar_busy", calendar.SourcePersonal, busyVal, now, 1.0); err != nil {
|
||||
if err := p.writeIfChanged(ctx, "calendar_busy", calendar.SourcePersonal, busyVal, now); err != nil {
|
||||
log.Printf("mavcaldav: write calendar_busy: %v", err)
|
||||
return
|
||||
}
|
||||
@@ -173,7 +182,7 @@ func (p *poller) pollOnce(ctx context.Context) {
|
||||
// reaching back to Radicale.
|
||||
for _, e := range events {
|
||||
key := calendar.FactKey(e)
|
||||
if err := p.writeIfChanged(ctx, key, calendar.SourcePersonal, calendar.FactValue(e), e.Start, 1.0); err != nil {
|
||||
if err := p.writeIfChanged(ctx, key, calendar.SourcePersonal, calendar.FactValue(e), e.Start); err != nil {
|
||||
log.Printf("mavcaldav: write %s: %v", key, err)
|
||||
}
|
||||
}
|
||||
@@ -206,7 +215,10 @@ func (p *poller) fetchEvents(ctx context.Context, now time.Time) ([]calendar.Eve
|
||||
}
|
||||
|
||||
// writeIfChanged writes a fact only when the value differs from the latest.
|
||||
func (p *poller) writeIfChanged(ctx context.Context, key, source, val string, ts time.Time, confidence float64) error {
|
||||
// Everything this poller writes is a calendar read, which is full confidence by
|
||||
// definition; a source that is not, such as the notification relay, does not
|
||||
// come through here.
|
||||
func (p *poller) writeIfChanged(ctx context.Context, key, source, val string, ts time.Time) error {
|
||||
prev, err := p.core.LatestFactBySource(ctx, key, source)
|
||||
switch {
|
||||
case err == nil && prev.Value == val:
|
||||
@@ -220,7 +232,7 @@ func (p *poller) writeIfChanged(ctx context.Context, key, source, val string, ts
|
||||
Key: key,
|
||||
Value: val,
|
||||
Source: source,
|
||||
Confidence: confidence,
|
||||
Confidence: 1.0,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("write %s: %w", key, err)
|
||||
|
||||
@@ -62,7 +62,7 @@ func TestWriteIfChanged(t *testing.T) {
|
||||
t.Run("no previous fact writes", func(t *testing.T) {
|
||||
fc := &fakeCore{}
|
||||
p := &poller{core: fc}
|
||||
err := p.writeIfChanged(ctx, "test_key", "poll:caldav", "hello", now, 1.0)
|
||||
err := p.writeIfChanged(ctx, "test_key", "poll:caldav", "hello", now)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
@@ -90,7 +90,7 @@ func TestWriteIfChanged(t *testing.T) {
|
||||
},
|
||||
}
|
||||
p := &poller{core: fc}
|
||||
err := p.writeIfChanged(ctx, "test_key", "poll:caldav", "hello", now, 1.0)
|
||||
err := p.writeIfChanged(ctx, "test_key", "poll:caldav", "hello", now)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
@@ -106,7 +106,7 @@ func TestWriteIfChanged(t *testing.T) {
|
||||
},
|
||||
}
|
||||
p := &poller{core: fc}
|
||||
err := p.writeIfChanged(ctx, "test_key", "poll:caldav", "new", now, 1.0)
|
||||
err := p.writeIfChanged(ctx, "test_key", "poll:caldav", "new", now)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
@@ -121,7 +121,7 @@ func TestWriteIfChanged(t *testing.T) {
|
||||
t.Run("read error other than ErrNoFact returns error", func(t *testing.T) {
|
||||
fc := &fakeCore{readErr: fmt.Errorf("connection refused")}
|
||||
p := &poller{core: fc}
|
||||
err := p.writeIfChanged(ctx, "fail_key", "poll:caldav", "x", now, 1.0)
|
||||
err := p.writeIfChanged(ctx, "fail_key", "poll:caldav", "x", now)
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
@@ -133,7 +133,7 @@ func TestWriteIfChanged(t *testing.T) {
|
||||
writeErr: fmt.Errorf("disk full"),
|
||||
}
|
||||
p := &poller{core: fc}
|
||||
err := p.writeIfChanged(ctx, "test_key", "poll:caldav", "hello", now, 1.0)
|
||||
err := p.writeIfChanged(ctx, "test_key", "poll:caldav", "hello", now)
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
@@ -190,13 +190,15 @@ func TestPollOnce(t *testing.T) {
|
||||
t.Errorf("calendar_busy ts is zero")
|
||||
}
|
||||
|
||||
// Second write: calendar_event_<date>_<summary> = "<summary> @ HH:MM-HH:MM"
|
||||
// Second write: calendar_event_<date>_<summary> = "<summary> @ HH:MM-HH:MM".
|
||||
// The iCal states the event in UTC and the fact is stamped on the owner's
|
||||
// clock, so the expected key date and times are the local reading of it.
|
||||
eventReq := fc.writeLog[1]
|
||||
expectedKey := "calendar_event_" + start.Format("20060102") + "_Current-meeting"
|
||||
expectedKey := "calendar_event_" + start.Local().Format("20060102") + "_Current-meeting"
|
||||
if eventReq.Key != expectedKey {
|
||||
t.Errorf("event key = %q, want %q", eventReq.Key, expectedKey)
|
||||
}
|
||||
expectedVal := "Current meeting @ " + start.Format("15:04") + "-" + end.Format("15:04")
|
||||
expectedVal := "Current meeting @ " + start.Local().Format("15:04") + "-" + end.Local().Format("15:04")
|
||||
if eventReq.Value != expectedVal {
|
||||
t.Errorf("event value = %q, want %q", eventReq.Value, expectedVal)
|
||||
}
|
||||
|
||||
+80
-3
@@ -2,15 +2,18 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/calendar"
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
"github.com/kami/maven/internal/store"
|
||||
)
|
||||
|
||||
// renderer is the write half of maven's own local calendar (Vikunja #127).
|
||||
@@ -38,6 +41,13 @@ type renderer struct {
|
||||
// unchanged reminder costs nothing. Purely an optimisation: a restart
|
||||
// re-publishes every reminder once, which is idempotent.
|
||||
published map[int64]string
|
||||
|
||||
// reconciled — whether the collection has been read once since start. It
|
||||
// has to be, because published is in-memory: withdrawal used to cover only
|
||||
// the reminders THIS process published, so a reminder that fired while the
|
||||
// daemon was down kept its event in the calendar forever, and nothing ever
|
||||
// revisited it.
|
||||
reconciled bool
|
||||
}
|
||||
|
||||
func newRenderer(core ipc.CoreAPI, hc *http.Client, url, user, pass string, dur time.Duration) *renderer {
|
||||
@@ -64,7 +74,7 @@ func (r *renderer) renderOnce(ctx context.Context) {
|
||||
|
||||
live := make(map[int64]bool, len(reminders))
|
||||
for _, rem := range reminders {
|
||||
if rem.Status != "pending" {
|
||||
if rem.Status != store.ReminderPending {
|
||||
continue
|
||||
}
|
||||
live[rem.ID] = true
|
||||
@@ -81,10 +91,28 @@ func (r *renderer) renderOnce(ctx context.Context) {
|
||||
log.Printf("mavcaldav: rendered reminder %d (%s)", rem.ID, e.Summary)
|
||||
}
|
||||
|
||||
stale := make(map[int64]bool)
|
||||
for id := range r.published {
|
||||
if live[id] {
|
||||
continue
|
||||
if !live[id] {
|
||||
stale[id] = true
|
||||
}
|
||||
}
|
||||
if !r.reconciled {
|
||||
remote, err := r.listPublished(ctx)
|
||||
if err != nil {
|
||||
// Try again next tick. A collection maven cannot read is not a
|
||||
// reason to stop publishing to it.
|
||||
log.Printf("mavcaldav: reconcile: %v", err)
|
||||
} else {
|
||||
r.reconciled = true
|
||||
for _, id := range remote {
|
||||
if !live[id] {
|
||||
stale[id] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for id := range stale {
|
||||
if err := r.delete(ctx, calendar.ReminderPath(id)); err != nil {
|
||||
log.Printf("mavcaldav: withdraw reminder %d: %v", id, err)
|
||||
continue
|
||||
@@ -94,6 +122,55 @@ func (r *renderer) renderOnce(ctx context.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
// listPublished PROPFINDs the collection and returns the reminder ids maven has
|
||||
// events for in it. Only resources carrying calendar.ReminderUIDPrefix are
|
||||
// reported, so a reconciliation pass can never propose deleting a file maven
|
||||
// did not create — the same bound every other path in this file has.
|
||||
func (r *renderer) listPublished(ctx context.Context) ([]int64, error) {
|
||||
const body = `<?xml version="1.0" encoding="utf-8"?>` +
|
||||
`<D:propfind xmlns:D="DAV:"><D:prop><D:resourcetype/></D:prop></D:propfind>`
|
||||
req, err := http.NewRequestWithContext(ctx, "PROPFIND", r.url+"/", strings.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.SetBasicAuth(r.user, r.pass)
|
||||
req.Header.Set("Content-Type", "application/xml; charset=utf-8")
|
||||
req.Header.Set("Depth", "1")
|
||||
|
||||
resp, err := r.http.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
raw, err := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resp.StatusCode != http.StatusMultiStatus && (resp.StatusCode < 200 || resp.StatusCode >= 300) {
|
||||
return nil, fmt.Errorf("PROPFIND %s: %s", r.url, resp.Status)
|
||||
}
|
||||
|
||||
var ms struct {
|
||||
Responses []struct {
|
||||
Href string `xml:"href"`
|
||||
} `xml:"response"`
|
||||
}
|
||||
if err := xml.Unmarshal(raw, &ms); err != nil {
|
||||
return nil, fmt.Errorf("PROPFIND %s: %w", r.url, err)
|
||||
}
|
||||
var ids []int64
|
||||
for _, resp := range ms.Responses {
|
||||
href, err := url.PathUnescape(strings.TrimSpace(resp.Href))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if id, ok := calendar.ReminderIDFromPath(href); ok {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
}
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
// renderMaxReminders bounds the read. Reminders past this count are older than
|
||||
// anything a calendar view is useful for.
|
||||
const renderMaxReminders = 200
|
||||
|
||||
+125
-10
@@ -2,9 +2,11 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
@@ -27,12 +29,16 @@ func (c *reminderCore) ListReminders(context.Context, int) ([]ipc.Reminder, erro
|
||||
return c.reminders, nil
|
||||
}
|
||||
|
||||
// calSrv records what a CalDAV collection received.
|
||||
// calSrv records what a CalDAV collection received. existing seeds resources
|
||||
// that were already in the collection before this process started, which is
|
||||
// what a restart looks like from the renderer's side.
|
||||
type calSrv struct {
|
||||
mu sync.Mutex
|
||||
puts map[string]string
|
||||
dels []string
|
||||
status int
|
||||
mu sync.Mutex
|
||||
puts map[string]string
|
||||
dels []string
|
||||
existing []string
|
||||
propfind int
|
||||
status int
|
||||
*httptest.Server
|
||||
}
|
||||
|
||||
@@ -47,12 +53,43 @@ func newCalSrv() *calSrv {
|
||||
s.puts[strings.TrimPrefix(r.URL.Path, "/cal/")] = string(body)
|
||||
case http.MethodDelete:
|
||||
s.dels = append(s.dels, strings.TrimPrefix(r.URL.Path, "/cal/"))
|
||||
case "PROPFIND":
|
||||
s.propfind++
|
||||
w.Header().Set("Content-Type", "application/xml; charset=utf-8")
|
||||
w.WriteHeader(http.StatusMultiStatus)
|
||||
io.WriteString(w, s.multistatusLocked(r.URL.Path))
|
||||
return
|
||||
}
|
||||
w.WriteHeader(s.status)
|
||||
}))
|
||||
return s
|
||||
}
|
||||
|
||||
// multistatusLocked renders the collection listing. Caller holds the lock.
|
||||
func (s *calSrv) multistatusLocked(base string) string {
|
||||
var b strings.Builder
|
||||
b.WriteString(`<?xml version="1.0"?><D:multistatus xmlns:D="DAV:">`)
|
||||
b.WriteString("<D:response><D:href>" + base + "</D:href></D:response>")
|
||||
names := append([]string{}, s.existing...)
|
||||
for name := range s.puts {
|
||||
names = append(names, name)
|
||||
}
|
||||
for _, name := range names {
|
||||
if slices.Contains(s.dels, name) {
|
||||
continue
|
||||
}
|
||||
b.WriteString("<D:response><D:href>/cal/" + name + "</D:href></D:response>")
|
||||
}
|
||||
b.WriteString("</D:multistatus>")
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func (s *calSrv) deleted() []string {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return append([]string{}, s.dels...)
|
||||
}
|
||||
|
||||
func (s *calSrv) putCount() int {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
@@ -168,19 +205,97 @@ func TestRenderOnceUsesNextFireForRecurring(t *testing.T) {
|
||||
|
||||
func TestCheckRenderTargetRefusesTheCalendarItReads(t *testing.T) {
|
||||
read := "http://localhost:5232/kami/personal"
|
||||
if err := checkRenderTarget(read, ""); err != nil {
|
||||
if err := checkRenderTarget([]string{read}, ""); err != nil {
|
||||
t.Fatalf("rendering off must be fine: %v", err)
|
||||
}
|
||||
if err := checkRenderTarget(read, "http://localhost:5232/kami/maven"); err != nil {
|
||||
if err := checkRenderTarget([]string{read}, "http://localhost:5232/kami/maven"); err != nil {
|
||||
t.Fatalf("a distinct collection must be accepted: %v", err)
|
||||
}
|
||||
if err := checkRenderTarget(read, read); err == nil {
|
||||
if err := checkRenderTarget([]string{read}, read); err == nil {
|
||||
t.Error("rendering into the read calendar must be refused")
|
||||
}
|
||||
if err := checkRenderTarget(read, read+"/"); err == nil {
|
||||
if err := checkRenderTarget([]string{read}, read+"/"); err == nil {
|
||||
t.Error("a trailing slash must not defeat the check")
|
||||
}
|
||||
if err := checkRenderTarget(read, strings.ToUpper(read)); err == nil {
|
||||
if err := checkRenderTarget([]string{read}, strings.ToUpper(read)); err == nil {
|
||||
t.Error("case must not defeat the check")
|
||||
}
|
||||
// Every read target is checked, not the first one. A second calendar to
|
||||
// read must not fall outside the guarantee just by being added later.
|
||||
work := "http://localhost:5232/kami/work"
|
||||
if err := checkRenderTarget([]string{read, work}, work); err == nil {
|
||||
t.Error("rendering into the second read calendar must be refused")
|
||||
}
|
||||
if err := checkRenderTarget([]string{read, work}, "http://localhost:5232/kami/maven"); err != nil {
|
||||
t.Fatalf("a collection maven owns must still be accepted: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Withdrawal has to survive a restart. published is in-memory, so a fresh
|
||||
// process knows nothing about the events an earlier one wrote: fire a reminder,
|
||||
// restart mavcaldav, and its event used to sit in the collection forever
|
||||
// because nothing ever revisited it. The first tick reads the collection and
|
||||
// reconciles what it finds against what is pending.
|
||||
func TestRenderOnceWithdrawsAfterRestart(t *testing.T) {
|
||||
srv := newCalSrv()
|
||||
defer srv.Close()
|
||||
// Left behind by a previous process: 4 is still pending, 5 has fired.
|
||||
// The third file is not maven's and must not be touched.
|
||||
srv.existing = []string{"maven-reminder-4.ics", "maven-reminder-5.ics", "dentist.ics"}
|
||||
|
||||
core := &reminderCore{reminders: []ipc.Reminder{
|
||||
{ID: 4, FireTs: time.Date(2026, 8, 1, 9, 0, 0, 0, time.UTC), Payload: "выпить воды", Status: "pending"},
|
||||
{ID: 5, FireTs: time.Date(2026, 8, 1, 8, 0, 0, 0, time.UTC), Payload: "уже прозвенело", Status: "fired"},
|
||||
}}
|
||||
r := newRenderer(core, srv.Client(), srv.URL+"/cal", "u", "p", 0)
|
||||
r.renderOnce(context.Background())
|
||||
|
||||
dels := srv.deleted()
|
||||
if len(dels) != 1 || dels[0] != "maven-reminder-5.ics" {
|
||||
t.Fatalf("deleted %v, want only the fired reminder's event", dels)
|
||||
}
|
||||
|
||||
// The collection is read once, not on every tick.
|
||||
r.renderOnce(context.Background())
|
||||
srv.mu.Lock()
|
||||
n := srv.propfind
|
||||
srv.mu.Unlock()
|
||||
if n != 1 {
|
||||
t.Errorf("PROPFIND ran %d times, want once per process", n)
|
||||
}
|
||||
}
|
||||
|
||||
// A collection maven cannot read is not a reason to stop publishing to it, and
|
||||
// the reconciliation must be retried rather than skipped for the process.
|
||||
func TestRenderOnceRetriesReconcile(t *testing.T) {
|
||||
srv := newCalSrv()
|
||||
defer srv.Close()
|
||||
srv.existing = []string{"maven-reminder-6.ics"}
|
||||
failing := &http.Client{Transport: &propfindFailure{base: srv.Client().Transport}}
|
||||
|
||||
core := &reminderCore{}
|
||||
r := newRenderer(core, failing, srv.URL+"/cal", "u", "p", 0)
|
||||
r.renderOnce(context.Background())
|
||||
if got := srv.deleted(); len(got) != 0 {
|
||||
t.Fatalf("nothing can be withdrawn on a failed read: %v", got)
|
||||
}
|
||||
if r.reconciled {
|
||||
t.Fatal("a failed read must not count as reconciled")
|
||||
}
|
||||
|
||||
r.http = srv.Client()
|
||||
r.renderOnce(context.Background())
|
||||
if got := srv.deleted(); len(got) != 1 || got[0] != "maven-reminder-6.ics" {
|
||||
t.Fatalf("deleted %v, want the orphaned event on the retry", got)
|
||||
}
|
||||
}
|
||||
|
||||
// propfindFailure fails PROPFIND and passes everything else through.
|
||||
type propfindFailure struct{ base http.RoundTripper }
|
||||
|
||||
func (f *propfindFailure) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
if req.Method == "PROPFIND" {
|
||||
return nil, errors.New("collection unreachable")
|
||||
}
|
||||
return f.base.RoundTrip(req)
|
||||
}
|
||||
|
||||
@@ -140,11 +140,16 @@ func (h *reactiveHandler) queryFactByKey(ctx context.Context, t *queryTurn) (str
|
||||
|
||||
// queryDayPlan — "какие планы на сегодня?", "что у меня по плану?", "что
|
||||
// дальше?" (Vikunja #128). Recites the day: calendar events, pending
|
||||
// reminders, and any morning checklist still outstanding.
|
||||
// reminders, and every morning checklist item today still has no evidence for,
|
||||
// including the ones whose window has closed.
|
||||
//
|
||||
// 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.
|
||||
//
|
||||
// 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").
|
||||
// The spoken recital here is the current answer, not the decided one.
|
||||
func (h *reactiveHandler) queryDayPlan(ctx context.Context, t *queryTurn) (string, bool) {
|
||||
if !router.IsDayPlanQuery(t.dec.Utterance) {
|
||||
return "", false
|
||||
@@ -154,7 +159,7 @@ func (h *reactiveHandler) queryDayPlan(ctx context.Context, t *queryTurn) (strin
|
||||
log.Printf("voice: day plan: %v", err)
|
||||
return "не получилось собрать план.", true
|
||||
}
|
||||
if !isRestOfDayQuery(t.dec.Utterance) {
|
||||
if !router.IsRestOfDayQuery(t.dec.Utterance) {
|
||||
return plan.Spoken, true
|
||||
}
|
||||
// Rebuild the pure plan so the rest-of-day rendering is the same code that
|
||||
@@ -171,13 +176,6 @@ func (h *reactiveHandler) queryDayPlan(ctx context.Context, t *queryTurn) (strin
|
||||
return p.After(h.now()).FormatRU(), true
|
||||
}
|
||||
|
||||
// isRestOfDayQuery — "что дальше?" and its English form, the only plan phrasing
|
||||
// that means "from now on" rather than "the whole day".
|
||||
func isRestOfDayQuery(text string) bool {
|
||||
s := strings.ToLower(text)
|
||||
return strings.Contains(s, "дальше") || strings.Contains(s, "next")
|
||||
}
|
||||
|
||||
// habitFactWindow — how many recent SELF facts the behaviour profile is counted
|
||||
// over. Enough for a season of habits without scanning the whole store on every
|
||||
// question; the profile is recomputed on read, so the bound is the cost control.
|
||||
|
||||
@@ -2,11 +2,13 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/calendar"
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
"github.com/kami/maven/internal/router"
|
||||
"github.com/kami/maven/internal/store"
|
||||
@@ -86,12 +88,40 @@ func TestQueryDayPlanTrimsToRestOfDay(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// "что дальше?" after the last item of the day. The day was not empty, it is
|
||||
// over, and the whole-day empty line says something false about a day he just
|
||||
// lived through.
|
||||
func TestQueryDayPlanRestOfDayWhenNothingIsLeft(t *testing.T) {
|
||||
plan := samplePlan()
|
||||
h := &reactiveHandler{api: &planAPI{plan: plan}, now: func() time.Time {
|
||||
return time.Date(2026, 8, 3, 23, 0, 0, 0, time.UTC)
|
||||
}}
|
||||
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 strings.Contains(reply, plan.Date.Format("02.01.2006")) {
|
||||
t.Errorf("the day had things on it and they are done, not empty: %q", reply)
|
||||
}
|
||||
if reply != "на сегодня больше ничего не запланировано." {
|
||||
t.Errorf("reply = %q", reply)
|
||||
}
|
||||
}
|
||||
|
||||
// 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) {
|
||||
for _, q := range []string{
|
||||
"что у меня сегодня?",
|
||||
"какие планы на завтра?",
|
||||
// The plan can only be built for the clock's own day. Naming another
|
||||
// one has to fall through, not get answered with today.
|
||||
"какие планы на понедельник?",
|
||||
"какие планы на неделю?",
|
||||
"какие планы на выходные?",
|
||||
"what are my plans for friday?",
|
||||
"когда планёрка?",
|
||||
"какая погода?",
|
||||
"",
|
||||
@@ -267,3 +297,57 @@ func TestHabitQueryWithPlanWordReachesHabits(t *testing.T) {
|
||||
t.Errorf("reply = %q, want %q", reply, want)
|
||||
}
|
||||
}
|
||||
|
||||
// The plan reads the store on the owner's clock: one line per event, the hour
|
||||
// printed once, and reminders selected by fire time rather than by how
|
||||
// recently they were stated.
|
||||
func TestTickDayPlanReadsTheStore(t *testing.T) {
|
||||
st := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
tl := newTestTickLoop(t, st, &fakeSink{}, nil)
|
||||
|
||||
now := time.Date(2026, 8, 3, 12, 0, 0, 0, time.Local)
|
||||
day := time.Date(2026, 8, 3, 0, 0, 0, 0, time.Local)
|
||||
ev := calendar.Event{
|
||||
Summary: "Standup",
|
||||
Start: day.Add(14 * time.Hour),
|
||||
End: day.Add(14*time.Hour + 30*time.Minute),
|
||||
}
|
||||
// Rescheduled: same key, a second row.
|
||||
if _, err := st.WriteFact(ctx, ev.Start, store.KindEnv, calendar.FactKey(ev),
|
||||
calendar.FactValue(ev), calendar.SourcePersonal, 1.0, sql.NullInt64{}); err != nil {
|
||||
t.Fatalf("WriteFact: %v", err)
|
||||
}
|
||||
moved := ev
|
||||
moved.Start, moved.End = day.Add(16*time.Hour), day.Add(16*time.Hour+30*time.Minute)
|
||||
if _, err := st.WriteFact(ctx, moved.Start, store.KindEnv, calendar.FactKey(moved),
|
||||
calendar.FactValue(moved), calendar.SourcePersonal, 1.0, sql.NullInt64{}); err != nil {
|
||||
t.Fatalf("WriteFact: %v", err)
|
||||
}
|
||||
// One reminder today, one next year. Both are pending; only today's is a
|
||||
// plan for today.
|
||||
if _, err := st.CreateReminder(ctx, day.Add(18*time.Hour), "позвонить маме", ""); err != nil {
|
||||
t.Fatalf("CreateReminder: %v", err)
|
||||
}
|
||||
if _, err := st.CreateReminder(ctx, day.AddDate(1, 0, 0), "продлить страховку", ""); err != nil {
|
||||
t.Fatalf("CreateReminder: %v", err)
|
||||
}
|
||||
|
||||
plan := tl.dayPlan(ctx, now)
|
||||
if len(plan.Items) != 2 {
|
||||
t.Fatalf("got %d items, want the moved standup and today's reminder: %+v", len(plan.Items), plan.Items)
|
||||
}
|
||||
ev0 := plan.Items[0]
|
||||
if ev0.Kind != "event" || ev0.At.In(time.Local).Format("15:04") != "16:00" {
|
||||
t.Errorf("event = %+v, want the 16:00 one", ev0)
|
||||
}
|
||||
if ev0.Text != "Standup" {
|
||||
t.Errorf("text = %q — the plan prints the hour itself", ev0.Text)
|
||||
}
|
||||
if plan.Items[1].Text != "позвонить маме" {
|
||||
t.Errorf("second item = %+v", plan.Items[1])
|
||||
}
|
||||
if strings.Contains(plan.Spoken, "страховку") {
|
||||
t.Errorf("a reminder for next year is not today's plan: %q", plan.Spoken)
|
||||
}
|
||||
}
|
||||
|
||||
+8
-9
@@ -19,6 +19,7 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/calendar"
|
||||
"github.com/kami/maven/internal/config"
|
||||
"github.com/kami/maven/internal/delivery"
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
@@ -825,8 +826,10 @@ func (t *tickLoop) dayPlan(ctx context.Context, now time.Time) ipc.DayPlan {
|
||||
}
|
||||
for _, f := range facts {
|
||||
events = append(events, morning.PlanEntry{
|
||||
At: f.Ts,
|
||||
Text: f.Value,
|
||||
At: f.Ts,
|
||||
// The plan prints the hour itself, so the "@ 14:00-14:30" tail the
|
||||
// fact value carries would say it twice.
|
||||
Text: calendar.FactSummary(f.Value),
|
||||
Kind: morning.PlanEvent,
|
||||
// Provenance below a calendar read (an ambient relay, #126) is
|
||||
// hedged rather than recited as fact.
|
||||
@@ -835,12 +838,12 @@ func (t *tickLoop) dayPlan(ctx context.Context, now time.Time) ipc.DayPlan {
|
||||
}
|
||||
|
||||
var reminders []morning.PlanEntry
|
||||
rems, err := t.store.ListReminders(ctx, dayPlanMaxReminders)
|
||||
rems, err := t.store.PendingReminders(ctx, dayStart, dayEnd)
|
||||
if err != nil {
|
||||
log.Printf("tick: day plan: list reminders: %v", err)
|
||||
log.Printf("tick: day plan: pending reminders: %v", err)
|
||||
}
|
||||
for _, r := range rems {
|
||||
if r.Status != "pending" {
|
||||
if r.Status != store.ReminderPending {
|
||||
continue
|
||||
}
|
||||
fire := r.NextFireTs
|
||||
@@ -873,10 +876,6 @@ func (t *tickLoop) dayPlan(ctx context.Context, now time.Time) ipc.DayPlan {
|
||||
return out
|
||||
}
|
||||
|
||||
// dayPlanMaxReminders bounds the reminder scan. The plan covers one day; a
|
||||
// pending queue longer than this is a bug elsewhere, not a plan to recite.
|
||||
const dayPlanMaxReminders = 500
|
||||
|
||||
// tune — the feedback auto-tuner's impure step. runs on a slow cadence
|
||||
// (autotuneInterval, see run) so it doesn't write a fact every tick. for each
|
||||
// rule:
|
||||
|
||||
@@ -55,7 +55,10 @@ func meetingNotification() calendar.Notification {
|
||||
Package: "com.google.android.gm",
|
||||
Title: "Планёрка",
|
||||
Text: "10:00-10:30",
|
||||
Posted: time.Date(2026, 8, 3, 9, 40, 0, 0, time.UTC),
|
||||
// Local, like a phone relaying from the box's own timezone: the fact
|
||||
// key and value are stamped on the owner's clock, so a UTC reading
|
||||
// here would only be testing the offset of the test machine.
|
||||
Posted: time.Date(2026, 8, 3, 9, 40, 0, 0, time.Local),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user