From e29752644ad879f8adfc5c15e606299c5b05bbe0 Mon Sep 17 00:00:00 2001 From: markosbenitez Date: Mon, 29 Jun 2026 09:03:49 -0300 Subject: [PATCH] =?UTF-8?q?fix(wizard):=20tests=20e2e=20sprint6=20?= =?UTF-8?q?=E2=80=94=20cache=20geo,=20usabilidad,=20draft?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- tests/e2e/sprint6-usabilidad.spec.ts | 211 +++++++++++++++++++++++++++ 1 file changed, 211 insertions(+) create mode 100644 tests/e2e/sprint6-usabilidad.spec.ts diff --git a/tests/e2e/sprint6-usabilidad.spec.ts b/tests/e2e/sprint6-usabilidad.spec.ts new file mode 100644 index 0000000..7699281 --- /dev/null +++ b/tests/e2e/sprint6-usabilidad.spec.ts @@ -0,0 +1,211 @@ +import { test, expect, type BrowserContext, type Page } from '@playwright/test' + +const DEMO_SURVEY_ID = '30000000-0000-0000-0000-000000000001' +const SURVEY_URL = `/?s=${DEMO_SURVEY_ID}` + +// Devuelve el código de la pregunta visible (ej: '2.3'), o null si estamos en una sección. +async function currentCode(page: Page): Promise { + const el = page.locator('.wizard-prompt-code') + if (await el.count() === 0) return null + return (await el.textContent())?.trim() ?? null +} + +// Navega hacia adelante hasta que la pregunta con `targetCode` sea visible. +// Maneja preguntas required (Q1.1) y screens de sección intermedias. +async function navigateUntil(page: Page, targetCode: string, maxSteps = 30): Promise { + for (let i = 0; i < maxSteps; i++) { + if (await currentCode(page) === targetCode) return + // Si hay error de validación (required sin respuesta), responder primero + if (await page.locator('.field-error').count() > 0) { + await page.locator('.option-label').first().click() + await page.waitForTimeout(150) + continue + } + await page.getByRole('button', { name: 'Siguiente' }).click() + // Esperar a que React aplique el state update y re-renderice + await page.waitForTimeout(150) + } + const code = await currentCode(page) + if (code !== targetCode) throw new Error(`navigateUntil: llegué a ${code}, esperaba ${targetCode}`) +} + +// Acepta el consent y espera a que el wizard esté visible. +async function acceptConsent(page: Page): Promise { + await page.check('#consent-operativo') + await page.getByRole('button', { name: 'Comenzar la encuesta' }).click() + await page.locator('.wizard-shell').waitFor() +} + +test.describe.serial('Sprint 6 — usabilidad y cache geo', () => { + let context: BrowserContext + let page: Page + const geoRequests: string[] = [] + + test.beforeAll(async ({ browser }) => { + context = await browser.newContext() + page = await context.newPage() + // Interceptar requests geo antes de navegar (para Test 1) + page.on('request', (req) => { + if (req.url().includes('geo_paraguay_full.json')) geoRequests.push(req.url()) + }) + }) + + test.afterAll(async () => { + await context.close() + }) + + // ───────────────────────────────────────────────────────────── + // Test 1 — Cache geo: un solo fetch para barrio y ciudad + // ───────────────────────────────────────────────────────────── + test('1. cache geo: un solo fetch para barrio y ciudad', async () => { + await page.goto(SURVEY_URL) + await acceptConsent(page) + + // Navegar hasta Q2.2 (CiudadSelectField monta → geo request se dispara aquí) + await navigateUntil(page, '2.2') + expect(await currentCode(page)).toBe('2.2') + + // Avanzar a Q2.3 (BarrioAutocompleteField monta → debe usar cache, no nueva request) + await page.getByRole('button', { name: 'Siguiente' }).click() + await page.waitForTimeout(200) + expect(await currentCode(page)).toBe('2.3') + + // Solo debe haber habido 1 request al JSON de geo (Q2.2 lo cargó, Q2.3 usó el cache) + expect(geoRequests.length).toBe(1) + }) + + // ───────────────────────────────────────────────────────────── + // Test 2 — Estado loading del campo geo + // ───────────────────────────────────────────────────────────── + test('2. estado loading del campo geo', async () => { + // Estamos en Q2.3 (del test anterior). Volver a Q2.2 para observar el estado del campo. + await page.getByRole('button', { name: 'Anterior' }).click() + await page.waitForTimeout(200) + expect(await currentCode(page)).toBe('2.2') + + // CiudadSelectField: input con clase .text-short-input + const input = page.locator('input.text-short-input').first() + await expect(input).toBeVisible() + + const isDisabled = await input.isDisabled() + if (isDisabled) { + // Estado loading capturado — verificar placeholder correcto + const placeholder = await input.getAttribute('placeholder') + expect(placeholder).toBe('Cargando opciones...') + } + // En cualquier caso, el campo debe estar (o volverse) habilitado + await expect(input).toBeEnabled({ timeout: 5000 }) + + // Volver a Q2.3 para el siguiente test + await page.getByRole('button', { name: 'Siguiente' }).click() + await page.waitForTimeout(200) + expect(await currentCode(page)).toBe('2.3') + }) + + // ───────────────────────────────────────────────────────────── + // Test 3 — Texto libre funciona en campo barrio + // ───────────────────────────────────────────────────────────── + test('3. texto libre funciona en campo barrio', async () => { + // Estamos en Q2.3 (BarrioAutocompleteField) + expect(await currentCode(page)).toBe('2.3') + + const barrioCampo = page.locator('input.text-short-input') + await expect(barrioCampo).toBeVisible() + await expect(barrioCampo).toBeEnabled() + + // Escribir un barrio que no existe en el JSON (texto libre) + await barrioCampo.fill('Barrio Inexistente XYZ') + await expect(barrioCampo).toHaveValue('Barrio Inexistente XYZ') + + // Avanzar — campo es opcional, texto libre debe aceptarse sin error + await page.getByRole('button', { name: 'Siguiente' }).click() + await page.waitForTimeout(200) + await expect(page.locator('.field-error')).toHaveCount(0) + await expect(page.locator('.wizard-shell')).toBeVisible() + }) + + // ───────────────────────────────────────────────────────────── + // Test 4 — Draft persiste tras recarga + // ───────────────────────────────────────────────────────────── + test('4. draft persiste tras recarga', async () => { + // Volver a Q2.3 para sobreescribir el draft con un valor reconocible + await page.getByRole('button', { name: 'Anterior' }).click() + await page.waitForTimeout(200) + expect(await currentCode(page)).toBe('2.3') + + const barrioCampo = page.locator('input.text-short-input') + await barrioCampo.fill('Barrio Test Draft') + await expect(barrioCampo).toHaveValue('Barrio Test Draft') + + // Avanzar — handleNext guarda el draft inmediatamente (no debounce) + await page.getByRole('button', { name: 'Siguiente' }).click() + await page.waitForTimeout(300) + + // Verificar que el draft fue guardado antes de recargar + const draftRaw = await page.evaluate( + (id) => localStorage.getItem(`survey_draft_${id}`), + DEMO_SURVEY_ID, + ) + expect(draftRaw, 'draft debe existir en localStorage antes del reload').not.toBeNull() + expect(draftRaw, 'draft debe contener el valor del barrio').toContain('Barrio Test Draft') + + // Recargar + await page.reload() + + // Verificar que el draft sobrevive el reload + const draftAfterReload = await page.evaluate( + (id) => localStorage.getItem(`survey_draft_${id}`), + DEMO_SURVEY_ID, + ) + expect(draftAfterReload, 'draft debe persistir después del reload').not.toBeNull() + + await acceptConsent(page) + + // El indicador de restauración es el valor de Q2.3 — navegamos hasta ahí + // (navigateUntil funciona desde cualquier pantalla de inicio, incluyendo S1) + await navigateUntil(page, '2.3') + await expect(page.locator('input.text-short-input')).toHaveValue('Barrio Test Draft') + }) + + // ───────────────────────────────────────────────────────────── + // Test 5 — Submit con error de red muestra estado error del wizard + // ───────────────────────────────────────────────────────────── + test('5. submit con error de red muestra estado error del wizard', async ({ browser }) => { + const ctx5 = await browser.newContext() + const page5 = await ctx5.newPage() + + // Pre-rellenar Q1.1 (única pregunta required) para no bloquearnos al avanzar + await page5.goto(SURVEY_URL) + await page5.evaluate((id) => { + localStorage.setItem(`survey_draft_${id}`, JSON.stringify({ '1.1:default': 'true' })) + }, DEMO_SURVEY_ID) + + await acceptConsent(page5) + + // Navegar hasta el end screen: todas las preguntas restantes son opcionales + let iters = 0 + while ( + (await page5.locator('button', { hasText: 'Enviar encuesta' }).count()) === 0 && + iters < 70 + ) { + await page5.getByRole('button', { name: 'Siguiente' }).click() + await page5.waitForTimeout(100) + iters++ + } + + await expect(page5.getByRole('button', { name: 'Enviar encuesta' })).toBeVisible() + + // Poner el contexto offline para simular error de red en el submit + await page5.context().setOffline(true) + + await page5.getByRole('button', { name: 'Enviar encuesta' }).click() + + // El wizard debe mostrar su propio estado de error (no un React error boundary) + await expect(page5.locator('.error-container')).toBeVisible({ timeout: 15000 }) + await expect(page5.getByText(/Application error/i)).toHaveCount(0) + await expect(page5.getByRole('button', { name: /Volver e intentar/i })).toBeVisible() + + await page5.context().setOffline(false) + await ctx5.close() + }) +})