`[tool.ruff]` set a line length and a target version and nothing else, so which rules ran depended on the installed ruff's defaults — fine while nobody ran it, not fine for a CI job that can turn a push red because a tool changed its mind. The set is named explicitly (E, F, I, UP, SIM, B, RUF) with three ignores that are each a real decision rather than a shrug: B008, because FastAPI's whole dependency idiom is a call in a default argument; E501, because wrapping prose is a formatter's business; and RUF001, because the reports and the bot speak Russian and "ambiguous character" is the wrong complaint about a string the user is meant to read. The 73 findings that remained are fixed — mostly `datetime.timezone.utc` → `datetime.UTC`, `typing.Callable` → `collections.abc.Callable`, import order, and unused `noqa` markers — plus seven by hand: `contextlib.suppress` for two try/except/pass pairs, unpacking instead of list concatenation, one negated return. `ruff format` is deliberately *not* adopted: it would reformat 51 files, and some of the layout here is hand-set (the comment tables, the DNS wire-format structs) in ways a formatter flattens. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
108 lines
4.2 KiB
Python
108 lines
4.2 KiB
Python
"""Metrics, achievements and the rendered report."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from datetime import timedelta
|
|
|
|
from app.models import utcnow
|
|
from app.services import pomodoro, report, settings_service, stats, tasks_service
|
|
from app.timeutil import local_date
|
|
|
|
|
|
def _close(session, settings, task) -> None:
|
|
"""Simulate what a refresh does when a task has been closed in its own tool."""
|
|
task.is_done = True
|
|
task.completed_at = utcnow()
|
|
task.completed_date = local_date(settings.timezone)
|
|
session.commit()
|
|
|
|
|
|
def _run_pomodoros(session, settings, count: int, *, minutes: int = 25) -> None:
|
|
"""Record `count` finished work intervals, backdated so they do not overlap."""
|
|
for index in range(count):
|
|
row = pomodoro.start(session, settings, kind="work", minutes=minutes)
|
|
start = utcnow() - timedelta(minutes=(count - index) * (minutes + 5))
|
|
row.started_at = start
|
|
row.ended_at = start + timedelta(minutes=minutes)
|
|
row.actual_seconds = minutes * 60
|
|
row.status = "completed"
|
|
session.commit()
|
|
|
|
|
|
def test_day_metrics_counts_what_happened(session, settings):
|
|
_run_pomodoros(session, settings, 3)
|
|
metrics = stats.day_metrics(session, settings, local_date(settings.timezone))
|
|
assert metrics["pomodoros"] == 3
|
|
assert metrics["focus_minutes"] == 75
|
|
assert metrics["goal"] == settings.pomodoro.daily_goal_pomodoros
|
|
assert metrics["first_start"] and metrics["last_end"]
|
|
|
|
|
|
def test_longest_stretch_joins_adjacent_pomodoros(session, settings):
|
|
_run_pomodoros(session, settings, 3, minutes=25)
|
|
metrics = stats.day_metrics(session, settings, local_date(settings.timezone))
|
|
# 25-minute intervals five minutes apart are one unbroken stretch.
|
|
assert metrics["longest_stretch_minutes"] >= 75
|
|
|
|
|
|
def test_streak_survives_an_empty_today(session, settings):
|
|
today = local_date(settings.timezone)
|
|
_run_pomodoros(session, settings, 1)
|
|
|
|
# Move the work to yesterday and leave today empty.
|
|
from app.models import PomodoroSession
|
|
|
|
for row in session.query(PomodoroSession).all():
|
|
row.local_date = today - timedelta(days=1)
|
|
session.commit()
|
|
|
|
streak = stats.streak(session, settings, until=today)
|
|
assert streak["current"] == 1
|
|
|
|
|
|
def test_report_payload_has_the_pieces_the_ui_draws(session, settings):
|
|
settings.pomodoro.daily_goal_pomodoros = 2
|
|
settings_service.save_settings(session, settings)
|
|
_run_pomodoros(session, settings, 2)
|
|
task = tasks_service.create_local_task(session, title="Ship the thing")
|
|
_close(session, settings, task)
|
|
|
|
payload = report.build_daily(session, settings, local_date(settings.timezone))
|
|
assert payload["metrics"]["pomodoros"] == 2
|
|
assert payload["metrics"]["tasks_completed_count"] == 1
|
|
assert any(item["code"] == "goal_met" for item in payload["achievements"])
|
|
assert payload["fun_facts"]
|
|
assert 0 <= payload["score"] <= 100
|
|
|
|
|
|
def test_rendered_report_escapes_borrowed_text(session, settings):
|
|
task = tasks_service.create_local_task(session, title="Fix <b>&</b> ship")
|
|
_close(session, settings, task)
|
|
payload = report.build_daily(session, settings, local_date(settings.timezone))
|
|
text = report.render_daily_telegram(payload, settings)
|
|
assert "Fix <b>&</b> ship" in text
|
|
|
|
|
|
def test_russian_report_renders(session, settings):
|
|
settings.notifications.report.language = "ru"
|
|
settings.notifications.report.tone = "drill_sergeant"
|
|
settings_service.save_settings(session, settings)
|
|
_run_pomodoros(session, settings, 1)
|
|
payload = report.build_daily(session, settings, local_date(settings.timezone))
|
|
text = report.render_daily_telegram(payload, settings)
|
|
assert "Отчёт за день" in text
|
|
|
|
|
|
def test_weekly_review_covers_seven_days(session, settings):
|
|
_run_pomodoros(session, settings, 2)
|
|
payload = report.build_weekly(session, settings, local_date(settings.timezone))
|
|
assert len(payload["days"]) == 7
|
|
assert payload["pomodoros"] == 2
|
|
assert "Weekly review" in report.render_weekly_telegram(payload, settings)
|
|
|
|
|
|
def test_progress_bar_is_proportional():
|
|
assert report.progress_bar(0, 8) == "░" * 10
|
|
assert report.progress_bar(8, 8) == "█" * 10
|
|
assert report.progress_bar(4, 8).count("█") == 5
|