import { expect, test } from '@playwright/test'; test.describe('Taller de letras', () => { test('la portada lleva a los principios y a los géneros', async ({ page }) => { await page.goto('/taller'); await expect(page.getByRole('heading', { level: 1 })).toHaveText('Taller de letras'); await expect(page.getByRole('heading', { level: 2, name: 'Los principios' })).toBeVisible(); // Las fichas se fueron a su propia sección; aquí queda el paso a ella. await expect(page.getByRole('heading', { level: 2, name: 'Fichas de género' })).toBeVisible(); await page.getByRole('link', { name: 'Ver los géneros' }).click(); await expect(page).toHaveURL('/generos'); }); test('se llega a un principio y se encadena con el siguiente', async ({ page }) => { await page.goto('/taller'); // El listado de la página, no el índice lateral, que repite los enlaces. await page .locator('section.bloque') .getByRole('link', { name: /Lo que hace que una letra funcione/ }) .click(); await expect(page).toHaveURL('/taller/lo-que-hace-que-una-letra-funcione'); await expect(page.getByRole('heading', { level: 1 })).toHaveText( 'Lo que hace que una letra funcione' ); // Los capítulos están pensados para seguirse en orden. await page.getByRole('link', { name: /Siguiente/ }).click(); await expect(page.getByRole('heading', { level: 1 })).toHaveText('La imagen concreta'); }); test('una ficha de género trae sus datos de creación', async ({ page }) => { await page.goto('/generos/bolero'); await expect(page.getByRole('heading', { level: 1 })).toHaveText('Bolero'); await expect(page.getByText('Santiago de Cuba, 1883')).toBeVisible(); /* * Los datos comparables van en una franja a lo ancho, no en una barra * lateral: el compás y la métrica de un género se leen juntos o no se leen. * Antes era una lista de definiciones en una columna estrecha a la derecha, * con los datos en letra pequeña y el texto escorado. */ const franja = page.locator('.datos .dato'); await expect(franja).toHaveCount(4); await expect(franja.filter({ hasText: 'Compás' })).toContainText('2/4'); await expect(franja.filter({ hasText: 'Metro' })).toContainText('Octosílabo'); await expect(page.locator('aside.lateral')).toHaveCount(0); await expect(page.getByRole('heading', { level: 2, name: 'De qué se habla' })).toBeVisible(); await expect( page.getByRole('heading', { level: 2, name: 'Rasgos de escritura' }) ).toBeVisible(); await expect( page.getByRole('heading', { level: 2, name: 'Estructura de las canciones' }) ).toBeVisible(); }); test('la franja de datos ocupa el ancho, y el texto su medida de lectura', async ({ page }) => { // Es la diferencia con la barra lateral de antes, y lo que hace que los // datos se puedan comparar de un vistazo. await page.setViewportSize({ width: 1440, height: 900 }); await page.goto('/generos/bolero'); const datos = (await page.locator('.datos').boundingBox())!; const texto = (await page.locator('.lectura .texto').boundingBox())!; expect(datos.width).toBeGreaterThan(texto.width * 1.4); expect(Math.abs(datos.x - texto.x)).toBeLessThan(2); }); test('nada se queda corto por la derecha', async ({ page }) => { /* * La queja recurrente, convertida en prueba: la franja de arriba, el índice * del capítulo y los bloques de abajo tienen que terminar en el mismo * borde. Un texto no puede llenar novecientos píxeles —a menos de letra de * veinticuatro puntos salen ciento veinte caracteres por línea—, así que lo * que cubre ese ancho es lo que va a su lado, no el texto estirado. */ await page.setViewportSize({ width: 1440, height: 900 }); await page.goto('/generos/bolero'); const derecha = async (selector: string) => { const caja = (await page.locator(selector).first().boundingBox())!; return caja.x + caja.width; }; const franja = await derecha('.datos'); const indice = await derecha('.apartados'); const bloque = await derecha('.bloque'); expect(Math.abs(franja - indice)).toBeLessThan(2); expect(Math.abs(franja - bloque)).toBeLessThan(2); }); test('la portada del taller también lleva el índice al lado', async ({ page }) => { // El índice está en el layout de la sección, así que acompaña a todas sus // páginas, no solo a los capítulos. await page.goto('/taller'); const indice = page.getByRole('navigation', { name: 'Contenido del taller' }); await expect(indice).toBeVisible(); await expect(indice.getByRole('link', { name: 'El estribillo' })).toBeVisible(); // En la portada no hay capítulo abierto que marcar. await expect(indice.locator('a[aria-current="page"]')).toHaveCount(0); }); test('cada capítulo lleva el índice del taller al lado', async ({ page }) => { await page.goto('/taller/el-estribillo'); const indice = page.getByRole('navigation', { name: 'Contenido del taller' }); await expect(indice).toBeVisible(); // Marca dónde estás dentro de la estructura. await expect(indice.getByRole('link', { name: 'El estribillo' })).toHaveAttribute( 'aria-current', 'page' ); // Y enseña el temario entero, no solo el capítulo actual. await expect(indice.getByRole('link', { name: 'La imagen concreta' })).toBeVisible(); }); test('un principio no enseña ficha de creación', async ({ page }) => { // La ficha es de los estilos; en un principio serían bloques vacíos. await page.goto('/taller/el-estribillo'); await expect(page.locator('.datos')).toHaveCount(0); await expect(page.locator('.instrumentos')).toHaveCount(0); await expect(page.getByRole('heading', { level: 2, name: 'Linaje' })).toHaveCount(0); }); /* * Lo que sigue solo existe desde que el taller lee de la base: en un archivo * de Markdown no cabía la instrumentación con el peso de cada instrumento, * ni los letristas con sus fechas, ni de qué géneros viene este. */ test('la ficha dice qué país es la cuna y cuáles lo adoptaron', async ({ page }) => { await page.goto('/generos/bolero'); const paises = page.locator('.paises li'); await expect(paises).toHaveCount(6); // La cuna va primero aunque alfabéticamente no le toque. await expect(paises.first()).toContainText('Cuba'); await expect(paises.first()).toContainText('cuna'); await expect(paises.filter({ hasText: 'México' })).not.toContainText('cuna'); }); test('la instrumentación distingue lo esencial de lo ocasional', async ({ page }) => { await page.goto('/generos/bolero'); const filas = page.locator('.instrumentos li'); await expect(filas.filter({ hasText: 'Guitarra' })).toContainText('esencial'); await expect(filas.filter({ hasText: 'Piano' })).toContainText('ocasional'); // El papel es lo que hace útil el dato: no basta con enumerar instrumentos. await expect(filas.filter({ hasText: 'Requinto' })).toContainText('adornos'); }); test('los letristas traen país, años y papel', async ({ page }) => { await page.goto('/generos/tango'); const gardel = page.locator('.personas li').filter({ hasText: 'Carlos Gardel' }); await expect(gardel).toContainText('Argentina · 1890–1935'); // «interprete» se guarda sin tilde, pero se lee con ella. await expect(gardel).toContainText('intérprete'); }); test('las obras de referencia se fechan y se acreditan', async ({ page }) => { await page.goto('/generos/bolero'); const primera = page.locator('.obras li').first(); await expect(primera).toContainText('1883'); await expect(primera).toContainText('Tristezas'); await expect(primera).toContainText('José «Pepe» Sánchez'); // Un fragmento de letra ajena solo se sostiene si se dice por qué está. await expect(page.locator('.obras')).toHaveCount(1); await expect(page.getByText('derecho de cita')).toBeVisible(); }); test('el linaje no enlaza a un género sin capítulo', async ({ page }) => { /* * El bolero-son existe en la base porque el bolero y el son confluyen en * él, pero nadie ha escrito su ficha. Enlazarlo mandaba a un 404. */ await page.goto('/generos/bolero'); const linaje = page.locator('.rama'); await expect(linaje.getByRole('link', { name: 'Bachata' })).toBeVisible(); await expect(linaje.getByText('Bolero-son')).toBeVisible(); await expect(linaje.getByRole('link', { name: 'Bolero-son' })).toHaveCount(0); }); test('la ficha enseña versos, no solo los describe', async ({ page }) => { /* * Se podía leer que el bolero va en octosílabo y endecasílabo alternados sin * ver nunca uno. Un taller que describe la métrica y no la enseña está * pidiendo que se la imaginen. */ await page.goto('/generos/bolero'); const ejemplos = page.locator('.ejemplo'); await expect(ejemplos).toHaveCount(2); const primero = ejemplos.first(); await expect(primero).toContainText('Dejaste la taza a medias'); await expect(primero).toContainText('métrica'); // Cuatro versos, cada uno con su cuenta. await expect(primero.locator('.estrofa li')).toHaveCount(4); await expect(primero.locator('.cuenta').first()).toHaveText('8'); }); test('la escansión marca la sinalefa y el acento', async ({ page }) => { await page.goto('/generos/bolero'); // Dos palabras en una casilla, unidas por el arco: al cantarlas se dice // una sola sílaba. Es lo que en un texto corrido no se ve. const casillas = page.locator('.ejemplo').first().locator('.escansion').first(); await expect(casillas.locator('.silaba')).toHaveCount(8); await expect(casillas).toContainText('za⁀a'); // Y el octosílabo lleva siempre acento en la séptima. await expect(casillas.locator('.silaba.tonica').last()).toHaveText('me'); }); test('el octosílabo agudo mide ocho con siete casillas', async ({ page }) => { // La ley del acento final, que es la primera regla que hay que tener // automatizada para escribir sobre clave. await page.goto('/generos/son-cubano'); const segundo = page.locator('.estrofa li').nth(1); await expect(segundo.locator('.verso')).toHaveText('yo respondo con el son;'); await expect(segundo.locator('.silaba')).toHaveCount(7); await expect(segundo.locator('.cuenta')).toHaveText('8'); }); test('los ejemplos van antes que la documentación del género', async ({ page }) => { // Es un taller: quien abre esta página viene a escribir una letra, no a // documentarse. Primero lo que se usa para escribir. await page.goto('/generos/bolero'); const titulos = await page.getByRole('heading', { level: 2 }).allTextContents(); expect(titulos.indexOf('Ejemplos de versos')).toBeLessThan(titulos.indexOf('Instrumentación')); expect(titulos.indexOf('Rasgos de escritura')).toBeLessThan( titulos.indexOf('Obras de referencia') ); }); test('cada obra de referencia dice dónde escucharla', async ({ page }) => { await page.goto('/generos/tango'); const cambalache = page.locator('.obras li').filter({ hasText: 'Cambalache' }); const spotify = cambalache.getByRole('link', { name: 'Spotify' }); await expect(spotify).toBeVisible(); await expect(cambalache.getByRole('link', { name: 'YouTube' })).toBeVisible(); // En otra pestaña: irse del taller a mitad de una ficha es perder el sitio. await expect(spotify).toHaveAttribute('target', '_blank'); await expect(spotify).toHaveAttribute('rel', /noopener/); }); test('se avisa de lo que aún no ha revisado nadie', async ({ page }) => { // La musicología la reunió un modelo y la ficha se publica con el nombre // del titular. Callarlo sería lo cómodo. await page.goto('/generos/bolero'); const aviso = page.locator('.sin-revisar'); await expect(aviso).toContainText('pendiente de revisión'); // Y enumera todo lo que sale de ahí, ejemplos de versos incluidos. await expect(aviso).toContainText('versos'); }); /* --- El glosario ------------------------------------------------------- */ test('los términos del glosario salen marcados en el texto', async ({ page }) => { // Lo que se pidió: «cada vez que aparezca un término del glosario en el // texto, estará marcado para visualizarse con un popup». await page.goto('/generos/son-cubano'); const marcados = page.locator('.texto .termino'); await expect(marcados.first()).toBeVisible(); // Cada término una sola vez por apartado, no en cada aparición: un texto // con nueve «compás» subrayados no hay quien lo lea. const slugs = await marcados.evaluateAll((bs) => bs.map((b) => (b as HTMLElement).dataset.termino) ); expect(new Set(slugs).size).toBeGreaterThan(1); }); test('al pulsar un término se abre su definición', async ({ page }) => { await page.goto('/generos/son-cubano'); const montuno = page.locator('.texto .termino[data-termino="montuno"]').first(); await montuno.click(); const globo = page.getByRole('dialog', { name: /Definición de montuno/ }); await expect(globo).toBeVisible(); await expect(globo).toContainText('llamada y respuesta'); // Y se cierra con Escape, devolviendo el foco a donde estaba. await page.keyboard.press('Escape'); await expect(globo).toBeHidden(); await expect(montuno).toBeFocused(); }); test('el globo no tapa el término que se acaba de pulsar', async ({ page }) => { await page.setViewportSize({ width: 1280, height: 900 }); await page.goto('/generos/son-cubano'); const termino = page.locator('.texto .termino').first(); await termino.click(); const suyo = (await termino.boundingBox())!; const globo = (await page.locator('.globo').boundingBox())!; const seSolapan = globo.y < suyo.y + suyo.height && suyo.y < globo.y + globo.height; expect(seSolapan).toBe(false); }); test('no se marca dentro de un encabezado', async ({ page }) => { // Ensuciaría el índice del capítulo, que sale de los encabezados. await page.goto('/generos/son-cubano'); await expect(page.locator('.texto h2 .termino')).toHaveCount(0); }); test('el glosario se busca y sus acepciones van numeradas', async ({ page }) => { await page.goto('/generos/glosario'); await expect(page.getByRole('heading', { level: 1 })).toHaveText('Glosario'); // El buscador acota; con doscientas entradas es la única forma de llegar. await page.getByLabel('Buscar un término').fill('clave'); await expect(page.locator('.palabra')).toHaveCount(1); /* * «Clave» tiene dos acepciones: patrón rítmico e instrumento. Con una sola * definición por palabra, una de las dos sería falsa. */ await page.locator('[data-termino="clave"]').click(); const globo = page.getByRole('dialog', { name: /Definición de clave/ }); await expect(globo.locator('.acepciones > li')).toHaveCount(2); await expect(globo).toContainText('patrón rítmico'); await expect(globo).toContainText('percusión'); }); test('se llega al glosario desde el índice de géneros', async ({ page }) => { await page.goto('/generos/bolero'); await page .getByRole('navigation', { name: 'Contenido de géneros' }) .getByRole('link', { name: 'Glosario' }) .click(); await expect(page).toHaveURL('/generos/glosario'); }); test('las direcciones viejas de las fichas siguen llevando a alguna parte', async ({ page }) => { // Un enlace compartido no puede empezar a dar 404 porque hayamos // reordenado el menú. await page.goto('/generos/bolero'); await expect(page).toHaveURL('/generos/bolero'); await page.goto('/taller/glosario'); await expect(page).toHaveURL('/generos/glosario'); }); test('el taller está en la navegación principal', async ({ page }) => { await page.goto('/'); await page .getByRole('navigation', { name: 'Principal' }) .getByRole('link', { name: 'Taller' }) .click(); await expect(page).toHaveURL('/taller'); }); test('géneros solo se ofrece dentro de la página del taller', async ({ page }) => { await page.goto('/'); const nav = page.getByRole('navigation', { name: 'Principal' }); await expect(nav.getByRole('link', { name: 'Géneros', exact: true })).toHaveCount(0); await nav.getByRole('link', { name: 'Taller de letra', exact: true }).click(); await expect(page).toHaveURL('/taller'); const generos = page.getByRole('link', { name: 'Ver los géneros', exact: true }); await expect(generos).toBeVisible(); await generos.click(); await expect(page).toHaveURL('/generos'); }); }); test.describe('Tema claro y oscuro', () => { test('sin elegir nada no hay atributo, y manda el marino', async ({ browser }) => { // Aunque el sistema pida el claro. El color es de la marca y no lo decide // una preferencia que se configuró pensando en otra cosa. const claro = await browser.newContext({ colorScheme: 'light' }); const pagina = await claro.newPage(); await pagina.goto('/taller'); await expect(pagina.locator('html')).not.toHaveAttribute('data-tema', /.+/); await expect(pagina.getByRole('button', { name: 'Cambiar al tema claro' })).toBeVisible(); await claro.close(); }); test('la elección manual manda y se recuerda', async ({ browser }) => { const claro = await browser.newContext({ colorScheme: 'light' }); const pagina = await claro.newPage(); await pagina.goto('/taller'); await pagina.getByRole('button', { name: 'Cambiar al tema claro' }).click(); await expect(pagina.locator('html')).toHaveAttribute('data-tema', 'claro'); // Sobrevive a la recarga y, sobre todo, se aplica antes de pintar: lo hace // un script en línea, no la hidratación. await pagina.reload(); await expect(pagina.locator('html')).toHaveAttribute('data-tema', 'claro'); // Y se puede volver. await pagina.getByRole('button', { name: 'Cambiar al tema oscuro' }).click(); await expect(pagina.locator('html')).toHaveAttribute('data-tema', 'oscuro'); await claro.close(); }); });