""" Step 21 — JSON Import: Title-Conflict Resolution UX - test_same_user_title_conflict_shown_as_collision: title-only conflict (different id) surfaces in the collisions screen instead of being silently dropped - test_same_user_title_conflict_skip: skip leaves the existing exercise untouched, no duplicate row - test_same_user_title_conflict_replace: replace updates the existing row in place (keeps its id) - test_same_user_title_conflict_dependency_redirect_skip: a combo in the same batch referencing the conflicting exercise's imported id ends up pointing at the existing exercise's id after skip - test_same_user_title_conflict_dependency_redirect_replace: same redirect check, replace branch - test_cross_user_title_conflict_skip: suffixed-title conflict (re-import from same external user) offers Replace/Skip instead of a forced rename; skip leaves the previous import untouched - test_cross_user_title_conflict_replace: same scenario, replace updates the previous import in place """ import json from playwright.sync_api import Page, expect def _wait_for_db(page: Page, app_url: str): 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): _wait_for_db(page, app_url) page.click('a[href="#/exercises"]') page.wait_for_timeout(500) def _clean_all(page: Page): page.evaluate( """async () => { const combos = await window.__repos.combos.getAll(); for (const c of combos) await window.__repos.combos.delete(c.id); 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 _get_local_uuid(page: Page): return page.evaluate("async () => await window.__repos.settings.get('user_uuid')") def _create_exercise_directly(page: Page, title, created_by=None, exercise_id=None): 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, description: '', asymmetric: false, alternate: false, image_urls: [], video_urls: [], default_reps: 10, default_weight: 20, created_by: cb, }); return exerciseId; } return await window.__repos.exercises.create({ title, description: '', asymmetric: false, alternate: false, image_urls: [], video_urls: [], default_reps: 10, default_weight: 20, created_by: cb, }); }""", [title, created_by, exercise_id], ) def _build_export_json(data, exporter_name, exporter_uuid): envelope = { "metadata": { "appName": "trainus", "appVersion": "1.0.0", "schemaVersion": 1, "exportDate": "2026-06-16T12:00:00.000Z", "exportedBy": {"name": exporter_name, "uuid": exporter_uuid}, }, "data": data, } return json.dumps(envelope) def _trigger_import_with_json(page: Page, json_str: str, test_id: str = "exercise-import-btn"): with page.expect_file_chooser() as fc_info: page.click(f'[data-testid="{test_id}"]') file_chooser = fc_info.value file_chooser.set_files( [ { "name": "test-import.json", "mimeType": "application/json", "buffer": json_str.encode("utf-8"), } ] ) def _exercise_count_by_title(page: Page, title: str) -> int: return page.evaluate( """async (title) => { const all = await window.__repos.exercises.getAll(); return all.filter((e) => e.title === title).length; }""", title, ) # ============================================================ # Same-user title conflicts (different id, same title) # ============================================================ def test_same_user_title_conflict_shown_as_collision(page: Page, app_url: str): """A same-user import whose title matches an existing exercise (different id) surfaces in the collisions screen instead of being silently dropped or raising a raw DB error.""" _go_to_exercises(page, app_url) _clean_all(page) local_uuid = _get_local_uuid(page) _create_exercise_directly(page, "Push-ups") imported = { "id": "11111111-1111-1111-1111-111111111111", "title": "Push-ups", "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": [imported]}, "Local User", local_uuid) _trigger_import_with_json(page, json_str) expect(page.locator('[data-testid="import-collisions-step"]')).to_be_visible(timeout=5000) expect(page.locator('[data-testid="collision-item-exercise-0"]')).to_contain_text("Push-ups") page.click('[data-testid="import-cancel"]') _clean_all(page) def test_same_user_title_conflict_skip(page: Page, app_url: str): """Skip on a same-user title conflict leaves the existing exercise untouched and creates no duplicate row under the imported id.""" _go_to_exercises(page, app_url) _clean_all(page) local_uuid = _get_local_uuid(page) existing_id = _create_exercise_directly(page, "Push-ups") new_id = "22222222-2222-2222-2222-222222222222" imported = { "id": new_id, "title": "Push-ups", "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": [imported]}, "Local User", local_uuid) _trigger_import_with_json(page, json_str) expect(page.locator('[data-testid="import-collisions-step"]')).to_be_visible(timeout=5000) # Default action is Skip page.click('[data-testid="import-apply"]') expect(page.locator('[data-testid="import-report-step"]')).to_be_visible(timeout=5000) expect(page.locator('[data-testid="import-report-skipped"]')).to_be_visible() page.click('[data-testid="import-done"]') existing = page.evaluate( f"""async () => await window.__repos.exercises.getById('{existing_id}')""" ) assert existing["default_reps"] == 10, "Existing exercise must be unchanged" duplicate = page.evaluate(f"""async () => await window.__repos.exercises.getById('{new_id}')""") assert duplicate is None, "No new row should be created under the imported id" assert _exercise_count_by_title(page, "Push-ups") == 1 _clean_all(page) def test_same_user_title_conflict_replace(page: Page, app_url: str): """Replace on a same-user title conflict updates the existing row in place (keeps its id).""" _go_to_exercises(page, app_url) _clean_all(page) local_uuid = _get_local_uuid(page) existing_id = _create_exercise_directly(page, "Push-ups") new_id = "33333333-3333-3333-3333-333333333333" imported = { "id": new_id, "title": "Push-ups", "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": [imported]}, "Local User", local_uuid) _trigger_import_with_json(page, json_str) expect(page.locator('[data-testid="import-collisions-step"]')).to_be_visible(timeout=5000) page.click('[data-testid="collision-replace-exercise-0"]') page.click('[data-testid="import-apply"]') expect(page.locator('[data-testid="import-report-step"]')).to_be_visible(timeout=5000) expect(page.locator('[data-testid="import-report-exercises-replaced"]')).to_be_visible() page.click('[data-testid="import-done"]') existing = page.evaluate( f"""async () => await window.__repos.exercises.getById('{existing_id}')""" ) assert existing is not None, "Existing row must keep its original id" assert existing["default_reps"] == 99, "Existing row must be updated with imported data" duplicate = page.evaluate(f"""async () => await window.__repos.exercises.getById('{new_id}')""") assert duplicate is None, "No new row should be created under the imported id" assert _exercise_count_by_title(page, "Push-ups") == 1 _clean_all(page) # ============================================================ # Same-user title conflict + same-batch dependency redirect # ============================================================ def _build_conflict_with_combo_json(local_uuid, new_exercise_id, combo_id): imported_exercise = { "id": new_exercise_id, "title": "Push-ups", "description": "Imported version", "asymmetric": False, "alternate": False, "image_urls": [], "video_urls": [], "default_reps": 99, "default_weight": 99, "created_by": local_uuid, } imported_combo = { "id": combo_id, "title": "Conflict Combo", "description": "", "type": "NONE", "timer_config": {}, "created_by": local_uuid, "exercises": [ { "position": 0, "exercise_id": new_exercise_id, "default_reps": 5, "default_weight": 10, "title": "Push-ups", "asymmetric": False, "alternate": False, } ], } return _build_export_json( {"exercises": [imported_exercise], "combos": [imported_combo]}, "Local User", local_uuid ) def test_same_user_title_conflict_dependency_redirect_skip(page: Page, app_url: str): """A combo in the same import batch referencing the conflicting exercise's imported id ends up pointing at the existing exercise's id once the conflict is skipped.""" _go_to_exercises(page, app_url) _clean_all(page) local_uuid = _get_local_uuid(page) existing_id = _create_exercise_directly(page, "Push-ups") new_exercise_id = "44444444-4444-4444-4444-444444444444" combo_id = "55555555-5555-5555-5555-555555555555" json_str = _build_conflict_with_combo_json(local_uuid, new_exercise_id, combo_id) _trigger_import_with_json(page, json_str) expect(page.locator('[data-testid="import-collisions-step"]')).to_be_visible(timeout=5000) # Default action is Skip page.click('[data-testid="import-apply"]') expect(page.locator('[data-testid="import-report-step"]')).to_be_visible(timeout=10000) page.click('[data-testid="import-done"]') combo = page.evaluate(f"""async () => await window.__repos.combos.getById('{combo_id}')""") assert combo is not None assert len(combo["exercises"]) == 1 assert combo["exercises"][0]["exercise_id"] == existing_id, ( "Combo must reference the existing exercise's id, not the skipped imported id" ) assert _exercise_count_by_title(page, "Push-ups") == 1 _clean_all(page) def test_same_user_title_conflict_dependency_redirect_replace(page: Page, app_url: str): """Same dependency-redirect check, this time resolving the conflict with Replace.""" _go_to_exercises(page, app_url) _clean_all(page) local_uuid = _get_local_uuid(page) existing_id = _create_exercise_directly(page, "Push-ups") new_exercise_id = "66666666-6666-6666-6666-666666666666" combo_id = "77777777-7777-7777-7777-777777777777" json_str = _build_conflict_with_combo_json(local_uuid, new_exercise_id, combo_id) _trigger_import_with_json(page, json_str) expect(page.locator('[data-testid="import-collisions-step"]')).to_be_visible(timeout=5000) page.click('[data-testid="collision-replace-exercise-0"]') page.click('[data-testid="import-apply"]') expect(page.locator('[data-testid="import-report-step"]')).to_be_visible(timeout=10000) page.click('[data-testid="import-done"]') combo = page.evaluate(f"""async () => await window.__repos.combos.getById('{combo_id}')""") assert combo is not None assert len(combo["exercises"]) == 1 assert combo["exercises"][0]["exercise_id"] == existing_id, ( "Combo must reference the existing exercise's id after replace too" ) existing = page.evaluate( f"""async () => await window.__repos.exercises.getById('{existing_id}')""" ) assert existing["default_reps"] == 99, "Existing exercise must be updated with imported data" assert _exercise_count_by_title(page, "Push-ups") == 1 _clean_all(page) # ============================================================ # Cross-user title conflicts (suffixed title collides with a previous import) # ============================================================ def _register_suffix(page: Page, uuid: str, name: str, suffix: str): page.evaluate( """async ([uuid, name, suffix]) => { await window.__repos.suffixes.create({ user_uuid: uuid, user_name: name, suffix }); }""", [uuid, name, suffix], ) def test_cross_user_title_conflict_skip(page: Page, app_url: str): """Re-importing from the same external user with a different id but the same pre-suffix title offers Replace/Skip (not a forced rename) against the previously-imported row; Skip leaves that row untouched.""" _go_to_exercises(page, app_url) _clean_all(page) other_uuid = "other-user-uuid-conflict-1" previous_id = "ext-prev-id-1" _create_exercise_directly(page, "Squats [BS]", other_uuid, previous_id) _register_suffix(page, other_uuid, "Bob Smith", "BS") new_id = "ext-new-id-1" imported = { "id": new_id, "title": "Squats", "description": "Newer export", "asymmetric": False, "alternate": False, "image_urls": [], "video_urls": [], "default_reps": 50, "default_weight": 50, "created_by": other_uuid, } json_str = _build_export_json({"exercises": [imported]}, "Bob Smith", other_uuid) _trigger_import_with_json(page, json_str) # Suffix already registered, so it goes straight to preview expect(page.locator('[data-testid="import-preview-step"]')).to_be_visible(timeout=5000) page.click('[data-testid="import-apply"]') # The suffixed title ("Squats [BS]") collides with the previous import -> collisions screen expect(page.locator('[data-testid="import-collisions-step"]')).to_be_visible(timeout=5000) page.click('[data-testid="import-apply"]') expect(page.locator('[data-testid="import-report-step"]')).to_be_visible(timeout=5000) expect(page.locator('[data-testid="import-report-skipped"]')).to_be_visible() page.click('[data-testid="import-done"]') existing = page.evaluate( f"""async () => await window.__repos.exercises.getById('{previous_id}')""" ) assert existing["default_reps"] == 10, "Previously-imported row must be unchanged" duplicate = page.evaluate(f"""async () => await window.__repos.exercises.getById('{new_id}')""") assert duplicate is None, "No new row should be created under the imported id" assert _exercise_count_by_title(page, "Squats [BS]") == 1 _clean_all(page) def test_cross_user_title_conflict_replace(page: Page, app_url: str): """Same scenario, resolved with Replace: the previously-imported row is updated in place.""" _go_to_exercises(page, app_url) _clean_all(page) other_uuid = "other-user-uuid-conflict-2" previous_id = "ext-prev-id-2" _create_exercise_directly(page, "Lunges [BS]", other_uuid, previous_id) _register_suffix(page, other_uuid, "Bob Smith", "BS") new_id = "ext-new-id-2" imported = { "id": new_id, "title": "Lunges", "description": "Newer export", "asymmetric": False, "alternate": False, "image_urls": [], "video_urls": [], "default_reps": 50, "default_weight": 50, "created_by": other_uuid, } json_str = _build_export_json({"exercises": [imported]}, "Bob Smith", other_uuid) _trigger_import_with_json(page, json_str) expect(page.locator('[data-testid="import-preview-step"]')).to_be_visible(timeout=5000) page.click('[data-testid="import-apply"]') expect(page.locator('[data-testid="import-collisions-step"]')).to_be_visible(timeout=5000) page.click('[data-testid="collision-replace-exercise-0"]') page.click('[data-testid="import-apply"]') expect(page.locator('[data-testid="import-report-step"]')).to_be_visible(timeout=5000) expect(page.locator('[data-testid="import-report-exercises-replaced"]')).to_be_visible() page.click('[data-testid="import-done"]') existing = page.evaluate( f"""async () => await window.__repos.exercises.getById('{previous_id}')""" ) assert existing is not None, "Previously-imported row must keep its id" assert existing["default_reps"] == 50, "Previously-imported row must be updated with new data" assert existing["title"] == "Lunges [BS]" duplicate = page.evaluate(f"""async () => await window.__repos.exercises.getById('{new_id}')""") assert duplicate is None, "No new row should be created under the imported id" assert _exercise_count_by_title(page, "Lunges [BS]") == 1 _clean_all(page)