Portal interno INSTA: base de conocimiento con Magic Login y permisos por rol

Base de conocimiento para el personal del Instituto Santiago Tapia: oferta
educativa (en tarjetas y en tabla), requisitos y titulacion, becas y fechas
de inscripcion, links de clases de Google Meet, preguntas frecuentes,
mensajes listos y datos del instituto.

Acceso con Magic Login: enlace de un solo uso, 15 minutos de vigencia, y en
la base solo se guarda el SHA-256 del token. El canje es atomico, asi que dos
canjes simultaneos del mismo enlace no pueden crear dos sesiones.

Tres roles con 19 permisos. El catalogo lo edita direccion; los links de
clases, direccion y administracion; el resto del equipo consulta. El menu se
pinta con lo que el servidor autoriza y cada endpoint exige la misma clave,
asi que no hay pantallas alcanzables escribiendo la direccion a mano.

Toda edicion pasa por una lista blanca de campos validados y queda en la
bitacora. Un precio vacio no se guarda como cero, un link que no es una
direccion se rechaza, y no se puede marcar una validez oficial como
confirmada sin numero de acuerdo.

Pensado para leerse comodo: texto de 17 px, contraste AA, areas de toque de
44 px, tema claro y bloques plegados en telefono.

La oferta, los costos, las becas, los requisitos y el contacto son los reales
del instituto, tomados del CSV de oferta educativa y de la base de
conocimiento. Los usuarios de acceso y los links de Meet son de demostracion.

