/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>
|
<div class=scroll><table class=mono>
|
||||||
<tr><th>noticed<th>happened<th>source<th>kind<th>pri<th>what<th>detail</tr>
|
<tr><th>noticed<th>happened<th>source<th>kind<th>pri<th>what<th>detail</tr>
|
||||||
{{range .Events}}<tr>
|
{{range .Events}}<tr>
|
||||||
<td>{{.NoticedAt.Format "02.01 15:04:05"}}</td>
|
<!-- Both columns in his clock (V-469 on /reminders, same rule here). NoticedAt
|
||||||
<td class=gray>{{.OccurredAt.Format "02.01 15:04:05"}}</td>
|
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>{{.Source}}</td>
|
||||||
<td class=gray>{{.Kind}}</td>
|
<td class=gray>{{.Kind}}</td>
|
||||||
<td class=gray>{{.Priority}}</td>
|
<td class=gray>{{.Priority}}</td>
|
||||||
|
|||||||
@@ -46,7 +46,8 @@ func TestEventsPageRendersTheJournal(t *testing.T) {
|
|||||||
t.Fatalf("status = %d, want 200", w.Code)
|
t.Fatalf("status = %d, want 200", w.Code)
|
||||||
}
|
}
|
||||||
body := w.Body.String()
|
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) {
|
if !strings.Contains(body, want) {
|
||||||
t.Errorf("page does not mention %q", 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) {
|
func TestEventsPageEscapesIntakeText(t *testing.T) {
|
||||||
// Titles come from outside — a feed headline, a notification. They are shown
|
// 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.
|
// on a page and must never be able to inject markup into it.
|
||||||
|
|||||||
@@ -8,7 +8,10 @@
|
|||||||
<div class=scroll><table class=mono>
|
<div class=scroll><table class=mono>
|
||||||
<tr><th>at<th>kind<th>what</tr>
|
<tr><th>at<th>kind<th>what</tr>
|
||||||
{{range .Items}}<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 class=gray>{{.Kind}}</td>
|
||||||
<td>{{if .Uncertain}}<span class=hint title="relayed notification, not a calendar read">похоже,</span> {{end}}{{.Text}}</td>
|
<td>{{if .Uncertain}}<span class=hint title="relayed notification, not a calendar read">похоже,</span> {{end}}{{.Text}}</td>
|
||||||
</tr>{{end}}
|
</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 {
|
if err := core.SetTaskFields(ctx, id, doneWhen, blockedOn); err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
if due != nil {
|
wgt, err := formWeight(r)
|
||||||
wgt, err := formWeight(r)
|
if err != nil {
|
||||||
if err != nil {
|
return "", err
|
||||||
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 {
|
if err := core.EditTask(ctx, id, text, due, wgt); err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,6 +32,31 @@ type fakeTaskCore struct {
|
|||||||
statusErr error
|
statusErr error
|
||||||
|
|
||||||
promoted bool
|
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) {
|
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.
|
// Out of range clamps rather than 400s; a non-number is a real client error.
|
||||||
func TestApplyTaskPostClampsWeight(t *testing.T) {
|
func TestApplyTaskPostClampsWeight(t *testing.T) {
|
||||||
core := &fakeTaskCore{created: true}
|
core := &fakeTaskCore{created: true}
|
||||||
|
|||||||
Reference in New Issue
Block a user