diff --git a/.forgeproof/chain-9.json b/.forgeproof/chain-9.json new file mode 100644 index 0000000..26d59a5 --- /dev/null +++ b/.forgeproof/chain-9.json @@ -0,0 +1,134 @@ +[ + { + "index": 0, + "timestamp": "2026-07-06T04:27:28.973771+00:00", + "action": "genesis", + "data": { + "issue": 9, + "title": "Feature: Add priority levels to Task", + "requirements": [ + "REQ-1: Each Task has a priority attribute with allowed values low, medium, high; default is medium", + "REQ-2: Creating or setting a Task with any other priority value raises ValueError", + "REQ-3: TaskStore has a filter_by_priority(priority) method returning tasks matching the given priority", + "REQ-4: Existing behavior (creation, completion, serialization) is unchanged for tasks that never set a priority" + ] + }, + "prev_hash": "0000000000000000000000000000000000000000000000000000000000000000", + "hash": "e4cb589ffdca29c85af325deefd38d69ea26d965023ba494bc21ee1b7e457ba0", + "signature": "-----BEGIN SSH SIGNATURE-----\nU1NIU0lHAAAAAQAAADMAAAALc3NoLWVkMjU1MTkAAAAg+oHvrJFS7a9DnrWjvpyvVEI/iz\nREJUsu7RBNAYIem/MAAAAKZm9yZ2Vwcm9vZgAAAAAAAAAGc2hhNTEyAAAAUwAAAAtzc2gt\nZWQyNTUxOQAAAEBJhK2GYt67kphuUEqfkiqVT+rOA0OqH15Sn/WH/P0qV7R8IpfOZuQWMB\njcP3av5Oz/74lpMmPp1BKuB1CltTAN\n-----END SSH SIGNATURE-----" + }, + { + "index": 1, + "timestamp": "2026-07-06T04:27:54.408673+00:00", + "action": "branch-create", + "data": { + "branch": "forgeproof/9", + "base": "main", + "base_sha": "97d0e0888111b9c36921daf76fe004bad6ca67e3" + }, + "prev_hash": "e4cb589ffdca29c85af325deefd38d69ea26d965023ba494bc21ee1b7e457ba0", + "hash": "4f4843185a04c1020e3b8f57f4fe2b2875de3d4d074c4c0eeebf9ad14d5e70cc", + "signature": "-----BEGIN SSH SIGNATURE-----\nU1NIU0lHAAAAAQAAADMAAAALc3NoLWVkMjU1MTkAAAAg+oHvrJFS7a9DnrWjvpyvVEI/iz\nREJUsu7RBNAYIem/MAAAAKZm9yZ2Vwcm9vZgAAAAAAAAAGc2hhNTEyAAAAUwAAAAtzc2gt\nZWQyNTUxOQAAAECZkHyaCO7MhZ5Po9UfWfbtaRp2UzVcptOyU6Uh1tYCuXItON6jHJfqox\ncXDSYXQG4eGGbbLH2BEkf/2MJTZ34L\n-----END SSH SIGNATURE-----" + }, + { + "index": 2, + "timestamp": "2026-07-06T04:28:14.179356+00:00", + "action": "file-edit", + "data": { + "path": "src/task.py", + "operation": "modify", + "sha256": "564372076d3e12bab4f2ee3b98c55e35c5d0347e51a5dd83f941b14c82a188d8" + }, + "prev_hash": "4f4843185a04c1020e3b8f57f4fe2b2875de3d4d074c4c0eeebf9ad14d5e70cc", + "hash": "beb25eb72c25fb4ee8f4ae9190239d15fd4fb0183538a89dfe6c5311ee04c071", + "signature": "-----BEGIN SSH SIGNATURE-----\nU1NIU0lHAAAAAQAAADMAAAALc3NoLWVkMjU1MTkAAAAg+oHvrJFS7a9DnrWjvpyvVEI/iz\nREJUsu7RBNAYIem/MAAAAKZm9yZ2Vwcm9vZgAAAAAAAAAGc2hhNTEyAAAAUwAAAAtzc2gt\nZWQyNTUxOQAAAEBfEuRRbQWNxfvLmWPQi26VVkWiQL2Td1BykARKKQVQDlgolrpv/f7Ydi\nytFhQGsphOCutp4myxqr8iQJj4Xm8L\n-----END SSH SIGNATURE-----" + }, + { + "index": 3, + "timestamp": "2026-07-06T04:28:14.273994+00:00", + "action": "decision", + "data": { + "context": "How to enforce priority validation on both construction and later assignment for a mutable dataclass", + "choice": "Override Task.__setattr__ to validate the priority field, coercing valid strings ('low'/'medium'/'high') to Priority enum members and raising ValueError otherwise", + "rationale": "Dataclass __init__ assigns fields via __setattr__, so one override covers both creation and mutation without switching to frozen dataclass or properties; string coercion keeps the public API forgiving while guaranteeing the stored value is always a Priority member so to_dict/from_dict behavior is unchanged" + }, + "prev_hash": "beb25eb72c25fb4ee8f4ae9190239d15fd4fb0183538a89dfe6c5311ee04c071", + "hash": "2c4f3813f8de36cf7c16673f2137f723468d4ea9da2144421e9f444d5fbe117a", + "signature": "-----BEGIN SSH SIGNATURE-----\nU1NIU0lHAAAAAQAAADMAAAALc3NoLWVkMjU1MTkAAAAg+oHvrJFS7a9DnrWjvpyvVEI/iz\nREJUsu7RBNAYIem/MAAAAKZm9yZ2Vwcm9vZgAAAAAAAAAGc2hhNTEyAAAAUwAAAAtzc2gt\nZWQyNTUxOQAAAEB87rQIM50m+Jn7/ASr2+axzPz6Cx9EOtCp5MdLcYlYjCDMZEYIK0+WLN\nefPhL0SIo1tTOUZw2+MQ7BG3uGZyEC\n-----END SSH SIGNATURE-----" + }, + { + "index": 4, + "timestamp": "2026-07-06T04:28:38.280927+00:00", + "action": "file-edit", + "data": { + "path": "tests/test_priority.py", + "operation": "create", + "sha256": "1d5212fc1b2d699eb176df3d8dcacac7d48ca2aec1ce2a231ecdc3067fd552ba" + }, + "prev_hash": "2c4f3813f8de36cf7c16673f2137f723468d4ea9da2144421e9f444d5fbe117a", + "hash": "b2256dc12e1bc3aea08654195696564788415e40fe4750e2c039efeaba1f94a5", + "signature": "-----BEGIN SSH SIGNATURE-----\nU1NIU0lHAAAAAQAAADMAAAALc3NoLWVkMjU1MTkAAAAg+oHvrJFS7a9DnrWjvpyvVEI/iz\nREJUsu7RBNAYIem/MAAAAKZm9yZ2Vwcm9vZgAAAAAAAAAGc2hhNTEyAAAAUwAAAAtzc2gt\nZWQyNTUxOQAAAEAXdfBxOkh4wzEaqF2f/Ca6rDh0E2R4kHi8cs/1gHga7NWVzBphCIsFeI\nREaV5y70nxXrUWxcAqcaa4I8wguFUO\n-----END SSH SIGNATURE-----" + }, + { + "index": 5, + "timestamp": "2026-07-06T04:28:38.374432+00:00", + "action": "decision", + "data": { + "context": "REQ-3 asks TaskStore to gain filter_by_priority, but the method already exists in src/task_store.py", + "choice": "Leave src/task_store.py unchanged and cover the existing method with new tests", + "rationale": "The existing implementation already satisfies the requirement; ForgeProof rules call for focused, minimal changes rather than rewriting working code" + }, + "prev_hash": "b2256dc12e1bc3aea08654195696564788415e40fe4750e2c039efeaba1f94a5", + "hash": "407a92317d6fe98dbefa4e56c98d7cbffc7d035951d3c0518921f3445e98d101", + "signature": "-----BEGIN SSH SIGNATURE-----\nU1NIU0lHAAAAAQAAADMAAAALc3NoLWVkMjU1MTkAAAAg+oHvrJFS7a9DnrWjvpyvVEI/iz\nREJUsu7RBNAYIem/MAAAAKZm9yZ2Vwcm9vZgAAAAAAAAAGc2hhNTEyAAAAUwAAAAtzc2gt\nZWQyNTUxOQAAAEA9vGx5pybObEFDg5hKfMVi5OVygn4gGFvvbJqRyeJ0QjmkaemLYPyNSD\neOfA7NQYdpLaFIYY49CSrdl0YLyZQC\n-----END SSH SIGNATURE-----" + }, + { + "index": 6, + "timestamp": "2026-07-06T04:28:59.838656+00:00", + "action": "test-result", + "data": { + "suite": "pytest", + "passed": 44, + "failed": 0, + "coverage": { + "REQ-1": [ + "test_priority_defaults_to_medium", + "test_priority_accepts_all_allowed_values", + "test_priority_accepts_allowed_strings" + ], + "REQ-2": [ + "test_invalid_priority_string_at_creation_raises", + "test_invalid_priority_type_at_creation_raises", + "test_invalid_priority_on_assignment_raises", + "test_valid_priority_on_assignment", + "test_invalid_priority_in_from_dict_raises" + ], + "REQ-3": [ + "test_filter_by_priority_returns_matches", + "test_filter_by_priority_no_match" + ], + "REQ-4": [ + "test_untouched_priority_creation_and_completion", + "test_untouched_priority_serialization_round_trip" + ] + }, + "failed_tests": [] + }, + "prev_hash": "407a92317d6fe98dbefa4e56c98d7cbffc7d035951d3c0518921f3445e98d101", + "hash": "e07ee3f54987cc6c5ab4c6ca095054dfa0652277b03e44fabc4a3eef5420aad4", + "signature": "-----BEGIN SSH SIGNATURE-----\nU1NIU0lHAAAAAQAAADMAAAALc3NoLWVkMjU1MTkAAAAg+oHvrJFS7a9DnrWjvpyvVEI/iz\nREJUsu7RBNAYIem/MAAAAKZm9yZ2Vwcm9vZgAAAAAAAAAGc2hhNTEyAAAAUwAAAAtzc2gt\nZWQyNTUxOQAAAEC0tuzazRJRKf3TBnyeBn+N7Yled/Y604E1azCK26dtMJUy0vkDmo3puh\nKUAJ1PWircco24TsPiAgk0wd6i5/cG\n-----END SSH SIGNATURE-----" + }, + { + "index": 7, + "timestamp": "2026-07-06T04:29:10.976253+00:00", + "action": "lint-result", + "data": { + "tool": "ruff", + "errors": 0, + "warnings": 0 + }, + "prev_hash": "e07ee3f54987cc6c5ab4c6ca095054dfa0652277b03e44fabc4a3eef5420aad4", + "hash": "2b04a5461e90580c8c75cd21a15324d465b3e9603b8c94a230e6769bf232ba4c", + "signature": "-----BEGIN SSH SIGNATURE-----\nU1NIU0lHAAAAAQAAADMAAAALc3NoLWVkMjU1MTkAAAAg+oHvrJFS7a9DnrWjvpyvVEI/iz\nREJUsu7RBNAYIem/MAAAAKZm9yZ2Vwcm9vZgAAAAAAAAAGc2hhNTEyAAAAUwAAAAtzc2gt\nZWQyNTUxOQAAAEDUwu+EPrmgxXaF+sb2xUHD7WWzTudDmK5q3I/vg1NoFp6D/hBAuNA2LY\nxrNxFSRpeIL7YLm0G+P69cVcK84r4D\n-----END SSH SIGNATURE-----" + } +] diff --git a/src/task.py b/src/task.py index 2997048..218bd09 100644 --- a/src/task.py +++ b/src/task.py @@ -32,6 +32,23 @@ class Task: completed_at: datetime | None = None due_date: datetime | None = None + def __setattr__(self, name: str, value: object) -> None: + if name == "priority": + if isinstance(value, str): + try: + value = Priority(value) + except ValueError: + raise ValueError( + f"invalid priority: {value!r}; allowed values are " + f"{', '.join(p.value for p in Priority)}" + ) from None + elif not isinstance(value, Priority): + raise ValueError( + f"invalid priority: {value!r}; allowed values are " + f"{', '.join(p.value for p in Priority)}" + ) + super().__setattr__(name, value) + def complete(self) -> None: """Mark the task as done. No-op if already done.""" if self.status == Status.DONE: diff --git a/tests/test_priority.py b/tests/test_priority.py new file mode 100644 index 0000000..fd4ff8b --- /dev/null +++ b/tests/test_priority.py @@ -0,0 +1,111 @@ +"""Tests for Task priority levels (issue #9).""" + +import pytest + +from src.task import Priority, Status, Task +from src.task_store import TaskStore + + +# REQ-1: priority attribute with allowed values low, medium, high; default medium + + +def test_priority_defaults_to_medium(): + task = Task(title="No priority given") + assert task.priority == Priority.MEDIUM + + +def test_priority_accepts_all_allowed_values(): + for priority in (Priority.LOW, Priority.MEDIUM, Priority.HIGH): + task = Task(title="Explicit priority", priority=priority) + assert task.priority == priority + + +def test_priority_accepts_allowed_strings(): + for value in ("low", "medium", "high"): + task = Task(title="String priority", priority=value) + assert task.priority == Priority(value) + + +# REQ-2: any other priority value raises ValueError + + +def test_invalid_priority_string_at_creation_raises(): + with pytest.raises(ValueError): + Task(title="Bad", priority="urgent") + + +def test_invalid_priority_type_at_creation_raises(): + with pytest.raises(ValueError): + Task(title="Bad", priority=3) + + +def test_invalid_priority_on_assignment_raises(): + task = Task(title="Good") + with pytest.raises(ValueError): + task.priority = "banana" + + +def test_valid_priority_on_assignment(): + task = Task(title="Good") + task.priority = Priority.HIGH + assert task.priority == Priority.HIGH + task.priority = "low" + assert task.priority == Priority.LOW + + +def test_invalid_priority_in_from_dict_raises(): + data = { + "title": "Bad", + "priority": "critical", + "status": "todo", + "tags": [], + "created_at": "2026-01-01T00:00:00+00:00", + "completed_at": None, + } + with pytest.raises(ValueError): + Task.from_dict(data) + + +# REQ-3: TaskStore.filter_by_priority returns tasks matching the given priority + + +def test_filter_by_priority_returns_matches(): + store = TaskStore() + low_id = store.add(Task(title="Low", priority=Priority.LOW)) + store.add(Task(title="Medium")) + high_id = store.add(Task(title="High", priority=Priority.HIGH)) + + high_tasks = store.filter_by_priority(Priority.HIGH) + assert [(tid, t.title) for tid, t in high_tasks] == [(high_id, "High")] + + low_tasks = store.filter_by_priority(Priority.LOW) + assert [(tid, t.title) for tid, t in low_tasks] == [(low_id, "Low")] + + +def test_filter_by_priority_no_match(): + store = TaskStore() + store.add(Task(title="Medium only")) + assert store.filter_by_priority(Priority.HIGH) == [] + + +# REQ-4: existing behavior unchanged for tasks that never set a priority + + +def test_untouched_priority_creation_and_completion(): + task = Task(title="Plain task", description="desc", tags=["a"]) + assert task.priority == Priority.MEDIUM + assert task.status == Status.TODO + task.complete() + assert task.status == Status.DONE + assert task.completed_at is not None + + +def test_untouched_priority_serialization_round_trip(): + original = Task(title="Plain task") + original.complete() + d = original.to_dict() + assert d["priority"] == "medium" + rebuilt = Task.from_dict(d) + assert rebuilt.priority == Priority.MEDIUM + assert rebuilt.status == original.status + assert rebuilt.completed_at == original.completed_at