@axzydev/axzy_ui_system 1.7.0 → 1.7.1

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 (51) hide show
  1. package/dist/index.cjs +1738 -308
  2. package/dist/index.cjs.map +1 -1
  3. package/dist/index.css +1 -1
  4. package/dist/index.d.cts +739 -3
  5. package/dist/index.d.ts +739 -3
  6. package/dist/index.js +1629 -233
  7. package/dist/index.js.map +1 -1
  8. package/dist/layered.css +1 -1
  9. package/package.json +14 -7
  10. package/src/App.tsx +60 -2
  11. package/src/components/atoms/image/image.props.ts +2 -0
  12. package/src/components/atoms/image/image.tsx +3 -2
  13. package/src/components/organisms/dialog/dialog.props.ts +2 -0
  14. package/src/components/organisms/dialog/dialog.tsx +4 -3
  15. package/src/dev.css +6 -0
  16. package/src/index.ts +90 -1
  17. package/src/json-renderer/catalog/axzyCatalog.ts +362 -0
  18. package/src/json-renderer/catalog/catalog.props.ts +128 -0
  19. package/src/json-renderer/catalog/resolve.ts +225 -0
  20. package/src/json-renderer/index.ts +70 -0
  21. package/src/json-renderer/kernel/NodeBoundary.tsx +53 -0
  22. package/src/json-renderer/kernel/SchemaRenderer.tsx +197 -0
  23. package/src/json-renderer/kernel/kernel.test.tsx +341 -0
  24. package/src/json-renderer/kernel/partial.ts +92 -0
  25. package/src/json-renderer/kernel/renderNode.tsx +113 -0
  26. package/src/json-renderer/kernel/schemaRenderer.props.ts +42 -0
  27. package/src/json-renderer/kernel/types.ts +159 -0
  28. package/src/json-renderer/shared/errors.ts +92 -0
  29. package/src/json-renderer/shared/limits.ts +87 -0
  30. package/src/json-renderer/shared/paths.ts +66 -0
  31. package/src/json-renderer/shared/shared.test.ts +108 -0
  32. package/src/json-renderer/stream/tolerantParse.test.ts +152 -0
  33. package/src/json-renderer/stream/tolerantParse.ts +449 -0
  34. package/src/json-renderer/test/setup.ts +38 -0
  35. package/src/json-renderer/validation/migrate.ts +83 -0
  36. package/src/json-renderer/validation/validate.ts +161 -0
  37. package/src/json-renderer/validation/validation.test.ts +165 -0
  38. package/src/json-renderer/vocabulary/buildContract.ts +158 -0
  39. package/src/sandbox/axzyConfig.test.ts +40 -0
  40. package/src/sandbox/axzyConfig.ts +77 -0
  41. package/src/sandbox/i18n.ts +218 -0
  42. package/src/sandbox/navigation.ts +1 -0
  43. package/src/sandbox/useHashRoute.ts +39 -9
  44. package/src/showcases/DemoJsonDashboard.tsx +489 -0
  45. package/src/showcases/DemoJsonDocs.tsx +339 -0
  46. package/src/showcases/DemoJsonPortfolio.tsx +257 -0
  47. package/src/showcases/JsonScreenShowcase.tsx +341 -0
  48. package/src/showcases/demoApp/demoData.ts +181 -0
  49. package/src/showcases/demoApp/nodeTree.test.ts +39 -0
  50. package/src/showcases/demoApp/nodeTree.ts +35 -0
  51. package/src/showcases/demoApp/schemas.test.ts +54 -0
