WIP: mejoras de fidelidad, editor, lógica y suite e2e (checkpoint tras reinicio)

This commit is contained in:
urieljareth
2026-09-27 20:46:51 -06:00
parent dc6e31a9d7
commit 9a5f21badf
33 changed files with 7719 additions and 1129 deletions
+351
View File
@@ -0,0 +1,351 @@
// 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");
});
});