session_item.item_id is polymorphic (no FK), so deleting an exercise or combo used in a session left orphaned rows that the INNER JOIN in getItems silently hid. Exercise/combo deletes now also remove their session_item rows in one transaction. Delete confirmations on the exercise/combo/session detail views now include usage counts (sessions/ combos using an exercise, sessions using a combo, collections containing a session) via new count helpers and deleteConfirmUsage keys (EN/FR/DA). Confirm stays informative, not blocking, consistent with the existing history warning. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
727 lines
25 KiB
Python
727 lines
25 KiB
Python
"""
|
|
Step 3 — Exercise Management Tests
|
|
- test_exercise_list_empty: Shows empty state when no exercises exist
|
|
- test_create_exercise_minimal: Create exercise with only required field (title)
|
|
- test_create_exercise_full: Create exercise with all fields filled
|
|
- test_exercise_appears_in_list: Created exercise appears in the list with badges
|
|
- test_exercise_detail_view: Detail view shows all exercise data
|
|
- test_edit_exercise: Edit an exercise and verify changes
|
|
- test_delete_exercise: Delete an exercise with confirmation
|
|
- test_delete_exercise_cancel: Cancelling delete does not remove exercise
|
|
- test_delete_exercise_used_in_session: Delete confirm mentions usage; no session_item orphans
|
|
- test_search_exercises: Search filters exercises by title
|
|
- test_search_no_results: Search with no matches shows empty message
|
|
- test_search_clear_button: Clear button empties the search field and restores the full list
|
|
- test_validation_title_required: Empty title shows validation error
|
|
- test_validation_negative_reps: Negative reps shows validation error
|
|
- test_validation_negative_weight: Negative weight shows validation error
|
|
- test_validation_invalid_url: Invalid URL format is rejected
|
|
- test_duplicate_title_rejected: Creating exercise with duplicate title shows error
|
|
- test_asymmetric_toggle: Asymmetric toggle shows alternation options
|
|
- test_action_bar_buttons: New and Search buttons appear in action bar
|
|
"""
|
|
from playwright.sync_api import Page, expect
|
|
|
|
|
|
def _wait_for_db(page: Page, app_url: str):
|
|
"""Navigate and wait for the database to be ready."""
|
|
page.goto(app_url)
|
|
page.wait_for_function(
|
|
"document.documentElement.getAttribute('data-db-ready') === 'true'",
|
|
timeout=15000,
|
|
)
|
|
|
|
|
|
def _go_to_exercises(page: Page, app_url: str):
|
|
"""Navigate to the exercises list and wait for it to load."""
|
|
_wait_for_db(page, app_url)
|
|
page.click('a[href="#/exercises"]')
|
|
page.wait_for_timeout(500)
|
|
|
|
|
|
def _clean_exercises(page: Page):
|
|
"""Remove all exercises via the repository."""
|
|
page.evaluate(
|
|
"""async () => {
|
|
const exercises = await window.__repos.exercises.getAll();
|
|
for (const ex of exercises) {
|
|
await window.__repos.exercises.delete(ex.id);
|
|
}
|
|
}"""
|
|
)
|
|
|
|
|
|
def _create_exercise_via_ui(page: Page, title, description="", reps=0, weight=0,
|
|
asymmetric=False, alternate=False,
|
|
image_urls=None, video_urls=None):
|
|
"""Fill the exercise form and save via UI."""
|
|
# Click New button in action bar
|
|
page.click('[data-testid="exercise-new-btn"]')
|
|
page.wait_for_selector('[data-testid="exercise-title"]')
|
|
|
|
# Fill title
|
|
page.fill('[data-testid="exercise-title"]', title)
|
|
|
|
# Fill description
|
|
if description:
|
|
page.fill('[data-testid="exercise-description"]', description)
|
|
|
|
# Toggle asymmetric
|
|
if asymmetric:
|
|
page.click('[data-testid="exercise-asymmetric"]')
|
|
if alternate:
|
|
page.click('[data-testid="exercise-alternate-true"]')
|
|
else:
|
|
page.click('[data-testid="exercise-alternate-false"]')
|
|
|
|
# Fill reps
|
|
if reps:
|
|
page.fill('[data-testid="exercise-reps"]', str(reps))
|
|
|
|
# Fill weight
|
|
if weight:
|
|
page.fill('[data-testid="exercise-weight"]', str(weight))
|
|
|
|
# Add image URLs
|
|
if image_urls:
|
|
for url in image_urls:
|
|
page.fill('[data-testid="exercise-new-image-url"]', url)
|
|
page.click('[data-testid="exercise-add-image-url"]')
|
|
|
|
# Add video URLs
|
|
if video_urls:
|
|
for url in video_urls:
|
|
page.fill('[data-testid="exercise-new-video-url"]', url)
|
|
page.click('[data-testid="exercise-add-video-url"]')
|
|
|
|
# Save
|
|
page.click('[data-testid="exercise-save"]')
|
|
# Wait for navigation to detail view
|
|
page.wait_for_selector('[data-testid="exercise-detail-title"]', timeout=5000)
|
|
|
|
|
|
def test_exercise_list_empty(page: Page, app_url: str):
|
|
"""Empty state is shown when no exercises exist."""
|
|
_go_to_exercises(page, app_url)
|
|
_clean_exercises(page)
|
|
page.reload()
|
|
page.wait_for_function(
|
|
"document.documentElement.getAttribute('data-db-ready') === 'true'",
|
|
timeout=15000,
|
|
)
|
|
page.click('a[href="#/exercises"]')
|
|
page.wait_for_timeout(500)
|
|
expect(page.locator('[data-testid="exercise-empty"]')).to_be_visible()
|
|
|
|
|
|
def test_create_exercise_minimal(page: Page, app_url: str):
|
|
"""Create an exercise with only the required title field."""
|
|
_go_to_exercises(page, app_url)
|
|
_clean_exercises(page)
|
|
page.reload()
|
|
page.wait_for_function(
|
|
"document.documentElement.getAttribute('data-db-ready') === 'true'",
|
|
timeout=15000,
|
|
)
|
|
page.click('a[href="#/exercises"]')
|
|
page.wait_for_timeout(500)
|
|
|
|
_create_exercise_via_ui(page, "Push-ups")
|
|
|
|
# Should be on detail view
|
|
expect(page.locator('[data-testid="exercise-detail-title"]')).to_have_text("Push-ups")
|
|
|
|
# Clean up
|
|
_clean_exercises(page)
|
|
|
|
|
|
def test_create_exercise_full(page: Page, app_url: str):
|
|
"""Create an exercise with all fields filled."""
|
|
_go_to_exercises(page, app_url)
|
|
_clean_exercises(page)
|
|
page.reload()
|
|
page.wait_for_function(
|
|
"document.documentElement.getAttribute('data-db-ready') === 'true'",
|
|
timeout=15000,
|
|
)
|
|
page.click('a[href="#/exercises"]')
|
|
page.wait_for_timeout(500)
|
|
|
|
_create_exercise_via_ui(
|
|
page,
|
|
title="Dumbbell Curl",
|
|
description="Bicep curl with dumbbell",
|
|
reps=12,
|
|
weight=10,
|
|
asymmetric=True,
|
|
alternate=True,
|
|
image_urls=["https://example.com/curl.jpg"],
|
|
video_urls=["https://www.youtube.com/watch?v=dQw4w9WgXcQ"],
|
|
)
|
|
|
|
# Verify detail page
|
|
expect(page.locator('[data-testid="exercise-detail-title"]')).to_have_text("Dumbbell Curl")
|
|
expect(page.locator('[data-testid="exercise-detail-description"]')).to_have_text(
|
|
"Bicep curl with dumbbell"
|
|
)
|
|
expect(page.locator('[data-testid="exercise-detail-reps"]')).to_contain_text("12")
|
|
expect(page.locator('[data-testid="exercise-detail-weight"]')).to_contain_text("10")
|
|
expect(page.locator('[data-testid="exercise-detail-asymmetric"]')).to_be_visible()
|
|
|
|
# Clean up
|
|
_clean_exercises(page)
|
|
|
|
|
|
def test_exercise_appears_in_list(page: Page, app_url: str):
|
|
"""Created exercise appears in the list view with correct info."""
|
|
_go_to_exercises(page, app_url)
|
|
_clean_exercises(page)
|
|
page.reload()
|
|
page.wait_for_function(
|
|
"document.documentElement.getAttribute('data-db-ready') === 'true'",
|
|
timeout=15000,
|
|
)
|
|
page.click('a[href="#/exercises"]')
|
|
page.wait_for_timeout(500)
|
|
|
|
_create_exercise_via_ui(page, title="Squats", reps=15, weight=50)
|
|
|
|
# Go back to list
|
|
page.click('[data-testid="exercise-back-btn"]')
|
|
page.wait_for_timeout(500)
|
|
|
|
# Exercise card should be visible
|
|
card_title = page.locator('[data-testid="exercise-card-title"]')
|
|
expect(card_title.first).to_have_text("Squats")
|
|
|
|
# Badges
|
|
expect(page.locator('[data-testid="exercise-card-reps"]').first).to_contain_text("15")
|
|
expect(page.locator('[data-testid="exercise-card-weight"]').first).to_contain_text("50")
|
|
|
|
# Clean up
|
|
_clean_exercises(page)
|
|
|
|
|
|
def test_exercise_detail_view(page: Page, app_url: str):
|
|
"""Detail view shows all exercise data correctly."""
|
|
_go_to_exercises(page, app_url)
|
|
_clean_exercises(page)
|
|
|
|
# Create via repo
|
|
exercise_id = page.evaluate(
|
|
"""async () => {
|
|
const uuid = await window.__repos.settings.get('user_uuid');
|
|
return window.__repos.exercises.create({
|
|
title: 'Bench Press',
|
|
description: 'Chest exercise with barbell',
|
|
asymmetric: false,
|
|
alternate: false,
|
|
image_urls: [],
|
|
video_urls: [],
|
|
default_reps: 8,
|
|
default_weight: 60,
|
|
created_by: uuid
|
|
});
|
|
}"""
|
|
)
|
|
|
|
# Navigate to detail
|
|
page.goto(f"{app_url}/#/exercises/{exercise_id}")
|
|
page.wait_for_selector('[data-testid="exercise-detail-title"]', timeout=5000)
|
|
|
|
expect(page.locator('[data-testid="exercise-detail-title"]')).to_have_text("Bench Press")
|
|
expect(page.locator('[data-testid="exercise-detail-description"]')).to_have_text(
|
|
"Chest exercise with barbell"
|
|
)
|
|
expect(page.locator('[data-testid="exercise-detail-reps"]')).to_contain_text("8")
|
|
expect(page.locator('[data-testid="exercise-detail-weight"]')).to_contain_text("60")
|
|
|
|
# Clean up
|
|
_clean_exercises(page)
|
|
|
|
|
|
def test_edit_exercise(page: Page, app_url: str):
|
|
"""Edit an exercise and verify changes are persisted."""
|
|
_go_to_exercises(page, app_url)
|
|
_clean_exercises(page)
|
|
page.reload()
|
|
page.wait_for_function(
|
|
"document.documentElement.getAttribute('data-db-ready') === 'true'",
|
|
timeout=15000,
|
|
)
|
|
page.click('a[href="#/exercises"]')
|
|
page.wait_for_timeout(500)
|
|
|
|
_create_exercise_via_ui(page, title="Plank", reps=1, weight=0)
|
|
|
|
# Click edit button
|
|
page.click('[data-testid="exercise-edit-btn"]')
|
|
page.wait_for_selector('[data-testid="exercise-title"]')
|
|
|
|
# Change title and reps
|
|
page.fill('[data-testid="exercise-title"]', "Side Plank")
|
|
page.fill('[data-testid="exercise-reps"]', "3")
|
|
page.fill('[data-testid="exercise-description"]', "Hold for 30 seconds each side")
|
|
|
|
# Save
|
|
page.click('[data-testid="exercise-save"]')
|
|
page.wait_for_selector('[data-testid="exercise-detail-title"]', timeout=5000)
|
|
|
|
# Verify changes
|
|
expect(page.locator('[data-testid="exercise-detail-title"]')).to_have_text("Side Plank")
|
|
expect(page.locator('[data-testid="exercise-detail-description"]')).to_have_text(
|
|
"Hold for 30 seconds each side"
|
|
)
|
|
expect(page.locator('[data-testid="exercise-detail-reps"]')).to_contain_text("3")
|
|
|
|
# Clean up
|
|
_clean_exercises(page)
|
|
|
|
|
|
def test_delete_exercise(page: Page, app_url: str):
|
|
"""Delete an exercise with confirmation dialog."""
|
|
_go_to_exercises(page, app_url)
|
|
_clean_exercises(page)
|
|
page.reload()
|
|
page.wait_for_function(
|
|
"document.documentElement.getAttribute('data-db-ready') === 'true'",
|
|
timeout=15000,
|
|
)
|
|
page.click('a[href="#/exercises"]')
|
|
page.wait_for_timeout(500)
|
|
|
|
_create_exercise_via_ui(page, title="To Delete")
|
|
|
|
# Accept the confirm dialog
|
|
page.on("dialog", lambda dialog: dialog.accept())
|
|
|
|
# Click delete button
|
|
page.click('[data-testid="exercise-delete-btn"]')
|
|
page.wait_for_timeout(500)
|
|
|
|
# Should be back on list with empty state
|
|
expect(page.locator('[data-testid="exercise-empty"]')).to_be_visible()
|
|
|
|
|
|
def test_delete_exercise_cancel(page: Page, app_url: str):
|
|
"""Cancelling the delete dialog does not remove the exercise."""
|
|
_go_to_exercises(page, app_url)
|
|
_clean_exercises(page)
|
|
page.reload()
|
|
page.wait_for_function(
|
|
"document.documentElement.getAttribute('data-db-ready') === 'true'",
|
|
timeout=15000,
|
|
)
|
|
page.click('a[href="#/exercises"]')
|
|
page.wait_for_timeout(500)
|
|
|
|
_create_exercise_via_ui(page, title="Keep Me")
|
|
|
|
# Dismiss the confirm dialog
|
|
page.on("dialog", lambda dialog: dialog.dismiss())
|
|
|
|
# Click delete button
|
|
page.click('[data-testid="exercise-delete-btn"]')
|
|
page.wait_for_timeout(500)
|
|
|
|
# Exercise should still be shown
|
|
expect(page.locator('[data-testid="exercise-detail-title"]')).to_have_text("Keep Me")
|
|
|
|
# Clean up
|
|
_clean_exercises(page)
|
|
|
|
|
|
def test_delete_exercise_used_in_session(page: Page, app_url: str):
|
|
"""Deleting an exercise used in a session and a combo warns about the
|
|
usage, and after deletion no session_item rows reference it (no orphans)."""
|
|
_go_to_exercises(page, app_url)
|
|
_clean_exercises(page)
|
|
|
|
exercise_id = page.evaluate(
|
|
"""async () => {
|
|
const uuid = await window.__repos.settings.get('user_uuid');
|
|
return window.__repos.exercises.create({
|
|
title: 'Used Everywhere',
|
|
description: '',
|
|
asymmetric: false,
|
|
alternate: false,
|
|
image_urls: [],
|
|
video_urls: [],
|
|
default_reps: 10,
|
|
default_weight: 0,
|
|
created_by: uuid
|
|
});
|
|
}"""
|
|
)
|
|
page.evaluate(
|
|
f"""async () => {{
|
|
const uuid = await window.__repos.settings.get('user_uuid');
|
|
const sessionId = await window.__repos.sessions.create({{
|
|
title: 'Session Using Exercise', description: '', created_by: uuid
|
|
}});
|
|
await window.__repos.sessions.setItems(sessionId, [
|
|
{{ item_id: '{exercise_id}', item_type: 'exercise', repetitions: 10, weight: 0 }},
|
|
]);
|
|
const comboId = await window.__repos.combos.create({{
|
|
title: 'Combo Using Exercise', description: '', type: 'NONE', created_by: uuid
|
|
}});
|
|
await window.__repos.combos.setExercises(comboId, ['{exercise_id}']);
|
|
}}"""
|
|
)
|
|
|
|
dialog_message = {}
|
|
|
|
def on_dialog(dialog):
|
|
dialog_message["text"] = dialog.message
|
|
dialog.accept()
|
|
|
|
page.on("dialog", on_dialog)
|
|
|
|
page.goto(f"{app_url}/#/exercises/{exercise_id}")
|
|
page.wait_for_selector('[data-testid="exercise-delete-btn"]', timeout=5000)
|
|
page.click('[data-testid="exercise-delete-btn"]')
|
|
page.wait_for_timeout(500)
|
|
|
|
msg = dialog_message.get("text", "")
|
|
assert "1 session(s)" in msg, f"Confirm must mention session usage, got: {msg}"
|
|
assert "1 combo(s)" in msg, f"Confirm must mention combo usage, got: {msg}"
|
|
|
|
orphans = page.evaluate(
|
|
f"""async () => (await window.__db.selectAll(
|
|
"SELECT * FROM session_item WHERE item_id = '{exercise_id}'"
|
|
)).length"""
|
|
)
|
|
assert orphans == 0, f"Expected no orphaned session_item rows, got {orphans}"
|
|
|
|
# Clean up the seeded session/combo
|
|
page.evaluate(
|
|
"""async () => {
|
|
for (const s of await window.__repos.sessions.getAll())
|
|
await window.__repos.sessions.delete(s.id)
|
|
for (const c of await window.__repos.combos.getAll())
|
|
await window.__repos.combos.delete(c.id)
|
|
}"""
|
|
)
|
|
|
|
|
|
def test_search_exercises(page: Page, app_url: str):
|
|
"""Search filters exercises by title."""
|
|
_go_to_exercises(page, app_url)
|
|
_clean_exercises(page)
|
|
|
|
# Create multiple exercises via repo
|
|
page.evaluate(
|
|
"""async () => {
|
|
const uuid = await window.__repos.settings.get('user_uuid');
|
|
await window.__repos.exercises.create({
|
|
title: 'Push-ups', description: '', asymmetric: false, alternate: false,
|
|
image_urls: [], video_urls: [], default_reps: 10, default_weight: 0,
|
|
created_by: uuid
|
|
});
|
|
await window.__repos.exercises.create({
|
|
title: 'Pull-ups', description: '', asymmetric: false, alternate: false,
|
|
image_urls: [], video_urls: [], default_reps: 8, default_weight: 0,
|
|
created_by: uuid
|
|
});
|
|
await window.__repos.exercises.create({
|
|
title: 'Squats', description: '', asymmetric: false, alternate: false,
|
|
image_urls: [], video_urls: [], default_reps: 15, default_weight: 0,
|
|
created_by: uuid
|
|
});
|
|
}"""
|
|
)
|
|
|
|
# Reload exercises list
|
|
page.reload()
|
|
page.wait_for_function(
|
|
"document.documentElement.getAttribute('data-db-ready') === 'true'",
|
|
timeout=15000,
|
|
)
|
|
page.click('a[href="#/exercises"]')
|
|
page.wait_for_timeout(500)
|
|
|
|
# All 3 should be visible
|
|
expect(page.locator('[data-testid="exercise-card-title"]')).to_have_count(3)
|
|
|
|
# Toggle search
|
|
page.click('[data-testid="exercise-search-toggle"]')
|
|
page.wait_for_selector('[data-testid="exercise-search-input"]')
|
|
|
|
# Type search query
|
|
page.fill('[data-testid="exercise-search-input"]', "ups")
|
|
page.wait_for_timeout(500)
|
|
|
|
# Only Push-ups and Pull-ups should be visible
|
|
expect(page.locator('[data-testid="exercise-card-title"]')).to_have_count(2)
|
|
|
|
# Clean up
|
|
_clean_exercises(page)
|
|
|
|
|
|
def test_search_no_results(page: Page, app_url: str):
|
|
"""Search with no matches shows no-results message."""
|
|
_go_to_exercises(page, app_url)
|
|
_clean_exercises(page)
|
|
|
|
# Create one exercise
|
|
page.evaluate(
|
|
"""async () => {
|
|
const uuid = await window.__repos.settings.get('user_uuid');
|
|
await window.__repos.exercises.create({
|
|
title: 'Push-ups', description: '', asymmetric: false, alternate: false,
|
|
image_urls: [], video_urls: [], default_reps: 10, default_weight: 0,
|
|
created_by: uuid
|
|
});
|
|
}"""
|
|
)
|
|
|
|
page.reload()
|
|
page.wait_for_function(
|
|
"document.documentElement.getAttribute('data-db-ready') === 'true'",
|
|
timeout=15000,
|
|
)
|
|
page.click('a[href="#/exercises"]')
|
|
page.wait_for_timeout(500)
|
|
|
|
# Toggle search and type non-matching query
|
|
page.click('[data-testid="exercise-search-toggle"]')
|
|
page.fill('[data-testid="exercise-search-input"]', "zzzznotfound")
|
|
page.wait_for_timeout(500)
|
|
|
|
expect(page.locator('[data-testid="exercise-no-results"]')).to_be_visible()
|
|
|
|
# Clean up
|
|
_clean_exercises(page)
|
|
|
|
|
|
def test_search_clear_button(page: Page, app_url: str):
|
|
"""Clear button empties the search field and restores the full list."""
|
|
_go_to_exercises(page, app_url)
|
|
_clean_exercises(page)
|
|
|
|
page.evaluate(
|
|
"""async () => {
|
|
const uuid = await window.__repos.settings.get('user_uuid');
|
|
await window.__repos.exercises.create({
|
|
title: 'Push-ups', description: '', asymmetric: false, alternate: false,
|
|
image_urls: [], video_urls: [], default_reps: 10, default_weight: 0,
|
|
created_by: uuid
|
|
});
|
|
await window.__repos.exercises.create({
|
|
title: 'Squats', description: '', asymmetric: false, alternate: false,
|
|
image_urls: [], video_urls: [], default_reps: 15, default_weight: 0,
|
|
created_by: uuid
|
|
});
|
|
}"""
|
|
)
|
|
|
|
page.reload()
|
|
page.wait_for_function(
|
|
"document.documentElement.getAttribute('data-db-ready') === 'true'",
|
|
timeout=15000,
|
|
)
|
|
page.click('a[href="#/exercises"]')
|
|
page.wait_for_timeout(500)
|
|
|
|
page.click('[data-testid="exercise-search-toggle"]')
|
|
|
|
# Clear button is hidden until there is text in the field
|
|
expect(page.locator('[data-testid="exercise-search-clear"]')).not_to_be_visible()
|
|
|
|
page.fill('[data-testid="exercise-search-input"]', "Push")
|
|
page.wait_for_timeout(500)
|
|
expect(page.locator('[data-testid="exercise-card-title"]')).to_have_count(1)
|
|
|
|
# Clicking the clear button empties the field and restores the full list
|
|
page.click('[data-testid="exercise-search-clear"]')
|
|
expect(page.locator('[data-testid="exercise-search-input"]')).to_have_value("")
|
|
page.wait_for_timeout(500)
|
|
expect(page.locator('[data-testid="exercise-card-title"]')).to_have_count(2)
|
|
|
|
# Search field keeps focus after clearing
|
|
expect(page.locator('[data-testid="exercise-search-input"]')).to_be_focused()
|
|
|
|
# Clean up
|
|
_clean_exercises(page)
|
|
|
|
|
|
def test_validation_title_required(page: Page, app_url: str):
|
|
"""Empty title shows validation error and prevents save."""
|
|
_go_to_exercises(page, app_url)
|
|
_clean_exercises(page)
|
|
page.reload()
|
|
page.wait_for_function(
|
|
"document.documentElement.getAttribute('data-db-ready') === 'true'",
|
|
timeout=15000,
|
|
)
|
|
page.click('a[href="#/exercises"]')
|
|
page.wait_for_timeout(500)
|
|
|
|
# Click New
|
|
page.click('[data-testid="exercise-new-btn"]')
|
|
page.wait_for_selector('[data-testid="exercise-title"]')
|
|
|
|
# Leave title empty, click Save
|
|
page.click('[data-testid="exercise-save"]')
|
|
|
|
# Error should appear
|
|
expect(page.locator('[data-testid="exercise-title-error"]')).to_be_visible()
|
|
|
|
# Should still be on the form (not navigated away)
|
|
expect(page.locator('[data-testid="exercise-title"]')).to_be_visible()
|
|
|
|
|
|
def test_validation_negative_reps(page: Page, app_url: str):
|
|
"""Negative reps shows validation error."""
|
|
_go_to_exercises(page, app_url)
|
|
_clean_exercises(page)
|
|
page.reload()
|
|
page.wait_for_function(
|
|
"document.documentElement.getAttribute('data-db-ready') === 'true'",
|
|
timeout=15000,
|
|
)
|
|
page.click('a[href="#/exercises"]')
|
|
page.wait_for_timeout(500)
|
|
|
|
page.click('[data-testid="exercise-new-btn"]')
|
|
page.wait_for_selector('[data-testid="exercise-title"]')
|
|
|
|
page.fill('[data-testid="exercise-title"]', "Test Exercise")
|
|
page.fill('[data-testid="exercise-reps"]', "-5")
|
|
page.click('[data-testid="exercise-save"]')
|
|
|
|
expect(page.locator('[data-testid="exercise-reps-error"]')).to_be_visible()
|
|
|
|
|
|
def test_validation_negative_weight(page: Page, app_url: str):
|
|
"""Negative weight shows validation error."""
|
|
_go_to_exercises(page, app_url)
|
|
_clean_exercises(page)
|
|
page.reload()
|
|
page.wait_for_function(
|
|
"document.documentElement.getAttribute('data-db-ready') === 'true'",
|
|
timeout=15000,
|
|
)
|
|
page.click('a[href="#/exercises"]')
|
|
page.wait_for_timeout(500)
|
|
|
|
page.click('[data-testid="exercise-new-btn"]')
|
|
page.wait_for_selector('[data-testid="exercise-title"]')
|
|
|
|
page.fill('[data-testid="exercise-title"]', "Test Exercise")
|
|
page.fill('[data-testid="exercise-weight"]', "-10")
|
|
page.click('[data-testid="exercise-save"]')
|
|
|
|
expect(page.locator('[data-testid="exercise-weight-error"]')).to_be_visible()
|
|
|
|
|
|
def test_validation_invalid_url(page: Page, app_url: str):
|
|
"""Invalid URL format is rejected when adding an image URL."""
|
|
_go_to_exercises(page, app_url)
|
|
_clean_exercises(page)
|
|
page.reload()
|
|
page.wait_for_function(
|
|
"document.documentElement.getAttribute('data-db-ready') === 'true'",
|
|
timeout=15000,
|
|
)
|
|
page.click('a[href="#/exercises"]')
|
|
page.wait_for_timeout(500)
|
|
|
|
page.click('[data-testid="exercise-new-btn"]')
|
|
page.wait_for_selector('[data-testid="exercise-title"]')
|
|
|
|
# Try adding an invalid URL
|
|
page.fill('[data-testid="exercise-new-image-url"]', "not-a-url")
|
|
page.click('[data-testid="exercise-add-image-url"]')
|
|
|
|
expect(page.locator('[data-testid="exercise-image-url-error"]')).to_be_visible()
|
|
|
|
|
|
def test_duplicate_title_rejected(page: Page, app_url: str):
|
|
"""Creating an exercise with a duplicate title shows an error."""
|
|
_go_to_exercises(page, app_url)
|
|
_clean_exercises(page)
|
|
page.reload()
|
|
page.wait_for_function(
|
|
"document.documentElement.getAttribute('data-db-ready') === 'true'",
|
|
timeout=15000,
|
|
)
|
|
page.click('a[href="#/exercises"]')
|
|
page.wait_for_timeout(500)
|
|
|
|
# Create first exercise
|
|
_create_exercise_via_ui(page, title="Unique Exercise")
|
|
|
|
# Go back to list
|
|
page.click('[data-testid="exercise-back-btn"]')
|
|
page.wait_for_timeout(500)
|
|
|
|
# Try to create another with the same title
|
|
page.click('[data-testid="exercise-new-btn"]')
|
|
page.wait_for_selector('[data-testid="exercise-title"]')
|
|
page.fill('[data-testid="exercise-title"]', "Unique Exercise")
|
|
page.click('[data-testid="exercise-save"]')
|
|
page.wait_for_timeout(500)
|
|
|
|
# Should show error (title error or save error)
|
|
title_error_visible = page.locator('[data-testid="exercise-title-error"]').is_visible()
|
|
save_error_visible = page.locator('[data-testid="exercise-save-error"]').is_visible()
|
|
assert title_error_visible or save_error_visible, "Expected duplicate title error"
|
|
|
|
# Clean up
|
|
page.goto(app_url)
|
|
page.wait_for_function(
|
|
"document.documentElement.getAttribute('data-db-ready') === 'true'",
|
|
timeout=15000,
|
|
)
|
|
_clean_exercises(page)
|
|
|
|
|
|
def test_asymmetric_toggle(page: Page, app_url: str):
|
|
"""Asymmetric toggle shows/hides alternation radio buttons."""
|
|
_go_to_exercises(page, app_url)
|
|
_clean_exercises(page)
|
|
page.reload()
|
|
page.wait_for_function(
|
|
"document.documentElement.getAttribute('data-db-ready') === 'true'",
|
|
timeout=15000,
|
|
)
|
|
page.click('a[href="#/exercises"]')
|
|
page.wait_for_timeout(500)
|
|
|
|
page.click('[data-testid="exercise-new-btn"]')
|
|
page.wait_for_selector('[data-testid="exercise-title"]')
|
|
|
|
# Alternate options should not be visible initially
|
|
expect(page.locator('[data-testid="exercise-alternate-true"]')).not_to_be_visible()
|
|
expect(page.locator('[data-testid="exercise-alternate-false"]')).not_to_be_visible()
|
|
|
|
# Toggle asymmetric
|
|
page.click('[data-testid="exercise-asymmetric"]')
|
|
|
|
# Alternate options should now be visible
|
|
expect(page.locator('[data-testid="exercise-alternate-true"]')).to_be_visible()
|
|
expect(page.locator('[data-testid="exercise-alternate-false"]')).to_be_visible()
|
|
|
|
# Untoggle asymmetric
|
|
page.click('[data-testid="exercise-asymmetric"]')
|
|
|
|
# Alternate options should be hidden again
|
|
expect(page.locator('[data-testid="exercise-alternate-true"]')).not_to_be_visible()
|
|
|
|
|
|
def test_action_bar_buttons(page: Page, app_url: str):
|
|
"""New and Search buttons are visible in the action bar on the exercise list."""
|
|
_go_to_exercises(page, app_url)
|
|
_clean_exercises(page)
|
|
page.reload()
|
|
page.wait_for_function(
|
|
"document.documentElement.getAttribute('data-db-ready') === 'true'",
|
|
timeout=15000,
|
|
)
|
|
page.click('a[href="#/exercises"]')
|
|
page.wait_for_timeout(500)
|
|
|
|
expect(page.locator('[data-testid="exercise-new-btn"]')).to_be_visible()
|
|
expect(page.locator('[data-testid="exercise-search-toggle"]')).to_be_visible()
|