import assert from "node:assert/strict"; import { test } from "node:test"; import type { ReactNode } from "react"; import { renderToStaticMarkup } from "react-dom/server"; import { EntityAction, EntityRowActions, EntityStatus, EntityTable, } from "./components/entity-list"; import { entityListLabels, entityListPages, entityListRange } from "./lib/entity-list"; const items = Array.from({ length: 21 }, (_, index) => ({ id: String(index), name: `Persona ${String(index + 1).padStart(2, "0")}`, })); const columns = [ { id: "name", label: "Nombre", value: (item: (typeof items)[number]) => item.name, render: (item: (typeof items)[number]) => item.name, hideable: false, }, ]; test("los rangos vacíos y la última página nunca muestran índices fuera del conjunto", () => { assert.deepEqual(entityListRange(0, 5, 10), { page: 0, pages: 1, from: 0, to: 0 }); assert.deepEqual(entityListRange(21, 2, 10), { page: 2, pages: 3, from: 21, to: 21 }); assert.deepEqual(entityListRange(9, 2, 10), { page: 0, pages: 1, from: 1, to: 9 }); assert.deepEqual(entityListPages(0, 12), [0, 1, 2, 3, 4]); assert.deepEqual(entityListPages(11, 12), [7, 8, 9, 10, 11]); assert.deepEqual(entityListPages(0, 1), [0]); }); test("el listado pagina el conjunto y repite su rango conservando controles accesibles", () => { const html = renderToStaticMarkup( ); assert.equal(html.split("Mostrando 1-10 de 21 personas").length - 1, 2); assert.ok(html.includes("Persona 10")); assert.equal(html.includes("Persona 11"), false); assert.ok(html.includes('aria-label="Página siguiente"')); assert.ok(html.includes('aria-current="page"')); assert.ok(html.includes('scope="col"')); assert.ok(html.includes("Columnas")); assert.ok(html.includes("Ordenar por")); }); test("la búsqueda local reduce el conjunto completo antes de paginar y permite limpiarla", () => { const html = renderToStaticMarkup( ); assert.ok(html.includes("Mostrando 1-1 de 1 personas")); assert.ok(html.includes("Persona 21")); assert.ok(html.includes("Limpiar filtros")); assert.equal(html.includes("Persona 01"), false); }); test("el orden inicial ascendente se indica en la cabecera y se aplica antes de paginar", () => { const html = renderToStaticMarkup( ); assert.match(html, /]*aria-sort="ascending"[^>]*>[\s\S]*?Nombre/); assert.ok(html.indexOf("Persona 01") < html.indexOf("Persona 02")); assert.ok(html.includes("Persona 10")); assert.equal(html.includes("Persona 11"), false); assert.equal(html.includes("Persona 21"), false); }); test("una columna explícita puede establecer un orden inicial descendente", () => { const html = renderToStaticMarkup( item.updated, render: (item) => item.updated, }, ]} items={[ { id: "old", name: "Ana", updated: "2026-01-01T12:00:00Z" }, { id: "new", name: "Zoe", updated: "2026-02-01T12:00:00Z" }, ]} title="Actividad" /> ); assert.match(html, /]*aria-sort="descending"[^>]*>[\s\S]*?Actualización/); assert.equal(html.includes('aria-sort="ascending"'), false); assert.ok(html.indexOf(">Zoe<") < html.indexOf(">Ana<")); }); test("el orden inicial ignora acciones y columnas sin valor comparable", () => { const html = renderToStaticMarkup( "Imagen" }, { id: "actions", label: "Acciones", defaultSort: "desc", value: (item) => item.name, render: () => "Editar", }, ...columns, ]} items={items.slice(0, 2).reverse()} title="Personas" /> ); assert.match(html, /]*aria-sort="ascending"[^>]*>[\s\S]*?Nombre/); assert.equal(html.includes('aria-sort="descending"'), false); assert.ok(html.indexOf("Persona 01") < html.indexOf("Persona 02")); }); test("las tablas sin columnas ordenables conservan el orden recibido", () => { const html = renderToStaticMarkup( item.name }]} items={[...items].reverse()} title="Personas" /> ); assert.equal(html.includes("aria-sort="), false); assert.ok(html.indexOf("Persona 21") < html.indexOf("Persona 20")); }); test("el orden inicial mantiene la estabilidad en empates y se indica con el listado vacío", () => { const tied = renderToStaticMarkup( item.name, render: (item) => item.id, }, ]} items={[ { id: "second", name: "Igual" }, { id: "first", name: "Igual" }, ]} title="Personas" /> ); const empty = renderToStaticMarkup(); assert.ok(tied.indexOf(">second<") < tied.indexOf(">first<")); assert.ok(empty.includes('aria-sort="ascending"')); }); test("una búsqueda controlada conserva los resultados filtrados por la pantalla", () => { const html = renderToStaticMarkup( undefined} search="criterio externo" title="Personas" /> ); assert.ok(html.includes("Mostrando 1-1 de 1 personas")); assert.ok(html.includes("Persona 01")); }); test("carga, error y búsqueda vacía se distinguen de un listado sin registros", () => { const render = (props: { loading?: boolean; unavailable?: boolean; search?: string }) => renderToStaticMarkup( ); assert.ok(render({}).includes("No hay personas.")); assert.ok(render({ search: "Ana" }).includes("No hay resultados para estos criterios.")); assert.ok(render({ loading: true }).includes('aria-busy="true"')); assert.equal(render({ loading: true }).includes("No hay personas."), false); assert.ok(render({ unavailable: true }).includes("No se ha podido cargar el listado.")); assert.equal(render({ unavailable: true }).includes("Mostrando 0-0"), false); }); test("el rango y la configuración también se traducen en inglés", () => { const html = renderToStaticMarkup( ); assert.ok(html.includes("Showing 0-0 of 0 people")); assert.ok(html.includes("Columns")); assert.ok(html.includes("Items per page")); }); test("acciones: disparador de solo icono con etiqueta accesible y menú contextual", () => { const html = renderToStaticMarkup( undefined}>Editar ); assert.ok(html.includes('aria-label="Acciones de Ana"')); assert.ok(html.includes('title="Acciones de Ana"')); assert.ok(html.includes('aria-haspopup="menu"')); assert.ok(html.includes("Acciones de Ana"), false); }); test("el estado activo se distingue en verde del inactivo", () => { const active = renderToStaticMarkup(Activa); const inactive = renderToStaticMarkup(Inactiva); assert.ok(active.includes("bg-emerald-500")); assert.ok(inactive.includes("bg-muted-foreground")); }); test("acciones: siempre última, compacta y anclada a la derecha, aunque se declare primero", () => { const html = renderToStaticMarkup( "Acción", render: () => ( undefined}>Editar ), }, ...columns, ]} items={items.slice(0, 1)} title="Personas" /> ); const header = html.match(//)?.[0] ?? ""; const row = html.match(//)?.[0] ?? ""; assert.ok(header.indexOf(">Nombre") < header.indexOf(">Acciones")); assert.ok(row.indexOf("Persona 01") < row.indexOf('aria-label="Acciones"')); assert.equal(html.split('data-pinned="right"').length - 1, 2); assert.equal(html.split("sticky right-0").length - 1, 2); assert.equal(header.includes('aria-label="Acciones"'), false); assert.ok(header.includes("text-right")); assert.ok(row.includes("text-right")); assert.ok(row.includes("bg-card")); }); test("los textos comunes mantienen claves, idiomas y parámetros del rango", () => { const spanish = entityListLabels("es"); const english = entityListLabels("en-GB"); assert.deepEqual(Object.keys(spanish).sort(), Object.keys(english).sort()); assert.equal(spanish.range(1, 10, 25, "marcas"), "Mostrando 1-10 de 25 marcas"); assert.equal(english.range(0, 0, 0, "brands"), "Showing 0-0 of 0 brands"); assert.equal(entityListLabels("fr").actions, spanish.actions); assert.equal(english.range(1, 1, 1, "$&"), "Showing 1-1 of 1 $&"); }); test("las celdas vacías muestran un guion largo en es/en y conservan cero, textos y controles", () => { const cases: readonly { content: ReactNode; expected: string }[] = [ { content: null, expected: "—" }, { content: undefined, expected: "—" }, { content: false, expected: "—" }, { content: true, expected: "—" }, { content: "", expected: "—" }, { content: " \n\t ", expected: "—" }, { content: [], expected: "—" }, { content: [null, false, ""], expected: "—" }, { content: <>, expected: "—" }, { content: ( <> {null} {false}{" "} ), expected: "—", }, { content: ( <> <>{undefined} ), expected: "—", }, { content: 0, expected: "0" }, { content: <>0, expected: "0" }, { content: "No", expected: "No" }, { content: , expected: '', }, ]; for (const locale of ["es", "en"]) { for (const { content, expected } of cases) { const html = renderToStaticMarkup( content }]} items={[{ id: "row" }]} locale={locale} title="Datos" /> ); const cell = html.match(/]*>([\s\S]*?)<\/td>/)?.[1]; assert.equal(cell, expected); } } });