F4: Delete confirms show usage; no more session_item orphans
session_item.item_id is polymorphic (no FK), so deleting an exercise or combo used in a session left orphaned rows that the INNER JOIN in getItems silently hid. Exercise/combo deletes now also remove their session_item rows in one transaction. Delete confirmations on the exercise/combo/session detail views now include usage counts (sessions/ combos using an exercise, sessions using a combo, collections containing a session) via new count helpers and deleteConfirmUsage keys (EN/FR/DA). Confirm stays informative, not blocking, consistent with the existing history warning. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
f324ffb2cb
commit
a6b4723ac6
|
|
@ -2,6 +2,12 @@
|
||||||
|
|
||||||
Record of architectural and technical decisions made during development.
|
Record of architectural and technical decisions made during development.
|
||||||
|
|
||||||
|
## 2026-07-09 — Deleting an exercise/combo cleans session usage and warns about it (code review F4)
|
||||||
|
|
||||||
|
**Decision:** `session_item.item_id` is polymorphic (exercise or combo) and carries no FK, so deleting an exercise or combo used in a session silently left orphaned `session_item` rows that the INNER JOIN in `sessionRepository.getItems` simply hid — the entry vanished from the session with no trace. Deletes in `exerciseRepository`/`comboRepository` now also remove the matching `session_item` rows (in one `db.batch` transaction), and the delete confirmations on the exercise/combo/session detail views show usage counts before the user confirms: sessions+combos using the exercise, sessions using the combo, collections containing the session (new repository count helpers, new `deleteConfirmUsage` i18n keys in EN/FR/DA).
|
||||||
|
|
||||||
|
**Rationale:** The confirmation stays **informative, not blocking** — consistent with the existing history-cascade warning UX; the user can still delete, they just see the blast radius first. Collection/session junctions already have FK CASCADE so they need no cleanup, only the usage warning. (Chosen from the remediation plan's proposal without live validation — flagged for review.)
|
||||||
|
|
||||||
## 2026-07-09 — DB restore no longer copies the backup's schema_version stamp (code review F3)
|
## 2026-07-09 — DB restore no longer copies the backup's schema_version stamp (code review F3)
|
||||||
|
|
||||||
**Decision:** `handleImportDb` in `src/db/worker.js` now excludes `schema_version` from the tables copied out of a restored backup. The live DB keeps its own version stamp, which is correct by construction: any adapt/migration scripts run against the temp DB *before* the copy, so the restored data always matches the current schema.
|
**Decision:** `handleImportDb` in `src/db/worker.js` now excludes `schema_version` from the tables copied out of a restored backup. The live DB keeps its own version stamp, which is correct by construction: any adapt/migration scripts run against the temp DB *before* the copy, so the restored data always matches the current schema.
|
||||||
|
|
|
||||||
|
|
@ -88,6 +88,14 @@ export const collectionRepository = {
|
||||||
])
|
])
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async countCollectionsUsingSession(sessionId) {
|
||||||
|
const row = await db.selectOne(
|
||||||
|
'SELECT COUNT(DISTINCT collection_id) AS count FROM collection_session WHERE session_id = ?',
|
||||||
|
[sessionId],
|
||||||
|
)
|
||||||
|
return row ? row.count : 0
|
||||||
|
},
|
||||||
|
|
||||||
async getSessions(collectionId) {
|
async getSessions(collectionId) {
|
||||||
return db.selectAll(
|
return db.selectAll(
|
||||||
`SELECT cs.position, cs.session_id, s.title
|
`SELECT cs.position, cs.session_id, s.title
|
||||||
|
|
|
||||||
|
|
@ -64,7 +64,23 @@ export const comboRepository = {
|
||||||
},
|
},
|
||||||
|
|
||||||
async delete(id) {
|
async delete(id) {
|
||||||
return db.run('DELETE FROM combo WHERE id = ?', [id])
|
// session_item.item_id is polymorphic (no FK), so combo entries in
|
||||||
|
// sessions must be removed explicitly or they'd linger as orphans
|
||||||
|
return db.batch([
|
||||||
|
{
|
||||||
|
sql: "DELETE FROM session_item WHERE item_id = ? AND item_type = 'combo'",
|
||||||
|
params: [id],
|
||||||
|
},
|
||||||
|
{ sql: 'DELETE FROM combo WHERE id = ?', params: [id] },
|
||||||
|
])
|
||||||
|
},
|
||||||
|
|
||||||
|
async countCombosUsingExercise(exerciseId) {
|
||||||
|
const row = await db.selectOne(
|
||||||
|
'SELECT COUNT(DISTINCT combo_id) AS count FROM combo_exercise WHERE exercise_id = ?',
|
||||||
|
[exerciseId],
|
||||||
|
)
|
||||||
|
return row ? row.count : 0
|
||||||
},
|
},
|
||||||
|
|
||||||
async createWithId(id, combo) {
|
async createWithId(id, combo) {
|
||||||
|
|
|
||||||
|
|
@ -113,7 +113,15 @@ export const exerciseRepository = {
|
||||||
},
|
},
|
||||||
|
|
||||||
async delete(id) {
|
async delete(id) {
|
||||||
return db.run('DELETE FROM exercise WHERE id = ?', [id])
|
// session_item.item_id is polymorphic (no FK), so exercise entries in
|
||||||
|
// sessions must be removed explicitly or they'd linger as orphans
|
||||||
|
return db.batch([
|
||||||
|
{
|
||||||
|
sql: "DELETE FROM session_item WHERE item_id = ? AND item_type = 'exercise'",
|
||||||
|
params: [id],
|
||||||
|
},
|
||||||
|
{ sql: 'DELETE FROM exercise WHERE id = ?', params: [id] },
|
||||||
|
])
|
||||||
},
|
},
|
||||||
|
|
||||||
async search(query) {
|
async search(query) {
|
||||||
|
|
|
||||||
|
|
@ -74,6 +74,14 @@ export const sessionRepository = {
|
||||||
])
|
])
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async countSessionsUsingItem(itemId, itemType) {
|
||||||
|
const row = await db.selectOne(
|
||||||
|
'SELECT COUNT(DISTINCT session_id) AS count FROM session_item WHERE item_id = ? AND item_type = ?',
|
||||||
|
[itemId, itemType],
|
||||||
|
)
|
||||||
|
return row ? row.count : 0
|
||||||
|
},
|
||||||
|
|
||||||
async getItems(sessionId) {
|
async getItems(sessionId) {
|
||||||
const exerciseRows = await db.selectAll(
|
const exerciseRows = await db.selectAll(
|
||||||
`SELECT si.position, si.item_id, si.item_type, si.repetitions, si.weight, e.title
|
`SELECT si.position, si.item_id, si.item_type, si.repetitions, si.weight, e.title
|
||||||
|
|
|
||||||
|
|
@ -54,6 +54,7 @@
|
||||||
"detail": "Øvelsesdetaljer",
|
"detail": "Øvelsesdetaljer",
|
||||||
"deleteConfirm": "Er du sikker på, at du vil slette \"{{title}}\"? Dette kan ikke fortrydes.",
|
"deleteConfirm": "Er du sikker på, at du vil slette \"{{title}}\"? Dette kan ikke fortrydes.",
|
||||||
"deleteConfirmWithHistory": "Er du sikker på, at du vil slette \"{{title}}\"? Dette sletter også {{count}} journalpost(er). Dette kan ikke fortrydes.",
|
"deleteConfirmWithHistory": "Er du sikker på, at du vil slette \"{{title}}\"? Dette sletter også {{count}} journalpost(er). Dette kan ikke fortrydes.",
|
||||||
|
"deleteConfirmUsage": "Den bruges i {{sessions}} session(er) og {{combos}} kombo(er) og vil blive fjernet derfra.",
|
||||||
"deleted": "Øvelse slettet.",
|
"deleted": "Øvelse slettet.",
|
||||||
"form": {
|
"form": {
|
||||||
"title": "Titel",
|
"title": "Titel",
|
||||||
|
|
@ -90,6 +91,7 @@
|
||||||
"edit": "Rediger kombo",
|
"edit": "Rediger kombo",
|
||||||
"detail": "Kombodetailer",
|
"detail": "Kombodetailer",
|
||||||
"deleteConfirm": "Er du sikker på, at du vil slette \"{{title}}\"? Dette kan ikke fortrydes.",
|
"deleteConfirm": "Er du sikker på, at du vil slette \"{{title}}\"? Dette kan ikke fortrydes.",
|
||||||
|
"deleteConfirmUsage": "Den bruges i {{sessions}} session(er) og vil blive fjernet derfra.",
|
||||||
"deleted": "Kombo slettet.",
|
"deleted": "Kombo slettet.",
|
||||||
"exercises": "øvelser",
|
"exercises": "øvelser",
|
||||||
"timerConfig": "Timer-konfiguration",
|
"timerConfig": "Timer-konfiguration",
|
||||||
|
|
@ -158,6 +160,7 @@
|
||||||
"detail": "Sessionsdetaljer",
|
"detail": "Sessionsdetaljer",
|
||||||
"deleteConfirm": "Er du sikker på, at du vil slette \"{{title}}\"? Dette kan ikke fortrydes.",
|
"deleteConfirm": "Er du sikker på, at du vil slette \"{{title}}\"? Dette kan ikke fortrydes.",
|
||||||
"deleteConfirmWithHistory": "Er du sikker på, at du vil slette \"{{title}}\"? Dette sletter også {{count}} udførelseslog(ge). Dette kan ikke fortrydes.",
|
"deleteConfirmWithHistory": "Er du sikker på, at du vil slette \"{{title}}\"? Dette sletter også {{count}} udførelseslog(ge). Dette kan ikke fortrydes.",
|
||||||
|
"deleteConfirmUsage": "Den er en del af {{collections}} samling(er) og vil blive fjernet derfra.",
|
||||||
"deleted": "Session slettet.",
|
"deleted": "Session slettet.",
|
||||||
"items": "elementer",
|
"items": "elementer",
|
||||||
"delete": "Slet session",
|
"delete": "Slet session",
|
||||||
|
|
|
||||||
|
|
@ -54,6 +54,7 @@
|
||||||
"detail": "Exercise Details",
|
"detail": "Exercise Details",
|
||||||
"deleteConfirm": "Are you sure you want to delete \"{{title}}\"? This cannot be undone.",
|
"deleteConfirm": "Are you sure you want to delete \"{{title}}\"? This cannot be undone.",
|
||||||
"deleteConfirmWithHistory": "Are you sure you want to delete \"{{title}}\"? This will also delete {{count}} journal entry/entries. This cannot be undone.",
|
"deleteConfirmWithHistory": "Are you sure you want to delete \"{{title}}\"? This will also delete {{count}} journal entry/entries. This cannot be undone.",
|
||||||
|
"deleteConfirmUsage": "It is used in {{sessions}} session(s) and {{combos}} combo(s) and will be removed from them.",
|
||||||
"deleted": "Exercise deleted.",
|
"deleted": "Exercise deleted.",
|
||||||
"form": {
|
"form": {
|
||||||
"title": "Title",
|
"title": "Title",
|
||||||
|
|
@ -90,6 +91,7 @@
|
||||||
"edit": "Edit Combo",
|
"edit": "Edit Combo",
|
||||||
"detail": "Combo Details",
|
"detail": "Combo Details",
|
||||||
"deleteConfirm": "Are you sure you want to delete \"{{title}}\"? This cannot be undone.",
|
"deleteConfirm": "Are you sure you want to delete \"{{title}}\"? This cannot be undone.",
|
||||||
|
"deleteConfirmUsage": "It is used in {{sessions}} session(s) and will be removed from them.",
|
||||||
"deleted": "Combo deleted.",
|
"deleted": "Combo deleted.",
|
||||||
"exercises": "exercises",
|
"exercises": "exercises",
|
||||||
"timerConfig": "Timer Configuration",
|
"timerConfig": "Timer Configuration",
|
||||||
|
|
@ -158,6 +160,7 @@
|
||||||
"detail": "Session Details",
|
"detail": "Session Details",
|
||||||
"deleteConfirm": "Are you sure you want to delete \"{{title}}\"? This cannot be undone.",
|
"deleteConfirm": "Are you sure you want to delete \"{{title}}\"? This cannot be undone.",
|
||||||
"deleteConfirmWithHistory": "Are you sure you want to delete \"{{title}}\"? This will also delete {{count}} journal log(s). This cannot be undone.",
|
"deleteConfirmWithHistory": "Are you sure you want to delete \"{{title}}\"? This will also delete {{count}} journal log(s). This cannot be undone.",
|
||||||
|
"deleteConfirmUsage": "It is part of {{collections}} collection(s) and will be removed from them.",
|
||||||
"deleted": "Session deleted.",
|
"deleted": "Session deleted.",
|
||||||
"items": "items",
|
"items": "items",
|
||||||
"delete": "Delete session",
|
"delete": "Delete session",
|
||||||
|
|
|
||||||
|
|
@ -54,6 +54,7 @@
|
||||||
"detail": "Détails de l'exercice",
|
"detail": "Détails de l'exercice",
|
||||||
"deleteConfirm": "Êtes-vous sûr de vouloir supprimer « {{title}} » ? Cette action est irréversible.",
|
"deleteConfirm": "Êtes-vous sûr de vouloir supprimer « {{title}} » ? Cette action est irréversible.",
|
||||||
"deleteConfirmWithHistory": "Êtes-vous sûr de vouloir supprimer « {{title}} » ? Cela supprimera également {{count}} entrée(s) du journal. Cette action est irréversible.",
|
"deleteConfirmWithHistory": "Êtes-vous sûr de vouloir supprimer « {{title}} » ? Cela supprimera également {{count}} entrée(s) du journal. Cette action est irréversible.",
|
||||||
|
"deleteConfirmUsage": "Il est utilisé dans {{sessions}} séance(s) et {{combos}} combo(s), et en sera retiré.",
|
||||||
"deleted": "Exercice supprimé.",
|
"deleted": "Exercice supprimé.",
|
||||||
"form": {
|
"form": {
|
||||||
"title": "Titre",
|
"title": "Titre",
|
||||||
|
|
@ -90,6 +91,7 @@
|
||||||
"edit": "Modifier le Combo",
|
"edit": "Modifier le Combo",
|
||||||
"detail": "Détails du Combo",
|
"detail": "Détails du Combo",
|
||||||
"deleteConfirm": "Êtes-vous sûr de vouloir supprimer « {{title}} » ? Cette action est irréversible.",
|
"deleteConfirm": "Êtes-vous sûr de vouloir supprimer « {{title}} » ? Cette action est irréversible.",
|
||||||
|
"deleteConfirmUsage": "Il est utilisé dans {{sessions}} séance(s) et en sera retiré.",
|
||||||
"deleted": "Combo supprimé.",
|
"deleted": "Combo supprimé.",
|
||||||
"exercises": "exercices",
|
"exercises": "exercices",
|
||||||
"timerConfig": "Configuration du minuteur",
|
"timerConfig": "Configuration du minuteur",
|
||||||
|
|
@ -158,6 +160,7 @@
|
||||||
"detail": "Détails de la séance",
|
"detail": "Détails de la séance",
|
||||||
"deleteConfirm": "Êtes-vous sûr de vouloir supprimer « {{title}} » ? Cette action est irréversible.",
|
"deleteConfirm": "Êtes-vous sûr de vouloir supprimer « {{title}} » ? Cette action est irréversible.",
|
||||||
"deleteConfirmWithHistory": "Êtes-vous sûr de vouloir supprimer « {{title}} » ? Cela supprimera également {{count}} journal(aux) d'exécution. Cette action est irréversible.",
|
"deleteConfirmWithHistory": "Êtes-vous sûr de vouloir supprimer « {{title}} » ? Cela supprimera également {{count}} journal(aux) d'exécution. Cette action est irréversible.",
|
||||||
|
"deleteConfirmUsage": "Elle fait partie de {{collections}} collection(s) et en sera retirée.",
|
||||||
"deleted": "Séance supprimée.",
|
"deleted": "Séance supprimée.",
|
||||||
"items": "éléments",
|
"items": "éléments",
|
||||||
"delete": "Supprimer la séance",
|
"delete": "Supprimer la séance",
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import { useCombos } from '../composables/useCombos.js'
|
||||||
import { useJsonExport } from '../composables/useJsonExport.js'
|
import { useJsonExport } from '../composables/useJsonExport.js'
|
||||||
import { useKeyboardShortcut } from '../composables/useKeyboardShortcut.js'
|
import { useKeyboardShortcut } from '../composables/useKeyboardShortcut.js'
|
||||||
import { renderMarkdown } from '../utils/markdown.js'
|
import { renderMarkdown } from '../utils/markdown.js'
|
||||||
|
import { sessionRepository } from '../db/repositories/sessionRepository.js'
|
||||||
|
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
@ -45,7 +46,12 @@ function goToExercise(exerciseId) {
|
||||||
|
|
||||||
async function onDelete() {
|
async function onDelete() {
|
||||||
if (!combo.value) return
|
if (!combo.value) return
|
||||||
const confirmed = window.confirm(t('combos.deleteConfirm', { title: combo.value.title }))
|
const sessionCount = await sessionRepository.countSessionsUsingItem(comboId.value, 'combo')
|
||||||
|
let msg = t('combos.deleteConfirm', { title: combo.value.title })
|
||||||
|
if (sessionCount > 0) {
|
||||||
|
msg += '\n\n' + t('combos.deleteConfirmUsage', { sessions: sessionCount })
|
||||||
|
}
|
||||||
|
const confirmed = window.confirm(msg)
|
||||||
if (!confirmed) return
|
if (!confirmed) return
|
||||||
await remove(comboId.value)
|
await remove(comboId.value)
|
||||||
router.push({ name: 'combos' })
|
router.push({ name: 'combos' })
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,8 @@ import { useOnlineStatus } from '../composables/useOnlineStatus.js'
|
||||||
import { renderMarkdown } from '../utils/markdown.js'
|
import { renderMarkdown } from '../utils/markdown.js'
|
||||||
import { getYouTubeId } from '../utils/youtube.js'
|
import { getYouTubeId } from '../utils/youtube.js'
|
||||||
import { executionLogRepository } from '../db/repositories/executionLogRepository.js'
|
import { executionLogRepository } from '../db/repositories/executionLogRepository.js'
|
||||||
|
import { sessionRepository } from '../db/repositories/sessionRepository.js'
|
||||||
|
import { comboRepository } from '../db/repositories/comboRepository.js'
|
||||||
|
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
@ -45,11 +47,20 @@ function goBack() {
|
||||||
|
|
||||||
async function onDelete() {
|
async function onDelete() {
|
||||||
if (!exercise.value) return
|
if (!exercise.value) return
|
||||||
const count = await executionLogRepository.countItemsByExercise(exerciseId.value)
|
const [count, sessionCount, comboCount] = await Promise.all([
|
||||||
const msg =
|
executionLogRepository.countItemsByExercise(exerciseId.value),
|
||||||
|
sessionRepository.countSessionsUsingItem(exerciseId.value, 'exercise'),
|
||||||
|
comboRepository.countCombosUsingExercise(exerciseId.value),
|
||||||
|
])
|
||||||
|
let msg =
|
||||||
count > 0
|
count > 0
|
||||||
? t('exercises.deleteConfirmWithHistory', { title: exercise.value.title, count })
|
? t('exercises.deleteConfirmWithHistory', { title: exercise.value.title, count })
|
||||||
: t('exercises.deleteConfirm', { title: exercise.value.title })
|
: t('exercises.deleteConfirm', { title: exercise.value.title })
|
||||||
|
if (sessionCount > 0 || comboCount > 0) {
|
||||||
|
msg +=
|
||||||
|
'\n\n' +
|
||||||
|
t('exercises.deleteConfirmUsage', { sessions: sessionCount, combos: comboCount })
|
||||||
|
}
|
||||||
const confirmed = window.confirm(msg)
|
const confirmed = window.confirm(msg)
|
||||||
if (!confirmed) return
|
if (!confirmed) return
|
||||||
await remove(exerciseId.value)
|
await remove(exerciseId.value)
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import { useJsonExport } from '../composables/useJsonExport.js'
|
||||||
import { useKeyboardShortcut } from '../composables/useKeyboardShortcut.js'
|
import { useKeyboardShortcut } from '../composables/useKeyboardShortcut.js'
|
||||||
import { renderMarkdown } from '../utils/markdown.js'
|
import { renderMarkdown } from '../utils/markdown.js'
|
||||||
import { executionLogRepository } from '../db/repositories/executionLogRepository.js'
|
import { executionLogRepository } from '../db/repositories/executionLogRepository.js'
|
||||||
|
import { collectionRepository } from '../db/repositories/collectionRepository.js'
|
||||||
|
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
@ -58,11 +59,17 @@ function getItemTypeLabel(itemType) {
|
||||||
|
|
||||||
async function onDelete() {
|
async function onDelete() {
|
||||||
if (!session.value) return
|
if (!session.value) return
|
||||||
const count = await executionLogRepository.countLogsBySession(sessionId.value)
|
const [count, collectionCount] = await Promise.all([
|
||||||
const msg =
|
executionLogRepository.countLogsBySession(sessionId.value),
|
||||||
|
collectionRepository.countCollectionsUsingSession(sessionId.value),
|
||||||
|
])
|
||||||
|
let msg =
|
||||||
count > 0
|
count > 0
|
||||||
? t('sessions.deleteConfirmWithHistory', { title: session.value.title, count })
|
? t('sessions.deleteConfirmWithHistory', { title: session.value.title, count })
|
||||||
: t('sessions.deleteConfirm', { title: session.value.title })
|
: t('sessions.deleteConfirm', { title: session.value.title })
|
||||||
|
if (collectionCount > 0) {
|
||||||
|
msg += '\n\n' + t('sessions.deleteConfirmUsage', { collections: collectionCount })
|
||||||
|
}
|
||||||
const confirmed = window.confirm(msg)
|
const confirmed = window.confirm(msg)
|
||||||
if (!confirmed) return
|
if (!confirmed) return
|
||||||
await remove(sessionId.value)
|
await remove(sessionId.value)
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ Step 3 — Exercise Management Tests
|
||||||
- test_edit_exercise: Edit an exercise and verify changes
|
- test_edit_exercise: Edit an exercise and verify changes
|
||||||
- test_delete_exercise: Delete an exercise with confirmation
|
- test_delete_exercise: Delete an exercise with confirmation
|
||||||
- test_delete_exercise_cancel: Cancelling delete does not remove exercise
|
- test_delete_exercise_cancel: Cancelling delete does not remove exercise
|
||||||
|
- test_delete_exercise_used_in_session: Delete confirm mentions usage; no session_item orphans
|
||||||
- test_search_exercises: Search filters exercises by title
|
- test_search_exercises: Search filters exercises by title
|
||||||
- test_search_no_results: Search with no matches shows empty message
|
- test_search_no_results: Search with no matches shows empty message
|
||||||
- test_search_clear_button: Clear button empties the search field and restores the full list
|
- test_search_clear_button: Clear button empties the search field and restores the full list
|
||||||
|
|
@ -330,6 +331,79 @@ def test_delete_exercise_cancel(page: Page, app_url: str):
|
||||||
_clean_exercises(page)
|
_clean_exercises(page)
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_exercise_used_in_session(page: Page, app_url: str):
|
||||||
|
"""Deleting an exercise used in a session and a combo warns about the
|
||||||
|
usage, and after deletion no session_item rows reference it (no orphans)."""
|
||||||
|
_go_to_exercises(page, app_url)
|
||||||
|
_clean_exercises(page)
|
||||||
|
|
||||||
|
exercise_id = page.evaluate(
|
||||||
|
"""async () => {
|
||||||
|
const uuid = await window.__repos.settings.get('user_uuid');
|
||||||
|
return window.__repos.exercises.create({
|
||||||
|
title: 'Used Everywhere',
|
||||||
|
description: '',
|
||||||
|
asymmetric: false,
|
||||||
|
alternate: false,
|
||||||
|
image_urls: [],
|
||||||
|
video_urls: [],
|
||||||
|
default_reps: 10,
|
||||||
|
default_weight: 0,
|
||||||
|
created_by: uuid
|
||||||
|
});
|
||||||
|
}"""
|
||||||
|
)
|
||||||
|
page.evaluate(
|
||||||
|
f"""async () => {{
|
||||||
|
const uuid = await window.__repos.settings.get('user_uuid');
|
||||||
|
const sessionId = await window.__repos.sessions.create({{
|
||||||
|
title: 'Session Using Exercise', description: '', created_by: uuid
|
||||||
|
}});
|
||||||
|
await window.__repos.sessions.setItems(sessionId, [
|
||||||
|
{{ item_id: '{exercise_id}', item_type: 'exercise', repetitions: 10, weight: 0 }},
|
||||||
|
]);
|
||||||
|
const comboId = await window.__repos.combos.create({{
|
||||||
|
title: 'Combo Using Exercise', description: '', type: 'NONE', created_by: uuid
|
||||||
|
}});
|
||||||
|
await window.__repos.combos.setExercises(comboId, ['{exercise_id}']);
|
||||||
|
}}"""
|
||||||
|
)
|
||||||
|
|
||||||
|
dialog_message = {}
|
||||||
|
|
||||||
|
def on_dialog(dialog):
|
||||||
|
dialog_message["text"] = dialog.message
|
||||||
|
dialog.accept()
|
||||||
|
|
||||||
|
page.on("dialog", on_dialog)
|
||||||
|
|
||||||
|
page.goto(f"{app_url}/#/exercises/{exercise_id}")
|
||||||
|
page.wait_for_selector('[data-testid="exercise-delete-btn"]', timeout=5000)
|
||||||
|
page.click('[data-testid="exercise-delete-btn"]')
|
||||||
|
page.wait_for_timeout(500)
|
||||||
|
|
||||||
|
msg = dialog_message.get("text", "")
|
||||||
|
assert "1 session(s)" in msg, f"Confirm must mention session usage, got: {msg}"
|
||||||
|
assert "1 combo(s)" in msg, f"Confirm must mention combo usage, got: {msg}"
|
||||||
|
|
||||||
|
orphans = page.evaluate(
|
||||||
|
f"""async () => (await window.__db.selectAll(
|
||||||
|
"SELECT * FROM session_item WHERE item_id = '{exercise_id}'"
|
||||||
|
)).length"""
|
||||||
|
)
|
||||||
|
assert orphans == 0, f"Expected no orphaned session_item rows, got {orphans}"
|
||||||
|
|
||||||
|
# Clean up the seeded session/combo
|
||||||
|
page.evaluate(
|
||||||
|
"""async () => {
|
||||||
|
for (const s of await window.__repos.sessions.getAll())
|
||||||
|
await window.__repos.sessions.delete(s.id)
|
||||||
|
for (const c of await window.__repos.combos.getAll())
|
||||||
|
await window.__repos.combos.delete(c.id)
|
||||||
|
}"""
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_search_exercises(page: Page, app_url: str):
|
def test_search_exercises(page: Page, app_url: str):
|
||||||
"""Search filters exercises by title."""
|
"""Search filters exercises by title."""
|
||||||
_go_to_exercises(page, app_url)
|
_go_to_exercises(page, app_url)
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ Step 4 — Combo Management Tests
|
||||||
- test_edit_combo: Edit a combo and verify changes
|
- test_edit_combo: Edit a combo and verify changes
|
||||||
- test_delete_combo: Delete a combo with confirmation
|
- test_delete_combo: Delete a combo with confirmation
|
||||||
- test_delete_combo_cancel: Cancelling delete does not remove combo
|
- test_delete_combo_cancel: Cancelling delete does not remove combo
|
||||||
|
- test_delete_combo_used_in_session: Delete confirm mentions usage; no session_item orphans
|
||||||
- test_search_combos: Search filters combos by title
|
- test_search_combos: Search filters combos by title
|
||||||
- test_search_no_results: Search with no matches shows empty message
|
- test_search_no_results: Search with no matches shows empty message
|
||||||
- test_search_clear_button: Clear button empties the search field and restores the full list
|
- test_search_clear_button: Clear button empties the search field and restores the full list
|
||||||
|
|
@ -298,6 +299,64 @@ def test_delete_combo_cancel(page: Page, app_url: str):
|
||||||
_clean_combos(page)
|
_clean_combos(page)
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_combo_used_in_session(page: Page, app_url: str):
|
||||||
|
"""Deleting a combo used in a session warns about the usage, and after
|
||||||
|
deletion no session_item rows reference it (no orphans)."""
|
||||||
|
_go_to_combos(page, app_url)
|
||||||
|
_clean_combos(page)
|
||||||
|
|
||||||
|
ex_id = _create_exercise_via_repo(page, "Combo Member Exercise")
|
||||||
|
combo_id = page.evaluate(
|
||||||
|
f"""async () => {{
|
||||||
|
const uuid = await window.__repos.settings.get('user_uuid');
|
||||||
|
const comboId = await window.__repos.combos.create({{
|
||||||
|
title: 'Combo In Session', description: '', type: 'NONE', created_by: uuid
|
||||||
|
}});
|
||||||
|
await window.__repos.combos.setExercises(comboId, ['{ex_id}']);
|
||||||
|
const sessionId = await window.__repos.sessions.create({{
|
||||||
|
title: 'Session Using Combo', description: '', created_by: uuid
|
||||||
|
}});
|
||||||
|
await window.__repos.sessions.setItems(sessionId, [
|
||||||
|
{{ item_id: comboId, item_type: 'combo', repetitions: 1, weight: 0 }},
|
||||||
|
]);
|
||||||
|
return comboId;
|
||||||
|
}}"""
|
||||||
|
)
|
||||||
|
|
||||||
|
dialog_message = {}
|
||||||
|
|
||||||
|
def on_dialog(dialog):
|
||||||
|
dialog_message["text"] = dialog.message
|
||||||
|
dialog.accept()
|
||||||
|
|
||||||
|
page.on("dialog", on_dialog)
|
||||||
|
|
||||||
|
page.goto(f"{app_url}/#/combos/{combo_id}")
|
||||||
|
page.wait_for_selector('[data-testid="combo-delete-btn"]', timeout=5000)
|
||||||
|
page.click('[data-testid="combo-delete-btn"]')
|
||||||
|
page.wait_for_timeout(500)
|
||||||
|
|
||||||
|
msg = dialog_message.get("text", "")
|
||||||
|
assert "1 session(s)" in msg, f"Confirm must mention session usage, got: {msg}"
|
||||||
|
|
||||||
|
orphans = page.evaluate(
|
||||||
|
f"""async () => (await window.__db.selectAll(
|
||||||
|
"SELECT * FROM session_item WHERE item_id = '{combo_id}'"
|
||||||
|
)).length"""
|
||||||
|
)
|
||||||
|
assert orphans == 0, f"Expected no orphaned session_item rows, got {orphans}"
|
||||||
|
|
||||||
|
# Clean up the seeded session and exercise
|
||||||
|
page.evaluate(
|
||||||
|
"""async () => {
|
||||||
|
for (const s of await window.__repos.sessions.getAll())
|
||||||
|
await window.__repos.sessions.delete(s.id)
|
||||||
|
for (const e of await window.__repos.exercises.getAll())
|
||||||
|
await window.__repos.exercises.delete(e.id)
|
||||||
|
}"""
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_search_combos(page: Page, app_url: str):
|
def test_search_combos(page: Page, app_url: str):
|
||||||
"""Search filters combos by title."""
|
"""Search filters combos by title."""
|
||||||
_go_to_combos(page, app_url)
|
_go_to_combos(page, app_url)
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue