Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
b5f09b3
workaround a dateparser bug
viseshrp Dec 15, 2025
38dc03e
select UUID for efficient delete-subquery
viseshrp Dec 15, 2025
8086fac
Merge branch 'main' into maint/optim
viseshrp Dec 15, 2025
5e8ea8b
use prefetch to get tag data
viseshrp Dec 15, 2025
72e7ca1
Merge branch 'maint/optim' of https://github.com/viseshrp/workedon in…
viseshrp Dec 15, 2025
ebc3a84
add index for duration
viseshrp Dec 21, 2025
c225920
Revert "use prefetch to get tag data"
viseshrp Dec 23, 2025
3da33fe
Update uv.lock
viseshrp Dec 23, 2025
5f27073
fix partial date test
viseshrp Dec 23, 2025
d414491
add prefetch for tags
viseshrp Dec 23, 2025
527064e
Update README.md
viseshrp Dec 23, 2025
12e716b
Update workedon.py
viseshrp Dec 23, 2025
76ce41f
improve tag filter queries
viseshrp Dec 23, 2025
4d4a26a
delay COUNT query until later
viseshrp Dec 23, 2025
fbf7086
Update workedon.py
viseshrp Dec 24, 2025
2bbbe53
add tests by Codex and Claude
viseshrp Dec 24, 2025
09bdd8d
Merge branch 'main' into maint/optim
viseshrp Dec 24, 2025
24345c4
fix ruff warnings
viseshrp Dec 24, 2025
1ebc442
remove unnecessary guard and test
viseshrp Dec 24, 2025
c55ed46
Update test_integration.py
viseshrp Dec 24, 2025
e9699ab
Update test_integration.py
viseshrp Dec 24, 2025
3b104f7
fix lint
viseshrp Dec 24, 2025
780be7b
cleanup
viseshrp Dec 25, 2025
313cd82
Freeze every test at a fixed date
viseshrp Dec 25, 2025
b8e656e
fix flaky test_parse_datetime_edge_of_midnight with double freeze
viseshrp Dec 25, 2025
a868469
Update test_conf.py
viseshrp Dec 29, 2025
1f76a0e
Merge branch 'main' into maint/optim
viseshrp Jan 18, 2026
028152c
fix debug check
viseshrp Jan 19, 2026
ad1c488
refactor cli tests
viseshrp Jan 19, 2026
5f47d7b
Update test_utils.py
viseshrp Jan 23, 2026
9dd82e1
add check constraint for empty tags
viseshrp Jan 23, 2026
ab1b58c
normalize tags
viseshrp Feb 4, 2026
abeff07
update tag tests
viseshrp Feb 4, 2026
4bc8372
Reduce redundant CLI/parser test cases
viseshrp Feb 4, 2026
bee2fc8
Revert "Reduce redundant CLI/parser test cases"
viseshrp Feb 4, 2026
76c3ae2
Update lockfile to fix pip-audit
viseshrp Feb 4, 2026
eabaea1
Normalize fetch tag filters and document behavior
viseshrp Feb 10, 2026
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
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,8 @@ Options:
-g, --no-page Don't page the output.
-l, --text-only Output the work log text only.
-T, --tag TEXT Tag to filter by. Can be used multiple times to filter
by multiple tags.
by multiple tags. Tags are normalized (trimmed and
lowercased).
-D, --duration TEXT Duration to filter by. [default: ""]
--date-format TEXT Set the date format of the output. Must be a valid
Python strftime string. [env var:
Expand Down Expand Up @@ -189,6 +190,7 @@ Options:
- Tags can contain alphanumeric characters, underscores, and hyphens only.
- Query logged work by tags using the `--tag/-T` option. Using it multiple times will match any
of the specified tags.
- Filter tags are normalized the same way as saved tags (trimmed, lowercased, and empty values are ignored).
- Specify duration while adding work.
- Duration can be specified in two ways:
- The `--duration/-D` option, e.g. `--duration 1h30m` or `--duration 90m`.
Expand Down
9 changes: 3 additions & 6 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,10 @@ def runner() -> CliRunner:
@pytest.fixture(autouse=True, scope="session")
def freeze_clock_at_2359() -> Generator[None, None, None]:
"""
Freeze every test at today's date but at 23:59:00,
so relative date parsing (“yesterday”, “tomorrow”, etc.)
is always based off of 11:59 PM local time.
Freeze every test at a fixed date (23:59:00) so relative date parsing
(“yesterday”, “tomorrow”, etc.) is deterministic across runs.
"""
# capture now, then move to 23:59:00 of the same day
now = datetime.now()
target = now.replace(hour=23, minute=59, second=0, microsecond=0)
target = datetime(2024, 1, 10, 23, 59, 0)
with freeze_time(target):
yield

Expand Down
92 changes: 83 additions & 9 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
from __future__ import annotations

from datetime import datetime
import importlib
import re
import warnings

from click.testing import CliRunner, Result
import pytest

import workedon
from workedon import __version__, cli, exceptions
from workedon.conf import CONF_PATH

Expand Down Expand Up @@ -58,6 +61,38 @@ def test_empty_fetch(runner: CliRunner) -> None:
assert "Nothing to show" in result.output


def test_main_with_subcommand_returns_early(runner: CliRunner) -> None:
result = runner.invoke(cli.main, ["what", "--no-page"])
assert result.exit_code == 0


def test_cli_import_skips_warning_filter_in_debug(monkeypatch: pytest.MonkeyPatch) -> None:
called = {"value": False}

def record_call(*_args: object, **_kwargs: object) -> None:
called["value"] = True

monkeypatch.setenv("WORKEDON_DEBUG", "1")
monkeypatch.setattr(warnings, "filterwarnings", record_call)
importlib.reload(cli)
importlib.reload(workedon)

assert called["value"] is False


def test_cli_import_filters_warnings_without_debug(monkeypatch: pytest.MonkeyPatch) -> None:
called = {"value": False}

def record_call(*_args: object, **_kwargs: object) -> None:
called["value"] = True

monkeypatch.delenv("WORKEDON_DEBUG", raising=False)
monkeypatch.setattr(warnings, "filterwarnings", record_call)
importlib.reload(cli)
importlib.reload(workedon)
assert called["value"] is True


# -- Basic save & fetch scenarios ------------------------------------------------


Expand Down Expand Up @@ -100,6 +135,15 @@ def test_fetch_last(runner: CliRunner, command: str, description: str, valid: bo
assert description not in result.output


def test_fetch_last_returns_most_recent_entry(runner: CliRunner) -> None:
save_and_verify(runner, "first thing @ 3 days ago", "first thing")
save_and_verify(runner, "second thing @ yesterday", "second thing")

result = runner.invoke(cli.what, ["--no-page", "--last"])
verify_work_output(result, "second thing")
assert "first thing" not in result.output


# -- Fetch by ID ----------------------------------------------------------------


Expand Down Expand Up @@ -206,10 +250,6 @@ def test_timezone_option(
("learning to cook @ 3pm yesterday", ["-f", "2 days ago", "-t", "3:05pm yesterday"]), # 36
("watching tv @ 9am", ["-g"]), # 37
("taking wife shopping @ 3pm", ["--no-page"]), # 38
(
"weights at the gym",
["--count", "1"],
),
],
)
def test_save_and_fetch_others(runner: CliRunner, command: str, flag: list[str]) -> None:
Expand All @@ -220,6 +260,15 @@ def test_save_and_fetch_others(runner: CliRunner, command: str, flag: list[str])
verify_work_output(result, description)


def test_default_fetch_excludes_entries_older_than_week(runner: CliRunner) -> None:
save_and_verify(runner, "ancient history @ 10 days ago", "ancient history")
save_and_verify(runner, "fresh work @ yesterday", "fresh work")

result = runner.invoke(cli.what, ["--no-page"])
verify_work_output(result, "fresh work")
assert "ancient history" not in result.output


# -- Deletion --------------------------------------------------------------------


Expand Down Expand Up @@ -458,6 +507,7 @@ def test_weird_tag_parsing(runner: CliRunner, input_text: str, expected_tags: se
("working on #devtools", ["--tag", "devtools"], True),
("#in-progress cleanup", ["--tag", "in-progress"], True),
("writing code #DEV", ["--tag", "dev"], True), # case-insensitive
("writing code #DEV", ["--tag", " dev "], True), # surrounding whitespace ignored
("refactoring #code_review", ["--tag", "code_review"], True),
("invalid ##doubletag", ["--tag", "doubletag"], True),
("emoji #🔥", ["--tag", "🔥"], False),
Expand All @@ -477,6 +527,16 @@ def test_cli_tag_filter(
assert "Nothing to show" in result_fetch.output


def test_list_tags_outputs_saved_tags(runner: CliRunner) -> None:
runner.invoke(cli.main, ["first", "tag", "#alpha"])
runner.invoke(cli.main, ["second", "tag", "#beta"])

result = runner.invoke(cli.main, ["--list-tags"])
assert result.exit_code == 0
assert "alpha" in result.output.lower()
assert "beta" in result.output.lower()


# -- Duration ------------------------------------------------------------


Expand All @@ -494,13 +554,10 @@ def test_cli_tag_filter(
def test_cli_duration_parsing_and_display(
runner: CliRunner, input_text: str, expected_duration: str, xargs: list
) -> None:
result_save = runner.invoke(cli.main, input_text.split())
result_save = runner.invoke(cli.main, [*input_text.split(), *xargs])
assert result_save.exit_code == 0
assert "Work saved." in result_save.output

result_fetch = runner.invoke(cli.what, ["--no-page", "--last", *xargs])
assert result_fetch.exit_code == 0
assert expected_duration in result_fetch.output
assert expected_duration in result_save.output


@pytest.mark.parametrize(
Expand Down Expand Up @@ -561,3 +618,20 @@ def test_cli_duration_filter(
def test_invalid_duration_filter(runner: CliRunner, invalid_filter_flag: list[str]) -> None:
result = runner.invoke(cli.what, ["--no-page", *invalid_filter_flag])
assert result.exit_code == 1
assert "Invalid duration" in result.output


def test_cli_duration_option_overrides_inline_value(runner: CliRunner) -> None:
save_result = runner.invoke(cli.main, ["overridden task", "[30m]", "--duration", "2h"])
verify_work_output(save_result, "overridden task")

fetch_result = runner.invoke(cli.what, ["--no-page", "--last"])
assert "Duration: 120.0 minutes" in fetch_result.output


def test_cli_duration_option_ignored_when_invalid(runner: CliRunner) -> None:
save_result = runner.invoke(cli.main, ["keep inline", "[30m]", "--duration", "abc"])
verify_work_output(save_result, "keep inline")

fetch_result = runner.invoke(cli.what, ["--no-page", "--last"])
assert "Duration: 30.0 minutes" in fetch_result.output
88 changes: 88 additions & 0 deletions tests/test_conf.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
from pathlib import Path

import pytest

from workedon import conf
from workedon.conf import Settings
from workedon.constants import SETTINGS_HEADER
from workedon.exceptions import CannotCreateSettingsError, CannotLoadSettingsError


def test_settings_getattr_and_setattr() -> None:
settings = Settings()
settings.FOO = "bar"
assert settings.FOO == "bar"
assert settings["FOO"] == "bar"


def test_configure_creates_settings_file(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
conf_path = tmp_path / "wonfile.py"
monkeypatch.setattr(conf, "CONF_PATH", conf_path)

settings = Settings()
settings.configure()

assert conf_path.exists()
assert SETTINGS_HEADER.strip() in conf_path.read_text()


def test_configure_loads_user_settings(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
conf_path = tmp_path / "wonfile.py"
conf_path.write_text('TIME_FORMAT = "%H:%M"\n')
monkeypatch.setattr(conf, "CONF_PATH", conf_path)

settings = Settings()
settings.configure()

assert settings.TIME_FORMAT == "%H:%M"


def test_configure_merges_user_settings(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
conf_path = tmp_path / "wonfile.py"
conf_path.write_text('DATE_FORMAT = "%Y"\n')
monkeypatch.setattr(conf, "CONF_PATH", conf_path)

settings = Settings()
settings.configure(user_settings={"DATE_FORMAT": "%d"})

assert settings.DATE_FORMAT == "%d"


def test_configure_raises_on_bad_spec(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
conf_path = tmp_path / "wonfile.py"
conf_path.write_text("# ok\n")
monkeypatch.setattr(conf, "CONF_PATH", conf_path)
monkeypatch.setattr(conf, "spec_from_file_location", lambda *args, **kwargs: None)

settings = Settings()
with pytest.raises(CannotLoadSettingsError):
settings.configure()


def test_configure_raises_on_exec_module_failure(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
conf_path = tmp_path / "wonfile.py"
conf_path.write_text("raise RuntimeError('boom')\n")
monkeypatch.setattr(conf, "CONF_PATH", conf_path)

settings = Settings()
with pytest.raises(CannotLoadSettingsError) as excinfo:
settings.configure()
assert "boom" in str(excinfo.value)


def test_configure_raises_on_settings_file_creation_failure(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
conf_path = tmp_path / "wonfile.py"
monkeypatch.setattr(conf, "CONF_PATH", conf_path)

settings = Settings()

def blow_up() -> None:
raise OSError("nope")

monkeypatch.setattr(Settings, "_create_settings_file", blow_up)
with pytest.raises(CannotCreateSettingsError):
settings.configure()
8 changes: 8 additions & 0 deletions tests/test_constants.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
from workedon import constants


def test_constants_have_expected_values() -> None:
assert constants.APP_NAME == "workedon"
assert constants.CURRENT_DB_VERSION > 0
assert constants.WORK_CHUNK_SIZE > 0
assert "workedon settings file" in constants.SETTINGS_HEADER
10 changes: 10 additions & 0 deletions tests/test_default_settings.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
from workedon import default_settings


def test_default_settings_are_sane() -> None:
assert isinstance(default_settings.DATE_FORMAT, str)
assert isinstance(default_settings.TIME_FORMAT, str)
assert default_settings.DATETIME_FORMAT == ""
assert isinstance(default_settings.TIME_ZONE, str)
assert default_settings.TIME_ZONE
assert default_settings.DURATION_UNIT == "minutes"
32 changes: 32 additions & 0 deletions tests/test_exceptions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import pytest

from workedon import exceptions


@pytest.mark.parametrize(
"exc_cls, detail",
[
(exceptions.DBInitializationError, "Unable to initialize the database."),
(exceptions.CannotCreateSettingsError, "Unable to create settings file."),
(exceptions.CannotLoadSettingsError, "Unable to load settings file."),
(exceptions.InvalidWorkError, "The provided work text is invalid."),
(
exceptions.InvalidDateTimeError,
"The provided date/time is invalid. Please refer the docs for valid phrases.",
),
(exceptions.DateTimeInFutureError, "The provided date/time is in the future."),
(exceptions.StartDateAbsentError, "Please provide a start date/time."),
(
exceptions.StartDateGreaterError,
"The provided start date/time is greater than the end date/time.",
),
(exceptions.CannotSaveWorkError, "Unable to save your work."),
(exceptions.CannotFetchWorkError, "Unable to fetch your work."),
],
)
def test_exception_details_and_string_formatting(
exc_cls: type[exceptions.WorkedOnError], detail: str
) -> None:
assert str(exc_cls()) == detail
assert str(exc_cls(extra_detail="extra")) == f"{detail} :: extra"
assert str(exc_cls(extra_detail="")) == detail
Loading