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]>
562 lines
27 KiB
JavaScript
562 lines
27 KiB
JavaScript
// =====================================================================
|
|
// 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();
|
|
});
|
|
});
|