""" Step 3b — Exercise JSON Export/Import Tests - test_export_button_visible: Export button is visible on exercise list - test_export_single_button_visible: Export button is visible on exercise detail - test_export_import_roundtrip: Export exercises then import them back (no collision) - test_import_invalid_json: Import rejects non-JSON file - test_import_wrong_app: Import rejects file from different app - test_same_user_collision_skip: Same-user import with skip action - test_same_user_collision_replace: Same-user import with replace action - test_same_user_collision_batch_replace_all: Batch replace all collisions - test_same_user_collision_batch_skip_all: Batch skip all collisions - test_different_user_suffix_prompt: Different-user import shows suffix prompt - test_different_user_import_with_suffix: Different-user import adds suffix to titles - test_different_user_update_same_id: Same external user same ID treated as update - test_settings_export_filter_dialog: Settings export opens filter dialog - test_settings_import_button: Settings import button works - test_suffix_management_visible: Suffix section visible after import from different user - test_suffix_rename_cascade: Renaming suffix updates titles across exercises - test_suffix_rename_collision_rolls_back: Colliding rename fails atomically, nothing renamed - test_suffix_delete: Suffix can be deleted from settings - test_import_as_mine_button_visible: Import-as-mine buttons visible on exercises and settings - test_import_as_mine_no_suffix_no_collision: Foreign exercises imported as own without suffix - test_import_as_mine_collision_replace: Import-as-mine with collision uses replace action - test_import_as_mine_collision_skip: Import-as-mine with collision uses skip action - test_import_as_mine_from_settings: Import-as-mine button works from settings page - test_import_as_mine_dialog_title: Import-as-mine shows correct dialog title """ import json 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 _go_to_settings(page: Page, app_url: str): """Navigate to the settings page.""" _wait_for_db(page, app_url) page.click('a[href="#/settings"]') page.wait_for_timeout(500) def _clean_all(page: Page): """Remove all exercises and suffixes via the repositories.""" page.evaluate( """async () => { const exercises = await window.__repos.exercises.getAll(); for (const ex of exercises) { await window.__repos.exercises.delete(ex.id); } const suffixes = await window.__repos.suffixes.getAll(); for (const s of suffixes) { await window.__repos.suffixes.delete(s.user_uuid); } }""" ) def _create_exercise_directly(page: Page, title, created_by=None, exercise_id=None): """Create an exercise directly via repository for test setup.""" return page.evaluate( """async ([title, createdBy, exerciseId]) => { const uuid = await window.__repos.settings.get('user_uuid'); const cb = createdBy || uuid; if (exerciseId) { await window.__repos.exercises.createWithId(exerciseId, { title: title, description: 'Test desc', asymmetric: false, alternate: false, image_urls: [], video_urls: [], default_reps: 10, default_weight: 20, created_by: cb, }); return exerciseId; } else { const id = await window.__repos.exercises.create({ title: title, description: 'Test desc', asymmetric: false, alternate: false, image_urls: [], video_urls: [], default_reps: 10, default_weight: 20, created_by: cb, }); return id; } }""", [title, created_by, exercise_id], ) def _build_export_json(exercises, exporter_name="Test User", exporter_uuid=None): """Build a valid export JSON envelope for testing.""" envelope = { "metadata": { "appName": "trainus", "appVersion": "1.0.0", "schemaVersion": 1, "exportDate": "2026-03-30T12:00:00.000Z", "exportedBy": { "name": exporter_name, "uuid": exporter_uuid or "test-uuid-1234", }, }, "data": {"exercises": exercises}, } return json.dumps(envelope) def _get_local_uuid(page: Page): """Get the local user UUID.""" return page.evaluate( "async () => await window.__repos.settings.get('user_uuid')" ) def _trigger_import_with_json(page: Page, json_str: str): """Trigger the import flow by injecting a file into the file picker. This uses a file chooser event listener to intercept the file picker and inject the JSON content. """ with page.expect_file_chooser() as fc_info: page.click('[data-testid="exercise-import-btn"]') file_chooser = fc_info.value file_chooser.set_files( [ { "name": "test-import.json", "mimeType": "application/json", "buffer": json_str.encode("utf-8"), } ] ) def _trigger_settings_import_with_json(page: Page, json_str: str): """Trigger import from settings page.""" with page.expect_file_chooser() as fc_info: page.click('[data-testid="settings-import"]') file_chooser = fc_info.value file_chooser.set_files( [ { "name": "test-import.json", "mimeType": "application/json", "buffer": json_str.encode("utf-8"), } ] ) def _trigger_import_as_mine_with_json(page: Page, json_str: str): """Trigger the 'import as mine' flow from the exercises action bar.""" with page.expect_file_chooser() as fc_info: page.click('[data-testid="exercise-import-as-mine-btn"]') file_chooser = fc_info.value file_chooser.set_files( [ { "name": "test-import.json", "mimeType": "application/json", "buffer": json_str.encode("utf-8"), } ] ) def _trigger_settings_import_as_mine_with_json(page: Page, json_str: str): """Trigger 'import as mine' from settings page.""" with page.expect_file_chooser() as fc_info: page.click('[data-testid="settings-import-as-mine"]') file_chooser = fc_info.value file_chooser.set_files( [ { "name": "test-import.json", "mimeType": "application/json", "buffer": json_str.encode("utf-8"), } ] ) # ============================================================ # Tests # ============================================================ def test_export_button_visible(page: Page, app_url: str): """Export button is visible on the exercise list action bar.""" _go_to_exercises(page, app_url) _clean_all(page) expect(page.locator('[data-testid="exercise-export-btn"]')).to_be_visible() expect(page.locator('[data-testid="exercise-import-btn"]')).to_be_visible() def test_export_single_button_visible(page: Page, app_url: str): """Export button is visible on exercise detail view.""" _go_to_exercises(page, app_url) _clean_all(page) ex_id = _create_exercise_directly(page, "Export Test Exercise") 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(f'[data-testid="exercise-card-{ex_id}"]') page.wait_for_timeout(500) expect( page.locator('[data-testid="exercise-export-single-btn"]') ).to_be_visible() # Cleanup _go_to_exercises(page, app_url) _clean_all(page) def test_export_import_roundtrip(page: Page, app_url: str): """Export exercises to JSON, clear DB, import them back, verify they exist.""" _go_to_exercises(page, app_url) _clean_all(page) # Create exercises _create_exercise_directly(page, "Roundtrip Exercise A") _create_exercise_directly(page, "Roundtrip Exercise B") # Get the export data by building it from the repo directly local_uuid = _get_local_uuid(page) exercises = page.evaluate( "async () => await window.__repos.exercises.getAll()" ) # Clean exercises _clean_all(page) # Build import JSON with the local user UUID (same user) json_str = _build_export_json( exercises, exporter_name="Local User", exporter_uuid=local_uuid, ) # Reload page so it picks up the clean state 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) # Trigger import _trigger_import_with_json(page, json_str) # Should see preview step (no collisions) page.wait_for_selector('[data-testid="import-preview-step"]', timeout=5000) expect(page.locator('[data-testid="import-preview-step"]')).to_be_visible() # Click import page.click('[data-testid="import-apply"]') # Should see report page.wait_for_selector('[data-testid="import-report-step"]', timeout=5000) expect(page.locator('[data-testid="import-report-imported"]')).to_be_visible() # Close dialog page.click('[data-testid="import-done"]') # Verify exercises exist page.wait_for_timeout(500) expect(page.locator('[data-testid="exercise-card-title"]')).to_have_count(2) # Cleanup _clean_all(page) def test_import_invalid_json(page: Page, app_url: str): """Import rejects a file with invalid JSON content.""" _go_to_exercises(page, app_url) _clean_all(page) # Trigger import with invalid JSON page.on("dialog", lambda d: d.accept()) with page.expect_file_chooser() as fc_info: page.click('[data-testid="exercise-import-btn"]') file_chooser = fc_info.value file_chooser.set_files( [ { "name": "bad.json", "mimeType": "application/json", "buffer": b"this is not json", } ] ) # An alert dialog should appear (handled by dialog listener) page.wait_for_timeout(1000) # The import dialog should NOT be visible expect(page.locator('[data-testid="modal-overlay"]')).not_to_be_visible() # Cleanup _clean_all(page) def test_import_wrong_app(page: Page, app_url: str): """Import rejects a file from a different application.""" _go_to_exercises(page, app_url) _clean_all(page) bad_envelope = json.dumps( { "metadata": { "appName": "other-app", "appVersion": "1.0.0", "schemaVersion": 1, "exportDate": "2026-03-30T12:00:00.000Z", "exportedBy": {"name": "Test", "uuid": "abc"}, }, "data": {"exercises": []}, } ) page.on("dialog", lambda d: d.accept()) with page.expect_file_chooser() as fc_info: page.click('[data-testid="exercise-import-btn"]') file_chooser = fc_info.value file_chooser.set_files( [ { "name": "wrong.json", "mimeType": "application/json", "buffer": bad_envelope.encode("utf-8"), } ] ) page.wait_for_timeout(1000) expect(page.locator('[data-testid="modal-overlay"]')).not_to_be_visible() _clean_all(page) def test_same_user_collision_skip(page: Page, app_url: str): """Same-user import with 'skip' action keeps the existing exercise.""" _go_to_exercises(page, app_url) _clean_all(page) local_uuid = _get_local_uuid(page) ex_id = _create_exercise_directly(page, "Collision Exercise") # Build import with same ID but different description exercises = [ { "id": ex_id, "title": "Collision Exercise", "description": "Imported version", "asymmetric": False, "alternate": False, "image_urls": [], "video_urls": [], "default_reps": 99, "default_weight": 99, "created_by": local_uuid, } ] json_str = _build_export_json(exercises, "Local User", local_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) _trigger_import_with_json(page, json_str) # Should see collisions step page.wait_for_selector( '[data-testid="import-collisions-step"]', timeout=5000 ) # Default is skip, so just click apply page.click('[data-testid="import-apply"]') # Should see report with skipped page.wait_for_selector('[data-testid="import-report-step"]', timeout=5000) expect(page.locator('[data-testid="import-report-skipped"]')).to_be_visible() page.click('[data-testid="import-done"]') # Verify original exercise is unchanged (10 reps, not 99) exercise_data = page.evaluate( f"""async () => await window.__repos.exercises.getById('{ex_id}')""" ) assert exercise_data["default_reps"] == 10 _clean_all(page) def test_same_user_collision_replace(page: Page, app_url: str): """Same-user import with 'replace' action overwrites the existing exercise.""" _go_to_exercises(page, app_url) _clean_all(page) local_uuid = _get_local_uuid(page) ex_id = _create_exercise_directly(page, "Replace Exercise") exercises = [ { "id": ex_id, "title": "Replace Exercise", "description": "Replaced version", "asymmetric": False, "alternate": False, "image_urls": [], "video_urls": [], "default_reps": 99, "default_weight": 99, "created_by": local_uuid, } ] json_str = _build_export_json(exercises, "Local User", local_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) _trigger_import_with_json(page, json_str) page.wait_for_selector( '[data-testid="import-collisions-step"]', timeout=5000 ) # Click replace on the first collision (exercise-specific testid) page.click('[data-testid="collision-replace-exercise-0"]') page.click('[data-testid="import-apply"]') page.wait_for_selector('[data-testid="import-report-step"]', timeout=5000) expect(page.locator('[data-testid="import-report-exercises-replaced"]')).to_be_visible() page.click('[data-testid="import-done"]') # Verify exercise was replaced (99 reps now) exercise_data = page.evaluate( f"""async () => await window.__repos.exercises.getById('{ex_id}')""" ) assert exercise_data["default_reps"] == 99 _clean_all(page) def test_same_user_collision_batch_replace_all(page: Page, app_url: str): """Batch 'Replace All' replaces all collisions at once.""" _go_to_exercises(page, app_url) _clean_all(page) local_uuid = _get_local_uuid(page) id_a = _create_exercise_directly(page, "Batch A") id_b = _create_exercise_directly(page, "Batch B") exercises = [ { "id": id_a, "title": "Batch A", "description": "Replaced A", "asymmetric": False, "alternate": False, "image_urls": [], "video_urls": [], "default_reps": 77, "default_weight": 77, "created_by": local_uuid, }, { "id": id_b, "title": "Batch B", "description": "Replaced B", "asymmetric": False, "alternate": False, "image_urls": [], "video_urls": [], "default_reps": 88, "default_weight": 88, "created_by": local_uuid, }, ] json_str = _build_export_json(exercises, "Local User", local_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) _trigger_import_with_json(page, json_str) page.wait_for_selector( '[data-testid="import-collisions-step"]', timeout=5000 ) # Click Replace All (exercise-specific testid) page.click('[data-testid="exercise-replace-all"]') page.click('[data-testid="import-apply"]') page.wait_for_selector('[data-testid="import-report-step"]', timeout=5000) expect(page.locator('[data-testid="import-report-exercises-replaced"]')).to_contain_text("2") page.click('[data-testid="import-done"]') # Verify both replaced ex_a = page.evaluate( f"""async () => await window.__repos.exercises.getById('{id_a}')""" ) ex_b = page.evaluate( f"""async () => await window.__repos.exercises.getById('{id_b}')""" ) assert ex_a["default_reps"] == 77 assert ex_b["default_reps"] == 88 _clean_all(page) def test_same_user_collision_batch_skip_all(page: Page, app_url: str): """Batch 'Skip All' skips all collisions.""" _go_to_exercises(page, app_url) _clean_all(page) local_uuid = _get_local_uuid(page) id_a = _create_exercise_directly(page, "Skip A") id_b = _create_exercise_directly(page, "Skip B") exercises = [ { "id": id_a, "title": "Skip A", "description": "Should not replace", "asymmetric": False, "alternate": False, "image_urls": [], "video_urls": [], "default_reps": 99, "default_weight": 99, "created_by": local_uuid, }, { "id": id_b, "title": "Skip B", "description": "Should not replace", "asymmetric": False, "alternate": False, "image_urls": [], "video_urls": [], "default_reps": 99, "default_weight": 99, "created_by": local_uuid, }, ] json_str = _build_export_json(exercises, "Local User", local_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) _trigger_import_with_json(page, json_str) page.wait_for_selector( '[data-testid="import-collisions-step"]', timeout=5000 ) # Click Skip All (exercise-specific testid) page.click('[data-testid="exercise-skip-all"]') page.click('[data-testid="import-apply"]') page.wait_for_selector('[data-testid="import-report-step"]', timeout=5000) expect(page.locator('[data-testid="import-report-skipped"]')).to_contain_text("2") page.click('[data-testid="import-done"]') # Verify originals unchanged ex_a = page.evaluate( f"""async () => await window.__repos.exercises.getById('{id_a}')""" ) assert ex_a["default_reps"] == 10 _clean_all(page) def test_different_user_suffix_prompt(page: Page, app_url: str): """Importing from a different user shows the suffix prompt.""" _go_to_exercises(page, app_url) _clean_all(page) other_uuid = "other-user-uuid-9999" exercises = [ { "id": "ext-exercise-id-1", "title": "External Push-ups", "description": "From another user", "asymmetric": False, "alternate": False, "image_urls": [], "video_urls": [], "default_reps": 15, "default_weight": 0, "created_by": other_uuid, } ] json_str = _build_export_json(exercises, "Jane Doe", other_uuid) _trigger_import_with_json(page, json_str) # Should see suffix step page.wait_for_selector('[data-testid="import-suffix-step"]', timeout=5000) expect(page.locator('[data-testid="import-suffix-input"]')).to_be_visible() # Cancel page.click('[data-testid="import-cancel"]') _clean_all(page) def test_different_user_import_with_suffix(page: Page, app_url: str): """Different-user import applies suffix to titles.""" _go_to_exercises(page, app_url) _clean_all(page) other_uuid = "other-user-uuid-8888" exercises = [ { "id": "ext-exercise-suffix-1", "title": "Squats", "description": "External exercise", "asymmetric": False, "alternate": False, "image_urls": [], "video_urls": [], "default_reps": 20, "default_weight": 50, "created_by": other_uuid, } ] json_str = _build_export_json(exercises, "Bob Smith", other_uuid) _trigger_import_with_json(page, json_str) # Suffix step page.wait_for_selector('[data-testid="import-suffix-step"]', timeout=5000) # Clear and type new suffix page.fill('[data-testid="import-suffix-input"]', "BS") page.click('[data-testid="import-suffix-confirm"]') # Preview step page.wait_for_selector('[data-testid="import-preview-step"]', timeout=5000) page.click('[data-testid="import-apply"]') # Report page.wait_for_selector('[data-testid="import-report-step"]', timeout=5000) expect(page.locator('[data-testid="import-report-imported"]')).to_be_visible() page.click('[data-testid="import-done"]') # Verify title has suffix page.wait_for_timeout(500) titles = page.locator('[data-testid="exercise-card-title"]') expect(titles).to_have_count(1) expect(titles.first).to_contain_text("Squats [BS]") _clean_all(page) def test_different_user_update_same_id(page: Page, app_url: str): """Same external user, same ID is treated as an update (no new ID).""" _go_to_exercises(page, app_url) _clean_all(page) other_uuid = "other-user-uuid-7777" ext_id = "ext-update-id-1" # First, create the exercise as if it was previously imported _create_exercise_directly(page, "Lunges [JD]", other_uuid, ext_id) # Also register the suffix page.evaluate( """async ([uuid]) => { await window.__repos.suffixes.create({ user_uuid: uuid, user_name: 'John Doe', suffix: 'JD', }); }""", [other_uuid], ) # Import update with same external user + same ID exercises = [ { "id": ext_id, "title": "Lunges", "description": "Updated version", "asymmetric": True, "alternate": False, "image_urls": [], "video_urls": [], "default_reps": 30, "default_weight": 15, "created_by": other_uuid, } ] json_str = _build_export_json(exercises, "John Doe", other_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) _trigger_import_with_json(page, json_str) # Should skip suffix prompt (already registered) and go to preview page.wait_for_selector('[data-testid="import-preview-step"]', timeout=5000) page.click('[data-testid="import-apply"]') page.wait_for_selector('[data-testid="import-report-step"]', timeout=5000) expect(page.locator('[data-testid="import-report-exercises-updated"]')).to_be_visible() page.click('[data-testid="import-done"]') # Verify exercise was updated (same ID, new data) exercise = page.evaluate( f"""async () => await window.__repos.exercises.getById('{ext_id}')""" ) assert exercise is not None assert exercise["default_reps"] == 30 assert exercise["title"] == "Lunges [JD]" _clean_all(page) def test_settings_export_filter_dialog(page: Page, app_url: str): """Settings export button opens the filter dialog.""" _go_to_settings(page, app_url) _clean_all(page) page.click('[data-testid="settings-export"]') page.wait_for_selector('[data-testid="modal-card"]', timeout=5000) expect(page.locator('[data-testid="export-filter-exercises"]')).to_be_visible() expect(page.locator('[data-testid="export-filter-combos"]')).to_be_visible() expect(page.locator('[data-testid="export-filter-sessions"]')).to_be_visible() expect(page.locator('[data-testid="export-filter-collections"]')).to_be_visible() expect(page.locator('[data-testid="export-filter-execution-logs"]')).to_be_visible() expect(page.locator('[data-testid="export-filter-settings"]')).to_be_visible() # Close page.click('[data-testid="export-filter-cancel"]') page.wait_for_timeout(300) expect(page.locator('[data-testid="modal-overlay"]')).not_to_be_visible() _clean_all(page) def test_settings_import_button(page: Page, app_url: str): """Settings import button opens file picker and handles import.""" _go_to_settings(page, app_url) _clean_all(page) other_uuid = "settings-import-uuid-1111" exercises = [ { "id": "settings-import-ex-1", "title": "Settings Import Exercise", "description": "Via settings", "asymmetric": False, "alternate": False, "image_urls": [], "video_urls": [], "default_reps": 5, "default_weight": 10, "created_by": other_uuid, } ] json_str = _build_export_json(exercises, "Settings User", other_uuid) _trigger_settings_import_with_json(page, json_str) # Should see suffix step (different user) page.wait_for_selector('[data-testid="import-suffix-step"]', timeout=5000) # Enter suffix and continue page.fill('[data-testid="import-suffix-input"]', "SU") page.click('[data-testid="import-suffix-confirm"]') # Preview page.wait_for_selector('[data-testid="import-preview-step"]', timeout=5000) page.click('[data-testid="import-apply"]') # Report page.wait_for_selector('[data-testid="import-report-step"]', timeout=5000) page.click('[data-testid="import-done"]') # Verify exercise created exercise = page.evaluate( """async () => { const exercises = await window.__repos.exercises.getAll(); return exercises.find(e => e.title === 'Settings Import Exercise [SU]'); }""" ) assert exercise is not None _clean_all(page) def test_suffix_management_visible(page: Page, app_url: str): """Suffix management section is visible after an import from a different user.""" _go_to_exercises(page, app_url) _clean_all(page) # Create a suffix entry directly page.evaluate( """async () => { await window.__repos.suffixes.create({ user_uuid: 'suffix-mgmt-uuid-1', user_name: 'Alice', suffix: 'AL', }); }""" ) _go_to_settings(page, app_url) expect(page.locator('[data-testid="suffix-management"]')).to_be_visible() expect(page.locator('[data-testid="suffix-value"]')).to_contain_text("[AL]") _clean_all(page) def test_suffix_rename_cascade(page: Page, app_url: str): """Renaming a suffix updates exercise titles that contain the old suffix.""" _go_to_exercises(page, app_url) _clean_all(page) other_uuid = "rename-test-uuid-5555" # Create suffix and exercise page.evaluate( f"""async () => {{ await window.__repos.suffixes.create({{ user_uuid: '{other_uuid}', user_name: 'Charlie', suffix: 'CH', }}); await window.__repos.exercises.createWithId('rename-ex-1', {{ title: 'Burpees [CH]', description: 'Test', asymmetric: false, alternate: false, image_urls: [], video_urls: [], default_reps: 10, default_weight: 0, created_by: '{other_uuid}', }}); }}""" ) _go_to_settings(page, app_url) # Start editing page.click('[data-testid="suffix-edit-btn"]') page.fill('[data-testid="suffix-edit-input"]', "CW") page.click('[data-testid="suffix-save-btn"]') page.wait_for_timeout(500) # Verify suffix updated in UI expect(page.locator('[data-testid="suffix-value"]')).to_contain_text("[CW]") # Verify exercise title was updated exercise = page.evaluate( """async () => await window.__repos.exercises.getById('rename-ex-1')""" ) assert exercise["title"] == "Burpees [CW]" _clean_all(page) def test_suffix_rename_collision_rolls_back(page: Page, app_url: str): """A rename that would create a duplicate title fails atomically: an error is shown and nothing (suffix entry or titles) was renamed.""" _go_to_exercises(page, app_url) _clean_all(page) other_uuid = "rename-atomic-uuid-6666" # Suffixed exercise 'Squats [CH]' plus a local 'Squats [CW]' that the # rename CH → CW would duplicate (exercise.title is UNIQUE) page.evaluate( f"""async () => {{ await window.__repos.suffixes.create({{ user_uuid: '{other_uuid}', user_name: 'Charlie', suffix: 'CH', }}); await window.__repos.exercises.createWithId('atomic-ex-1', {{ title: 'Squats [CH]', description: 'Test', asymmetric: false, alternate: false, image_urls: [], video_urls: [], default_reps: 10, default_weight: 0, created_by: '{other_uuid}', }}); const userId = await window.__repos.settings.get('user_uuid') await window.__repos.exercises.createWithId('atomic-ex-2', {{ title: 'Squats [CW]', description: 'Blocker', asymmetric: false, alternate: false, image_urls: [], video_urls: [], default_reps: 10, default_weight: 0, created_by: userId, }}); }}""" ) _go_to_settings(page, app_url) page.click('[data-testid="suffix-edit-btn"]') page.fill('[data-testid="suffix-edit-input"]', "CW") page.click('[data-testid="suffix-save-btn"]') # The rename must fail with a visible error expect(page.locator('[data-testid="suffix-edit-error"]')).to_be_visible() # ... and nothing must have been renamed (transaction rolled back) state = page.evaluate( f"""async () => {{ const suffix = await window.__repos.suffixes.getByUuid('{other_uuid}') const ex = await window.__repos.exercises.getById('atomic-ex-1') return {{ suffix: suffix.suffix, title: ex.title }} }}""" ) assert state["suffix"] == "CH", f"Suffix entry must be unchanged, got {state['suffix']}" assert state["title"] == "Squats [CH]", f"Exercise title must be unchanged, got {state['title']}" _clean_all(page) def test_suffix_delete(page: Page, app_url: str): """Suffix can be deleted from settings.""" _go_to_exercises(page, app_url) _clean_all(page) # Create suffix page.evaluate( """async () => { await window.__repos.suffixes.create({ user_uuid: 'delete-suffix-uuid', user_name: 'Dave', suffix: 'DV', }); }""" ) _go_to_settings(page, app_url) expect(page.locator('[data-testid="suffix-management"]')).to_be_visible() # Delete with confirmation via modal dialog page.click('[data-testid="suffix-delete-btn"]') # Confirm deletion in the modal expect(page.locator('[data-testid="suffix-delete-confirm"]')).to_be_visible() page.click('[data-testid="suffix-delete-confirm"]') page.wait_for_timeout(500) # Suffix section should be hidden (no suffixes left) expect(page.locator('[data-testid="suffix-management"]')).not_to_be_visible() _clean_all(page) # ============================================================ # Import As Mine Tests # ============================================================ def test_import_as_mine_button_visible(page: Page, app_url: str): """Import-as-mine buttons are visible on exercise list and settings.""" _go_to_exercises(page, app_url) _clean_all(page) expect( page.locator('[data-testid="exercise-import-as-mine-btn"]') ).to_be_visible() _go_to_settings(page, app_url) expect( page.locator('[data-testid="settings-import-as-mine"]') ).to_be_visible() _clean_all(page) def test_import_as_mine_no_suffix_no_collision(page: Page, app_url: str): """Import-as-mine from a different user inserts exercises without suffix, owned by local user.""" _go_to_exercises(page, app_url) _clean_all(page) local_uuid = _get_local_uuid(page) other_uuid = "foreign-user-uuid-aaaa" exercises = [ { "id": "as-mine-ex-1", "title": "Foreign Push-ups", "description": "From another user", "asymmetric": False, "alternate": False, "image_urls": [], "video_urls": [], "default_reps": 15, "default_weight": 0, "created_by": other_uuid, }, { "id": "as-mine-ex-2", "title": "Foreign Squats", "description": "From another user", "asymmetric": False, "alternate": False, "image_urls": [], "video_urls": [], "default_reps": 20, "default_weight": 50, "created_by": other_uuid, }, ] json_str = _build_export_json(exercises, "Foreign User", other_uuid) _trigger_import_as_mine_with_json(page, json_str) # Should go directly to preview (no suffix step) page.wait_for_selector('[data-testid="import-preview-step"]', timeout=5000) expect(page.locator('[data-testid="import-suffix-step"]')).not_to_be_visible() page.click('[data-testid="import-apply"]') # Report page.wait_for_selector('[data-testid="import-report-step"]', timeout=5000) expect(page.locator('[data-testid="import-report-imported"]')).to_contain_text( "2" ) page.click('[data-testid="import-done"]') # Verify exercises exist with original titles (no suffix) page.wait_for_timeout(500) titles = page.locator('[data-testid="exercise-card-title"]') expect(titles).to_have_count(2) expect(titles.nth(0)).to_contain_text("Foreign") expect(titles.nth(1)).to_contain_text("Foreign") # Verify created_by is the local user, not the foreign user ex1 = page.evaluate( """async () => await window.__repos.exercises.getById('as-mine-ex-1')""" ) assert ex1["created_by"] == local_uuid assert ex1["title"] == "Foreign Push-ups" ex2 = page.evaluate( """async () => await window.__repos.exercises.getById('as-mine-ex-2')""" ) assert ex2["created_by"] == local_uuid _clean_all(page) def test_import_as_mine_collision_replace(page: Page, app_url: str): """Import-as-mine with ID collision shows collision step; replace overwrites exercise.""" _go_to_exercises(page, app_url) _clean_all(page) local_uuid = _get_local_uuid(page) other_uuid = "foreign-user-uuid-bbbb" ex_id = "as-mine-collision-1" # Create existing exercise with the same ID _create_exercise_directly(page, "Existing Plank", None, ex_id) exercises = [ { "id": ex_id, "title": "Existing Plank", "description": "Imported version", "asymmetric": True, "alternate": False, "image_urls": [], "video_urls": [], "default_reps": 60, "default_weight": 0, "created_by": other_uuid, } ] json_str = _build_export_json(exercises, "Foreign User", other_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) _trigger_import_as_mine_with_json(page, json_str) # Should see collisions step (same-user logic) page.wait_for_selector( '[data-testid="import-collisions-step"]', timeout=5000 ) # Choose replace (exercise-specific testid) page.click('[data-testid="collision-replace-exercise-0"]') page.click('[data-testid="import-apply"]') # Report page.wait_for_selector('[data-testid="import-report-step"]', timeout=5000) expect(page.locator('[data-testid="import-report-exercises-replaced"]')).to_be_visible() page.click('[data-testid="import-done"]') # Verify exercise was replaced with new data, owned by local user exercise = page.evaluate( f"""async () => await window.__repos.exercises.getById('{ex_id}')""" ) assert exercise["default_reps"] == 60 assert exercise["asymmetric"] == 1 # SQLite stores bool as int assert exercise["created_by"] == local_uuid _clean_all(page) def test_import_as_mine_collision_skip(page: Page, app_url: str): """Import-as-mine with ID collision and skip action keeps the original exercise.""" _go_to_exercises(page, app_url) _clean_all(page) other_uuid = "foreign-user-uuid-cccc" ex_id = "as-mine-skip-1" _create_exercise_directly(page, "Keep This Exercise", None, ex_id) exercises = [ { "id": ex_id, "title": "Keep This Exercise", "description": "Should not replace", "asymmetric": False, "alternate": False, "image_urls": [], "video_urls": [], "default_reps": 99, "default_weight": 99, "created_by": other_uuid, } ] json_str = _build_export_json(exercises, "Foreign User", other_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) _trigger_import_as_mine_with_json(page, json_str) # Should see collisions step page.wait_for_selector( '[data-testid="import-collisions-step"]', timeout=5000 ) # Default is skip, just apply page.click('[data-testid="import-apply"]') # Report shows skipped page.wait_for_selector('[data-testid="import-report-step"]', timeout=5000) expect(page.locator('[data-testid="import-report-skipped"]')).to_be_visible() page.click('[data-testid="import-done"]') # Verify original unchanged exercise = page.evaluate( f"""async () => await window.__repos.exercises.getById('{ex_id}')""" ) assert exercise["default_reps"] == 10 _clean_all(page) def test_import_as_mine_from_settings(page: Page, app_url: str): """Import-as-mine from settings page works without suffix prompt.""" _go_to_settings(page, app_url) _clean_all(page) local_uuid = _get_local_uuid(page) other_uuid = "foreign-user-uuid-dddd" exercises = [ { "id": "settings-as-mine-ex-1", "title": "Settings Mine Exercise", "description": "Via settings as mine", "asymmetric": False, "alternate": False, "image_urls": [], "video_urls": [], "default_reps": 12, "default_weight": 25, "created_by": other_uuid, } ] json_str = _build_export_json(exercises, "Foreign User", other_uuid) _trigger_settings_import_as_mine_with_json(page, json_str) # Should go to preview directly (no suffix) page.wait_for_selector('[data-testid="import-preview-step"]', timeout=5000) page.click('[data-testid="import-apply"]') # Report page.wait_for_selector('[data-testid="import-report-step"]', timeout=5000) expect(page.locator('[data-testid="import-report-imported"]')).to_be_visible() page.click('[data-testid="import-done"]') # Verify exercise created and owned by local user exercise = page.evaluate( """async () => { const exercises = await window.__repos.exercises.getAll(); return exercises.find(e => e.title === 'Settings Mine Exercise'); }""" ) assert exercise is not None assert exercise["created_by"] == local_uuid _clean_all(page) def test_import_as_mine_dialog_title(page: Page, app_url: str): """Import-as-mine dialog shows the correct 'Import as Mine' title.""" _go_to_exercises(page, app_url) _clean_all(page) other_uuid = "foreign-user-uuid-eeee" exercises = [ { "id": "dialog-title-ex-1", "title": "Dialog Title Test", "description": "Test", "asymmetric": False, "alternate": False, "image_urls": [], "video_urls": [], "default_reps": 5, "default_weight": 0, "created_by": other_uuid, } ] json_str = _build_export_json(exercises, "Test User", other_uuid) _trigger_import_as_mine_with_json(page, json_str) # Wait for dialog page.wait_for_selector('[data-testid="import-preview-step"]', timeout=5000) # Verify the dialog title says "Import as Mine" modal_title = page.locator('[data-testid="modal-title"]') expect(modal_title).to_contain_text("Import as Mine") # Cancel page.click('[data-testid="import-cancel"]') _clean_all(page)