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) <noreply@anthropic.com>
This commit is contained in:
parent
a934c46746
commit
e7ac2b174f
5 changed files with 435 additions and 0 deletions
|
|
@ -17,6 +17,8 @@
|
|||
python-telegram-bot
|
||||
python-dotenv
|
||||
aiohttp
|
||||
pytest
|
||||
pytest-asyncio
|
||||
]);
|
||||
|
||||
in
|
||||
|
|
|
|||
0
tests/__init__.py
Normal file
0
tests/__init__.py
Normal file
18
tests/conftest.py
Normal file
18
tests/conftest.py
Normal file
|
|
@ -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
|
||||
228
tests/test_db.py
Normal file
228
tests/test_db.py
Normal file
|
|
@ -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
|
||||
187
tests/test_parser.py
Normal file
187
tests/test_parser.py
Normal file
|
|
@ -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"
|
||||
Loading…
Add table
Add a link
Reference in a new issue