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">
|
<script lang="ts">
|
||||||
import { createDemoStore } from './demo.store.svelte';
|
import { createDemoStore } from './demo.store.svelte.js';
|
||||||
import AttEditor from './AttEditor.svelte';
|
import AttEditor from './AttEditor.svelte';
|
||||||
import RuntimePanel from './RuntimePanel.svelte';
|
import RuntimePanel from './RuntimePanel.svelte';
|
||||||
import GraphPanel from './GraphPanel.svelte';
|
import GraphPanel from './GraphPanel.svelte';
|
||||||
import type { DomainKind } from './demo.store.svelte';
|
import type { DomainKind } from './demo.store.svelte.js';
|
||||||
|
|
||||||
const store = createDemoStore();
|
const store = createDemoStore();
|
||||||
|
|
||||||
@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { createConditionBuilderStore } from '../../stores/ConditionEditor.svelte.js';
|
import { createConditionBuilderStore } from '../stores/condition-builder.svelte.ts';
|
||||||
import ConditionBuilder from '../ConditionBuilder.svelte';
|
import ConditionBuilder from '../comps/ConditionBuilder.svelte';
|
||||||
import type { Obj } from '@/vcen/lib/types';
|
import type { Obj } from '@/vcen/lib/types';
|
||||||
import type { JsonLogicValue } from '@/jslg';
|
import type { JsonLogicValue } from '@/jslg';
|
||||||
|
|
||||||
@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { DemoStore } from './demo.store.svelte';
|
import type { DemoStore } from './demo.store.svelte.js';
|
||||||
import { getTransitiveAffected, getDependencies } from '@/vcen/lib/graph';
|
import { getTransitiveAffected, getDependencies } from '@/vcen/lib/graph.ts';
|
||||||
|
|
||||||
|
|
||||||
interface Props { store: DemoStore; }
|
interface Props { store: DemoStore; }
|
||||||
@ -1,5 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { DemoStore } from './demo.store.svelte';
|
import type { DemoStore } from './demo.store.svelte.js';
|
||||||
|
|
||||||
interface Props { store: DemoStore; }
|
interface Props { store: DemoStore; }
|
||||||
let { store }: Props = $props();
|
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