tasks: compare due dates in the caller's day, not in UTC
dayDelta truncated both instants to a UTC day. A task due at 02:00 Moscow time tonight read as due tomorrow, and one due at 23:00 last night read as due today, so the two classes that decide the whole order were assigned from the wrong calendar. Both sides are now truncated in now's location. Dated work also lost to age alone because the later-due score sat below the age cap, and the tail said "и ещё 3" with no noun and no Russian plural agreement. The page hardcoded time.Now, so none of this was testable from a fixed clock. It now takes an injectable clock, parses the due date in that clock's location, parses ids and weights with strconv instead of a hand-rolled scan, caps the resolved table and says so, shows who resolved each row, and reports a promotion as the confirmation it is. Found in review of #61.
This commit is contained in:
+119
-3
@@ -2,6 +2,7 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
@@ -27,7 +28,10 @@ type fakeTaskCore struct {
|
||||
|
||||
statusID int64
|
||||
statusVal string
|
||||
statusBy string
|
||||
statusErr error
|
||||
|
||||
promoted bool
|
||||
}
|
||||
|
||||
func (f *fakeTaskCore) ListTasks(_ context.Context, status string) ([]ipc.Task, error) {
|
||||
@@ -42,11 +46,11 @@ func (f *fakeTaskCore) CaptureTask(_ context.Context, req ipc.CaptureTaskReq) (i
|
||||
if f.captureErr != nil {
|
||||
return ipc.CaptureTaskResp{}, f.captureErr
|
||||
}
|
||||
return ipc.CaptureTaskResp{ID: 7, Created: f.created}, nil
|
||||
return ipc.CaptureTaskResp{ID: 7, Created: f.created, Promoted: f.promoted}, nil
|
||||
}
|
||||
|
||||
func (f *fakeTaskCore) SetTaskStatus(_ context.Context, id int64, status string, _ time.Time) error {
|
||||
f.statusID, f.statusVal = id, status
|
||||
func (f *fakeTaskCore) SetTaskStatus(_ context.Context, id int64, status string, _ time.Time, by string) error {
|
||||
f.statusID, f.statusVal, f.statusBy = id, status, by
|
||||
return f.statusErr
|
||||
}
|
||||
|
||||
@@ -223,3 +227,115 @@ func TestApplyTaskPostClampsWeight(t *testing.T) {
|
||||
t.Errorf("weight = %d, want the cap", core.captured[0].Weight)
|
||||
}
|
||||
}
|
||||
|
||||
// The page ranked with the wall clock while the daemon path ranked with a clock
|
||||
// it was handed, so this was the one surface that could only be tested at
|
||||
// whatever time it happened to run.
|
||||
func TestHandleTasksRanksAtTheInjectedClock(t *testing.T) {
|
||||
fixed := time.Date(2026, 8, 1, 10, 0, 0, 0, time.FixedZone("UTC+4", 4*3600))
|
||||
old := now
|
||||
now = func() time.Time { return fixed }
|
||||
t.Cleanup(func() { now = old })
|
||||
|
||||
// Due tomorrow, local time, stored the way the store hands it back: UTC.
|
||||
due := time.Date(2026, 8, 2, 0, 0, 0, 0, fixed.Location()).UTC()
|
||||
core := &fakeTaskCore{tasks: []ipc.Task{
|
||||
{ID: 1, Text: "оплатить интернет", Status: "open", CreatedTs: fixed, Due: &due},
|
||||
}}
|
||||
rec := httptest.NewRecorder()
|
||||
handleTasks(rec, httptest.NewRequest(http.MethodGet, "/tasks", nil), core)
|
||||
body := rec.Body.String()
|
||||
if !strings.Contains(body, "завтра") {
|
||||
t.Errorf("why column does not say завтра: %q", why(body))
|
||||
}
|
||||
if strings.Contains(body, "сегодня") || strings.Contains(body, "просрочено") {
|
||||
t.Error("a task due tomorrow was ranked as today's or overdue")
|
||||
}
|
||||
}
|
||||
|
||||
// why is a crude excerpt of the rendered why column, for a readable failure.
|
||||
func why(body string) string {
|
||||
i := strings.Index(body, "<td class=hint>")
|
||||
if i < 0 {
|
||||
return body
|
||||
}
|
||||
j := i + 200
|
||||
if j > len(body) {
|
||||
j = len(body)
|
||||
}
|
||||
return body[i:j]
|
||||
}
|
||||
|
||||
// The resolved section rendered every row that ever existed.
|
||||
func TestHandleTasksBoundsResolved(t *testing.T) {
|
||||
base := time.Date(2026, 8, 1, 9, 0, 0, 0, time.UTC)
|
||||
var rows []ipc.Task
|
||||
for i := 0; i < resolvedShown+10; i++ {
|
||||
ts := base.Add(time.Duration(i) * time.Minute)
|
||||
rows = append(rows, ipc.Task{
|
||||
ID: int64(i + 1), Text: fmt.Sprintf("задача %d", i), Status: "done",
|
||||
CreatedTs: ts, Resolved: &ts,
|
||||
})
|
||||
}
|
||||
core := &fakeTaskCore{tasks: rows}
|
||||
rec := httptest.NewRecorder()
|
||||
handleTasks(rec, httptest.NewRequest(http.MethodGet, "/tasks", nil), core)
|
||||
body := rec.Body.String()
|
||||
if n := strings.Count(body, "задача "); n != resolvedShown {
|
||||
t.Errorf("rendered %d resolved rows, want the %d-row bound", n, resolvedShown)
|
||||
}
|
||||
if !strings.Contains(body, "most recent are shown") {
|
||||
t.Error("the page must say it is showing only part of the history")
|
||||
}
|
||||
}
|
||||
|
||||
// A capture over a candidate is a confirmation, not a duplicate.
|
||||
func TestHandleTasksAddSaysPromoted(t *testing.T) {
|
||||
core := &fakeTaskCore{promoted: true}
|
||||
form := url.Values{"action": {"add"}, "text": {"продлить страховку"}}
|
||||
req := httptest.NewRequest(http.MethodPost, "/tasks", strings.NewReader(form.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
rec := httptest.NewRecorder()
|
||||
handleTasks(rec, req, core)
|
||||
if !strings.Contains(rec.Body.String(), "confirmed a candidate") {
|
||||
t.Error("a promoted capture must not read as a duplicate")
|
||||
}
|
||||
}
|
||||
|
||||
// Sscanf accepted "3junk" as 3, and the same call parsed the row id.
|
||||
func TestApplyTaskPostRejectsTrailingGarbage(t *testing.T) {
|
||||
core := &fakeTaskCore{created: true}
|
||||
form := url.Values{"action": {"add"}, "text": {"что-то"}, "weight": {"3junk"}}
|
||||
req := httptest.NewRequest(http.MethodPost, "/tasks", strings.NewReader(form.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
rec := httptest.NewRecorder()
|
||||
handleTasks(rec, req, core)
|
||||
if len(core.captured) != 0 {
|
||||
t.Errorf("captured %+v, want nothing on a malformed weight", core.captured)
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), "bad weight") {
|
||||
t.Error("error not surfaced on the page")
|
||||
}
|
||||
|
||||
core = &fakeTaskCore{}
|
||||
form = url.Values{"action": {"done"}, "id": {"42junk"}}
|
||||
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 core.statusID != 0 {
|
||||
t.Errorf("SetTaskStatus called with id %d on a malformed id", core.statusID)
|
||||
}
|
||||
}
|
||||
|
||||
// A resolution says what resolved it: resolved_ts recorded when and never by
|
||||
// what.
|
||||
func TestHandleTasksRecordsTheCaller(t *testing.T) {
|
||||
core := &fakeTaskCore{}
|
||||
form := url.Values{"action": {"done"}, "id": {"42"}}
|
||||
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 core.statusBy != "tap:web" {
|
||||
t.Errorf("resolved by %q, want tap:web", core.statusBy)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user