Verificado: 103 comprobaciones de API y 51 pruebas en WebKit escritorio y
iPhone 13.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
Uriel Jareth
2026-07-29 23:20:34 -06:00
co-authored by Claude Opus 5
commit 0b9c9c1dc2
34 changed files with 8307 additions and 0 deletions
+561
View File
@@ -0,0 +1,561 @@
// =====================================================================
// Verificación del portal en WebKit
// Comprueba lo que la demo tiene que sostener: Magic Login, permisos,
// que el contenido sea el del archivo de oferta educativa, que dirección
// pueda editar de verdad, y que nada se desborde en ningún tamaño.
// =====================================================================
import { test, expect } from '@playwright/test';
const ROLES = {
director: { email: '[email protected]', nombre: 'Marisol Aguilar', secciones: 10 },
administrativo: { email: '[email protected]', nombre: 'Rubén Cázares', secciones: 9 },
empleado: { email: '[email protected]', nombre: 'Denisse Ochoa', secciones: 8 },
};
const SECCIONES = ['inicio', 'oferta', 'requisitos', 'becas', 'clases', 'preguntas', 'mensajes', 'contacto', 'equipo', 'bitacora'];
/** Entra por la vía real: genera el enlace y lo canjea, como haría el correo. */
async function entrar(page, email) {
const r = await page.request.post('/api/auth/enlace', { data: { email } });
expect(r.ok(), `no se pudo generar el enlace de ${email}`).toBeTruthy();
const { token } = await r.json();
await page.goto(`/acceso/${token}`);
await expect(page.locator('.barra__nav .nav-item').first()).toBeVisible();
}
async function irA(page, seccion) {
await page.goto(`/portal#/${seccion}`);
await expect(page.locator('.cabecera-seccion h1')).toBeVisible();
await expect(page.locator('.cargando')).toHaveCount(0);
// En teléfono los bloques largos llegan plegados. Se abren para que las
// pruebas midan lo mismo en cualquier tamaño; el plegado se prueba aparte.
await page.evaluate(() => {
document.querySelectorAll('details.plegable').forEach((d) => { d.open = true; });
});
}
// ---------------------------------------------------------------------
test.describe('Entrada', () => {
test('ofrece los tres accesos y explica el enlace de un solo uso', async ({ page }) => {
await page.goto('/');
await expect(page.getByRole('heading', { name: /Trabajar y estudiar/ })).toBeVisible();
await expect(page.locator('.tarjeta-rol')).toHaveCount(3);
await expect(page.getByText(/sirve una vez y caduca/i)).toBeVisible();
});
test('un correo no registrado se rechaza con un mensaje claro', async ({ page }) => {
await page.goto('/');
await page.fill('#email', '[email protected]');
await page.click('#enviar');
await expect(page.locator('#error-entrada')).toContainText(/no está dado de alta/i);
});
test('el acceso rápido entra al portal', async ({ page }) => {
await page.goto('/');
await page.locator('.tarjeta-rol', { hasText: 'Empleado' }).click();
await page.waitForURL('**/portal');
await expect(page.locator('.usuario__txt b')).toHaveText(ROLES.empleado.nombre);
});
test('un enlace ya usado no vuelve a servir', async ({ page }) => {
const r = await page.request.post('/api/auth/enlace', { data: { email: ROLES.empleado.email } });
const { token } = await r.json();
await page.goto(`/acceso/${token}`);
await expect(page.locator('.barra__nav .nav-item').first()).toBeVisible();
await page.request.post('/api/auth/salir');
await page.goto(`/acceso/${token}`);
await expect(page.locator('#error-entrada')).toContainText(/ya se usó/i);
});
test('un enlace inventado no entra', async ({ page }) => {
await page.goto('/acceso/token-que-no-existe');
await expect(page.locator('#error-entrada')).toContainText(/no es válido/i);
});
});
// ---------------------------------------------------------------------
test.describe('Permisos', () => {
for (const [clave, rol] of Object.entries(ROLES)) {
test(`${clave} ve exactamente sus ${rol.secciones} secciones`, async ({ page }) => {
await entrar(page, rol.email);
await expect(page.locator('.barra__nav .nav-item[data-seccion]')).toHaveCount(rol.secciones);
await expect(page.locator('.usuario__txt b')).toHaveText(rol.nombre);
});
}
test('el empleado no ve Equipo ni Bitácora', async ({ page }) => {
await entrar(page, ROLES.empleado.email);
for (const oculta of ['equipo', 'bitacora']) {
await expect(page.locator(`.nav-item[data-seccion="${oculta}"]`)).toHaveCount(0);
}
});
test('el administrativo ve Equipo pero no la Bitácora', async ({ page }) => {
await entrar(page, ROLES.administrativo.email);
await expect(page.locator('.nav-item[data-seccion="equipo"]')).toHaveCount(1);
await expect(page.locator('.nav-item[data-seccion="bitacora"]')).toHaveCount(0);
});
test('la API niega lo que el menú oculta', async ({ page }) => {
await entrar(page, ROLES.empleado.email);
for (const ruta of ['/api/equipo', '/api/bitacora']) {
const r = await page.request.get(ruta);
expect(r.status(), `${ruta} debería negarse`).toBe(403);
}
});
test('sin sesión la API responde 401 y el portal manda a la entrada', async ({ page }) => {
await page.goto('/');
expect((await page.request.get('/api/inicio')).status()).toBe(401);
await page.goto('/portal');
await page.waitForURL('**/');
await expect(page.locator('.tarjeta-rol').first()).toBeVisible();
});
test('un intento denegado queda en la bitácora', async ({ page }) => {
await entrar(page, ROLES.empleado.email);
await page.request.get('/api/bitacora'); // 403 a propósito
await page.request.post('/api/auth/salir');
await entrar(page, ROLES.director.email);
const { entradas } = await (await page.request.get('/api/bitacora?limite=60')).json();
expect(entradas.some((e) => e.accion === 'acceso.denegado' && e.resultado === 'denegado')).toBeTruthy();
});
test('solo dirección ve los botones de editar el catálogo', async ({ page }) => {
await entrar(page, ROLES.empleado.email);
await irA(page, 'oferta');
await expect(page.locator('[data-editar-programa]')).toHaveCount(0);
await irA(page, 'clases');
await expect(page.locator('[data-editar-clase]')).toHaveCount(0);
await page.request.post('/api/auth/salir');
await entrar(page, ROLES.administrativo.email);
await irA(page, 'oferta');
await expect(page.locator('[data-editar-programa]')).toHaveCount(0);
await irA(page, 'clases');
await expect(page.locator('[data-editar-clase]').first()).toBeVisible();
await page.request.post('/api/auth/salir');
await entrar(page, ROLES.director.email);
await irA(page, 'oferta');
await expect(page.locator('[data-editar-programa]').first()).toBeVisible();
});
});
// ---------------------------------------------------------------------
test.describe('Oferta educativa', () => {
test.beforeEach(async ({ page }) => { await entrar(page, ROLES.director.email); });
test('muestra los 8 programas en tarjetas', async ({ page }) => {
await irA(page, 'oferta');
await page.locator('[data-vista="tarjetas"]').click();
await expect(page.locator('.programa')).toHaveCount(8);
await expect(page.getByRole('heading', { name: 'Licenciatura en Derecho' })).toBeVisible();
await expect(page.locator('.programa', { hasText: 'Psicología' }).locator('.precio b')).toHaveText('$1,500');
});
test('la vista de tabla trae las columnas del archivo de oferta', async ({ page }) => {
await irA(page, 'oferta');
await page.locator('[data-vista="tabla"]').click();
const tabla = page.locator('.tabla').first();
await expect(tabla.locator('tbody tr')).toHaveCount(8);
// Debajo de 780 px el encabezado se oculta y cada celda lleva su propia
// etiqueta en data-col, así que se comprueba la estructura, no la vista.
for (const col of ['Programa', 'Nivel', 'Duración', 'Inicia', 'Inscripción', 'Mensualidad', 'Horarios', 'Validez oficial']) {
await expect(tabla.locator('thead th', { hasText: col })).toHaveCount(1);
await expect(tabla.locator(`tbody td[data-col="${col}"]`).first()).toBeVisible();
}
await expect(tabla).toContainText('20211510'); // Derecho
await expect(tabla).toContainText('$1,125'); // Bachillerato
});
test('la elección entre tarjetas y tabla se recuerda al volver', async ({ page }) => {
await irA(page, 'oferta');
await page.locator('[data-vista="tabla"]').click();
await expect(page.locator('.tabla').first()).toBeVisible();
await irA(page, 'inicio');
await irA(page, 'oferta');
await expect(page.locator('[data-vista="tabla"]')).toHaveAttribute('aria-pressed', 'true');
await expect(page.locator('.programa')).toHaveCount(0);
});
test('marca los programas sin RVOE confirmado y dice por qué', async ({ page }) => {
await irA(page, 'oferta');
await page.locator('[data-vista="tarjetas"]').click();
const bach = page.locator('.programa', { hasText: 'Bachillerato General' });
await expect(bach).toContainText(/Sin confirmar/i);
await expect(bach).toContainText(/no lo cites/i);
await expect(page.locator('.programa', { hasText: 'Trabajo Social' })).toContainText(/Por revisar/i);
await expect(page.locator('.respuesta')).toContainText(/validez oficial sin confirmar/i);
});
test('dirección edita un programa y el cambio se ve', async ({ page }) => {
await irA(page, 'oferta');
await page.locator('[data-vista="tarjetas"]').click();
await page.locator('.programa', { hasText: 'Licenciatura en Derecho' }).locator('[data-editar-programa]').click();
const dlg = page.locator('dialog.editor');
await expect(dlg).toBeVisible();
await expect(dlg.locator('input[name="nombre"]')).toHaveValue('Licenciatura en Derecho');
await dlg.locator('textarea[name="beneficios"]').fill('Beca del 25% y asesoría para titulación');
await dlg.locator('[data-guardar]').click();
await expect(dlg).toHaveCount(0);
await expect(page.locator('.programa', { hasText: 'Licenciatura en Derecho' })).toContainText('asesoría para titulación');
// Se deja como estaba.
await page.locator('.programa', { hasText: 'Licenciatura en Derecho' }).locator('[data-editar-programa]').click();
await page.locator('dialog.editor textarea[name="beneficios"]').fill('Beca del 25% en inscripción y mensualidad.');
await page.locator('dialog.editor [data-guardar]').click();
await expect(page.locator('dialog.editor')).toHaveCount(0);
});
test('el editor rechaza un dato inválido y lo explica', async ({ page }) => {
await irA(page, 'oferta');
await page.locator('[data-vista="tarjetas"]').click();
await page.locator('[data-editar-programa]').first().click();
const dlg = page.locator('dialog.editor');
await dlg.locator('input[name="nombre"]').fill('');
await dlg.locator('[data-guardar]').click();
await expect(dlg.locator('.editor__error')).toContainText(/Falta llenar/i);
await expect(dlg).toBeVisible();
await dlg.locator('[data-cerrar]').first().click();
await expect(dlg).toHaveCount(0);
});
test('dirección ve los datos pendientes de confirmar', async ({ page }) => {
await irA(page, 'oferta');
const panel = page.locator('.placa', { hasText: 'Datos pendientes de confirmar' });
await expect(panel).toBeVisible();
await expect(panel).toContainText(/RVOE del Bachillerato/i);
});
test('el empleado no ve el panel de datos pendientes', async ({ page }) => {
await page.request.post('/api/auth/salir');
await entrar(page, ROLES.empleado.email);
await irA(page, 'oferta');
await expect(page.locator('.placa', { hasText: 'Datos pendientes de confirmar' })).toHaveCount(0);
});
});
// ---------------------------------------------------------------------
test.describe('Links de clases', () => {
test('se agrupan por programa y traen link para entrar y copiar', async ({ page }) => {
await entrar(page, ROLES.empleado.email);
await irA(page, 'clases');
await expect(page.locator('[data-bloque]')).toHaveCount(8);
const psico = page.locator('[data-bloque]', { hasText: 'Licenciatura en Psicología' });
await expect(psico).toContainText('Psicopatología');
const fila = psico.locator('tbody tr', { hasText: 'Psicopatología' });
await expect(fila.locator('.clase-enlace')).toHaveAttribute('href', /^https:\/\/meet\.google\.com\//);
await expect(fila.locator('.clase-enlace')).toHaveAttribute('target', '_blank');
await expect(fila).toContainText('Martes');
});
test('el filtro por programa deja solo ese bloque', async ({ page }) => {
await entrar(page, ROLES.empleado.email);
await irA(page, 'clases');
await page.locator('#filtros-clases .filtro', { hasText: 'Derecho' }).first().click();
const visibles = await page.locator('[data-bloque]:not([hidden])').count();
expect(visibles).toBe(1);
await page.locator('#filtros-clases .filtro', { hasText: 'Todos los programas' }).click();
await expect(page.locator('[data-bloque]:not([hidden])')).toHaveCount(8);
});
test('administración crea, edita y elimina una clase', async ({ page }, info) => {
// Nombre único por ejecución: así el test no depende de que otro haya
// limpiado, y un alta duplicada se vería de inmediato en el conteo.
const materia = `Materia de prueba ${info.project.name} ${Date.now()}`;
await entrar(page, ROLES.administrativo.email);
await irA(page, 'clases');
await page.locator('.cabecera-seccion__acciones [data-agregar-clase]').click();
let dlg = page.locator('dialog.editor');
await expect(dlg).toBeVisible();
await dlg.locator('input[name="materia"]').fill(materia);
await dlg.locator('input[name="enlace"]').fill('https://meet.google.com/prueba-auto-uno');
await dlg.locator('select[name="dias"]').selectOption('Viernes');
await dlg.locator('[data-guardar]').click();
await expect(dlg).toHaveCount(0);
const fila = page.locator('tbody tr', { hasText: materia });
await expect(fila, 'guardar una vez debe crear exactamente una clase').toHaveCount(1);
await fila.locator('[data-editar-clase]').click();
dlg = page.locator('dialog.editor');
await dlg.locator('input[name="docente"]').fill('Profa. Prueba');
await dlg.locator('[data-guardar]').click();
await expect(dlg).toHaveCount(0);
await expect(page.locator('tbody tr', { hasText: materia })).toContainText('Profa. Prueba');
page.once('dialog', (d) => d.accept());
await page.locator('tbody tr', { hasText: materia }).locator('[data-editar-clase]').click();
await page.locator('dialog.editor [data-borrar]').click();
await expect(page.locator('dialog.editor')).toHaveCount(0);
await expect(page.locator('tbody tr', { hasText: materia })).toHaveCount(0);
// Y se confirma contra la API, no solo contra lo que se pintó.
const { clases } = await (await page.request.get('/api/clases')).json();
expect(clases.filter((c) => c.materia === materia)).toHaveLength(0);
});
test('rechaza un link que no es una dirección', async ({ page }) => {
await entrar(page, ROLES.administrativo.email);
await irA(page, 'clases');
await page.locator('.cabecera-seccion__acciones [data-agregar-clase]').click();
const dlg = page.locator('dialog.editor');
await dlg.locator('input[name="materia"]').fill('Prueba de link malo');
await dlg.locator('input[name="enlace"]').fill('meet.google.com/sin-esquema');
await dlg.locator('[data-guardar]').click();
await expect(dlg.locator('.editor__error')).toContainText(/https:\/\//);
await expect(dlg.locator('input[name="enlace"]')).toHaveAttribute('aria-invalid', 'true');
await dlg.locator('[data-cerrar]').first().click();
});
test('el empleado ve los links pero no puede editarlos', async ({ page }) => {
await entrar(page, ROLES.empleado.email);
await irA(page, 'clases');
await expect(page.locator('.clase-enlace').first()).toBeVisible();
await expect(page.locator('[data-agregar-clase]')).toHaveCount(0);
await expect(page.locator('[data-editar-clase]')).toHaveCount(0);
});
});
// ---------------------------------------------------------------------
test.describe('Contenido', () => {
test.beforeEach(async ({ page }) => { await entrar(page, ROLES.director.email); });
test('los requisitos distinguen las 6 fotos del bachillerato', async ({ page }) => {
await irA(page, 'requisitos');
await expect(page.locator('.placa', { hasText: 'Bachillerato' }).first()).toContainText('6 fotografías');
await expect(page.locator('.placa', { hasText: 'Licenciatura' }).first()).toContainText('4 fotografías');
await expect(page.getByText('Por experiencia profesional')).toBeVisible();
});
test('dirección agrega y quita un requisito', async ({ page }) => {
await irA(page, 'requisitos');
await page.locator('.placa', { hasText: 'Maestría' }).locator('[data-agregar-requisito]').click();
const dlg = page.locator('dialog.editor');
await dlg.locator('input[name="texto"]').fill('Carta de motivos');
await dlg.locator('[data-guardar]').click();
await expect(dlg).toHaveCount(0);
await expect(page.locator('.placa', { hasText: 'Maestría' })).toContainText('Carta de motivos');
page.once('dialog', (d) => d.accept());
await page.locator('.horario', { hasText: 'Carta de motivos' }).locator('[data-editar-requisito]').click();
await page.locator('dialog.editor [data-borrar]').click();
await expect(page.locator('dialog.editor')).toHaveCount(0);
await expect(page.locator('.lienzo__interno')).not.toContainText('Carta de motivos');
});
test('becas explica que los referidos no se juntan y trae la escala', async ({ page }) => {
await irA(page, 'becas');
await expect(page.locator('.respuesta')).toContainText(/no se junta/i);
await expect(page.locator('.tabla', { hasText: 'Referidos' }).locator('tbody tr').last()).toContainText('$0');
await expect(page.locator('.placa', { hasText: 'Cuándo inicia cada programa' })).toContainText('Mayo 2026');
await expect(page.locator('.placa', { hasText: 'Cuándo inicia cada programa' })).toContainText('Enero 2026');
});
test('las preguntas frecuentes buscan de verdad', async ({ page }) => {
await irA(page, 'preguntas');
const total = await page.locator('#resultados details').count();
expect(total).toBeGreaterThan(15);
await page.fill('#buscar', 'referidos');
await expect.poll(async () => page.locator('#resultados details').count()).toBeLessThan(total);
await expect(page.locator('#resultados')).toContainText('20%');
await page.fill('#buscar', 'zzzznoexiste');
await expect(page.locator('.vacio')).toBeVisible();
});
test('los mensajes se filtran por categoría', async ({ page }) => {
await irA(page, 'mensajes');
const total = await page.locator('#lista > .placa').count();
expect(total).toBeGreaterThan(15);
await page.locator('.filtro', { hasText: 'Objeciones' }).click();
const visibles = await page.locator('#lista > .placa:not([hidden])').count();
expect(visibles).toBeGreaterThan(0);
expect(visibles).toBeLessThan(total);
});
test('los datos del instituto traen la dirección correcta', async ({ page }) => {
await irA(page, 'contacto');
await expect(page.locator('.respuesta')).toContainText('1409-d');
await expect(page.locator('.respuesta')).toContainText('58110');
await expect(page.locator('.lienzo__interno')).toContainText(/Trabajar y estudiar es posible/);
await expect(page.locator('.lienzo__interno')).toContainText('443 356 0979');
});
test('dirección edita un dato del instituto', async ({ page }) => {
await irA(page, 'contacto');
const editar = page.getByRole('button', { name: 'Editar Horario de atención' });
await editar.click();
const dlg = page.locator('dialog.editor');
await expect(dlg).toBeVisible();
const antes = await dlg.locator('textarea[name="valor"]').inputValue();
expect(antes).toContain('10:00');
await dlg.locator('textarea[name="valor"]').fill(`${antes} · verificado`);
await dlg.locator('[data-guardar]').click();
await expect(dlg).toHaveCount(0);
await expect(page.locator('.lienzo__interno')).toContainText('· verificado');
// Se deja como estaba.
await page.getByRole('button', { name: 'Editar Horario de atención' }).click();
await page.locator('dialog.editor textarea[name="valor"]').fill(antes);
await page.locator('dialog.editor [data-guardar]').click();
await expect(page.locator('dialog.editor')).toHaveCount(0);
await expect(page.locator('.lienzo__interno')).not.toContainText('· verificado');
});
test('editar dos veces seguidas no abre dos ventanas', async ({ page }) => {
await irA(page, 'contacto');
for (let i = 0; i < 3; i++) {
await page.getByRole('button', { name: 'Editar WhatsApp' }).click();
await expect(page.locator('dialog.editor')).toHaveCount(1);
await page.locator('dialog.editor [data-guardar]').click();
await expect(page.locator('dialog.editor')).toHaveCount(0);
}
});
test('el equipo muestra la matriz de quién ve qué', async ({ page }) => {
await irA(page, 'equipo');
await expect(page.locator('.matriz')).toBeVisible();
const filaBitacora = page.locator('.matriz tbody tr', { hasText: 'Bitácora' });
await expect(filaBitacora.locator('td').nth(1)).toContainText('●'); // dirección
await expect(filaBitacora.locator('td').nth(2)).toContainText('○'); // administración
await expect(filaBitacora.locator('td').nth(3)).toContainText('○'); // empleado
});
});
// ---------------------------------------------------------------------
test.describe('Legibilidad y responsividad', () => {
test('el texto base no baja de 17 px', async ({ page }) => {
await entrar(page, ROLES.director.email);
const tam = await page.evaluate(() => parseFloat(getComputedStyle(document.body).fontSize));
expect(tam).toBeGreaterThanOrEqual(17);
});
test('el portal siempre usa tema claro, incluso si el sistema pide oscuro', async ({ page }) => {
await page.emulateMedia({ colorScheme: 'dark' });
await entrar(page, ROLES.director.email);
const fondo = await page.evaluate(() => getComputedStyle(document.body).backgroundColor);
expect(fondo).toBe('rgb(250, 251, 252)');
const texto = await page.evaluate(() => getComputedStyle(document.body).color);
expect(texto).toBe('rgb(20, 24, 29)');
});
test('los botones y campos llegan al alto mínimo para tocarse', async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
await entrar(page, ROLES.director.email);
await irA(page, 'clases');
for (const sel of ['.nav-item', '.boton', '.clase-enlace']) {
const caja = await page.locator(sel).first().boundingBox();
expect(caja.height, `${sel} es demasiado bajo para tocarse`).toBeGreaterThanOrEqual(38);
}
});
for (const ancho of [390, 768, 1024, 1440]) {
test(`ninguna sección se desborda a ${ancho}px`, async ({ page }) => {
await page.setViewportSize({ width: ancho, height: 900 });
await entrar(page, ROLES.director.email);
for (const s of SECCIONES) {
await irA(page, s);
const desborde = await page.evaluate(() =>
document.documentElement.scrollWidth - document.documentElement.clientWidth);
expect(desborde, `${s} se desborda horizontalmente`).toBeLessThanOrEqual(1);
}
});
}
test('la ventana de edición cabe en un teléfono', async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
await entrar(page, ROLES.director.email);
await irA(page, 'clases');
await page.locator('.cabecera-seccion__acciones [data-agregar-clase]').click();
const dlg = page.locator('dialog.editor');
await expect(dlg).toBeVisible();
const caja = await dlg.boundingBox();
expect(caja.width).toBeLessThanOrEqual(390);
expect(caja.x).toBeGreaterThanOrEqual(0);
await dlg.locator('[data-cerrar]').first().click();
await expect(dlg).toHaveCount(0);
});
test('en móvil el menú es un cajón que abre, navega y cierra', async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
await entrar(page, ROLES.director.email);
await expect(page.locator('.tope')).toBeVisible();
const barra = page.locator('#barra');
await page.click('#abrir-menu');
await expect(barra).toHaveAttribute('data-abierta', '1');
await page.locator('.nav-item[data-seccion="oferta"]').click();
await expect(page.locator('.cabecera-seccion h1')).toHaveText('Oferta educativa');
await expect(barra).toHaveAttribute('data-abierta', '0');
});
test('la sección activa se anuncia con aria-current', async ({ page }) => {
await entrar(page, ROLES.director.email);
await irA(page, 'clases');
await expect(page.locator('.nav-item[data-seccion="clases"]')).toHaveAttribute('aria-current', 'page');
await expect(page.locator('.nav-item[aria-current="page"]')).toHaveCount(1);
});
test('se puede navegar con el teclado desde la entrada', async ({ page }) => {
await page.goto('/');
await page.keyboard.press('Tab');
await page.keyboard.press('Tab');
const enfocado = await page.evaluate(() => document.activeElement?.className ?? '');
expect(enfocado).toContain('tarjeta-rol');
await page.keyboard.press('Enter');
await page.waitForURL('**/portal');
});
test('Escape cierra la ventana de edición sin guardar', async ({ page }) => {
await entrar(page, ROLES.director.email);
await irA(page, 'clases');
await page.locator('.cabecera-seccion__acciones [data-agregar-clase]').click();
await expect(page.locator('dialog.editor')).toBeVisible();
await page.keyboard.press('Escape');
await expect(page.locator('dialog.editor')).toHaveCount(0);
await expect(page.locator('#barra')).not.toHaveAttribute('data-abierta', '1');
});
test('en teléfono los bloques largos llegan plegados y en escritorio abiertos', async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
await entrar(page, ROLES.director.email);
await page.goto('/portal#/clases');
await expect(page.locator('.cabecera-seccion h1')).toBeVisible();
const plegables = page.locator('details.plegable');
await expect(plegables.first()).toBeVisible();
expect(await plegables.first().evaluate((d) => d.open)).toBe(false);
// El resumen sí se ve y al pulsarlo se abre.
await plegables.first().locator('summary').click();
expect(await plegables.first().evaluate((d) => d.open)).toBe(true);
await page.setViewportSize({ width: 1440, height: 900 });
await page.goto('/portal#/clases');
await expect(page.locator('.cabecera-seccion h1')).toBeVisible();
expect(await page.locator('details.plegable').first().evaluate((d) => d.open)).toBe(true);
await expect(page.locator('details.plegable summary').first()).toBeHidden();
});
test('ninguna sección se vuelve un scroll interminable en teléfono', async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
await entrar(page, ROLES.director.email);
for (const s of SECCIONES) {
await page.goto(`/portal#/${s}`);
await expect(page.locator('.cabecera-seccion h1')).toBeVisible();
await expect(page.locator('.cargando')).toHaveCount(0);
const alto = await page.evaluate(() => document.documentElement.scrollHeight);
expect(alto, `${s} mide ${alto} px de alto en teléfono`).toBeLessThan(12000);
}
});
test('el logo del instituto se carga', async ({ page }) => {
await entrar(page, ROLES.director.email);
const ok = await page.locator('.barra__cabeza img').evaluate((img) => img.complete && img.naturalWidth > 0);
expect(ok, 'el logo no se cargó').toBeTruthy();
});
});