diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9b33963..900b1e7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,7 +29,7 @@ jobs: command: mypy src tests - name: Test - command: pytest -q + command: pytest -q --cov=taskli --cov-report=term-missing steps: - name: Checkout repository diff --git a/pyproject.toml b/pyproject.toml index 0857244..4e6f574 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,6 +22,7 @@ task = "taskli.cli:main" [project.optional-dependencies] dev = [ "pytest>=8.0", + "pytest-cov>=5.0", "black>=24.0", "mypy>=1.8", "isort>=5.13", @@ -87,6 +88,7 @@ exclude_lines = [ ] show_missing = true precision = 2 +fail_under = 95 [tool.black] line-length = 79 diff --git a/src/taskli/render.py b/src/taskli/render.py index 62f1fb9..f6f7460 100644 --- a/src/taskli/render.py +++ b/src/taskli/render.py @@ -18,28 +18,22 @@ _err_console = Console(stderr=True) -def _add_bold(name: str, color: Color | str | None) -> str: +def _add_bold(name: str, color: str | None) -> str: """Wrap a name in bold markup, adding a list color if set.""" if color is None: return f"[bold]{name}[/bold]" - if isinstance(color, str): - return f"[bold {color}]{name}[/bold {color}]" + return f"[bold {color}]{name}[/bold {color}]" - return f"[bold {color.value}]{name}[/bold {color.value}]" - -def _add_color(name: str, color: Color | str | None) -> str: +def _add_color(name: str, color: str | None) -> str: """Wrap a name in color markup if a color is set, else leave it plain.""" if color is None: return name - if isinstance(color, str): - return f"[{color}]{name}[/{color}]" - - return f"[{color.value}]{name}[/{color.value}]" + return f"[{color}]{name}[/{color}]" def _items_table(items: list[TaskliItem], color: Color | None = None) -> Table: diff --git a/tests/unit/test_cli.py b/tests/unit/test_cli.py index 97e5379..9a34e33 100644 --- a/tests/unit/test_cli.py +++ b/tests/unit/test_cli.py @@ -1,4 +1,6 @@ -from taskli.cli import main +import pytest + +from taskli.cli import TOP_LEVEL_COMMANDS, main from taskli.models import Color, Priority from taskli.storage import load_list @@ -11,14 +13,14 @@ def test_creates_list_if_missing(self, taskli_env, capsys): assert exit_code == 0 assert "added #1 to 'work'" in captured.out - def test_add_to_inbox_by_default(self, taskli_env, capsys): + def test_defaults_to_inbox(self, taskli_env, capsys): exit_code = main(["-a", "task"]) captured = capsys.readouterr() assert exit_code == 0 assert "added #1 to 'inbox'" in captured.out - def test_repeated_add_flag_adds_each_item(self, taskli_env, capsys): + def test_repeated_add_flags(self, taskli_env, capsys): exit_code = main(["-a", "buy", "milk", "-a", "buy", "eggs"]) captured = capsys.readouterr() @@ -26,9 +28,7 @@ def test_repeated_add_flag_adds_each_item(self, taskli_env, capsys): assert "added #1 to 'inbox'" in captured.out assert "added #2 to 'inbox'" in captured.out - def test_repeated_add_flag_shares_priority_and_tags( - self, taskli_env, capsys - ): + def test_repeated_flag_shares(self, taskli_env, capsys): main( [ "-a", @@ -51,54 +51,32 @@ def test_repeated_add_flag_shares_priority_and_tags( assert all(item.priority == Priority.HIGH for item in task_list.items) assert all(item.tags == ["urgent"] for item in task_list.items) - def test_unquoted_bare_text_errors_unrecognized(self, taskli_env, capsys): + def test_unquoted_bare_text_errors(self, taskli_env, capsys): exit_code = main(["buy", "milk"]) captured = capsys.readouterr() assert exit_code == 2 assert "unrecognized arguments" in captured.err - def test_quoted_bare_text_errors_list_not_found(self, taskli_env, capsys): + def test_quoted_bare_text_errors(self, taskli_env, capsys): exit_code = main(["buy milk"]) captured = capsys.readouterr() assert exit_code == 1 assert "does not exist" in captured.err - def test_former_action_word_is_now_a_valid_list_name( - self, taskli_env, capsys - ): - exit_code = main(["add", "-a", "buy milk"]) - - captured = capsys.readouterr() - assert exit_code == 0 - assert "added #1 to 'add'" in captured.out - - def test_tag_flag_invalid_outside_add_or_edit(self, taskli_env, capsys): - exit_code = main(["work", "-d", "1", "-t", "urgent"]) - - captured = capsys.readouterr() - assert exit_code == 2 - assert "only valid with" in captured.err - - def test_priority_flag_invalid_outside_add_or_edit( - self, taskli_env, capsys - ): - exit_code = main(["work", "--prune", "-p", "high"]) - - captured = capsys.readouterr() - assert exit_code == 2 - assert "only valid with" in captured.err - - def test_filter_tag_invalid_outside_view(self, taskli_env, capsys): - exit_code = main(["work", "-a", "task", "-f", "urgent"]) - - captured = capsys.readouterr() - assert exit_code == 2 - assert "only valid with" in captured.err - - def test_text_flag_invalid_outside_edit(self, taskli_env, capsys): - exit_code = main(["work", "--text", "new"]) + @pytest.mark.parametrize( + "argv", + [ + ["work", "-d", "1", "-t", "urgent"], + ["work", "--prune", "-p", "high"], + ["work", "-a", "task", "-f", "urgent"], + ["work", "--text", "new"], + ], + ids=["tag", "priority", "filter-tag", "text"], + ) + def test_modifier_flag_invalid(self, taskli_env, capsys, argv): + exit_code = main(argv) captured = capsys.readouterr() assert exit_code == 2 @@ -134,7 +112,7 @@ def test_filters_by_tag(self, taskli_env, capsys): assert "a" in captured.out assert "b" not in captured.out - def test_explicit_list_flag_matches_default_view(self, taskli_env, capsys): + def test_explicit_list_flag(self, taskli_env, capsys): main(["work", "-a", "task"]) capsys.readouterr() @@ -144,7 +122,7 @@ def test_explicit_list_flag_matches_default_view(self, taskli_env, capsys): assert exit_code == 0 assert "task" in captured.out - def test_raises_clean_error_for_missing_list(self, taskli_env, capsys): + def test_missing_list_raises(self, taskli_env, capsys): exit_code = main(["ghost"]) captured = capsys.readouterr() @@ -208,7 +186,7 @@ def test_rm_removes_item(self, taskli_env, capsys): class TestPrune: - def test_prune_removes_done_items_only(self, taskli_env, capsys): + def test_removes_done_items(self, taskli_env, capsys): main(["work", "-a", "done task"]) main(["work", "-a", "open task"]) main(["work", "-d", "1"]) @@ -223,7 +201,7 @@ def test_prune_removes_done_items_only(self, taskli_env, capsys): assert "done task" not in list_captured.out assert "open task" in list_captured.out - def test_prune_reports_count_in_message(self, taskli_env, capsys): + def test_reports_count_in_message(self, taskli_env, capsys): main(["work", "-a", "task"]) main(["work", "-d", "1"]) capsys.readouterr() @@ -233,7 +211,7 @@ def test_prune_reports_count_in_message(self, taskli_env, capsys): captured = capsys.readouterr() assert "pruned 1 item" in captured.out - def test_prune_noop_when_no_done_items(self, taskli_env, capsys): + def test_no_done_items(self, taskli_env, capsys): main(["work", "-a", "task"]) capsys.readouterr() @@ -247,16 +225,14 @@ def test_prune_noop_when_no_done_items(self, taskli_env, capsys): assert "pruned 0 item" in prune_captured.out assert "task" in list_captured.out - def test_prune_raises_clean_error_for_missing_list( - self, taskli_env, capsys - ): + def test_missing_list_raises(self, taskli_env, capsys): exit_code = main(["ghost", "--prune"]) captured = capsys.readouterr() assert exit_code == 1 assert "does not exist" in captured.err - def test_prune_implicit_inbox(self, taskli_env, capsys): + def test_implicit_inbox(self, taskli_env, capsys): main(["-a", "task"]) main(["-d", "1"]) capsys.readouterr() @@ -283,7 +259,7 @@ def test_shows_distinct_tags(self, taskli_env, capsys): class TestListsCommand: - def test_lists_all_list_names(self, taskli_env, capsys): + def test_shows_all_list_names(self, taskli_env, capsys): main(["work", "-a", "task"]) main(["new-list", "groceries"]) capsys.readouterr() @@ -300,7 +276,7 @@ def test_shows_message_when_empty(self, taskli_env, capsys): captured = capsys.readouterr() assert "no lists yet" in captured.out - def test_lists_shows_nested_sublist(self, taskli_env, capsys): + def test_shows_nested_sublist(self, taskli_env, capsys): main(["new-list", "work"]) main(["new-list", "work.meetings"]) capsys.readouterr() @@ -318,7 +294,7 @@ def test_lists_shows_nested_sublist(self, taskli_env, capsys): assert lines.index(child_line) > lines.index(parent_line) - def test_lists_unrelated_sibling(self, taskli_env, capsys): + def test_similarly_prefixed_sibling(self, taskli_env, capsys): main(["new-list", "work"]) main(["new-list", "work-log"]) main(["new-list", "work.meetings"]) @@ -381,30 +357,23 @@ def test_shows_sublist_under_parent(self, taskli_env, capsys): class TestNewListRmList: - def test_new_list_creates_empty_list(self, taskli_env, capsys): + def test_creates_empty_list(self, taskli_env, capsys): exit_code = main(["new-list", "groceries"]) captured = capsys.readouterr() assert exit_code == 0 assert "created list 'groceries'" in captured.out - def test_new_list_allows_former_action_word(self, taskli_env, capsys): - exit_code = main(["new-list", "add"]) + @pytest.mark.parametrize("reserved_word", sorted(TOP_LEVEL_COMMANDS)) + def test_rejects_reserved_words(self, taskli_env, capsys, reserved_word): + exit_code = main(["new-list", reserved_word]) captured = capsys.readouterr() - assert exit_code == 0 - assert "created list 'add'" in captured.out - - def test_new_list_rejects_reserved_top_level_word( - self, taskli_env, capsys - ): - exit_code = main(["new-list", "lists"]) - captured = capsys.readouterr() assert exit_code == 1 assert "reserved" in captured.err - def test_new_list_rejects_excessive_depth(self, taskli_env, capsys): + def test_rejects_excessive_depth(self, taskli_env, capsys): exit_code = main(["new-list", "work.meetings.boring.extra"]) captured = capsys.readouterr() @@ -412,22 +381,12 @@ def test_new_list_rejects_excessive_depth(self, taskli_env, capsys): assert exit_code == 1 assert "nested too deep" in captured.err - def test_new_list_with_color(self, taskli_env, capsys): + def test_new_list_creates_with_color(self, taskli_env, capsys): exit_code = main(["new-list", "groceries", "-c", "coral"]) assert exit_code == 0 assert load_list(taskli_env, "groceries").color is Color.CORAL - def test_new_list_with_color_shown_in_lists(self, taskli_env, capsys): - main(["new-list", "groceries", "-c", "coral"]) - capsys.readouterr() - - exit_code = main(["lists"]) - - captured = capsys.readouterr() - assert exit_code == 0 - assert "groceries" in captured.out - def test_new_list_rejects_invalid_color(self, taskli_env, capsys): exit_code = main(["new-list", "groceries", "-c", "notacolor"]) @@ -436,7 +395,7 @@ def test_new_list_rejects_invalid_color(self, taskli_env, capsys): assert exit_code == 2 assert "invalid choice" in captured.err - def test_rm_list_deletes_with_confirmation( + def test_rm_deletes_with_confirmation( self, taskli_env, monkeypatch, capsys ): monkeypatch.setattr("builtins.input", lambda _: "y") @@ -463,7 +422,7 @@ def test_new_sublist_creates_missing_parent(self, taskli_env, capsys): assert "work" in lists_captured.out assert "meetings" in lists_captured.out - def test_rm_list_cascades(self, taskli_env, monkeypatch, capsys): + def test_rm_cascades_to_children(self, taskli_env, monkeypatch, capsys): prompts = [] monkeypatch.setattr( "builtins.input", lambda p: prompts.append(p) or "y" @@ -495,18 +454,6 @@ def test_changes_color(self, taskli_env, capsys): assert exit_code == 0 assert load_list(taskli_env, "work").color is Color.TEAL - def test_colored_list_items_still_render(self, taskli_env, capsys): - main(["new-list", "work"]) - main(["work", "-a", "task"]) - main(["edit-list", "work", "-c", "teal"]) - capsys.readouterr() - - exit_code = main(["work"]) - - captured = capsys.readouterr() - assert exit_code == 0 - assert "task" in captured.out - def test_missing_list_errors(self, taskli_env, capsys): exit_code = main(["edit-list", "ghost", "-c", "teal"]) @@ -546,7 +493,7 @@ def test_bare_invocation_shows_inbox(self, taskli_env, capsys): assert exit_code == 0 assert "inbox" in captured.out - def test_list_name_alone_shows_items(self, taskli_env, capsys): + def test_list_name_shows_items(self, taskli_env, capsys): main(["work", "-a", "task"]) capsys.readouterr() @@ -581,7 +528,7 @@ def test_shows_child_section(self, taskli_env, capsys): assert "sub-task" in captured.out assert "meetings" in captured.out - def test_grandchild_shown_under_top_ancestor(self, taskli_env, capsys): + def test_grandchild_shown_under_parent(self, taskli_env, capsys): main(["work.meetings.notes", "-a", "deep-task"]) capsys.readouterr() @@ -596,7 +543,7 @@ def test_grandchild_shown_under_top_ancestor(self, taskli_env, capsys): assert "notes" in top_captured.out assert "deep-task" in mid_captured.out - def test_tag_filter_applies_to_child_sections(self, taskli_env, capsys): + def test_tag_filter_applies_to_sublists(self, taskli_env, capsys): main(["work", "-a", "a", "-t", "urgent"]) main(["work.meetings", "-a", "b", "-t", "urgent"]) main(["work.meetings", "-a", "c", "-t", "later"]) diff --git a/tests/unit/test_models.py b/tests/unit/test_models.py index ada433b..95237c6 100644 --- a/tests/unit/test_models.py +++ b/tests/unit/test_models.py @@ -37,7 +37,7 @@ def test_add_item_defaults(self): assert item.tags == [] assert item.done is False - def test_get_item_raises_for_missing_id(self): + def test_get_item_missing_id_raises(self): todo_list = TaskliList(name="work") with pytest.raises(ItemNotFoundError): @@ -70,7 +70,7 @@ def test_remove_item(self): assert todo_list.items == [] - def test_remove_done_items_removes_done_only(self): + def test_remove_done_items_keeps_open_items(self): todo_list = TaskliList(name="work") done_item = todo_list.add_item("done task") not_done_item = todo_list.add_item("open task") diff --git a/tests/unit/test_storage.py b/tests/unit/test_storage.py index ffe0dd5..0bc2789 100644 --- a/tests/unit/test_storage.py +++ b/tests/unit/test_storage.py @@ -42,14 +42,17 @@ def test_defaults_to_home_dotfolder(self, monkeypatch, tmp_path): class TestListHierarchyHelpers: - def test_parent_list_name_top_level(self): - assert parent_list_name("work") is None - - def test_parent_list_name_nested(self): - assert parent_list_name("work.meetings") == "work" - - def test_parent_list_name_deeply_nested(self): - assert parent_list_name("work.meetings.notes") == "work.meetings" + @pytest.mark.parametrize( + ("name", "expected"), + [ + ("work", None), + ("work.meetings", "work"), + ("work.meetings.notes", "work.meetings"), + ], + ids=["top-level", "nested", "deeply-nested"], + ) + def test_parent_list_name(self, name, expected): + assert parent_list_name(name) == expected def test_child_list_names_excludes_grandchildren(self): names = [ @@ -116,17 +119,14 @@ def test_create_list_with_color_persists_it(self, tmp_path): assert task_list.color is Color.CORAL assert load_list(tmp_path, "work").color is Color.CORAL - def test_list_file_path_rejects_trailing_dot(self, tmp_path): - with pytest.raises(InvalidListNameError): - list_file_path(tmp_path, "work.") - - def test_list_file_path_rejects_leading_dot(self, tmp_path): - with pytest.raises(InvalidListNameError): - list_file_path(tmp_path, ".meetings") - - def test_list_file_path_rejects_consecutive_dots(self, tmp_path): + @pytest.mark.parametrize( + "name", + ["work.", ".meetings", "work..meetings"], + ids=["trailing-dot", "leading-dot", "consecutive-dots"], + ) + def test_list_file_path_rejects_malformed_name(self, tmp_path, name): with pytest.raises(InvalidListNameError): - list_file_path(tmp_path, "work..meetings") + list_file_path(tmp_path, name) def test_list_file_path_allows_two_ancestor_levels(self, tmp_path): list_file_path(tmp_path, "work.meetings.boring")