personal memory agent
at main 613 lines 20 kB view raw
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