1090 lines
46 KiB
Python
1090 lines
46 KiB
Python
"""
|
|
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}"
|
|
)
|