trainUs/tests/test_step21_import_title_conflicts.py
David 352a3d5e88
Some checks are pending
CI / Lint & Format (push) Waiting to run
CI / Build (push) Waiting to run
CI / Tests (${{ matrix.browser }}) (chromium) (push) Waiting to run
CI / Tests (${{ matrix.browser }}) (firefox) (push) Waiting to run
Initial commit
2026-06-18 13:06:57 +02:00

462 lines
18 KiB
Python

"""
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)