/events and /morning read the clock on his wall (V-612)
Three defects on the server-rendered pages. /events printed both timestamps in whatever zone the value arrived in. NoticedAt is the bus's local instant; OccurredAt is the store's UTC, or a pubDate internal/rss parsed to UTC. So one row carried two zones and a feed item read hours older than it was, on a page whose hint tells him that column gap is real. /morning printed a plan item's At raw. It is a calendar fact's Ts or a reminder's FireTs, both UTC out of the store, so the same reminder named a different hour here than on /reminders — which does call Local, since V-469. promoteCandidate read the importance select inside `if due != nil`. Confirming a candidate as "срочно" with no deadline threw the word away and the row came back normal with nothing saying why. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user