Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -87,6 +88,7 @@ exclude_lines = [
]
show_missing = true
precision = 2
fail_under = 95

[tool.black]
line-length = 79
Expand Down
14 changes: 4 additions & 10 deletions src/taskli/render.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
135 changes: 41 additions & 94 deletions tests/unit/test_cli.py
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -11,24 +13,22 @@ 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()
assert exit_code == 0
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",
Expand All @@ -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
Expand Down Expand Up @@ -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()

Expand All @@ -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()
Expand Down Expand Up @@ -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"])
Expand All @@ -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()
Expand All @@ -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()

Expand All @@ -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()
Expand All @@ -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()
Expand All @@ -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()
Expand All @@ -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"])
Expand Down Expand Up @@ -381,53 +357,36 @@ 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()

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"])

Expand All @@ -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")
Expand All @@ -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"
Expand Down Expand Up @@ -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"])

Expand Down Expand Up @@ -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()

Expand Down Expand Up @@ -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()

Expand All @@ -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"])
Expand Down
4 changes: 2 additions & 2 deletions tests/unit/test_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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")
Expand Down
Loading
Loading