462 lines
18 KiB
Python
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)
|