// Campos del editor: contadores, barra de formato, variables, subida de archivos // (input y arrastrar-soltar), cuenta, sub-editores y validación. import { test, expect, estado, vaciar, anadirTipo, campo, filaChat, tarjeta, abrirTarjeta, irA, PNG_1PX, } from "./fixtures.mjs"; const contadorDe = (page, ruta) => page.locator(".ed-campo", { has: page.locator(`[data-ruta="${ruta}"]`) }).first().locator(".ed-contador"); /** Selecciona [ini, fin) en un input/textarea. */ async function seleccionar(loc, ini, fin) { await loc.evaluate((el, [a, b]) => { el.focus(); el.setSelectionRange(a, b); }, [ini, fin]); } /** Suelta un archivo sobre un elemento (drag & drop del sistema). */ async function soltarArchivo(page, destino, { nombre, tipo, base64 }) { const dt = await page.evaluateHandle(({ nombre, tipo, base64 }) => { const bytes = Uint8Array.from(atob(base64), (c) => c.charCodeAt(0)); const d = new DataTransfer(); d.items.add(new File([bytes], nombre, { type: tipo })); return d; }, { nombre, tipo, base64 }); await destino.dispatchEvent("dragenter", { dataTransfer: dt }); await destino.dispatchEvent("dragover", { dataTransfer: dt }); await destino.dispatchEvent("drop", { dataTransfer: dt }); } test.describe("Contadores de límites", () => { test("n / límite, ámbar al 90 % y rojo al pasarse sin cortar la escritura", async ({ app }) => { await vaciar(app); await anadirTipo(app, "buttons"); const ruta = "mensajes.0.botones.0.texto"; const cont = contadorDe(app, ruta); await campo(app, ruta).fill("Hola"); await expect(cont).toHaveText("4 / 20"); await expect(cont).not.toHaveClass(/ed-contador-(alerta|over)/); await campo(app, ruta).fill("x".repeat(18)); await expect(cont).toHaveText("18 / 20"); await expect(cont).toHaveClass(/ed-contador-alerta/); await campo(app, ruta).fill("x".repeat(23)); await expect(cont).toHaveText("23 / 20"); await expect(cont).toHaveClass(/ed-contador-over/); await expect(campo(app, ruta)).toHaveValue("x".repeat(23)); // la tarjeta avisa de que la Cloud API lo rechazaría await expect(tarjeta(app, 0).locator(".ed-chip-aviso")).toBeVisible(); await expect(tarjeta(app, 0).locator(".ed-validacion")).toContainText("rechazaría"); await expect(tarjeta(app, 0).locator(".ed-validacion")).toContainText("supera 20"); await expect(app.locator("#ed-avisos-global")).toBeVisible(); }); test("el cuerpo de texto cuenta hasta 4096", async ({ app }) => { await vaciar(app); await anadirTipo(app, "text"); await campo(app, "mensajes.0.texto").fill("abc"); await expect(contadorDe(app, "mensajes.0.texto")).toHaveText("3 / 4096"); }); }); test.describe("Barra de formato y variables", () => { for (const [boton, marca, etiqueta] of [ ["ed-fmt-negrita", "*", "strong"], ["ed-fmt-cursiva", "_", "em"], ["ed-fmt-tachado", "~", "del"], ["ed-fmt-mono", "`", "code"], ]) { test(`${boton} envuelve la selección con ${marca}`, async ({ app }) => { await vaciar(app); const id = await anadirTipo(app, "text"); const ta = campo(app, "mensajes.0.texto"); await ta.fill("Hola mundo feliz"); await seleccionar(ta, 5, 10); await tarjeta(app, 0).locator(`.ed-fmtbar .${boton}`).click(); await expect(ta).toHaveValue(`Hola ${marca}mundo${marca} feliz`); // el cursor mantiene la palabra seleccionada const sel = await ta.evaluate((el) => [el.selectionStart, el.selectionEnd]); expect(sel).toEqual([6, 11]); await expect(filaChat(app, id).locator(`.wa-texto ${etiqueta}`)).toHaveText("mundo"); }); } test("sin selección inserta el par de marcas con el cursor en medio", async ({ app }) => { await vaciar(app); await anadirTipo(app, "text"); const ta = campo(app, "mensajes.0.texto"); await ta.fill("ab"); await seleccionar(ta, 1, 1); await tarjeta(app, 0).locator(".ed-fmtbar .ed-fmt-negrita").click(); await expect(ta).toHaveValue("a**b"); expect(await ta.evaluate((el) => [el.selectionStart, el.selectionEnd])).toEqual([2, 2]); }); test("la barra de formato existe en plantilla y lista", async ({ app }) => { await vaciar(app); await anadirTipo(app, "template"); await anadirTipo(app, "list"); await expect(tarjeta(app, 0).locator(".ed-fmtbar").first()).toBeVisible(); await expect(tarjeta(app, 1).locator(".ed-fmtbar").first()).toBeVisible(); }); test("«+ Añadir variable» inserta {{n}} siguiente en el cursor", async ({ app }) => { await vaciar(app); const id = await anadirTipo(app, "template"); const body = campo(app, "mensajes.0.body"); await body.fill("Hola {{1}}, tu pedido"); await seleccionar(body, 21, 21); const btnVar = app.locator(".ed-campo", { has: app.locator('[data-ruta="mensajes.0.body"]') }).locator('[data-acc="var"]'); await btnVar.click(); await expect(body).toHaveValue("Hola {{1}}, tu pedido{{2}}"); await btnVar.click(); await expect(body).toHaveValue("Hola {{1}}, tu pedido{{2}}{{3}}"); await expect(filaChat(app, id).locator(".tpl-body")).toHaveText("Hola {{1}}, tu pedido{{2}}{{3}}"); }); }); test.describe("Subida de archivos", () => { test("imagen por archivo: dataURL en el chat y persiste tras recargar", async ({ app }) => { await vaciar(app); const id = await anadirTipo(app, "image"); const t = tarjeta(app, 0); await t.locator('input[type="file"]').first().setInputFiles({ name: "foto.png", mimeType: "image/png", buffer: PNG_1PX }); await expect.poll(async () => (await estado(app)).mensajes[0].src).toMatch(/^data:image\/png;base64,/); await expect(filaChat(app, id).locator("img.wa-media")).toHaveAttribute("src", /^data:image\/png;base64,/); await expect(t.locator(".ed-media-vista").first()).toBeVisible(); // miniatura en el editor await app.reload(); await expect(filaChat(app, id).locator("img.wa-media")).toHaveAttribute("src", /^data:image\/png;base64,/); }); test("clic en la zona de soltado abre el selector de archivos", async ({ app }) => { await vaciar(app); await anadirTipo(app, "sticker"); const [chooser] = await Promise.all([ app.waitForEvent("filechooser"), tarjeta(app, 0).locator('[data-acc="drop-clic"]').first().click(), ]); await chooser.setFiles({ name: "sticker.png", mimeType: "image/png", buffer: PNG_1PX }); await expect.poll(async () => (await estado(app)).mensajes[0].src).toMatch(/^data:image\/png/); }); test("arrastrar y soltar un archivo en la zona lo carga", async ({ app }) => { await vaciar(app); const id = await anadirTipo(app, "carousel"); const zona = tarjeta(app, 0).locator('.ed-dropzona[data-media-ruta="mensajes.0.cards.1.media"]'); await zona.scrollIntoViewIfNeeded(); await soltarArchivo(app, zona, { nombre: "card.png", tipo: "image/png", base64: PNG_1PX.toString("base64") }); await expect.poll(async () => (await estado(app)).mensajes[0].cards[1].media).toMatch(/^data:image\/png/); await expect(filaChat(app, id).locator(".card").nth(1).locator("img.card-media")).toHaveAttribute("src", /^data:image\/png/); }); test("soltar un archivo fuera de una zona no navega ni cambia nada", async ({ app }) => { const antes = await estado(app); const url = app.url(); await soltarArchivo(app, app.locator("#lista-mensajes"), { nombre: "x.png", tipo: "image/png", base64: PNG_1PX.toString("base64") }); expect(app.url()).toBe(url); expect((await estado(app)).mensajes.length).toBe(antes.mensajes.length); }); test("documento: subir archivo mantiene el nombre con extensión", async ({ app }) => { await vaciar(app); const id = await anadirTipo(app, "document"); await campo(app, "mensajes.0.filename").fill("contrato.docx"); await expect(filaChat(app, id).locator(".doc-nombre")).toHaveText("contrato.docx"); await expect(filaChat(app, id).locator(".doc-sub")).toContainText(/docx/i); }); }); test.describe("Cuenta", () => { test("nombre, subtítulo y verificado se reflejan en la cabecera del chat", async ({ app }) => { await app.locator("#bloque-cuenta > summary").click(); await app.locator("#cuenta-nombre").fill("Óptica Luz"); await expect(app.locator("#chat-nombre")).toHaveText("Óptica Luz"); await expect(app.locator("#cuenta-resumen")).toContainText("Óptica Luz"); await app.locator("#cuenta-subtitulo").fill("responde en minutos"); await expect(app.locator("#chat-subtitulo")).toHaveText("responde en minutos"); await expect(app.locator("#chat-verificado")).toBeVisible(); await app.locator("#cuenta-verificado").uncheck({ force: true }); await expect(app.locator("#chat-verificado")).toBeHidden(); expect((await estado(app)).cuenta).toMatchObject({ nombre: "Óptica Luz", subtitulo: "responde en minutos", verificado: false }); }); test("avatar por archivo, por URL y quitar", async ({ app }) => { await app.locator("#bloque-cuenta > summary").click(); await app.locator("#cuenta-avatar-archivo").setInputFiles({ name: "logo.png", mimeType: "image/png", buffer: PNG_1PX }); await expect(app.locator("#chat-avatar")).toHaveAttribute("src", /^data:image\/png/); await expect(app.locator("#cuenta-avatar-vista")).toHaveAttribute("src", /^data:image\/png/); await app.locator('#editor-cuenta [data-acc="avatar-quitar"]').click(); await expect(app.locator("#chat-avatar")).toHaveAttribute("src", /^data:image\/svg\+xml/); expect((await estado(app)).cuenta.avatar).toBe(""); const dataUrl = "data:image/png;base64," + PNG_1PX.toString("base64"); const url = app.locator("#cuenta-avatar-url"); if (!(await url.isVisible())) await app.locator('#editor-cuenta label.ed-radio:has(input[value="url"])').click(); await url.fill(dataUrl); await expect(app.locator("#chat-avatar")).toHaveAttribute("src", dataUrl); }); }); test.describe("Sub-editores de botones, secciones, filas, tarjetas y respuestas", () => { test("botones rápidos: añadir hasta 3 (máximo de la API) y borrar", async ({ app }) => { await vaciar(app); const id = await anadirTipo(app, "buttons"); const add = tarjeta(app, 0).locator('[data-acc="add-boton"]'); await add.click(); await add.click(); await expect(filaChat(app, id).locator("button.qr-btn")).toHaveCount(3); if (await add.isEnabled()) await add.click(); expect((await estado(app)).mensajes[0].botones).toHaveLength(3); await tarjeta(app, 0).locator('[data-acc="del-boton"]').nth(1).click(); await expect(filaChat(app, id).locator("button.qr-btn")).toHaveCount(2); }); test("plantilla con >3 botones: 2 visibles + «Ver todas las opciones»", async ({ app }) => { await vaciar(app); const id = await anadirTipo(app, "template"); const add = tarjeta(app, 0).locator('[data-acc="add-boton"]').first(); for (let k = 0; k < 3; k++) await add.click(); expect((await estado(app)).mensajes[0].botones).toHaveLength(4); const fila = filaChat(app, id); await expect(fila.locator(".qr-btn:visible")).toHaveCount(3); await expect(fila.locator("[data-ver-todas]")).toHaveText(/Ver todas las opciones/); await fila.locator("[data-ver-todas]").click(); await expect(app.locator("#sheet .sheet-titulo")).toHaveText("Todas las opciones"); await expect(app.locator("#sheet [data-opcion]")).toHaveCount(4); }); test("plantilla: botón con URL se pinta como enlace", async ({ app }) => { await vaciar(app); const id = await anadirTipo(app, "template"); await campo(app, "mensajes.0.botones.0.url").fill("https://example.org/pago"); const a = filaChat(app, id).locator("a.qr-btn"); await expect(a).toHaveAttribute("href", "https://example.org/pago"); await expect(filaChat(app, id).locator("button.qr-btn[data-btn]")).toHaveCount(0); }); test("lista: añadir sección y fila", async ({ app }) => { await vaciar(app); await anadirTipo(app, "list"); await tarjeta(app, 0).locator('[data-acc="add-fila"]').first().click(); await tarjeta(app, 0).locator('[data-acc="add-sec"]').click(); const e = await estado(app); expect(e.mensajes[0].secciones).toHaveLength(2); expect(e.mensajes[0].secciones[0].filas).toHaveLength(2); await campo(app, "mensajes.0.secciones.1.titulo").fill("Extras"); await campo(app, "mensajes.0.secciones.1.filas.0.titulo").fill("Envío exprés"); await filaChat(app, e.mensajes[0].id).locator("button.qr-btn").click(); await expect(app.locator("#sheet .sheet-seccion").nth(1)).toHaveText("Extras"); await expect(app.locator("#sheet .sheet-fila-titulo").last()).toHaveText("Envío exprés"); }); test("carrusel: añadir y borrar tarjetas (2–5)", async ({ app }) => { await vaciar(app); const id = await anadirTipo(app, "carousel"); const add = tarjeta(app, 0).locator('[data-acc="add-card"]'); await add.click(); await expect(filaChat(app, id).locator(".card")).toHaveCount(3); await tarjeta(app, 0).locator('[data-acc="del-card"]').first().click(); await expect(filaChat(app, id).locator(".card")).toHaveCount(2); // no baja de 2 await tarjeta(app, 0).locator('[data-acc="del-card"]').first().click(); expect((await estado(app)).mensajes[0].cards.length).toBeGreaterThanOrEqual(2); }); test("respuestas encadenadas: añadir, editar, cambiar de tipo y borrar", async ({ app }) => { await vaciar(app); await anadirTipo(app, "buttons"); const t = tarjeta(app, 0); await expect(t.locator(".ed-chip-flujo")).toHaveClass(/ed-chip-flujo-incompleto/); await t.locator('[data-acc="add-resp"][data-ruta="mensajes.0.botones.0.respuestas"]').click(); const ta = campo(app, "mensajes.0.botones.0.respuestas.0.texto"); await expect(ta).toBeFocused(); await ta.fill("Respuesta encadenada"); await expect(t.locator(".ed-chip-flujo")).not.toHaveClass(/ed-chip-flujo-incompleto/); let r = (await estado(app)).mensajes[0].botones[0].respuestas; expect(r).toHaveLength(1); expect(r[0]).toMatchObject({ dir: "in", tipo: "text", texto: "Respuesta encadenada" }); await t.locator('select[data-rol="tipo-resp"]').first().selectOption("image"); await expect.poll(async () => (await estado(app)).mensajes[0].botones[0].respuestas[0].tipo).toBe("image"); r = (await estado(app)).mensajes[0].botones[0].respuestas; expect(r).toHaveLength(1); expect(r[0]).toMatchObject({ dir: "in", tipo: "image", src: "" }); await expect(t.locator('[data-ruta="mensajes.0.botones.0.respuestas.0.caption"]')).toBeVisible(); await t.locator('[data-acc="del-resp"]').first().click(); r = (await estado(app)).mensajes[0].botones[0].respuestas; expect(r).toHaveLength(0); }); test("autoPulsar se elige en el editor y se guarda como índice", async ({ app }) => { const e = await estado(app); const i = e.mensajes.findIndex((m) => m.tipo === "buttons"); await abrirTarjeta(app, i); const sel = campo(app, `mensajes.${i}.autoPulsar`); const valor = await sel.locator("option").nth(2).getAttribute("value"); await sel.selectOption(valor); await expect.poll(async () => (await estado(app)).mensajes[i].autoPulsar).toBe(Number(valor)); await app.reload(); expect((await estado(app)).mensajes[i].autoPulsar).toBe(Number(valor)); // «vacío» quita la pulsación automática await abrirTarjeta(app, i); await campo(app, `mensajes.${i}.autoPulsar`).selectOption(""); await expect.poll(async () => "autoPulsar" in (await estado(app)).mensajes[i]).toBe(false); }); }); test.describe("Validación", () => { test("ubicación con latitud fuera de rango y contacto sin nombre muestran avisos", async ({ app }) => { await vaciar(app); await anadirTipo(app, "location"); await campo(app, "mensajes.0.latitud").fill("123"); await expect(tarjeta(app, 0).locator(".ed-validacion")).toContainText("Latitud no válida"); await anadirTipo(app, "contact"); await campo(app, "mensajes.1.nombre").fill(""); await expect(tarjeta(app, 1).locator(".ed-validacion")).toContainText("necesita un nombre"); await expect(app.locator("#ed-avisos-global")).toBeVisible(); await app.locator("#ed-avisos-global").click(); await expect(tarjeta(app, 0)).toHaveClass(/ed-abierta/); }); test("la demo de ejemplo no tiene avisos de validación", async ({ app }) => { await expect(app.locator("#lista-mensajes .ed-chip-aviso")).toHaveCount(0); await expect(app.locator("#ed-avisos-global")).toBeHidden(); }); }); test.describe("Persistencia", () => { test("F5 conserva todo el estado editado", async ({ app }) => { await vaciar(app); await anadirTipo(app, "text"); await campo(app, "mensajes.0.texto").fill("Persistente"); await anadirTipo(app, "cta_url"); const antes = await estado(app); await app.reload(); await expect(app.locator("#lista-mensajes > .ed-tarjeta")).toHaveCount(2); expect(await estado(app)).toEqual(antes); await expect(filaChat(app, antes.mensajes[0].id)).toContainText("Persistente"); }); test("el indicador de guardado pasa por «Guardando…» y vuelve a «Guardado»", async ({ app }) => { await vaciar(app); await anadirTipo(app, "text"); await campo(app, "mensajes.0.texto").fill("x"); await expect(app.locator("#estado-guardado")).toHaveAttribute("data-estado", "ok", { timeout: 4000 }); await expect(app.locator("#estado-guardado .tb-guardado-txt")).toContainText("Guardado"); }); test("un localStorage corrupto no rompe el arranque: carga la demo", async ({ page }) => { await page.addInitScript(() => { if (!sessionStorage.getItem("sembrado")) { localStorage.setItem("wam-mockup-v1", "{esto no es json"); sessionStorage.setItem("sembrado", "1"); } }); await irA(page, "/index.html"); await expect(page.locator("#chat-nombre")).toHaveText("Café Aurora"); }); });