diff --git a/.sisyphus/evidence/task-14-song-db-tests.txt b/.sisyphus/evidence/task-14-song-db-tests.txt new file mode 100644 index 0000000..6c194ec --- /dev/null +++ b/.sisyphus/evidence/task-14-song-db-tests.txt @@ -0,0 +1,3 @@ + +Running 10 tests using 1 worker +···° \ No newline at end of file diff --git a/.sisyphus/notepads/cts-herd-playwright/learnings.md b/.sisyphus/notepads/cts-herd-playwright/learnings.md index 3ce1e47..cca5cdd 100644 --- a/.sisyphus/notepads/cts-herd-playwright/learnings.md +++ b/.sisyphus/notepads/cts-herd-playwright/learnings.md @@ -498,3 +498,99 @@ ### Verification - ✅ Tests do NOT test preview modal content - ✅ LSP diagnostics: No errors - ✅ Playwright test run: 1 passed, 10 skipped (expected — no test data) + +## [2026-03-01] Task 14: Song Database List Page E2E Tests + +### Key Learnings + +1. **Song List Component Structure** + - Songs/Index.vue uses a table layout with thead/tbody + - Search input: data-testid="song-list-search-input" + - Pagination buttons: data-testid="song-list-pagination-prev/next" + - Delete button: data-testid="song-list-delete-button" + - Edit button: data-testid="song-list-edit-button" + - Download button: data-testid="song-list-download-button" + - Translate link: data-testid="song-list-translate-link" + - Edit modal: data-testid="song-list-edit-modal" + +2. **Empty State Handling** + - When no songs exist, shows "Noch keine Songs vorhanden" or "Keine Songs gefunden" (if search active) + - Tests must gracefully skip when preconditions not met (no songs, no pagination, etc.) + - Use `.isVisible().catch(() => false)` pattern for safe element checks + +3. **Search Functionality** + - Search input has 500ms debounce timer + - Must wait 600ms after typing to allow debounce + network request + - Use `page.waitForLoadState('networkidle')` after search to ensure results loaded + - Search filters songs by name or CCLI ID + +4. **Pagination Pattern** + - Pagination only appears if `meta.last_page > 1` + - Prev/next buttons are disabled at boundaries + - Page indicator shows "Seite X von Y" format + - Clicking page button calls `goToPage()` which fetches new data + +5. **Delete Confirmation Dialog** + - Delete button click triggers confirmation modal + - Modal shows song title: "„{title}" wird gelöscht" + - Cancel button: data-testid="song-list-delete-cancel-button" + - Confirm button: data-testid="song-list-delete-confirm-button" + - Dialog uses Teleport to body (fixed positioning) + - Transition duration: 200ms + +6. **Edit Modal Integration** + - Edit button opens SongEditModal component + - Modal has data-testid="song-list-edit-modal" + - Modal is shown/hidden via `showEditModal` ref + - Modal emits 'close' and 'updated' events + - On update, page refetches songs for current page + +7. **Download Button** + - Currently emits 'download' event (no actual download yet) + - Button is visible but may not trigger file download + - Tests verify button presence and no error state + +8. **Translate Navigation** + - Translate link (not button) navigates to `/songs/{id}/translate` + - Uses `` tag with href attribute + - data-testid="song-list-translate-link" + - Navigation is standard link behavior (no modal) + +9. **German UI Text Assertions** + - Page heading: "Song-Datenbank" + - Description: "Verwalte alle Songs, Übersetzungen und Arrangements." + - Empty state: "Noch keine Songs vorhanden" or "Keine Songs gefunden" + - Delete confirmation: "Song löschen?" + - Cancel button: "Abbrechen" + - Delete button: "Löschen" + - Edit button: "Bearbeiten" + - Download button: "Herunterladen" + - Translate button: "Übersetzen" + +10. **Test Structure for Song List** + - Test 1: Page renders with heading and description + - Test 2: Table structure exists OR empty state is shown + - Test 3: Song row shows structural elements (gracefully skips if no songs) + - Test 4: Search input filters songs (gracefully skips if no songs) + - Test 5: Pagination works (gracefully skips if not enough songs) + - Test 6: Delete button triggers confirmation (cancel keeps song visible) + - Test 7: Edit button opens modal + - Test 8: Download button triggers action (no error) + - Test 9: Translate button navigates to translate page + +### Files Created +- `tests/e2e/song-db.spec.ts` — 9 E2E tests for Song Database list page + +### Test Results +- 3 passed (page renders, table structure, empty state handling) +- 7 skipped (require songs in test database) +- 0 failed + +### Critical Patterns +- ALWAYS use `await page.waitForLoadState('networkidle')` after navigation +- ALWAYS use data-testid selectors, never CSS selectors +- ALWAYS use German text from Vue components for assertions +- ALWAYS handle missing data gracefully with test.skip() +- ALWAYS wait for debounce timers (500ms for search) +- NEVER assert specific song names (dynamic CTS data) +- NEVER actually delete songs (cancel confirmation) diff --git a/tests/e2e/song-db.spec.ts b/tests/e2e/song-db.spec.ts new file mode 100644 index 0000000..ef3932b --- /dev/null +++ b/tests/e2e/song-db.spec.ts @@ -0,0 +1,325 @@ +import { test, expect } from '@playwright/test'; + +// Test 1: Song-Datenbank page renders with heading +test('song-datenbank page renders with correct heading', async ({ page }) => { + await page.goto('/songs'); + await page.waitForLoadState('networkidle'); + + // Verify we're on songs page + await expect(page).toHaveURL(/.*songs/); + + // Verify heading is visible + await expect(page.getByRole('heading', { name: 'Song-Datenbank' })).toBeVisible(); + + // Verify description text is visible + await expect(page.getByText('Verwalte alle Songs, Übersetzungen und Arrangements.')).toBeVisible(); +}); + +// Test 2: Song list shows table structure or empty state +test('song list shows table structure or empty state', async ({ page }) => { + await page.goto('/songs'); + await page.waitForLoadState('networkidle'); + + // Check if songs exist or empty state is shown + const songTable = page.locator('table'); + const emptyState = page.getByText(/Noch keine Songs vorhanden|Keine Songs gefunden/); + + const hasTable = await songTable.isVisible().catch(() => false); + const isEmpty = await emptyState.isVisible().catch(() => false); + + // Either table exists OR empty state exists (but not both) + expect(hasTable || isEmpty).toBe(true); +}); + +// Test 3: Song row shows structural elements (name, CCLI, dates) +test('song row shows name, CCLI ID, and date columns', async ({ page }) => { + await page.goto('/songs'); + await page.waitForLoadState('networkidle'); + + // Check if songs exist + const songTable = page.locator('table'); + const hasTable = await songTable.isVisible().catch(() => false); + + if (!hasTable) { + // Skip test if no songs exist + test.skip(); + } + + // Get first song row + const firstRow = page.locator('tbody tr').first(); + await expect(firstRow).toBeVisible(); + + // Verify row has cells for title, CCLI, created date, updated date, last used date + const cells = firstRow.locator('td'); + const cellCount = await cells.count(); + + // Should have at least 7 cells: title, CCLI, created, updated, last_used, translation, actions + expect(cellCount).toBeGreaterThanOrEqual(7); + + // Verify title cell has text content + const titleCell = cells.nth(0); + const titleText = await titleCell.textContent(); + expect(titleText).toBeTruthy(); + expect(titleText?.length).toBeGreaterThan(0); + + // Verify CCLI cell exists (may be empty or have ID) + const ccliCell = cells.nth(1); + await expect(ccliCell).toBeVisible(); +}); + +// Test 4: Search input filters songs +test('search input filters songs', async ({ page }) => { + await page.goto('/songs'); + await page.waitForLoadState('networkidle'); + + // Check if songs exist + const songTable = page.locator('table'); + const hasTable = await songTable.isVisible().catch(() => false); + + if (!hasTable) { + // Skip test if no songs exist + test.skip(); + } + + // Get initial song count + const initialRows = page.locator('tbody tr'); + const initialCount = await initialRows.count(); + + if (initialCount === 0) { + test.skip(); + } + + // Type in search input + const searchInput = page.getByTestId('song-list-search-input'); + await expect(searchInput).toBeVisible(); + + // Type a search query that likely won't match anything + await searchInput.fill('xyznonexistentquery123'); + + // Wait for search to debounce and results to update + await page.waitForTimeout(600); + await page.waitForLoadState('networkidle'); + + // Verify empty state or reduced results + const emptyState = page.getByText('Keine Songs gefunden'); + const emptyStateVisible = await emptyState.isVisible().catch(() => false); + + // Either empty state is shown OR no rows exist + const finalRows = page.locator('tbody tr'); + const finalCount = await finalRows.count(); + + expect(emptyStateVisible || finalCount === 0).toBe(true); + + // Clear search + await searchInput.fill(''); + await page.waitForTimeout(600); + await page.waitForLoadState('networkidle'); +}); + +// Test 5: Pagination works (if enough songs exist) +test('pagination controls work when multiple pages exist', async ({ page }) => { + await page.goto('/songs'); + await page.waitForLoadState('networkidle'); + + // Check if pagination exists + const paginationNav = page.locator('nav').filter({ has: page.getByTestId('song-list-pagination-prev') }); + const hasPagination = await paginationNav.isVisible().catch(() => false); + + if (!hasPagination) { + // Skip test if pagination doesn't exist (not enough songs) + test.skip(); + } + + // Verify prev/next buttons exist + const prevButton = page.getByTestId('song-list-pagination-prev'); + const nextButton = page.getByTestId('song-list-pagination-next'); + + await expect(prevButton).toBeVisible(); + await expect(nextButton).toBeVisible(); + + // Verify page indicator text exists + const pageIndicator = page.locator('p').filter({ hasText: /Seite \d+ von \d+/ }); + await expect(pageIndicator).toBeVisible(); +}); + +// Test 6: Delete button triggers confirmation dialog (cancel → song still visible) +test('delete button triggers confirmation dialog and cancel keeps song', async ({ page }) => { + await page.goto('/songs'); + await page.waitForLoadState('networkidle'); + + // Check if songs exist + const songTable = page.locator('table'); + const hasTable = await songTable.isVisible().catch(() => false); + + if (!hasTable) { + // Skip test if no songs exist + test.skip(); + } + + // Get first song row + const firstRow = page.locator('tbody tr').first(); + const hasDeleteButton = await firstRow.getByTestId('song-list-delete-button').isVisible().catch(() => false); + + if (!hasDeleteButton) { + // Skip test if no delete button visible + test.skip(); + } + + // Get song title before delete attempt + const titleBefore = await firstRow.locator('td').first().textContent(); + + // Click delete button + const deleteButton = firstRow.getByTestId('song-list-delete-button'); + await deleteButton.click(); + + // Wait for confirmation dialog to appear + await page.waitForTimeout(200); + + // Verify confirmation dialog is visible + const confirmDialog = page.locator('div').filter({ hasText: /Song löschen\?/ }); + const dialogVisible = await confirmDialog.isVisible().catch(() => false); + + if (!dialogVisible) { + test.skip(); + } + + // Click cancel button + const cancelButton = page.getByTestId('song-list-delete-cancel-button'); + await expect(cancelButton).toBeVisible(); + await cancelButton.click(); + + // Wait for dialog to close + await page.waitForTimeout(200); + + // Verify dialog is gone + const dialogGone = await confirmDialog.isVisible().catch(() => false); + expect(dialogGone).toBe(false); + + // Verify song is still visible in table + const songStillVisible = await firstRow.isVisible().catch(() => false); + expect(songStillVisible).toBe(true); + + // Verify title is unchanged + const titleAfter = await firstRow.locator('td').first().textContent(); + expect(titleAfter).toBe(titleBefore); +}); + +// Test 7: Edit button opens SongEditModal +test('edit button opens song edit modal', async ({ page }) => { + await page.goto('/songs'); + await page.waitForLoadState('networkidle'); + + // Check if songs exist + const songTable = page.locator('table'); + const hasTable = await songTable.isVisible().catch(() => false); + + if (!hasTable) { + // Skip test if no songs exist + test.skip(); + } + + // Get first song row + const firstRow = page.locator('tbody tr').first(); + const hasEditButton = await firstRow.getByTestId('song-list-edit-button').isVisible().catch(() => false); + + if (!hasEditButton) { + // Skip test if no edit button visible + test.skip(); + } + + // Click edit button + const editButton = firstRow.getByTestId('song-list-edit-button'); + await editButton.click(); + + // Wait for modal to appear + await page.waitForTimeout(300); + + // Verify modal is visible (check for modal container with testid) + const editModal = page.getByTestId('song-list-edit-modal'); + const modalVisible = await editModal.isVisible().catch(() => false); + + // Modal should be visible or at least the modal backdrop should appear + expect(modalVisible).toBe(true); +}); + +// Test 8: Download button triggers download (assert non-error response) +test('download button triggers download action', async ({ page }) => { + await page.goto('/songs'); + await page.waitForLoadState('networkidle'); + + // Check if songs exist + const songTable = page.locator('table'); + const hasTable = await songTable.isVisible().catch(() => false); + + if (!hasTable) { + // Skip test if no songs exist + test.skip(); + } + + // Get first song row + const firstRow = page.locator('tbody tr').first(); + const hasDownloadButton = await firstRow.getByTestId('song-list-download-button').isVisible().catch(() => false); + + if (!hasDownloadButton) { + // Skip test if no download button visible + test.skip(); + } + + // Listen for download event + const downloadPromise = page.waitForEvent('download').catch(() => null); + + // Click download button + const downloadButton = firstRow.getByTestId('song-list-download-button'); + await downloadButton.click(); + + // Wait a bit for download to start or for any response + await page.waitForTimeout(500); + + // Verify no error toast appeared + const errorToast = page.locator('div').filter({ hasText: /Fehler|Error/ }); + const hasError = await errorToast.isVisible().catch(() => false); + + // Download may or may not trigger (depends on implementation) + // But we verify no error occurred + expect(hasError).toBe(false); +}); + +// Test 9: Translate button navigates to translate page +test('translate button navigates to translate page', async ({ page }) => { + await page.goto('/songs'); + await page.waitForLoadState('networkidle'); + + // Check if songs exist + const songTable = page.locator('table'); + const hasTable = await songTable.isVisible().catch(() => false); + + if (!hasTable) { + // Skip test if no songs exist + test.skip(); + } + + // Get first song row + const firstRow = page.locator('tbody tr').first(); + const hasTranslateLink = await firstRow.getByTestId('song-list-translate-link').isVisible().catch(() => false); + + if (!hasTranslateLink) { + // Skip test if no translate link visible + test.skip(); + } + + // Get the translate link href + const translateLink = firstRow.getByTestId('song-list-translate-link'); + const href = await translateLink.getAttribute('href'); + + // Verify href matches translate pattern + expect(href).toMatch(/\/songs\/\d+\/translate/); + + // Click translate link + await translateLink.click(); + + // Wait for navigation + await page.waitForLoadState('networkidle'); + + // Verify we navigated to translate page + await expect(page).toHaveURL(/\/songs\/\d+\/translate/); +});