@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.
Files changed (61) hide show
  1. package/dist/index.cjs +1621 -594
  2. package/dist/index.cjs.map +1 -1
  3. package/dist/index.css +1 -1
  4. package/dist/index.d.cts +366 -12
  5. package/dist/index.d.ts +366 -12
  6. package/dist/index.js +1331 -313
  7. package/dist/index.js.map +1 -1
  8. package/dist/layered.css +1 -1
  9. package/package.json +4 -2
  10. package/src/App.tsx +6 -8
  11. package/src/components/atoms/flex/flex.props.ts +3 -1
  12. package/src/components/atoms/flex/flex.tsx +11 -3
  13. package/src/components/atoms/textarea/textarea.props.ts +4 -0
  14. package/src/components/atoms/textarea/textarea.tsx +13 -3
  15. package/src/components/molecules/card/card.props.ts +2 -0
  16. package/src/components/molecules/card/card.tsx +15 -2
  17. package/src/components/molecules/line-bar-chart/line-bar-chart.props.ts +4 -0
  18. package/src/components/molecules/line-bar-chart/line-bar-chart.tsx +57 -41
  19. package/src/components/organisms/dropfile/dropfile.props.ts +8 -2
  20. package/src/components/organisms/dropfile/dropfile.tsx +17 -0
  21. package/src/i18n/locales/en.ts +7 -0
  22. package/src/i18n/locales/es.ts +7 -0
  23. package/src/index.ts +31 -3
  24. package/src/json-renderer/actions/effects.test.tsx +115 -0
  25. package/src/json-renderer/actions/effects.ts +291 -0
  26. package/src/json-renderer/actions/effectsChain.test.ts +117 -0
  27. package/src/json-renderer/binding/binding.test.tsx +168 -0
  28. package/src/json-renderer/binding/resolveValue.ts +193 -0
  29. package/src/json-renderer/estado-acciones.test.tsx +166 -0
  30. package/src/json-renderer/form/FormNode.tsx +92 -0
  31. package/src/json-renderer/form/form.test.tsx +148 -0
  32. package/src/json-renderer/form/formLength.test.tsx +99 -0
  33. package/src/json-renderer/form/rules.ts +162 -0
  34. package/src/json-renderer/index.ts +21 -0
  35. package/src/json-renderer/kernel/SchemaRenderer.tsx +83 -7
  36. package/src/json-renderer/kernel/capabilities.ts +30 -0
  37. package/src/json-renderer/kernel/renderNode.tsx +256 -3
  38. package/src/json-renderer/kernel/schemaRenderer.props.ts +29 -3
  39. package/src/json-renderer/kernel/slots.test.tsx +104 -0
  40. package/src/json-renderer/kernel/slots.ts +34 -0
  41. package/src/json-renderer/kernel/types.ts +4 -0
  42. package/src/json-renderer/routing/match.ts +82 -0
  43. package/src/json-renderer/routing/navigation.ts +71 -0
  44. package/src/json-renderer/routing/routes.ts +179 -0
  45. package/src/json-renderer/routing/routing.test.ts +147 -0
  46. package/src/json-renderer/seguridad.test.tsx +299 -0
  47. package/src/json-renderer/shared/errors.ts +1 -0
  48. package/src/json-renderer/stream/tolerantParse.ts +5 -0
  49. package/src/json-renderer/stream/useSchemaStream.test.tsx +143 -0
  50. package/src/json-renderer/stream/useSchemaStream.ts +227 -0
  51. package/src/json-renderer/validation/jsonSchema.test.ts +105 -0
  52. package/src/json-renderer/validation/jsonSchema.ts +317 -0
  53. package/src/json-renderer/validation/validate.ts +17 -0
  54. package/src/sandbox/i18n.ts +38 -0
  55. package/src/sandbox/useHashRoute.ts +2 -1
  56. package/src/showcases/DemoJsonDashboard.tsx +218 -103
  57. package/src/showcases/DemoJsonDocs.tsx +11 -11
  58. package/src/showcases/JsonScreenShowcase.tsx +43 -53
  59. package/src/showcases/demoApp/demoJson.test.tsx +241 -0
  60. package/src/showcases/demoApp/routes.test.ts +63 -0
  61. package/src/showcases/demoApp/schemas.test.ts +6 -17
