import { expect, test } from '@playwright/test'; import pg from 'pg'; import { randomUUID } from 'node:crypto'; // Solo contra desarrollo con el código de prueba habilitado y una cuenta efímera. test.describe('Programación autenticada del Juke-box', () => { test.skip(process.env.E2E_JUKEBOX !== '1', 'Requiere la base de desarrollo y ACCESO_PRUEBAS.'); test.describe.configure({ mode: 'serial' }); const usuarioId = randomUUID(); const otroId = randomUUID(); const email = `jukebox-${usuarioId}@example.test`; const listaId = randomUUID(); const listaAjena = randomUUID(); let canciones: { id: string; slug: string; titulo: string }[]; let db: pg.Client; let conectada = false; test.beforeAll(async () => { db = new pg.Client({ connectionString: process.env.DATABASE_URL }); await db.connect(); conectada = true; canciones = ( await db.query( "select id, slug, titulo from cancion where estado='publicado' and slug in ('prometiste','cuando-tus-manos-me-tocan') order by slug" ) ).rows; await db.query('insert into usuario (id,email) values ($1,$2), ($3,$4)', [ usuarioId, email, otroId, `otro-${usuarioId}@example.test` ]); await db.query( "insert into suscripcion (id,usuario_id,plan_id,estado) select $1,$2,id,'activa' from plan_suscripcion where codigo='friend' and acceso_jukebox=true", [randomUUID(), usuarioId] ); await db.query( "insert into lista_reproduccion (id,usuario_id,nombre) values ($1,$2,'Mi selección de mañana'),($3,$4,'Lista ajena')", [listaId, usuarioId, listaAjena, otroId] ); for (const [orden, c] of canciones.entries()) await db.query( 'insert into lista_reproduccion_cancion (lista_id,cancion_id,orden) values ($1,$2,$3)', [listaId, c.id, orden] ); }); test.afterAll(async () => { if (db && conectada) { await db.query('delete from usuario where id = any($1::text[])', [[usuarioId, otroId]]); await db.end(); } }); test.beforeEach(async ({ page }) => { await page.goto(`/entrar?paso=codigo&email=${encodeURIComponent(email)}&volverA=%2Fjuke-box`); await page.locator('html[data-hidratado]').waitFor(); await page.getByLabel('Código', { exact: true }).fill('123456'); await page.getByRole('button', { name: 'Entrar', exact: true }).click(); await expect(page).toHaveURL('/juke-box'); }); let primero = ''; let segundo = ''; test('separa reproducción y edición, crea dos jukebox y conserva catálogo, listas y modos', async ({ page }) => { await page.setViewportSize({ width: 1440, height: 1000 }); await expect(page.locator('.programador')).toHaveCount(0); await page.getByRole('link', { name: 'Configura tu Jukebox', exact: true }).click(); await expect(page).toHaveURL('/juke-box/configurar'); await page.getByRole('link', { name: 'Crear jukebox', exact: true }).click(); await expect(page).toHaveURL('/juke-box/nuevo'); await page.getByLabel('Nombre del jukebox', { exact: true }).fill('Mañanas'); await page.getByLabel('Descripción', { exact: true }).fill('Música para empezar el día.'); await expect(page.getByLabel('Nombre de la franja')).toHaveCount(0); await expect(page.getByLabel('Zona horaria')).toHaveCount(0); await page.getByRole('button', { name: 'Crear primera franja' }).click(); await page.getByRole('button', { name: 'Listas y canciones', exact: true }).click(); await page.getByRole('button', { name: '+ Añadir contenido', exact: true }).click(); await page .getByRole('button', { name: 'Añadir lista Mi selección de mañana', exact: true }) .click(); await page.getByRole('button', { name: 'Canciones', exact: true }).click(); await page .getByRole('button', { name: 'Añadir canción ' + canciones[1].titulo, exact: true }) .click(); await page.getByRole('button', { name: 'Listo', exact: false }).click(); await page.getByRole('button', { name: 'Subir ' + canciones[1].titulo, exact: true }).click(); await expect(page.locator('.contenidos > .contenido').first()).toContainText( canciones[1].titulo ); await page.getByRole('button', { name: 'Bajar ' + canciones[1].titulo, exact: true }).click(); await page.getByRole('button', { name: 'Todo el catálogo', exact: true }).click(); await page.getByRole('button', { name: 'Listas y canciones', exact: true }).click(); await expect(page.locator('.contenidos > .contenido')).toHaveCount(2); await page.getByRole('button', { name: 'Guardar jukebox', exact: true }).click(); await expect(page).toHaveURL(/juke-box\/configurar\?jukebox=.+/); primero = new URL(page.url()).searchParams.get('jukebox')!; await expect(page.locator('.programador')).toHaveCount(0); await page.getByRole('link', { name: '+ Crear jukebox', exact: true }).click(); await page.getByLabel('Nombre del jukebox', { exact: true }).fill('Tardes'); await page .getByLabel('Descripción', { exact: true }) .fill('Todo el catálogo al caer la tarde.'); await page.getByRole('button', { name: 'Crear primera franja' }).click(); await page.getByRole('button', { name: 'Aleatoria', exact: true }).click(); await expect( page.getByRole('button', { name: 'Todo el catálogo', exact: true }) ).toHaveAttribute('aria-pressed', 'true'); await page.getByRole('button', { name: 'Guardar jukebox', exact: true }).click(); await expect(page).toHaveURL(/juke-box\/configurar\?jukebox=.+/); segundo = new URL(page.url()).searchParams.get('jukebox')!; expect(segundo).not.toBe(primero); await page.reload(); await page.locator('html[data-hidratado]').waitFor(); await expect(page.locator('.coleccion .jukebox')).toHaveCount(2); await expect(page.locator('.coleccion')).toContainText('Música para empezar el día.'); await expect(page.locator('.coleccion')).toContainText('Todo el catálogo al caer la tarde.'); await expect(page.locator('.horarios')).toContainText('Todo el catálogo'); await expect(page.locator('.horarios')).toContainText('Aleatoria'); await page.getByRole('link', { name: 'Abrir reproductor de Mañanas', exact: true }).click(); await page.getByRole('button', { name: 'Elegir Jukebox', exact: true }).click(); await page.getByRole('button', { name: 'Reproducir Mañanas', exact: true }).click(); await expect(page.locator('.estado-jukebox')).toContainText('Mañanas'); const alturasAcciones = await page.locator('.acciones-heroe').evaluate((fila) => Array.from(fila.querySelectorAll('button')) .filter((b) => b.offsetHeight) .map((b) => { const r = b.getBoundingClientRect(); return r.y + r.height / 2; }) ); expect(Math.max(...alturasAcciones) - Math.min(...alturasAcciones)).toBeLessThanOrEqual(1); await expect(page.getByRole('region', { name: 'Reproductor', exact: true })).toContainText( canciones[0].titulo ); await page.getByRole('link', { name: 'Configura tu Jukebox', exact: true }).click(); await page.getByRole('link', { name: 'Editar Tardes', exact: true }).click(); await expect(page).toHaveURL('/juke-box/' + segundo + '/editar'); await page.getByRole('button', { name: 'Secuencial', exact: true }).click(); await page.getByRole('button', { name: 'Guardar jukebox', exact: true }).click(); await expect(page).toHaveURL(/juke-box\/configurar\?jukebox=.+/); await expect(page.locator('.control-jukebox')).toContainText('Mañanas'); await expect(page.getByRole('region', { name: 'Reproductor', exact: true })).toContainText( canciones[0].titulo ); await page.getByRole('link', { name: 'Abrir reproductor de Tardes', exact: true }).click(); await page.getByRole('button', { name: 'Elegir Jukebox', exact: true }).click(); await page.getByRole('button', { name: 'Reproducir Tardes', exact: true }).click(); await expect(page.locator('.estado-jukebox')).toContainText('Tardes'); await page .locator('.estado-jukebox') .getByRole('button', { name: 'Detener Jukebox', exact: true }) .click(); await expect(page.locator('.estado-jukebox')).toContainText('Tardes'); await expect(page.locator('.estado-jukebox')).toContainText('Detenido'); expect((await page.request.get('/juke-box/' + segundo + '/programacion')).status()).toBe(200); await page.goto('/juke-box'); await page.locator('html[data-hidratado]').waitFor(); await page.getByRole('button', { name: 'Elegir Jukebox', exact: true }).click(); await expect(page.getByRole('list', { name: 'Tus jukebox' })).toBeVisible(); await page.getByRole('button', { name: 'Reproducir Mañanas', exact: true }).click(); await expect(page.getByRole('list', { name: 'Tus jukebox' })).not.toBeVisible(); await expect(page.locator('.estado-jukebox')).toContainText('Mañanas'); await page .locator('.estado-jukebox') .getByRole('button', { name: 'Detener Jukebox', exact: true }) .click(); }); test('mantiene borradores con el historial y permite cancelar un jukebox nuevo sin crearlo', async ({ page }) => { await page.getByRole('link', { name: 'Configura tu Jukebox', exact: true }).click(); await page.getByRole('link', { name: 'Editar Mañanas', exact: true }).click(); await page.getByRole('button', { name: 'Aleatoria', exact: true }).click(); await page.getByRole('banner').getByRole('link', { name: 'Música', exact: true }).click(); await expect(page).toHaveURL('/musica'); await page.goBack(); await expect(page.getByRole('button', { name: 'Aleatoria', exact: true })).toHaveAttribute( 'aria-pressed', 'true' ); await page.getByRole('button', { name: 'Deshacer cambios', exact: true }).click(); await expect(page.getByRole('button', { name: 'Secuencial', exact: true })).toHaveAttribute( 'aria-pressed', 'true' ); await page.getByRole('link', { name: 'Cancelar', exact: true }).click(); await page.getByRole('link', { name: '+ Crear jukebox', exact: true }).click(); await page.getByRole('button', { name: 'Crear primera franja' }).click(); await page.getByRole('link', { name: 'Cancelar', exact: true }).click(); await expect(page.locator('.coleccion .jukebox')).toHaveCount(2); }); test('comprueba propietario, listas ajenas, revisión y la independencia de guardados', async ({ page, request }) => { const endpoint = '/juke-box/' + primero + '/programacion'; expect((await request.get(endpoint)).status()).toBe(401); const actual = await (await page.request.get(endpoint)).json(); const headers = { origin: new URL(page.url()).origin }; const ajena = structuredClone(actual); ajena.programacion.franjas[0].contenidos.push({ tipo: 'lista', id: listaAjena }); expect((await page.request.put(endpoint, { headers, data: ajena })).status()).toBe(400); const otroJukebox = randomUUID(); await db.query( 'insert into jukebox(id,usuario_id,numero,zona_horaria,franjas) values($1,$2,1,$3,$4)', [otroJukebox, otroId, 'UTC', JSON.stringify(actual.programacion.franjas)] ); expect((await page.request.get('/juke-box/' + otroJukebox + '/programacion')).status()).toBe( 404 ); expect( ( await page.request.put('/juke-box/' + otroJukebox + '/programacion', { headers, data: actual }) ).status() ).toBe(409); expect( ( await page.request.delete('/juke-box/' + otroJukebox + '/programacion', { headers, data: { revision: 1 } }) ).status() ).toBe(409); const revSegundo = await ( await page.request.get('/juke-box/' + segundo + '/programacion') ).json(); actual.programacion.usuarioId = otroId; actual.programacion.franjas[0].nombre = 'Campo obsoleto'; expect((await page.request.put(endpoint, { headers, data: actual })).status()).toBe(200); expect((await page.request.put(endpoint, { headers, data: actual })).status()).toBe(409); const recargado = await (await page.request.get(endpoint)).json(); expect(recargado.programacion.usuarioId).toBeUndefined(); expect(recargado.programacion.franjas[0].nombre).toBeUndefined(); expect( (await (await page.request.get('/juke-box/' + segundo + '/programacion')).json()).revision ).toBe(revSegundo.revision); expect( ( await page.request.put(endpoint, { headers: { origin: 'https://ajena.example' }, data: recargado }) ).status() ).toBe(403); }); test('el hero ocupa 100vh, conserva el play dorado y coloca las dos acciones debajo del título', async ({ page }) => { await page.setViewportSize({ width: 1440, height: 1000 }); await page.emulateMedia({ reducedMotion: 'reduce' }); await expect(page.getByRole('heading', { name: 'Mis jukebox', exact: true })).toHaveCount(0); await expect(page.getByRole('contentinfo')).toHaveCount(1); expect( await page.getByRole('contentinfo').evaluate((pie) => pie.getBoundingClientRect().top) ).toBe(1000); await page.locator('.heroe-jukebox img').evaluate((img: HTMLImageElement) => img.decode()); const geometria = await page.locator('.heroe-jukebox').evaluate((h) => { const titulo = h.querySelector('h1')!.getBoundingClientRect(); const botones = h.querySelector('.acciones-heroe')!.getBoundingClientRect(); return { alto: h.getBoundingClientRect().height, ventana: innerHeight, izquierda: botones.x - titulo.x, debajo: botones.y >= titulo.bottom }; }); expect(geometria.alto).toBe(geometria.ventana); expect(geometria.izquierda).toBe(0); expect(geometria.debajo).toBe(true); await expect .poll(() => page .locator('.titulo-heroe') .evaluate((h) => Math.abs( h.querySelector('h1')!.getBoundingClientRect().width - h.querySelector('.entradilla span')!.getBoundingClientRect().width ) ) ) .toBeLessThan(1); expect( await page .locator('.entradilla span') .evaluate((s) => parseFloat(getComputedStyle(s).fontSize)) ).toBeGreaterThan(24); await page.getByRole('button', { name: 'Elegir Jukebox', exact: true }).click(); await expect(page.getByRole('list', { name: 'Tus jukebox' })).toContainText( 'Música para empezar el día.' ); await page.screenshot({ path: 'test-results-publica/jukebox-desplegable-oscuro.png' }); await page.keyboard.press('Escape'); await expect(page.getByText('Piloto automático', { exact: true })).toHaveCount(0); await expect(page.getByRole('button', { name: 'Play JukeBox', exact: true })).toHaveClass( /btn--primary/ ); await expect( page.getByRole('link', { name: 'Configura tu Jukebox', exact: true }) ).not.toHaveClass(/btn--primary/); await page.screenshot({ path: 'test-results-publica/jukebox-hero-100vh-oscuro.png' }); await page.getByRole('button', { name: 'Cambiar al tema claro' }).click(); await expect(page.locator('html')).toHaveAttribute('data-tema', 'claro'); await page.evaluate( () => new Promise((done) => requestAnimationFrame(() => requestAnimationFrame(() => done())) ) ); await page.screenshot({ path: 'test-results-publica/jukebox-hero-100vh-claro.png' }); await page.getByRole('button', { name: 'Elegir Jukebox', exact: true }).click(); await page.screenshot({ path: 'test-results-publica/jukebox-desplegable-claro.png' }); await page.keyboard.press('Escape'); await page.setViewportSize({ width: 390, height: 844 }); expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe( true ); expect( await page.locator('.heroe-jukebox').evaluate((h) => h.getBoundingClientRect().height) ).toBe(844); await page.screenshot({ path: 'test-results-publica/jukebox-hero-100vh-movil.png' }); expect( await page.getByRole('contentinfo').evaluate((pie) => pie.getBoundingClientRect().top) ).toBe(844); await page.getByRole('link', { name: 'Configura tu Jukebox', exact: true }).click(); await page.getByRole('link', { name: 'Editar Mañanas', exact: true }).click(); await page.getByRole('button', { name: '+ Añadir contenido', exact: true }).click(); await page.getByRole('button', { name: 'Canciones', exact: true }).click(); expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe( true ); const alturas = await page .locator('.selector .etiqueta') .evaluateAll((ns) => ns.map((n) => n.getBoundingClientRect().height)); expect(Math.max(...alturas)).toBeLessThanOrEqual(26); await page.evaluate(() => window.scrollTo({ top: 0, behavior: 'instant' })); await page.screenshot({ path: 'test-results-publica/jukebox-editor-movil.png', fullPage: true }); const guardarFranja = page.getByRole('button', { name: 'Guardar franja', exact: true }); const claro = await guardarFranja.evaluate((b) => getComputedStyle(b).backgroundColor); await expect(guardarFranja).toHaveCSS('background-image', 'none'); await expect(page.locator('.toggle-rail')).toHaveCount(4); await page.setViewportSize({ width: 1440, height: 1000 }); await page.evaluate(() => window.scrollTo({ top: 0, behavior: 'instant' })); await page.screenshot({ path: 'test-results-publica/jukebox-formulario-claro.png', fullPage: true }); await page.getByRole('button', { name: 'Cambiar al tema oscuro' }).click(); await expect .poll(() => guardarFranja.evaluate((b) => getComputedStyle(b).backgroundColor)) .not.toBe(claro); await page.screenshot({ path: 'test-results-publica/jukebox-formulario-oscuro.png', fullPage: true }); }); test('cambia de franja al navegar, aplica sus modos y reproduce audio real', async ({ page }) => { const endpoint = '/juke-box/' + primero + '/programacion'; const actual = await (await page.request.get(endpoint)).json(); actual.programacion = { zonaHoraria: 'UTC', franjas: [ { id: 'primera', inicio: '09:00', fin: '10:00', orden: 'secuencial', catalogo: false, contenidos: [{ tipo: 'lista', id: listaId }] }, { id: 'segunda', inicio: '10:00', fin: '11:00', orden: 'aleatoria', catalogo: false, contenidos: [{ tipo: 'cancion', id: canciones[1].slug }] } ] }; expect( ( await page.request.put(endpoint, { headers: { origin: new URL(page.url()).origin }, data: actual }) ).status() ).toBe(200); await page.addInitScript(() => { window.Audio = new Proxy(window.Audio, { construct(Original, args) { const audio = Reflect.construct(Original, args) as HTMLAudioElement; audio.dataset.pruebaJukebox = ''; document.body.append(audio); return audio; } }); }); await page.clock.setFixedTime(new Date('2026-09-14T08:59:50Z')); await page.goto('/juke-box?jukebox=' + primero); await page.locator('html[data-hidratado]').waitFor(); await page.getByRole('button', { name: 'Elegir Jukebox', exact: true }).click(); await page.getByRole('button', { name: 'Reproducir Mañanas', exact: true }).click(); await expect(page.locator('.estado-jukebox')).toContainText('Esperando próxima franja'); const respuesta = page.waitForResponse( (r) => r.url().includes('/escucha/' + canciones[0].slug) && [200, 206].includes(r.status()) ); await page.clock.setFixedTime(new Date('2026-09-14T09:00:01Z')); await respuesta; const audio = page.locator('audio[data-prueba-jukebox]'); await expect .poll(() => audio.evaluate( (a: HTMLAudioElement) => !a.paused && a.readyState >= 2 && a.currentTime > 0.05 ) ) .toBe(true); await page.getByRole('banner').getByRole('link', { name: 'Música', exact: true }).click(); await expect(page).toHaveURL('/musica'); await page.clock.setFixedTime(new Date('2026-09-14T10:00:01Z')); await expect(page.locator('.control-jukebox')).toContainText('Franja 2'); await expect(page.getByRole('region', { name: 'Reproductor', exact: true })).toContainText( canciones[1].titulo ); await page.clock.setFixedTime(new Date('2026-09-14T11:00:01Z')); await expect(page.locator('.control-jukebox')).toContainText('Esperando próxima franja'); await expect(page.getByRole('region', { name: 'Reproductor', exact: true })).toBeVisible(); await expect(page.getByRole('group', { name: 'Sonando ahora', exact: true })).toHaveCount(0); await page.getByRole('button', { name: 'Desactivar Jukebox', exact: true }).click(); const control = page.getByRole('group', { name: 'Jukebox', exact: true }); const barra = page.getByRole('region', { name: 'Reproductor', exact: true }); await expect(control).toContainText('Mañanas'); await expect(control).toContainText('Detenido'); await expect(control.getByRole('button', { name: 'Reproducir', exact: true })).toBeVisible(); const guardado = await (await page.request.get(endpoint)).json(); expect(guardado.nombre).toBe(actual.nombre); expect(guardado.programacion).toEqual(actual.programacion); await page.clock.setFixedTime(new Date('2026-09-15T09:15:00Z')); await page.waitForTimeout(1200); await expect(control).toContainText('Detenido'); expect(await audio.evaluate((a: HTMLAudioElement) => a.paused)).toBe(true); await control.getByRole('button', { name: 'Reproducir', exact: true }).click(); await expect(control).toContainText('Franja 1'); await expect .poll(() => audio.evaluate((a: HTMLAudioElement) => !a.paused && a.currentTime > 0.05)) .toBe(true); await control.getByRole('button', { name: 'Detener Jukebox', exact: true }).click(); await expect(control).toContainText('Detenido'); await expect(barra.getByRole('group', { name: 'Sonando ahora', exact: true })).toHaveCount(0); expect(await audio.evaluate((a: HTMLAudioElement) => a.paused)).toBe(true); await barra.screenshot({ path: 'test-results-publica/jukebox-detenido-oscuro.png' }); await page.getByRole('button', { name: 'Cambiar al tema claro', exact: true }).click(); await barra.screenshot({ path: 'test-results-publica/jukebox-detenido-claro.png' }); await page.setViewportSize({ width: 390, height: 844 }); await barra.screenshot({ path: 'test-results-publica/jukebox-detenido-movil.png' }); expect( await page.evaluate(() => document.documentElement.scrollWidth - innerWidth) ).toBeLessThanOrEqual(0); }); test('interrumpe y recupera audio real, agrupa los controles y permite volver desde la configuración', async ({ page }) => { await page.setViewportSize({ width: 1440, height: 1000 }); await page.clock.setFixedTime(new Date('2026-09-14T09:15:00Z')); await page.addInitScript(() => { window.Audio = new Proxy(window.Audio, { construct(Original, args) { const audio = Reflect.construct(Original, args) as HTMLAudioElement; audio.dataset.pruebaJukebox = ''; document.body.append(audio); return audio; } }); }); await page.goto('/juke-box?jukebox=' + primero); await page.locator('html[data-hidratado]').waitFor(); await page.getByRole('button', { name: 'Elegir Jukebox', exact: true }).click(); await page.getByRole('button', { name: 'Reproducir Mañanas', exact: true }).click(); const barra = page.getByRole('region', { name: 'Reproductor', exact: true }); const control = barra.getByRole('group', { name: 'Jukebox', exact: true }); const audio = page.locator('audio[data-prueba-jukebox]'); const suena = async () => { await expect .poll(() => audio.evaluate( (a: HTMLAudioElement) => !a.paused && a.readyState >= 2 && a.currentTime > 0 ) ) .toBe(true); }; await suena(); await page.getByRole('banner').getByRole('link', { name: 'Música', exact: true }).click(); await page.locator(`main a[href="/canciones/${canciones[1].slug}"]`).first().click(); await expect(page).toHaveURL('/canciones/' + canciones[1].slug); await audio.evaluate((a: HTMLAudioElement) => { a.currentTime = 37; }); await page.getByRole('button', { name: 'Escuchar completa', exact: true }).click(); await expect(control).toContainText('Vuelve al terminar'); await expect(barra.getByRole('group', { name: 'Sonando ahora', exact: true })).toContainText( canciones[1].titulo ); await suena(); // El texto y sus acciones permanecen juntos, incluso con mucho ancho disponible. for (const [tema, width] of [ ['oscuro', 1440], ['claro', 1440], ['movil', 390] ] as const) { if (tema === 'claro') await page.getByRole('button', { name: 'Cambiar al tema claro', exact: true }).click(); await page.setViewportSize({ width, height: tema === 'movil' ? 844 : 1000 }); await expect( control.getByRole('button', { name: 'Continuar', exact: true }) ).toBeInViewport(); const medidas = await control.evaluate((grupo) => { const datos = grupo.querySelector('.datos')!.getBoundingClientRect(); const botones = Array.from(grupo.querySelectorAll('button')).map((b) => b.getBoundingClientRect() ); return { separacion: botones[0].left - datos.right, entreBotones: botones[1].left - botones[0].right, desborde: document.documentElement.scrollWidth - innerWidth }; }); expect(medidas.separacion).toBeLessThanOrEqual(12); expect(medidas.separacion).toBeGreaterThanOrEqual(0); expect(medidas.entreBotones).toBeLessThanOrEqual(12); expect(medidas.desborde).toBeLessThanOrEqual(0); await barra.screenshot({ path: `test-results-publica/jukebox-retorno-${tema}.png` }); await expect(barra.getByText('Calidad de escucha', { exact: true })).toHaveCount(0); const tipo = await control.evaluate((grupo) => ({ nombre: getComputedStyle(grupo.querySelector('a')!).fontSize, boton: getComputedStyle(grupo.querySelector('button')!).fontSize, alto: grupo.querySelector('button')!.getBoundingClientRect().height })); expect(tipo).toEqual({ nombre: '16px', boton: '13px', alto: 36 }); const estiloContinuar = await control .getByRole('button', { name: 'Continuar', exact: true }) .evaluate((b) => ({ fondo: getComputedStyle(b).backgroundImage, peso: getComputedStyle(b).fontWeight })); expect(estiloContinuar.fondo).toContain('linear-gradient'); expect(estiloContinuar.peso).toBe('400'); if (tema !== 'movil') { const continuar = control.getByRole('button', { name: 'Continuar', exact: true }); const colorDorado = await continuar.evaluate((b) => getComputedStyle(b).getPropertyValue('--c-accent-text').trim() ); await expect(continuar).toHaveCSS('color', colorDorado); await continuar.hover(); await continuar.evaluate(async (b) => { await Promise.all(b.getAnimations().map((a) => a.finished.catch(() => {}))); }); await expect(continuar).toHaveCSS('color', colorDorado); await barra.screenshot({ path: `test-results-publica/jukebox-continuar-hover-${tema}.png` }); await page.mouse.move(0, 0); await continuar.focus(); await page.keyboard.press('Tab'); await page.keyboard.press('Shift+Tab'); await expect(continuar).toBeFocused(); await expect(continuar).toHaveCSS('color', colorDorado); await barra.screenshot({ path: `test-results-publica/jukebox-continuar-foco-${tema}.png` }); } if (tema === 'movil') { expect((await barra.boundingBox())!.height).toBeLessThanOrEqual(240); await expect( barra.getByRole('button', { name: 'Tema anterior', exact: true }) ).toBeInViewport(); await expect( barra.getByRole('button', { name: 'Tema siguiente', exact: true }) ).toBeInViewport(); } } await page.setViewportSize({ width: 1440, height: 1000 }); await control.getByRole('button', { name: 'Continuar', exact: true }).click(); await suena(); await expect(barra.getByRole('group', { name: 'Sonando ahora', exact: true })).toContainText( canciones[0].titulo ); await expect .poll(() => audio.evaluate((a: HTMLAudioElement) => a.currentTime)) .toBeGreaterThanOrEqual(37); expect(await audio.evaluate((a: HTMLAudioElement) => a.currentTime)).toBeLessThan(45); await expect(control.getByRole('button', { name: 'Continuar', exact: true })).toHaveCount(0); // La pausa desde la ficha y desde la barra devuelve inmediatamente el jukebox. for (const origen of ['ficha', 'barra'] as const) { await page.getByRole('button', { name: 'Escuchar completa', exact: true }).click(); await suena(); await expect(control).toContainText('Vuelve al terminar'); await (origen === 'ficha' ? page.locator('main') : barra) .getByRole('button', { name: 'Pausar', exact: true }) .click(); await expect(control).toContainText('Franja 1'); await suena(); await expect(barra.getByRole('group', { name: 'Sonando ahora', exact: true })).toContainText( canciones[0].titulo ); await expect .poll(() => audio.evaluate((a: HTMLAudioElement) => a.currentTime)) .toBeGreaterThanOrEqual(37); await expect(control.getByRole('button', { name: 'Continuar', exact: true })).toHaveCount(0); } // Si el jukebox ya estaba pausado, una canción manual no levanta esa pausa. await barra.getByRole('button', { name: 'Pausar', exact: true }).click(); await expect(control).toContainText('Franja 1 · En pausa'); await page.getByRole('button', { name: 'Escuchar completa', exact: true }).click(); await suena(); await expect(control).toContainText('Jukebox en pausa'); await page.locator('main').getByRole('button', { name: 'Pausar', exact: true }).click(); await expect(control).toContainText('Franja 1 · En pausa'); expect(await audio.evaluate((a: HTMLAudioElement) => a.paused)).toBe(true); await expect(barra.getByRole('group', { name: 'Sonando ahora', exact: true })).toContainText( canciones[0].titulo ); await barra.getByRole('button', { name: 'Reproducir', exact: true }).click(); await suena(); // Un final real del audio elegido vuelve automáticamente al mismo punto. await page.getByRole('button', { name: 'Escuchar completa', exact: true }).click(); await suena(); await audio.evaluate((a: HTMLAudioElement) => { a.currentTime = a.duration - 0.15; }); await expect(control).toContainText('Franja 1'); await suena(); await expect .poll(() => audio.evaluate((a: HTMLAudioElement) => a.currentTime)) .toBeGreaterThanOrEqual(37); // El reloj cambia de franja durante la elección manual sin cortarla. await page.getByRole('button', { name: 'Escuchar completa', exact: true }).click(); await suena(); await page.clock.setFixedTime(new Date('2026-09-14T10:00:01Z')); await expect(control).toContainText('Vuelve al terminar'); await audio.evaluate((a: HTMLAudioElement) => { a.currentTime = a.duration - 0.15; }); await expect(control).toContainText('Franja 2'); await suena(); expect(await audio.evaluate((a: HTMLAudioElement) => a.currentTime)).toBeLessThan(10); await barra.getByRole('button', { name: 'Pausar', exact: true }).click(); await page.clock.setFixedTime(new Date('2026-09-14T11:00:01Z')); // Dejamos pasar al menos una consulta real del reloj de programación. await page.waitForTimeout(1200); await expect(control).toContainText('Franja 2 · En pausa'); expect(await audio.evaluate((a: HTMLAudioElement) => a.paused)).toBe(true); await barra.screenshot({ path: 'test-results-publica/jukebox-pausa-persistente.png' }); await barra.getByRole('button', { name: 'Reproducir', exact: true }).click(); await expect(control).toContainText('Esperando próxima franja'); await expect(barra.getByRole('group', { name: 'Sonando ahora', exact: true })).toHaveCount(0); await barra.screenshot({ path: 'test-results-publica/jukebox-espera-compacta.png' }); await control.getByRole('link', { name: 'Mañanas', exact: true }).click(); await expect(page).toHaveURL('/juke-box?jukebox=' + primero); await page.getByRole('link', { name: 'Configura tu Jukebox', exact: true }).click(); await expect(page).toHaveURL('/juke-box/configurar'); await expect(page.getByRole('link', { name: 'Volver al Juke-box', exact: true })).toBeVisible(); await page.screenshot({ path: 'test-results-publica/jukebox-coleccion-claro.png' }); await page.getByRole('link', { name: 'Editar Mañanas', exact: true }).click(); await expect(page).toHaveURL('/juke-box/' + primero + '/editar'); await expect( page.getByRole('heading', { name: 'Editar Jukebox 1', exact: true }) ).toBeVisible(); await page.evaluate(() => window.scrollTo(0, 0)); const separacion = await page.evaluate(() => { const cabecera = document.querySelector('header')!.getBoundingClientRect(); const migas = document.querySelector('.migas')!.getBoundingClientRect(); return migas.top - cabecera.bottom; }); expect(separacion).toBeGreaterThanOrEqual(0); expect(separacion).toBeLessThanOrEqual(32); expect((await page.locator('.agenda').boundingBox())!.y).toBeLessThanOrEqual(460); await page.screenshot({ path: 'test-results-publica/jukebox-editor-cabecera.png' }); await page.getByRole('button', { name: 'Cambiar al tema oscuro', exact: true }).click(); await page.screenshot({ path: 'test-results-publica/jukebox-editor-oscuro.png' }); await page.setViewportSize({ width: 390, height: 844 }); await page.locator('.editor').scrollIntoViewIfNeeded(); await page.screenshot({ path: 'test-results-publica/jukebox-editor-movil.png' }); expect( await page.evaluate(() => document.documentElement.scrollWidth - innerWidth) ).toBeLessThanOrEqual(0); await page.setViewportSize({ width: 1440, height: 1000 }); await page.getByRole('link', { name: 'Volver al Juke-box', exact: true }).click(); await expect(page).toHaveURL('/juke-box?jukebox=' + primero); await control.getByRole('button', { name: 'Desactivar Jukebox', exact: true }).click(); await expect(barra).toBeVisible(); await expect(control).toContainText('Detenido'); }); test('guardar una franja persiste sus cambios sin guardar el borrador de otra', async ({ page }) => { await page.goto('/juke-box/' + primero + '/editar'); await page.locator('html[data-hidratado]').waitFor(); await expect(page.getByLabel('Nombre del jukebox')).toHaveValue('Mañanas'); await page.getByLabel('Descripción', { exact: true }).fill('Descripción editada y guardada.'); await page.getByRole('button', { name: 'Aleatoria', exact: true }).click(); await page.locator('.franjas .franja').nth(1).click(); await page.getByLabel('Hasta', { exact: true }).fill('12:00'); await page.getByRole('button', { name: 'Guardar franja', exact: true }).click(); await expect(page.getByRole('status')).toContainText('Franja 2 guardada'); const guardado = await ( await page.request.get('/juke-box/' + primero + '/programacion') ).json(); expect(guardado.nombre).toBe('Mañanas'); expect(guardado.descripcion).toBe('Descripción editada y guardada.'); expect(guardado.programacion.franjas[0].orden).toBe('secuencial'); expect(guardado.programacion.franjas[1].fin).toBe('12:00'); await expect(page.getByText('Cambios sin guardar', { exact: true })).toBeVisible(); // Otra pestaña comprueba lo persistido sin restaurar el borrador del historial. const recargada = await page.context().newPage(); await recargada.goto('/juke-box/' + primero + '/editar'); await recargada.locator('html[data-hidratado]').waitFor(); await expect(recargada.getByLabel('Descripción', { exact: true })).toHaveValue( 'Descripción editada y guardada.' ); await expect( recargada.getByRole('button', { name: 'Secuencial', exact: true }) ).toHaveAttribute('aria-pressed', 'true'); await recargada.close(); }); test('detecta solapamientos y elimina solo el jukebox elegido', async ({ page }) => { await page.getByRole('link', { name: 'Configura tu Jukebox', exact: true }).click(); await page.getByRole('link', { name: 'Editar Mañanas', exact: true }).click(); await page.getByLabel('Hasta', { exact: true }).fill('10:30'); await expect(page.getByRole('alert')).toContainText('solapa'); await expect(page.getByRole('button', { name: 'Guardar jukebox' })).toBeDisabled(); await page.getByRole('button', { name: 'Deshacer cambios', exact: true }).click(); await page.getByRole('link', { name: 'Cancelar', exact: true }).click(); await page.getByRole('link', { name: 'Editar Tardes', exact: true }).click(); await page.getByRole('button', { name: 'Eliminar Jukebox', exact: true }).click(); await page.getByRole('button', { name: 'Sí, eliminar Jukebox', exact: true }).click(); await expect(page).toHaveURL('/juke-box/configurar'); await expect(page.locator('.coleccion .jukebox')).toHaveCount(1); await expect(page.locator('.coleccion .jukebox')).toContainText('Mañanas'); }); });