metis-desk/packages/rdx-ui/src/entity-list.test.tsx
2026-10-20 13:20:22 +02:00

542 lines
17 KiB
TypeScript

import assert from "node:assert/strict";
import { test } from "node:test";
import type { ReactNode } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { CursorPagination } from "./components/cursor-pagination";
import {
EntityAction,
EntityRowActions,
EntityStatus,
EntityTable,
} from "./components/entity-list";
import { entityListLabels, entityListPages, entityListRange } from "./lib/entity-list";
import { PencilIcon } from "./lib/icons";
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(
<EntityTable columns={columns} items={items} title="Personas" />
);
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(
<EntityTable columns={columns} items={items} search="Persona 21" title="Personas" />
);
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(
<EntityTable columns={columns} items={[...items].reverse()} title="Personas" />
);
assert.match(html, /<th[^>]*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(
<EntityTable
columns={[
...columns,
{
id: "updated",
label: "Actualización",
defaultSort: "desc",
value: (item) => 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, /<th[^>]*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(
<EntityTable
columns={[
{ id: "logo", label: "Logo", render: () => "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, /<th[^>]*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(
<EntityTable
columns={[{ id: "name", label: "Nombre", render: (item) => 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(
<EntityTable
columns={[
{
id: "name",
label: "Nombre",
value: (item) => item.name,
render: (item) => item.id,
},
]}
items={[
{ id: "second", name: "Igual" },
{ id: "first", name: "Igual" },
]}
title="Personas"
/>
);
const empty = renderToStaticMarkup(<EntityTable columns={columns} items={[]} title="Personas" />);
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(
<EntityTable
columns={columns}
items={items.slice(0, 1)}
onSearchChange={() => 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(
<EntityTable
columns={columns}
emptyMessage="No hay personas."
items={[]}
title="Personas"
{...props}
/>
);
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(
<EntityTable columns={columns} items={[]} locale="en" title="People" />
);
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(
<EntityRowActions label="Acciones de Ana">
<EntityAction onClick={() => undefined}>Editar</EntityAction>
</EntityRowActions>
);
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("<svg"));
assert.equal(html.includes(">Acciones de Ana"), false);
});
test("acciones: operación principal visible con nombre accesible y secundarias en el menú", () => {
for (const label of ["Editar", "Edit"]) {
const html = renderToStaticMarkup(
<EntityRowActions
label="Acciones"
primaryAction={{ label, icon: <PencilIcon aria-hidden="true" />, onClick: () => undefined }}
>
<EntityAction onClick={() => undefined}>Eliminar</EntityAction>
</EntityRowActions>
);
assert.ok(html.includes(`aria-label="${label}"`));
assert.ok(html.includes('data-slot="tooltip-trigger"'));
assert.ok(html.includes('aria-haspopup="menu"'));
assert.ok(html.indexOf(`aria-label="${label}"`) < html.indexOf('aria-label="Acciones"'));
assert.equal(html.includes(`>${label}</button>`), false);
}
});
test("acciones: conserva la restricción de la principal y evita menús sin secundarias", () => {
const primaryAction = {
label: "Editar",
icon: <PencilIcon aria-hidden="true" />,
onClick: () => undefined,
disabled: true,
};
const html = renderToStaticMarkup(
<EntityRowActions label="Acciones" primaryAction={primaryAction} />
);
assert.match(html, /<button[^>]*disabled=""/);
assert.equal(html.includes('aria-haspopup="menu"'), false);
const hidden = renderToStaticMarkup(
<EntityRowActions label="Acciones" menuVisible={false} primaryAction={primaryAction}>
{false}
</EntityRowActions>
);
assert.equal(hidden.includes('aria-haspopup="menu"'), false);
});
test("el estado activo se distingue en verde del inactivo", () => {
const active = renderToStaticMarkup(<EntityStatus active>Activa</EntityStatus>);
const inactive = renderToStaticMarkup(<EntityStatus active={false}>Inactiva</EntityStatus>);
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(
<EntityTable
columns={[
{
id: "actions",
label: "Acciones",
hideable: true,
value: () => "Acción",
render: () => (
<EntityRowActions label="Acciones">
<EntityAction onClick={() => undefined}>Editar</EntityAction>
</EntityRowActions>
),
},
...columns,
]}
items={items.slice(0, 1)}
title="Personas"
/>
);
const header = html.match(/<thead[\s\S]*?<\/thead>/)?.[0] ?? "";
const row = html.match(/<tbody[\s\S]*?<\/tbody>/)?.[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: <button type="button">Editar</button>,
expected: '<button type="button">Editar</button>',
},
];
for (const locale of ["es", "en"]) {
for (const { content, expected } of cases) {
const html = renderToStaticMarkup(
<EntityTable
columns={[{ id: "value", label: "Valor", render: () => content }]}
items={[{ id: "row" }]}
locale={locale}
title="Datos"
/>
);
const cell = html.match(/<td\b[^>]*>([\s\S]*?)<\/td>/)?.[1];
assert.equal(cell, expected);
}
}
});
test("las columnas opcionales empiezan ocultas sin ocultar identificación ni acciones obligatorias", () => {
const html = renderToStaticMarkup(
<EntityTable
columns={[
{
id: "name",
label: "Nombre",
hideable: false,
defaultVisible: false,
value: (item) => item.name,
render: (item) => item.name,
},
{
id: "detail",
label: "Detalle opcional",
defaultVisible: false,
value: (item) => item.detail,
render: (item) => item.detail,
},
{
id: "actions",
label: "Acciones",
defaultVisible: false,
render: () => <button type="button">Editar</button>,
},
]}
items={[{ id: "one", name: "Equipo", detail: "Información adicional" }]}
title="Activos"
/>
);
assert.ok(html.includes("Equipo"));
assert.ok(html.includes("Editar"));
assert.equal(html.includes("Información adicional"), false);
assert.equal(html.includes("Detalle opcional"), false);
});
test("Nombre ordena sin tildes ni mayúsculas antes de paginar, en español e inglés", () => {
const names = [
"Zorro",
"Úrsula",
"Óscar",
"María",
"Luis",
"Íñigo",
"Hugo",
"Gabriel",
"Félix",
"Érica",
"David",
"Celia",
"Bruno",
"Álvaro",
];
const expected = [
"Álvaro",
"Bruno",
"Celia",
"David",
"Érica",
"Félix",
"Gabriel",
"Hugo",
"Íñigo",
"Luis",
];
for (const locale of ["es", "en"]) {
const html = renderToStaticMarkup(
<EntityTable
columns={columns}
items={names.map((name, index) => ({ id: String(index), name }))}
locale={locale}
title="Personas"
/>
);
let previous = -1;
for (const name of expected) {
const position = html.indexOf(`>${name}<`);
assert.ok(position > previous, `${locale}: ${name} debe respetar el orden alfabético`);
previous = position;
}
assert.equal(html.includes(">Zorro<"), false);
assert.equal(html.includes(">María<"), false);
}
});
test("Nombre tiene prioridad inicial aunque otra columna comparable aparezca antes", () => {
const html = renderToStaticMarkup(
<EntityTable
columns={[
{ id: "code", label: "Código", value: (item) => item.id, render: (item) => item.id },
...columns,
]}
items={[
{ id: "1", name: "Zoe" },
{ id: "2", name: "álvaro" },
{ id: "3", name: "Bruno" },
]}
title="Personas"
/>
);
assert.ok(html.indexOf(">álvaro<") < html.indexOf(">Bruno<"));
assert.ok(html.indexOf(">Bruno<") < html.indexOf(">Zoe<"));
});
test("Nombre conserva empates entre acentos, mayúsculas y Unicode compuesto al ordenar descendente", () => {
const html = renderToStaticMarkup(
<EntityTable
columns={[
{
id: "name",
label: "Nombre",
defaultSort: "desc",
value: (item) => item.name,
render: (item) => item.id,
},
]}
items={[
{ id: "accent-first", name: "Álvaro" },
{ id: "plain-second", name: "alvaro" },
{ id: "unicode-third", name: "A\u0301LVARO" },
{ id: "last-alphabetically", name: "Zoe" },
{ id: "middle-alphabetically", name: "Érica" },
]}
title="Personas"
/>
);
const expected = [
"last-alphabetically",
"middle-alphabetically",
"accent-first",
"plain-second",
"unicode-third",
];
let previous = -1;
for (const id of expected) {
const position = html.indexOf(`>${id}<`);
assert.ok(position > previous);
previous = position;
}
assert.ok(html.includes('aria-sort="descending"'));
});
test("paginación por cursor: navegación accesible y límites sin inventar totales", () => {
const html = renderToStaticMarkup(
<CursorPagination
canNext={false}
canPrevious={false}
nextLabel="Siguiente"
onNext={() => undefined}
onPrevious={() => undefined}
onRestart={() => undefined}
pending={false}
previousLabel="Anterior"
restartLabel="Primera página"
/>
);
assert.ok(html.includes("<nav"));
assert.ok(html.includes('aria-label="Primera página"'));
assert.equal((html.match(/ disabled=""/g) ?? []).length, 2);
assert.equal(html.includes("Mostrando"), false);
});