""" Step 7 — Full Data Import/Export Tests - test_full_export_import_roundtrip: Export all data then import back, verify all entities - test_cross_entity_reference_integrity: After ID collision remapping, references stay valid - test_new_id_propagation_to_combo: new_id UUID is propagated to combo exercise FK on collision - test_per_entity_export_from_detail_views: Export single combo/session/collection from detail - test_different_user_suffix_applies_to_all_types: Suffix applied to exercises, combos, sessions, collections - test_same_user_collision_combo: Same-user combo collision with replace/skip - test_same_user_collision_session: Same-user session collision with replace/skip - test_same_user_collision_collection: Same-user collection collision with replace/skip - test_export_filter_all_enabled: All checkboxes enabled in export filter dialog - test_import_combo_list_view: Import button visible on combo list - test_import_session_list_view: Import button visible on session list - test_import_collection_list_view: Import button visible on collection list - test_full_export_from_settings: Full export button in settings works - test_import_as_mine_combo: Import-as-mine button works on combo list - test_import_as_mine_session: Import-as-mine button works on session list - test_execution_logs_same_user_roundtrip: Execution logs survive a same-user export/import cycle - test_execution_log_id_remapping: Log item exercise_id is remapped when the exercise gets a new_id - test_session_with_combo_item_remapping: Session combo item_id follows remap when combo gets new_id - test_collection_session_remapping: Collection session ref follows remap when session gets new_id - test_settings_import_roundtrip: Settings values are applied after import - test_single_entity_export_buttons_session_collection: Export button visible on session/collection detail - test_export_respects_active_search_filter: Exporting while a search filter is active only exports matching items - test_single_export_bundles_dependencies: Exporting a combo/session/collection bundles its dependencies - test_settings_export_filter_bundles_dependencies: Settings export filter bundles dependencies for a partial selection """ import json import os 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(page: Page, app_url: str, hash_path: str): _wait_for_db(page, app_url) page.click(f'a[href="{hash_path}"]') page.wait_for_timeout(500) def _clean_all(page: Page): page.evaluate( """async () => { const exercises = await window.__repos.exercises.getAll(); for (const ex of exercises) await window.__repos.exercises.delete(ex.id); const combos = await window.__repos.combos.getAll(); for (const c of combos) await window.__repos.combos.delete(c.id); const sessions = await window.__repos.sessions.getAll(); for (const s of sessions) await window.__repos.sessions.delete(s.id); const collections = await window.__repos.collections.getAll(); for (const c of collections) await window.__repos.collections.delete(c.id); const suffixes = await window.__repos.suffixes.getAll(); for (const s of suffixes) await window.__repos.suffixes.delete(s.user_uuid); }""" ) def _create_exercise(page: Page, title, exercise_id=None, created_by=None): return page.evaluate( """async ([title, exerciseId, createdBy]) => { 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, exercise_id, created_by], ) def _create_combo(page: Page, title, exercise_ids, combo_id=None, created_by=None): return page.evaluate( """async ([title, exerciseIds, comboId, createdBy]) => { const uuid = await window.__repos.settings.get('user_uuid'); const cb = createdBy || uuid; const id = comboId || crypto.randomUUID(); await window.__repos.combos.createWithId(id, { title, description: '', type: 'NONE', timer_config: {}, created_by: cb, }); const exercises = exerciseIds.map((eid, i) => ({ exerciseId: eid, position: i, defaultReps: 5, defaultWeight: 10, })); await window.__repos.combos.setExercises(id, exercises); return id; }""", [title, exercise_ids, combo_id, created_by], ) def _create_session(page: Page, title, items, session_id=None, created_by=None): return page.evaluate( """async ([title, items, sessionId, createdBy]) => { const uuid = await window.__repos.settings.get('user_uuid'); const cb = createdBy || uuid; const id = sessionId || crypto.randomUUID(); await window.__repos.sessions.createWithId(id, { title, description: '', created_by: cb, }); const sessionItems = items.map((item, i) => ({ item_id: item.item_id, item_type: item.item_type, repetitions: item.repetitions || 1, weight: item.weight !== undefined ? item.weight : 0.0, })); await window.__repos.sessions.setItems(id, sessionItems); return id; }""", [title, items, session_id, created_by], ) def _create_collection(page: Page, label, session_ids, collection_id=None, created_by=None): return page.evaluate( """async ([label, sessionIds, collectionId, createdBy]) => { const uuid = await window.__repos.settings.get('user_uuid'); const cb = createdBy || uuid; const id = collectionId || crypto.randomUUID(); await window.__repos.collections.createWithId(id, { label, created_by: cb }); await window.__repos.collections.setSessions(id, sessionIds); return id; }""", [label, session_ids, collection_id, created_by], ) def _build_full_export_json(page: Page): """Build a full export JSON envelope from current database state.""" return page.evaluate( """async () => { const exercises = await window.__repos.exercises.getAll(); const combos = await window.__repos.combos.getAllWithExercises(); const sessions = await window.__repos.sessions.getAllWithItems(); const collections = await window.__repos.collections.getAllWithSessions(); const executionLogs = await window.__repos.executionLogs.getAllWithItems(); const userName = await window.__repos.settings.get('user_name'); const userUuid = await window.__repos.settings.get('user_uuid'); return JSON.stringify({ metadata: { appName: 'trainus', appVersion: '1.0.0', schemaVersion: 1, exportDate: new Date().toISOString(), exportedBy: { name: userName || 'User', uuid: userUuid }, }, data: { exercises, combos, sessions, collections, executionLogs, }, }); }""" ) def _build_different_user_export_json(page: Page): """Build an export JSON with a different user UUID.""" return page.evaluate( """async () => { const exercises = await window.__repos.exercises.getAll(); const combos = await window.__repos.combos.getAllWithExercises(); const sessions = await window.__repos.sessions.getAllWithItems(); const collections = await window.__repos.collections.getAllWithSessions(); return JSON.stringify({ metadata: { appName: 'trainus', appVersion: '1.0.0', schemaVersion: 1, exportDate: new Date().toISOString(), exportedBy: { name: 'Other User', uuid: 'other-user-uuid-789' }, }, data: { exercises, combos, sessions, collections, }, }); }""" ) def _load_fixture(filename: str) -> str: """Read a static JSON fixture from tests/fixtures/.""" path = os.path.join(os.path.dirname(__file__), "fixtures", filename) with open(path, "r", encoding="utf-8") as f: return f.read() def _create_execution_log(page: Page, session_id: str, exercise_id: str) -> str: """Create an execution log with one completed item; returns the log ID.""" return page.evaluate( """async ([sessionId, exerciseId]) => { const logId = await window.__repos.executionLogs.create({ session_id: sessionId, started_at: new Date().toISOString(), finished_at: new Date().toISOString(), }); await window.__repos.executionLogs.addItem({ log_id: logId, exercise_id: exerciseId, round_number: 1, reps_done: 10, weight_used: 50, side: null, completed: true, timestamp: new Date().toISOString(), }); return logId; }""", [session_id, exercise_id], ) def _trigger_import_with_json(page: Page, json_str: str, test_id: str = "exercise-import-btn"): """Trigger import by intercepting file chooser.""" 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 test_full_export_import_roundtrip(page: Page, app_url: str): """Export all data then import back, verify all entities are restored.""" _go_to(page, app_url, "#/exercises") _clean_all(page) exercise_id = _create_exercise(page, "Push-ups") combo_id = _create_combo(page, "Upper Body", [exercise_id]) session_id = _create_session( page, "Day 1", [{"item_id": exercise_id, "item_type": "exercise", "repetitions": 10, "weight": 0}] ) _create_collection(page, "Week 1", [session_id]) json_str = _build_full_export_json(page) page.evaluate( """async () => { const exercises = await window.__repos.exercises.getAll(); for (const ex of exercises) await window.__repos.exercises.delete(ex.id); const combos = await window.__repos.combos.getAll(); for (const c of combos) await window.__repos.combos.delete(c.id); const sessions = await window.__repos.sessions.getAll(); for (const s of sessions) await window.__repos.sessions.delete(s.id); const collections = await window.__repos.collections.getAll(); for (const c of collections) await window.__repos.collections.delete(c.id); }""" ) page.click('a[href="#/exercises"]') page.wait_for_timeout(500) expect(page.locator('[data-testid="exercise-empty"]')).to_be_visible() _trigger_import_with_json(page, json_str) # Wait for the import dialog to finish parsing and land on a known step. # Under parallel load, 500ms fixed wait is unreliable — wait for the actual # apply button to become visible instead (present in both 'preview' and # 'collisions' steps). expect(page.locator('[data-testid="import-apply"]')).to_be_visible(timeout=10000) page.locator('[data-testid="import-apply"]').click() # Wait for the report step rather than using a fixed timeout. expect(page.locator('[data-testid="import-report-step"]')).to_be_visible(timeout=15000) page.click('[data-testid="import-done"]') page.wait_for_timeout(500) exercise_count = page.evaluate("async () => (await window.__repos.exercises.getAll()).length") combo_count = page.evaluate("async () => (await window.__repos.combos.getAll()).length") session_count = page.evaluate("async () => (await window.__repos.sessions.getAll()).length") collection_count = page.evaluate("async () => (await window.__repos.collections.getAll()).length") assert exercise_count == 1, f"Expected 1 exercise, got {exercise_count}" assert combo_count == 1, f"Expected 1 combo, got {combo_count}" assert session_count == 1, f"Expected 1 session, got {session_count}" assert collection_count == 1, f"Expected 1 collection, got {collection_count}" def test_cross_entity_reference_integrity(page: Page, app_url: str): """After ID collision remapping, combo exercises still reference correctly.""" _go_to(page, app_url, "#/exercises") _clean_all(page) other_uuid = "other-user-uuid-123" ex_id = _create_exercise(page, "Squats", created_by=other_uuid) _create_combo(page, "Leg Day", [ex_id], created_by=other_uuid) json_str = _build_different_user_export_json(page) page.evaluate( """async () => { const exercises = await window.__repos.exercises.getAll(); for (const ex of exercises) await window.__repos.exercises.delete(ex.id); const combos = await window.__repos.combos.getAll(); for (const c of combos) await window.__repos.combos.delete(c.id); }""" ) page.click('a[href="#/exercises"]') page.wait_for_timeout(500) _trigger_import_with_json(page, json_str) page.wait_for_timeout(500) page.locator('[data-testid="import-suffix-input"]').fill("OD") page.locator('[data-testid="import-suffix-confirm"]').click() page.wait_for_timeout(500) page.locator('[data-testid="import-apply"]').click() page.wait_for_timeout(1000) page.click('[data-testid="import-done"]') page.wait_for_timeout(500) combo = page.evaluate( """async () => { const combos = await window.__repos.combos.getAll(); if (combos.length === 0) return null; return await window.__repos.combos.getById(combos[0].id); }""" ) assert combo is not None, "Combo should exist after import" assert len(combo.get("exercises", [])) == 1, "Combo should have 1 exercise" assert combo["exercises"][0]["title"] == "Squats [OD]", f"Exercise title should have suffix: {combo['exercises'][0]['title']}" def test_per_entity_export_from_detail_views(page: Page, app_url: str): """Export single combo/session/collection from their detail views.""" _go_to(page, app_url, "#/exercises") _clean_all(page) ex_id = _create_exercise(page, "Pull-ups") combo_id = _create_combo(page, "Back Blast", [ex_id]) page.click('a[href="#/combos"]') page.wait_for_timeout(500) page.click(f'[data-testid="combo-card-{combo_id}"]') page.wait_for_timeout(500) expect(page.locator('[data-testid="combo-export-single-btn"]')).to_be_visible() def test_different_user_suffix_applies_to_all_types(page: Page, app_url: str): """Suffix is applied to exercises, combos, sessions, and collections from different user.""" _go_to(page, app_url, "#/exercises") _clean_all(page) other_uuid = "other-user-456" ex_id = _create_exercise(page, "Burpees", created_by=other_uuid) _create_combo(page, "HIIT", [ex_id], created_by=other_uuid) _create_session( page, "Cardio Day", [{"item_id": ex_id, "item_type": "exercise", "repetitions": 15, "weight": 0}], created_by=other_uuid, ) _create_collection(page, "Cardio Week", [], created_by=other_uuid) json_str = _build_different_user_export_json(page) page.evaluate( """async () => { const exercises = await window.__repos.exercises.getAll(); for (const ex of exercises) await window.__repos.exercises.delete(ex.id); const combos = await window.__repos.combos.getAll(); for (const c of combos) await window.__repos.combos.delete(c.id); const sessions = await window.__repos.sessions.getAll(); for (const s of sessions) await window.__repos.sessions.delete(s.id); const collections = await window.__repos.collections.getAll(); for (const c of collections) await window.__repos.collections.delete(c.id); }""" ) page.click('a[href="#/exercises"]') page.wait_for_timeout(500) _trigger_import_with_json(page, json_str) page.wait_for_timeout(500) page.locator('[data-testid="import-suffix-input"]').fill("HI") page.locator('[data-testid="import-suffix-confirm"]').click() page.wait_for_timeout(500) page.locator('[data-testid="import-apply"]').click() page.wait_for_timeout(1000) page.click('[data-testid="import-done"]') page.wait_for_timeout(500) exercise_title = page.evaluate( """async () => { const exercises = await window.__repos.exercises.getAll(); return exercises[0]?.title; }""" ) combo_title = page.evaluate( """async () => { const combos = await window.__repos.combos.getAll(); return combos[0]?.title; }""" ) session_title = page.evaluate( """async () => { const sessions = await window.__repos.sessions.getAll(); return sessions[0]?.title; }""" ) collection_label = page.evaluate( """async () => { const collections = await window.__repos.collections.getAll(); return collections[0]?.label; }""" ) assert "[HI]" in exercise_title, f"Exercise title should have suffix: {exercise_title}" assert "[HI]" in combo_title, f"Combo title should have suffix: {combo_title}" assert "[HI]" in session_title, f"Session title should have suffix: {session_title}" assert "[HI]" in collection_label, f"Collection label should have suffix: {collection_label}" def test_same_user_collision_combo(page: Page, app_url: str): """Same-user combo collision shows replace/skip options.""" _go_to(page, app_url, "#/exercises") _clean_all(page) local_uuid = page.evaluate("async () => await window.__repos.settings.get('user_uuid')") ex_id = _create_exercise(page, "Deadlifts") _create_combo(page, "Power", [ex_id], created_by=local_uuid) json_str = _build_full_export_json(page) page.click('a[href="#/combos"]') page.wait_for_timeout(500) _trigger_import_with_json(page, json_str, "combo-import-btn") page.wait_for_timeout(500) expect(page.locator('[data-testid="collision-group-combos"]')).to_be_visible() def test_same_user_collision_session(page: Page, app_url: str): """Same-user session collision shows replace/skip options.""" _go_to(page, app_url, "#/exercises") _clean_all(page) local_uuid = page.evaluate("async () => await window.__repos.settings.get('user_uuid')") ex_id = _create_exercise(page, "Lunges") _create_session( page, "Leg Day", [{"item_id": ex_id, "item_type": "exercise", "repetitions": 12, "weight": 0}], created_by=local_uuid, ) json_str = _build_full_export_json(page) page.click('a[href="#/sessions"]') page.wait_for_timeout(500) _trigger_import_with_json(page, json_str, "session-import-btn") page.wait_for_timeout(500) expect(page.locator('[data-testid="collision-group-sessions"]')).to_be_visible() def test_same_user_collision_collection(page: Page, app_url: str): """Same-user collection collision shows replace/skip options.""" _go_to(page, app_url, "#/exercises") _clean_all(page) local_uuid = page.evaluate("async () => await window.__repos.settings.get('user_uuid')") ex_id = _create_exercise(page, "Plank") session_id = _create_session( page, "Core", [{"item_id": ex_id, "item_type": "exercise", "repetitions": 3, "weight": 0}], created_by=local_uuid, ) _create_collection(page, "Core Week", [session_id], created_by=local_uuid) json_str = _build_full_export_json(page) page.click('a[href="#/collections"]') page.wait_for_timeout(500) _trigger_import_with_json(page, json_str, "collection-import-btn") page.wait_for_timeout(500) expect(page.locator('[data-testid="collision-group-collections"]')).to_be_visible() def test_export_filter_all_enabled(page: Page, app_url: str): """All checkboxes are enabled in the export filter dialog.""" _go_to(page, app_url, "#/settings") _clean_all(page) page.click('a[href="#/settings"]') page.wait_for_timeout(500) page.click('[data-testid="settings-export"]') page.wait_for_timeout(500) for test_id in [ "export-filter-exercises", "export-filter-combos", "export-filter-sessions", "export-filter-collections", "export-filter-execution-logs", "export-filter-settings", ]: checkbox = page.locator(f'[data-testid="{test_id}"] input') expect(checkbox).to_be_enabled() page.click('[data-testid="export-filter-cancel"]') def test_import_combo_list_view(page: Page, app_url: str): """Import button is visible on combo list view.""" _go_to(page, app_url, "#/combos") expect(page.locator('[data-testid="combo-import-btn"]')).to_be_visible() expect(page.locator('[data-testid="combo-export-btn"]')).to_be_visible() def test_import_session_list_view(page: Page, app_url: str): """Import button is visible on session list view.""" _go_to(page, app_url, "#/sessions") expect(page.locator('[data-testid="session-import-btn"]')).to_be_visible() expect(page.locator('[data-testid="session-export-btn"]')).to_be_visible() def test_import_collection_list_view(page: Page, app_url: str): """Import button is visible on collection list view.""" _go_to(page, app_url, "#/collections") expect(page.locator('[data-testid="collection-import-btn"]')).to_be_visible() expect(page.locator('[data-testid="collection-export-btn"]')).to_be_visible() def test_full_export_from_settings(page: Page, app_url: str): """Full export button in settings is visible.""" _go_to(page, app_url, "#/settings") _clean_all(page) _create_exercise(page, "Test Exercise") page.click('a[href="#/settings"]') page.wait_for_timeout(500) expect(page.locator('[data-testid="settings-export-all"]')).to_be_visible() # Clean up so subsequent tests (chromium variants) start with an empty DB. _clean_all(page) def test_import_as_mine_combo(page: Page, app_url: str): """Import-as-mine button is visible on combo list.""" _go_to(page, app_url, "#/combos") expect(page.locator('[data-testid="combo-import-as-mine-btn"]')).to_be_visible() def test_import_as_mine_session(page: Page, app_url: str): """Import-as-mine button is visible on session list.""" _go_to(page, app_url, "#/sessions") expect(page.locator('[data-testid="session-import-as-mine-btn"]')).to_be_visible() def test_new_id_propagation_to_combo(page: Page, app_url: str): """When a different-user exercise collides with a local exercise (same ID, different created_by), a new UUID is generated and the combo's exercise FK is remapped to that new UUID.""" _go_to(page, app_url, "#/exercises") _clean_all(page) # Create a local exercise with a known, fixed UUID owned by the local user. # This will collide with the imported exercise below (same ID, different created_by). conflicting_id = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" _create_exercise(page, "Local Exercise", exercise_id=conflicting_id) other_uuid = "other-user-uuid-collision-999" combo_id = "11111111-2222-3333-4444-555555555555" import_json = _load_fixture("cross_user_id_collision.json") _trigger_import_with_json(page, import_json) page.wait_for_timeout(500) page.locator('[data-testid="import-suffix-input"]').fill("OT") page.locator('[data-testid="import-suffix-confirm"]').click() page.wait_for_timeout(500) page.locator('[data-testid="import-apply"]').click() expect(page.locator('[data-testid="import-report-step"]')).to_be_visible(timeout=15000) page.click('[data-testid="import-done"]') page.wait_for_timeout(500) result = page.evaluate( """async ([conflictingId, otherUuid]) => { const allExercises = await window.__repos.exercises.getAll(); const localEx = allExercises.find(e => e.id === conflictingId); const importedEx = allExercises.find(e => e.created_by === otherUuid); const allCombos = await window.__repos.combos.getAllWithExercises(); const importedCombo = allCombos.find(c => c.created_by === otherUuid); return { localExExists: !!localEx, importedExId: importedEx?.id ?? null, comboExerciseRef: importedCombo?.exercises?.[0]?.exercise_id ?? null, }; }""", [conflicting_id, other_uuid], ) assert result["localExExists"], "Local exercise must still exist with its original ID" assert result["importedExId"] is not None, "Imported exercise must exist" assert result["importedExId"] != conflicting_id, ( f"Imported exercise must have a new UUID, not the conflicting one ({conflicting_id})" ) assert result["comboExerciseRef"] == result["importedExId"], ( f"Combo exercise FK ({result['comboExerciseRef']}) must point to the " f"new exercise UUID ({result['importedExId']}), not the original conflicting ID" ) # --------------------------------------------------------------------------- # New coverage tests added to complete all import/export paths # --------------------------------------------------------------------------- FIXTURE_USER_UUID = "facade00-0000-4000-a000-000000000001" FIXTURE_EX1_ID = "facade00-0000-4000-a000-e00000000001" FIXTURE_COMBO_ID = "facade00-0000-4000-a000-c00000000001" FIXTURE_SESSION_ID = "facade00-0000-4000-a000-500000000001" FIXTURE_LOG_ID = "facade00-0000-4000-a000-100000000001" def test_execution_logs_same_user_roundtrip(page: Page, app_url: str): """Execution log (with one item) survives a same-user export → clear → reimport cycle.""" _go_to(page, app_url, "#/exercises") _clean_all(page) ex_id = _create_exercise(page, "Log Roundtrip Exercise") session_id = _create_session( page, "Log Roundtrip Session", [{"item_id": ex_id, "item_type": "exercise", "repetitions": 10, "weight": 0}], ) _create_execution_log(page, session_id, ex_id) json_str = _build_full_export_json(page) # Delete everything — execution_log cascades from session delete page.evaluate( """async () => { const exercises = await window.__repos.exercises.getAll(); for (const ex of exercises) await window.__repos.exercises.delete(ex.id); const sessions = await window.__repos.sessions.getAll(); for (const s of sessions) await window.__repos.sessions.delete(s.id); }""" ) page.click('a[href="#/exercises"]') page.wait_for_timeout(500) _trigger_import_with_json(page, json_str) expect(page.locator('[data-testid="import-apply"]')).to_be_visible(timeout=10000) page.locator('[data-testid="import-apply"]').click() expect(page.locator('[data-testid="import-report-step"]')).to_be_visible(timeout=15000) page.click('[data-testid="import-done"]') page.wait_for_timeout(500) result = page.evaluate( """async () => { const logs = await window.__repos.executionLogs.getAllWithItems(); if (logs.length === 0) return { count: 0 }; const log = logs[0]; const sessionExists = !!(await window.__repos.sessions.getById(log.session_id)); return { count: logs.length, itemCount: log.items.length, sessionExists }; }""" ) assert result["count"] == 1, f"Expected 1 execution log after reimport, got {result['count']}" assert result["itemCount"] == 1, f"Expected 1 log item, got {result['itemCount']}" assert result["sessionExists"], "Log session_id must reference an existing session after reimport" def test_execution_log_id_remapping(page: Page, app_url: str): """When an exercise gets new_id on collision, the execution log item's exercise_id is remapped.""" _go_to(page, app_url, "#/exercises") _clean_all(page) # Collide with fixture's exercise 1: same ID, owned by local user → triggers new_id _create_exercise(page, "Local Colliding Exercise", exercise_id=FIXTURE_EX1_ID) _trigger_import_with_json(page, _load_fixture("cross_user_full.json")) page.wait_for_timeout(500) page.locator('[data-testid="import-suffix-input"]').fill("FX") page.locator('[data-testid="import-suffix-confirm"]').click() page.wait_for_timeout(500) page.locator('[data-testid="import-apply"]').click() expect(page.locator('[data-testid="import-report-step"]')).to_be_visible(timeout=15000) page.click('[data-testid="import-done"]') page.wait_for_timeout(500) result = page.evaluate( """async ([fixedExId, exporterUuid, logId]) => { const importedEx = (await window.__repos.exercises.getAll()) .find(e => e.created_by === exporterUuid && e.id !== fixedExId); const log = await window.__repos.executionLogs.getById(logId); return { importedExId: importedEx?.id ?? null, logItemExerciseId: log?.items?.[0]?.exercise_id ?? null, }; }""", [FIXTURE_EX1_ID, FIXTURE_USER_UUID, FIXTURE_LOG_ID], ) assert result["importedExId"] is not None, "Imported exercise must exist with a new UUID" assert result["importedExId"] != FIXTURE_EX1_ID, ( f"Imported exercise must have a new UUID, not the colliding one ({FIXTURE_EX1_ID})" ) assert result["logItemExerciseId"] == result["importedExId"], ( f"Log item exercise_id ({result['logItemExerciseId']}) must be remapped " f"to the new exercise UUID ({result['importedExId']})" ) def test_session_with_combo_item_remapping(page: Page, app_url: str): """Session item with item_type='combo' has its item_id remapped when the combo gets new_id.""" _go_to(page, app_url, "#/exercises") _clean_all(page) # Collide with fixture's combo: same ID, owned by local user → triggers new_id for imported combo page.evaluate( """async () => { const uuid = await window.__repos.settings.get('user_uuid'); await window.__repos.combos.createWithId( 'facade00-0000-4000-a000-c00000000001', { title: 'Local Combo', description: '', type: 'NONE', timer_config: {}, created_by: uuid } ); }""" ) _trigger_import_with_json(page, _load_fixture("cross_user_full.json")) page.wait_for_timeout(500) page.locator('[data-testid="import-suffix-input"]').fill("FX") page.locator('[data-testid="import-suffix-confirm"]').click() page.wait_for_timeout(500) page.locator('[data-testid="import-apply"]').click() expect(page.locator('[data-testid="import-report-step"]')).to_be_visible(timeout=15000) page.click('[data-testid="import-done"]') page.wait_for_timeout(500) result = page.evaluate( """async ([fixedComboId, exporterUuid, sessionId]) => { const importedCombo = (await window.__repos.combos.getAll()) .find(c => c.created_by === exporterUuid); const sess = await window.__repos.sessions.getById(sessionId); const comboItem = sess?.items?.find(i => i.item_type === 'combo'); return { importedComboId: importedCombo?.id ?? null, sessionComboItemId: comboItem?.item_id ?? null, }; }""", [FIXTURE_COMBO_ID, FIXTURE_USER_UUID, FIXTURE_SESSION_ID], ) assert result["importedComboId"] is not None, "Imported combo must exist with a new UUID" assert result["importedComboId"] != FIXTURE_COMBO_ID, ( f"Imported combo must have a new UUID, not the colliding one ({FIXTURE_COMBO_ID})" ) assert result["sessionComboItemId"] == result["importedComboId"], ( f"Session combo item_id ({result['sessionComboItemId']}) must be remapped " f"to the new combo UUID ({result['importedComboId']})" ) def test_collection_session_remapping(page: Page, app_url: str): """Collection session reference is remapped when the imported session gets new_id on collision.""" _go_to(page, app_url, "#/exercises") _clean_all(page) # Collide with fixture's session: same ID, owned by local user → triggers new_id for imported session page.evaluate( """async () => { const uuid = await window.__repos.settings.get('user_uuid'); await window.__repos.sessions.createWithId( 'facade00-0000-4000-a000-500000000001', { title: 'Local Session', description: '', created_by: uuid } ); }""" ) _trigger_import_with_json(page, _load_fixture("cross_user_full.json")) page.wait_for_timeout(500) page.locator('[data-testid="import-suffix-input"]').fill("FX") page.locator('[data-testid="import-suffix-confirm"]').click() page.wait_for_timeout(500) page.locator('[data-testid="import-apply"]').click() expect(page.locator('[data-testid="import-report-step"]')).to_be_visible(timeout=15000) page.click('[data-testid="import-done"]') page.wait_for_timeout(500) result = page.evaluate( """async (exporterUuid) => { const importedSession = (await window.__repos.sessions.getAll()) .find(s => s.created_by === exporterUuid); const allCollections = await window.__repos.collections.getAll(); const importedColl = allCollections.find(c => c.created_by === exporterUuid); const collDetail = importedColl ? await window.__repos.collections.getById(importedColl.id) : null; return { importedSessionId: importedSession?.id ?? null, collectionSessionRef: collDetail?.sessions?.[0]?.session_id ?? null, }; }""", FIXTURE_USER_UUID, ) assert result["importedSessionId"] is not None, "Imported session must exist with a new UUID" assert result["importedSessionId"] != FIXTURE_SESSION_ID, ( f"Imported session must have a new UUID, not the colliding one ({FIXTURE_SESSION_ID})" ) assert result["collectionSessionRef"] == result["importedSessionId"], ( f"Collection session ref ({result['collectionSessionRef']}) must be remapped " f"to the new session UUID ({result['importedSessionId']})" ) def test_settings_import_roundtrip(page: Page, app_url: str): """Settings values from an import envelope are applied to the database.""" _go_to(page, app_url, "#/exercises") original_lang = page.evaluate( "async () => await window.__repos.settings.get('language')" ) # Build a settings-only envelope using the local UUID to avoid the suffix prompt json_str = page.evaluate( """async () => { const uuid = await window.__repos.settings.get('user_uuid'); return JSON.stringify({ metadata: { appName: 'trainus', appVersion: '1.0.0', schemaVersion: 1, exportDate: new Date().toISOString(), exportedBy: { name: 'Me', uuid }, }, data: { settings: { language: 'fr', theme: 'dark' } }, }); }""" ) _trigger_import_with_json(page, json_str) expect(page.locator('[data-testid="import-apply"]')).to_be_visible(timeout=10000) page.locator('[data-testid="import-apply"]').click() expect(page.locator('[data-testid="import-report-step"]')).to_be_visible(timeout=15000) page.click('[data-testid="import-done"]') page.wait_for_timeout(500) lang = page.evaluate("async () => await window.__repos.settings.get('language')") theme = page.evaluate("async () => await window.__repos.settings.get('theme')") # Restore original settings so subsequent tests are not affected page.evaluate( """async ([lang]) => { await window.__repos.settings.set('language', lang); await window.__repos.settings.set('theme', 'light'); }""", [original_lang or "en"], ) assert lang == "fr", f"language should be 'fr' after import, got '{lang}'" assert theme == "dark", f"theme should be 'dark' after import, got '{theme}'" def test_single_entity_export_buttons_session_collection(page: Page, app_url: str): """Single-entity export button is visible on both session and collection detail views.""" _go_to(page, app_url, "#/exercises") _clean_all(page) ex_id = _create_exercise(page, "Detail Export Exercise") session_id = _create_session( page, "Detail Export Session", [{"item_id": ex_id, "item_type": "exercise", "repetitions": 5, "weight": 0}], ) collection_id = _create_collection(page, "Detail Export Collection", [session_id]) # Session detail page.click('a[href="#/sessions"]') page.wait_for_timeout(500) page.click(f'[data-testid="session-card-{session_id}"]') page.wait_for_timeout(500) expect(page.locator('[data-testid="session-export-single-btn"]')).to_be_visible() # Collection detail page.click('a[href="#/collections"]') page.wait_for_timeout(500) page.click(f'[data-testid="collection-card-{collection_id}"]') page.wait_for_timeout(500) expect(page.locator('[data-testid="collection-export-single-btn"]')).to_be_visible() def _export_via_button(page: Page, export_btn_selector: str, tmp_dir): """Click an export button, confirm the save-file prompt, and return the parsed JSON.""" page.click(export_btn_selector) page.wait_for_selector('[data-testid="save-file-input"]') with page.expect_download() as download_info: page.click('[data-testid="save-file-confirm"]') out_path = str(tmp_dir / "export.json") download_info.value.save_as(out_path) with open(out_path, "r", encoding="utf-8") as f: return json.load(f) def test_export_respects_active_search_filter(page: Page, app_url: str, tmp_path_factory): """Exporting from a list view while a search filter is active only exports the filtered items.""" _go_to(page, app_url, "#/exercises") _clean_all(page) ex_a = _create_exercise(page, "Squats") ex_b = _create_exercise(page, "Burpees") _create_combo(page, "Squat Combo", [ex_a]) _create_combo(page, "Burpee Combo", [ex_b]) session_a = _create_session( page, "Squat Session", [{"item_id": ex_a, "item_type": "exercise"}] ) _create_session(page, "Burpee Session", [{"item_id": ex_b, "item_type": "exercise"}]) _create_collection(page, "Squat Week", [session_a]) _create_collection(page, "Burpee Week", []) tmp_dir = tmp_path_factory.mktemp("filtered_export") page.click('a[href="#/exercises"]') page.wait_for_timeout(300) page.click('[data-testid="exercise-search-toggle"]') page.fill('[data-testid="exercise-search-input"]', "Squat") page.wait_for_timeout(400) data = _export_via_button(page, '[data-testid="exercise-export-btn"]', tmp_dir) titles = [e["title"] for e in data["data"]["exercises"]] assert titles == ["Squats"], f"Expected only the filtered exercise, got: {titles}" page.click('a[href="#/combos"]') page.wait_for_timeout(300) page.click('[data-testid="combo-search-toggle"]') page.fill('[data-testid="combo-search-input"]', "Squat") page.wait_for_timeout(400) data = _export_via_button(page, '[data-testid="combo-export-btn"]', tmp_dir) titles = [c["title"] for c in data["data"]["combos"]] assert titles == ["Squat Combo"], f"Expected only the filtered combo, got: {titles}" page.click('a[href="#/sessions"]') page.wait_for_timeout(300) page.click('[data-testid="session-search-toggle"]') page.fill('[data-testid="session-search-input"]', "Squat") page.wait_for_timeout(400) data = _export_via_button(page, '[data-testid="session-export-btn"]', tmp_dir) titles = [s["title"] for s in data["data"]["sessions"]] assert titles == ["Squat Session"], f"Expected only the filtered session, got: {titles}" page.click('a[href="#/collections"]') page.wait_for_timeout(300) page.click('[data-testid="collection-search-toggle"]') page.fill('[data-testid="collection-search-input"]', "Squat") page.wait_for_timeout(400) data = _export_via_button(page, '[data-testid="collection-export-btn"]', tmp_dir) labels = [c["label"] for c in data["data"]["collections"]] assert labels == ["Squat Week"], f"Expected only the filtered collection, got: {labels}" def test_single_export_bundles_dependencies(page: Page, app_url: str, tmp_path_factory): """Exporting a combo/session/collection bundles the exercises/combos/sessions it depends on.""" _go_to(page, app_url, "#/exercises") _clean_all(page) ex_a = _create_exercise(page, "Squats") ex_b = _create_exercise(page, "Lunges") combo_id = _create_combo(page, "Leg Combo", [ex_a, ex_b]) session_id = _create_session( page, "Leg Day", [ {"item_id": ex_a, "item_type": "exercise"}, {"item_id": combo_id, "item_type": "combo"}, ], ) collection_id = _create_collection(page, "Leg Week", [session_id]) tmp_dir = tmp_path_factory.mktemp("dependency_export") page.click('a[href="#/combos"]') page.wait_for_timeout(500) page.click(f'[data-testid="combo-card-{combo_id}"]') page.wait_for_timeout(500) data = _export_via_button(page, '[data-testid="combo-export-single-btn"]', tmp_dir) exercise_titles = {e["title"] for e in data["data"].get("exercises", [])} assert exercise_titles == {"Squats", "Lunges"}, ( f"Combo export should bundle its exercises, got: {exercise_titles}" ) page.click('a[href="#/sessions"]') page.wait_for_timeout(500) page.click(f'[data-testid="session-card-{session_id}"]') page.wait_for_timeout(500) data = _export_via_button(page, '[data-testid="session-export-single-btn"]', tmp_dir) exercise_titles = {e["title"] for e in data["data"].get("exercises", [])} combo_titles = {c["title"] for c in data["data"].get("combos", [])} assert exercise_titles == {"Squats", "Lunges"}, ( f"Session export should bundle all referenced exercises, got: {exercise_titles}" ) assert combo_titles == {"Leg Combo"}, ( f"Session export should bundle its referenced combo, got: {combo_titles}" ) page.click('a[href="#/collections"]') page.wait_for_timeout(500) page.click(f'[data-testid="collection-card-{collection_id}"]') page.wait_for_timeout(500) data = _export_via_button(page, '[data-testid="collection-export-single-btn"]', tmp_dir) session_titles = {s["title"] for s in data["data"].get("sessions", [])} combo_titles = {c["title"] for c in data["data"].get("combos", [])} exercise_titles = {e["title"] for e in data["data"].get("exercises", [])} assert session_titles == {"Leg Day"}, ( f"Collection export should bundle its session, got: {session_titles}" ) assert combo_titles == {"Leg Combo"}, ( f"Collection export should bundle the session's combo, got: {combo_titles}" ) assert exercise_titles == {"Squats", "Lunges"}, ( f"Collection export should bundle all transitive exercises, got: {exercise_titles}" ) def test_settings_export_filter_bundles_dependencies(page: Page, app_url: str, tmp_path_factory): """Settings export filter dialog bundles dependencies even when only sessions is selected.""" _go_to(page, app_url, "#/exercises") _clean_all(page) ex_a = _create_exercise(page, "Squats") combo_id = _create_combo(page, "Leg Combo", [ex_a]) _create_session( page, "Leg Day", [{"item_id": combo_id, "item_type": "combo"}], ) tmp_dir = tmp_path_factory.mktemp("settings_filter_export") page.click('a[href="#/settings"]') page.wait_for_timeout(500) page.click('[data-testid="settings-export"]') page.wait_for_selector('[data-testid="export-filter-sessions"]') # Uncheck everything except sessions for testid in ["export-filter-exercises", "export-filter-combos", "export-filter-collections"]: page.locator(f'[data-testid="{testid}"] input[type="checkbox"]').uncheck() page.click('[data-testid="export-filter-confirm"]') page.wait_for_selector('[data-testid="save-file-input"]') with page.expect_download() as download_info: page.click('[data-testid="save-file-confirm"]') out_path = str(tmp_dir / "export.json") download_info.value.save_as(out_path) with open(out_path, "r", encoding="utf-8") as f: data = json.load(f) exercise_titles = {e["title"] for e in data["data"].get("exercises", [])} combo_titles = {c["title"] for c in data["data"].get("combos", [])} assert combo_titles == {"Leg Combo"}, ( f"Sessions-only export should bundle the referenced combo, got: {combo_titles}" ) assert exercise_titles == {"Squats"}, ( f"Sessions-only export should bundle the transitively referenced exercise, got: {exercise_titles}" )