[workouts] Add workouts app with exercise catalog and workout routines (ca613753)
This commit is contained in:
0
tests/workouts_tests/__init__.py
Normal file
0
tests/workouts_tests/__init__.py
Normal file
55
tests/workouts_tests/conftest.py
Normal file
55
tests/workouts_tests/conftest.py
Normal file
@ -0,0 +1,55 @@
|
||||
import pytest
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.utils import timezone
|
||||
from scrobbles.models import Scrobble
|
||||
from workouts.models import Exercise, WorkoutLogData, WorkoutRoutine
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def user(db):
|
||||
return User.objects.create(email="lifter@example.com")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def exercise(db):
|
||||
return Exercise.objects.create(
|
||||
name="Deadlift",
|
||||
force="pull",
|
||||
level="intermediate",
|
||||
mechanic="compound",
|
||||
equipment="barbell",
|
||||
category="strength",
|
||||
primary_muscles=["lower back"],
|
||||
secondary_muscles=["glutes"],
|
||||
instructions=["Step 1", "Step 2"],
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def workout_routine(db):
|
||||
return WorkoutRoutine.objects.create(title="Test Routine")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def workout_scrobble(user, workout_routine, exercise):
|
||||
return Scrobble.objects.create(
|
||||
user=user,
|
||||
workout_routine=workout_routine,
|
||||
media_type=Scrobble.MediaType.WORKOUT,
|
||||
timestamp=timezone.now(),
|
||||
played_to_completion=True,
|
||||
log={
|
||||
"workouts": [
|
||||
{"exercise_id": exercise.id, "sets": 3, "reps": 10, "weight_kg": 50.0}
|
||||
],
|
||||
"duration_minutes": 45,
|
||||
"bodyweight_kg": 80.0,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def logdata():
|
||||
return WorkoutLogData()
|
||||
114
tests/workouts_tests/test_forms.py
Normal file
114
tests/workouts_tests/test_forms.py
Normal file
@ -0,0 +1,114 @@
|
||||
from django.http import QueryDict
|
||||
from workouts.forms import WorkoutSetsField, WorkoutSetsWidget
|
||||
from workouts.models import WorkoutLogData
|
||||
from workouts.utils import lbs_to_kg
|
||||
|
||||
|
||||
def build_post_data(exercise_id, rows):
|
||||
qd = QueryDict("", mutable=True)
|
||||
qd.setlist("workouts_exercise_id", [str(r["exercise_id"]) for r in rows])
|
||||
qd.setlist("workouts_sets", [str(r.get("sets") or "") for r in rows])
|
||||
qd.setlist("workouts_reps", [str(r.get("reps") or "") for r in rows])
|
||||
qd.setlist("workouts_weight", [str(r.get("weight") or "") for r in rows])
|
||||
qd.setlist("workouts_notes", [r.get("notes") or "" for r in rows])
|
||||
return qd
|
||||
|
||||
|
||||
class TestWorkoutSetsField:
|
||||
def field_value(self, field, qd):
|
||||
return field.widget.value_from_datadict(qd, {}, "workouts")
|
||||
|
||||
def test_clean_metric(self, exercise):
|
||||
field = WorkoutSetsField(units="metric")
|
||||
qd = build_post_data(
|
||||
exercise.id,
|
||||
[{"exercise_id": exercise.id, "sets": 3, "reps": 10, "weight": 50}],
|
||||
)
|
||||
result = field.clean(self.field_value(field, qd))
|
||||
assert result[0]["exercise_id"] == exercise.id
|
||||
assert result[0]["sets"] == 3
|
||||
assert result[0]["reps"] == 10
|
||||
assert result[0]["weight_kg"] == 50.0
|
||||
|
||||
def test_clean_imperial(self, exercise):
|
||||
field = WorkoutSetsField(units="imperial")
|
||||
qd = build_post_data(
|
||||
exercise.id,
|
||||
[{"exercise_id": exercise.id, "sets": 3, "reps": 10, "weight": 45}],
|
||||
)
|
||||
result = field.clean(self.field_value(field, qd))
|
||||
assert result[0]["weight_kg"] == lbs_to_kg(45)
|
||||
|
||||
def test_clean_empty(self, exercise):
|
||||
field = WorkoutSetsField(units="metric")
|
||||
qd = build_post_data(
|
||||
exercise.id, [{"exercise_id": "", "sets": "", "reps": "", "weight": ""}]
|
||||
)
|
||||
assert field.clean(self.field_value(field, qd)) is None
|
||||
|
||||
def test_clean_missing_exercise(self, exercise):
|
||||
field = WorkoutSetsField(units="metric")
|
||||
qd = build_post_data(
|
||||
exercise.id,
|
||||
[{"exercise_id": "", "sets": 3, "reps": 10, "weight": 50}],
|
||||
)
|
||||
assert field.clean(self.field_value(field, qd)) is None
|
||||
|
||||
|
||||
class TestWorkoutLogDataForm:
|
||||
def test_form_valid_metric(self, exercise):
|
||||
form = WorkoutLogData.form()(
|
||||
build_post_data(
|
||||
exercise.id,
|
||||
[
|
||||
{"exercise_id": exercise.id, "sets": 4, "reps": 8, "weight": 45},
|
||||
{
|
||||
"exercise_id": exercise.id,
|
||||
"sets": 3,
|
||||
"reps": 12,
|
||||
"weight": 30,
|
||||
"notes": "drop set",
|
||||
},
|
||||
],
|
||||
)
|
||||
)
|
||||
WorkoutLogData.prepare_form(form, "metric")
|
||||
assert form.is_valid(), form.errors
|
||||
data = form.cleaned_data
|
||||
assert data["workouts"][0]["sets"] == 4
|
||||
assert data["workouts"][0]["weight_kg"] == 45.0
|
||||
assert data["workouts"][1]["notes"] == "drop set"
|
||||
|
||||
def test_form_valid_imperial(self, exercise):
|
||||
form = WorkoutLogData.form()(
|
||||
build_post_data(
|
||||
exercise.id,
|
||||
[{"exercise_id": exercise.id, "sets": 3, "reps": 10, "weight": 100}],
|
||||
)
|
||||
)
|
||||
WorkoutLogData.prepare_form(form, "imperial")
|
||||
assert form.is_valid(), form.errors
|
||||
data = form.cleaned_data
|
||||
assert data["workouts"][0]["weight_kg"] == lbs_to_kg(100)
|
||||
|
||||
def test_form_bodyweight_imperial(self):
|
||||
qd = QueryDict("", mutable=True)
|
||||
qd["bodyweight_kg"] = "180.5"
|
||||
form = WorkoutLogData.form()(qd)
|
||||
WorkoutLogData.prepare_form(form, "imperial")
|
||||
assert form.is_valid(), form.errors
|
||||
data = form.cleaned_data.copy()
|
||||
WorkoutLogData.normalize_form_data(data, "imperial")
|
||||
assert data["bodyweight_kg"] == lbs_to_kg(180.5)
|
||||
|
||||
|
||||
class TestWorkoutSetsWidget:
|
||||
def test_value_from_datadict(self, exercise):
|
||||
widget = WorkoutSetsWidget()
|
||||
qd = build_post_data(
|
||||
exercise.id,
|
||||
[{"exercise_id": exercise.id, "sets": 3, "reps": 10, "weight": 50}],
|
||||
)
|
||||
value = widget.value_from_datadict(qd, {}, "workouts")
|
||||
assert value["exercise_id"] == [str(exercise.id)]
|
||||
assert value["weight"] == ["50"]
|
||||
114
tests/workouts_tests/test_importer.py
Normal file
114
tests/workouts_tests/test_importer.py
Normal file
@ -0,0 +1,114 @@
|
||||
import json
|
||||
import os
|
||||
import tarfile
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
from workouts.importer import (
|
||||
_find_root,
|
||||
_image_paths_for,
|
||||
import_wrkout_exercises,
|
||||
load_exercises_data,
|
||||
)
|
||||
from workouts.models import Exercise
|
||||
|
||||
SAMPLE_EXERCISE = {
|
||||
"name": "3/4 Sit-Up",
|
||||
"force": "pull",
|
||||
"level": "beginner",
|
||||
"mechanic": "compound",
|
||||
"equipment": "body only",
|
||||
"primaryMuscles": ["abdominals"],
|
||||
"secondaryMuscles": [],
|
||||
"instructions": ["Lie down.", "Sit up."],
|
||||
"category": "strength",
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_tarball():
|
||||
with tempfile.NamedTemporaryFile(suffix=".tar.gz", delete=False) as f:
|
||||
tarball_path = f.name
|
||||
with tempfile.TemporaryDirectory() as src:
|
||||
root = os.path.join(src, "exercises.json-master")
|
||||
exercise_dir = os.path.join(root, "exercises", "3_4_Sit-Up")
|
||||
images_dir = os.path.join(exercise_dir, "images")
|
||||
os.makedirs(images_dir)
|
||||
with open(os.path.join(exercise_dir, "exercise.json"), "w") as f:
|
||||
json.dump(SAMPLE_EXERCISE, f)
|
||||
with open(os.path.join(images_dir, "0.jpg"), "wb") as f:
|
||||
f.write(b"\xff\xd8\xff\xe0fakejpeg")
|
||||
with tarfile.open(tarball_path, "w:gz") as tf:
|
||||
for dirpath, dirnames, filenames in os.walk(src):
|
||||
for filename in filenames:
|
||||
full = os.path.join(dirpath, filename)
|
||||
arcname = os.path.relpath(full, src)
|
||||
tf.add(full, arcname=arcname)
|
||||
return tarball_path
|
||||
|
||||
|
||||
class TestHelpers:
|
||||
def test_find_root(self, sample_tarball):
|
||||
import tempfile
|
||||
|
||||
dest = tempfile.mkdtemp()
|
||||
with tarfile.open(sample_tarball, "r:gz") as tf:
|
||||
tf.extractall(dest)
|
||||
root = _find_root(dest)
|
||||
assert root.endswith("exercises.json-master")
|
||||
|
||||
def test_load_exercises_data(self, sample_tarball):
|
||||
import tempfile
|
||||
|
||||
dest = tempfile.mkdtemp()
|
||||
with tarfile.open(sample_tarball, "r:gz") as tf:
|
||||
tf.extractall(dest)
|
||||
root = _find_root(dest)
|
||||
data = load_exercises_data(root)
|
||||
assert len(data) == 1
|
||||
assert data[0]["name"] == "3/4 Sit-Up"
|
||||
assert os.path.isdir(data[0]["_dir"])
|
||||
|
||||
def test_image_paths_for(self, sample_tarball):
|
||||
import tempfile
|
||||
|
||||
dest = tempfile.mkdtemp()
|
||||
with tarfile.open(sample_tarball, "r:gz") as tf:
|
||||
tf.extractall(dest)
|
||||
root = _find_root(dest)
|
||||
data = load_exercises_data(root)
|
||||
paths = _image_paths_for(data[0]["_dir"])
|
||||
assert len(paths) == 1
|
||||
assert paths[0].endswith("0.jpg")
|
||||
|
||||
|
||||
class TestImport:
|
||||
def test_import_creates_exercises(self, db, sample_tarball):
|
||||
result = import_wrkout_exercises(tarball_path=sample_tarball)
|
||||
assert result["created"] == 1
|
||||
exercise = Exercise.objects.get(name="3/4 Sit-Up")
|
||||
assert exercise.level == "beginner"
|
||||
assert exercise.primary_muscles == ["abdominals"]
|
||||
assert exercise.instructions == ["Lie down.", "Sit up."]
|
||||
assert exercise.photo
|
||||
|
||||
def test_import_dry_run(self, db, sample_tarball):
|
||||
result = import_wrkout_exercises(tarball_path=sample_tarball, dry_run=True)
|
||||
assert result["created"] == 0
|
||||
assert Exercise.objects.count() == 0
|
||||
|
||||
def test_import_no_images(self, db, sample_tarball):
|
||||
result = import_wrkout_exercises(
|
||||
tarball_path=sample_tarball, import_images=False
|
||||
)
|
||||
assert result["created"] == 1
|
||||
exercise = Exercise.objects.get(name="3/4 Sit-Up")
|
||||
assert not exercise.photo
|
||||
|
||||
def test_import_updates_existing(self, db, sample_tarball):
|
||||
Exercise.objects.create(name="3/4 Sit-Up", level="beginner")
|
||||
result = import_wrkout_exercises(
|
||||
tarball_path=sample_tarball, import_images=False
|
||||
)
|
||||
assert result["created"] == 0
|
||||
assert result["updated"] == 1
|
||||
120
tests/workouts_tests/test_models.py
Normal file
120
tests/workouts_tests/test_models.py
Normal file
@ -0,0 +1,120 @@
|
||||
from workouts.models import (
|
||||
Exercise,
|
||||
WorkoutLogData,
|
||||
WorkoutRoutine,
|
||||
WorkoutSetEntry,
|
||||
)
|
||||
from workouts.utils import (
|
||||
display_value,
|
||||
display_weight,
|
||||
kg_to_lbs,
|
||||
lbs_to_kg,
|
||||
normalize_weight,
|
||||
)
|
||||
|
||||
|
||||
class TestWeightUtils:
|
||||
def test_lbs_to_kg(self):
|
||||
assert lbs_to_kg(1) == 0.45
|
||||
assert lbs_to_kg(100) == 45.36
|
||||
|
||||
def test_kg_to_lbs(self):
|
||||
assert kg_to_lbs(1) == 2.2
|
||||
assert kg_to_lbs(45.36) == 100.0
|
||||
|
||||
def test_normalize_weight_metric(self):
|
||||
assert normalize_weight("50", "metric") == 50.0
|
||||
|
||||
def test_normalize_weight_imperial(self):
|
||||
assert normalize_weight("45", "imperial") == lbs_to_kg(45)
|
||||
|
||||
def test_normalize_weight_empty(self):
|
||||
assert normalize_weight("", "metric") is None
|
||||
assert normalize_weight(None, "metric") is None
|
||||
|
||||
def test_display_value(self):
|
||||
assert display_value(50, "metric") == 50.0
|
||||
assert display_value(45.36, "imperial") == 100.0
|
||||
|
||||
def test_display_weight(self):
|
||||
assert display_weight(50, "metric") == "50.0 kg"
|
||||
assert display_weight(45.36, "imperial") == "100.0 lbs"
|
||||
|
||||
|
||||
class TestExerciseModel:
|
||||
def test_str(self, exercise):
|
||||
assert str(exercise) == "Deadlift"
|
||||
|
||||
def test_find_or_create(self, exercise):
|
||||
found = Exercise.find_or_create("deadlift")
|
||||
assert found.id == exercise.id
|
||||
new = Exercise.find_or_create("New Exercise")
|
||||
assert new.id != exercise.id
|
||||
|
||||
|
||||
class TestWorkoutRoutineModel:
|
||||
def test_media_type_label(self, workout_routine):
|
||||
assert workout_routine.media_type_label == "Workout"
|
||||
|
||||
def test_find_or_create(self, workout_routine):
|
||||
found = WorkoutRoutine.find_or_create("test routine")
|
||||
assert found.id == workout_routine.id
|
||||
|
||||
def test_logdata_cls(self, workout_routine):
|
||||
assert workout_routine.logdata_cls is WorkoutLogData
|
||||
|
||||
|
||||
class TestWorkoutLogData:
|
||||
def test_as_html_metric(self, workout_scrobble, exercise):
|
||||
html = workout_scrobble.logdata.as_html()
|
||||
assert "Deadlift" in html
|
||||
assert "3 x 10" in html
|
||||
assert "50.0 kg" in html
|
||||
|
||||
def test_as_html_imperial(self, workout_scrobble, exercise):
|
||||
html = workout_scrobble.logdata.as_html(units="imperial")
|
||||
assert "110.2 lbs" in html
|
||||
|
||||
def test_workout_list(self, workout_scrobble):
|
||||
assert "Deadlift" in workout_scrobble.logdata.workout_list
|
||||
|
||||
def test_from_log_dict_roundtrip(self, workout_scrobble):
|
||||
log_dict = workout_scrobble.log
|
||||
data = WorkoutLogData.from_log_dict(log_dict)
|
||||
assert (
|
||||
data["workouts"][0]["exercise_id"]
|
||||
== workout_scrobble.log["workouts"][0]["exercise_id"]
|
||||
)
|
||||
restored = WorkoutLogData(**data)
|
||||
assert restored.workouts[0]["sets"] == 3
|
||||
assert restored.workouts[0]["weight_kg"] == 50.0
|
||||
entry = WorkoutSetEntry(**restored.workouts[0])
|
||||
assert entry.sets == 3
|
||||
assert entry.weight_kg == 50.0
|
||||
|
||||
def test_override_fields(self):
|
||||
fields = WorkoutLogData.override_fields()
|
||||
assert "workouts" in fields
|
||||
assert "with_people_ids" in fields
|
||||
|
||||
def test_prepare_form_metric(self, logdata):
|
||||
form = WorkoutLogData.form()()
|
||||
WorkoutLogData.prepare_form(form, "metric")
|
||||
assert form.fields["workouts"].units == "metric"
|
||||
assert form.fields["bodyweight_kg"].label == "Bodyweight (kg)"
|
||||
|
||||
def test_prepare_form_imperial(self, logdata):
|
||||
form = WorkoutLogData.form()()
|
||||
WorkoutLogData.prepare_form(form, "imperial")
|
||||
assert form.fields["workouts"].units == "imperial"
|
||||
assert form.fields["bodyweight_kg"].label == "Bodyweight (lbs)"
|
||||
|
||||
def test_normalize_form_data_imperial(self, logdata):
|
||||
data = {"bodyweight_kg": 180.5}
|
||||
WorkoutLogData.normalize_form_data(data, "imperial")
|
||||
assert data["bodyweight_kg"] == lbs_to_kg(180.5)
|
||||
|
||||
def test_normalize_form_data_metric_no_change(self, logdata):
|
||||
data = {"bodyweight_kg": 80.0}
|
||||
WorkoutLogData.normalize_form_data(data, "metric")
|
||||
assert data["bodyweight_kg"] == 80.0
|
||||
105
tests/workouts_tests/test_views.py
Normal file
105
tests/workouts_tests/test_views.py
Normal file
@ -0,0 +1,105 @@
|
||||
from django.test import Client
|
||||
from django.urls import reverse
|
||||
from scrobbles.models import Scrobble
|
||||
|
||||
|
||||
class TestExerciseViews:
|
||||
def test_exercise_list_anonymous_redirects(self, db):
|
||||
client = Client()
|
||||
response = client.get(reverse("workouts:exercise_list"))
|
||||
assert response.status_code == 302
|
||||
|
||||
def test_exercise_list_authenticated(self, db, user, exercise):
|
||||
client = Client()
|
||||
client.force_login(user)
|
||||
response = client.get(reverse("workouts:exercise_list"))
|
||||
assert response.status_code == 200
|
||||
assert "Deadlift" in response.content.decode()
|
||||
|
||||
def test_exercise_detail(self, db, user, exercise):
|
||||
client = Client()
|
||||
client.force_login(user)
|
||||
response = client.get(
|
||||
reverse("workouts:exercise_detail", kwargs={"slug": exercise.uuid})
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert "Deadlift" in response.content.decode()
|
||||
|
||||
|
||||
class TestWorkoutRoutineViews:
|
||||
def test_routine_list(self, db, user, workout_routine, workout_scrobble):
|
||||
client = Client()
|
||||
client.force_login(user)
|
||||
response = client.get(reverse("workouts:workout_routine_list"))
|
||||
assert response.status_code == 200
|
||||
|
||||
def test_routine_detail(self, db, user, workout_routine, workout_scrobble):
|
||||
client = Client()
|
||||
client.force_login(user)
|
||||
response = client.get(
|
||||
reverse(
|
||||
"workouts:workout_routine_detail",
|
||||
kwargs={"slug": workout_routine.uuid},
|
||||
)
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert "Test Routine" in response.content.decode()
|
||||
|
||||
|
||||
class TestWorkoutScrobbleViews:
|
||||
def test_scrobble_detail_shows_workouts(self, db, user, workout_scrobble):
|
||||
client = Client()
|
||||
client.force_login(user)
|
||||
response = client.get(workout_scrobble.get_absolute_url())
|
||||
assert response.status_code == 200
|
||||
assert b"Workout Details" in response.content
|
||||
|
||||
def test_scrobble_post_imperial_conversion(
|
||||
self, db, user, workout_scrobble, exercise
|
||||
):
|
||||
user.profile.weigh_in_units = "imperial"
|
||||
user.profile.save()
|
||||
client = Client()
|
||||
client.force_login(user)
|
||||
response = client.post(
|
||||
workout_scrobble.get_absolute_url(),
|
||||
{
|
||||
"duration_minutes": "45",
|
||||
"bodyweight_kg": "180.5",
|
||||
"workouts_exercise_id": [str(exercise.id)],
|
||||
"workouts_sets": ["4"],
|
||||
"workouts_reps": ["8"],
|
||||
"workouts_weight": ["100"],
|
||||
"workouts_notes": [""],
|
||||
},
|
||||
)
|
||||
assert response.status_code == 302
|
||||
updated = Scrobble.objects.get(pk=workout_scrobble.pk)
|
||||
log = updated.log
|
||||
assert log["bodyweight_kg"] == round(180.5 * 0.45359237, 2)
|
||||
assert log["workouts"][0]["weight_kg"] == round(100 * 0.45359237, 2)
|
||||
|
||||
def test_scrobble_post_metric_no_conversion(
|
||||
self, db, user, workout_scrobble, exercise
|
||||
):
|
||||
user.profile.weigh_in_units = "metric"
|
||||
user.profile.save()
|
||||
client = Client()
|
||||
client.force_login(user)
|
||||
response = client.post(
|
||||
workout_scrobble.get_absolute_url(),
|
||||
{
|
||||
"duration_minutes": "45",
|
||||
"bodyweight_kg": "80.5",
|
||||
"workouts_exercise_id": [str(exercise.id)],
|
||||
"workouts_sets": ["4"],
|
||||
"workouts_reps": ["8"],
|
||||
"workouts_weight": ["50"],
|
||||
"workouts_notes": [""],
|
||||
},
|
||||
)
|
||||
assert response.status_code == 302
|
||||
updated = Scrobble.objects.get(pk=workout_scrobble.pk)
|
||||
log = updated.log
|
||||
assert log["bodyweight_kg"] == 80.5
|
||||
assert log["workouts"][0]["weight_kg"] == 50.0
|
||||
Reference in New Issue
Block a user