diff --git a/.sisyphus/evidence/task-12-songs-block-tests.txt b/.sisyphus/evidence/task-12-songs-block-tests.txt new file mode 100644 index 0000000..ece566c --- /dev/null +++ b/.sisyphus/evidence/task-12-songs-block-tests.txt @@ -0,0 +1,3 @@ + +Running 11 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 5b6f7f6..3ce1e47 100644 --- a/.sisyphus/notepads/cts-herd-playwright/learnings.md +++ b/.sisyphus/notepads/cts-herd-playwright/learnings.md @@ -404,3 +404,97 @@ ### Critical Patterns - ALWAYS wait for transitions with `page.waitForTimeout(300)` after accordion toggle - NEVER assert specific slide content (dynamic CTS data) - NEVER upload real files in tests (conversion tools not available) + +## [2026-03-01] Task 16: Songs Block E2E Tests + +### Songs Block Component Structure + +**SongsBlock.vue data-testid selectors**: +- `songs-block` — Root container +- `songs-block-song-card` — Individual song card (v-for, sorted by order) +- `songs-block-request-button` — "Erstellung anfragen" button (unmatched songs only) +- `songs-block-search-input` — Song search input (unmatched songs only) +- `songs-block-song-select` — Song select dropdown (unmatched songs only) +- `songs-block-assign-button` — "Zuordnen" button (unmatched songs only) +- `songs-block-translation-checkbox` — Translation checkbox (matched songs with translation) +- `songs-block-preview-button` — "Vorschau" button (matched songs) +- `songs-block-download-button` — "PDF herunterladen" button (matched songs) + +**ArrangementConfigurator.vue data-testid selectors**: +- `arrangement-configurator` — Root container (only rendered for matched songs) +- `arrangement-select` — Arrangement dropdown +- `arrangement-add-button` — "Hinzufügen" button +- `arrangement-clone-button` — "Klonen" button +- `arrangement-delete-button` — "Löschen" button +- `arrangement-drag-handle` — Drag handle for arrangement groups +- `arrangement-remove-button` — "Entfernen" button for groups + +### Song States in UI + +**Two states per song**: +1. **Unmatched** (`!serviceSong.song_id`): Shows amber "Nicht zugeordnet" badge + request/search/assign panel +2. **Matched** (`serviceSong.song_id`): Shows emerald "Zugeordnet" badge + ArrangementConfigurator + preview/download buttons + +**Empty state**: When no songs exist at all, shows "Fuer diesen Service sind aktuell keine Songs vorhanden." + +### Dialog Handling for Arrangement Buttons + +**Key Pattern**: Arrangement add/clone buttons use `window.prompt()` (native browser dialog) + +```typescript +// Register handler BEFORE clicking (prompt is synchronous, blocks browser) +let promptShown = false; +page.once('dialog', async (dialog) => { + promptShown = true; + expect(dialog.type()).toBe('prompt'); + await dialog.dismiss(); // Cancel without creating +}); +await addButton.click(); +await page.waitForTimeout(500); +expect(promptShown).toBe(true); +``` + +**Add button**: Always shows prompt (no guard condition) +**Clone button**: Guards with `if (!selectedArrangement.value) return` — only shows prompt when arrangement is selected. Test must verify arrangement options exist before expecting dialog. + +### Preview/Download Buttons + +Currently both call `showPlaceholder()` which shows toast "Demnaechst verfuegbar". SongPreviewModal exists as component but is NOT yet integrated into SongsBlock. Tests verify button presence and text only, not modal behavior. + +### Key Differences from Previous Block Tests + +1. **No upload area** — Songs block doesn't have file upload (unlike Information/Moderation/Sermon) +2. **No slide grid** — Shows song cards instead of slide thumbnails +3. **Complex sub-states** — Each song can be matched or unmatched, requiring different test flows +4. **Dialog interaction** — Uses `window.prompt` for arrangement creation (unique to this block) +5. **Translation checkbox** — Toggle behavior with server-side persistence +6. **Nested component** — ArrangementConfigurator is a separate component embedded in matched songs + +### German UI Text Assertions + +- "Songs" — Block label (4th accordion) +- "Songs und Arrangements verwalten" — Block description +- "Song X" — Song order label (X = number) +- "CCLI:" — CCLI ID prefix +- "Hat Uebersetzung:" — Translation indicator +- "Zugeordnet" / "Nicht zugeordnet" — Match status badge +- "Erstellung anfragen" — Request creation button +- "Zuordnen" — Assign button +- "Hinzufügen" — Add arrangement button +- "Klonen" — Clone arrangement button +- "Vorschau" — Preview button +- "PDF herunterladen" — Download button +- "Uebersetzung verwenden" — Translation checkbox label +- "Name des neuen Arrangements" — Prompt message for add/clone + +### Verification + +- ✅ File created: `tests/e2e/service-edit-songs.spec.ts` +- ✅ 10 tests covering all requirements +- ✅ Tests dynamically find non-finalized service +- ✅ Tests use data-testid selectors only +- ✅ Tests gracefully skip if no editable service/songs +- ✅ Tests do NOT create/delete arrangements +- ✅ Tests do NOT test preview modal content +- ✅ LSP diagnostics: No errors +- ✅ Playwright test run: 1 passed, 10 skipped (expected — no test data) diff --git a/tests/e2e/service-edit-songs.spec.ts b/tests/e2e/service-edit-songs.spec.ts new file mode 100644 index 0000000..0ca035f --- /dev/null +++ b/tests/e2e/service-edit-songs.spec.ts @@ -0,0 +1,383 @@ +import { test, expect } from '@playwright/test'; + +// Test 1: Songs block accordion can be expanded and collapsed +test('songs block accordion can be expanded and collapsed', async ({ page }) => { + await page.goto('/services'); + await page.waitForLoadState('networkidle'); + + const editButton = page.getByTestId('service-list-edit-button').first(); + const hasEditableService = await editButton.isVisible().catch(() => false); + + if (!hasEditableService) { + test.skip(); + } + + await editButton.click(); + await page.waitForLoadState('networkidle'); + + // Find the Songs block toggle button (4th block) + const blockToggles = page.getByTestId('service-edit-block-toggle'); + const songsToggle = blockToggles.filter({ has: page.locator('text=Songs') }).first(); + + const toggleExists = await songsToggle.isVisible().catch(() => false); + if (!toggleExists) { + test.skip(); + } + + await expect(songsToggle).toBeVisible(); + + // Get the Songs block content container + const songsBlock = page.getByTestId('songs-block'); + + // Verify block is initially visible (expanded by default) + await expect(songsBlock).toBeVisible(); + + // Click toggle to collapse + await songsToggle.click(); + await page.waitForTimeout(300); // Wait for transition + + // Verify block is hidden + const isHidden = await songsBlock.isHidden().catch(() => true); + expect(isHidden).toBe(true); + + // Click toggle again to expand + await songsToggle.click(); + await page.waitForTimeout(300); // Wait for transition + + // Verify block is visible again + await expect(songsBlock).toBeVisible(); +}); + +// Test 2: Song list shows songs in correct order or empty state +test('song list shows songs or empty state', async ({ page }) => { + await page.goto('/services'); + await page.waitForLoadState('networkidle'); + + const editButton = page.getByTestId('service-list-edit-button').first(); + const hasEditableService = await editButton.isVisible().catch(() => false); + + if (!hasEditableService) { + test.skip(); + } + + await editButton.click(); + await page.waitForLoadState('networkidle'); + + const songsBlock = page.getByTestId('songs-block'); + await expect(songsBlock).toBeVisible(); + + // Check for song cards + const songCards = page.getByTestId('songs-block-song-card'); + const songCount = await songCards.count(); + + if (songCount === 0) { + // No songs - verify empty state message + await expect(songsBlock).toContainText('Fuer diesen Service sind aktuell keine Songs vorhanden.'); + return; + } + + // Songs exist - verify first card is rendered with order label + const firstSongCard = songCards.first(); + await expect(firstSongCard).toBeVisible(); + await expect(firstSongCard.locator('text=/Song \\d+/')).toBeVisible(); +}); + +// Test 3: Each song row shows name, CCLI ID, and status badge +test('song row shows name, CCLI ID, and status badge', async ({ page }) => { + await page.goto('/services'); + await page.waitForLoadState('networkidle'); + + const editButton = page.getByTestId('service-list-edit-button').first(); + const hasEditableService = await editButton.isVisible().catch(() => false); + + if (!hasEditableService) { + test.skip(); + } + + await editButton.click(); + await page.waitForLoadState('networkidle'); + + const songCards = page.getByTestId('songs-block-song-card'); + const songCount = await songCards.count(); + + if (songCount === 0) { + test.skip(); + } + + const firstSongCard = songCards.first(); + + // Verify CCLI label exists + await expect(firstSongCard.locator('text=/CCLI:/')).toBeVisible(); + + // Verify translation indicator exists + await expect(firstSongCard.locator('text=/Hat Uebersetzung:/')).toBeVisible(); + + // Verify status badge exists (either "Zugeordnet" or "Nicht zugeordnet") + const statusBadge = firstSongCard.locator('text=/zugeordnet/i').first(); + await expect(statusBadge).toBeVisible(); +}); + +// Test 4: Unmatched songs show "Erstellung anfragen" button and manual assign select +test('unmatched songs show request creation button and manual assign', async ({ page }) => { + await page.goto('/services'); + await page.waitForLoadState('networkidle'); + + const editButton = page.getByTestId('service-list-edit-button').first(); + const hasEditableService = await editButton.isVisible().catch(() => false); + + if (!hasEditableService) { + test.skip(); + } + + await editButton.click(); + await page.waitForLoadState('networkidle'); + + const songCards = page.getByTestId('songs-block-song-card'); + const songCount = await songCards.count(); + + if (songCount === 0) { + test.skip(); + } + + // Look for unmatched song card (has "Nicht zugeordnet" badge) + const unmatchedCard = songCards.filter({ has: page.locator('text=Nicht zugeordnet') }).first(); + const hasUnmatched = await unmatchedCard.isVisible().catch(() => false); + + if (!hasUnmatched) { + test.skip(); + } + + // Verify "Erstellung anfragen" button + const requestButton = page.getByTestId('songs-block-request-button').first(); + await expect(requestButton).toBeVisible(); + await expect(requestButton).toContainText('Erstellung anfragen'); + + // Verify search input + const searchInput = page.getByTestId('songs-block-search-input').first(); + await expect(searchInput).toBeVisible(); + + // Verify song select dropdown + const songSelect = page.getByTestId('songs-block-song-select').first(); + await expect(songSelect).toBeVisible(); + + // Verify "Zuordnen" (assign) button + const assignButton = page.getByTestId('songs-block-assign-button').first(); + await expect(assignButton).toBeVisible(); + await expect(assignButton).toContainText('Zuordnen'); +}); + +// Test 5: Matched songs show arrangement dropdown with options +test('matched songs show arrangement dropdown with options', async ({ page }) => { + await page.goto('/services'); + await page.waitForLoadState('networkidle'); + + const editButton = page.getByTestId('service-list-edit-button').first(); + const hasEditableService = await editButton.isVisible().catch(() => false); + + if (!hasEditableService) { + test.skip(); + } + + await editButton.click(); + await page.waitForLoadState('networkidle'); + + // Check for arrangement configurator (only present for matched songs) + const arrangementConfigurator = page.getByTestId('arrangement-configurator').first(); + const hasMatched = await arrangementConfigurator.isVisible().catch(() => false); + + if (!hasMatched) { + test.skip(); + } + + // Verify arrangement select dropdown exists + const arrangementSelect = page.getByTestId('arrangement-select').first(); + await expect(arrangementSelect).toBeVisible(); + + // Verify add button + const addButton = page.getByTestId('arrangement-add-button').first(); + await expect(addButton).toBeVisible(); + await expect(addButton).toContainText('Hinzufügen'); + + // Verify clone button + const cloneButton = page.getByTestId('arrangement-clone-button').first(); + await expect(cloneButton).toBeVisible(); + await expect(cloneButton).toContainText('Klonen'); +}); + +// Test 6: Arrangement "Hinzufügen" (Add) button opens name prompt +test('arrangement add button opens name prompt', async ({ page }) => { + await page.goto('/services'); + await page.waitForLoadState('networkidle'); + + const editButton = page.getByTestId('service-list-edit-button').first(); + const hasEditableService = await editButton.isVisible().catch(() => false); + + if (!hasEditableService) { + test.skip(); + } + + await editButton.click(); + await page.waitForLoadState('networkidle'); + + const addButton = page.getByTestId('arrangement-add-button').first(); + const hasMatched = await addButton.isVisible().catch(() => false); + + if (!hasMatched) { + test.skip(); + } + + // Set up dialog handler before clicking (window.prompt is synchronous) + let promptShown = false; + page.once('dialog', async (dialog) => { + promptShown = true; + expect(dialog.type()).toBe('prompt'); + expect(dialog.message()).toContain('Name des neuen Arrangements'); + await dialog.dismiss(); // Cancel without creating + }); + + await addButton.click(); + await page.waitForTimeout(500); + + expect(promptShown).toBe(true); +}); + +// Test 7: Arrangement "Klonen" (Clone) button opens name prompt +test('arrangement clone button opens name prompt', async ({ page }) => { + await page.goto('/services'); + await page.waitForLoadState('networkidle'); + + const editButton = page.getByTestId('service-list-edit-button').first(); + const hasEditableService = await editButton.isVisible().catch(() => false); + + if (!hasEditableService) { + test.skip(); + } + + await editButton.click(); + await page.waitForLoadState('networkidle'); + + const cloneButton = page.getByTestId('arrangement-clone-button').first(); + const hasMatched = await cloneButton.isVisible().catch(() => false); + + if (!hasMatched) { + test.skip(); + } + + // Verify arrangement select has options (clone requires a selected arrangement) + const arrangementSelect = page.getByTestId('arrangement-select').first(); + const optionCount = await arrangementSelect.locator('option').count(); + + if (optionCount === 0) { + test.skip(); + } + + // Set up dialog handler before clicking + let promptShown = false; + page.once('dialog', async (dialog) => { + promptShown = true; + expect(dialog.type()).toBe('prompt'); + expect(dialog.message()).toContain('Name des neuen Arrangements'); + await dialog.dismiss(); // Cancel without creating + }); + + await cloneButton.click(); + await page.waitForTimeout(500); + + expect(promptShown).toBe(true); +}); + +// Test 8: Preview button opens song preview (placeholder toast for now) +test('preview button is present for matched songs', async ({ page }) => { + await page.goto('/services'); + await page.waitForLoadState('networkidle'); + + const editButton = page.getByTestId('service-list-edit-button').first(); + const hasEditableService = await editButton.isVisible().catch(() => false); + + if (!hasEditableService) { + test.skip(); + } + + await editButton.click(); + await page.waitForLoadState('networkidle'); + + const previewButton = page.getByTestId('songs-block-preview-button').first(); + const hasMatched = await previewButton.isVisible().catch(() => false); + + if (!hasMatched) { + test.skip(); + } + + // Verify preview button exists with correct text + await expect(previewButton).toBeVisible(); + await expect(previewButton).toContainText('Vorschau'); +}); + +// Test 9: Download (PDF) button is present for songs with selected arrangement +test('download button is present for matched songs', async ({ page }) => { + await page.goto('/services'); + await page.waitForLoadState('networkidle'); + + const editButton = page.getByTestId('service-list-edit-button').first(); + const hasEditableService = await editButton.isVisible().catch(() => false); + + if (!hasEditableService) { + test.skip(); + } + + await editButton.click(); + await page.waitForLoadState('networkidle'); + + const downloadButton = page.getByTestId('songs-block-download-button').first(); + const hasMatched = await downloadButton.isVisible().catch(() => false); + + if (!hasMatched) { + test.skip(); + } + + // Verify download button exists with correct text + await expect(downloadButton).toBeVisible(); + await expect(downloadButton).toContainText('PDF herunterladen'); +}); + +// Test 10: Translation checkbox toggles (if song has translation) +test('translation checkbox toggles if song has translation', async ({ page }) => { + await page.goto('/services'); + await page.waitForLoadState('networkidle'); + + const editButton = page.getByTestId('service-list-edit-button').first(); + const hasEditableService = await editButton.isVisible().catch(() => false); + + if (!hasEditableService) { + test.skip(); + } + + await editButton.click(); + await page.waitForLoadState('networkidle'); + + const translationCheckbox = page.getByTestId('songs-block-translation-checkbox').first(); + const hasTranslation = await translationCheckbox.isVisible().catch(() => false); + + if (!hasTranslation) { + test.skip(); + } + + // Get current checked state + const initialState = await translationCheckbox.isChecked(); + + // Toggle the checkbox + await translationCheckbox.click(); + await page.waitForTimeout(300); + + // Verify state changed + const toggledState = await translationCheckbox.isChecked(); + expect(toggledState).not.toBe(initialState); + + // Toggle back to restore original state + await translationCheckbox.click(); + await page.waitForTimeout(300); + + // Verify restored to original state + const restoredState = await translationCheckbox.isChecked(); + expect(restoredState).toBe(initialState); +});