@@ -0,0 +1,143 @@
1
+ import React from "react";
2
+ import { describe, expect, it } from "vitest";
3
+ import { renderHook, waitFor } from "@testing-library/react";
4
+ import { useSchemaStream, type StreamSource } from "./useSchemaStream";
5
+
6
+ /** Documento de ejemplo que llega en trozos. */
7
+ const DOCUMENTO = JSON.stringify({
8
+ version: 1,
9
+ id: "stream",
10
+ content: [
11
+ { type: "ITText", text: "Hola" },
12
+ { type: "ITText", text: "Mundo" },
13
+ ],
14
+ });
15
+
16
+ /** Parte un texto en trozos de tamaño fijo, con una pausa entre ellos. */
17
+ async function* trozos(texto: string, tamano: number, pausaMs = 1): AsyncGenerator<string> {
18
+ for (let indice = 0; indice < texto.length; indice += tamano) {
19
+ await new Promise((resolve) => setTimeout(resolve, pausaMs));
20
+ yield texto.slice(indice, indice + tamano);
21
+ }
22
+ }
23
+
24
+ /** Espera a que la fuente termine de emitir. */
25
+ const hastaTerminar = (result: { current: { streaming: boolean } }) => waitFor(() => expect(result.current.streaming).toBe(false));
26
+
27
+ describe("useSchemaStream: fuentes", () => {
28
+ it("una cadena completa se pinta de una vez, sin estar en streaming", async () => {
29
+ const { result } = renderHook(() => useSchemaStream(DOCUMENTO));
30
+ await hastaTerminar(result);
31
+ expect(result.current.tree?.id).toBe("stream");
32
+ expect(result.current.status).toBe("complete");
33
+ expect(result.current.issues).toEqual([]);
34
+ expect(result.current.text).toBe(DOCUMENTO);
35
+ });
36
+
37
+ it("una cadena sin cerrar avisa con JR080 y conserva lo que sí es válido", async () => {
38
+ const { result } = renderHook(() => useSchemaStream('{"version":1,"id":"x","content":{"type":"ITText","text":"ho'));
39
+ await hastaTerminar(result);
40
+ expect(result.current.status).not.toBe("complete");
41
+ expect(result.current.issues.map((aviso) => aviso.code)).toContain("JR080");
42
+ expect(result.current.tree).not.toBeNull();
43
+ });
44
+
45
+ it("un iterable asíncrono muestra árboles parciales y termina con el documento entero", async () => {
46
+ const vistos: (string | null)[] = [];
47
+ // La fuente se crea fuera del render: una fuente nueva en cada render relanzaría el stream.
48
+ const fuente = trozos(DOCUMENTO, 9);
49
+ const { result } = renderHook(() => {
50
+ const estado = useSchemaStream(fuente);
51
+ vistos.push(estado.tree?.id ?? null);
52
+ return estado;
53
+ });
54
+ await hastaTerminar(result);
55
+ expect(result.current.text).toBe(DOCUMENTO);
56
+ expect(result.current.status).toBe("complete");
57
+ expect(result.current.issues).toEqual([]);
58
+ // Hubo renders intermedios con árbol: el documento se fue pintando.
59
+ expect(vistos.filter((id) => id === "stream").length).toBeGreaterThan(1);
60
+ });
61
+
62
+ it("un ReadableStream de texto funciona igual que un iterable", async () => {
63
+ const readable = new ReadableStream<string>({
64
+ start(controller) {
65
+ for (let indice = 0; indice < DOCUMENTO.length; indice += 12) controller.enqueue(DOCUMENTO.slice(indice, indice + 12));
66
+ controller.close();
67
+ },
68
+ });
69
+ const { result } = renderHook(() => useSchemaStream(readable));
70
+ await hastaTerminar(result);
71
+ expect(result.current.text).toBe(DOCUMENTO);
72
+ expect(result.current.status).toBe("complete");
73
+ });
74
+
75
+ it("si la fuente falla a mitad, se conserva el último árbol válido y se avisa con JR080", async () => {
76
+ async function* fallando(): AsyncGenerator<string> {
77
+ yield DOCUMENTO.slice(0, 60);
78
+ await new Promise((resolve) => setTimeout(resolve, 1));
79
+ throw new Error("red caída");
80
+ }
81
+ const fuente = fallando();
82
+ const { result } = renderHook(() => useSchemaStream(fuente));
83
+ await hastaTerminar(result);
84
+ const aviso = result.current.issues.find((item) => item.code === "JR080");
85
+ expect(aviso?.severity).toBe("error");
86
+ expect(aviso?.message).toContain("red caída");
87
+ });
88
+
89
+ it("si el texto deja de ser recuperable, el árbol no se rompe: se queda el último válido", async () => {
90
+ const fuente = trozos(DOCUMENTO.slice(0, 40) + "@@@", 8);
91
+ const { result } = renderHook(() => useSchemaStream(fuente));
92
+ await hastaTerminar(result);
93
+ expect(result.current.tree?.id).toBe("stream");
94
+ });
95
+
96
+ it("sin fuente el estado está vacío", () => {
97
+ const { result } = renderHook(() => useSchemaStream(null));
98
+ expect(result.current.tree).toBeNull();
99
+ expect(result.current.streaming).toBe(false);
100
+ });
101
+ });
102
+
103
+ describe("useSchemaStream: montajes y cancelación", () => {
104
+ it("con StrictMode (doble montaje) no se pierde ni se duplica ningún trozo", async () => {
105
+ const fuente = trozos(DOCUMENTO, 7, 2);
106
+ const { result } = renderHook(() => useSchemaStream(fuente), {
107
+ wrapper: ({ children }) => <React.StrictMode>{children}</React.StrictMode>,
108
+ });
109
+ await hastaTerminar(result);
110
+ expect(result.current.text).toBe(DOCUMENTO);
111
+ expect(result.current.issues).toEqual([]);
112
+ });
113
+
114
+ it("al desmontar, la fuente recibe return() y deja de emitir", async () => {
115
+ let cerrada = false;
116
+ async function* larga(): AsyncGenerator<string> {
117
+ try {
118
+ for (let indice = 0; indice < 1000; indice += 1) {
119
+ await new Promise((resolve) => setTimeout(resolve, 1));
120
+ yield "x";
121
+ }
122
+ } finally {
123
+ cerrada = true;
124
+ }
125
+ }
126
+ const fuente = larga();
127
+ const { unmount } = renderHook(() => useSchemaStream(fuente));
128
+ await new Promise((resolve) => setTimeout(resolve, 15));
129
+ unmount();
130
+ await waitFor(() => expect(cerrada).toBe(true));
131
+ });
132
+
133
+ it("cambiar de fuente reinicia el estado con la nueva", async () => {
134
+ const { result, rerender } = renderHook(({ fuente }: { fuente: StreamSource }) => useSchemaStream(fuente), {
135
+ initialProps: { fuente: '{"version":1,"id":"primera","content":{"type":"ITText","text":"a"}}' },
136
+ });
137
+ await hastaTerminar(result);
138
+ expect(result.current.tree?.id).toBe("primera");
139
+
140
+ rerender({ fuente: '{"version":1,"id":"segunda","content":{"type":"ITText","text":"b"}}' });
141
+ await waitFor(() => expect(result.current.tree?.id).toBe("segunda"));
142
+ });
143
+ });
@@ -0,0 +1,227 @@
1
+ /**
2
+ * `useSchemaStream`: pinta un esquema mientras llega por trozos.
3
+ *
4
+ * Acepta una fuente completa (`string`), un iterable asíncrono o un `ReadableStream` de
5
+ * texto. Acumula el texto, lo parsea en modo tolerante con cada trozo y devuelve el último
6
+ * árbol válido. Lo que aún no está cerrado no se pinta a medias (ver `pendingFallback`).
7
+ *
8
+ * Garantías:
9
+ *
10
+ * - **Sin pérdida con StrictMode:** el texto acumulado vive en un registro asociado a la
11
+ * fuente, no en el efecto. Si React monta dos veces, el segundo montaje continúa donde
12
+ * quedó el primero; ningún fragmento se pierde ni se duplica.
13
+ * - **Cancelación:** si el componente deja de usar la fuente, el registro se cierra y se
14
+ * devuelve el control a la fuente (`return()` del iterable, `cancel()` del stream).
15
+ * - **Sin árbol roto:** si el texto deja de ser recuperable, se conserva el último árbol
16
+ * válido. Un stream que falla o termina sin cerrar avisa con `JR080`.
17
+ *
18
+ * **La fuente debe ser estable entre renders.** Crea el iterable o el stream una vez
19
+ * (en un estado, o fuera del componente) y pásalo tal cual. Crearlo dentro del render
20
+ * arranca un stream nuevo en cada render.
21
+ *
22
+ * @example
23
+ * const { tree, streaming, issues } = useSchemaStream(respuestaDelModelo);
24
+ * <ITSchemaRenderer schema={tree} onIssue={registrar} pendingFallback={<ITSkeleton />} />
25
+ */
26
+
27
+ import { useEffect, useState } from "react";
28
+ import type { ScreenSchema } from "../kernel/types";
29
+ import { issue, type JsonEngineIssue } from "../shared/errors";
30
+ import { parsePartialSchema, type StreamState } from "./tolerantParse";
31
+
32
+ /** Fuente de un esquema que llega por trozos. */
33
+ export type StreamSource = string | AsyncIterable<string> | ReadableStream<string>;
34
+
35
+ /** Estado inicial: nada recibido todavía. */
36
+ const VACIO: StreamState = {
37
+ tree: null,
38
+ status: "complete",
39
+ broken: false,
40
+ pendingNodes: 0,
41
+ streaming: false,
42
+ text: "",
43
+ issues: [],
44
+ };
45
+
46
+ /** Registro compartido de una fuente: texto acumulado, iterador y avisos. */
47
+ interface Grabacion {
48
+ texto: string;
49
+ iterador: AsyncIterator<string> | null;
50
+ terminado: boolean;
51
+ error: string | null;
52
+ avisos: JsonEngineIssue[];
53
+ /** Montajes que la están usando ahora. Si llega a cero, la fuente se cancela. */
54
+ activos: number;
55
+ /** Último árbol válido: se conserva cuando el texto deja de ser recuperable. */
56
+ ultimoValido: ScreenSchema | null;
57
+ /** `true` cuando los avisos de cierre ya se añadieron (para no duplicarlos). */
58
+ cerrada: boolean;
59
+ /** `true` cuando la fuente se cortó desde fuera: no es un fin de stream. */
60
+ cancelada: boolean;
61
+ }
62
+
63
+ /** Un registro por fuente. Al soltarse la fuente, el registro se libera solo. */
64
+ const grabaciones = new WeakMap<object, Grabacion>();
65
+
66
+ /** `true` cuando el valor es un iterable asíncrono. */
67
+ function esAsyncIterable(value: unknown): value is AsyncIterable<string> {
68
+ return value !== null && typeof value === "object" && typeof (value as AsyncIterable<string>)[Symbol.asyncIterator] === "function";
69
+ }
70
+
71
+ /** Convierte un `ReadableStream` de texto en un generador asíncrono. Cancelar el generador cancela el stream. */
72
+ async function* desdeReadable(readable: ReadableStream<string>): AsyncGenerator<string> {
73
+ const reader = readable.getReader();
74
+ try {
75
+ for (;;) {
76
+ const { done, value } = await reader.read();
77
+ if (done) return;
78
+ yield value;
79
+ }
80
+ } finally {
81
+ await reader.cancel().catch(() => undefined);
82
+ }
83
+ }
84
+
85
+ /** Mensaje legible de un error de la fuente. */
86
+ function mensajeDe(error: unknown): string {
87
+ return error instanceof Error ? error.message : String(error);
88
+ }
89
+
90
+ /** Crea el registro de una fuente nueva. */
91
+ function crearGrabacion(source: StreamSource): Grabacion {
92
+ const iterador: AsyncIterator<string> | null =
93
+ typeof source === "string" ? null : esAsyncIterable(source) ? source[Symbol.asyncIterator]() : desdeReadable(source)[Symbol.asyncIterator]();
94
+ return {
95
+ texto: typeof source === "string" ? source : "",
96
+ iterador,
97
+ terminado: typeof source === "string",
98
+ error: null,
99
+ avisos: [],
100
+ activos: 0,
101
+ ultimoValido: null,
102
+ cerrada: false,
103
+ cancelada: false,
104
+ };
105
+ }
106
+
107
+ /** Estado que ve el componente a partir del texto acumulado. */
108
+ function estadoDe(grab: Grabacion, streaming: boolean): StreamState {
109
+ const parcial = parsePartialSchema(grab.texto);
110
+ if (!parcial.broken && parcial.tree) grab.ultimoValido = parcial.tree;
111
+ return {
112
+ tree: parcial.broken ? grab.ultimoValido : parcial.tree,
113
+ status: parcial.status,
114
+ broken: parcial.broken,
115
+ pendingNodes: parcial.pendingNodes,
116
+ streaming,
117
+ text: grab.texto,
118
+ issues: [...grab.avisos],
119
+ };
120
+ }
121
+
122
+ /** Al terminar la fuente: avisa si el stream falló o si el JSON quedó sin cerrar. */
123
+ function cerrarGrabacion(grab: Grabacion): void {
124
+ if (grab.cerrada || grab.cancelada) return;
125
+ grab.cerrada = true;
126
+ if (grab.error !== null) {
127
+ grab.avisos.push(issue("JR080", `El stream falló: ${grab.error}. Se conserva el último árbol válido.`, "", { severity: "error" }));
128
+ return;
129
+ }
130
+ const parcial = parsePartialSchema(grab.texto);
131
+ if (parcial.broken || parcial.status !== "complete") {
132
+ grab.avisos.push(
133
+ issue("JR080", "El stream terminó con el JSON sin cerrar. Se conserva el último árbol válido.", "", {
134
+ severity: "warning",
135
+ hint: "Revisa que el emisor cierre todas las llaves y corchetes.",
136
+ })
137
+ );
138
+ }
139
+ }
140
+
141
+ /**
142
+ * Consume la fuente sin perder fragmentos y avisa a cada trozo (`onTrozo`) y al terminar.
143
+ *
144
+ * Un trozo que ya se pidió se guarda aunque este montaje se haya soltado: el siguiente
145
+ * montaje lo encuentra en el registro. Así StrictMode no pierde nada.
146
+ */
147
+ async function consumir(grab: Grabacion, cancelado: () => boolean, onTrozo: () => void): Promise<void> {
148
+ while (!grab.terminado && grab.iterador) {
149
+ let paso: IteratorResult<string>;
150
+ try {
151
+ paso = await grab.iterador.next();
152
+ } catch (error) {
153
+ grab.error = mensajeDe(error);
154
+ grab.terminado = true;
155
+ break;
156
+ }
157
+ if (paso.done) {
158
+ grab.terminado = true;
159
+ break;
160
+ }
161
+ grab.texto += String(paso.value);
162
+ if (!cancelado()) onTrozo();
163
+ if (cancelado()) return;
164
+ }
165
+ if (grab.terminado) cerrarGrabacion(grab);
166
+ }
167
+
168
+ /**
169
+ * Devuelve el estado de un esquema que llega por trozos.
170
+ *
171
+ * @example
172
+ * const estado = useSchemaStream(stream);
173
+ * estado.tree; // árbol pintable, o null si aún no hay nada válido
174
+ * estado.streaming; // true mientras la fuente sigue emitiendo
175
+ */
176
+ export function useSchemaStream(source: StreamSource | null | undefined): StreamState {
177
+ const [estado, setEstado] = useState<StreamState>(VACIO);
178
+
179
+ useEffect(() => {
180
+ if (source === null || source === undefined) {
181
+ setEstado(VACIO);
182
+ return;
183
+ }
184
+
185
+ // Una cadena no puede ser clave de WeakMap, y ya está entera: basta un registro efímero.
186
+ // Los iterables y los streams se registran para sobrevivir a StrictMode.
187
+ let registro: Grabacion;
188
+ if (typeof source === "string") {
189
+ registro = crearGrabacion(source);
190
+ } else {
191
+ const existente = grabaciones.get(source);
192
+ registro = existente ?? crearGrabacion(source);
193
+ if (!existente) grabaciones.set(source, registro);
194
+ }
195
+ registro.activos += 1;
196
+
197
+ let cancelado = false;
198
+ // Primero se arranca el proceso: una cadena se cierra de forma síncrona (y sus avisos
199
+ // quedan ya en el registro), así que el estado inicial los incluye.
200
+ const proceso = consumir(
201
+ registro,
202
+ () => cancelado,
203
+ () => setEstado(estadoDe(registro, true))
204
+ );
205
+ setEstado(estadoDe(registro, !registro.terminado));
206
+
207
+ void proceso.then(() => {
208
+ if (!cancelado) setEstado(estadoDe(registro, false));
209
+ });
210
+
211
+ return () => {
212
+ cancelado = true;
213
+ registro.activos -= 1;
214
+ // Si nadie más usa la fuente tras el ciclo de montaje (StrictMode monta dos veces de
215
+ // forma síncrona), se devuelve el control a la fuente.
216
+ setTimeout(() => {
217
+ if (registro.activos === 0 && !registro.terminado && registro.iterador) {
218
+ registro.terminado = true;
219
+ registro.cancelada = true;
220
+ void registro.iterador.return?.(undefined);
221
+ }
222
+ }, 0);
223
+ };
224
+ }, [source]);
225
+
226
+ return estado;
227
+ }
@@ -0,0 +1,105 @@
1
+ import { readdirSync, readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { describe, expect, it } from "vitest";
4
+ import Ajv2020 from "ajv/dist/2020.js";
5
+ import { axzyCatalog } from "../catalog/axzyCatalog";
6
+ import { buildRoutesSchema, buildScreenSchema } from "./jsonSchema";
7
+
8
+ const DOCS = join(process.cwd(), "docs/json-renderer");
9
+ const DEMO = join(process.cwd(), "public/demo/app");
10
+
11
+ /** Lee un JSON versionado. */
12
+ const leer = (ruta: string) => JSON.parse(readFileSync(ruta, "utf8")) as Record<string, unknown>;
13
+
14
+ /** Validador compilado con las mismas opciones que el tester de `scripts/json-renderer/check.mjs`. */
15
+ function validador(schema: Record<string, unknown>) {
16
+ const ajv = new Ajv2020({ allErrors: true, strict: true, allowUnionTypes: true, strictRequired: false });
17
+ return ajv.compile(schema);
18
+ }
19
+
20
+ const screen = validador(buildScreenSchema());
21
+ const routes = validador(buildRoutesSchema());
22
+
23
+ describe("schemas generados", () => {
24
+ it("los archivos versionados coinciden con el generador (regenera con pnpm docs:json-renderer)", () => {
25
+ expect(leer(join(DOCS, "screen.schema.json"))).toEqual(JSON.parse(JSON.stringify(buildScreenSchema())));
26
+ expect(leer(join(DOCS, "routes.schema.json"))).toEqual(JSON.parse(JSON.stringify(buildRoutesSchema())));
27
+ });
28
+
29
+ it("cubre todo el catálogo: cada componente es un tipo válido", () => {
30
+ const schema = buildScreenSchema() as { $defs: { node: { properties: { type: { enum: string[] } } } } };
31
+ const tipos = schema.$defs.node.properties.type.enum;
32
+ for (const tipo of Object.keys(axzyCatalog.entries)) {
33
+ expect(tipos, tipo).toContain(tipo);
34
+ }
35
+ expect(tipos).toEqual(expect.arrayContaining(["slot", "form"]));
36
+ });
37
+
38
+ it("cada prop del catálogo aparece en el schema de su tipo (autocompletado completo)", () => {
39
+ const schema = buildScreenSchema() as { $defs: { node: { allOf: { if: { properties: { type: { const: string } } }; then: { properties: { props?: { properties?: Record<string, unknown> } } } }[] } } };
40
+ for (const [tipo, entrada] of Object.entries(axzyCatalog.entries)) {
41
+ const regla = schema.$defs.node.allOf.find((item) => item.if.properties.type.const === tipo);
42
+ expect(regla, tipo).toBeDefined();
43
+ const propiedades = regla?.then.properties.props?.properties ?? {};
44
+ for (const prop of Object.keys(entrada.props ?? {})) {
45
+ expect(propiedades, `${tipo}.${prop}`).toHaveProperty([prop]);
46
+ }
47
+ }
48
+ });
49
+ });
50
+
51
+ describe("documentos del demo cumplen el schema", () => {
52
+ const paginas = readdirSync(DEMO).filter((archivo) => archivo.endsWith(".json") && archivo !== "layout.json" && archivo !== "routes.json");
53
+
54
+ it.each(paginas)("%s es una pantalla válida", (archivo) => {
55
+ const ok = screen(leer(join(DEMO, archivo)));
56
+ expect(ok, JSON.stringify(screen.errors)).toBe(true);
57
+ });
58
+
59
+ it("routes.json es un documento de rutas válido", () => {
60
+ expect(routes(leer(join(DEMO, "routes.json"))), JSON.stringify(routes.errors)).toBe(true);
61
+ });
62
+ });
63
+
64
+ describe("el schema rechaza lo que el motor no pinta", () => {
65
+ const pantalla = (content: unknown) => ({ version: 1, id: "x", content });
66
+
67
+ it("un tipo fuera del catálogo", () => {
68
+ expect(screen(pantalla({ type: "ITInventado" }))).toBe(false);
69
+ });
70
+
71
+ it("una prop que el componente no declara", () => {
72
+ expect(screen(pantalla({ type: "ITButton", props: { label: "Hola", colorRaro: "red" } }))).toBe(false);
73
+ });
74
+
75
+ it("un valor de enum fuera de su lista", () => {
76
+ expect(screen(pantalla({ type: "ITButton", props: { label: "Hola", size: "gigante" } }))).toBe(false);
77
+ });
78
+
79
+ it("un slot sin nombre", () => {
80
+ expect(screen(pantalla({ type: "slot", props: { titulo: "x" } }))).toBe(false);
81
+ });
82
+
83
+ it("un campo de formulario con tipo no admitido", () => {
84
+ expect(screen(pantalla({ type: "form", props: { fields: [{ name: "edad", kind: "number" }] } }))).toBe(false);
85
+ });
86
+
87
+ it("una referencia que no existe, como `$row` fuera de una tabla", () => {
88
+ expect(screen(pantalla({ type: "ITText", text: { $row: "nombre" } }))).toBe(false);
89
+ });
90
+
91
+ it("un hijo en un componente que no admite hijos", () => {
92
+ expect(screen(pantalla({ type: "ITDivider", children: [{ type: "ITText", text: "y" }] }))).toBe(false);
93
+ expect(screen(pantalla({ type: "ITCard", children: [{ type: "ITText", text: "y" }] }))).toBe(true);
94
+ });
95
+
96
+ it("una ruta grupo con page, o una hoja sin page", () => {
97
+ expect(routes({ version: 1, routes: [{ id: "g", page: "x", children: [{ id: "h", page: "y" }] }] })).toBe(false);
98
+ expect(routes({ version: 1, routes: [{ id: "hoja" }] })).toBe(false);
99
+ });
100
+
101
+ it("acepta lo que sí pinta: referencias y textos traducibles", () => {
102
+ expect(screen(pantalla({ type: "ITText", text: { i18n: "clave" } }))).toBe(true);
103
+ expect(screen(pantalla({ type: "ITButton", props: { label: { $param: "x" }, size: "sm" } }))).toBe(true);
104
+ });
105
+ });