@@ -0,0 +1,159 @@
1
+ /**
2
+ * Contrato de datos del motor de pantallas.
3
+ *
4
+ * Un esquema describe **qué** pintar; nunca **cómo** comportarse: no hay código,
5
+ * ni expresiones, ni URLs. Todo lo que exige autoridad (componentes, props,
6
+ * datos, acciones, permisos) se resuelve contra registros del host.
7
+ */
8
+
9
+ /** Estado de un nodo respecto al stream. */
10
+ export type NodeStatus = "complete" | "pending";
11
+
12
+ /** Valor de una prop: literal, referencia a datos o texto traducible. */
13
+ export type SchemaValue =
14
+ | string
15
+ | number
16
+ | boolean
17
+ | null
18
+ | SchemaValue[]
19
+ | { [key: string]: SchemaValue };
20
+
21
+ /** Referencia declarativa a un dato. Ninguna provoca red por sí misma. */
22
+ export interface SchemaReference {
23
+ /** Valor de una fuente de datos declarada en `data`. */
24
+ $source?: string;
25
+ /** Valor del estado de pantalla. */
26
+ $state?: string;
27
+ /** Parámetro de la ruta. */
28
+ $param?: string;
29
+ /** Campo de la fila actual (dentro de una tabla). */
30
+ $row?: string;
31
+ /** Campo del formulario actual (dentro de un `form`). */
32
+ $form?: string;
33
+ /** Concatenación de valores ya resueltos. */
34
+ $concat?: SchemaValue[];
35
+ /** Proyección de un array: se queda con la clave indicada. */
36
+ $map?: { $source?: string; $state?: string; $param?: string; pick: string };
37
+ /** Longitud de un array o número de claves de un objeto. */
38
+ $count?: SchemaValue;
39
+ /** Primer valor definido y no nulo. */
40
+ $coalesce?: SchemaValue[];
41
+ }
42
+
43
+ /** Condicional declarativo, sin lógica libre. */
44
+ export interface WhenSchema {
45
+ /** Compara el valor de una fuente o del estado. */
46
+ state?: string;
47
+ /** Compara el estado de una fuente de datos. */
48
+ source?: string;
49
+ /** Igualdad estricta contra este valor. */
50
+ equals?: SchemaValue;
51
+ /** Pertenencia a esta lista. */
52
+ in?: SchemaValue[];
53
+ /** Estado de la fuente (`loading` | `empty` | `error` | `ok`). */
54
+ status?: "loading" | "empty" | "error" | "ok";
55
+ /** Capacidad del host que debe estar presente. */
56
+ capability?: string;
57
+ /** Niega la condición. */
58
+ not?: WhenSchema;
59
+ /** Todas deben cumplirse. */
60
+ all?: WhenSchema[];
61
+ /** Al menos una debe cumplirse. */
62
+ any?: WhenSchema[];
63
+ }
64
+
65
+ /** Efecto declarativo que ejecuta el motor tras una acción. */
66
+ export interface EffectSchema {
67
+ /** Acción registrada por el host. */
68
+ action?: string;
69
+ /** Argumentos explícitos del efecto. */
70
+ args?: Record<string, SchemaValue>;
71
+ /** Aviso al usuario. */
72
+ toast?: { variant?: "success" | "error" | "info" | "warning"; message: SchemaValue };
73
+ /** Navegación a otra ruta. */
74
+ navigate?: string;
75
+ /** Abre un overlay declarado en `overlays`. */
76
+ openOverlay?: string;
77
+ /** Cierra el overlay indicado, o el que esté abierto si se omite. */
78
+ closeOverlay?: string;
79
+ /** Refresca una fuente de datos. */
80
+ refresh?: string;
81
+ }
82
+
83
+ /** Acción declarada en un nodo: nombre registrado + argumentos + reacciones. */
84
+ export interface ActionSchema extends EffectSchema {
85
+ /** Confirmación previa obligatoria. */
86
+ confirm?: { title: SchemaValue; message?: SchemaValue };
87
+ /** Efectos que se ejecutan si la acción termina bien. */
88
+ then?: EffectSchema[];
89
+ /** Efectos que se ejecutan si la acción falla. */
90
+ onError?: EffectSchema;
91
+ }
92
+
93
+ /** Nodo del esquema. */
94
+ export interface NodeSchema {
95
+ /** Tipo de componente del catálogo (`ITCard`, `ITButton`…) o nodo del motor (`form`, `table`). */
96
+ type: string;
97
+ /** Identificador opcional, para depuración y binding. */
98
+ id?: string;
99
+ /** Props del componente, ya filtradas por el catálogo. */
100
+ props?: Record<string, SchemaValue>;
101
+ /** Hijos: nodo, lista de nodos o nada. */
102
+ children?: NodeSchema | NodeSchema[];
103
+ /**
104
+ * Atajo de contenido para componentes textuales (`ITText`, `ITButton`…).
105
+ * Admite una referencia (`{ i18n: "clave" }`), que el host resuelve.
106
+ */
107
+ text?: SchemaValue;
108
+ /** Capacidad o capacidades que exige el nodo. */
109
+ requires?: string | string[];
110
+ /** Qué pintar cuando falla o no hay permiso. */
111
+ fallback?: NodeSchema;
112
+ /** Condición para pintar el nodo. */
113
+ when?: WhenSchema;
114
+ /**
115
+ * Estado respecto al stream. Lo rellena el parser tolerante; el autor no lo
116
+ * escribe.
117
+ */
118
+ $status?: NodeStatus;
119
+ }
120
+
121
+ /** Definición de una fuente de datos. El host aporta el `handler`. */
122
+ export interface DataSourceSchema {
123
+ /** Nombre de un handler registrado por el host. Nunca una URL. */
124
+ handler: string;
125
+ /** Parámetros, normalmente con referencias. */
126
+ params?: Record<string, SchemaValue>;
127
+ /** Fuentes o acciones que invalidan esta fuente. */
128
+ refreshOn?: string[];
129
+ }
130
+
131
+ /** Esquema de pantalla completo. */
132
+ export interface ScreenSchema {
133
+ /** Identificador del formato. */
134
+ $schema?: "axzy/screen";
135
+ /** Versión del formato; obligatoria para poder migrar. */
136
+ version: number;
137
+ /** Identificador de la pantalla. */
138
+ id: string;
139
+ /** Título, normalmente traducible. */
140
+ title?: SchemaValue;
141
+ /** Descripción corta de la pantalla, para la cabecera que ponga el host. */
142
+ description?: SchemaValue;
143
+ /** Capacidades que exige la pantalla completa. */
144
+ requires?: string | string[];
145
+ /** Fuentes de datos que el host resolverá. */
146
+ data?: Record<string, DataSourceSchema>;
147
+ /** Estado local de la pantalla. */
148
+ state?: Record<string, SchemaValue>;
149
+ /** Contenido raíz. */
150
+ content: NodeSchema | NodeSchema[];
151
+ }
152
+
153
+ /** Resultado de un intento de pintar un nodo. */
154
+ export interface NodeOutcome {
155
+ /** Si el nodo se pudo resolver. */
156
+ ok: boolean;
157
+ /** Motivo cuando no se pudo. */
158
+ reason?: string;
159
+ }
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Errores y avisos del motor de pantallas declarativas.
3
+ *
4
+ * Todo lo que el motor detecta (nodo desconocido, prop prohibida, acción no
5
+ * registrada, permiso insuficiente…) se expresa con un **código estable** y el
6
+ * `path` exacto dentro del esquema. Los códigos son contrato público: están
7
+ * documentados en `docs/json-renderer/PLAN-IMPLEMENTACION.md` §11 y no cambian
8
+ * sin migración.
9
+ */
10
+
11
+ /** Gravedad de un aviso del motor. */
12
+ export type JsonEngineSeverity = "error" | "warning" | "info";
13
+
14
+ /** Códigos de aviso del motor. Contrato estable. */
15
+ export type JsonEngineCode =
16
+ | "JR010" // type fuera del catálogo
17
+ | "JR011" // prop prohibida o no declarada
18
+ | "JR012" // el componente lanzó durante el render (aislado)
19
+ | "JR013" // presupuesto de nodos/profundidad excedido
20
+ | "JR014" // el `id` del nodo no llegó al DOM (el componente no reenvía props)
21
+ | "JR020" // esquema inválido (estructura o versión)
22
+ | "JR021" // nodo inválido (forma o `type` ausente)
23
+ | "JR030" // fuente de datos sin handler registrado
24
+ | "JR031" // el esquema intenta provocar red (prohibido)
25
+ | "JR040" // acción no registrada
26
+ | "JR041" // acción excedió su timeout
27
+ | "JR042" // encadenado de efectos demasiado profundo
28
+ | "JR050" // guard de ruta inválido o que lanzó
29
+ | "JR060" // capacidad insuficiente para un nodo
30
+ | "JR061" // capacidad insuficiente para una acción
31
+ | "JR070" // migración de versión ausente
32
+ | "JR080" // stream cortado sin cerrar
33
+ | "JR081"; // trabajo por fragmento excedido
34
+
35
+ /** Aviso del motor: lo que pasó, dónde y qué hacer. */
36
+ export interface JsonEngineIssue {
37
+ /** Código estable (`JR010`…). */
38
+ code: JsonEngineCode;
39
+ /** Gravedad: `error` impide pintar el nodo, `warning` lo pinta con reservas. */
40
+ severity: JsonEngineSeverity;
41
+ /** Mensaje legible, ya traducido por el motor. */
42
+ message: string;
43
+ /** Ruta exacta dentro del esquema (`content[0].children[2].props.onClick`). */
44
+ path: string;
45
+ /** Sugerencia accionable, cuando la hay. */
46
+ hint?: string;
47
+ }
48
+
49
+ /** Error interno del motor: siempre lleva código y ruta. */
50
+ export class JsonEngineError extends Error {
51
+ /** Código estable del catálogo de avisos. */
52
+ readonly code: JsonEngineCode;
53
+ /** Ruta del esquema donde se produjo. */
54
+ readonly path: string;
55
+ /** Sugerencia accionable. */
56
+ readonly hint?: string;
57
+
58
+ constructor(code: JsonEngineCode, message: string, path: string, hint?: string) {
59
+ super(`${code} ${path ? `en ${path}: ` : ""}${message}`);
60
+ this.name = "JsonEngineError";
61
+ this.code = code;
62
+ this.path = path;
63
+ this.hint = hint;
64
+ }
65
+
66
+ /** Convierte el error en el aviso que consume la interfaz y el informe. */
67
+ toIssue(severity: JsonEngineSeverity = "error"): JsonEngineIssue {
68
+ return {
69
+ code: this.code,
70
+ severity,
71
+ message: this.message.replace(/^JR\d{3}\s?(en .*?: )?/, ""),
72
+ path: this.path,
73
+ hint: this.hint,
74
+ };
75
+ }
76
+ }
77
+
78
+ /** Crea un aviso sin lanzar excepción (para flujos tolerantes). */
79
+ export function issue(
80
+ code: JsonEngineCode,
81
+ message: string,
82
+ path = "",
83
+ options: { severity?: JsonEngineSeverity; hint?: string } = {}
84
+ ): JsonEngineIssue {
85
+ return {
86
+ code,
87
+ severity: options.severity ?? "error",
88
+ message,
89
+ path,
90
+ hint: options.hint,
91
+ };
92
+ }
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Presupuestos del motor.
3
+ *
4
+ * Un esquema puede venir de un modelo que alucina o de un backend con un bug: el
5
+ * motor no puede confiar en que el árbol sea razonable. Estos techos son la red
6
+ * que evita que una pantalla congele el hilo o agote la memoria.
7
+ */
8
+
9
+ /** Techos de trabajo del motor. */
10
+ export interface EngineLimits {
11
+ /** Número máximo de nodos que se pintan en una pantalla. @default 5000 */
12
+ maxNodes: number;
13
+ /** Profundidad máxima de anidación. @default 40 */
14
+ maxDepth: number;
15
+ /** Milisegundos de trabajo por fragmento de stream antes de diferir al siguiente frame. @default 8 */
16
+ maxMsPerChunk: number;
17
+ /** Milisegundos máximos de una acción antes de considerarla colgada. @default 15000 */
18
+ actionTimeoutMs: number;
19
+ /** Profundidad máxima de encadenado de efectos (`then`/`onError`). @default 5 */
20
+ maxEffectChain: number;
21
+ }
22
+
23
+ /** Presupuesto por defecto. */
24
+ export const DEFAULT_LIMITS: EngineLimits = {
25
+ maxNodes: 5000,
26
+ maxDepth: 40,
27
+ maxMsPerChunk: 8,
28
+ actionTimeoutMs: 15000,
29
+ maxEffectChain: 5,
30
+ };
31
+
32
+ /** Resuelve un presupuesto parcial sobre los valores por defecto. */
33
+ export function resolveLimits(overrides: Partial<EngineLimits> = {}): EngineLimits {
34
+ const merged: EngineLimits = { ...DEFAULT_LIMITS, ...overrides };
35
+ for (const [key, value] of Object.entries(merged) as [keyof EngineLimits, number][]) {
36
+ if (!Number.isFinite(value) || value < 0) {
37
+ throw new RangeError(`Límite inválido: ${key} = ${String(value)}`);
38
+ }
39
+ }
40
+ return merged;
41
+ }
42
+
43
+ /** Consumidor de presupuesto: cuenta nodos y profundidad y avisa al agotarse. */
44
+ export interface LimitTracker {
45
+ /** Registra un nodo en la profundidad indicada. `false` si ya no cabe. */
46
+ visit(depth: number): boolean;
47
+ /** Nodos contados hasta ahora. */
48
+ readonly nodes: number;
49
+ /** Profundidad máxima vista. */
50
+ readonly deepest: number;
51
+ /** `true` cuando se agotó el presupuesto y se recortó el árbol. */
52
+ readonly exceeded: boolean;
53
+ }
54
+
55
+ /**
56
+ * Crea un consumidor de presupuesto para un render.
57
+ *
58
+ * @example
59
+ * const tracker = createLimitTracker({ maxNodes: 100 });
60
+ * tracker.visit(0); // true
61
+ */
62
+ export function createLimitTracker(limits: EngineLimits = DEFAULT_LIMITS): LimitTracker {
63
+ let nodes = 0;
64
+ let deepest = 0;
65
+ let exceeded = false;
66
+
67
+ return {
68
+ visit(depth: number) {
69
+ if (depth > deepest) deepest = depth;
70
+ if (nodes >= limits.maxNodes || depth > limits.maxDepth) {
71
+ exceeded = true;
72
+ return false;
73
+ }
74
+ nodes += 1;
75
+ return true;
76
+ },
77
+ get nodes() {
78
+ return nodes;
79
+ },
80
+ get deepest() {
81
+ return deepest;
82
+ },
83
+ get exceeded() {
84
+ return exceeded;
85
+ },
86
+ };
87
+ }
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Rutas dentro de un esquema.
3
+ *
4
+ * Un aviso del motor tiene que poder señalar **exactamente** dónde está el
5
+ * problema: `content[2].children[0].props.onClick`. Estas utilidades construyen,
6
+ * leen y renderizan esas rutas sin depender de nada externo.
7
+ */
8
+
9
+ /** Un segmento de ruta: clave de objeto o índice de array. */
10
+ export type PathSegment = string | number;
11
+
12
+ /** Ruta de un nodo o valor dentro del esquema. */
13
+ export type SchemaPath = readonly PathSegment[];
14
+
15
+ /** Añade un segmento a una ruta sin mutarla. */
16
+ export function pushPath(path: SchemaPath, segment: PathSegment): SchemaPath {
17
+ return [...path, segment];
18
+ }
19
+
20
+ /** Renderiza una ruta en la forma legible `content[0].children[2].props`. */
21
+ export function formatPath(path: SchemaPath): string {
22
+ let out = "";
23
+ for (const segment of path) {
24
+ if (typeof segment === "number") out += `[${segment}]`;
25
+ else out += out ? `.${segment}` : segment;
26
+ }
27
+ return out;
28
+ }
29
+
30
+ /** Parsea una ruta renderizada (`content[0].children`) de vuelta a segmentos. */
31
+ export function parsePath(rendered: string): PathSegment[] {
32
+ if (!rendered) return [];
33
+ return rendered
34
+ .split(/\.(?![^[]*])/)
35
+ .flatMap((part) => {
36
+ const match = part.match(/^([^[\]]*)((?:\[\d+\])*)$/);
37
+ if (!match) return [part];
38
+ const [, key, indexes] = match;
39
+ const segments: PathSegment[] = key ? [key] : [];
40
+ for (const index of indexes.match(/\d+/g) ?? []) segments.push(Number(index));
41
+ return segments;
42
+ });
43
+ }
44
+
45
+ /** Lee un valor del esquema siguiendo una ruta. Devuelve `undefined` si no existe. */
46
+ export function readPath(source: unknown, path: SchemaPath): unknown {
47
+ let cursor: unknown = source;
48
+ for (const segment of path) {
49
+ if (cursor === null || typeof cursor !== "object") return undefined;
50
+ cursor = (cursor as Record<PathSegment, unknown>)[segment];
51
+ }
52
+ return cursor;
53
+ }
54
+
55
+ /** `true` cuando la ruta apunta a un valor que existe (aunque sea `null`). */
56
+ export function hasPath(source: unknown, path: SchemaPath): boolean {
57
+ if (path.length === 0) return true;
58
+ const parent = readPath(source, path.slice(0, -1));
59
+ if (parent === null || typeof parent !== "object") return false;
60
+ return Object.prototype.hasOwnProperty.call(parent, path[path.length - 1]);
61
+ }
62
+
63
+ /** Añade el sufijo de una prop a la ruta de un nodo. */
64
+ export function propPath(nodePath: SchemaPath, prop: string): SchemaPath {
65
+ return pushPath(pushPath(nodePath, "props"), prop);
66
+ }
@@ -0,0 +1,108 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { formatPath, hasPath, parsePath, propPath, pushPath, readPath } from "./paths";
3
+ import { createLimitTracker, DEFAULT_LIMITS, resolveLimits } from "./limits";
4
+ import { JsonEngineError, issue } from "./errors";
5
+
6
+ describe("paths", () => {
7
+ it("renderiza rutas en la forma legible del contrato", () => {
8
+ expect(formatPath(["content", 0, "children", 2, "props"])).toBe("content[0].children[2].props");
9
+ expect(formatPath([])).toBe("");
10
+ expect(formatPath([0])).toBe("[0]");
11
+ });
12
+
13
+ it("parsea de vuelta a segmentos", () => {
14
+ expect(parsePath("content[0].children[2].props")).toEqual(["content", 0, "children", 2, "props"]);
15
+ expect(parsePath("")).toEqual([]);
16
+ expect(parsePath("version")).toEqual(["version"]);
17
+ expect(parsePath("a[0][1].b")).toEqual(["a", 0, 1, "b"]);
18
+ });
19
+
20
+ it("ida y vuelta estable", () => {
21
+ const segments = ["content", 1, "children", 0, "props", "onClick"] as const;
22
+ expect(parsePath(formatPath([...segments]))).toEqual([...segments]);
23
+ });
24
+
25
+ it("lee y comprueba existencia", () => {
26
+ const schema = { content: [{ type: "ITCard" }] };
27
+ expect(readPath(schema, ["content", 0, "type"])).toBe("ITCard");
28
+ expect(readPath(schema, ["content", 5])).toBeUndefined();
29
+ expect(readPath(schema, ["content", 0, "type", "x"])).toBeUndefined();
30
+ expect(hasPath(schema, ["content", 0, "type"])).toBe(true);
31
+ expect(hasPath(schema, ["content", 0, "missing"])).toBe(false);
32
+ expect(hasPath(schema, [])).toBe(true);
33
+ });
34
+
35
+ it("no muta la ruta original", () => {
36
+ const base = ["content"] as const;
37
+ expect(pushPath(base, 0)).toEqual(["content", 0]);
38
+ expect(base).toEqual(["content"]);
39
+ });
40
+
41
+ it("compone la ruta de una prop", () => {
42
+ expect(propPath(["content", 0], "title")).toEqual(["content", 0, "props", "title"]);
43
+ });
44
+ });
45
+
46
+ describe("limits", () => {
47
+ it("trae presupuestos por defecto razonables", () => {
48
+ expect(DEFAULT_LIMITS.maxNodes).toBeGreaterThan(100);
49
+ expect(DEFAULT_LIMITS.maxDepth).toBeGreaterThan(5);
50
+ expect(DEFAULT_LIMITS.maxEffectChain).toBeLessThanOrEqual(10);
51
+ });
52
+
53
+ it("resuelve overrides", () => {
54
+ expect(resolveLimits({ maxNodes: 10 }).maxNodes).toBe(10);
55
+ expect(resolveLimits({}).maxNodes).toBe(DEFAULT_LIMITS.maxNodes);
56
+ });
57
+
58
+ it("rechaza límites inválidos", () => {
59
+ expect(() => resolveLimits({ maxNodes: -1 })).toThrow(RangeError);
60
+ expect(() => resolveLimits({ maxDepth: Number.NaN })).toThrow(RangeError);
61
+ });
62
+
63
+ it("corta al llegar al techo de nodos", () => {
64
+ const tracker = createLimitTracker(resolveLimits({ maxNodes: 3 }));
65
+ expect(tracker.visit(0)).toBe(true);
66
+ expect(tracker.visit(1)).toBe(true);
67
+ expect(tracker.visit(1)).toBe(true);
68
+ expect(tracker.visit(1)).toBe(false);
69
+ expect(tracker.exceeded).toBe(true);
70
+ expect(tracker.nodes).toBe(3);
71
+ });
72
+
73
+ it("corta al pasar la profundidad máxima", () => {
74
+ const tracker = createLimitTracker(resolveLimits({ maxDepth: 2 }));
75
+ expect(tracker.visit(0)).toBe(true);
76
+ expect(tracker.visit(2)).toBe(true);
77
+ expect(tracker.visit(3)).toBe(false);
78
+ expect(tracker.exceeded).toBe(true);
79
+ expect(tracker.deepest).toBe(3);
80
+ });
81
+ });
82
+
83
+ describe("errors", () => {
84
+ it("el error lleva código, ruta y sugerencia", () => {
85
+ const error = new JsonEngineError("JR010", "tipo desconocido", "content[0]", "Revisa el catálogo");
86
+ expect(error.code).toBe("JR010");
87
+ expect(error.path).toBe("content[0]");
88
+ expect(error.hint).toBe("Revisa el catálogo");
89
+ expect(error.message).toContain("content[0]");
90
+ expect(error.message).toContain("tipo desconocido");
91
+ });
92
+
93
+ it("se convierte en aviso sin duplicar el prefijo", () => {
94
+ const aviso = new JsonEngineError("JR011", "prop prohibida", "content[1].props.children").toIssue("warning");
95
+ expect(aviso).toEqual({
96
+ code: "JR011",
97
+ severity: "warning",
98
+ message: "prop prohibida",
99
+ path: "content[1].props.children",
100
+ hint: undefined,
101
+ });
102
+ });
103
+
104
+ it("crea avisos sueltos con gravedad por defecto", () => {
105
+ expect(issue("JR080", "stream cortado").severity).toBe("error");
106
+ expect(issue("JR080", "stream cortado", "content", { severity: "warning" }).severity).toBe("warning");
107
+ });
108
+ });
@@ -0,0 +1,152 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { parsePartialSchema } from "./tolerantParse";
3
+ import type { NodeSchema, ScreenSchema } from "../kernel/types";
4
+
5
+ /** Esquema de ejemplo con nodos anidados: cubre objetos, arrays y literales. */
6
+ const SCHEMA_TEXT = `{
7
+ "$schema": "axzy/screen",
8
+ "version": 1,
9
+ "id": "usuarios.lista",
10
+ "requires": "usuarios.leer",
11
+ "state": { "page": 1, "q": "" },
12
+ "data": {
13
+ "usuarios": { "handler": "usuarios.listar", "params": { "page": { "$state": "page" } } }
14
+ },
15
+ "content": [
16
+ { "type": "ITPageHeader", "props": { "title": { "i18n": "usuarios.titulo" } } },
17
+ {
18
+ "type": "ITDataTable",
19
+ "id": "tabla",
20
+ "props": {
21
+ "columns": [
22
+ { "field": "nombre", "header": { "i18n": "usuarios.nombre" }, "sortable": true },
23
+ { "field": "correo", "header": { "i18n": "usuarios.correo" } }
24
+ ],
25
+ "loading": false,
26
+ "emptyTitle": { "i18n": "usuarios.vacio" }
27
+ }
28
+ }
29
+ ]
30
+ }`;
31
+
32
+ /** Cuenta nodos con `$status: "pending"` en un árbol. */
33
+ function countPending(value: unknown): number {
34
+ if (Array.isArray(value)) return value.reduce<number>((total, item) => total + countPending(item), 0);
35
+ if (value && typeof value === "object") {
36
+ const record = value as Record<string, unknown>;
37
+ const own = record.$status === "pending" ? 1 : 0;
38
+ return Object.values(record).reduce<number>((total, nested) => total + countPending(nested), own);
39
+ }
40
+ return 0;
41
+ }
42
+
43
+ /** Cuenta nodos con `type` en un árbol (nodos de esquema reales). */
44
+ function countTypedNodes(value: unknown): number {
45
+ if (Array.isArray(value)) return value.reduce<number>((total, item) => total + countTypedNodes(item), 0);
46
+ if (value && typeof value === "object") {
47
+ const record = value as Record<string, unknown>;
48
+ const own = typeof record.type === "string" ? 1 : 0;
49
+ return Object.values(record).reduce<number>((total, nested) => total + countTypedNodes(nested), own);
50
+ }
51
+ return 0;
52
+ }
53
+
54
+ describe("parsePartialSchema", () => {
55
+ it("parsea el esquema completo y lo marca como cerrado", () => {
56
+ const result = parsePartialSchema(SCHEMA_TEXT);
57
+ expect(result.status).toBe("complete");
58
+ expect(result.broken).toBe(false);
59
+ expect(result.pendingNodes).toBe(0);
60
+
61
+ const tree = result.tree as ScreenSchema;
62
+ expect(tree.id).toBe("usuarios.lista");
63
+ expect(tree.version).toBe(1);
64
+ expect(Array.isArray(tree.content)).toBe(true);
65
+ expect((tree.content as NodeSchema[])[1].type).toBe("ITDataTable");
66
+ });
67
+
68
+ it("no se rompe ni lanza con texto vacío", () => {
69
+ const result = parsePartialSchema("");
70
+ expect(result.tree).toBeNull();
71
+ expect(result.status).toBe("pending");
72
+ expect(result.broken).toBe(false);
73
+ });
74
+
75
+ it("marca como no recuperable la basura al principio", () => {
76
+ const result = parsePartialSchema("@@@ no es json");
77
+ expect(result.broken).toBe(true);
78
+ });
79
+
80
+ it("trata como pendiente una cadena sin cerrar", () => {
81
+ const result = parsePartialSchema('{"id": "usuarios');
82
+ expect(result.status).toBe("pending");
83
+ });
84
+
85
+ it("trata como pendiente un número al final (puede seguir creciendo)", () => {
86
+ const result = parsePartialSchema('{"version": 1');
87
+ expect(result.status).toBe("pending");
88
+ });
89
+
90
+ it("acepta un documento completo con espacio final", () => {
91
+ expect(parsePartialSchema('{"version": 1} \n').status).toBe("complete");
92
+ });
93
+
94
+ it("marca pendiente el nodo cuyo valor está a medias", () => {
95
+ const result = parsePartialSchema('{"content": [{"type": "ITCard", "props": {"title": "Ho');
96
+ const content = (result.tree as ScreenSchema | null)?.content as NodeSchema[] | undefined;
97
+ expect(result.status).toBe("pending");
98
+ // El nodo existe (tiene `type` cerrado) pero su prop quedó a medias.
99
+ expect(content?.[0]?.type).toBe("ITCard");
100
+ });
101
+
102
+ // ── El criterio de aceptación de la fase 0: truncar en CADA posición ──────────
103
+ describe("truncado exhaustivo", () => {
104
+ const truncations = Array.from({ length: SCHEMA_TEXT.length + 1 }, (_, index) => index);
105
+
106
+ it(`no lanza y siempre avanza en las ${truncations.length} truncaciones`, () => {
107
+ let previousTyped = 0;
108
+
109
+ for (const cut of truncations) {
110
+ const text = SCHEMA_TEXT.slice(0, cut);
111
+ const result = parsePartialSchema(text);
112
+
113
+ // 1. Nunca lanza y siempre devuelve un resultado coherente.
114
+ expect(result.offset).toBeGreaterThanOrEqual(0);
115
+ expect(["complete", "pending"]).toContain(result.status);
116
+
117
+ // 2. Solo el texto completo puede darse por cerrado.
118
+ if (cut < SCHEMA_TEXT.length) {
119
+ expect(result.status).toBe("pending");
120
+ }
121
+
122
+ // 3. El árbol no retrocede: al cortar más tarde nunca hay menos nodos.
123
+ const typed = result.tree ? countTypedNodes(result.tree) : 0;
124
+ expect(typed).toBeGreaterThanOrEqual(previousTyped);
125
+ previousTyped = typed;
126
+
127
+ // 4. Si hay árbol, es un objeto con la forma de un esquema.
128
+ if (result.tree) {
129
+ expect(typeof result.tree).toBe("object");
130
+ }
131
+ }
132
+ });
133
+
134
+ it("termina con los dos nodos de contenido y sin pendientes", () => {
135
+ const result = parsePartialSchema(SCHEMA_TEXT);
136
+ expect(countTypedNodes(result.tree)).toBe(2);
137
+ expect(countPending(result.tree)).toBe(0);
138
+ });
139
+
140
+ it("a mitad de la tabla ya ha pintado la cabecera de página", () => {
141
+ // Corte justo antes de cerrar el segundo nodo de `content`.
142
+ const cut = SCHEMA_TEXT.indexOf('"loading": false');
143
+ const result = parsePartialSchema(SCHEMA_TEXT.slice(0, cut));
144
+ const content = (result.tree as ScreenSchema | null)?.content as NodeSchema[] | undefined;
145
+
146
+ expect(result.status).toBe("pending");
147
+ expect(content?.[0]?.type).toBe("ITPageHeader");
148
+ // El segundo nodo está a medias: existe pero marcado como pendiente.
149
+ expect(content?.[1]?.type).toBe("ITDataTable");
150
+ });
151
+ });
152
+ });