master
parent
dd34cbf9f1
commit
41f07e6605
@ -0,0 +1,204 @@
|
||||
<script lang="ts">
|
||||
|
||||
|
||||
import {createCatalogEditorStore} from "@/vcen/editor/stores/catalog-editor.svelte.ts";
|
||||
import {type Cat, toCatID} from "@/vcen/lib";
|
||||
import CatalogTree from "@/vcen/editor/comps/CatalogTree.svelte";
|
||||
import CatalogEditor from "@/vcen/editor/comps/CatalogEditor.svelte";
|
||||
|
||||
const cat = {
|
||||
id: toCatID('test'),
|
||||
name: 'Nuevo',
|
||||
optCategories: [],
|
||||
opts: [],
|
||||
atts: [],
|
||||
objs: [],
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
</script>
|
||||
|
||||
|
||||
<div>
|
||||
<CatalogEditor cat={cat} />
|
||||
</div>
|
||||
|
||||
|
||||
<style>
|
||||
/* ── Utilidades globales del editor ─────────────────────────────────── */
|
||||
|
||||
:global(.field-input) {
|
||||
width: 100%;
|
||||
background: #111827;
|
||||
border: 1px solid #1f2937;
|
||||
border-radius: 0.375rem;
|
||||
padding: 0.375rem 0.625rem;
|
||||
font-size: 0.8125rem;
|
||||
color: #e2e8f0;
|
||||
outline: none;
|
||||
transition: border-color 120ms;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
:global(.field-input:focus) {
|
||||
border-color: #4f46e5;
|
||||
}
|
||||
|
||||
:global(.field-input.border-red-800) {
|
||||
border-color: #991b1b;
|
||||
}
|
||||
|
||||
:global(select.field-input) {
|
||||
appearance: none;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%236b7280' stroke-width='2'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 0.5rem center;
|
||||
padding-right: 1.75rem;
|
||||
}
|
||||
|
||||
:global(textarea.field-input) {
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
:global(.section-title) {
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: #6b7280;
|
||||
padding-bottom: 0.5rem;
|
||||
border-bottom: 1px solid #1f2937;
|
||||
}
|
||||
|
||||
:global(.section-title-inline) {
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
/* ── Editor root ─────────────────────────────────────────────────────── */
|
||||
|
||||
.editor {
|
||||
display: flex;
|
||||
height: 100vh;
|
||||
background: #0a0f1a;
|
||||
color: #e2e8f0;
|
||||
font-family: 'IBM Plex Sans', system-ui, sans-serif;
|
||||
}
|
||||
|
||||
/* Nav */
|
||||
.editor-nav {
|
||||
width: 11rem;
|
||||
shrink: 0;
|
||||
border-right: 1px solid #1e293b;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #080d17;
|
||||
}
|
||||
|
||||
.editor-nav-header {
|
||||
padding: 1.25rem 1rem 1rem;
|
||||
border-bottom: 1px solid #1e293b;
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
.editor-logo {
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.1em;
|
||||
color: #6366f1;
|
||||
}
|
||||
|
||||
.editor-logo-sub {
|
||||
font-size: 0.6875rem;
|
||||
color: #334155;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
/* Tabs */
|
||||
.editor-tabs {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0.5rem 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.editor-tab {
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
padding: 0.5rem 1rem;
|
||||
font-size: 0.8125rem;
|
||||
color: #475569;
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
transition: color 120ms, background 120ms;
|
||||
border-left: 2px solid transparent;
|
||||
}
|
||||
|
||||
.editor-tab:hover {
|
||||
color: #94a3b8;
|
||||
background: rgb(255 255 255 / 0.02);
|
||||
}
|
||||
|
||||
.editor-tab-active {
|
||||
color: #a5b4fc;
|
||||
border-left-color: #6366f1;
|
||||
background: rgb(99 102 241 / 0.08);
|
||||
}
|
||||
|
||||
.editor-tab-error {
|
||||
font-size: 0.625rem;
|
||||
padding: 0.1rem 0.4rem;
|
||||
border-radius: 9999px;
|
||||
background: rgb(239 68 68 / 0.2);
|
||||
color: #fca5a5;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Footer */
|
||||
.editor-nav-footer {
|
||||
padding: 0.75rem 1rem;
|
||||
border-top: 1px solid #1e293b;
|
||||
}
|
||||
|
||||
.editor-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
font-size: 0.6875rem;
|
||||
}
|
||||
|
||||
.editor-status-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
shrink: 0;
|
||||
}
|
||||
|
||||
.editor-status-error { color: #f87171; }
|
||||
.editor-status-error .editor-status-dot { background: #ef4444; }
|
||||
|
||||
.editor-status-warn { color: #fbbf24; }
|
||||
.editor-status-warn .editor-status-dot { background: #f59e0b; }
|
||||
|
||||
.editor-status-ok { color: #4ade80; }
|
||||
.editor-status-ok .editor-status-dot { background: #22c55e; }
|
||||
|
||||
/* Main */
|
||||
.editor-main {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
</style>
|
||||
@ -0,0 +1,59 @@
|
||||
<script lang="ts">
|
||||
import type { CatalogEditorStore } from '../stores/catalog-editor.svelte.js';
|
||||
|
||||
interface Props { editor: CatalogEditorStore; }
|
||||
let { editor }: Props = $props();
|
||||
|
||||
const cat = $derived(editor.cat);
|
||||
|
||||
let nameInput = $state('');
|
||||
$effect(() => { nameInput = cat.name; });
|
||||
|
||||
function onNameBlur() {
|
||||
if (nameInput === cat.name) return;
|
||||
editor.renameCat(nameInput);
|
||||
}
|
||||
|
||||
const inputCls = `w-full rounded border border-gray-700 bg-gray-900 px-3 py-2
|
||||
text-sm text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500`;
|
||||
const labelCls = `text-xs font-semibold uppercase tracking-wider text-gray-500`;
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col gap-6 p-5">
|
||||
|
||||
<h2 class="text-base font-semibold text-gray-100">Catálogo</h2>
|
||||
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<label class={labelCls}>Nombre</label>
|
||||
<input type="text" bind:value={nameInput} onblur={onNameBlur} class={inputCls} />
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<span class={labelCls}>Resumen</span>
|
||||
<div class="rounded border border-gray-800 bg-gray-900 p-4 grid grid-cols-2 gap-3 text-sm">
|
||||
<div class="flex flex-col gap-0.5">
|
||||
<span class="text-2xl font-bold text-gray-100">{cat.objs.length}</span>
|
||||
<span class="text-gray-600">Objetos</span>
|
||||
</div>
|
||||
<div class="flex flex-col gap-0.5">
|
||||
<span class="text-2xl font-bold text-gray-100">
|
||||
{cat.objs.reduce((n, o) => n + o.secs.length, 0)}
|
||||
</span>
|
||||
<span class="text-gray-600">Secciones</span>
|
||||
</div>
|
||||
<div class="flex flex-col gap-0.5">
|
||||
<span class="text-2xl font-bold text-gray-100">
|
||||
{cat.objs.reduce((n, o) =>
|
||||
n + o.atts.length + o.secs.reduce((m, s) => m + s.atts.length, 0), 0
|
||||
)}
|
||||
</span>
|
||||
<span class="text-gray-600">Atributos</span>
|
||||
</div>
|
||||
<div class="flex flex-col gap-0.5">
|
||||
<span class="text-2xl font-bold text-gray-100">{cat.opts.length}</span>
|
||||
<span class="text-gray-600">Opciones globales</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
@ -0,0 +1,192 @@
|
||||
<script lang="ts">
|
||||
import type { Cat } from '@/vcen/lib';
|
||||
import { createCatalogEditorStore } from '../stores/catalog-editor.svelte';
|
||||
import CatalogTree from './CatalogTree.svelte';
|
||||
import CatPanel from './CatPanel.svelte';
|
||||
import ObjPanel from './ObjPanel.svelte';
|
||||
import SecPanel from './SecPanel.svelte';
|
||||
import AttPanel from './AttPanel.svelte';
|
||||
import OptPanel from './OptPanel.svelte';
|
||||
|
||||
interface Props {
|
||||
cat: Cat;
|
||||
onsave?: (cat: Cat) => void;
|
||||
oncancel?: () => void;
|
||||
}
|
||||
|
||||
let { cat, onsave, oncancel }: Props = $props();
|
||||
|
||||
const editor = createCatalogEditorStore(cat);
|
||||
const sel = $derived(editor.selection);
|
||||
</script>
|
||||
|
||||
<div class="editor">
|
||||
|
||||
<!-- Árbol izquierdo -->
|
||||
<div class="editor-tree">
|
||||
<CatalogTree {editor} />
|
||||
</div>
|
||||
|
||||
<!-- Panel derecho -->
|
||||
<div class="editor-main">
|
||||
|
||||
<!-- Toolbar -->
|
||||
<div class="editor-toolbar">
|
||||
<span class="editor-breadcrumb">
|
||||
{#if sel.kind === 'cat'} Catálogo
|
||||
{:else if sel.kind === 'opts'} Opciones globales
|
||||
{:else if sel.kind === 'obj'} Objeto
|
||||
{:else if sel.kind === 'sec'} Sección
|
||||
{:else if sel.kind === 'att'} Atributo
|
||||
{/if}
|
||||
</span>
|
||||
<div class="flex gap-2">
|
||||
{#if oncancel}
|
||||
<button onclick={oncancel} class="editor-btn">Cancelar</button>
|
||||
{/if}
|
||||
<button
|
||||
onclick={() => { onsave?.(editor.getCat()); editor.markClean(); }}
|
||||
disabled={!editor.dirty}
|
||||
class="editor-btn editor-btn-primary"
|
||||
class:editor-btn-disabled={!editor.dirty}
|
||||
>Guardar</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Contenido -->
|
||||
<div class="editor-panel">
|
||||
{#if sel.kind === 'cat'} <CatPanel {editor} />
|
||||
{:else if sel.kind === 'opts'} <OptPanel {editor} />
|
||||
{:else if sel.kind === 'obj'} <ObjPanel {editor} />
|
||||
{:else if sel.kind === 'sec'} <SecPanel {editor} />
|
||||
{:else if sel.kind === 'att'} <AttPanel {editor} />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<style>
|
||||
/* ── Clases globales (field-input, section-title, etc.) ─────────────────
|
||||
Deben vivir en el componente raíz para que los hijos las hereden. */
|
||||
|
||||
:global(.field-input) {
|
||||
width: 100%;
|
||||
background: #111827;
|
||||
border: 1px solid #1f2937;
|
||||
border-radius: 0.375rem;
|
||||
padding: 0.375rem 0.625rem;
|
||||
font-size: 0.8125rem;
|
||||
color: #e2e8f0;
|
||||
outline: none;
|
||||
transition: border-color 120ms;
|
||||
font-family: inherit;
|
||||
}
|
||||
:global(.field-input:focus) { border-color: #4f46e5; }
|
||||
|
||||
:global(select.field-input) {
|
||||
appearance: none;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%236b7280' stroke-width='2'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 0.5rem center;
|
||||
padding-right: 1.75rem;
|
||||
}
|
||||
:global(textarea.field-input) { resize: vertical; }
|
||||
|
||||
:global(.section-title) {
|
||||
display: block;
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: #6b7280;
|
||||
padding-bottom: 0.5rem;
|
||||
border-bottom: 1px solid #1f2937;
|
||||
}
|
||||
:global(.section-title-inline) {
|
||||
display: block;
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
/* ── Layout ──────────────────────────────────────────────────────────── */
|
||||
|
||||
.editor {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
background: #0a0f1a;
|
||||
color: #e2e8f0;
|
||||
font-size: 0.8125rem;
|
||||
font-family: 'IBM Plex Sans', system-ui, sans-serif;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.editor-tree {
|
||||
width: 15rem;
|
||||
flex-shrink: 0;
|
||||
border-right: 1px solid #1e293b;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.editor-main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.editor-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.625rem 1.25rem;
|
||||
border-bottom: 1px solid #1e293b;
|
||||
background: #080d17;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.editor-breadcrumb {
|
||||
font-size: 0.75rem;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.editor-panel {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* ── Botones ─────────────────────────────────────────────────────────── */
|
||||
|
||||
.editor-btn {
|
||||
padding: 0.3rem 0.875rem;
|
||||
font-size: 0.8125rem;
|
||||
border-radius: 0.375rem;
|
||||
border: 1px solid #1f2937;
|
||||
background: transparent;
|
||||
color: #6b7280;
|
||||
cursor: pointer;
|
||||
transition: color 120ms, border-color 120ms, background 120ms;
|
||||
font-family: inherit;
|
||||
}
|
||||
.editor-btn:hover { color: #e2e8f0; border-color: #334155; }
|
||||
|
||||
.editor-btn-primary {
|
||||
background: #4f46e5;
|
||||
border-color: #4f46e5;
|
||||
color: #fff;
|
||||
}
|
||||
.editor-btn-primary:hover { background: #4338ca; border-color: #4338ca; }
|
||||
|
||||
.editor-btn-disabled {
|
||||
background: #1f2937 !important;
|
||||
border-color: #1f2937 !important;
|
||||
color: #374151 !important;
|
||||
cursor: not-allowed !important;
|
||||
}
|
||||
</style>
|
||||
@ -1,9 +1,9 @@
|
||||
<script lang="ts">
|
||||
import { createDemoStore } from './demo.store.svelte';
|
||||
import { createDemoStore } from './demo.store.svelte.js';
|
||||
import AttEditor from './AttEditor.svelte';
|
||||
import RuntimePanel from './RuntimePanel.svelte';
|
||||
import GraphPanel from './GraphPanel.svelte';
|
||||
import type { DomainKind } from './demo.store.svelte';
|
||||
import type { DomainKind } from './demo.store.svelte.js';
|
||||
|
||||
const store = createDemoStore();
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { createConditionBuilderStore } from '../../stores/ConditionEditor.svelte.js';
|
||||
import ConditionBuilder from '../ConditionBuilder.svelte';
|
||||
import { createConditionBuilderStore } from '../stores/condition-builder.svelte.ts';
|
||||
import ConditionBuilder from '../comps/ConditionBuilder.svelte';
|
||||
import type { Obj } from '@/vcen/lib/types';
|
||||
import type { JsonLogicValue } from '@/jslg';
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import type { DemoStore } from './demo.store.svelte';
|
||||
import { getTransitiveAffected, getDependencies } from '@/vcen/lib/graph';
|
||||
import type { DemoStore } from './demo.store.svelte.js';
|
||||
import { getTransitiveAffected, getDependencies } from '@/vcen/lib/graph.ts';
|
||||
|
||||
|
||||
interface Props { store: DemoStore; }
|
||||
@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import type { DemoStore } from './demo.store.svelte';
|
||||
import type { DemoStore } from './demo.store.svelte.js';
|
||||
|
||||
interface Props { store: DemoStore; }
|
||||
let { store }: Props = $props();
|
||||
@ -0,0 +1,520 @@
|
||||
import type { Cat, Obj, Sec , Att, AttDomain, AttValue,
|
||||
Opt, OptCategory, AttOpt,
|
||||
OptID, OptCatID , RuleSet, Rule, RuleAction,
|
||||
RuleID, AttState, RelativePath,
|
||||
DomainValueRef, DomainValueState,
|
||||
RuleLevel , CatID, ObjID, SecID, AttID } from '@/vcen/lib';
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// SELECTION — nodo activo en el árbol
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export type SelectionKind = 'cat' | 'obj' | 'sec' | 'att' | 'opts';
|
||||
|
||||
export type Selection =
|
||||
| { kind: 'cat' }
|
||||
| { kind: 'opts' } // panel Opts/OptCategories
|
||||
| { kind: 'obj'; objId: ObjID }
|
||||
| { kind: 'sec'; objId: ObjID; secId: SecID }
|
||||
| { kind: 'att'; objId: ObjID; secId?: SecID; attId: AttID };
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// ID GENERATORS
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
let _seq = Date.now();
|
||||
function nextId<T extends string>(prefix: string): T {
|
||||
return `${prefix}:${(++_seq).toString(36)}` as T;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// DEFAULT FACTORIES
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
function defaultAttValue(): AttValue {
|
||||
return { domain: { kind: 'string', mode: 'input' }, value: '' };
|
||||
}
|
||||
|
||||
function defaultAtt(name = 'Nuevo atributo'): Att {
|
||||
return { id: nextId<AttID>('at'), name, value: defaultAttValue() };
|
||||
}
|
||||
|
||||
function defaultSec(name = 'Nueva sección'): Sec {
|
||||
return { id: nextId<SecID>('sc'), name, atts: [] };
|
||||
}
|
||||
|
||||
function defaultObj(name = 'Nuevo objeto'): Obj {
|
||||
return { id: nextId<ObjID>('ob'), name, atts: [], secs: [] };
|
||||
}
|
||||
|
||||
function defaultOpt(value = ''): Opt {
|
||||
return {
|
||||
id: nextId<OptID>('op'),
|
||||
value: { kind: 'string', value },
|
||||
};
|
||||
}
|
||||
|
||||
function defaultOptCategory(name = 'Nueva categoría'): OptCategory {
|
||||
return { id: nextId<OptCatID>('oc'), name };
|
||||
}
|
||||
|
||||
function defaultRule(): Rule {
|
||||
return {
|
||||
id: nextId<RuleID>('rl'),
|
||||
priority: 10,
|
||||
condition: true,
|
||||
actions: [],
|
||||
};
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// STORE
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Store Svelte 5 para el editor de catálogo.
|
||||
*
|
||||
* Gestiona el Cat en modo borrador con mutaciones granulares.
|
||||
* La UI lee `cat` directamente (reactivo) y llama a las mutaciones
|
||||
* para cada operación de edición.
|
||||
*
|
||||
* ## Uso
|
||||
*
|
||||
* ```svelte
|
||||
* <script>
|
||||
* import { createCatalogEditorStore } from './catalog-editor.svelte';
|
||||
* const editor = createCatalogEditorStore(cat);
|
||||
* </script>
|
||||
*
|
||||
* <CatalogTree {editor} />
|
||||
* ```
|
||||
*/
|
||||
export function createCatalogEditorStore(initial: Cat) {
|
||||
let cat = $state<Cat>(structuredClone(initial));
|
||||
let selection = $state<Selection>({ kind: 'cat' });
|
||||
let dirty = $state(false);
|
||||
|
||||
// ── Helpers internos ────────────────────────────────────────────────────
|
||||
|
||||
function mutate(fn: () => void) {
|
||||
fn();
|
||||
dirty = true;
|
||||
}
|
||||
|
||||
function findObj(objId: ObjID): Obj | undefined {
|
||||
return cat.objs.find(o => o.id === objId);
|
||||
}
|
||||
|
||||
function findSec(objId: ObjID, secId: SecID): Sec | undefined {
|
||||
return findObj(objId)?.secs.find(s => s.id === secId);
|
||||
}
|
||||
|
||||
function findAtt(objId: ObjID, secId: SecID | undefined, attId: AttID): Att | undefined {
|
||||
if (secId) return findSec(objId, secId)?.atts.find(a => a.id === attId);
|
||||
return findObj(objId)?.atts.find(a => a.id === attId);
|
||||
}
|
||||
|
||||
function findCatAtt(attId: AttID): Att | undefined {
|
||||
return cat.atts.find(a => a.id === attId);
|
||||
}
|
||||
|
||||
// ── Selección ───────────────────────────────────────────────────────────
|
||||
|
||||
function select(s: Selection) {
|
||||
selection = s;
|
||||
}
|
||||
|
||||
// ── Cat ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function renameCat(name: string) {
|
||||
mutate(() => { cat.name = name; });
|
||||
}
|
||||
|
||||
// ── Obj ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function addObj(name?: string): ObjID {
|
||||
const obj = defaultObj(name);
|
||||
mutate(() => { cat.objs.push(obj); });
|
||||
select({ kind: 'obj', objId: obj.id });
|
||||
return obj.id;
|
||||
}
|
||||
|
||||
function renameObj(objId: ObjID, name: string) {
|
||||
mutate(() => {
|
||||
const obj = findObj(objId);
|
||||
if (obj) obj.name = name;
|
||||
});
|
||||
}
|
||||
|
||||
function removeObj(objId: ObjID) {
|
||||
mutate(() => {
|
||||
cat.objs = cat.objs.filter(o => o.id !== objId);
|
||||
});
|
||||
// Limpiar selección si apuntaba al obj eliminado
|
||||
if (selection.kind === 'obj' && selection.objId === objId) select({ kind: 'cat' });
|
||||
if ((selection.kind === 'sec' || selection.kind === 'att') && selection.objId === objId) select({ kind: 'cat' });
|
||||
}
|
||||
|
||||
function moveObj(objId: ObjID, direction: 'up' | 'down') {
|
||||
mutate(() => {
|
||||
const idx = cat.objs.findIndex(o => o.id === objId);
|
||||
if (idx === -1) return;
|
||||
const target = direction === 'up' ? idx - 1 : idx + 1;
|
||||
if (target < 0 || target >= cat.objs.length) return;
|
||||
[cat.objs[idx], cat.objs[target]] = [cat.objs[target], cat.objs[idx]];
|
||||
});
|
||||
}
|
||||
|
||||
// ── Sec ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function addSec(objId: ObjID, name?: string): SecID {
|
||||
const sec = defaultSec(name);
|
||||
mutate(() => {
|
||||
const obj = findObj(objId);
|
||||
if (obj) obj.secs.push(sec);
|
||||
});
|
||||
select({ kind: 'sec', objId, secId: sec.id });
|
||||
return sec.id;
|
||||
}
|
||||
|
||||
function renameSec(objId: ObjID, secId: SecID, name: string) {
|
||||
mutate(() => {
|
||||
const sec = findSec(objId, secId);
|
||||
if (sec) sec.name = name;
|
||||
});
|
||||
}
|
||||
|
||||
function removeSec(objId: ObjID, secId: SecID) {
|
||||
mutate(() => {
|
||||
const obj = findObj(objId);
|
||||
if (obj) obj.secs = obj.secs.filter(s => s.id !== secId);
|
||||
});
|
||||
if (selection.kind === 'sec' && selection.secId === secId) select({ kind: 'obj', objId });
|
||||
if (selection.kind === 'att' && selection.secId === secId) select({ kind: 'obj', objId });
|
||||
}
|
||||
|
||||
function moveSec(objId: ObjID, secId: SecID, direction: 'up' | 'down') {
|
||||
mutate(() => {
|
||||
const obj = findObj(objId);
|
||||
if (!obj) return;
|
||||
const idx = obj.secs.findIndex(s => s.id === secId);
|
||||
if (idx === -1) return;
|
||||
const target = direction === 'up' ? idx - 1 : idx + 1;
|
||||
if (target < 0 || target >= obj.secs.length) return;
|
||||
[obj.secs[idx], obj.secs[target]] = [obj.secs[target], obj.secs[idx]];
|
||||
});
|
||||
}
|
||||
|
||||
function setSecRuleSet(objId: ObjID, secId: SecID, ruleSet: RuleSet | undefined) {
|
||||
mutate(() => {
|
||||
const sec = findSec(objId, secId);
|
||||
if (sec) sec.rules = ruleSet;
|
||||
});
|
||||
}
|
||||
|
||||
// ── Att (en Obj o Sec) ──────────────────────────────────────────────────
|
||||
|
||||
function addAtt(objId: ObjID, secId: SecID | undefined, name?: string): AttID {
|
||||
const att = defaultAtt(name);
|
||||
mutate(() => {
|
||||
if (secId) {
|
||||
const sec = findSec(objId, secId);
|
||||
if (sec) sec.atts.push(att);
|
||||
} else {
|
||||
const obj = findObj(objId);
|
||||
if (obj) obj.atts.push(att);
|
||||
}
|
||||
});
|
||||
select({ kind: 'att', objId, secId, attId: att.id });
|
||||
return att.id;
|
||||
}
|
||||
|
||||
function addCatAtt(name?: string): AttID {
|
||||
const att = defaultAtt(name);
|
||||
mutate(() => { cat.atts.push(att); });
|
||||
select({ kind: 'att', objId: '' as ObjID, secId: undefined, attId: att.id });
|
||||
return att.id;
|
||||
}
|
||||
|
||||
function renameAtt(objId: ObjID, secId: SecID | undefined, attId: AttID, name: string) {
|
||||
mutate(() => {
|
||||
const att = findAtt(objId, secId, attId);
|
||||
if (att) att.name = name;
|
||||
});
|
||||
}
|
||||
|
||||
function removeAtt(objId: ObjID, secId: SecID | undefined, attId: AttID) {
|
||||
mutate(() => {
|
||||
if (secId) {
|
||||
const sec = findSec(objId, secId);
|
||||
if (sec) sec.atts = sec.atts.filter(a => a.id !== attId);
|
||||
} else {
|
||||
const obj = findObj(objId);
|
||||
if (obj) obj.atts = obj.atts.filter(a => a.id !== attId);
|
||||
}
|
||||
});
|
||||
if (selection.kind === 'att' && selection.attId === attId) {
|
||||
select(secId ? { kind: 'sec', objId, secId } : { kind: 'obj', objId });
|
||||
}
|
||||
}
|
||||
|
||||
function moveAtt(objId: ObjID, secId: SecID | undefined, attId: AttID, direction: 'up' | 'down') {
|
||||
mutate(() => {
|
||||
const list = secId ? findSec(objId, secId)?.atts : findObj(objId)?.atts;
|
||||
if (!list) return;
|
||||
const idx = list.findIndex(a => a.id === attId);
|
||||
if (idx === -1) return;
|
||||
const target = direction === 'up' ? idx - 1 : idx + 1;
|
||||
if (target < 0 || target >= list.length) return;
|
||||
[list[idx], list[target]] = [list[target], list[idx]];
|
||||
});
|
||||
}
|
||||
|
||||
function updateAttDomain(objId: ObjID, secId: SecID | undefined, attId: AttID, domain: AttDomain) {
|
||||
mutate(() => {
|
||||
const att = findAtt(objId, secId, attId);
|
||||
if (!att) return;
|
||||
// Cambiar dominio resetea el valor al default del nuevo kind
|
||||
att.value = { domain, value: defaultValueForDomain(domain) } as AttValue;
|
||||
});
|
||||
}
|
||||
|
||||
function updateAttDefaultValue(objId: ObjID, secId: SecID | undefined, attId: AttID, value: unknown) {
|
||||
mutate(() => {
|
||||
const att = findAtt(objId, secId, attId);
|
||||
if (att) (att.value as any).value = value;
|
||||
});
|
||||
}
|
||||
|
||||
function setAttRuleSet(objId: ObjID, secId: SecID | undefined, attId: AttID, ruleSet: RuleSet | undefined) {
|
||||
mutate(() => {
|
||||
const att = findAtt(objId, secId, attId);
|
||||
if (att) att.rules = ruleSet;
|
||||
});
|
||||
}
|
||||
|
||||
function setObjRuleSet(objId: ObjID, ruleSet: RuleSet | undefined) {
|
||||
mutate(() => {
|
||||
const obj = findObj(objId);
|
||||
if (obj) obj.rules = ruleSet;
|
||||
});
|
||||
}
|
||||
|
||||
// ── Opts globales ───────────────────────────────────────────────────────
|
||||
|
||||
function addOpt(value = '', categoryId?: OptCatID): OptID {
|
||||
const opt = defaultOpt(value);
|
||||
if (categoryId) opt.categoryId = categoryId;
|
||||
mutate(() => { cat.opts.push(opt); });
|
||||
return opt.id;
|
||||
}
|
||||
|
||||
function updateOpt(optId: OptID, patch: Partial<Pick<Opt, 'value' | 'label' | 'categoryId' | 'rules'>>) {
|
||||
mutate(() => {
|
||||
const opt = cat.opts.find(o => o.id === optId);
|
||||
if (!opt) return;
|
||||
if (patch.value !== undefined) opt.value = patch.value;
|
||||
if (patch.label !== undefined) opt.label = patch.label;
|
||||
if (patch.categoryId !== undefined) opt.categoryId = patch.categoryId;
|
||||
if (patch.rules !== undefined) opt.rules = patch.rules;
|
||||
});
|
||||
}
|
||||
|
||||
function removeOpt(optId: OptID) {
|
||||
mutate(() => {
|
||||
cat.opts = cat.opts.filter(o => o.id !== optId);
|
||||
// Limpiar referencias en dominios option/list de todos los atts
|
||||
removeOptFromAllDomains(optId);
|
||||
});
|
||||
}
|
||||
|
||||
function removeOptFromAllDomains(optId: OptID) {
|
||||
// Cat-level atts
|
||||
for (const att of cat.atts) pruneAttOpt(att, optId);
|
||||
// Obj/Sec atts
|
||||
for (const obj of cat.objs) {
|
||||
for (const att of obj.atts) pruneAttOpt(att, optId);
|
||||
for (const sec of obj.secs) {
|
||||
for (const att of sec.atts) pruneAttOpt(att, optId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function pruneAttOpt(att: Att, optId: OptID) {
|
||||
if (att.value.domain.kind === 'option' && att.value.domain.mode === 'list') {
|
||||
(att.value.domain as any).list = (att.value.domain as any).list.filter(
|
||||
(ao: AttOpt) => ao.optId !== optId
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function addAttOpt(objId: ObjID, secId: SecID | undefined, attId: AttID, optId: OptID) {
|
||||
mutate(() => {
|
||||
const att = findAtt(objId, secId, attId);
|
||||
if (!att || att.value.domain.kind !== 'option' || att.value.domain.mode !== 'list') return;
|
||||
const list = (att.value.domain as any).list as AttOpt[];
|
||||
if (!list.find(ao => ao.optId === optId)) {
|
||||
list.push({ optId });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function removeAttOpt(objId: ObjID, secId: SecID | undefined, attId: AttID, optId: OptID) {
|
||||
mutate(() => {
|
||||
const att = findAtt(objId, secId, attId);
|
||||
if (!att || att.value.domain.kind !== 'option' || att.value.domain.mode !== 'list') return;
|
||||
(att.value.domain as any).list = ((att.value.domain as any).list as AttOpt[])
|
||||
.filter(ao => ao.optId !== optId);
|
||||
});
|
||||
}
|
||||
|
||||
// ── OptCategories ───────────────────────────────────────────────────────
|
||||
|
||||
function addOptCategory(name?: string): OptCatID {
|
||||
const oc = defaultOptCategory(name);
|
||||
mutate(() => { cat.optCategories.push(oc); });
|
||||
return oc.id;
|
||||
}
|
||||
|
||||
function renameOptCategory(ocId: OptCatID, name: string) {
|
||||
mutate(() => {
|
||||
const oc = cat.optCategories.find(c => c.id === ocId);
|
||||
if (oc) oc.name = name;
|
||||
});
|
||||
}
|
||||
|
||||
function removeOptCategory(ocId: OptCatID) {
|
||||
mutate(() => {
|
||||
cat.optCategories = cat.optCategories.filter(c => c.id !== ocId);
|
||||
// Opts que apuntaban a esta categoría quedan sin categoría
|
||||
for (const opt of cat.opts) {
|
||||
if (opt.categoryId === ocId) delete opt.categoryId;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ── RuleSet helpers ─────────────────────────────────────────────────────
|
||||
|
||||
function addRule(ruleSet: RuleSet): RuleID {
|
||||
const rule = defaultRule();
|
||||
mutate(() => { ruleSet.rules.push(rule); });
|
||||
return rule.id;
|
||||
}
|
||||
|
||||
function updateRule(ruleSet: RuleSet, ruleId: RuleID, patch: Partial<Pick<Rule, 'condition' | 'priority'>>) {
|
||||
mutate(() => {
|
||||
const rule = ruleSet.rules.find(r => r.id === ruleId);
|
||||
if (!rule) return;
|
||||
if (patch.condition !== undefined) rule.condition = patch.condition;
|
||||
if (patch.priority !== undefined) rule.priority = patch.priority;
|
||||
});
|
||||
}
|
||||
|
||||
function removeRule(ruleSet: RuleSet, ruleId: RuleID) {
|
||||
mutate(() => {
|
||||
ruleSet.rules = ruleSet.rules.filter(r => r.id !== ruleId);
|
||||
});
|
||||
}
|
||||
|
||||
function addAction(ruleSet: RuleSet, ruleId: RuleID, action: RuleAction) {
|
||||
mutate(() => {
|
||||
const rule = ruleSet.rules.find(r => r.id === ruleId);
|
||||
if (rule) rule.actions.push(action);
|
||||
});
|
||||
}
|
||||
|
||||
function updateAction(ruleSet: RuleSet, ruleId: RuleID, actionIdx: number, action: RuleAction) {
|
||||
mutate(() => {
|
||||
const rule = ruleSet.rules.find(r => r.id === ruleId);
|
||||
if (rule && actionIdx < rule.actions.length) rule.actions[actionIdx] = action;
|
||||
});
|
||||
}
|
||||
|
||||
function removeAction(ruleSet: RuleSet, ruleId: RuleID, actionIdx: number) {
|
||||
mutate(() => {
|
||||
const rule = ruleSet.rules.find(r => r.id === ruleId);
|
||||
if (rule) rule.actions.splice(actionIdx, 1);
|
||||
});
|
||||
}
|
||||
|
||||
// ── Persistencia ────────────────────────────────────────────────────────
|
||||
|
||||
/** Devuelve el Cat en estado actual. Útil para guardar. */
|
||||
function getCat(): Cat {
|
||||
return cat;
|
||||
}
|
||||
|
||||
/** Marca el store como limpio (p.ej. tras guardar). */
|
||||
function markClean() {
|
||||
dirty = false;
|
||||
}
|
||||
|
||||
/** Descarta todos los cambios y vuelve al Cat inicial. */
|
||||
function discard() {
|
||||
cat = structuredClone(initial);
|
||||
dirty = false;
|
||||
selection = { kind: 'cat' };
|
||||
}
|
||||
|
||||
// ── API pública ──────────────────────────────────────────────────────────
|
||||
|
||||
return {
|
||||
// Estado reactivo
|
||||
get cat() { return cat; },
|
||||
get selection() { return selection; },
|
||||
get dirty() { return dirty; },
|
||||
|
||||
// Selección
|
||||
select,
|
||||
|
||||
// Cat
|
||||
renameCat,
|
||||
|
||||
// Obj
|
||||
addObj, renameObj, removeObj, moveObj,
|
||||
setObjRuleSet,
|
||||
|
||||
// Sec
|
||||
addSec, renameSec, removeSec, moveSec,
|
||||
setSecRuleSet,
|
||||
|
||||
// Att
|
||||
addAtt, addCatAtt, renameAtt, removeAtt, moveAtt,
|
||||
updateAttDomain, updateAttDefaultValue, setAttRuleSet,
|
||||
|
||||
// Opts globales
|
||||
addOpt, updateOpt, removeOpt, addAttOpt, removeAttOpt,
|
||||
|
||||
// OptCategories
|
||||
addOptCategory, renameOptCategory, removeOptCategory,
|
||||
|
||||
// RuleSet helpers (operan sobre cualquier RuleSet pasado por ref)
|
||||
addRule, updateRule, removeRule,
|
||||
addAction, updateAction, removeAction,
|
||||
|
||||
// Persistencia
|
||||
getCat, markClean, discard,
|
||||
|
||||
// Acceso a finders para los paneles
|
||||
findObj, findSec, findAtt, findCatAtt,
|
||||
};
|
||||
}
|
||||
|
||||
export type CatalogEditorStore = ReturnType<typeof createCatalogEditorStore>;
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// HELPERS
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
function defaultValueForDomain(domain: AttDomain): unknown {
|
||||
switch (domain.kind) {
|
||||
case 'string': return '';
|
||||
case 'number': return 0;
|
||||
case 'currency': return 0;
|
||||
case 'boolean': return false;
|
||||
case 'option': return null;
|
||||
case 'date': return '';
|
||||
case 'time': return '';
|
||||
case 'datetime': return '';
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,144 @@
|
||||
import {
|
||||
type Cat,
|
||||
type AttPath,
|
||||
type AttEffectiveState,
|
||||
type EvaluationResult, serializeAttPath
|
||||
} from '@/vcen/lib';
|
||||
import { Session, createSession } from '@/vcen/lib';
|
||||
import { JsonLogicEngine } from '@/jslg';
|
||||
|
||||
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// SESSION STORE — adaptador Svelte 5 sobre Session
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Store Svelte 5 reactivo para una sesión de configuración.
|
||||
*
|
||||
* Envuelve `Session` con runas `$state`/`$derived` de forma que los
|
||||
* componentes Svelte reaccionan automáticamente a cualquier cambio de valor
|
||||
* sin necesidad de `subscribe` manual.
|
||||
*
|
||||
* ## Uso básico
|
||||
*
|
||||
* ```svelte
|
||||
* <script>
|
||||
* import { createSessionStore } from './session.svelte';
|
||||
*
|
||||
* const store = createSessionStore(cat);
|
||||
* </script>
|
||||
*
|
||||
* <input
|
||||
* value={store.getValue(['ct:cars', 'ob:bmw', 'at:color'])}
|
||||
* oninput={e => store.setValue(['ct:cars', 'ob:bmw', 'at:color'], e.target.value)}
|
||||
* />
|
||||
*
|
||||
* {#if !store.isValid}
|
||||
* <p>Hay {store.errors.length} errores</p>
|
||||
* {/if}
|
||||
* ```
|
||||
*
|
||||
* ## Uso con estado efectivo
|
||||
*
|
||||
* ```svelte
|
||||
* {@const es = store.effectiveState(['ct:cars', 'ob:bmw', 'at:color'])}
|
||||
*
|
||||
* {#if es?.state === 'hidden'}
|
||||
* <!-- no renderizar -->
|
||||
* {:else if es?.state === 'required'}
|
||||
* <RequiredField />
|
||||
* {/if}
|
||||
* ```
|
||||
*/
|
||||
export function createSessionStore(
|
||||
cat: Cat,
|
||||
engine: JsonLogicEngine | null = null,
|
||||
) {
|
||||
const session = createSession(cat, engine);
|
||||
|
||||
// ── Estado reactivo ───────────────────────────────────────────────────────
|
||||
// Un único $state que contiene el EvaluationResult completo.
|
||||
// Toda la reactividad deriva de aquí — cuando cambia, todos los $derived
|
||||
// se recalculan automáticamente.
|
||||
|
||||
let result = $state<EvaluationResult>(session.getResult());
|
||||
|
||||
// Sincronizar Session → $state: cada vez que Session notifica,
|
||||
// actualizamos el $state para propagar la reactividad a Svelte.
|
||||
session.subscribe(next => { result = next; });
|
||||
|
||||
// ── Derivados ─────────────────────────────────────────────────────────────
|
||||
|
||||
const isValid = $derived(result.valid);
|
||||
const errors = $derived(result.errors);
|
||||
const warnings = $derived(result.warnings);
|
||||
const iterations = $derived(result.iterations);
|
||||
|
||||
// ── API de lectura ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Valor actual de un atributo.
|
||||
* Si el motor ha derivado un valor, devuelve el derivado.
|
||||
* Lee directamente de `result` ($state) — Svelte rastrea la dependencia
|
||||
* de forma natural sin necesidad de hacks.
|
||||
*/
|
||||
function getValue(path: AttPath): unknown {
|
||||
const key = serializeAttPath(path);
|
||||
const es = result.effectiveState.get(key);
|
||||
if (es?.derivedValue !== undefined) return es.derivedValue.value;
|
||||
// Valor base: buscar en el Cat original (no cambia — no necesita ser reactivo)
|
||||
return session.getValue(path);
|
||||
}
|
||||
|
||||
/**
|
||||
* Estado efectivo de un atributo.
|
||||
* Lee directamente de `result` ($state) — reactivo de forma natural.
|
||||
*/
|
||||
function effectiveState(path: AttPath): AttEffectiveState | undefined {
|
||||
return result.effectiveState.get(serializeAttPath(path));
|
||||
}
|
||||
|
||||
// ── API de mutación ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Cambia el valor de un atributo y re-evalúa incrementalmente.
|
||||
* Dispara reactividad Svelte automáticamente.
|
||||
*/
|
||||
function setValue(path: AttPath, value: unknown): void {
|
||||
session.setValue(path, value);
|
||||
// session.subscribe ya actualizó result — no hay que hacer nada más
|
||||
}
|
||||
|
||||
/**
|
||||
* Vuelve al estado inicial del catálogo.
|
||||
* Dispara reactividad Svelte automáticamente.
|
||||
*/
|
||||
function reset(): void {
|
||||
session.reset();
|
||||
}
|
||||
|
||||
// ── API pública ───────────────────────────────────────────────────────────
|
||||
|
||||
return {
|
||||
// Estado derivado (reactivo, solo lectura)
|
||||
get isValid() { return isValid; },
|
||||
get errors() { return errors; },
|
||||
get warnings() { return warnings; },
|
||||
get iterations() { return iterations; },
|
||||
get result() { return result; },
|
||||
|
||||
// Lectura por path (reactiva)
|
||||
getValue,
|
||||
effectiveState,
|
||||
|
||||
// Mutación
|
||||
setValue,
|
||||
reset,
|
||||
|
||||
// Acceso a la Session subyacente para casos avanzados
|
||||
get session() { return session; },
|
||||
};
|
||||
}
|
||||
|
||||
export type SessionStore = ReturnType<typeof createSessionStore>;
|
||||
@ -0,0 +1,12 @@
|
||||
// index.ts
|
||||
|
||||
|
||||
export * from './types'
|
||||
export * from './engine.ts';
|
||||
export * from './incremental.ts'
|
||||
export * from './node_util.ts'
|
||||
export * from './serialization.ts'
|
||||
export * from './session.ts'
|
||||
export * from './validation.ts'
|
||||
|
||||
|
||||
@ -0,0 +1,472 @@
|
||||
import type {
|
||||
Cat, Obj, Sec, Att, AttValue, AttDomain,
|
||||
AttOpt, Opt, OptCategory, RuleSet, Rule, RuleAction,
|
||||
RuleActionValue,
|
||||
RelativePath, RuleCondition
|
||||
} from './types';
|
||||
import type {LingString} from "@/ling";
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// SERIALIZE — Cat → JSON-safe plain object
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Serializa un `Cat` a un objeto plano JSON-safe.
|
||||
* El resultado puede pasarse a `JSON.stringify` directamente.
|
||||
*
|
||||
* No hay transformación de datos — `Cat` ya es JSON-safe por diseño.
|
||||
* Esta función documenta explícitamente la intención y sirve de punto
|
||||
* de entrada para futuras transformaciones (versioning, migrations).
|
||||
*
|
||||
* @example
|
||||
* const json = JSON.stringify(serializeCat(cat), null, 2);
|
||||
* await fs.writeFile('catalog.json', json);
|
||||
*/
|
||||
export function serializeCat(cat: Cat): unknown {
|
||||
return cat;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// DESERIALIZE — plain object → Cat (with validation)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Deserializa un objeto plano (p.ej. parseado de JSON) a `Cat`.
|
||||
* Valida la estructura y lanza `CatDeserializationError` si hay errores.
|
||||
*
|
||||
* @example
|
||||
* const raw = JSON.parse(await fs.readFile('catalog.json', 'utf-8'));
|
||||
* const cat = deserializeCat(raw);
|
||||
* const sess = createSession(cat);
|
||||
*/
|
||||
export function deserializeCat(raw: unknown): Cat {
|
||||
const ctx = new ParseContext('cat');
|
||||
const cat = parseCat(raw, ctx);
|
||||
ctx.throwIfErrors();
|
||||
return cat;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// ERROR
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export class CatDeserializationError extends Error {
|
||||
constructor(public readonly issues: string[]) {
|
||||
super(`Cat deserialization failed:\n${issues.map(i => ` - ${i}`).join('\n')}`);
|
||||
this.name = 'CatDeserializationError';
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// PARSE CONTEXT — acumula errores con paths legibles
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
class ParseContext {
|
||||
private readonly issues: string[];
|
||||
|
||||
constructor(
|
||||
private readonly path: string,
|
||||
issues: string[] = [],
|
||||
) {
|
||||
this.issues = issues;
|
||||
}
|
||||
|
||||
/** Crea un contexto hijo que comparte la misma lista de errores. */
|
||||
child(segment: string | number): ParseContext {
|
||||
return new ParseContext(`${this.path}.${segment}`, this.issues);
|
||||
}
|
||||
|
||||
error(message: string): void {
|
||||
this.issues.push(`${this.path}: ${message}`);
|
||||
}
|
||||
|
||||
get hasErrors(): boolean {
|
||||
return this.issues.length > 0;
|
||||
}
|
||||
|
||||
throwIfErrors(): void {
|
||||
if (this.hasErrors) throw new CatDeserializationError([...this.issues]);
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// PRIMITIVE HELPERS
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
function requireString(raw: unknown, ctx: ParseContext): string {
|
||||
if (typeof raw === 'string') return raw;
|
||||
ctx.error(`expected string, got ${typeof raw}`);
|
||||
return '';
|
||||
}
|
||||
|
||||
function requireNumber(raw: unknown, ctx: ParseContext): number {
|
||||
if (typeof raw === 'number') return raw;
|
||||
ctx.error(`expected number, got ${typeof raw}`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
function requireBoolean(raw: unknown, ctx: ParseContext): boolean {
|
||||
if (typeof raw === 'boolean') return raw;
|
||||
ctx.error(`expected boolean, got ${typeof raw}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
function requireArray(raw: unknown, ctx: ParseContext): unknown[] {
|
||||
if (Array.isArray(raw)) return raw;
|
||||
ctx.error(`expected array, got ${typeof raw}`);
|
||||
return [];
|
||||
}
|
||||
|
||||
function requireObject(raw: unknown, ctx: ParseContext): Record<string, unknown> {
|
||||
if (raw !== null && typeof raw === 'object' && !Array.isArray(raw)) {
|
||||
return raw as Record<string, unknown>;
|
||||
}
|
||||
ctx.error(`expected object, got ${Array.isArray(raw) ? 'array' : typeof raw}`);
|
||||
return {};
|
||||
}
|
||||
|
||||
function requireStringEnum<T extends string>(
|
||||
raw: unknown,
|
||||
allowed: readonly T[],
|
||||
ctx: ParseContext,
|
||||
): T {
|
||||
const s = requireString(raw, ctx);
|
||||
if ((allowed as readonly string[]).includes(s)) return s as T;
|
||||
ctx.error(`expected one of [${allowed.join(', ')}], got "${s}"`);
|
||||
return allowed[0];
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// CAT
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
function parseCat(raw: unknown, ctx: ParseContext): Cat {
|
||||
const obj = requireObject(raw, ctx);
|
||||
|
||||
const id = requireString(obj['id'], ctx.child('id'));
|
||||
const name = requireString(obj['name'], ctx.child('name'));
|
||||
const optCategories = parseOptCategoryArray(obj['optCategories'], ctx.child('optCategories'));
|
||||
const opts = parseOptArray(obj['opts'], ctx.child('opts'));
|
||||
const atts = parseAttArray(obj['atts'], ctx.child('atts'));
|
||||
const objs = parseObjArray(obj['objs'], ctx.child('objs'));
|
||||
|
||||
return { id: id as Cat['id'], name, optCategories, opts, atts, objs };
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// OPT CATEGORY
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
function parseOptCategoryArray(raw: unknown, ctx: ParseContext): OptCategory[] {
|
||||
return requireArray(raw, ctx).map((item, i) => parseOptCategory(item, ctx.child(i)));
|
||||
}
|
||||
|
||||
function parseOptCategory(raw: unknown, ctx: ParseContext): OptCategory {
|
||||
const obj = requireObject(raw, ctx);
|
||||
return {
|
||||
id: requireString(obj['id'], ctx.child('id')) as OptCategory['id'],
|
||||
name: requireString(obj['name'], ctx.child('name')),
|
||||
};
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// OPT
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
function parseOptArray(raw: unknown, ctx: ParseContext): Opt[] {
|
||||
return requireArray(raw, ctx).map((item, i) => parseOpt(item, ctx.child(i)));
|
||||
}
|
||||
|
||||
function parseOpt(raw: unknown, ctx: ParseContext): Opt {
|
||||
const obj = requireObject(raw, ctx);
|
||||
const id = requireString(obj['id'], ctx.child('id')) as Opt['id'];
|
||||
const value = parseOptValue(obj['value'], ctx.child('value'));
|
||||
|
||||
const opt: Opt = { id, value };
|
||||
|
||||
if (obj['categoryId'] !== undefined)
|
||||
opt.categoryId = requireString(obj['categoryId'], ctx.child('categoryId')) as Opt['categoryId'];
|
||||
if (obj['label'] !== undefined)
|
||||
opt.label = parseLingString(obj['label'], ctx.child('label'));
|
||||
if (obj['metadata'] !== undefined)
|
||||
opt.metadata = obj['metadata'] as Opt['metadata'];
|
||||
if (obj['rules'] !== undefined)
|
||||
opt.rules = parseRuleSet(obj['rules'], ctx.child('rules'));
|
||||
|
||||
return opt;
|
||||
}
|
||||
|
||||
function parseOptValue(raw: unknown, ctx: ParseContext): Opt['value'] {
|
||||
const obj = requireObject(raw, ctx);
|
||||
const kind = requireString(obj['kind'], ctx.child('kind'));
|
||||
|
||||
switch (kind) {
|
||||
case 'string': return { kind: 'string', value: requireString(obj['value'], ctx.child('value')) };
|
||||
case 'number': return { kind: 'number', value: requireNumber(obj['value'], ctx.child('value')) };
|
||||
case 'boolean': return { kind: 'boolean', value: requireBoolean(obj['value'], ctx.child('value')) };
|
||||
default:
|
||||
ctx.child('kind').error(`unknown opt value kind "${kind}"`);
|
||||
return { kind: 'string', value: '' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parsea un LingString, que puede ser:
|
||||
* - string plano: 'Rojo'
|
||||
* - IDLing: '#?colors.red' (string con prefijo '#?')
|
||||
* - LingRecord: { es: 'Rojo', en: 'Red', ... }
|
||||
*/
|
||||
function parseLingString(raw: unknown, ctx: ParseContext): LingString {
|
||||
if (typeof raw === 'string') return raw; // string plano o IDLing (#?)
|
||||
|
||||
const obj = requireObject(raw, ctx);
|
||||
const result: Record<string, string> = {};
|
||||
for (const [lang, val] of Object.entries(obj)) {
|
||||
result[lang] = requireString(val, ctx.child(lang));
|
||||
}
|
||||
return result as LingString;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// OBJECT
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
function parseObjArray(raw: unknown, ctx: ParseContext): Obj[] {
|
||||
return requireArray(raw, ctx).map((item, i) => parseObj(item, ctx.child(i)));
|
||||
}
|
||||
|
||||
function parseObj(raw: unknown, ctx: ParseContext): Obj {
|
||||
const obj = requireObject(raw, ctx);
|
||||
const id = requireString(obj['id'], ctx.child('id')) as Obj['id'];
|
||||
const name = requireString(obj['name'], ctx.child('name'));
|
||||
const atts = parseAttArray(obj['atts'], ctx.child('atts'));
|
||||
const secs = parseSecArray(obj['secs'], ctx.child('secs'));
|
||||
|
||||
const result: Obj = { id, name, atts, secs };
|
||||
if (obj['rules'] !== undefined)
|
||||
result.rules = parseRuleSet(obj['rules'], ctx.child('rules'));
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// SECTION
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
function parseSecArray(raw: unknown, ctx: ParseContext): Sec[] {
|
||||
return requireArray(raw, ctx).map((item, i) => parseSec(item, ctx.child(i)));
|
||||
}
|
||||
|
||||
function parseSec(raw: unknown, ctx: ParseContext): Sec {
|
||||
const obj = requireObject(raw, ctx);
|
||||
const id = requireString(obj['id'], ctx.child('id')) as Sec['id'];
|
||||
const name = requireString(obj['name'], ctx.child('name'));
|
||||
const atts = parseAttArray(obj['atts'], ctx.child('atts'));
|
||||
|
||||
const result: Sec = { id, name, atts };
|
||||
if (obj['rules'] !== undefined)
|
||||
result.rules = parseRuleSet(obj['rules'], ctx.child('rules'));
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// ATTRIBUTE
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
function parseAttArray(raw: unknown, ctx: ParseContext): Att[] {
|
||||
return requireArray(raw, ctx).map((item, i) => parseAtt(item, ctx.child(i)));
|
||||
}
|
||||
|
||||
function parseAtt(raw: unknown, ctx: ParseContext): Att {
|
||||
const obj = requireObject(raw, ctx);
|
||||
const id = requireString(obj['id'], ctx.child('id')) as Att['id'];
|
||||
const name = requireString(obj['name'], ctx.child('name'));
|
||||
const value = parseAttValue(obj['value'], ctx.child('value'));
|
||||
|
||||
const result: Att = { id, name, value };
|
||||
if (obj['rules'] !== undefined)
|
||||
result.rules = parseRuleSet(obj['rules'], ctx.child('rules'));
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// ATT VALUE — discriminar por domain.kind + domain.mode
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
function parseAttValue(raw: unknown, ctx: ParseContext): AttValue {
|
||||
const obj = requireObject(raw, ctx);
|
||||
const domain = parseAttDomain(obj['domain'], ctx.child('domain'));
|
||||
|
||||
switch (domain.kind) {
|
||||
case 'string': return { domain, value: parseScalarOrArray(obj['value'], requireString, ctx.child('value')) as any };
|
||||
case 'number': return { domain, value: parseScalarOrArray(obj['value'], requireNumber, ctx.child('value')) as any };
|
||||
case 'currency': return { domain, value: parseScalarOrArray(obj['value'], requireNumber, ctx.child('value')) as any };
|
||||
case 'boolean': return { domain, value: requireBoolean(obj['value'], ctx.child('value')) } as any;
|
||||
case 'option': return { domain, value: parseScalarOrArray(obj['value'], requireString, ctx.child('value')) as any };
|
||||
case 'date': return { domain, value: parseScalarOrArray(obj['value'], requireString, ctx.child('value')) as any };
|
||||
case 'time': return { domain, value: parseScalarOrArray(obj['value'], requireString, ctx.child('value')) as any };
|
||||
case 'datetime': return { domain, value: parseScalarOrArray(obj['value'], requireString, ctx.child('value')) as any };
|
||||
}
|
||||
}
|
||||
|
||||
function parseScalarOrArray<T>(
|
||||
raw: unknown,
|
||||
parser: (v: unknown, ctx: ParseContext) => T,
|
||||
ctx: ParseContext,
|
||||
): T | T[] {
|
||||
if (Array.isArray(raw)) return raw.map((v, i) => parser(v, ctx.child(i)));
|
||||
return parser(raw, ctx);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// ATT DOMAIN
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
const DOMAIN_KINDS = ['string', 'number', 'currency', 'boolean', 'option', 'date', 'time', 'datetime'] as const;
|
||||
|
||||
function parseAttDomain(raw: unknown, ctx: ParseContext): AttDomain {
|
||||
const obj = requireObject(raw, ctx);
|
||||
const kind = requireStringEnum(obj['kind'], DOMAIN_KINDS, ctx.child('kind'));
|
||||
const mode = requireString(obj['mode'], ctx.child('mode'));
|
||||
|
||||
switch (kind) {
|
||||
case 'string': {
|
||||
switch (mode) {
|
||||
case 'list': return { kind, mode, list: requireArray(obj['list'], ctx.child('list')).map((v, i) => requireString(v, ctx.child(`list.${i}`))) as any, ...parseMultiple(obj, ctx) };
|
||||
case 'pattern': return { kind, mode, pattern: requireString(obj['pattern'], ctx.child('pattern')), ...parseMultiple(obj, ctx) };
|
||||
case 'fixed': return { kind, mode, fixed: requireString(obj['fixed'], ctx.child('fixed')) };
|
||||
case 'input': return { kind, mode, ...parseMultiple(obj, ctx) };
|
||||
default: ctx.child('mode').error(`unknown mode "${mode}" for kind "string"`); return { kind, mode: 'input' };
|
||||
}
|
||||
}
|
||||
case 'number':
|
||||
case 'currency': {
|
||||
switch (mode) {
|
||||
case 'range': return { kind, mode, min: requireNumber(obj['min'], ctx.child('min')), max: requireNumber(obj['max'], ctx.child('max')), ...parseMultiple(obj, ctx) };
|
||||
case 'step': return { kind, mode, min: requireNumber(obj['min'], ctx.child('min')), max: requireNumber(obj['max'], ctx.child('max')), step: requireNumber(obj['step'], ctx.child('step')), ...parseMultiple(obj, ctx) };
|
||||
case 'list': return { kind, mode, list: requireArray(obj['list'], ctx.child('list')).map((v, i) => requireNumber(v, ctx.child(`list.${i}`))) as any, ...parseMultiple(obj, ctx) };
|
||||
case 'fixed': return { kind, mode, fixed: requireNumber(obj['fixed'], ctx.child('fixed')) };
|
||||
case 'input': return { kind, mode, ...parseMultiple(obj, ctx) };
|
||||
default: ctx.child('mode').error(`unknown mode "${mode}" for kind "${kind}"`); return { kind, mode: 'input' };
|
||||
}
|
||||
}
|
||||
case 'boolean': {
|
||||
switch (mode) {
|
||||
case 'input': return { kind, mode };
|
||||
case 'fixed': return { kind, mode, fixed: requireBoolean(obj['fixed'], ctx.child('fixed')) };
|
||||
default: ctx.child('mode').error(`unknown mode "${mode}" for kind "boolean"`); return { kind, mode: 'input' };
|
||||
}
|
||||
}
|
||||
case 'option': {
|
||||
switch (mode) {
|
||||
case 'list': return { kind, mode, list: parseAttOptArray(obj['list'], ctx.child('list')), ...parseMultiple(obj, ctx) };
|
||||
case 'fixed': return { kind, mode, fixed: requireString(obj['fixed'], ctx.child('fixed')) as any };
|
||||
default: ctx.child('mode').error(`unknown mode "${mode}" for kind "option"`); return { kind, mode: 'list', list: [] };
|
||||
}
|
||||
}
|
||||
case 'date':
|
||||
case 'time':
|
||||
case 'datetime': {
|
||||
switch (mode) {
|
||||
case 'range': return { kind, mode, min: requireString(obj['min'], ctx.child('min')) as any, max: requireString(obj['max'], ctx.child('max')) as any, ...parseMultiple(obj, ctx) };
|
||||
case 'list': return { kind, mode, list: requireArray(obj['list'], ctx.child('list')).map((v, i) => requireString(v, ctx.child(`list.${i}`))) as any, ...parseMultiple(obj, ctx) };
|
||||
case 'fixed': return { kind, mode, fixed: requireString(obj['fixed'], ctx.child('fixed')) as any };
|
||||
case 'input': return { kind, mode, ...parseMultiple(obj, ctx) };
|
||||
default: ctx.child('mode').error(`unknown mode "${mode}" for kind "${kind}"`); return { kind, mode: 'input' } as any;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function parseMultiple(obj: Record<string, unknown>, ctx: ParseContext): { multiple?: { min: number; max: number } } {
|
||||
if (obj['multiple'] === undefined) return {};
|
||||
const m = requireObject(obj['multiple'], ctx.child('multiple'));
|
||||
return {
|
||||
multiple: {
|
||||
min: requireNumber(m['min'], ctx.child('multiple.min')),
|
||||
max: requireNumber(m['max'], ctx.child('multiple.max')),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function parseAttOptArray(raw: unknown, ctx: ParseContext): AttOpt[] {
|
||||
return requireArray(raw, ctx).map((item, i) => parseAttOpt(item, ctx.child(i)));
|
||||
}
|
||||
|
||||
function parseAttOpt(raw: unknown, ctx: ParseContext): AttOpt {
|
||||
const obj = requireObject(raw, ctx);
|
||||
const optId = requireString(obj['optId'], ctx.child('optId')) as AttOpt['optId'];
|
||||
const result: AttOpt = { optId };
|
||||
if (obj['label'] !== undefined) result.label = parseLingString(obj['label'], ctx.child('label'));
|
||||
if (obj['metadata'] !== undefined) result.metadata = obj['metadata'] as AttOpt['metadata'];
|
||||
if (obj['rules'] !== undefined) result.rules = parseRuleSet(obj['rules'], ctx.child('rules'));
|
||||
return result;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// RULE SET / RULE / ACTION
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
function parseRuleSet(raw: unknown, ctx: ParseContext): RuleSet {
|
||||
const obj = requireObject(raw, ctx);
|
||||
return {
|
||||
rules: requireArray(obj['rules'], ctx.child('rules')).map((r, i) => parseRule(r, ctx.child(`rules.${i}`))),
|
||||
};
|
||||
}
|
||||
|
||||
function parseRule(raw: unknown, ctx: ParseContext): Rule {
|
||||
const obj = requireObject(raw, ctx);
|
||||
return {
|
||||
id: requireString(obj['id'], ctx.child('id')) as Rule['id'],
|
||||
priority: requireNumber(obj['priority'], ctx.child('priority')),
|
||||
condition: obj['condition'] as RuleCondition, // JsonLogicValue — opaco, no validamos la estructura interna
|
||||
actions: requireArray(obj['actions'], ctx.child('actions')).map((a, i) => parseRuleAction(a, ctx.child(`actions.${i}`))),
|
||||
};
|
||||
}
|
||||
|
||||
const ACTION_TYPES = ['attribute', 'domain', 'value', 'validation'] as const;
|
||||
|
||||
function parseRuleAction(raw: unknown, ctx: ParseContext): RuleAction {
|
||||
const obj = requireObject(raw, ctx);
|
||||
const type = requireStringEnum(obj['type'], ACTION_TYPES, ctx.child('type'));
|
||||
|
||||
switch (type) {
|
||||
case 'attribute':
|
||||
return {
|
||||
type,
|
||||
target: parseRelativePath(obj['target'], ctx.child('target')),
|
||||
ifTrue: requireStringEnum(obj['ifTrue'], ['available', 'required', 'hidden', 'forbidden'] as const, ctx.child('ifTrue')),
|
||||
ifFalse: requireStringEnum(obj['ifFalse'], ['available', 'required', 'hidden', 'forbidden'] as const, ctx.child('ifFalse')),
|
||||
};
|
||||
case 'domain':
|
||||
return {
|
||||
type,
|
||||
target: parseRelativePath(obj['target'], ctx.child('target')),
|
||||
value: obj['value'] as any,
|
||||
state: requireStringEnum(obj['state'], ['allowed', 'forbidden'] as const, ctx.child('state')),
|
||||
};
|
||||
case 'value':
|
||||
return {
|
||||
type,
|
||||
target: parseRelativePath(obj['target'], ctx.child('target')),
|
||||
value: parseAttValue(obj['value'], ctx.child('value')),
|
||||
} as RuleActionValue;
|
||||
case 'validation':
|
||||
return {
|
||||
type,
|
||||
level: requireStringEnum(obj['level'], ['error', 'warning'] as const, ctx.child('level')),
|
||||
message: requireString(obj['message'], ctx.child('message')),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function parseRelativePath(raw: unknown, ctx: ParseContext): RelativePath {
|
||||
const arr = requireArray(raw, ctx);
|
||||
if (arr.length === 0) return [];
|
||||
if (arr.length === 1) return [requireString(arr[0], ctx.child(0)) as any];
|
||||
if (arr.length === 2) return [requireString(arr[0], ctx.child(0)) as any, requireString(arr[1], ctx.child(1)) as any];
|
||||
ctx.error(`RelativePath must have 0, 1 or 2 elements, got ${arr.length}`);
|
||||
return [];
|
||||
}
|
||||
@ -0,0 +1,309 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {serializeCat, deserializeCat, CatDeserializationError, type Opt} from '@/vcen/lib';
|
||||
import type { Cat } from '@/vcen/lib';
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// FIXTURES
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
const minimalCat: Cat = {
|
||||
id: 'ct_test' as Cat['id'],
|
||||
name: 'Test',
|
||||
optCategories: [],
|
||||
opts: [],
|
||||
atts: [],
|
||||
objs: [],
|
||||
};
|
||||
|
||||
const fullCat : Cat = {
|
||||
id: 'ct_vehicles' as Cat['id'],
|
||||
name: 'Vehículos',
|
||||
optCategories: [
|
||||
{ id: 'oc_colors' as any, name: 'Colores' },
|
||||
],
|
||||
opts: [
|
||||
{
|
||||
id: 'op_rojo' as any,
|
||||
categoryId: 'oc_colors' as any,
|
||||
value: { kind: 'string', value: 'rojo' },
|
||||
label: { es: 'Rojo', en: 'Red' },
|
||||
},
|
||||
{
|
||||
id: 'op_azul' as any,
|
||||
value: { kind: 'string', value: 'azul' },
|
||||
rules: {
|
||||
rules: [{
|
||||
id: 'rl_azul-sport' as any,
|
||||
priority: 10,
|
||||
condition: { '==': [{ var: 'at_tipo' }, 'sport'] },
|
||||
actions: [],
|
||||
}],
|
||||
},
|
||||
},
|
||||
],
|
||||
atts: [
|
||||
{
|
||||
id: 'at_brand' as any,
|
||||
name: 'Marca',
|
||||
value: { domain: { kind: 'string', mode: 'input' }, value: '' },
|
||||
},
|
||||
],
|
||||
objs: [
|
||||
{
|
||||
id: 'ob:bmw' as any,
|
||||
name: 'BMW 430i',
|
||||
atts: [
|
||||
{
|
||||
id: 'at_color' as any,
|
||||
name: 'Color',
|
||||
value: {
|
||||
domain: {
|
||||
kind: 'option',
|
||||
mode: 'list',
|
||||
list: [
|
||||
{ optId: 'op_rojo' as any },
|
||||
{ optId: 'op_azul' as any, label: { es: 'Azul deportivo' } },
|
||||
],
|
||||
},
|
||||
value: 'op_rojo' as any,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'at_precio' as any,
|
||||
name: 'Precio',
|
||||
value: {
|
||||
domain: { kind: 'currency', mode: 'range', min: 30000, max: 80000 },
|
||||
value: 45000,
|
||||
},
|
||||
rules: {
|
||||
rules: [{
|
||||
id: 'rl_precio-lujo' as any,
|
||||
priority: 5,
|
||||
condition: { '==': [{ var: 'at_color' }, 'op_rojo'] },
|
||||
actions: [
|
||||
{ type: 'attribute', target: [], ifTrue: 'required', ifFalse: 'available' },
|
||||
{ type: 'validation', level: 'warning', message: 'Rojo tiene suplemento' },
|
||||
],
|
||||
}],
|
||||
},
|
||||
},
|
||||
],
|
||||
secs: [
|
||||
{
|
||||
id: 'sc:motor' as any,
|
||||
name: 'Motor',
|
||||
atts: [
|
||||
{
|
||||
id: 'at_combustible' as any,
|
||||
name: 'Combustible',
|
||||
value: {
|
||||
domain: { kind: 'string', mode: 'list', list: ['gasolina', 'diesel', 'electrico'] },
|
||||
value: 'gasolina',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'at_potencia' as any,
|
||||
name: 'Potencia (CV)',
|
||||
value: {
|
||||
domain: { kind: 'number', mode: 'step', min: 100, max: 500, step: 50 },
|
||||
value: 200,
|
||||
},
|
||||
},
|
||||
],
|
||||
rules: {
|
||||
rules: [{
|
||||
id: 'rl_electrico-hide-potencia' as any,
|
||||
priority: 1,
|
||||
condition: { '==': [{ var: 'at_combustible' }, 'electrico'] },
|
||||
actions: [
|
||||
{ type: 'attribute', target: ['at_potencia' as any], ifTrue: 'hidden', ifFalse: 'available' },
|
||||
{ type: 'value', target: ['at_potencia' as any], value: { domain: { kind: 'number', mode: 'fixed', fixed: 0 }, value: 0 } },
|
||||
],
|
||||
}],
|
||||
},
|
||||
},
|
||||
],
|
||||
rules: {
|
||||
rules: [{
|
||||
id: 'rl_domain-color' as any,
|
||||
priority: 1,
|
||||
condition: { '==': [{ var: 'at_precio' }, 30000] },
|
||||
actions: [
|
||||
{ type: 'domain', target: ['at_color' as any], value: 'op_azul' as any, state: 'forbidden' },
|
||||
],
|
||||
}],
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// ROUNDTRIP
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('serializeCat / deserializeCat — roundtrip', () => {
|
||||
it('roundtrip de Cat mínimo', () => {
|
||||
const json = JSON.stringify(serializeCat(minimalCat));
|
||||
const result = deserializeCat(JSON.parse(json));
|
||||
expect(result).toEqual(minimalCat);
|
||||
});
|
||||
|
||||
it('roundtrip de Cat completo', () => {
|
||||
const json = JSON.stringify(serializeCat(fullCat));
|
||||
const result = deserializeCat(JSON.parse(json));
|
||||
expect(result).toEqual(fullCat);
|
||||
});
|
||||
|
||||
it('preserva optCategories', () => {
|
||||
const json = JSON.stringify(serializeCat(fullCat));
|
||||
const result = deserializeCat(JSON.parse(json));
|
||||
expect(result.optCategories).toHaveLength(1);
|
||||
expect(result.optCategories[0].id).toBe('oc_colors');
|
||||
});
|
||||
|
||||
it('preserva opts con label y categoryId', () => {
|
||||
const json = JSON.stringify(serializeCat(fullCat));
|
||||
const result = deserializeCat(JSON.parse(json));
|
||||
const opt = result.opts.find((o : Opt) => o.id === 'op_rojo');
|
||||
//expect(opt?.label).toEqual({ es: 'Rojo', en: 'Red' });
|
||||
expect(opt?.categoryId).toBe('oc_colors');
|
||||
});
|
||||
|
||||
it('preserva opts con rules', () => {
|
||||
const json = JSON.stringify(serializeCat(fullCat));
|
||||
const result = deserializeCat(JSON.parse(json));
|
||||
const opt = result.opts.find(o => o.id === 'op_azul');
|
||||
expect(opt?.rules?.rules).toHaveLength(1);
|
||||
expect(opt?.rules?.rules[0].id).toBe('rl_azul-sport');
|
||||
});
|
||||
|
||||
it('preserva dominio option/list con AttOpt y label local', () => {
|
||||
const json = JSON.stringify(serializeCat(fullCat));
|
||||
const result = deserializeCat(JSON.parse(json));
|
||||
const att = result.objs[0].atts.find(a => a.id === 'at_color');
|
||||
expect(att?.value.domain.kind).toBe('option');
|
||||
const domain = att?.value.domain as any;
|
||||
expect(domain.list).toHaveLength(2);
|
||||
expect(domain.list[1].label).toEqual({ es: 'Azul deportivo' });
|
||||
});
|
||||
|
||||
it('preserva dominio number/step con min, max, step', () => {
|
||||
const json = JSON.stringify(serializeCat(fullCat));
|
||||
const result = deserializeCat(JSON.parse(json));
|
||||
const sec = result.objs[0].secs[0];
|
||||
const att = sec.atts.find(a => a.id === 'at_potencia');
|
||||
const domain = att?.value.domain as any;
|
||||
expect(domain.step).toBe(50);
|
||||
expect(domain.max).toBe(500);
|
||||
});
|
||||
|
||||
it('preserva acción attribute con ifTrue/ifFalse', () => {
|
||||
const json = JSON.stringify(serializeCat(fullCat));
|
||||
const result = deserializeCat(JSON.parse(json));
|
||||
const action = result.objs[0].atts[1].rules!.rules[0].actions[0] as any;
|
||||
expect(action.type).toBe('attribute');
|
||||
expect(action.ifTrue).toBe('required');
|
||||
expect(action.ifFalse).toBe('available');
|
||||
});
|
||||
|
||||
it('preserva acción value con AttValue embebido', () => {
|
||||
const json = JSON.stringify(serializeCat(fullCat));
|
||||
const result = deserializeCat(JSON.parse(json));
|
||||
const actions = result.objs[0].secs[0].rules!.rules[0].actions;
|
||||
const action = actions.find((a: any) => a.type === 'value') as any;
|
||||
expect(action.value.domain.kind).toBe('number');
|
||||
expect(action.value.domain.fixed).toBe(0);
|
||||
});
|
||||
|
||||
it('preserva acción domain con value y state', () => {
|
||||
const json = JSON.stringify(serializeCat(fullCat));
|
||||
const result = deserializeCat(JSON.parse(json));
|
||||
const action = result.objs[0].rules!.rules[0].actions[0] as any;
|
||||
expect(action.type).toBe('domain');
|
||||
expect(action.value).toBe('op_azul');
|
||||
expect(action.state).toBe('forbidden');
|
||||
});
|
||||
|
||||
it('preserva acción validation con level y message', () => {
|
||||
const json = JSON.stringify(serializeCat(fullCat));
|
||||
const result = deserializeCat(JSON.parse(json));
|
||||
const action = result.objs[0].atts[1].rules!.rules[0].actions[1] as any;
|
||||
expect(action.type).toBe('validation');
|
||||
expect(action.level).toBe('warning');
|
||||
expect(action.message).toBe('Rojo tiene suplemento');
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// ERRORES DE DESERIALIZACIÓN
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('deserializeCat — errores', () => {
|
||||
it('lanza CatDeserializationError si el input no es objeto', () => {
|
||||
expect(() => deserializeCat('not an object')).toThrow(CatDeserializationError);
|
||||
});
|
||||
|
||||
it('lanza si falta id', () => {
|
||||
const raw = { name: 'X', optCategories: [], opts: [], atts: [], objs: [] };
|
||||
expect(() => deserializeCat(raw)).toThrow(CatDeserializationError);
|
||||
});
|
||||
|
||||
it('lanza si falta name', () => {
|
||||
const raw = { id: 'ct_x', optCategories: [], opts: [], atts: [], objs: [] };
|
||||
expect(() => deserializeCat(raw)).toThrow(CatDeserializationError);
|
||||
});
|
||||
|
||||
it('lanza si objs no es array', () => {
|
||||
const raw = { id: 'ct_x', name: 'X', optCategories: [], opts: [], atts: [], objs: 'bad' };
|
||||
expect(() => deserializeCat(raw)).toThrow(CatDeserializationError);
|
||||
});
|
||||
|
||||
it('lanza si domain.kind es desconocido', () => {
|
||||
const raw = {
|
||||
id: 'ct_x', name: 'X', optCategories: [], opts: [], objs: [],
|
||||
atts: [{ id: 'at_x', name: 'X', value: { domain: { kind: 'unknown', mode: 'input' }, value: '' } }],
|
||||
};
|
||||
expect(() => deserializeCat(raw)).toThrow(CatDeserializationError);
|
||||
});
|
||||
|
||||
it('lanza si domain option tiene mode inválido', () => {
|
||||
const raw = {
|
||||
id: 'ct_x', name: 'X', optCategories: [], opts: [], objs: [],
|
||||
atts: [{ id: 'at_x', name: 'X', value: { domain: { kind: 'option', mode: 'input' }, value: null } }],
|
||||
};
|
||||
expect(() => deserializeCat(raw)).toThrow(CatDeserializationError);
|
||||
});
|
||||
|
||||
it('lanza si action.type es desconocido', () => {
|
||||
const raw = {
|
||||
id: 'ct_x', name: 'X', optCategories: [], opts: [], atts: [], objs: [{
|
||||
id: 'ob:x', name: 'X', atts: [], secs: [],
|
||||
rules: { rules: [{ id: 'rl_x', priority: 1, condition: true, actions: [{ type: 'unknown' }] }] },
|
||||
}],
|
||||
};
|
||||
expect(() => deserializeCat(raw)).toThrow(CatDeserializationError);
|
||||
});
|
||||
|
||||
it('el mensaje de error incluye el path del campo inválido', () => {
|
||||
const raw = { id: 'ct_x', name: 'X', optCategories: [], opts: [], atts: [], objs: 'bad' };
|
||||
try {
|
||||
deserializeCat(raw);
|
||||
expect.fail('debería haber lanzado');
|
||||
} catch (e) {
|
||||
expect(e).toBeInstanceOf(CatDeserializationError);
|
||||
expect((e as CatDeserializationError).message).toContain('objs');
|
||||
}
|
||||
});
|
||||
|
||||
it('acumula múltiples errores en lugar de parar en el primero', () => {
|
||||
// Sin id ni name — debe reportar ambos
|
||||
const raw = { optCategories: [], opts: [], atts: [], objs: [] };
|
||||
try {
|
||||
deserializeCat(raw);
|
||||
expect.fail('debería haber lanzado');
|
||||
} catch (e) {
|
||||
expect(e).toBeInstanceOf(CatDeserializationError);
|
||||
expect((e as CatDeserializationError).issues.length).toBeGreaterThanOrEqual(2);
|
||||
}
|
||||
});
|
||||
});
|
||||
Loading…
Reference in new issue