personal memory agent
1# SPDX-License-Identifier: AGPL-3.0-only
2# Copyright (c) 2026 sol pbc
3
4"""Tests for todos CLI commands (sol call todos ...)."""
5
6import json
7from datetime import datetime
8
9from typer.testing import CliRunner
10
11import apps.todos.call as todos_call
12from think.call import call_app
13
14runner = CliRunner()
15
16
17class TestTodosList:
18 """Tests for 'sol call todos list' command."""
19
20 def test_list_with_facet(self, todo_env):
21 """List todos for a single day with --facet."""
22 todo_env(
23 [{"text": "Buy milk"}, {"text": "Walk dog", "completed": True}],
24 day="20240101",
25 )
26 result = runner.invoke(
27 call_app, ["todos", "list", "20240101", "--facet", "personal"]
28 )
29 assert result.exit_code == 0
30 assert "Buy milk" in result.output
31 assert "Walk dog" in result.output
32
33 def test_list_all_facets(self, todo_env):
34 """List todos across all facets when --facet is omitted."""
35 todo_env([{"text": "Work task"}], day="20240101", facet="work")
36 # Add a second facet's todos in the same journal
37 todo_env([{"text": "Home task"}], day="20240101", facet="home")
38 result = runner.invoke(call_app, ["todos", "list", "20240101"])
39 assert result.exit_code == 0
40 assert "Work task" in result.output
41 assert "Home task" in result.output
42
43 def test_list_empty_day(self, todo_env):
44 """Empty day shows no-todos message."""
45 todo_env([], day="20240101")
46 result = runner.invoke(
47 call_app, ["todos", "list", "20240101", "--facet", "personal"]
48 )
49 assert result.exit_code == 0
50 assert "No todos" in result.output
51
52 def test_list_invalid_range(self, todo_env):
53 """--to before day produces an error."""
54 todo_env([], day="20240101")
55 result = runner.invoke(
56 call_app,
57 ["todos", "list", "20240201", "--facet", "personal", "--to", "20240101"],
58 )
59 assert result.exit_code == 1
60 assert "Error" in result.output
61
62
63class TestTodosAdd:
64 """Tests for 'sol call todos add' command."""
65
66 def test_add_todo(self, todo_env):
67 """Add a todo to a future day."""
68 todo_env([], day="29991231")
69 result = runner.invoke(
70 call_app,
71 [
72 "todos",
73 "add",
74 "Ship feature",
75 "--day",
76 "29991231",
77 "--facet",
78 "personal",
79 ],
80 )
81 assert result.exit_code == 0
82 assert "Ship feature" in result.output
83
84 def test_add_appends_to_existing(self, todo_env):
85 """Add appends after existing items."""
86 todo_env([{"text": "First"}], day="29991231")
87 result = runner.invoke(
88 call_app,
89 ["todos", "add", "Second", "--day", "29991231", "--facet", "personal"],
90 )
91 assert result.exit_code == 0
92 assert "First" in result.output
93 assert "Second" in result.output
94
95 def test_add_past_date_allowed(self, todo_env):
96 """Adding to a past date succeeds."""
97 todo_env([], day="20200101")
98 result = runner.invoke(
99 call_app,
100 ["todos", "add", "Nope", "--day", "20200101", "--facet", "personal"],
101 )
102 assert result.exit_code == 0
103 assert "Nope" in result.output
104
105 def test_add_empty_text_rejected(self, todo_env):
106 """Adding empty text fails."""
107 todo_env([], day="29991231")
108 result = runner.invoke(
109 call_app,
110 ["todos", "add", " ", "--day", "29991231", "--facet", "personal"],
111 )
112 assert result.exit_code == 1
113
114 def test_add_with_nudge(self, todo_env):
115 """Add a todo with --nudge flag."""
116 todo_env(day="20260301", facet="personal")
117 result = runner.invoke(
118 call_app,
119 [
120 "todos",
121 "add",
122 "Test nudge",
123 "--nudge",
124 "15:00",
125 "-f",
126 "personal",
127 "-d",
128 "20260301",
129 ],
130 )
131 assert result.exit_code == 0
132 assert "Test nudge" in result.output
133
134
135class TestTodosDone:
136 """Tests for 'sol call todos done' command."""
137
138 def test_done_marks_complete(self, todo_env):
139 """Mark a todo as done."""
140 todo_env([{"text": "Buy milk"}], day="20240101")
141 result = runner.invoke(
142 call_app, ["todos", "done", "1", "--day", "20240101", "--facet", "personal"]
143 )
144 assert result.exit_code == 0
145 assert "[x]" in result.output
146
147 def test_done_invalid_line_number(self, todo_env):
148 """Invalid line number fails."""
149 todo_env([{"text": "Only one"}], day="20240101")
150 result = runner.invoke(
151 call_app, ["todos", "done", "5", "--day", "20240101", "--facet", "personal"]
152 )
153 assert result.exit_code == 1
154
155
156class TestTodosCancel:
157 """Tests for 'sol call todos cancel' command."""
158
159 def test_cancel_entry(self, todo_env):
160 """Cancel a todo."""
161 todo_env([{"text": "Buy milk"}], day="20240101")
162 result = runner.invoke(
163 call_app,
164 ["todos", "cancel", "1", "--day", "20240101", "--facet", "personal"],
165 )
166 assert result.exit_code == 0
167 assert "cancelled" in result.output
168
169 def test_cancel_invalid_line_number(self, todo_env):
170 """Invalid line number fails."""
171 todo_env([{"text": "Only one"}], day="20240101")
172 result = runner.invoke(
173 call_app,
174 ["todos", "cancel", "5", "--day", "20240101", "--facet", "personal"],
175 )
176 assert result.exit_code == 1
177
178
179class TestTodosUpcoming:
180 """Tests for 'sol call todos upcoming' command."""
181
182 def test_upcoming_shows_future(self, todo_env):
183 """Upcoming shows future todos."""
184 todo_env([{"text": "Future task"}], day="29991231")
185 result = runner.invoke(call_app, ["todos", "upcoming"])
186 assert result.exit_code == 0
187 assert "Future task" in result.output
188
189 def test_upcoming_with_facet_filter(self, todo_env):
190 """Upcoming filters by facet."""
191 todo_env([{"text": "Work task"}], day="29991231", facet="work")
192 result = runner.invoke(call_app, ["todos", "upcoming", "--facet", "work"])
193 assert result.exit_code == 0
194 assert "Work task" in result.output
195
196 def test_upcoming_no_future_todos(self, todo_env):
197 """No future todos shows appropriate message."""
198 todo_env([], day="20200101")
199 result = runner.invoke(call_app, ["todos", "upcoming"])
200 assert result.exit_code == 0
201 assert "No upcoming todos" in result.output
202
203
204class TestTodosNudges:
205 class _FixedDateTime:
206 @classmethod
207 def now(cls):
208 return datetime(2026, 3, 10, 12, 0)
209
210 def test_list_nudges_due_is_readonly(self, todo_env, monkeypatch):
211 day, facet, todo_path = todo_env(
212 [{"text": "Follow up", "nudge": "20260310T09:00"}],
213 day="20260310",
214 )
215 before = todo_path.read_text(encoding="utf-8")
216 monkeypatch.setattr(todos_call, "datetime", self._FixedDateTime)
217
218 result = runner.invoke(call_app, ["todos", "list-nudges-due", "--facet", facet])
219
220 assert result.exit_code == 0
221 assert "Follow up" in result.output
222 assert todo_path.read_text(encoding="utf-8") == before
223
224 def test_list_nudges_due_json_all_facets(self, todo_env, monkeypatch):
225 todo_env(
226 [{"text": "Work ping", "nudge": "20260310T08:00"}],
227 day="20260310",
228 facet="work",
229 )
230 todo_env(
231 [{"text": "Home ping", "nudge": "20260310T09:00"}],
232 day="20260310",
233 facet="home",
234 )
235 monkeypatch.setattr(todos_call, "datetime", self._FixedDateTime)
236
237 result = runner.invoke(call_app, ["todos", "list-nudges-due", "--json"])
238
239 assert result.exit_code == 0
240 payload = json.loads(result.output)
241 assert payload == [
242 {
243 "day": "20260310",
244 "facet": "work",
245 "index": 1,
246 "text": "Work ping",
247 "nudge": "20260310T08:00",
248 "nudge_display": "4h ago",
249 },
250 {
251 "day": "20260310",
252 "facet": "home",
253 "index": 1,
254 "text": "Home ping",
255 "nudge": "20260310T09:00",
256 "nudge_display": "3h ago",
257 },
258 ]
259
260 def test_list_nudges_due_empty(self, todo_env, monkeypatch):
261 todo_env([], day="20260310")
262 monkeypatch.setattr(todos_call, "datetime", self._FixedDateTime)
263
264 human = runner.invoke(call_app, ["todos", "list-nudges-due"])
265 json_result = runner.invoke(call_app, ["todos", "list-nudges-due", "--json"])
266
267 assert human.exit_code == 0
268 assert human.output.strip() == "No nudges due."
269 assert json_result.exit_code == 0
270 assert json.loads(json_result.output) == []
271
272 def test_dispatch_nudges_notifies_and_marks(self, todo_env, monkeypatch):
273 _day, facet, todo_path = todo_env(
274 [{"text": "Follow up", "nudge": "20260310T09:00"}],
275 day="20260310",
276 )
277 calls: list[tuple[list[str], dict]] = []
278
279 def fake_run(argv, **kwargs):
280 calls.append((argv, kwargs))
281 return None
282
283 monkeypatch.setattr(todos_call, "datetime", self._FixedDateTime)
284 monkeypatch.setattr(todos_call.subprocess, "run", fake_run)
285
286 result = runner.invoke(call_app, ["todos", "dispatch-nudges", "--facet", facet])
287
288 assert result.exit_code == 0
289 assert result.output.strip() == "dispatched 1 nudge(s)"
290 assert calls == [
291 (
292 [
293 "sol",
294 "notify",
295 "Follow up",
296 "--title",
297 "Todo Reminder",
298 "--icon",
299 "✅",
300 "--app",
301 "todos",
302 "--facet",
303 facet,
304 "--action",
305 "/app/todos/20260310",
306 ],
307 {"check": False, "capture_output": True},
308 )
309 ]
310 saved = [
311 json.loads(line)
312 for line in todo_path.read_text(encoding="utf-8").splitlines()
313 ]
314 assert saved == [
315 {
316 "text": "Follow up",
317 "nudge": "20260310T09:00",
318 "notified": True,
319 }
320 ]
321
322 def test_dispatch_nudges_noop_when_nothing_due(self, todo_env, monkeypatch):
323 todo_env([], day="20260310")
324 calls: list[tuple[list[str], dict]] = []
325
326 def fake_run(argv, **kwargs):
327 calls.append((argv, kwargs))
328 return None
329
330 monkeypatch.setattr(todos_call, "datetime", self._FixedDateTime)
331 monkeypatch.setattr(todos_call.subprocess, "run", fake_run)
332
333 result = runner.invoke(call_app, ["todos", "dispatch-nudges"])
334
335 assert result.exit_code == 0
336 assert result.output.strip() == "dispatched 0 nudge(s)"
337 assert calls == []
338
339 def test_legacy_nudge_command_removed(self, todo_env):
340 todo_env([], day="20260310")
341
342 result = runner.invoke(call_app, ["todos", "check" + "-nudges"])
343
344 assert result.exit_code != 0
345
346
347class TestTodosMove:
348 """Tests for 'sol call todos move' command."""
349
350 def test_move_todo(self, move_env):
351 journal, src_facet, dst_facet = move_env([{"text": "Ship feature"}])
352
353 result = runner.invoke(
354 call_app,
355 [
356 "todos",
357 "move",
358 "1",
359 "--day",
360 "20240101",
361 "--from",
362 src_facet,
363 "--to",
364 dst_facet,
365 ],
366 )
367
368 assert result.exit_code == 0
369 source_items = [
370 json.loads(line)
371 for line in (journal / "facets" / src_facet / "todos" / "20240101.jsonl")
372 .read_text(encoding="utf-8")
373 .splitlines()
374 ]
375 dest_items = [
376 json.loads(line)
377 for line in (journal / "facets" / dst_facet / "todos" / "20240101.jsonl")
378 .read_text(encoding="utf-8")
379 .splitlines()
380 ]
381 assert source_items[0]["cancelled"] is True
382 assert source_items[0]["cancelled_reason"] == "moved_to_facet"
383 assert source_items[0]["moved_to"] == dst_facet
384 assert dest_items[0]["text"] == "Ship feature"
385 assert dest_items[0]["created_at"] == source_items[0]["created_at"]
386
387 def test_move_todo_with_nudge(self, move_env):
388 journal, src_facet, dst_facet = move_env(
389 [{"text": "Call Alice", "nudge": "20240101T09:00"}]
390 )
391
392 result = runner.invoke(
393 call_app,
394 [
395 "todos",
396 "move",
397 "1",
398 "--day",
399 "20240101",
400 "--from",
401 src_facet,
402 "--to",
403 dst_facet,
404 ],
405 )
406
407 assert result.exit_code == 0
408 dest_items = [
409 json.loads(line)
410 for line in (journal / "facets" / dst_facet / "todos" / "20240101.jsonl")
411 .read_text(encoding="utf-8")
412 .splitlines()
413 ]
414 assert dest_items[0]["nudge"] == "20240101T09:00"
415
416 def test_move_already_cancelled(self, move_env):
417 _, src_facet, dst_facet = move_env(
418 [{"text": "Ship feature", "cancelled": True}]
419 )
420
421 result = runner.invoke(
422 call_app,
423 [
424 "todos",
425 "move",
426 "1",
427 "--day",
428 "20240101",
429 "--from",
430 src_facet,
431 "--to",
432 dst_facet,
433 ],
434 )
435
436 assert result.exit_code == 1
437 assert "already cancelled" in result.output
438
439 def test_move_already_completed(self, move_env):
440 _, src_facet, dst_facet = move_env(
441 [{"text": "Ship feature", "completed": True}]
442 )
443
444 result = runner.invoke(
445 call_app,
446 [
447 "todos",
448 "move",
449 "1",
450 "--day",
451 "20240101",
452 "--from",
453 src_facet,
454 "--to",
455 dst_facet,
456 ],
457 )
458
459 assert result.exit_code == 1
460 assert "completed todo" in result.output
461
462 def test_move_invalid_line_number(self, move_env):
463 _, src_facet, dst_facet = move_env([{"text": "Ship feature"}])
464
465 result = runner.invoke(
466 call_app,
467 [
468 "todos",
469 "move",
470 "5",
471 "--day",
472 "20240101",
473 "--from",
474 src_facet,
475 "--to",
476 dst_facet,
477 ],
478 )
479
480 assert result.exit_code == 1
481 assert "out of range" in result.output
482
483 def test_move_missing_facet(self, move_env):
484 move_env([{"text": "Ship feature"}], dst_facet="personal")
485
486 result = runner.invoke(
487 call_app,
488 [
489 "todos",
490 "move",
491 "1",
492 "--day",
493 "20240101",
494 "--from",
495 "work",
496 "--to",
497 "missing",
498 ],
499 )
500
501 assert result.exit_code == 1
502 assert "does not exist" in result.output
503
504
505class TestSolEnvResolution:
506 """Tests for SOL_* env var resolution in todos commands."""
507
508 def test_list_from_sol_day(self, todo_env, monkeypatch):
509 """list with SOL_DAY env and no day arg works."""
510 todo_env([{"text": "Env task"}], day="20240101")
511 monkeypatch.setenv("SOL_DAY", "20240101")
512 result = runner.invoke(call_app, ["todos", "list", "--facet", "personal"])
513 assert result.exit_code == 0
514 assert "Env task" in result.output
515
516 def test_add_from_sol_day_and_facet(self, todo_env, monkeypatch):
517 """add with SOL_DAY + SOL_FACET env works."""
518 todo_env([], day="29991231")
519 monkeypatch.setenv("SOL_DAY", "29991231")
520 monkeypatch.setenv("SOL_FACET", "personal")
521 result = runner.invoke(call_app, ["todos", "add", "Env todo"])
522 assert result.exit_code == 0
523 assert "Env todo" in result.output
524
525 def test_done_from_sol_day_and_facet(self, todo_env, monkeypatch):
526 """done with SOL_DAY + SOL_FACET env works."""
527 todo_env([{"text": "Buy milk"}], day="20240101")
528 monkeypatch.setenv("SOL_DAY", "20240101")
529 monkeypatch.setenv("SOL_FACET", "personal")
530 result = runner.invoke(call_app, ["todos", "done", "1"])
531 assert result.exit_code == 0
532 assert "[x]" in result.output
533
534
535class TestTodosAddDedup:
536 """Tests for cross-facet duplicate detection in 'sol call todos add'."""
537
538 def test_add_rejects_duplicate_in_other_facet(self, move_env):
539 """Adding a duplicate todo in another facet is rejected with exit code 1."""
540 _, src_facet, dst_facet = move_env([{"text": "Draft Q1 plan"}], day="20240102")
541 result = runner.invoke(
542 call_app,
543 [
544 "todos",
545 "add",
546 "Draft Q1 plan",
547 "--day",
548 "20240102",
549 "--facet",
550 dst_facet,
551 ],
552 )
553 assert result.exit_code == 1
554 assert "Duplicate detected" in result.output
555
556 def test_add_force_bypasses_dedup(self, move_env):
557 """--force flag allows adding despite duplicate detection."""
558 _, src_facet, dst_facet = move_env([{"text": "Draft Q1 plan"}], day="20240102")
559 result = runner.invoke(
560 call_app,
561 [
562 "todos",
563 "add",
564 "Draft Q1 plan",
565 "--day",
566 "20240102",
567 "--facet",
568 dst_facet,
569 "--force",
570 ],
571 )
572 assert result.exit_code == 0
573 assert "Draft Q1 plan" in result.output
574
575 def test_add_succeeds_when_no_matches(self, move_env):
576 """Adding a unique todo succeeds normally."""
577 _, src_facet, dst_facet = move_env([{"text": "Buy groceries"}], day="20240102")
578 result = runner.invoke(
579 call_app,
580 [
581 "todos",
582 "add",
583 "Draft Q1 plan",
584 "--day",
585 "20240102",
586 "--facet",
587 dst_facet,
588 ],
589 )
590 assert result.exit_code == 0
591 assert "Draft Q1 plan" in result.output
592
593 def test_add_dedup_stderr_format(self, move_env):
594 """Rejection message includes score, facet, day, line, and text."""
595 _, src_facet, dst_facet = move_env([{"text": "Draft Q1 plan"}], day="20240102")
596 result = runner.invoke(
597 call_app,
598 [
599 "todos",
600 "add",
601 "Draft Q1 plan",
602 "--day",
603 "20240102",
604 "--facet",
605 dst_facet,
606 ],
607 )
608 assert result.exit_code == 1
609 assert "100%" in result.output
610 assert src_facet in result.output
611 assert "20240102" in result.output
612 assert "line 1" in result.output
613 assert "--force" in result.output