@axzydev/axzy_ui_system 1.7.3 → 1.7.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.cjs +1621 -594
- package/dist/index.cjs.map +1 -1
- package/dist/index.css +1 -1
- package/dist/index.d.cts +366 -12
- package/dist/index.d.ts +366 -12
- package/dist/index.js +1331 -313
- package/dist/index.js.map +1 -1
- package/dist/layered.css +1 -1
- package/package.json +4 -2
- package/src/App.tsx +6 -8
- package/src/components/atoms/flex/flex.props.ts +3 -1
- package/src/components/atoms/flex/flex.tsx +11 -3
- package/src/components/atoms/textarea/textarea.props.ts +4 -0
- package/src/components/atoms/textarea/textarea.tsx +13 -3
- package/src/components/molecules/card/card.props.ts +2 -0
- package/src/components/molecules/card/card.tsx +15 -2
- package/src/components/molecules/line-bar-chart/line-bar-chart.props.ts +4 -0
- package/src/components/molecules/line-bar-chart/line-bar-chart.tsx +57 -41
- package/src/components/organisms/dropfile/dropfile.props.ts +8 -2
- package/src/components/organisms/dropfile/dropfile.tsx +17 -0
- package/src/i18n/locales/en.ts +7 -0
- package/src/i18n/locales/es.ts +7 -0
- package/src/index.ts +31 -3
- package/src/json-renderer/actions/effects.test.tsx +115 -0
- package/src/json-renderer/actions/effects.ts +291 -0
- package/src/json-renderer/actions/effectsChain.test.ts +117 -0
- package/src/json-renderer/binding/binding.test.tsx +168 -0
- package/src/json-renderer/binding/resolveValue.ts +193 -0
- package/src/json-renderer/estado-acciones.test.tsx +166 -0
- package/src/json-renderer/form/FormNode.tsx +92 -0
- package/src/json-renderer/form/form.test.tsx +148 -0
- package/src/json-renderer/form/formLength.test.tsx +99 -0
- package/src/json-renderer/form/rules.ts +162 -0
- package/src/json-renderer/index.ts +21 -0
- package/src/json-renderer/kernel/SchemaRenderer.tsx +83 -7
- package/src/json-renderer/kernel/capabilities.ts +30 -0
- package/src/json-renderer/kernel/renderNode.tsx +256 -3
- package/src/json-renderer/kernel/schemaRenderer.props.ts +29 -3
- package/src/json-renderer/kernel/slots.test.tsx +104 -0
- package/src/json-renderer/kernel/slots.ts +34 -0
- package/src/json-renderer/kernel/types.ts +4 -0
- package/src/json-renderer/routing/match.ts +82 -0
- package/src/json-renderer/routing/navigation.ts +71 -0
- package/src/json-renderer/routing/routes.ts +179 -0
- package/src/json-renderer/routing/routing.test.ts +147 -0
- package/src/json-renderer/seguridad.test.tsx +299 -0
- package/src/json-renderer/shared/errors.ts +1 -0
- package/src/json-renderer/stream/tolerantParse.ts +5 -0
- package/src/json-renderer/stream/useSchemaStream.test.tsx +143 -0
- package/src/json-renderer/stream/useSchemaStream.ts +227 -0
- package/src/json-renderer/validation/jsonSchema.test.ts +105 -0
- package/src/json-renderer/validation/jsonSchema.ts +317 -0
- package/src/json-renderer/validation/validate.ts +17 -0
- package/src/sandbox/i18n.ts +38 -0
- package/src/sandbox/useHashRoute.ts +2 -1
- package/src/showcases/DemoJsonDashboard.tsx +218 -103
- package/src/showcases/DemoJsonDocs.tsx +11 -11
- package/src/showcases/JsonScreenShowcase.tsx +43 -53
- package/src/showcases/demoApp/demoJson.test.tsx +241 -0
- package/src/showcases/demoApp/routes.test.ts +63 -0
- package/src/showcases/demoApp/schemas.test.ts +6 -17
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import React, { useCallback, useEffect, useMemo,
|
|
1
|
+
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
|
2
2
|
import { FaBolt, FaBug, FaCheck, FaTrashAlt } from "react-icons/fa";
|
|
3
3
|
import {
|
|
4
4
|
ITAlert,
|
|
@@ -11,8 +11,9 @@ import {
|
|
|
11
11
|
ITText,
|
|
12
12
|
type JsonEngineIssue,
|
|
13
13
|
type ScreenSchema,
|
|
14
|
-
|
|
14
|
+
type StreamSource,
|
|
15
15
|
axzyCatalogTypes,
|
|
16
|
+
useSchemaStream,
|
|
16
17
|
} from "../index";
|
|
17
18
|
import { ShowcaseLayout } from "./ShowcaseLayout";
|
|
18
19
|
|
|
@@ -53,7 +54,7 @@ const EJEMPLO: ScreenSchema = {
|
|
|
53
54
|
props: { title: "Listo para pintar" },
|
|
54
55
|
children: [
|
|
55
56
|
{ type: "ITText", text: "Este árbol viene de un JSON, no de TSX." },
|
|
56
|
-
{ type: "
|
|
57
|
+
{ type: "ITText", props: { muted: true }, text: "Progreso: 64 de 100" },
|
|
57
58
|
],
|
|
58
59
|
},
|
|
59
60
|
],
|
|
@@ -77,9 +78,17 @@ const EJEMPLO: ScreenSchema = {
|
|
|
77
78
|
/** Texto inicial del editor: el esquema anterior, formateado. */
|
|
78
79
|
const TEXTO_INICIAL = JSON.stringify(EJEMPLO, null, 2);
|
|
79
80
|
|
|
80
|
-
/** Velocidad del stream simulado (caracteres por
|
|
81
|
+
/** Velocidad del stream simulado (caracteres por trozo). */
|
|
81
82
|
const CHUNK = 6;
|
|
82
83
|
|
|
84
|
+
/** Emite un texto en trozos de `CHUNK` caracteres, con una pausa entre ellos, como haría un modelo. */
|
|
85
|
+
async function* emitir(total: string): AsyncGenerator<string> {
|
|
86
|
+
for (let cursor = 0; cursor < total.length; cursor += CHUNK) {
|
|
87
|
+
await new Promise((resolve) => window.setTimeout(resolve, 24));
|
|
88
|
+
yield total.slice(cursor, cursor + CHUNK);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
83
92
|
/**
|
|
84
93
|
* Laboratorio del motor de pantallas declarativas.
|
|
85
94
|
*
|
|
@@ -92,63 +101,40 @@ const CHUNK = 6;
|
|
|
92
101
|
*/
|
|
93
102
|
export const JsonScreenShowcase: React.FC = () => {
|
|
94
103
|
const [texto, setTexto] = useState(TEXTO_INICIAL);
|
|
95
|
-
const [streaming, setStreaming] = useState(false);
|
|
96
|
-
const [emitido, setEmitido] = useState(TEXTO_INICIAL.length);
|
|
97
104
|
const [avisos, setAvisos] = useState<JsonEngineIssue[]>([]);
|
|
98
|
-
|
|
105
|
+
/** Fuente del stream simulado. Mientras existe, el laboratorio está emitiendo. */
|
|
106
|
+
const [fuente, setFuente] = useState<StreamSource | null>(null);
|
|
107
|
+
const stream = useSchemaStream(fuente);
|
|
108
|
+
const streaming = fuente !== null;
|
|
109
|
+
const emitido = stream.text.length;
|
|
99
110
|
|
|
100
|
-
/** Estado del esquema: mientras se emite, se
|
|
111
|
+
/** Estado del esquema: mientras se emite, lo da el hook; si no, se lee el texto del editor. */
|
|
101
112
|
const estado = useMemo(() => {
|
|
102
|
-
if (
|
|
103
|
-
|
|
104
|
-
const completo = JSON.parse(texto) as ScreenSchema;
|
|
105
|
-
return { tree: completo, status: "complete" as const, pending: 0, roto: false };
|
|
106
|
-
} catch {
|
|
107
|
-
return { tree: null, status: "complete" as const, pending: 0, roto: true };
|
|
108
|
-
}
|
|
113
|
+
if (streaming) {
|
|
114
|
+
return { tree: stream.tree, status: stream.status, pending: stream.pendingNodes, roto: stream.broken };
|
|
109
115
|
}
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
tree:
|
|
113
|
-
|
|
114
|
-
pending:
|
|
115
|
-
roto: parcial.broken,
|
|
116
|
-
};
|
|
117
|
-
}, [texto, streaming]);
|
|
118
|
-
|
|
119
|
-
/** Detiene el stream y limpia el temporizador. */
|
|
120
|
-
const detener = useCallback(() => {
|
|
121
|
-
if (timer.current !== null) {
|
|
122
|
-
window.clearInterval(timer.current);
|
|
123
|
-
timer.current = null;
|
|
116
|
+
try {
|
|
117
|
+
const completo = JSON.parse(texto) as ScreenSchema;
|
|
118
|
+
return { tree: completo, status: "complete" as const, pending: 0, roto: false };
|
|
119
|
+
} catch {
|
|
120
|
+
return { tree: null, status: "complete" as const, pending: 0, roto: true };
|
|
124
121
|
}
|
|
125
|
-
|
|
126
|
-
}, []);
|
|
122
|
+
}, [texto, streaming, stream]);
|
|
127
123
|
|
|
128
|
-
|
|
124
|
+
/** Al terminar la emisión, el texto completo pasa al editor y el laboratorio vuelve a ser editable. */
|
|
125
|
+
useEffect(() => {
|
|
126
|
+
if (fuente !== null && !stream.streaming && stream.text.length > 0) {
|
|
127
|
+
setTexto(stream.text);
|
|
128
|
+
setFuente(null);
|
|
129
|
+
}
|
|
130
|
+
}, [fuente, stream.streaming, stream.text]);
|
|
129
131
|
|
|
130
|
-
/** Emite el esquema
|
|
132
|
+
/** Emite el esquema por trozos, como haría un modelo. */
|
|
131
133
|
const emitirStream = useCallback(() => {
|
|
132
|
-
detener();
|
|
133
134
|
setAvisos([]);
|
|
134
|
-
setEmitido(0);
|
|
135
135
|
setTexto("");
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
const total = TEXTO_INICIAL;
|
|
139
|
-
let cursor = 0;
|
|
140
|
-
|
|
141
|
-
timer.current = window.setInterval(() => {
|
|
142
|
-
cursor = Math.min(total.length, cursor + CHUNK);
|
|
143
|
-
setTexto(total.slice(0, cursor));
|
|
144
|
-
setEmitido(cursor);
|
|
145
|
-
if (cursor >= total.length) {
|
|
146
|
-
window.clearInterval(timer.current as number);
|
|
147
|
-
timer.current = null;
|
|
148
|
-
setStreaming(false);
|
|
149
|
-
}
|
|
150
|
-
}, 24);
|
|
151
|
-
}, [detener]);
|
|
136
|
+
setFuente(emitir(TEXTO_INICIAL));
|
|
137
|
+
}, []);
|
|
152
138
|
|
|
153
139
|
/** Registro de acciones del host: el esquema solo puede pedir estos nombres. */
|
|
154
140
|
const resolveAction = useCallback(
|
|
@@ -166,6 +152,11 @@ export const JsonScreenShowcase: React.FC = () => {
|
|
|
166
152
|
);
|
|
167
153
|
}, []);
|
|
168
154
|
|
|
155
|
+
/** Los avisos del stream (JR080: falló, o terminó sin cerrar) pasan al panel de avisos. */
|
|
156
|
+
useEffect(() => {
|
|
157
|
+
stream.issues.forEach(registrarAviso);
|
|
158
|
+
}, [stream.issues, registrarAviso]);
|
|
159
|
+
|
|
169
160
|
const codigo = `<ITSchemaRenderer
|
|
170
161
|
schema={parsePartialSchema(texto).tree}
|
|
171
162
|
onIssue={(issue) => registrar(issue)}
|
|
@@ -246,9 +237,8 @@ export const JsonScreenShowcase: React.FC = () => {
|
|
|
246
237
|
variant="outlined"
|
|
247
238
|
color="gray"
|
|
248
239
|
onClick={() => {
|
|
249
|
-
|
|
240
|
+
setFuente(null);
|
|
250
241
|
setTexto(TEXTO_INICIAL);
|
|
251
|
-
setEmitido(TEXTO_INICIAL.length);
|
|
252
242
|
setAvisos([]);
|
|
253
243
|
}}
|
|
254
244
|
/>
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pruebas de los JSON del demo (`public/demo/app/*.json`).
|
|
3
|
+
*
|
|
4
|
+
* Los JSON son contrato con el host: cada acción, slot, fuente, parámetro y clave de
|
|
5
|
+
* traducción que nombran tiene que existir del lado del host. Estas pruebas lo
|
|
6
|
+
* comprueban para todas las páginas y para `routes.json`, y renderizan cada página
|
|
7
|
+
* con el motor exigiendo cero avisos.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import React from "react";
|
|
11
|
+
import { describe, expect, it } from "vitest";
|
|
12
|
+
import { render } from "@testing-library/react";
|
|
13
|
+
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
14
|
+
import { join } from "node:path";
|
|
15
|
+
import { ITSchemaRenderer } from "../../json-renderer/kernel/SchemaRenderer";
|
|
16
|
+
import type { JsonEngineIssue } from "../../json-renderer/shared/errors";
|
|
17
|
+
import type { SlotRegistry } from "../../json-renderer/kernel/slots";
|
|
18
|
+
import { validateSchema, validateRoutes, flattenRoutes, matchRoute, type RoutesSchema } from "../../json-renderer";
|
|
19
|
+
import { sandboxMessages } from "../../sandbox/i18n";
|
|
20
|
+
import { DEMO_ACTIONS, DEMO_SLOT_NAMES } from "../DemoJsonDashboard";
|
|
21
|
+
import { isDemoSource } from "./demoData";
|
|
22
|
+
|
|
23
|
+
const DIR = join(process.cwd(), "public/demo/app");
|
|
24
|
+
const NO_SON_PAGINAS = ["layout.json", "routes.json"];
|
|
25
|
+
|
|
26
|
+
/** Lee un JSON del demo. */
|
|
27
|
+
function leer<T = unknown>(nombre: string): T {
|
|
28
|
+
return JSON.parse(readFileSync(join(DIR, nombre), "utf8")) as T;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Recorre todos los objetos de un JSON y llama a `visita` con cada uno. */
|
|
32
|
+
function recorrer(valor: unknown, visita: (objeto: Record<string, unknown>) => void): void {
|
|
33
|
+
if (Array.isArray(valor)) {
|
|
34
|
+
valor.forEach((item) => recorrer(item, visita));
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
if (valor && typeof valor === "object") {
|
|
38
|
+
visita(valor as Record<string, unknown>);
|
|
39
|
+
Object.values(valor).forEach((item) => recorrer(item, visita));
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Todas las claves `{ i18n }` de un JSON. */
|
|
44
|
+
function clavesI18n(json: unknown): string[] {
|
|
45
|
+
const claves: string[] = [];
|
|
46
|
+
recorrer(json, (objeto) => {
|
|
47
|
+
if (typeof objeto.i18n === "string") claves.push(objeto.i18n);
|
|
48
|
+
});
|
|
49
|
+
return claves;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Todos los nombres de acción (`{ action: "…" }`) de un JSON. */
|
|
53
|
+
function nombresDeAccion(json: unknown): string[] {
|
|
54
|
+
const nombres: string[] = [];
|
|
55
|
+
recorrer(json, (objeto) => {
|
|
56
|
+
if (typeof objeto.action === "string") nombres.push(objeto.action);
|
|
57
|
+
});
|
|
58
|
+
return nombres;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Todos los `{ $param: "…" }` de un JSON. */
|
|
62
|
+
function parametrosUsados(json: unknown): string[] {
|
|
63
|
+
const nombres: string[] = [];
|
|
64
|
+
recorrer(json, (objeto) => {
|
|
65
|
+
if (typeof objeto.$param === "string") nombres.push(objeto.$param);
|
|
66
|
+
});
|
|
67
|
+
return nombres;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Nodos `slot` de un JSON, por nombre de slot. */
|
|
71
|
+
function slotsUsados(json: unknown): string[] {
|
|
72
|
+
const nombres: string[] = [];
|
|
73
|
+
recorrer(json, (objeto) => {
|
|
74
|
+
if (objeto.type === "slot" && typeof (objeto.props as { name?: unknown } | undefined)?.name === "string") {
|
|
75
|
+
nombres.push((objeto.props as { name: string }).name);
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
return nombres;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Todos los `{ $source: "…" }` de un JSON. */
|
|
82
|
+
function fuentesUsadas(json: unknown): string[] {
|
|
83
|
+
const nombres: string[] = [];
|
|
84
|
+
recorrer(json, (objeto) => {
|
|
85
|
+
if (typeof objeto.$source === "string") nombres.push(objeto.$source);
|
|
86
|
+
});
|
|
87
|
+
return nombres;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Páginas de pantalla: todos los JSON salvo el shell y las rutas. */
|
|
91
|
+
const PAGINAS = readdirSync(DIR).filter((archivo) => archivo.endsWith(".json") && !NO_SON_PAGINAS.includes(archivo));
|
|
92
|
+
|
|
93
|
+
const rutas = leer<RoutesSchema>("routes.json");
|
|
94
|
+
const planas = flattenRoutes(rutas.routes);
|
|
95
|
+
|
|
96
|
+
/** Pantalla que pinta una página, según la ruta que la declara (params a "1"). */
|
|
97
|
+
function ejemploDePagina(pagina: string) {
|
|
98
|
+
const ruta = planas.find((item) => item.page === pagina);
|
|
99
|
+
if (!ruta) return null;
|
|
100
|
+
const segmentos = ruta.segments.map((segmento) => (segmento.startsWith(":") ? "1" : segmento));
|
|
101
|
+
return matchRoute(planas, segmentos, ruta.query);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Diccionario de traducción en español para resolver `{ i18n }` al renderizar. */
|
|
105
|
+
const es = sandboxMessages.es as Record<string, string>;
|
|
106
|
+
const en = sandboxMessages.en as Record<string, string>;
|
|
107
|
+
|
|
108
|
+
describe("routes.json", () => {
|
|
109
|
+
it("es un documento de rutas válido", () => {
|
|
110
|
+
expect(validateRoutes(rutas)).toEqual([]);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("cada ruta hoja pinta una página que existe (docs es la página TSX)", () => {
|
|
114
|
+
for (const ruta of planas) {
|
|
115
|
+
if (ruta.page === "docs") continue;
|
|
116
|
+
expect(existsSync(join(DIR, `${ruta.page}.json`)), `${ruta.id} → ${ruta.page}.json`).toBe(true);
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("no hay páginas huérfanas: cada JSON de pantalla tiene una ruta que lo pinta", () => {
|
|
121
|
+
const paginasConRuta = new Set(planas.map((ruta) => ruta.page));
|
|
122
|
+
for (const archivo of PAGINAS) {
|
|
123
|
+
expect(paginasConRuta.has(archivo.replace(/\.json$/, "")), `${archivo} sin ruta`).toBe(true);
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("los ids de ruta son únicos", () => {
|
|
128
|
+
const ids = planas.map((ruta) => ruta.id);
|
|
129
|
+
expect(new Set(ids).size).toBe(ids.length);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("cada clave de traducción existe en español e inglés", () => {
|
|
133
|
+
for (const clave of clavesI18n(rutas)) {
|
|
134
|
+
expect(es[clave], `es: ${clave}`).toBeTruthy();
|
|
135
|
+
expect(en[clave], `en: ${clave}`).toBeTruthy();
|
|
136
|
+
}
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("cada ruta con :param declara un parámetro que su página usa, y viceversa", () => {
|
|
140
|
+
for (const ruta of planas) {
|
|
141
|
+
const declarados = ruta.segments.filter((segmento) => segmento.startsWith(":")).map((segmento) => segmento.slice(1));
|
|
142
|
+
if (declarados.length === 0 || ruta.page === "docs") continue;
|
|
143
|
+
const pagina = leer(`${ruta.page}.json`);
|
|
144
|
+
for (const usado of parametrosUsados(pagina)) {
|
|
145
|
+
expect(declarados, `${ruta.page} usa $param "${usado}"`).toContain(usado);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
describe.each(PAGINAS)("%s", (archivo) => {
|
|
152
|
+
const pagina = leer(archivo);
|
|
153
|
+
const nombre = archivo.replace(/\.json$/, "");
|
|
154
|
+
|
|
155
|
+
it("es un esquema válido, sin nodos pendientes", () => {
|
|
156
|
+
const informe = validateSchema(pagina);
|
|
157
|
+
expect(informe.errors, JSON.stringify(informe.errors)).toHaveLength(0);
|
|
158
|
+
expect(informe.verdict).toBe("valid");
|
|
159
|
+
expect(informe.pendingNodes).toBe(0);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it("solo usa acciones que el host resuelve", () => {
|
|
163
|
+
for (const accion of nombresDeAccion(pagina)) {
|
|
164
|
+
expect(DEMO_ACTIONS as readonly string[], `acción "${accion}"`).toContain(accion);
|
|
165
|
+
}
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it("cada clave de traducción existe en español e inglés", () => {
|
|
169
|
+
for (const clave of clavesI18n(pagina)) {
|
|
170
|
+
expect(es[clave], `es: ${clave}`).toBeTruthy();
|
|
171
|
+
expect(en[clave], `en: ${clave}`).toBeTruthy();
|
|
172
|
+
}
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it("cada slot nombra un bloque que el host registra", () => {
|
|
176
|
+
for (const slot of slotsUsados(pagina)) {
|
|
177
|
+
expect(DEMO_SLOT_NAMES, `slot "${slot}"`).toContain(slot);
|
|
178
|
+
}
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it("cada $source usa una fuente declarada en `data`, con un handler que existe", () => {
|
|
182
|
+
const data = ((pagina as { data?: Record<string, { handler: string }> }).data ?? {}) as Record<string, { handler: string }>;
|
|
183
|
+
for (const fuente of fuentesUsadas(pagina)) {
|
|
184
|
+
expect(data[fuente], `fuente "${fuente}" sin declarar en data`).toBeTruthy();
|
|
185
|
+
expect(isDemoSource(data[fuente].handler), `handler "${data[fuente].handler}"`).toBe(true);
|
|
186
|
+
}
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it("cada $param tiene una ruta que lo proporciona", () => {
|
|
190
|
+
const ejemplo = ejemploDePagina(nombre);
|
|
191
|
+
if (parametrosUsados(pagina).length === 0) return;
|
|
192
|
+
expect(ejemplo, `${nombre} necesita una ruta con :param`).not.toBeNull();
|
|
193
|
+
for (const usado of parametrosUsados(pagina)) {
|
|
194
|
+
expect(ejemplo?.params, `$param "${usado}"`).toHaveProperty(usado);
|
|
195
|
+
}
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it("se pinta con el motor sin avisos (ni errores ni advertencias)", () => {
|
|
199
|
+
const issues: JsonEngineIssue[] = [];
|
|
200
|
+
const ejemplo = ejemploDePagina(nombre);
|
|
201
|
+
const slots: SlotRegistry = Object.fromEntries(
|
|
202
|
+
DEMO_SLOT_NAMES.map((nombreSlot) => [nombreSlot, () => <div data-slot={nombreSlot} />])
|
|
203
|
+
);
|
|
204
|
+
|
|
205
|
+
render(
|
|
206
|
+
<ITSchemaRenderer
|
|
207
|
+
schema={pagina as never}
|
|
208
|
+
params={ejemplo?.params ?? {}}
|
|
209
|
+
// Como el host real: todos los efectos del demo están implementados.
|
|
210
|
+
effects={{ toast: () => undefined, navigate: () => undefined, refresh: () => undefined, openOverlay: () => undefined, closeOverlay: () => undefined }}
|
|
211
|
+
// Como el host real: solo las acciones de DEMO_ACTIONS tienen handler.
|
|
212
|
+
resolveAction={(_node, _prop, accion) => {
|
|
213
|
+
const nombre = (accion as { action?: unknown } | null)?.action;
|
|
214
|
+
return typeof nombre === "string" && (DEMO_ACTIONS as readonly string[]).includes(nombre) ? () => undefined : undefined;
|
|
215
|
+
}}
|
|
216
|
+
slots={slots}
|
|
217
|
+
dataHandlers={{
|
|
218
|
+
usuarios: async () => ({ data: [], total: 0 }),
|
|
219
|
+
pedidos: async () => ({ data: [], total: 0 }),
|
|
220
|
+
productos: async () => ({ data: [], total: 0 }),
|
|
221
|
+
}}
|
|
222
|
+
resolveValue={(valor) =>
|
|
223
|
+
valor && typeof valor === "object" && typeof (valor as { i18n?: unknown }).i18n === "string"
|
|
224
|
+
? es[(valor as { i18n: string }).i18n]
|
|
225
|
+
: valor
|
|
226
|
+
}
|
|
227
|
+
onIssue={(issue) => issues.push(issue)}
|
|
228
|
+
/>
|
|
229
|
+
);
|
|
230
|
+
|
|
231
|
+
expect(issues.map((issue) => `${issue.code} ${issue.path}: ${issue.message}`)).toEqual([]);
|
|
232
|
+
});
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
describe("layout.json", () => {
|
|
236
|
+
it("declara la marca y nada más: el menú sale de routes.json", () => {
|
|
237
|
+
const layout = leer<{ layout: { brand?: { text?: string }; modules?: unknown } }>("layout.json");
|
|
238
|
+
expect(layout.layout.brand?.text).toBeTruthy();
|
|
239
|
+
expect(layout.layout.modules).toBeUndefined();
|
|
240
|
+
});
|
|
241
|
+
});
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { describe, expect, it } from "vitest";
|
|
4
|
+
import { buildNavigationTree, flattenRoutes, matchRoute, routeHref, validateRoutes, type NavNode, type RoutesSchema } from "../../json-renderer";
|
|
5
|
+
import { sandboxMessages } from "../../sandbox/i18n";
|
|
6
|
+
|
|
7
|
+
const DIR = join(process.cwd(), "public/demo/app");
|
|
8
|
+
const rutas = JSON.parse(readFileSync(join(DIR, "routes.json"), "utf8")) as RoutesSchema;
|
|
9
|
+
const planas = flattenRoutes(rutas.routes);
|
|
10
|
+
|
|
11
|
+
/** Todas las claves `{ i18n }` de un árbol de navegación. */
|
|
12
|
+
function claves(nodos: NavNode[]): string[] {
|
|
13
|
+
return nodos.flatMap((nodo) => [
|
|
14
|
+
...(typeof nodo.label === "object" && nodo.label !== null && "i18n" in nodo.label ? [String(nodo.label.i18n)] : []),
|
|
15
|
+
...(nodo.children ? claves(nodo.children) : []),
|
|
16
|
+
]);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
describe("routes.json del demo", () => {
|
|
20
|
+
it("es un documento de rutas válido", () => {
|
|
21
|
+
expect(validateRoutes(rutas)).toEqual([]);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("cada página de una hoja tiene su JSON (docs es la página TSX)", () => {
|
|
25
|
+
for (const ruta of planas) {
|
|
26
|
+
if (ruta.page === "docs") continue;
|
|
27
|
+
expect(existsSync(join(DIR, `${ruta.page}.json`)), `${ruta.id} → ${ruta.page}.json`).toBe(true);
|
|
28
|
+
}
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("todas las etiquetas del menú tienen traducción en el catálogo del sandbox", () => {
|
|
32
|
+
for (const clave of claves(buildNavigationTree(rutas.routes))) {
|
|
33
|
+
expect(sandboxMessages.es[clave], clave).toBeTruthy();
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("el menú sale de las rutas: grupos con sus sub-items y la marca de pedidos", () => {
|
|
38
|
+
const arbol = buildNavigationTree(rutas.routes);
|
|
39
|
+
expect(arbol.map((entrada) => entrada.id)).toEqual(["inicio", "usuarios", "pedidos", "productos", "reportes", "docs"]);
|
|
40
|
+
expect(arbol.find((entrada) => entrada.id === "pedidos")?.badge).toBe(11);
|
|
41
|
+
expect(arbol.find((entrada) => entrada.id === "usuarios")?.children?.map((hijo) => hijo.href)).toEqual([
|
|
42
|
+
"/usuarios",
|
|
43
|
+
"/usuarios?estado=Activo",
|
|
44
|
+
"/usuarios?estado=Inactivo",
|
|
45
|
+
]);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("cada dirección del menú casa con la ruta que la declara", () => {
|
|
49
|
+
for (const ruta of planas) {
|
|
50
|
+
const casada = matchRoute(planas, ruta.segments, ruta.query);
|
|
51
|
+
expect(casada?.route.id, routeHref(ruta)).toBe(ruta.id);
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("la ruta de detalle recibe el id como :param y no compite con /usuarios", () => {
|
|
56
|
+
expect(matchRoute(planas, ["usuarios", "7"])).toMatchObject({ route: { id: "usuario-detalle" }, params: { id: "7" } });
|
|
57
|
+
expect(matchRoute(planas, ["usuarios"])?.route.id).toBe("u-todos");
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("una query sin ruta específica cae en la ruta base del mismo path", () => {
|
|
61
|
+
expect(matchRoute(planas, ["pedidos"], { estado: "Cancelado" })?.route.id).toBe("p-todos");
|
|
62
|
+
});
|
|
63
|
+
});
|
|
@@ -10,7 +10,9 @@ function leerJson(nombre: string): unknown {
|
|
|
10
10
|
return JSON.parse(readFileSync(join(DIR, nombre), "utf8"));
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
-
|
|
13
|
+
// layout.json (marca) y routes.json (rutas) no son pantallas: se prueban aparte.
|
|
14
|
+
const NO_SON_PAGINAS = ["layout.json", "routes.json"];
|
|
15
|
+
const paginas = readdirSync(DIR).filter((archivo) => archivo.endsWith(".json") && !NO_SON_PAGINAS.includes(archivo));
|
|
14
16
|
|
|
15
17
|
describe("JSON del demo", () => {
|
|
16
18
|
it("hay páginas y un layout", () => {
|
|
@@ -25,23 +27,10 @@ describe("JSON del demo", () => {
|
|
|
25
27
|
expect(informe.pendingNodes).toBe(0);
|
|
26
28
|
});
|
|
27
29
|
|
|
28
|
-
it("el layout declara marca
|
|
29
|
-
const layout = leerJson("layout.json") as {
|
|
30
|
-
layout: { brand?: { text?: string }; modules: { id: string; page: string; icon?: string }[] };
|
|
31
|
-
};
|
|
30
|
+
it("el layout declara la marca; el menú vive en routes.json", () => {
|
|
31
|
+
const layout = leerJson("layout.json") as { layout: { brand?: { text?: string }; modules?: unknown } };
|
|
32
32
|
expect(layout.layout.brand?.text).toBeTruthy();
|
|
33
|
-
expect(layout.layout.modules.
|
|
34
|
-
for (const modulo of layout.layout.modules) {
|
|
35
|
-
expect(modulo.id, "cada módulo necesita id").toBeTruthy();
|
|
36
|
-
expect(modulo.page, `${modulo.id} necesita page`).toBeTruthy();
|
|
37
|
-
}
|
|
38
|
-
// Todo módulo apunta a una página que existe (docs no tiene archivo propio).
|
|
39
|
-
const ids = new Set(layout.layout.modules.map((modulo) => modulo.page));
|
|
40
|
-
expect(ids.has("docs")).toBe(true);
|
|
41
|
-
for (const pagina of ids) {
|
|
42
|
-
if (pagina === "docs") continue;
|
|
43
|
-
expect(paginas, `falta ${pagina}.json`).toContain(`${pagina}.json`);
|
|
44
|
-
}
|
|
33
|
+
expect(layout.layout.modules, "los módulos ya no van en layout.json").toBeUndefined();
|
|
45
34
|
});
|
|
46
35
|
|
|
47
36
|
it("las páginas declaran título y descripción traducibles", () => {
|