""" Step 4 — Combo Management Tests - test_combo_list_empty: Shows empty state when no combos exist - test_create_combo_minimal: Create combo with only required field (title) - test_create_combo_full: Create combo with all fields including exercises - test_combo_appears_in_list: Created combo appears in the list with badges - test_combo_detail_view: Detail view shows all combo data - test_edit_combo: Edit a combo and verify changes - test_delete_combo: Delete a combo with confirmation - test_delete_combo_cancel: Cancelling delete does not remove combo - test_search_combos: Search filters combos 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_duplicate_title_rejected: Creating combo with duplicate title shows error - test_reorder_exercises: Reorder exercises in combo - test_remove_exercise: Remove an exercise from combo - test_type_change: Changing type shows/hides timer config fields - 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_combos(page: Page, app_url: str): """Navigate to the combos list and wait for it to load.""" _wait_for_db(page, app_url) page.click('a[href="#/combos"]') page.wait_for_timeout(500) def _clean_combos(page: Page): """Remove all combos via the repository.""" page.evaluate( """async () => { const combos = await window.__repos.combos.getAll(); for (const c of combos) { await window.__repos.combos.delete(c.id); } }""" ) def _create_exercise_via_repo(page: Page, title): """Create an exercise via the repository and return its ID.""" return page.evaluate( """async (title) => { const uuid = await window.__repos.settings.get('user_uuid'); return window.__repos.exercises.create({ title: title, description: '', asymmetric: false, alternate: false, image_urls: [], video_urls: [], default_reps: 10, default_weight: 0, created_by: uuid }); }""", title, ) def _create_combo_via_ui(page: Page, title, description="", combo_type="NONE", exercise_ids=None): """Fill the combo form and save via UI.""" page.click('[data-testid="combo-new-btn"]') page.wait_for_selector('[data-testid="combo-title"]') page.fill('[data-testid="combo-title"]', title) if description: page.fill('[data-testid="combo-description"]', description) if combo_type != "NONE": page.select_option('[data-testid="combo-type"]', combo_type) if exercise_ids: for ex_id in exercise_ids: page.select_option('[data-testid="combo-exercise-select"]', ex_id) page.click('[data-testid="combo-exercise-add"]') page.click('[data-testid="combo-save"]') page.wait_for_selector('[data-testid="combo-detail-title"]', timeout=5000) def test_combo_list_empty(page: Page, app_url: str): """Empty state is shown when no combos exist.""" _go_to_combos(page, app_url) _clean_combos(page) page.reload() page.wait_for_function( "document.documentElement.getAttribute('data-db-ready') === 'true'", timeout=15000, ) page.click('a[href="#/combos"]') page.wait_for_timeout(500) expect(page.locator('[data-testid="combo-empty"]')).to_be_visible() def test_create_combo_minimal(page: Page, app_url: str): """Create a combo with only the required title field.""" _go_to_combos(page, app_url) _clean_combos(page) page.reload() page.wait_for_function( "document.documentElement.getAttribute('data-db-ready') === 'true'", timeout=15000, ) page.click('a[href="#/combos"]') page.wait_for_timeout(500) _create_combo_via_ui(page, "My First Combo") expect(page.locator('[data-testid="combo-detail-title"]')).to_have_text("My First Combo") _clean_combos(page) def test_create_combo_full(page: Page, app_url: str): """Create a combo with all fields including exercises.""" _go_to_combos(page, app_url) _clean_combos(page) page.reload() page.wait_for_function( "document.documentElement.getAttribute('data-db-ready') === 'true'", timeout=15000, ) page.click('a[href="#/combos"]') page.wait_for_timeout(500) # Create exercises first ex1_id = _create_exercise_via_repo(page, "Push-ups") ex2_id = _create_exercise_via_repo(page, "Squats") page.reload() page.wait_for_function( "document.documentElement.getAttribute('data-db-ready') === 'true'", timeout=15000, ) page.click('a[href="#/combos"]') page.wait_for_timeout(500) _create_combo_via_ui( page, title="Upper Body Blast", description="Quick combo for upper body", combo_type="EMOM", exercise_ids=[ex1_id, ex2_id], ) expect(page.locator('[data-testid="combo-detail-title"]')).to_have_text("Upper Body Blast") expect(page.locator('[data-testid="combo-detail-description"]')).to_have_text( "Quick combo for upper body" ) expect(page.locator('[data-testid="combo-detail-type"]')).to_have_text("EMOM") expect(page.locator('[data-testid="combo-detail-exercise-0"]')).to_contain_text("Push-ups") expect(page.locator('[data-testid="combo-detail-exercise-1"]')).to_contain_text("Squats") _clean_combos(page) def test_combo_appears_in_list(page: Page, app_url: str): """Created combo appears in the list view with correct info.""" _go_to_combos(page, app_url) _clean_combos(page) page.reload() page.wait_for_function( "document.documentElement.getAttribute('data-db-ready') === 'true'", timeout=15000, ) page.click('a[href="#/combos"]') page.wait_for_timeout(500) _create_combo_via_ui(page, title="Test Combo", combo_type="AMRAP") page.click('[data-testid="combo-back-btn"]') page.wait_for_timeout(500) card_title = page.locator('[data-testid="combo-card-title"]') expect(card_title.first).to_have_text("Test Combo") expect(page.locator('[data-testid="combo-card-type"]').first).to_have_text("AMRAP") _clean_combos(page) def test_combo_detail_view(page: Page, app_url: str): """Detail view shows all combo data correctly.""" _go_to_combos(page, app_url) _clean_combos(page) # Create exercise first ex_id = _create_exercise_via_repo(page, "Burpees") # Create combo via repo combo_id = page.evaluate( """async ({ exId }) => { const uuid = await window.__repos.settings.get('user_uuid'); const id = await window.__repos.combos.create({ title: 'HIIT Combo', description: 'High intensity interval training', type: 'TABATA', timer_config: { rounds: 8 }, created_by: uuid }); await window.__repos.combos.setExercises(id, [exId]); return id; }""", {"exId": ex_id}, ) page.goto(f"{app_url}/#/combos/{combo_id}") page.wait_for_selector('[data-testid="combo-detail-title"]', timeout=5000) expect(page.locator('[data-testid="combo-detail-title"]')).to_have_text("HIIT Combo") expect(page.locator('[data-testid="combo-detail-description"]')).to_have_text( "High intensity interval training" ) expect(page.locator('[data-testid="combo-detail-type"]')).to_have_text("TABATA") expect(page.locator('[data-testid="combo-detail-tabata-rounds"]')).to_contain_text("8") _clean_combos(page) def test_edit_combo(page: Page, app_url: str): """Edit a combo and verify changes are persisted.""" _go_to_combos(page, app_url) _clean_combos(page) page.reload() page.wait_for_function( "document.documentElement.getAttribute('data-db-ready') === 'true'", timeout=15000, ) page.click('a[href="#/combos"]') page.wait_for_timeout(500) _create_combo_via_ui(page, title="Original Combo", combo_type="NONE") page.click('[data-testid="combo-edit-btn"]') page.wait_for_selector('[data-testid="combo-title"]') page.fill('[data-testid="combo-title"]', "Updated Combo") page.select_option('[data-testid="combo-type"]', "EMOM") page.fill('[data-testid="combo-emom-duration"]', "15") page.click('[data-testid="combo-save"]') page.wait_for_selector('[data-testid="combo-detail-title"]', timeout=5000) expect(page.locator('[data-testid="combo-detail-title"]')).to_have_text("Updated Combo") expect(page.locator('[data-testid="combo-detail-type"]')).to_have_text("EMOM") _clean_combos(page) def test_delete_combo(page: Page, app_url: str): """Delete a combo with confirmation dialog.""" _go_to_combos(page, app_url) _clean_combos(page) page.reload() page.wait_for_function( "document.documentElement.getAttribute('data-db-ready') === 'true'", timeout=15000, ) page.click('a[href="#/combos"]') page.wait_for_timeout(500) _create_combo_via_ui(page, title="To Delete") page.on("dialog", lambda dialog: dialog.accept()) page.click('[data-testid="combo-delete-btn"]') page.wait_for_timeout(500) expect(page.locator('[data-testid="combo-empty"]')).to_be_visible() def test_delete_combo_cancel(page: Page, app_url: str): """Cancelling the delete dialog does not remove the combo.""" _go_to_combos(page, app_url) _clean_combos(page) page.reload() page.wait_for_function( "document.documentElement.getAttribute('data-db-ready') === 'true'", timeout=15000, ) page.click('a[href="#/combos"]') page.wait_for_timeout(500) _create_combo_via_ui(page, title="Keep Me") page.on("dialog", lambda dialog: dialog.dismiss()) page.click('[data-testid="combo-delete-btn"]') page.wait_for_timeout(500) expect(page.locator('[data-testid="combo-detail-title"]')).to_have_text("Keep Me") _clean_combos(page) def test_search_combos(page: Page, app_url: str): """Search filters combos by title.""" _go_to_combos(page, app_url) _clean_combos(page) # Create multiple combos via repo page.evaluate( """async () => { const uuid = await window.__repos.settings.get('user_uuid'); await window.__repos.combos.create({ title: 'Upper Body', description: '', type: 'EMOM', timer_config: {}, created_by: uuid }); await window.__repos.combos.create({ title: 'Lower Body', description: '', type: 'AMRAP', timer_config: {}, created_by: uuid }); await window.__repos.combos.create({ title: 'Full Body', description: '', type: 'TABATA', timer_config: {}, created_by: uuid }); }""" ) page.reload() page.wait_for_function( "document.documentElement.getAttribute('data-db-ready') === 'true'", timeout=15000, ) page.click('a[href="#/combos"]') page.wait_for_timeout(500) expect(page.locator('[data-testid="combo-card-title"]')).to_have_count(3) page.click('[data-testid="combo-search-toggle"]') page.wait_for_selector('[data-testid="combo-search-input"]') page.fill('[data-testid="combo-search-input"]', "Body") page.wait_for_timeout(500) expect(page.locator('[data-testid="combo-card-title"]')).to_have_count(3) page.fill('[data-testid="combo-search-input"]', "Upper") page.wait_for_timeout(500) expect(page.locator('[data-testid="combo-card-title"]')).to_have_count(1) expect(page.locator('[data-testid="combo-card-title"]')).to_have_text("Upper Body") _clean_combos(page) def test_search_no_results(page: Page, app_url: str): """Search with no matches shows no-results message.""" _go_to_combos(page, app_url) _clean_combos(page) page.evaluate( """async () => { const uuid = await window.__repos.settings.get('user_uuid'); await window.__repos.combos.create({ title: 'Upper Body', description: '', type: 'EMOM', timer_config: {}, created_by: uuid }); }""" ) page.reload() page.wait_for_function( "document.documentElement.getAttribute('data-db-ready') === 'true'", timeout=15000, ) page.click('a[href="#/combos"]') page.wait_for_timeout(500) page.click('[data-testid="combo-search-toggle"]') page.fill('[data-testid="combo-search-input"]', "zzzznotfound") page.wait_for_timeout(500) expect(page.locator('[data-testid="combo-no-results"]')).to_be_visible() _clean_combos(page) def test_search_clear_button(page: Page, app_url: str): """Clear button empties the search field and restores the full list.""" _go_to_combos(page, app_url) _clean_combos(page) page.evaluate( """async () => { const uuid = await window.__repos.settings.get('user_uuid'); await window.__repos.combos.create({ title: 'Upper Body', description: '', type: 'EMOM', timer_config: {}, created_by: uuid }); await window.__repos.combos.create({ title: 'Lower Body', description: '', type: 'AMRAP', timer_config: {}, created_by: uuid }); }""" ) page.reload() page.wait_for_function( "document.documentElement.getAttribute('data-db-ready') === 'true'", timeout=15000, ) page.click('a[href="#/combos"]') page.wait_for_timeout(500) page.click('[data-testid="combo-search-toggle"]') expect(page.locator('[data-testid="combo-search-clear"]')).not_to_be_visible() page.fill('[data-testid="combo-search-input"]', "Upper") page.wait_for_timeout(500) expect(page.locator('[data-testid="combo-card-title"]')).to_have_count(1) page.click('[data-testid="combo-search-clear"]') expect(page.locator('[data-testid="combo-search-input"]')).to_have_value("") page.wait_for_timeout(500) expect(page.locator('[data-testid="combo-card-title"]')).to_have_count(2) expect(page.locator('[data-testid="combo-search-input"]')).to_be_focused() _clean_combos(page) def test_validation_title_required(page: Page, app_url: str): """Empty title shows validation error and prevents save.""" _go_to_combos(page, app_url) _clean_combos(page) page.reload() page.wait_for_function( "document.documentElement.getAttribute('data-db-ready') === 'true'", timeout=15000, ) page.click('a[href="#/combos"]') page.wait_for_timeout(500) page.click('[data-testid="combo-new-btn"]') page.wait_for_selector('[data-testid="combo-title"]') page.click('[data-testid="combo-save"]') expect(page.locator('[data-testid="combo-title-error"]')).to_be_visible() expect(page.locator('[data-testid="combo-title"]')).to_be_visible() def test_duplicate_title_rejected(page: Page, app_url: str): """Creating a combo with a duplicate title shows an error.""" _go_to_combos(page, app_url) _clean_combos(page) page.reload() page.wait_for_function( "document.documentElement.getAttribute('data-db-ready') === 'true'", timeout=15000, ) page.click('a[href="#/combos"]') page.wait_for_timeout(500) _create_combo_via_ui(page, title="Unique Combo") page.click('[data-testid="combo-back-btn"]') page.wait_for_timeout(500) page.click('[data-testid="combo-new-btn"]') page.wait_for_selector('[data-testid="combo-title"]') page.fill('[data-testid="combo-title"]', "Unique Combo") page.click('[data-testid="combo-save"]') page.wait_for_timeout(500) title_error_visible = page.locator('[data-testid="combo-title-error"]').is_visible() save_error_visible = page.locator('[data-testid="combo-save-error"]').is_visible() assert title_error_visible or save_error_visible, "Expected duplicate title error" _clean_combos(page) def test_reorder_exercises(page: Page, app_url: str): """Reorder exercises in a combo.""" _go_to_combos(page, app_url) _clean_combos(page) page.reload() page.wait_for_function( "document.documentElement.getAttribute('data-db-ready') === 'true'", timeout=15000, ) page.click('a[href="#/combos"]') page.wait_for_timeout(500) # Create exercises ex1_id = _create_exercise_via_repo(page, "Push-ups") ex2_id = _create_exercise_via_repo(page, "Squats") ex3_id = _create_exercise_via_repo(page, "Burpees") page.reload() page.wait_for_function( "document.documentElement.getAttribute('data-db-ready') === 'true'", timeout=15000, ) page.click('a[href="#/combos"]') page.wait_for_timeout(500) # Create combo with 3 exercises _create_combo_via_ui( page, title="Reorder Test", exercise_ids=[ex1_id, ex2_id, ex3_id], ) # Go to edit page.click('[data-testid="combo-edit-btn"]') page.wait_for_selector('[data-testid="combo-title"]') # Move Squats (index 1) up page.click('[data-testid="combo-exercise-move-up-1"]') # Save page.click('[data-testid="combo-save"]') page.wait_for_selector('[data-testid="combo-detail-title"]', timeout=5000) # Verify order: Squats, Push-ups, Burpees expect(page.locator('[data-testid="combo-detail-exercise-0"]')).to_contain_text("Squats") expect(page.locator('[data-testid="combo-detail-exercise-1"]')).to_contain_text("Push-ups") expect(page.locator('[data-testid="combo-detail-exercise-2"]')).to_contain_text("Burpees") _clean_combos(page) def test_remove_exercise(page: Page, app_url: str): """Remove an exercise from a combo.""" _go_to_combos(page, app_url) _clean_combos(page) page.reload() page.wait_for_function( "document.documentElement.getAttribute('data-db-ready') === 'true'", timeout=15000, ) page.click('a[href="#/combos"]') page.wait_for_timeout(500) ex1_id = _create_exercise_via_repo(page, "Push-ups") ex2_id = _create_exercise_via_repo(page, "Squats") page.reload() page.wait_for_function( "document.documentElement.getAttribute('data-db-ready') === 'true'", timeout=15000, ) page.click('a[href="#/combos"]') page.wait_for_timeout(500) _create_combo_via_ui( page, title="Remove Test", exercise_ids=[ex1_id, ex2_id], ) page.click('[data-testid="combo-edit-btn"]') page.wait_for_selector('[data-testid="combo-title"]') # Remove first exercise page.click('[data-testid="combo-exercise-remove-0"]') page.click('[data-testid="combo-save"]') page.wait_for_selector('[data-testid="combo-detail-title"]', timeout=5000) # Only Squats should remain expect(page.locator('[data-testid="combo-detail-exercise-0"]')).to_contain_text("Squats") expect(page.locator('[data-testid="combo-detail-exercise-1"]')).not_to_be_attached() _clean_combos(page) def test_type_change(page: Page, app_url: str): """Changing type shows/hides timer config fields.""" _go_to_combos(page, app_url) _clean_combos(page) page.reload() page.wait_for_function( "document.documentElement.getAttribute('data-db-ready') === 'true'", timeout=15000, ) page.click('a[href="#/combos"]') page.wait_for_timeout(500) page.click('[data-testid="combo-new-btn"]') page.wait_for_selector('[data-testid="combo-title"]') # NONE type: no timer config fields expect(page.locator('[data-testid="combo-emom-duration"]')).not_to_be_visible() expect(page.locator('[data-testid="combo-amrap-duration"]')).not_to_be_visible() expect(page.locator('[data-testid="combo-tabata-work-time"]')).not_to_be_visible() expect(page.locator('[data-testid="combo-tabata-rest-time"]')).not_to_be_visible() # Switch to EMOM page.select_option('[data-testid="combo-type"]', "EMOM") expect(page.locator('[data-testid="combo-emom-duration"]')).to_be_visible() expect(page.locator('[data-testid="combo-amrap-duration"]')).not_to_be_visible() expect(page.locator('[data-testid="combo-tabata-work-time"]')).not_to_be_visible() expect(page.locator('[data-testid="combo-tabata-rest-time"]')).not_to_be_visible() # Switch to TABATA page.select_option('[data-testid="combo-type"]', "TABATA") expect(page.locator('[data-testid="combo-emom-duration"]')).not_to_be_visible() expect(page.locator('[data-testid="combo-amrap-duration"]')).not_to_be_visible() expect(page.locator('[data-testid="combo-tabata-work-time"]')).to_be_visible() expect(page.locator('[data-testid="combo-tabata-rest-time"]')).to_be_visible() _clean_combos(page) def test_action_bar_buttons(page: Page, app_url: str): """New and Search buttons are visible in the action bar on the combo list.""" _go_to_combos(page, app_url) _clean_combos(page) page.reload() page.wait_for_function( "document.documentElement.getAttribute('data-db-ready') === 'true'", timeout=15000, ) page.click('a[href="#/combos"]') page.wait_for_timeout(500) expect(page.locator('[data-testid="combo-new-btn"]')).to_be_visible() expect(page.locator('[data-testid="combo-search-toggle"]')).to_be_visible() def test_create_combo_with_reps_and_weight(page: Page, app_url: str): """Create combo with exercises and set reps/weight values.""" _go_to_combos(page, app_url) _clean_combos(page) page.reload() page.wait_for_function( "document.documentElement.getAttribute('data-db-ready') === 'true'", timeout=15000, ) page.click('a[href="#/combos"]') page.wait_for_timeout(500) # Create exercises ex1_id = _create_exercise_via_repo(page, "Push-ups") ex2_id = _create_exercise_via_repo(page, "Squats") # Create combo with exercises page.click('[data-testid="combo-new-btn"]') page.wait_for_selector('[data-testid="combo-title"]') page.fill('[data-testid="combo-title"]', "Reps Weight Test") # Add first exercise page.select_option('[data-testid="combo-exercise-select"]', ex1_id) page.click('[data-testid="combo-exercise-add"]') # Add second exercise page.select_option('[data-testid="combo-exercise-select"]', ex2_id) page.click('[data-testid="combo-exercise-add"]') # Set reps and weight for first exercise page.fill('[data-testid="combo-exercise-reps-0"]', "12") page.fill('[data-testid="combo-exercise-weight-0"]', "50") # Set reps for second exercise (weight stays default) page.fill('[data-testid="combo-exercise-reps-1"]', "15") page.click('[data-testid="combo-save"]') page.wait_for_selector('[data-testid="combo-detail-title"]', timeout=5000) # Verify detail view shows reps and weight expect(page.locator('[data-testid="combo-detail-exercise-0"]')).to_contain_text("Push-ups") expect(page.locator('[data-testid="combo-detail-exercise-0"]')).to_contain_text("12") expect(page.locator('[data-testid="combo-detail-exercise-0"]')).to_contain_text("50") expect(page.locator('[data-testid="combo-detail-exercise-1"]')).to_contain_text("Squats") expect(page.locator('[data-testid="combo-detail-exercise-1"]')).to_contain_text("15") _clean_combos(page) def test_edit_combo_reps_and_weight(page: Page, app_url: str): """Edit combo and change reps/weight values.""" _go_to_combos(page, app_url) _clean_combos(page) page.reload() page.wait_for_function( "document.documentElement.getAttribute('data-db-ready') === 'true'", timeout=15000, ) page.click('a[href="#/combos"]') page.wait_for_timeout(500) # Create exercise ex_id = _create_exercise_via_repo(page, "Deadlifts") # Create combo _create_combo_via_ui( page, title="Edit Reps Weight Test", exercise_ids=[ex_id], ) # Edit combo page.click('[data-testid="combo-edit-btn"]') page.wait_for_selector('[data-testid="combo-title"]') # Change reps and weight page.fill('[data-testid="combo-exercise-reps-0"]', "8") page.fill('[data-testid="combo-exercise-weight-0"]', "100") page.click('[data-testid="combo-save"]') page.wait_for_selector('[data-testid="combo-detail-title"]', timeout=5000) # Verify updated values expect(page.locator('[data-testid="combo-detail-exercise-0"]')).to_contain_text("Deadlifts") expect(page.locator('[data-testid="combo-detail-exercise-0"]')).to_contain_text("8") expect(page.locator('[data-testid="combo-detail-exercise-0"]')).to_contain_text("100") _clean_combos(page) def test_reps_weight_defaults(page: Page, app_url: str): """Reps prefill from the exercise's default_reps and weight defaults to 0.""" _go_to_combos(page, app_url) _clean_combos(page) page.reload() page.wait_for_function( "document.documentElement.getAttribute('data-db-ready') === 'true'", timeout=15000, ) page.click('a[href="#/combos"]') page.wait_for_timeout(500) # Create exercise ex_id = _create_exercise_via_repo(page, "Lunges") # Create combo page.click('[data-testid="combo-new-btn"]') page.wait_for_selector('[data-testid="combo-title"]') page.fill('[data-testid="combo-title"]', "Defaults Test") # Add exercise page.select_option('[data-testid="combo-exercise-select"]', ex_id) page.click('[data-testid="combo-exercise-add"]') # Verify default values in inputs (_create_exercise_via_repo sets default_reps=10) reps_input = page.locator('[data-testid="combo-exercise-reps-0"]') weight_input = page.locator('[data-testid="combo-exercise-weight-0"]') expect(reps_input).to_have_value("10") expect(weight_input).to_have_value("0") # Save without changing page.click('[data-testid="combo-save"]') page.wait_for_selector('[data-testid="combo-detail-title"]', timeout=5000) # Verify defaults are shown in detail view (always shown now) exercise_item = page.locator('[data-testid="combo-detail-exercise-0"]') expect(exercise_item).to_contain_text("Lunges") expect(page.locator('[data-testid="combo-detail-exercise-details-0"]')).to_contain_text("10") expect(page.locator('[data-testid="combo-detail-exercise-details-0"]')).to_contain_text("0") _clean_combos(page)