Merge the task edit path (#188)
This commit is contained in:
+94
-21
@@ -883,14 +883,19 @@ type taskRow struct {
|
||||
Created string
|
||||
Resolved string
|
||||
ResolvedBy string
|
||||
// DueValue and Weight are the raw values the edit form posts back
|
||||
// (Vikunja #509). Due above is for reading and says "—" for no date; a
|
||||
// date input needs "2026-08-07" or the empty string.
|
||||
DueValue string
|
||||
Weight int
|
||||
// Why — the ranker's reason for this row's position (Vikunja #129), in
|
||||
// Russian, empty when nothing distinguished the task. Blank is the honest
|
||||
// rendering: he never said this one mattered more.
|
||||
Why string
|
||||
}
|
||||
|
||||
// handleTasks serves the task review surface (GET) and the four writes it
|
||||
// offers (POST): add, confirm, done, drop.
|
||||
// handleTasks serves the task review surface (GET) and the five writes it
|
||||
// offers (POST): add, edit, confirm, done, drop.
|
||||
//
|
||||
// Not step-up gated, unlike /tools and /routines, and the difference is the
|
||||
// point: enabling a tool defines argv Maven will execute, and accepting a
|
||||
@@ -900,6 +905,13 @@ type taskRow struct {
|
||||
// still sits behind whatever transport auth fronts mavweb, like every other
|
||||
// page.
|
||||
//
|
||||
// "edit" was re-argued on the same terms rather than inheriting the exemption
|
||||
// (Vikunja #509), and it stays ungated. It rewrites a line on a list he reads
|
||||
// himself, the same blast radius "drop" already has on this page, and the store
|
||||
// refuses the two edits that would cost something: a resolved task keeps the
|
||||
// text it was finished under, and a text collision with another live row is
|
||||
// named instead of merged.
|
||||
//
|
||||
// "confirm" is the only interesting move: it promotes a candidate Maven derived
|
||||
// from something she read into work he owns. That review step is why derived
|
||||
// tasks are captured as candidates in the first place.
|
||||
@@ -965,6 +977,7 @@ func handleTasks(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
|
||||
ID: t.ID, Text: t.Text, Source: t.Source, Evidence: t.Evidence,
|
||||
Status: t.Status, Created: fmtTaskTime(&t.CreatedTs),
|
||||
Due: fmtTaskDate(t.Due), Resolved: fmtTaskTime(t.Resolved),
|
||||
DueValue: fmtTaskDateValue(t.Due), Weight: t.Weight,
|
||||
Why: r.Reason,
|
||||
}
|
||||
if t.Status == "candidate" {
|
||||
@@ -1000,27 +1013,16 @@ func applyTaskPost(ctx context.Context, core ipc.CoreAPI, r *http.Request) (stri
|
||||
return "", errors.New("empty task text")
|
||||
}
|
||||
req := ipc.CaptureTaskReq{Text: text, Source: "tap:web", Status: "open", Ts: now()}
|
||||
// Importance is his, stated on the form. Out-of-range values are
|
||||
// clamped rather than rejected — a bad select is not worth a 400.
|
||||
if v := r.FormValue("weight"); v != "" {
|
||||
// strconv, not Sscanf: Sscanf("3junk", "%d") succeeds with 3, and a
|
||||
// form value is not a place to accept trailing garbage.
|
||||
wgt, err := strconv.Atoi(v)
|
||||
if err != nil || wgt < 0 {
|
||||
return "", fmt.Errorf("bad weight %q", v)
|
||||
}
|
||||
if wgt > tasks.MaxWeight {
|
||||
wgt = tasks.MaxWeight
|
||||
}
|
||||
req.Weight = wgt
|
||||
wgt, err := formWeight(r)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if d := r.FormValue("due"); d != "" {
|
||||
due, err := time.ParseInLocation("2006-01-02", d, now().Location())
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("bad due date %q", d)
|
||||
}
|
||||
req.Due = &due
|
||||
req.Weight = wgt
|
||||
due, err := formDue(r, now())
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req.Due = due
|
||||
resp, err := core.CaptureTask(ctx, req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
@@ -1038,6 +1040,36 @@ func applyTaskPost(ctx context.Context, core ipc.CoreAPI, r *http.Request) (stri
|
||||
if err != nil {
|
||||
return "", errors.New("invalid id")
|
||||
}
|
||||
|
||||
if action == "edit" {
|
||||
// The three fields capture set, and only those (Vikunja #509). Status
|
||||
// is not editable here: that ladder is one-way and has its own buttons.
|
||||
text := strings.TrimSpace(r.FormValue("text"))
|
||||
if text == "" {
|
||||
return "", errors.New("empty task text")
|
||||
}
|
||||
wgt, err := formWeight(r)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
due, err := formDue(r, now())
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
switch err := core.EditTask(ctx, id, text, due, wgt); {
|
||||
case err == nil:
|
||||
return "saved task", nil
|
||||
case errors.Is(err, ipc.ErrTaskDuplicate):
|
||||
// Naming the collision instead of merging: two live rows carry two
|
||||
// provenances, and picking one is not the page's call.
|
||||
return "", errors.New("another open task already says this — drop one of the two")
|
||||
case errors.Is(err, ipc.ErrTaskResolved):
|
||||
return "", errors.New("a resolved task keeps the text it was finished under")
|
||||
default:
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
|
||||
var status, msg string
|
||||
switch action {
|
||||
case "confirm":
|
||||
@@ -1061,6 +1093,47 @@ func applyTaskPost(ctx context.Context, core ipc.CoreAPI, r *http.Request) (stri
|
||||
return msg, nil
|
||||
}
|
||||
|
||||
// fmtTaskDateValue renders a due date the way <input type=date> requires, or
|
||||
// "" for no date. Separate from fmtTaskDate, which renders it for reading.
|
||||
// formWeight reads the importance select. Out-of-range clamps rather than
|
||||
// rejects — a bad select is not worth a 400 — but trailing garbage is refused,
|
||||
// because strconv is not Sscanf and "3junk" is not a 3.
|
||||
func formWeight(r *http.Request) (int, error) {
|
||||
v := r.FormValue("weight")
|
||||
if v == "" {
|
||||
return 0, nil
|
||||
}
|
||||
wgt, err := strconv.Atoi(v)
|
||||
if err != nil || wgt < 0 {
|
||||
return 0, fmt.Errorf("bad weight %q", v)
|
||||
}
|
||||
if wgt > tasks.MaxWeight {
|
||||
wgt = tasks.MaxWeight
|
||||
}
|
||||
return wgt, nil
|
||||
}
|
||||
|
||||
// formDue reads the date input. An empty field is nil, which on an edit means
|
||||
// "clear the date" — the form has no other way to say it.
|
||||
func formDue(r *http.Request, now time.Time) (*time.Time, error) {
|
||||
d := r.FormValue("due")
|
||||
if d == "" {
|
||||
return nil, nil
|
||||
}
|
||||
due, err := time.ParseInLocation("2006-01-02", d, now.Location())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("bad due date %q", d)
|
||||
}
|
||||
return &due, nil
|
||||
}
|
||||
|
||||
func fmtTaskDateValue(t *time.Time) string {
|
||||
if t == nil || t.IsZero() {
|
||||
return ""
|
||||
}
|
||||
return t.Local().Format("2006-01-02")
|
||||
}
|
||||
|
||||
func fmtTaskTime(t *time.Time) string {
|
||||
if t == nil || t.IsZero() {
|
||||
return "—"
|
||||
|
||||
+18
-3
@@ -60,12 +60,27 @@
|
||||
<h2 class=card-title>open <span class=badge>{{len .Open}}</span></h2>
|
||||
<div class=hint>most pressing first — by the deadlines and the urgency you gave. nothing about a task is guessed; the only signal that is not yours is age, which lifts anything sitting here for weeks.</div>
|
||||
{{if .Open}}<div class=scroll><table>
|
||||
<tr><th>task</th><th>why</th><th>from</th><th>due</th><th>captured</th><th></th><th></th></tr>
|
||||
<tr><th>task</th><th>why</th><th>from</th><th>captured</th><th></th><th></th></tr>
|
||||
{{range .Open}}<tr>
|
||||
<td class=text-max>{{.Text}}</td>
|
||||
<!-- The text, the date and the importance are editable in place (V-509): a
|
||||
dictated task can carry a typo, and a deadline moves. The status is not
|
||||
here — that ladder is one-way and has its own two buttons. -->
|
||||
<td class=text-max><form method=post action=/tasks class=inline-form>
|
||||
<input type=hidden name=id value="{{.ID}}">
|
||||
<input type=hidden name=action value=edit>
|
||||
<input type=text name=text value="{{.Text}}" size=30 required>
|
||||
<input type=date name=due value="{{.DueValue}}" title="due date">
|
||||
<select name=weight title=importance>
|
||||
<!-- Any weight that is not one of the three rungs keeps its own option, or
|
||||
saving an unrelated edit would silently reset it to normal. -->
|
||||
{{if and (ne .Weight 0) (ne .Weight 2) (ne .Weight 3)}}<option value={{.Weight}} selected>{{.Weight}}</option>{{end}}
|
||||
<option value=0 {{if eq .Weight 0}}selected{{end}}>normal</option>
|
||||
<option value=2 {{if eq .Weight 2}}selected{{end}}>важно</option>
|
||||
<option value=3 {{if eq .Weight 3}}selected{{end}}>срочно</option>
|
||||
</select>
|
||||
<button class="btn btn-muted">save</button></form></td>
|
||||
<td class=hint>{{.Why}}</td>
|
||||
<td class=hint>{{.Source}}</td>
|
||||
<td>{{.Due}}</td>
|
||||
<td class=muted>{{.Created}}</td>
|
||||
<td><form method=post action=/tasks class=inline-form>
|
||||
<input type=hidden name=id value="{{.ID}}">
|
||||
|
||||
@@ -549,6 +549,16 @@ type setTaskStatusReq struct {
|
||||
By string `json:"by,omitempty"`
|
||||
}
|
||||
|
||||
// editTaskReq — the rewrite of the three fields capture set (Vikunja #509).
|
||||
// Due nil clears the date, so "no date given" and "remove the date" cannot be
|
||||
// the same request.
|
||||
type editTaskReq struct {
|
||||
ID int64 `json:"id"`
|
||||
Text string `json:"text"`
|
||||
Due *time.Time `json:"due,omitempty"`
|
||||
Weight int `json:"weight,omitempty"`
|
||||
}
|
||||
|
||||
// setTaskFieldsReq — the write for the two board columns. Both are sent every
|
||||
// time and both may be empty: clearing a blocker is as ordinary as setting one,
|
||||
// so an omitted field cannot mean "leave it alone" without a second way to say
|
||||
@@ -872,6 +882,13 @@ var ErrToolNotFound = errors.New("ipc: tool not found")
|
||||
// form can say which refusal it hit rather than "не найдено".
|
||||
var ErrTaskNoDoneWhen = errors.New("ipc: task has no definition of done")
|
||||
|
||||
// ErrTaskDuplicate — an edit would collide with another live task's normalised
|
||||
// text (Vikunja #509). The surface says which row holds it rather than merging.
|
||||
var ErrTaskDuplicate = errors.New("ipc: another live task already has this text")
|
||||
|
||||
// ErrTaskResolved — a resolved task is not editable.
|
||||
var ErrTaskResolved = errors.New("ipc: task is resolved")
|
||||
|
||||
// callerKey — context key for the authenticated caller. Server sets it from
|
||||
// SO_PEERCRED before dispatch; in-process callers omit it (the adapter treats
|
||||
// a missing Caller as "trusted same-process", the equivalent of the socket's
|
||||
|
||||
@@ -515,6 +515,10 @@ func (c *Client) SetTaskStatus(ctx context.Context, id int64, status string, ts
|
||||
return c.call(ctx, MethodSetTaskStatus, setTaskStatusReq{ID: id, Status: status, Ts: ts, By: by}, nil)
|
||||
}
|
||||
|
||||
func (c *Client) EditTask(ctx context.Context, id int64, text string, due *time.Time, weight int) error {
|
||||
return c.call(ctx, MethodEditTask, editTaskReq{ID: id, Text: text, Due: due, Weight: weight}, nil)
|
||||
}
|
||||
|
||||
func (c *Client) SetTaskFields(ctx context.Context, id int64, doneWhen, blockedOn string) error {
|
||||
return c.call(ctx, MethodSetTaskFields, setTaskFieldsReq{ID: id, DoneWhen: doneWhen, BlockedOn: blockedOn}, nil)
|
||||
}
|
||||
|
||||
@@ -142,6 +142,11 @@ type TaskAPI interface {
|
||||
// SetTaskStatus moves a task forward once: candidate→open|dropped,
|
||||
// open→done|dropped. Any other move is refused.
|
||||
SetTaskStatus(ctx context.Context, id int64, status string, ts time.Time, by string) error
|
||||
// EditTask rewrites the three fields capture set: text, due date and
|
||||
// weight. Status is not among them — that ladder is one-way and belongs to
|
||||
// SetTaskStatus. A resolved task is refused, and a text edit that would
|
||||
// duplicate another live task is refused rather than merged.
|
||||
EditTask(ctx context.Context, id int64, text string, due *time.Time, weight int) error
|
||||
// SetTaskFields writes the definition of done and the blocker. Not a
|
||||
// status move, so it is not one-way: he may sharpen a criterion, and a
|
||||
// blocker clears when the person answers. blockedOn is a canonical Nexus
|
||||
|
||||
@@ -32,6 +32,8 @@ var mapErrPairs = []struct {
|
||||
{"ErrReminderState", store.ErrReminderState, ErrReminderState},
|
||||
{"ErrToolNotFound", store.ErrToolNotFound, ErrToolNotFound},
|
||||
{"ErrTaskNoDoneWhen", store.ErrTaskNoDoneWhen, ErrTaskNoDoneWhen},
|
||||
{"ErrTaskDuplicate", store.ErrTaskDuplicate, ErrTaskDuplicate},
|
||||
{"ErrTaskResolved", store.ErrTaskResolved, ErrTaskResolved},
|
||||
}
|
||||
|
||||
// unmappedStoreErrors — store sentinels that deliberately have no wire twin,
|
||||
|
||||
@@ -540,6 +540,9 @@ var methodTable = map[Method]handlerFunc{
|
||||
MethodSetTaskStatus: withParamsVoid(func(ctx context.Context, api CoreAPI, p setTaskStatusReq) error {
|
||||
return api.SetTaskStatus(ctx, p.ID, p.Status, p.Ts, p.By)
|
||||
}),
|
||||
MethodEditTask: withParamsVoid(func(ctx context.Context, api CoreAPI, p editTaskReq) error {
|
||||
return api.EditTask(ctx, p.ID, p.Text, p.Due, p.Weight)
|
||||
}),
|
||||
MethodSetTaskFields: withParamsVoid(func(ctx context.Context, api CoreAPI, p setTaskFieldsReq) error {
|
||||
return api.SetTaskFields(ctx, p.ID, p.DoneWhen, p.BlockedOn)
|
||||
}),
|
||||
|
||||
@@ -356,6 +356,10 @@ func (a *storeAPI) SetTaskStatus(ctx context.Context, id int64, status string, t
|
||||
return mapErr(a.s.SetTaskStatus(ctx, id, status, ts, by))
|
||||
}
|
||||
|
||||
func (a *storeAPI) EditTask(ctx context.Context, id int64, text string, due *time.Time, weight int) error {
|
||||
return mapErr(a.s.EditTask(ctx, id, text, due, weight))
|
||||
}
|
||||
|
||||
func (a *storeAPI) SetTaskFields(ctx context.Context, id int64, doneWhen, blockedOn string) error {
|
||||
return mapErr(a.s.SetTaskFields(ctx, id, doneWhen, blockedOn))
|
||||
}
|
||||
@@ -468,6 +472,10 @@ func mapErr(err error) error {
|
||||
return ErrToolNotFound
|
||||
case errors.Is(err, store.ErrTaskNoDoneWhen):
|
||||
return ErrTaskNoDoneWhen
|
||||
case errors.Is(err, store.ErrTaskDuplicate):
|
||||
return ErrTaskDuplicate
|
||||
case errors.Is(err, store.ErrTaskResolved):
|
||||
return ErrTaskResolved
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -114,6 +114,9 @@ func (UnimplementedCoreAPI) ListTasks(ctx context.Context, status string) ([]Tas
|
||||
func (UnimplementedCoreAPI) SetTaskStatus(ctx context.Context, id int64, status string, ts time.Time, by string) error {
|
||||
return ErrNotImplemented
|
||||
}
|
||||
func (UnimplementedCoreAPI) EditTask(ctx context.Context, id int64, text string, due *time.Time, weight int) error {
|
||||
return ErrNotImplemented
|
||||
}
|
||||
func (UnimplementedCoreAPI) SetTaskFields(ctx context.Context, id int64, doneWhen, blockedOn string) error {
|
||||
return ErrNotImplemented
|
||||
}
|
||||
|
||||
@@ -56,6 +56,7 @@ const (
|
||||
MethodListTasks Method = "list_tasks"
|
||||
MethodSetTaskStatus Method = "set_task_status"
|
||||
MethodSetTaskFields Method = "set_task_fields"
|
||||
MethodEditTask Method = "edit_task"
|
||||
MethodIngestMail Method = "ingest_mail"
|
||||
MethodSwapModel Method = "swap_model"
|
||||
MethodModelStatus Method = "model_status"
|
||||
|
||||
@@ -98,6 +98,16 @@ var (
|
||||
// finish line nobody wrote is how a board fills with rows that can never
|
||||
// leave it. Dropping such a candidate stays legal.
|
||||
ErrTaskNoDoneWhen = errors.New("store: task has no definition of done")
|
||||
// ErrTaskDuplicate — an edit would give this task the normalised text of
|
||||
// another live row (Vikunja #509). A refusal, not a merge: two live rows
|
||||
// carry two provenances, two capture times and possibly two external
|
||||
// identities, and merging picks a winner for all three silently. The
|
||||
// surface tells the owner which row already holds the text and lets him
|
||||
// drop one.
|
||||
ErrTaskDuplicate = errors.New("store: another live task already has this text")
|
||||
// ErrTaskResolved — a resolved task is not editable. Its text is the
|
||||
// record of what was finished, and rewriting it rewrites history.
|
||||
ErrTaskResolved = errors.New("store: task is resolved")
|
||||
)
|
||||
|
||||
// liveTaskStatuses — the two statuses that count as outstanding work.
|
||||
@@ -381,6 +391,50 @@ func (s *Store) setTaskStatus(ctx context.Context, id int64, status string, ts t
|
||||
return nil
|
||||
}
|
||||
|
||||
// EditTask rewrites the three fields capture set and nothing else: text, due
|
||||
// date and weight (Vikunja #509). Status stays the one-way ladder SetTaskStatus
|
||||
// owns, and a resolved task is refused outright — its text is the record of
|
||||
// what was finished.
|
||||
//
|
||||
// Editing text re-normalises the dedupe key, which can collide with another
|
||||
// live row. That is ErrTaskDuplicate and it is a refusal: merging would pick
|
||||
// one row's provenance, capture time and external identity over the other's
|
||||
// with nobody asked.
|
||||
//
|
||||
// due nil clears the date. Clearing has to be sayable, so an absent date and
|
||||
// "remove the date" cannot be the same argument.
|
||||
func (s *Store) EditTask(ctx context.Context, id int64, text string, due *time.Time, weight int) error {
|
||||
text = strings.TrimSpace(text)
|
||||
if text == "" {
|
||||
return ErrTaskEmpty
|
||||
}
|
||||
cur, err := s.LookupTask(ctx, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if cur.Status != TaskCandidate && cur.Status != TaskOpen {
|
||||
return fmt.Errorf("%w: id=%d is %s", ErrTaskResolved, id, cur.Status)
|
||||
}
|
||||
norm := NormalizeTaskText(text)
|
||||
if norm != NormalizeTaskText(cur.Text) {
|
||||
if other, err := s.lookupLiveTaskByNorm(ctx, norm); err == nil && other.ID != id {
|
||||
return fmt.Errorf("%w: id=%d holds it", ErrTaskDuplicate, other.ID)
|
||||
} else if err != nil && !errors.Is(err, ErrTaskNotFound) {
|
||||
return err
|
||||
}
|
||||
}
|
||||
var dueVal sql.NullInt64
|
||||
if due != nil {
|
||||
dueVal = sql.NullInt64{Int64: due.UnixMilli(), Valid: true}
|
||||
}
|
||||
if _, err := s.db.ExecContext(ctx,
|
||||
`UPDATE tasks SET text = ?, norm = ?, due_ts = ?, weight = ? WHERE id = ?`,
|
||||
text, norm, dueVal, weight, id); err != nil {
|
||||
return fmt.Errorf("edit task: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetTaskFields writes the two board columns. Separate from SetTaskStatus
|
||||
// because a status move is one-way and these are not: he may sharpen a
|
||||
// definition of done, and a blocker clears when the person answers.
|
||||
|
||||
@@ -465,3 +465,80 @@ func TestPromotingACandidateNeedsADefinitionOfDone(t *testing.T) {
|
||||
t.Errorf("status = %q, want open", got.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEditTaskRewritesTheCaptureFields(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
st := newTestStore(t)
|
||||
now := time.Date(2026, 8, 5, 9, 0, 0, 0, time.UTC)
|
||||
due := now.Add(48 * time.Hour)
|
||||
|
||||
res, err := st.CaptureTask(ctx, Task{Text: "купить малако", Source: "tap:voice", CreatedTs: now, Weight: 1})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := st.EditTask(ctx, res.ID, " купить молоко ", &due, 3); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := st.LookupTask(ctx, res.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.Text != "купить молоко" || got.Weight != 3 || got.Due == nil || !got.Due.Equal(due) {
|
||||
t.Fatalf("task = %+v, want the dictation typo fixed with the date and weight", got)
|
||||
}
|
||||
// Clearing the date has to be sayable, or an absent date and "remove the
|
||||
// date" would be one argument.
|
||||
if err := st.EditTask(ctx, res.ID, "купить молоко", nil, 3); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, err = st.LookupTask(ctx, res.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
} else if got.Due != nil {
|
||||
t.Errorf("due = %v, want it cleared", got.Due)
|
||||
}
|
||||
// The dedupe key moved with the text: capturing the old wording is new work.
|
||||
again, err := st.CaptureTask(ctx, Task{Text: "купить малако", Source: "tap:voice", CreatedTs: now})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !again.Created {
|
||||
t.Error("the old normalised text must be free after the edit")
|
||||
}
|
||||
if err := st.EditTask(ctx, res.ID, "", nil, 0); !errors.Is(err, ErrTaskEmpty) {
|
||||
t.Errorf("empty text = %v, want ErrTaskEmpty", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEditTaskRefusesACollisionAndAResolvedRow(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
st := newTestStore(t)
|
||||
now := time.Date(2026, 8, 5, 9, 0, 0, 0, time.UTC)
|
||||
|
||||
first, err := st.CaptureTask(ctx, Task{Text: "купить молоко", Source: "tap:voice", CreatedTs: now})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
second, err := st.CaptureTask(ctx, Task{Text: "оплатить интернет", Source: "tap:voice", CreatedTs: now})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Two live rows carry two provenances and two capture times, so a merge
|
||||
// would pick a winner for both with nobody asked.
|
||||
if err := st.EditTask(ctx, second.ID, "Купить молоко!", nil, 0); !errors.Is(err, ErrTaskDuplicate) {
|
||||
t.Errorf("collision = %v, want ErrTaskDuplicate", err)
|
||||
}
|
||||
// Editing a row to the text it already has is not a collision with itself.
|
||||
if err := st.EditTask(ctx, first.ID, "купить молоко", nil, 2); err != nil {
|
||||
t.Errorf("re-saving the same text: %v", err)
|
||||
}
|
||||
if err := st.SetTaskStatus(ctx, first.ID, TaskDone, now.Add(time.Hour), "tap:web"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// A resolved task's text is the record of what was finished.
|
||||
if err := st.EditTask(ctx, first.ID, "купить кефир", nil, 0); !errors.Is(err, ErrTaskResolved) {
|
||||
t.Errorf("editing a resolved task = %v, want ErrTaskResolved", err)
|
||||
}
|
||||
if err := st.EditTask(ctx, 9999, "что-то", nil, 0); !errors.Is(err, ErrTaskNotFound) {
|
||||
t.Errorf("editing a missing row = %v, want ErrTaskNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user