|
| 1 | +import { expect, test } from '@playwright/test'; |
| 2 | +import { waitForAlpine } from './fixtures/helpers.js'; |
| 3 | +import { createLibraryState, setupLibraryMocks } from './fixtures/mock-library.js'; |
| 4 | + |
| 5 | +// Real lyrics from LRCLIB cache -- short lines, narrower content width |
| 6 | +const TRAIL_OF_DEAD_LYRICS = |
| 7 | + `If i could make a list\nI'd put your name on top\nOf my mistakes and regrets\nAnd every line after it\nBecause every inch of hope\n\nBecomes a world of shame\nI've had to walk through\nEach and every day\n\nAt the top of my lungs\nIt would never return\nAnd if i screamed, "you were wrong"\nAll the faith that i've lost\n\nAnd there is nothing left to say\nThat has not been said\nIf i shout, you wouldn't listen\nI don't think it'd even sink in\nIf i could make a list\n\nOf my mistakes and regrets\nI'd put your name on top\nAnd every line after it\n\nBecomes a world of shame\nBecause every inch of hope\nI've had to walk through\nEach and every day\n\nAnd there is nothing left to say\nIf i shout, you wouldn't listen\nThat has not been said\n\nI don't think it'd even sink in\nIf you forget how to feel\nReach inside your chest\nIs there a heart beating?\nIs it just emptiness? (repeat)`; |
| 8 | + |
| 9 | +// Real lyrics from LRCLIB cache -- long lines, wider content width |
| 10 | +const BECK_LYRICS = |
| 11 | + `In a cast iron cage you couldn't help but stare like a creature\nWith the laws of a brothel and the fireproof bones of a preacher\nAnd your lingo coined from the sacrament of a casino\nOn a government loan with a guillotine in your libido\n\nWho's gonna answer\nProfanity prayers\nWho's gonna answer\nThese profanity prayers\n\nWell you know how it looks when you pull all your books from the table\nAnd you stare into space trying to discern what to say now\nAnd you wait at the light and watch for a sign that you're breathing\n'Cause you can't just live on air and float to the ceiling\n\nWho's gonna answer\nProfanity prayers\nWho's gonna answer\nProfanity prayers\n\nWho's gonna answer\nProfanity prayers\nWho's gonna answer\nProfanity prayers`; |
| 12 | + |
| 13 | +const TRAIL_OF_DEAD_TRACK = { |
| 14 | + id: 1, |
| 15 | + title: 'Mistakes & Regrets', |
| 16 | + artist: '...And You Will Know Us by the Trail of Dead', |
| 17 | + album: 'Source Tags & Codes', |
| 18 | + duration: 214000, |
| 19 | + filepath: '/music/trail-of-dead/mistakes-and-regrets.mp3', |
| 20 | +}; |
| 21 | + |
| 22 | +const BECK_TRACK = { |
| 23 | + id: 2, |
| 24 | + title: 'Profanity Prayers', |
| 25 | + artist: 'Beck', |
| 26 | + album: 'Modern Guilt', |
| 27 | + duration: 209000, |
| 28 | + filepath: '/music/beck/profanity-prayers.mp3', |
| 29 | +}; |
| 30 | + |
| 31 | +const INSTRUMENTAL_TRACK = { |
| 32 | + id: 3, |
| 33 | + title: 'A Brief Yet Triumphant Intermission', |
| 34 | + artist: 'Against Me!', |
| 35 | + album: 'Searching for a Former Clarity', |
| 36 | + duration: 47000, |
| 37 | + filepath: '/music/against-me/a-brief-yet-triumphant-intermission.mp3', |
| 38 | +}; |
| 39 | + |
| 40 | +/** |
| 41 | + * Navigate to Now Playing view and wait for it |
| 42 | + * @param {import('@playwright/test').Page} page |
| 43 | + */ |
| 44 | +async function navigateToNowPlaying(page) { |
| 45 | + await page.evaluate(() => { |
| 46 | + window.Alpine.store('ui').view = 'nowPlaying'; |
| 47 | + }); |
| 48 | + await page.waitForSelector('[x-data="nowPlayingView"]', { state: 'visible' }); |
| 49 | +} |
| 50 | + |
| 51 | +/** |
| 52 | + * Set current track and artwork on the player store |
| 53 | + * @param {import('@playwright/test').Page} page |
| 54 | + * @param {Object} track |
| 55 | + */ |
| 56 | +async function setCurrentTrack(page, track) { |
| 57 | + await page.evaluate((trackData) => { |
| 58 | + window.Alpine.store('player').currentTrack = trackData; |
| 59 | + window.Alpine.store('player').artwork = { |
| 60 | + data: |
| 61 | + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg==', |
| 62 | + mime_type: 'image/png', |
| 63 | + source: 'test', |
| 64 | + }; |
| 65 | + }, track); |
| 66 | +} |
| 67 | + |
| 68 | +/** |
| 69 | + * Inject lyrics directly into the nowPlayingView Alpine component and |
| 70 | + * trigger the measurement pipeline. |
| 71 | + * @param {import('@playwright/test').Page} page |
| 72 | + * @param {string|null} lyricsText |
| 73 | + */ |
| 74 | +async function setLyrics(page, lyricsText) { |
| 75 | + await page.evaluate((text) => { |
| 76 | + const el = document.querySelector('[x-data="nowPlayingView"]'); |
| 77 | + const data = window.Alpine.$data(el); |
| 78 | + data.lyrics = text; |
| 79 | + if (text) { |
| 80 | + data.$nextTick(() => data._updateLyricsScrollState()); |
| 81 | + } else { |
| 82 | + data._lyricsContentWidth = null; |
| 83 | + } |
| 84 | + }, lyricsText); |
| 85 | +} |
| 86 | + |
| 87 | +/** |
| 88 | + * Wait for _lyricsContentWidth to be set (non-null) |
| 89 | + * @param {import('@playwright/test').Page} page |
| 90 | + */ |
| 91 | +async function waitForMeasurement(page) { |
| 92 | + await page.waitForFunction( |
| 93 | + () => { |
| 94 | + const el = document.querySelector('[x-data="nowPlayingView"]'); |
| 95 | + return window.Alpine.$data(el)._lyricsContentWidth !== null; |
| 96 | + }, |
| 97 | + null, |
| 98 | + { timeout: 5000 }, |
| 99 | + ); |
| 100 | +} |
| 101 | + |
| 102 | +/** |
| 103 | + * Get the current _lyricsContentWidth value |
| 104 | + * @param {import('@playwright/test').Page} page |
| 105 | + * @returns {Promise<number|null>} |
| 106 | + */ |
| 107 | +async function getLyricsContentWidth(page) { |
| 108 | + return await page.evaluate(() => { |
| 109 | + const el = document.querySelector('[x-data="nowPlayingView"]'); |
| 110 | + return window.Alpine.$data(el)._lyricsContentWidth; |
| 111 | + }); |
| 112 | +} |
| 113 | + |
| 114 | +test.describe('Lyrics Layout Reflow', () => { |
| 115 | + test.beforeEach(async ({ page }) => { |
| 116 | + const libraryState = createLibraryState(); |
| 117 | + await setupLibraryMocks(page, libraryState); |
| 118 | + await page.goto('/'); |
| 119 | + await waitForAlpine(page); |
| 120 | + await page.waitForSelector('[x-data="libraryBrowser"]', { state: 'visible' }); |
| 121 | + }); |
| 122 | + |
| 123 | + test('short lyrics (Trail of Dead) should size wrapper to content width', async ({ page }) => { |
| 124 | + await setCurrentTrack(page, TRAIL_OF_DEAD_TRACK); |
| 125 | + await navigateToNowPlaying(page); |
| 126 | + |
| 127 | + // Before lyrics: layout should not be visible |
| 128 | + await expect(page.locator('[data-testid="lyrics-layout"]')).not.toBeVisible(); |
| 129 | + |
| 130 | + await setLyrics(page, TRAIL_OF_DEAD_LYRICS); |
| 131 | + await expect(page.locator('[data-testid="lyrics-text"]')).toBeVisible(); |
| 132 | + await waitForMeasurement(page); |
| 133 | + |
| 134 | + // Wrapper should have explicit pixel width |
| 135 | + const lyricsWrapper = page.locator( |
| 136 | + '[data-testid="lyrics-layout"] > div > div:nth-child(2)', |
| 137 | + ); |
| 138 | + const style = await lyricsWrapper.getAttribute('style'); |
| 139 | + expect(style).toMatch(/width:\s*\d+(\.\d+)?px/); |
| 140 | + |
| 141 | + const width = await getLyricsContentWidth(page); |
| 142 | + expect(width).toBeGreaterThan(0); |
| 143 | + expect(width).toBeLessThan(1624); |
| 144 | + }); |
| 145 | + |
| 146 | + test('long lyrics (Beck) should produce wider content width than short lyrics', async ({ page }) => { |
| 147 | + await setCurrentTrack(page, TRAIL_OF_DEAD_TRACK); |
| 148 | + await navigateToNowPlaying(page); |
| 149 | + |
| 150 | + // Measure short lyrics (Trail of Dead) |
| 151 | + await setLyrics(page, TRAIL_OF_DEAD_LYRICS); |
| 152 | + await waitForMeasurement(page); |
| 153 | + const shortWidth = await getLyricsContentWidth(page); |
| 154 | + |
| 155 | + // Switch to long lyrics (Beck) |
| 156 | + await setCurrentTrack(page, BECK_TRACK); |
| 157 | + await setLyrics(page, BECK_LYRICS); |
| 158 | + await waitForMeasurement(page); |
| 159 | + const longWidth = await getLyricsContentWidth(page); |
| 160 | + |
| 161 | + // Beck's longer lines should produce a wider measurement |
| 162 | + expect(longWidth).toBeGreaterThan(shortWidth); |
| 163 | + }); |
| 164 | + |
| 165 | + test('instrumental track (no lyrics) should not set content width', async ({ page }) => { |
| 166 | + await setCurrentTrack(page, INSTRUMENTAL_TRACK); |
| 167 | + await navigateToNowPlaying(page); |
| 168 | + |
| 169 | + // Simulate lyrics not found (null) |
| 170 | + await setLyrics(page, null); |
| 171 | + |
| 172 | + const width = await getLyricsContentWidth(page); |
| 173 | + expect(width).toBeNull(); |
| 174 | + |
| 175 | + // Lyrics layout should not be visible |
| 176 | + await expect(page.locator('[data-testid="lyrics-layout"]')).not.toBeVisible(); |
| 177 | + }); |
| 178 | + |
| 179 | + test('switching from lyrics to instrumental should reset layout', async ({ page }) => { |
| 180 | + await setCurrentTrack(page, BECK_TRACK); |
| 181 | + await navigateToNowPlaying(page); |
| 182 | + |
| 183 | + // Show lyrics first |
| 184 | + await setLyrics(page, BECK_LYRICS); |
| 185 | + await waitForMeasurement(page); |
| 186 | + const widthWithLyrics = await getLyricsContentWidth(page); |
| 187 | + expect(widthWithLyrics).toBeGreaterThan(0); |
| 188 | + |
| 189 | + // Switch to instrumental (lyrics not found) |
| 190 | + await setCurrentTrack(page, INSTRUMENTAL_TRACK); |
| 191 | + await setLyrics(page, null); |
| 192 | + |
| 193 | + const widthAfterClear = await getLyricsContentWidth(page); |
| 194 | + expect(widthAfterClear).toBeNull(); |
| 195 | + }); |
| 196 | + |
| 197 | + test('lyrics panel width should change on viewport resize', async ({ page }) => { |
| 198 | + await setCurrentTrack(page, BECK_TRACK); |
| 199 | + await navigateToNowPlaying(page); |
| 200 | + await setLyrics(page, BECK_LYRICS); |
| 201 | + await waitForMeasurement(page); |
| 202 | + |
| 203 | + const initialWidth = await getLyricsContentWidth(page); |
| 204 | + |
| 205 | + // Shrink viewport -- font uses clamp(1rem, 2vw, 1.5rem) so text width changes |
| 206 | + await page.setViewportSize({ width: 900, height: 700 }); |
| 207 | + |
| 208 | + await page.waitForFunction( |
| 209 | + (prev) => { |
| 210 | + const el = document.querySelector('[x-data="nowPlayingView"]'); |
| 211 | + const current = window.Alpine.$data(el)._lyricsContentWidth; |
| 212 | + return current !== null && current !== prev; |
| 213 | + }, |
| 214 | + initialWidth, |
| 215 | + { timeout: 5000 }, |
| 216 | + ); |
| 217 | + |
| 218 | + const newWidth = await getLyricsContentWidth(page); |
| 219 | + expect(newWidth).not.toBe(initialWidth); |
| 220 | + expect(newWidth).toBeGreaterThan(0); |
| 221 | + }); |
| 222 | + |
| 223 | + test('inner container should drop width:100% after measurement', async ({ page }) => { |
| 224 | + await setCurrentTrack(page, TRAIL_OF_DEAD_TRACK); |
| 225 | + await navigateToNowPlaying(page); |
| 226 | + await setLyrics(page, TRAIL_OF_DEAD_LYRICS); |
| 227 | + await waitForMeasurement(page); |
| 228 | + |
| 229 | + const innerContainer = page.locator('[data-testid="lyrics-layout"] > div'); |
| 230 | + const style = await innerContainer.getAttribute('style'); |
| 231 | + expect(style || '').not.toContain('width: 100%'); |
| 232 | + }); |
| 233 | + |
| 234 | + test('lyrics wrapper should use shrink class when measured', async ({ page }) => { |
| 235 | + await setCurrentTrack(page, TRAIL_OF_DEAD_TRACK); |
| 236 | + await navigateToNowPlaying(page); |
| 237 | + await setLyrics(page, TRAIL_OF_DEAD_LYRICS); |
| 238 | + await waitForMeasurement(page); |
| 239 | + |
| 240 | + const lyricsWrapper = page.locator( |
| 241 | + '[data-testid="lyrics-layout"] > div > div:nth-child(2)', |
| 242 | + ); |
| 243 | + const classes = await lyricsWrapper.getAttribute('class'); |
| 244 | + expect(classes).toContain('shrink'); |
| 245 | + expect(classes).not.toContain('flex-1'); |
| 246 | + }); |
| 247 | +}); |
0 commit comments