From e7ac2b174f7e0eca388002014132152a2e9eee41 Mon Sep 17 00:00:00 2001 From: Danny Date: Mon, 13 Apr 2026 20:41:03 +0200 Subject: [PATCH] feat(tg-fitness-bot): add test suite with pre-commit hook pytest + pytest-asyncio in flake.nix. 53 tests covering parser (all formats, error cases) and db (CRUD, soft delete, update, stats, pagination). Pre-commit hook runs tests when fitness bot files are staged. Co-Authored-By: Claude Opus 4.6 (1M context) --- flake.nix | 2 + tests/__init__.py | 0 tests/conftest.py | 18 ++++ tests/test_db.py | 228 +++++++++++++++++++++++++++++++++++++++++++ tests/test_parser.py | 187 +++++++++++++++++++++++++++++++++++ 5 files changed, 435 insertions(+) create mode 100644 tests/__init__.py create mode 100644 tests/conftest.py create mode 100644 tests/test_db.py create mode 100644 tests/test_parser.py diff --git a/flake.nix b/flake.nix index eaaa362..881d5c0 100644 --- a/flake.nix +++ b/flake.nix @@ -17,6 +17,8 @@ python-telegram-bot python-dotenv aiohttp + pytest + pytest-asyncio ]); in diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..641d670 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,18 @@ +import sys +from pathlib import Path + +import pytest + +# Add project root to path so tests can import modules directly +sys.path.insert(0, str(Path(__file__).parent.parent)) + + +@pytest.fixture +def tmp_db(monkeypatch, tmp_path): + """Provide a fresh SQLite database for each test.""" + import db + + db_file = tmp_path / "test_workouts.db" + monkeypatch.setattr(db, "DB_PATH", db_file) + db.init_db() + return db_file diff --git a/tests/test_db.py b/tests/test_db.py new file mode 100644 index 0000000..adf322f --- /dev/null +++ b/tests/test_db.py @@ -0,0 +1,228 @@ +from datetime import datetime, timezone, timedelta + +import db + + +# ── init_db ────────────────────────────────────────────────────── + + +class TestInitDb: + def test_tables_created(self, tmp_db): + with db.get_db() as conn: + tables = {r[0] for r in conn.execute( + "SELECT name FROM sqlite_master WHERE type='table'" + ).fetchall()} + assert "workouts" in tables + assert "superset_groups" in tables + assert "exercises" in tables + + def test_idempotent(self, tmp_db): + db.init_db() # second call should not raise + + def test_migrations_applied(self, tmp_db): + with db.get_db() as conn: + w_cols = {r[1] for r in conn.execute("PRAGMA table_info(workouts)").fetchall()} + e_cols = {r[1] for r in conn.execute("PRAGMA table_info(exercises)").fetchall()} + assert "raw_text" in w_cols + assert "sets_detail" in e_cols + + +# ── Helpers ────────────────────────────────────────────────────── + + +def _make_exercise(name="Bench", sets=3, reps=8, weight=35.0, machine_id=None): + detail = [{"reps": reps, "weight_kg": weight}] * sets + return { + "name": name, + "machine_id": machine_id, + "sets": sets, + "reps": reps, + "weight_kg": weight, + "sets_detail": detail, + "raw_line": f"{name}: {sets}x{reps}x{weight}", + } + + +def _save_simple(user_id=1, name="Bench", ts=None): + ts = ts or datetime.now(timezone.utc) + return db.save_workout( + user_id=user_id, + timestamp=ts, + superset_groups=[[_make_exercise(name=name)]], + raw_text=f"{name}: 3x8x35", + ) + + +# ── save_workout + get_workouts round-trip ─────────────────────── + + +class TestSaveAndGet: + def test_basic_round_trip(self, tmp_db): + wid = _save_simple() + workouts = db.get_workouts(user_id=1) + assert len(workouts) == 1 + w = workouts[0] + assert w["id"] == wid + assert len(w["superset_groups"]) == 1 + ex = w["superset_groups"][0][0] + assert ex["name"] == "Bench" + assert ex["sets"] == 3 + assert ex["reps"] == 8 + assert ex["weight_kg"] == 35.0 + + def test_sets_detail_round_trip(self, tmp_db): + detail = [{"reps": 8, "weight_kg": 25}, {"reps": 5, "weight_kg": 35}] + ex = { + "name": "Press", "machine_id": None, + "sets": 2, "reps": 8, "weight_kg": 25, + "sets_detail": detail, "raw_line": "Press: 8x25, 5x35", + } + db.save_workout(1, datetime.now(timezone.utc), [[ex]]) + workouts = db.get_workouts(1) + got = workouts[0]["superset_groups"][0][0]["sets_detail"] + assert got == detail + + def test_machine_id(self, tmp_db): + ex = _make_exercise(machine_id="3032") + db.save_workout(1, datetime.now(timezone.utc), [[ex]]) + workouts = db.get_workouts(1) + assert workouts[0]["superset_groups"][0][0]["machine_id"] == "3032" + + def test_raw_text_stored(self, tmp_db): + _save_simple() + workouts = db.get_workouts(1) + assert workouts[0]["raw_text"] == "Bench: 3x8x35" + + def test_newest_first(self, tmp_db): + t1 = datetime(2024, 1, 1, tzinfo=timezone.utc) + t2 = datetime(2024, 1, 2, tzinfo=timezone.utc) + t3 = datetime(2024, 1, 3, tzinfo=timezone.utc) + _save_simple(name="First", ts=t1) + _save_simple(name="Second", ts=t2) + _save_simple(name="Third", ts=t3) + workouts = db.get_workouts(1) + names = [w["superset_groups"][0][0]["name"] for w in workouts] + assert names == ["Third", "Second", "First"] + + def test_pagination(self, tmp_db): + for i in range(5): + _save_simple(ts=datetime(2024, 1, i + 1, tzinfo=timezone.utc)) + page1 = db.get_workouts(1, limit=2, offset=0) + page2 = db.get_workouts(1, limit=2, offset=2) + page3 = db.get_workouts(1, limit=2, offset=4) + assert len(page1) == 2 + assert len(page2) == 2 + assert len(page3) == 1 + + +# ── delete_workout ─────────────────────────────────────────────── + + +class TestDeleteWorkout: + def test_delete_success(self, tmp_db): + wid = _save_simple() + assert db.delete_workout(user_id=1, workout_id=wid) is True + assert db.get_workouts(1) == [] # not visible + + def test_soft_delete_preserves_row(self, tmp_db): + wid = _save_simple() + db.delete_workout(user_id=1, workout_id=wid) + # Row still exists with deleted_at set + with db.get_db() as conn: + row = conn.execute("SELECT deleted_at FROM workouts WHERE id = ?", (wid,)).fetchone() + assert row is not None + assert row["deleted_at"] is not None + + def test_delete_nonexistent(self, tmp_db): + assert db.delete_workout(user_id=1, workout_id=999) is False + + def test_delete_wrong_user(self, tmp_db): + wid = _save_simple(user_id=1) + assert db.delete_workout(user_id=2, workout_id=wid) is False + assert len(db.get_workouts(1)) == 1 # still there + + def test_delete_idempotent(self, tmp_db): + wid = _save_simple() + assert db.delete_workout(1, wid) is True + assert db.delete_workout(1, wid) is False # already deleted + + +# ── update_workout ─────────────────────────────────────────────── + + +class TestUpdateWorkout: + def test_update_preserves_timestamp(self, tmp_db): + t = datetime(2024, 6, 15, 10, 0, tzinfo=timezone.utc) + wid = db.save_workout(1, t, [[_make_exercise(name="Old")]]) + new_id = db.update_workout(1, wid, [[_make_exercise(name="New")]]) + assert new_id is not None + assert new_id != wid + workouts = db.get_workouts(1) + assert len(workouts) == 1 + assert workouts[0]["superset_groups"][0][0]["name"] == "New" + assert workouts[0]["timestamp"] == t.isoformat() + + def test_update_soft_deletes_old(self, tmp_db): + wid = _save_simple() + db.update_workout(1, wid, [[_make_exercise(name="Updated")]]) + # Old workout should have deleted_at set + with db.get_db() as conn: + row = conn.execute("SELECT deleted_at FROM workouts WHERE id = ?", (wid,)).fetchone() + assert row["deleted_at"] is not None + + def test_update_nonexistent(self, tmp_db): + assert db.update_workout(1, 999, [[_make_exercise()]]) is None + + def test_update_wrong_user(self, tmp_db): + wid = _save_simple(user_id=1) + assert db.update_workout(2, wid, [[_make_exercise()]]) is None + assert len(db.get_workouts(1)) == 1 # unchanged + + def test_update_with_note(self, tmp_db): + wid = _save_simple() + new_id = db.update_workout(1, wid, [[_make_exercise()]], note="Updated note") + workouts = db.get_workouts(1) + assert workouts[0]["note"] == "Updated note" + + +# ── get_workout_count ──────────────────────────────────────────── + + +class TestGetWorkoutCount: + def test_zero(self, tmp_db): + assert db.get_workout_count(1) == 0 + + def test_counts(self, tmp_db): + _save_simple() + _save_simple() + assert db.get_workout_count(1) == 2 + + +# ── get_stats_sql ──────────────────────────────────────────────── + + +class TestGetStatsSql: + def test_empty(self, tmp_db): + stats = db.get_stats_sql(1) + assert stats["total_workouts"] == 0 + assert stats["total_volume"] == 0 + + def test_volume_calculation(self, tmp_db): + # 3 sets x 10 reps x 50kg = 1500kg volume + ex = _make_exercise(sets=3, reps=10, weight=50.0) + db.save_workout(1, datetime.now(timezone.utc), [[ex]]) + stats = db.get_stats_sql(1) + assert stats["total_workouts"] == 1 + assert stats["total_sets"] == 3 + assert stats["total_volume"] == 1500.0 + + def test_unique_exercises(self, tmp_db): + db.save_workout(1, datetime.now(timezone.utc), [ + [_make_exercise(name="Bench")], + [_make_exercise(name="Squats")], + ]) + db.save_workout(1, datetime.now(timezone.utc), [ + [_make_exercise(name="bench")], # same exercise, different case + ]) + stats = db.get_stats_sql(1) + assert stats["unique_exercises"] == 2 # bench + squats diff --git a/tests/test_parser.py b/tests/test_parser.py new file mode 100644 index 0000000..e14414b --- /dev/null +++ b/tests/test_parser.py @@ -0,0 +1,187 @@ +from parser import parse_exercise_line, parse_workout, format_workout, _fmt_weight + + +# ── parse_exercise_line ────────────────────────────────────────── + + +class TestParseExerciseLine: + def test_classic_format(self): + ex = parse_exercise_line("Bench press: 4x8x35") + assert ex.name == "Bench press" + assert ex.sets == 4 + assert ex.reps == 8 + assert ex.weight_kg == 35.0 + assert len(ex.sets_detail) == 4 + assert all(s.reps == 8 and s.weight_kg == 35.0 for s in ex.sets_detail) + + def test_bodyweight(self): + ex = parse_exercise_line("Pull-ups: 3x10") + assert ex.sets == 3 + assert ex.reps == 10 + assert ex.weight_kg == 0.0 + + def test_per_set_varying(self): + ex = parse_exercise_line("Shoulder press: 8x25, 5x35, 6x40") + assert ex.sets == 3 + assert ex.sets_detail[0].reps == 8 + assert ex.sets_detail[0].weight_kg == 25.0 + assert ex.sets_detail[1].reps == 5 + assert ex.sets_detail[1].weight_kg == 35.0 + assert ex.sets_detail[2].reps == 6 + assert ex.sets_detail[2].weight_kg == 40.0 + + def test_per_set_bodyweight(self): + ex = parse_exercise_line("Pull-ups: 12, 10, 8") + assert ex.sets == 3 + assert [s.reps for s in ex.sets_detail] == [12, 10, 8] + assert all(s.weight_kg == 0.0 for s in ex.sets_detail) + + def test_machine_id(self): + ex = parse_exercise_line("Shoulder press (3032): 4x8x25") + assert ex.machine_id == "3032" + assert ex.name == "Shoulder press" + + def test_machine_id_with_per_set(self): + ex = parse_exercise_line("Butterfly chest (5014): 7x40, 7x40, 5x45") + assert ex.machine_id == "5014" + assert ex.sets == 3 + + def test_asterisk_separator(self): + ex = parse_exercise_line("Deadlift: 3*5*100") + assert ex.sets == 3 + assert ex.reps == 5 + assert ex.weight_kg == 100.0 + + def test_decimal_weight(self): + ex = parse_exercise_line("Bench: 3x8x22.5") + assert ex.weight_kg == 22.5 + + def test_no_colon_returns_none(self): + assert parse_exercise_line("just text") is None + + def test_bad_format_after_colon_returns_none(self): + assert parse_exercise_line("Foo: abc") is None + + def test_empty_string_returns_none(self): + assert parse_exercise_line("") is None + + def test_whitespace_only_returns_none(self): + assert parse_exercise_line(" ") is None + + +# ── parse_workout ──────────────────────────────────────────────── + + +class TestParseWorkout: + def test_single_line(self): + groups, errors = parse_workout("Bench: 4x8x35") + assert len(groups) == 1 + assert len(groups[0]) == 1 + assert groups[0][0].name == "Bench" + + def test_superset_consecutive_lines(self): + groups, errors = parse_workout("Bench: 4x8x35\nCurls: 3x10x15") + assert len(groups) == 1 + assert len(groups[0]) == 2 + + def test_blank_line_separates_groups(self): + groups, errors = parse_workout("Bench: 4x8x35\n\nSquats: 5x5x60") + assert len(groups) == 2 + assert len(groups[0]) == 1 + assert len(groups[1]) == 1 + + def test_errors_collected(self): + groups, errors = parse_workout("Bench: 4x8x35\nBad: nope\nSquats: 5x5x60") + assert len(groups) == 1 # bench and squats in one group (no blank line) + assert len(groups[0]) == 2 + assert len(errors) == 1 + assert "Bad: nope" in errors[0].line + + def test_all_lines_fail(self): + groups, errors = parse_workout("Bad: nope\nAlso bad: xyz") + assert len(groups) == 0 + assert len(errors) == 2 + + def test_lines_without_colon_skipped_silently(self): + groups, errors = parse_workout("Note: this is a header\nBench: 4x8x35") + # "Note: this is a header" has a colon but fails parse → error + # Actually let me check: it tries to parse "this is a header" as sets + # which fails, so it IS an error + assert len(groups) == 1 + assert len(errors) == 1 + + def test_plain_text_no_colon_no_error(self): + groups, errors = parse_workout("just a note\nBench: 4x8x35") + assert len(groups) == 1 + assert len(errors) == 0 + + def test_empty_text(self): + groups, errors = parse_workout("") + assert groups == [] + assert errors == [] + + def test_full_workout(self): + text = """Shoulder press (3032): 8x25, 5x35, 6x40, 6x40 +Butterfly chest (5014): 7x40, 7x40, 5x45 +Bicep curl machine: 10x20, 5x25, 4x25 +Ab curls: 3x7x70""" + groups, errors = parse_workout(text) + assert len(errors) == 0 + assert len(groups) == 1 # all consecutive = one superset group + assert len(groups[0]) == 4 + + +# ── format_workout ─────────────────────────────────────────────── + + +class TestFormatWorkout: + def test_uniform_sets_compact(self): + data = [[{"name": "Bench", "machine_id": None, "sets": 4, "reps": 8, + "weight_kg": 35, "sets_detail": [{"reps": 8, "weight_kg": 35}] * 4}]] + result = format_workout(data) + assert "4x8x35kg" in result + + def test_varying_sets_listed(self): + data = [[{"name": "Press", "machine_id": None, "sets": 2, "reps": 8, + "weight_kg": 25, "sets_detail": [ + {"reps": 8, "weight_kg": 25}, {"reps": 5, "weight_kg": 35}]}]] + result = format_workout(data) + assert "8x25kg" in result + assert "5x35kg" in result + + def test_machine_id_shown(self): + data = [[{"name": "Press", "machine_id": "3032", "sets": 1, "reps": 8, + "weight_kg": 25, "sets_detail": [{"reps": 8, "weight_kg": 25}]}]] + result = format_workout(data) + assert "(3032)" in result + + def test_bodyweight_omits_kg(self): + data = [[{"name": "Pull-ups", "machine_id": None, "sets": 3, "reps": 10, + "weight_kg": 0, "sets_detail": [{"reps": 10, "weight_kg": 0}] * 3}]] + result = format_workout(data) + assert "kg" not in result + assert "3x10" in result + + def test_superset_label(self): + data = [[ + {"name": "A", "machine_id": None, "sets": 1, "reps": 10, "weight_kg": 20, + "sets_detail": [{"reps": 10, "weight_kg": 20}]}, + {"name": "B", "machine_id": None, "sets": 1, "reps": 10, "weight_kg": 20, + "sets_detail": [{"reps": 10, "weight_kg": 20}]}, + ]] + result = format_workout(data) + assert "Superset" in result + + +# ── _fmt_weight ────────────────────────────────────────────────── + + +class TestFmtWeight: + def test_integer(self): + assert _fmt_weight(70.0) == "70" + + def test_fractional(self): + assert _fmt_weight(22.5) == "22.5" + + def test_zero(self): + assert _fmt_weight(0.0) == "0"