diff --git a/index.html b/index.html index 574f785..fdeaa55 100644 --- a/index.html +++ b/index.html @@ -8,7 +8,7 @@
- + \ No newline at end of file diff --git a/src/apps/svelte/components/GlobDemo.svelte b/src/apps/svelte/components/GlobDemo.svelte index 8e0f60b..46d262d 100644 --- a/src/apps/svelte/components/GlobDemo.svelte +++ b/src/apps/svelte/components/GlobDemo.svelte @@ -1,10 +1,10 @@ + +
+ + {#if label} +
+ + + {filledCount}/{locales.length} idiomas + +
+ {/if} + +
+ + +
+
+ {#each locales as locale (locale)} + {#if localeTab} + {@render localeTab({ locale, active: currentLocale === locale, hasContent: hasContent(locale), activate: () => activateLocale(locale) })} + {:else} + {@const active = currentLocale === locale} + {@const filled = hasContent(locale)} + {@const isDef = locale === defaultLocale} + + {/if} + {/each} +
+ {#if charCount > 0} + {charCount} + {/if} +
+ + {#if asType === 'textarea'} + + {:else} + focused = true} + onblur={() => focused = false} + class="h-11 w-full bg-transparent px-4 text-[14px] text-zinc-800 + placeholder:text-zinc-300 focus:outline-none + dark:text-zinc-200 dark:placeholder:text-zinc-600" + /> + {/if} + + {#if invalid} +
+ + + + + El campo {defaultLocale.toUpperCase()} es obligatorio + +
+ {/if} +
+
\ No newline at end of file diff --git a/src/ling/tests/ling-schemas.test.ts b/src/ling/tests/ling-schemas.test.ts index e28a8a1..6e42ad8 100644 --- a/src/ling/tests/ling-schemas.test.ts +++ b/src/ling/tests/ling-schemas.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect, beforeEach } from 'vitest'; -import { createLing } from '@/ling/engine'; +import { createLing } from '@/ling/lib/engine.ts'; -import type { LingNode } from '@/ling/types'; +import type { LingNode } from '@/ling/lib/types.ts'; // ─── SCHEMAS DE TEST ───────────────────────────────────────────────────────── @@ -94,7 +94,7 @@ describe('Carga Eager', () => { // ============================================================================= // LAZY — extend() sobre instancia global -// Los tests de lazy verifican comportamiento en runtime, no tipos. +// Los testsass de lazy verifican comportamiento en runtime, no tipos. // El type-safety del lazy se verifica en instance.ts con FullSchema + import type. // Aquí usamos createLing sin genérico — t() acepta cualquier string. // ============================================================================= diff --git a/src/ling/tests/ling.test.ts b/src/ling/tests/ling.test.ts index 7b3bfd1..0ca00e4 100644 --- a/src/ling/tests/ling.test.ts +++ b/src/ling/tests/ling.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; -import {createLing, ling, type LingString} from '@/ling'; -import type { LingNode } from '@/ling'; +import {createLing, ling, type LingString} from '@/ling/lib'; +import type { LingNode } from '@/ling/lib'; // ============================== diff --git a/src/ling/tests/ling2.test.ts b/src/ling/tests/ling2.test.ts index 40fbc32..5f4dbeb 100644 --- a/src/ling/tests/ling2.test.ts +++ b/src/ling/tests/ling2.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { createLing } from '@/ling'; -import type { LingNode, LingString } from '@/ling'; +import { createLing } from '@/ling/lib'; +import type { LingNode, LingString } from '@/ling/lib'; // ============================== // SCHEMA DE TEST ACTUALIZADO @@ -27,7 +27,7 @@ const testSchema = { invoice: '#?checkout.total' }, danger: { - // Referencia circular para test de seguridad + // Referencia circular para testsass de seguridad loopA: '#?danger.loopB', loopB: '#?danger.loopA' } @@ -128,5 +128,5 @@ describe('Ling Library', () => { expect(extended.t('admin.saveBtn')).toBe('Guardar'); }); }); -});// ling2.test.ts +});// ling2.testsass.ts diff --git a/src/ling/tests/plural.test.ts b/src/ling/tests/plural.test.ts index 9e11321..1470483 100644 --- a/src/ling/tests/plural.test.ts +++ b/src/ling/tests/plural.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { createLing, p } from '@/ling'; +import { createLing, p } from '@/ling/lib'; describe('Pluralización con helper p()', () => { diff --git a/src/ling/tests/serializer.test.ts b/src/ling/tests/serializer.test.ts index 3dba387..98c2a81 100644 --- a/src/ling/tests/serializer.test.ts +++ b/src/ling/tests/serializer.test.ts @@ -1,8 +1,8 @@ import { describe, it, expect, vi } from 'vitest'; -import { lingNodeToJSON, JSONToLingNode } from '../json'; -import { p } from '../engine'; -import { createLing } from '../engine'; -import type { LingNode } from '../types'; +import { lingNodeToJSON, JSONToLingNode } from '../lib/json.ts'; +import { p } from '../lib/engine.ts'; +import { createLing } from '../lib/engine.ts'; +import type { LingNode } from '../lib/types.ts'; describe('Ling Serialization System', () => { @@ -107,5 +107,5 @@ describe('Ling Serialization System', () => { expect(ling.t("user.items" as any, { count: 5 })).toBe("5 items"); }); }); -});// serializer.test.ts +});// serializer.testsass.ts diff --git a/src/logr/docs/logr.md b/src/logr/docs/logr.md index 4da0c9e..f769179 100644 --- a/src/logr/docs/logr.md +++ b/src/logr/docs/logr.md @@ -97,12 +97,12 @@ logr.getLogs({ category: 'auth', level: LogLevel.WARN }) // combinados > Los mensajes se devuelven como `I18nString` sin resolver. -### `serialize()` +### `serialization()` Exporta el historial como JSON. Los mensajes se resuelven al locale activo **en el momento de la llamada**. ```ts -const json = logr.serialize(); +const json = logr.serialization(); // [ // { // "timestamp": "2024-01-01T00:00:00.000Z", @@ -125,7 +125,7 @@ logr.getLogs(); // → [] ```ts logr.setLevel(LogLevel.DEBUG) // activa todos los niveles -logr.setLevel(LogLevel.NONE) // silencia todos los logs — útil en tests +logr.setLevel(LogLevel.NONE) // silencia todos los logs — útil en testsass ``` ### `setMaxLogs(max)` @@ -199,7 +199,7 @@ callbackTransport((entry, message) => { } }) -// Captura en tests — sin output a consola +// Captura en testsass — sin output a consola const captured: string[] = []; const logr = createLogr(i18n, { level : LogLevel.DEBUG, @@ -255,7 +255,7 @@ logr.warn('auth', { es: 'Sesión expirada', en: 'Session expired' }); El mensaje se resuelve **una sola vez** por entrada y se pasa ya resuelto a todos los transports. -> El mensaje se almacena como `I18nString` sin resolver. La resolución al locale ocurre en el momento del output, y en `serialize()` en el momento de la llamada. +> El mensaje se almacena como `I18nString` sin resolver. La resolución al locale ocurre en el momento del output, y en `serialization()` en el momento de la llamada. --- diff --git a/src/logr/engine.ts b/src/logr/lib/engine.ts similarity index 98% rename from src/logr/engine.ts rename to src/logr/lib/engine.ts index 716cf33..aba0f94 100644 --- a/src/logr/engine.ts +++ b/src/logr/lib/engine.ts @@ -1,7 +1,7 @@ import type { LingInstance, LingString -} from '@/ling'; +} from '@/ling/lib'; import type { LogrOptions, @@ -129,7 +129,7 @@ export function createLogr(ling: LingInstance, options: LogrOptions = {}): Logr /** * Los mensajes se resuelven al locale activo en el momento - * de llamar a serialize(), no al momento en que se registraron. + * de llamar a serialization(), no al momento en que se registraron. */ function serialize(): string { return JSON.stringify( diff --git a/src/logr/index.ts b/src/logr/lib/index.ts similarity index 100% rename from src/logr/index.ts rename to src/logr/lib/index.ts diff --git a/src/logr/transports.ts b/src/logr/lib/transports.ts similarity index 97% rename from src/logr/transports.ts rename to src/logr/lib/transports.ts index 48ef409..89e399f 100644 --- a/src/logr/transports.ts +++ b/src/logr/lib/transports.ts @@ -99,7 +99,7 @@ export function httpTransport(options: HttpTransportOptions): Transport { /** * Transport que invoca una función callback por cada entrada de log. * Es el transport más flexible — ideal para integraciones custom, - * tests, o cuando necesitas lógica de routing entre destinos. + * testsass, o cuando necesitas lógica de routing entre destinos. * * @example * // Integración con Sentry @@ -110,7 +110,7 @@ export function httpTransport(options: HttpTransportOptions): Transport { * }) * * @example - * // En tests — captura los logs sin output a consola + * // En testsass — captura los logs sin output a consola * const captured: string[] = []; * callbackTransport((entry, message) => captured.push(message)) */ diff --git a/src/logr/types.ts b/src/logr/lib/types.ts similarity index 98% rename from src/logr/types.ts rename to src/logr/lib/types.ts index de03a8a..fca264a 100644 --- a/src/logr/types.ts +++ b/src/logr/lib/types.ts @@ -1,4 +1,4 @@ -import type { LingString } from '@/ling'; +import type { LingString } from '@/ling/lib'; // ============================================================================ @@ -24,7 +24,7 @@ export enum LogLevel { WARN = 2, /** Errores que requieren atención inmediata */ ERROR = 3, - /** Desactiva todos los logs — útil en tests */ + /** Desactiva todos los logs — útil en testsass */ NONE = 4 } diff --git a/src/logr/tests/logr.test.ts b/src/logr/tests/logr.test.ts index 070b6b6..bde607f 100644 --- a/src/logr/tests/logr.test.ts +++ b/src/logr/tests/logr.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { createLogr, LogLevel } from '@/logr'; -import { createLing } from '@/ling'; -import type { LingNode, SupportedLocale } from '@/ling'; +import { createLogr, LogLevel } from '@/logr/lib'; +import { createLing } from '@/ling/lib'; +import type { LingNode, SupportedLocale } from '@/ling/lib'; @@ -272,7 +272,7 @@ describe('createLogger', () => { }); }); - describe('serialize()', () => { + describe('serialization()', () => { it('exporta los logs como JSON con mensajes resueltos', () => { const i18n = makeI18n('en'); const logger = createLogr(i18n, { level: LogLevel.DEBUG }); diff --git a/src/logr/tests/logr_transports.test.ts b/src/logr/tests/logr_transports.test.ts index 70edf78..57a98be 100644 --- a/src/logr/tests/logr_transports.test.ts +++ b/src/logr/tests/logr_transports.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; -import {callbackTransport, consoleTransport, createLogr, httpTransport, type LogEntry, LogLevel} from '@/logr'; -import { createLing } from '@/ling'; -import type { LingNode, SupportedLocale } from '@/ling'; +import {callbackTransport, consoleTransport, createLogr, httpTransport, type LogEntry, LogLevel} from '@/logr/lib'; +import { createLing } from '@/ling/lib'; +import type { LingNode, SupportedLocale } from '@/ling/lib'; // ============================================================================ // HELPERS @@ -182,7 +182,7 @@ describe('callbackTransport', () => { expect(fn).toHaveBeenCalledWith(entry, 'Login fallido'); }); - it('se puede usar para capturar logs en tests', () => { + it('se puede usar para capturar logs en testsass', () => { const i18n = makeI18n('en'); const captured: string[] = []; const logr = createLogr(i18n, { @@ -246,7 +246,7 @@ describe('múltiples transports', () => { }); // El segundo transport sí debería ejecutarse aunque el primero falle - // Este test documenta el comportamiento actual — si se quiere + // Este testsass documenta el comportamiento actual — si se quiere // aislamiento habría que añadir try/catch en el engine expect(() => logr.warn('auth', { es: 'msg' })).toThrow(); }); diff --git a/src/mtas/lib/builders.ts b/src/mtas/lib/builders.ts new file mode 100644 index 0000000..c611c14 --- /dev/null +++ b/src/mtas/lib/builders.ts @@ -0,0 +1,139 @@ +import type {LingString} from "@/ling/lib"; +import type { + Metadata, MetadataField, + CurrencyValue, MediaRef, + StringField, NumberField, BooleanField, LingStringField, + DateField, DateTimeField, UrlField, CurrencyField, + ImageField, VideoField, AudioField, PdfField, FileField, GroupField, +} from './types.ts'; + + +// ───────────────────────────────────────────── +// Builders de campo (devuelven MetadataField) +// ───────────────────────────────────────────── + +export const string = (value: string): StringField => ({ type: 'string', value }); +export const number = (value: number): NumberField => ({ type: 'number', value }); +export const boolean = (value: boolean): BooleanField => ({ type: 'boolean', value }); +export const lingString = (value: LingString): LingStringField => ({ type: 'lingstring', value }); +export const date = (value: string): DateField => ({ type: 'date', value }); +export const datetime = (value: string): DateTimeField => ({ type: 'datetime', value }); +export const url = (value: string): UrlField => ({ type: 'url', value }); +export const currency = (value: CurrencyValue): CurrencyField => ({ type: 'currency', value }); +export const image = (value: MediaRef): ImageField => ({ type: 'image', value }); +export const video = (value: MediaRef): VideoField => ({ type: 'video', value }); +export const audio = (value: MediaRef): AudioField => ({ type: 'audio', value }); +export const pdf = (value: MediaRef): PdfField => ({ type: 'pdf', value }); +export const file = (value: MediaRef): FileField => ({ type: 'file', value }); +export const group = (value: Metadata, order?: string[]): GroupField => ({ type: 'group', value, ...(order !== undefined && { order }) }); + +/** Namespace agrupado para importar como `field.string(...)`, `field.group(...)`, etc. */ +export const field = { + string, number, boolean, lingstring: lingString, date, datetime, + url, currency, image, video, audio, pdf, file, group, +} as const; + +// ───────────────────────────────────────────── +// Operaciones sobre Metadata (inmutables) +// ───────────────────────────────────────────── + +/** + * Obtiene el MetadataField asociado a una clave (solo nivel raíz). + * Devuelve undefined si la clave no existe o el valor no es un MetadataField. + */ +export function get(metadata: Metadata, key: string): MetadataField | undefined { + const value = metadata[key]; + if (typeof value !== 'object' || value === null) return undefined; + const v = value as Record; + if (typeof v['type'] !== 'string') return undefined; + return value as MetadataField; +} + +/** + * Búsqueda recursiva por clave en toda la jerarquía de grupos. + * Devuelve el primer MetadataField cuya clave coincida. + */ +export function find(metadata: Metadata, key: string): MetadataField | undefined { + for (const [k, value] of Object.entries(metadata)) { + if (k === key) return value as MetadataField; + // Si es un grupo, buscar dentro recursivamente + if ( + typeof value === 'object' && value !== null && + (value as Record)['type'] === 'group' + ) { + const groupValue = (value as GroupField).value; + const found = find(groupValue, key); + if (found) return found; + } + } + return undefined; +} + +/** + * Añade una clave al Metadata. Lanza error si la clave ya existe. + * Devuelve un nuevo Metadata (inmutable). + */ +export function add(metadata: Metadata, key: string, value: MetadataField): Metadata { + if (key in metadata) throw new Error(`Key "${key}" already exists in Metadata`); + return { ...metadata, [key]: value }; +} + +/** + * Establece (crea o reemplaza) una clave en el Metadata. + * Devuelve un nuevo Metadata (inmutable). + */ +export function set(metadata: Metadata, key: string, value: MetadataField): Metadata { + return { ...metadata, [key]: value }; +} + +/** + * Elimina una clave del Metadata (no-op si no existe). + * Devuelve un nuevo Metadata (inmutable). + */ +export function remove(metadata: Metadata, key: string): Metadata { + const { [key]: _, ...rest } = metadata; + return rest; +} + +/** + * Convierte un Metadata a un objeto plano con los valores resueltos. + * Los grupos se convierten en objetos anidados. + * + * @example + * toPlain({ brand: { type: 'string', value: 'Toyota' } }) + * // → { brand: 'Toyota' } + */ +export function toPlain(metadata: Metadata): Record { + const result: Record = {}; + for (const [key, value] of Object.entries(metadata)) { + if (typeof value !== 'object' || value === null) { result[key] = value; continue; } + const field = value as MetadataField; + result[key] = field.type === 'group' ? toPlain(field.value) : field.value; + } + return result; +} + +// ───────────────────────────────────────────── +// Resolución de orden en GroupField +// ───────────────────────────────────────────── + +/** + * Devuelve las claves de un GroupField en el orden correcto. + * + * - Si `order` está definido: primero las claves de `order` (ignorando las que + * no existan en `value`), luego las claves restantes en orden natural. + * - Si `order` es undefined: orden natural del Record. + * + * @example + * resolveOrder({ type: 'group', value: { hp: ..., fuel: ... }, order: ['fuel', 'hp'] }) + * // → ['fuel', 'hp'] + */ +export function resolveOrder(groupField: GroupField): string[] { + const allKeys = Object.keys(groupField.value); + if (!groupField.order || groupField.order.length === 0) return allKeys; + + const ordered = groupField.order.filter(k => k in groupField.value); + const rest = allKeys.filter(k => !ordered.includes(k)); + return [...ordered, ...rest]; +} + diff --git a/src/mtas/lib/guards.ts b/src/mtas/lib/guards.ts new file mode 100644 index 0000000..bffbc27 --- /dev/null +++ b/src/mtas/lib/guards.ts @@ -0,0 +1,37 @@ +// ───────────────────────────────────────────── +// Type guards de primitivos +// ───────────────────────────────────────────── + +import type {CurrencyValue, MediaRef, MetadataField, MetadataFieldType} from "@/mtas/lib/types.ts"; + +export function isCurrencyValue(v: unknown): v is CurrencyValue { + if (typeof v !== 'object' || v === null) return false; + const c = v as Record; + return typeof c['amount'] === 'number' && typeof c['currency'] === 'string' && c['currency'].length > 0; +} + +export function isMediaRef(v: unknown): v is MediaRef { + if (typeof v !== 'object' || v === null) return false; + const m = v as Record; + return typeof m['url'] === 'string' && m['url'].length > 0; +} + + + +export function isValidUrl(value: string): boolean { + try { new URL(value); return true; } catch { return false; } +} + +const KNOWN_TYPES: MetadataFieldType[] = [ + 'string','number','boolean','lingstring','date','datetime', + 'url','currency','image','video','audio','pdf','file','group', +]; + + + +export function isMetadataField(v: unknown): v is MetadataField { + if (typeof v !== 'object' || v === null) return false; + const f = v as Record; + return KNOWN_TYPES.includes(f['type'] as MetadataFieldType); +} + diff --git a/src/mtas/lib/index.ts b/src/mtas/lib/index.ts new file mode 100644 index 0000000..c1e2802 --- /dev/null +++ b/src/mtas/lib/index.ts @@ -0,0 +1,9 @@ + + +export * from './builders.ts' +export * from './guards.ts' +export * from './validation.ts' +export * from './builders.ts' +export * from './serialization.ts' +export * from './types.ts' +export * from './version.ts' diff --git a/src/mtas/lib/serialization.ts b/src/mtas/lib/serialization.ts new file mode 100644 index 0000000..298afbd --- /dev/null +++ b/src/mtas/lib/serialization.ts @@ -0,0 +1,99 @@ +import type {DeserializeResult, Metadata, SerializedMetadata} from './types'; +import { validateMetadata } from './validation.ts'; +import {CURRENT_VERSION} from "@/mtas/lib/version.ts"; + + + +// ───────────────────────────────────────────── +// Migraciones por versión +// Añadir aquí cuando __v cambie en el futuro. +// ───────────────────────────────────────────── + +type Migrator = (data: unknown) => Metadata; + +const migrations: Record = { + 1: (data) => data as Metadata, // v1 → v1: sin transformación +}; + +function migrate(version: number, data: unknown): Metadata | undefined { + const migrator = migrations[version]; + if (!migrator) return undefined; + return migrator(data); +} + +// ───────────────────────────────────────────── +// serialization +// ───────────────────────────────────────────── + +/** + * Serializa un Metadata a string JSON versionado. + * + * @example + * const json = serialization({ brand: { type: 'string', value: 'Toyota' } }); + * // '{"__v":1,"data":{"brand":{"type":"string","value":"Toyota"}}}' + */ +export function serialization(metadata: Metadata): string { + const payload: SerializedMetadata = { + __v: CURRENT_VERSION, + data: metadata, + }; + return JSON.stringify(payload); +} + +// ───────────────────────────────────────────── +// deserialize +// ───────────────────────────────────────────── + +/** + * Deserializa un string JSON versionado a Metadata. + * Valida el resultado antes de devolverlo. + * Devuelve DeserializeError si el JSON es inválido o la validación falla. + * + * @example + * const result = deserialize('{"__v":1,"data":{...}}'); + * if (result.ok) { + * const metadata = result.metadata; + * } else { + * console.error(result.error, result.validation?.errors); + * } + */ +export function deserialize(json: string): DeserializeResult { + // 1. Parse JSON + let parsed: unknown; + try { + parsed = JSON.parse(json); + } catch { + return { ok: false, error: 'Invalid JSON string' }; + } + + // 2. Comprobar estructura mínima + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { + return { ok: false, error: 'Serialized payload must be a plain object' }; + } + + const payload = parsed as Record; + + if (typeof payload['__v'] !== 'number') { + return { ok: false, error: 'Missing or invalid "__v" version field' }; + } + + if (!('data' in payload)) { + return { ok: false, error: 'Missing "data" field' }; + } + + // 3. Migrar según versión + const version = payload['__v'] as number; + const migrated = migrate(version, payload['data']); + + if (!migrated) { + return { ok: false, error: `Unsupported version: ${version}` }; + } + + // 4. Validar el Metadata resultante + const validation = validateMetadata(migrated); + if (!validation.valid) { + return { ok: false, error: 'Metadata validation failed', validation }; + } + + return { ok: true, metadata: migrated }; +} diff --git a/src/mtas/lib/types.ts b/src/mtas/lib/types.ts new file mode 100644 index 0000000..7cc9759 --- /dev/null +++ b/src/mtas/lib/types.ts @@ -0,0 +1,183 @@ +import type {LingString} from "@/ling/lib"; +import type {CURRENT_VERSION} from "@/mtas/lib/version.ts"; + +// ───────────────────────────────────────────── +// Tipos base — tal como fueron definidos +// ───────────────────────────────────────────── + + + +/** + * Metadata arbitraria — pares clave/valor para datos adicionales. + * Los valores son MetadataField (discriminated union por 'type'). + * Un valor puede ser otro Metadata (grupo anidado via GroupField). + */ +export type Metadata = Record; + + + +// ───────────────────────────────────────────── +// Primitivos de soporte +// ───────────────────────────────────────────── + + +/** Valor monetario con código ISO 4217 */ +export interface CurrencyValue { + amount : number; + currency: string; // "EUR", "USD"… +} + +/** Referencia a un recurso externo (imagen, vídeo, audio, pdf, fichero) */ +export interface MediaRef { + url : string; + mimeType?: string; + size? : number; // bytes + filename?: string; + alt? : LingString; +} + +// ───────────────────────────────────────────── +// Campos tipados — Discriminated Union +// Los valores del Record de Metadata son de este tipo. +// ───────────────────────────────────────────── + +interface FieldBase { + type : MetadataFieldType; + hidden? : boolean; + // futuras: required?, disabled?, label?... +} + + +export interface StringField extends FieldBase { type: 'string'; value: string } +export interface NumberField extends FieldBase { type: 'number'; value: number } +export interface BooleanField extends FieldBase { type: 'boolean'; value: boolean } +export interface LingStringField extends FieldBase { type: 'lingstring'; value: LingString } +export interface DateField extends FieldBase { type: 'date'; value: string } // ISO 8601: "YYYY-MM-DD" +export interface DateTimeField extends FieldBase { type: 'datetime'; value: string } // ISO 8601: "YYYY-MM-DDThh:mm:ssZ" +export interface UrlField extends FieldBase { type: 'url'; value: string } +export interface CurrencyField extends FieldBase { type: 'currency'; value: CurrencyValue } +export interface ImageField extends FieldBase { type: 'image'; value: MediaRef } +export interface VideoField extends FieldBase { type: 'video'; value: MediaRef } +export interface AudioField extends FieldBase { type: 'audio'; value: MediaRef } +export interface PdfField extends FieldBase { type: 'pdf'; value: MediaRef } +export interface FileField extends FieldBase { type: 'file'; value: MediaRef } + +/** + * Grupo anidado — su value ES otro Metadata. + * Permite construir estructuras jerárquicas de profundidad arbitraria. + * + * @example + * const field: GroupField = { + * type: 'group', + * value: { + * hp: { type: 'number', value: 120 }, + * fuel: { type: 'string', value: 'diesel' }, + * } + * } + */ +export interface GroupField { + type: 'group'; + order?: string[]; // claves en el orden deseado + value: Metadata; +} + +/** Unión discriminada de todos los tipos de campo */ +export type MetadataField = + | StringField + | NumberField + | BooleanField + | LingStringField + | DateField + | DateTimeField + | UrlField + | CurrencyField + | ImageField + | VideoField + | AudioField + | PdfField + | FileField + | GroupField; + + + +/** Todos los discriminantes posibles */ +export type MetadataFieldType = MetadataField['type']; + + + +// ───────────────────────────────────────────── +// Mixin — adjunta Metadata a cualquier entidad +// ───────────────────────────────────────────── + +/** + * @example + * interface Car extends WithMetadata { + * id: string; + * model: string; + * } + * + * const car: Car = { + * id: '1', + * model: 'Corolla', + * metadata: { + * brand: { type: 'string', value: 'Toyota' }, + * price: { type: 'currency', value: { amount: 25000, currency: 'EUR' } }, + * specs: { type: 'group', value: { + * hp: { type: 'number', value: 120 }, + * }}, + * } + * }; + */ +export type WithMetadata = T & { + metadata?: Metadata; +}; + + + + +// ───────────────────────────────────────────── +// Resultado de validación +// ───────────────────────────────────────────── + +export interface ValidationOk { valid: true } + +export interface ValidationError { valid: false; errors: string[] } + + +export type ValidationResult = ValidationOk | ValidationError; + + + +// ───────────────────────────────────────────── +// Formato serializado con versión +// ───────────────────────────────────────────── + + +export type SerializedVersion = typeof CURRENT_VERSION; + +/** + * Formato wire de un Metadata serializado. + * El campo `__v` permite migrar el schema en el futuro + * sin romper consumidores que manejen versiones anteriores. + */ +export interface SerializedMetadata { + __v: SerializedVersion; + data: Metadata; +} + +// ───────────────────────────────────────────── +// Resultado de deserialización +// ───────────────────────────────────────────── + +export interface DeserializeOk { + ok: true; + metadata: Metadata; +} + +export interface DeserializeError { + ok: false; + error: string; + validation?: ValidationError; +} + +export type DeserializeResult = DeserializeOk | DeserializeError; diff --git a/src/mtas/lib/validation.ts b/src/mtas/lib/validation.ts new file mode 100644 index 0000000..b7de2b4 --- /dev/null +++ b/src/mtas/lib/validation.ts @@ -0,0 +1,117 @@ +import type { + Metadata, MetadataField, ValidationError, ValidationOk, ValidationResult +} from './types.ts'; +import {isLingString} from "@/ling/lib/guards.ts"; +import {isCurrencyValue, isMediaRef, isMetadataField, isValidUrl} from "@/mtas/lib/guards.ts"; + + + + + +const ok = (): ValidationOk => ({ valid: true }); +const fail = (...errors: string[]): ValidationError => ({ valid: false, errors }); + + +function mergeResults(...results: ValidationResult[]): ValidationResult { + const errors = results.flatMap(r => r.valid ? [] : r.errors); + return errors.length > 0 ? fail(...errors) : ok(); +} + + +const ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}$/; +const ISO_DATETIME_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/; + + + + +// ───────────────────────────────────────────── +// Validadores por tipo +// ───────────────────────────────────────────── + +function validateByType(key: string, field: MetadataField): ValidationResult { + switch (field.type) { + case 'string': + return ok(); + + case 'number': + return !isNaN(field.value) + ? ok() : fail(`[${key}] value must be a valid number`); + + case 'boolean': + return ok(); + + case 'lingstring': + return isLingString(field.value) + ? ok() : fail(`[${key}] value must be a LingString (string | LingRecord | IDLing)`); + + case 'date': + return ISO_DATE_RE.test(field.value) + ? ok() : fail(`[${key}] value must be an ISO 8601 date (YYYY-MM-DD)`); + + case 'datetime': + return ISO_DATETIME_RE.test(field.value) + ? ok() : fail(`[${key}] value must be an ISO 8601 datetime (YYYY-MM-DDThh:mm:ssZ)`); + + case 'url': + return isValidUrl(field.value) + ? ok() : fail(`[${key}] value must be a valid URL`); + + case 'currency': { + if (!isCurrencyValue(field.value)) return fail(`[${key}] value must be { amount: number, currency: string }`); + if (field.value.currency.length !== 3) return fail(`[${key}] currency must be a 3-letter ISO 4217 code`); + return ok(); + } + + case 'image': + case 'video': + case 'audio': + case 'pdf': + case 'file': { + if (!isMediaRef(field.value)) return fail(`[${key}] value must be a MediaRef with a non-empty 'url'`); + if (!isValidUrl(field.value.url)) return fail(`[${key}] MediaRef.url must be a valid URL`); + return ok(); + } + + case 'group': + return validateMetadata(field.value, key); + + default: { + const exhaustive: never = field; + return fail(`Unknown field type: ${(exhaustive as MetadataField).type}`); + } + } +} + +// ───────────────────────────────────────────── +// Validadores públicos +// ───────────────────────────────────────────── + +/** + * Valida un único valor extraído de un Metadata. + * @param key - clave dentro del Metadata (para mensajes de error) + * @param value - el valor en bruto (unknown) almacenado en el Record + */ +export function validateField(key: string, value: unknown): ValidationResult { + if (typeof key !== 'string' || key.trim() === '') + return fail('Field key must be a non-empty string'); + if (!isMetadataField(value)) + return fail(`[${key}] value must be a MetadataField with a known 'type'`); + return validateByType(key, value); +} + +/** + * Valida un objeto Metadata completo (todos sus pares clave/valor). + * @param metadata - el Record a validar + * @param prefix - prefijo para los mensajes de error en grupos anidados + */ +export function validateMetadata(metadata: Metadata, prefix = ''): ValidationResult { + if (typeof metadata !== 'object' || metadata === null || Array.isArray(metadata)) + return fail(`${prefix ? `[${prefix}] ` : ''}Metadata must be a plain object`); + + const results = Object.entries(metadata).map(([key, value]) => { + const fullKey = prefix ? `${prefix}.${key}` : key; + return validateField(fullKey, value); + }); + + return mergeResults(...results); +} \ No newline at end of file diff --git a/src/mtas/lib/version.ts b/src/mtas/lib/version.ts new file mode 100644 index 0000000..588e5d8 --- /dev/null +++ b/src/mtas/lib/version.ts @@ -0,0 +1,3 @@ +export const CURRENT_VERSION = 1 as const; + + diff --git a/src/mtas/svelte/components/FieldEditor.svelte b/src/mtas/svelte/components/FieldEditor.svelte new file mode 100644 index 0000000..d1eb18d --- /dev/null +++ b/src/mtas/svelte/components/FieldEditor.svelte @@ -0,0 +1,168 @@ + + +
+ + +
+ + e.key === 'Enter' && (e.currentTarget as HTMLInputElement).blur()} + class="{inputCls} font-mono" + placeholder="nombre_campo" + /> +
+ + + {#if field.type === 'string'} +
+ + update({ value: (e.currentTarget as HTMLInputElement).value })} + class={inputCls} placeholder="Texto…" /> +
+ + {:else if field.type === 'number'} +
+ + update({ value: parseFloat((e.currentTarget as HTMLInputElement).value) })} + class={inputCls} /> +
+ + {:else if field.type === 'boolean'} +
+ +
+ + + {field.value ? 'Verdadero' : 'Falso'} + +
+
+ + {:else if field.type === 'lingstring'} + update({ value: val })} + /> + + {:else if field.type === 'date'} +
+ + update({ value: (e.currentTarget as HTMLInputElement).value })} + class={inputCls} /> +
+ + {:else if field.type === 'datetime'} +
+ + update({ value: (e.currentTarget as HTMLInputElement).value + 'Z' })} + class={inputCls} /> +
+ + {:else if field.type === 'url'} +
+ + update({ value: (e.currentTarget as HTMLInputElement).value })} + class={inputCls} placeholder="https://… o /ruta/relativa" /> +
+ + {:else if field.type === 'currency'} +
+
+ + update({ value: { ...field.value, amount: parseFloat((e.currentTarget as HTMLInputElement).value) } })} + class={inputCls} /> +
+
+ + update({ value: { ...field.value, currency: (e.currentTarget as HTMLInputElement).value.toUpperCase() } })} + class="{inputCls} font-mono uppercase" placeholder="EUR" /> +
+
+ + {:else if field.type === 'image' || field.type === 'video' || field.type === 'audio' || field.type === 'pdf' || field.type === 'file'} +
+ + updateMediaUrl((e.currentTarget as HTMLInputElement).value)} + class={inputCls} placeholder="/ruta/relativa o https://…" /> +
+ {#if field.type === 'image' && field.value.url} +
+ {fieldKey} +
+ {/if} + + {:else if field.type === 'group'} +

+ Edita los campos del grupo expandiéndolos en la lista. +

+ {/if} + + +
+
+

Ocultar campo

+

El campo no se mostrará en la UI

+
+ +
+ +
\ No newline at end of file diff --git a/src/mtas/svelte/components/FieldRow.svelte b/src/mtas/svelte/components/FieldRow.svelte new file mode 100644 index 0000000..dfcbcd8 --- /dev/null +++ b/src/mtas/svelte/components/FieldRow.svelte @@ -0,0 +1,300 @@ + + +
{ e.preventDefault(); dragOver = true; onDragOver(e); }} + ondragleave={() => dragOver = false} + ondrop={(e) => { dragOver = false; onDrop(e); }} + class={[ + 'group/row overflow-hidden rounded-xl border-2 bg-white transition-all duration-150 dark:bg-zinc-900', + depth > 0 ? 'ml-5' : '', + dragOver + ? 'border-zinc-400 dark:border-zinc-500' + : validation.valid + ? expanded + ? 'border-zinc-900 dark:border-zinc-100' + : 'border-zinc-200 hover:border-zinc-300 dark:border-zinc-700 dark:hover:border-zinc-600' + : 'border-red-300 dark:border-red-700', + ].join(' ')} +> + +
expanded = !expanded} + onkeydown={(e) => e.key === 'Enter' && (expanded = !expanded)} + class={[ + 'flex cursor-pointer items-center gap-3 px-4 transition-colors duration-100', + thumbUrl ? 'py-2' : 'py-3', + expanded ? 'bg-zinc-50 dark:bg-zinc-800/50' : '', + ].join(' ')} + > + +
e.stopPropagation()}> + + + + +
+ + + {#if thumbUrl} +
+ {fieldKey} +
+ {/if} + + + + + + + {fieldKey} + + + + + {summary} + + + + {#if field.hidden} + + oculto + + {/if} + + +
+ + +
e.stopPropagation()}> + + +
+ + + + + +
+ + + {#if !validation.valid && (validation as any).errors?.length} +
+ + + + {(validation as any).errors[0]} +
+ {/if} + + + {#if expanded} +
+ onFieldChange(f)} + /> +
+ {/if} + + + {#if field.type === 'group'} +
+ + +
+ + +
+ + + {#each (['string','number','boolean','lingstring','date','datetime','url','currency','image','video','audio','pdf','file','group'] as MetadataFieldType[]) as type} + + {/each} +
+ + + {#if groupOpen} +
+ {#if Object.keys(field.value).length === 0} +
+ + Grupo vacío — añade campos con los botones de arriba + +
+ {:else} + {#each Object.keys(field.value) as childKey (childKey)} + renameChildField(childKey, newKey)} + onFieldChange={(f) => updateChildField(childKey, f)} + onDelete={() => deleteChildField(childKey)} + onDragStart={(e) => { e.dataTransfer?.setData('text/plain', `${fieldKey}::${childKey}`); childDragSrc = childKey; }} + onDragOver={() => { childDragOver = childKey; }} + onDrop={() => dropChildField(childKey)} + /> + {/each} + {/if} +
+ {/if} +
+ {/if} +
\ No newline at end of file diff --git a/src/mtas/svelte/components/MetadataEditor.svelte b/src/mtas/svelte/components/MetadataEditor.svelte new file mode 100644 index 0000000..7863f4e --- /dev/null +++ b/src/mtas/svelte/components/MetadataEditor.svelte @@ -0,0 +1,191 @@ + + +
+
+ + +
+ + +
+

+ Metadata +

+ + {fieldKeys.length} {fieldKeys.length === 1 ? 'campo' : 'campos'} + + +
+
+ {validation.valid ? 'Válido' : `${(validation as any).errors?.length} errores`} +
+
+ + +
+ {#each typeGroups as group} +
+ + {group.label} + +
+ {#each group.types as type} + + {/each} +
+
+ {/each} +
+
+ + +
+ {#if fieldKeys.length === 0} +
+ + + + +

+ Añade campos usando los botones de arriba +

+
+ {:else} + {#each fieldKeys as key (key)} + updateFieldKey(key, newKey)} + onFieldChange={(field) => updateField(key, field)} + onDelete={() => deleteField(key)} + onDragStart={(e) => { e.dataTransfer?.setData('text/plain', key); dragSrcKey = key; }} + onDragOver={() => {}} + onDrop={() => handleDrop(key)} + /> + {/each} + {/if} +
+ + + {#if !validation.valid && (validation as any).errors?.length} +
+

+ Errores de validación +

+
    + {#each (validation as any).errors as error} +
  • · {error}
  • + {/each} +
+
+ {/if} + +
+
\ No newline at end of file diff --git a/src/mtas/svelte/components/TypeBadge.svelte b/src/mtas/svelte/components/TypeBadge.svelte new file mode 100644 index 0000000..f09ca56 --- /dev/null +++ b/src/mtas/svelte/components/TypeBadge.svelte @@ -0,0 +1,32 @@ + + + + {cfg.label} + diff --git a/src/mtas/svelte/demo/App.svelte b/src/mtas/svelte/demo/App.svelte new file mode 100644 index 0000000..90bafd3 --- /dev/null +++ b/src/mtas/svelte/demo/App.svelte @@ -0,0 +1,285 @@ + + + + meta-content · Demo + + + + +
+ + + + + +
+
+
+
+ + + + + + +
+
+ + meta-content + + + v0.1.0 + +
+
+ +
+ + +
+
+
+ + +
+ + +
+

+ Metadata
+ Editor +

+

+ Campos tipados · Validación en tiempo real · Drag & drop · Dark mode +

+
+ + {#if showJson} + +
+
+ + metadata · serializado + + +
+
{JSON.stringify(metadata, null, 2)}
+
+ + {:else} +
+ + +
+
+ + MetadataEditor + +
+
+ metadata = m} + /> +
+ + +
+ + +
+
+ + LingInput + +
+
+ +
+ + + lingValue = v} + /> + + + lingValue = v} + /> + + +
+ Simular inválido + +
+
+
+ + +
+

+ Estado actual +

+
+ {#each [ + { label: 'campos', value: Object.keys(metadata).length }, + { label: 'tipos únicos', value: new Set(Object.values(metadata).map((f: any) => f.type)).size }, + { label: 'con hidden', value: Object.values(metadata).filter((f: any) => f.hidden).length }, + ] as stat} +
+ {stat.label} + {stat.value} +
+ {/each} +
+
+ + +
+

+ Tipos disponibles +

+
+ {#each ['string','number','boolean','lingstring','date','datetime','url','currency','image','video','audio','pdf','file','group'] as type} + {@const colors: Record = { + string: 'bg-sky-50 text-sky-500 border-sky-200 dark:bg-sky-900/20 dark:text-sky-400 dark:border-sky-800', + number: 'bg-violet-50 text-violet-500 border-violet-200 dark:bg-violet-900/20 dark:text-violet-400 dark:border-violet-800', + boolean: 'bg-amber-50 text-amber-500 border-amber-200 dark:bg-amber-900/20 dark:text-amber-400 dark:border-amber-800', + lingstring: 'bg-emerald-50 text-emerald-500 border-emerald-200 dark:bg-emerald-900/20 dark:text-emerald-400 dark:border-emerald-800', + date: 'bg-orange-50 text-orange-500 border-orange-200 dark:bg-orange-900/20 dark:text-orange-400 dark:border-orange-800', + datetime: 'bg-rose-50 text-rose-500 border-rose-200 dark:bg-rose-900/20 dark:text-rose-400 dark:border-rose-800', + url: 'bg-cyan-50 text-cyan-500 border-cyan-200 dark:bg-cyan-900/20 dark:text-cyan-400 dark:border-cyan-800', + currency: 'bg-lime-50 text-lime-500 border-lime-200 dark:bg-lime-900/20 dark:text-lime-400 dark:border-lime-800', + image: 'bg-pink-50 text-pink-500 border-pink-200 dark:bg-pink-900/20 dark:text-pink-400 dark:border-pink-800', + video: 'bg-red-50 text-red-500 border-red-200 dark:bg-red-900/20 dark:text-red-400 dark:border-red-800', + audio: 'bg-purple-50 text-purple-500 border-purple-200 dark:bg-purple-900/20 dark:text-purple-400 dark:border-purple-800', + pdf: 'bg-zinc-100 text-zinc-500 border-zinc-200 dark:bg-zinc-800 dark:text-zinc-400 dark:border-zinc-700', + file: 'bg-teal-50 text-teal-500 border-teal-200 dark:bg-teal-900/20 dark:text-teal-400 dark:border-teal-800', + group: 'bg-indigo-50 text-indigo-500 border-indigo-200 dark:bg-indigo-900/20 dark:text-indigo-400 dark:border-indigo-800', + }} + + {type} + + {/each} +
+
+ +
+
+ {/if} + +
+ + +
+

+ meta-content · TypeScript · Svelte 5 · Tailwind CSS +

+
+ +
diff --git a/src/mtas/svelte/demo/app.css b/src/mtas/svelte/demo/app.css new file mode 100644 index 0000000..15fd2d9 --- /dev/null +++ b/src/mtas/svelte/demo/app.css @@ -0,0 +1,10 @@ +@import "tailwindcss"; + + +/* Smooth scroll para navegación */ +html { + scroll-behavior: smooth; +} + + +@variant dark (&:where([data-theme=dark], [data-theme=dark] *)); \ No newline at end of file diff --git a/src/vcen/editor/main.ts b/src/mtas/svelte/demo/main.ts similarity index 100% rename from src/vcen/editor/main.ts rename to src/mtas/svelte/demo/main.ts diff --git a/src/mtas/tests/serialization.test.ts b/src/mtas/tests/serialization.test.ts new file mode 100644 index 0000000..f4cd6d3 --- /dev/null +++ b/src/mtas/tests/serialization.test.ts @@ -0,0 +1,178 @@ +import { describe, it, expect } from 'vitest'; +import {deserialize, serialization} from '../lib'; +import { field } from '../lib'; +import type { Metadata } from '../lib'; + +// ───────────────────────────────────────────── +// serialization +// ───────────────────────────────────────────── + +describe('serialization', () => { + it('produce un JSON con __v y data', () => { + const meta: Metadata = { brand: field.string('Toyota') }; + const json = serialization(meta); + const parsed = JSON.parse(json); + expect(parsed.__v).toBe(1); + expect(parsed.data).toEqual(meta); + }); + + it('serializa metadata vacía', () => { + const json = serialization({}); + const parsed = JSON.parse(json); + expect(parsed.data).toEqual({}); + }); + + it('serializa grupos anidados', () => { + const meta: Metadata = { + specs: field.group({ + hp: field.number(120), + fuel: field.string('diesel'), + }), + }; + const json = serialization(meta); + const parsed = JSON.parse(json); + expect(parsed.data.specs.type).toBe('group'); + expect(parsed.data.specs.value.hp.value).toBe(120); + }); + + it('serializa todos los tipos de campo', () => { + const meta: Metadata = { + a: field.string('hello'), + b: field.number(42), + c: field.boolean(true), + d: field.date('2024-01-15'), + e: field.datetime('2024-01-15T10:00:00Z'), + f: field.url('https://example.com'), + g: field.currency({ amount: 99, currency: 'EUR' }), + h: field.image({ url: 'https://cdn.example.com/img.jpg' }), + i: field.video({ url: 'https://cdn.example.com/v.mp4' }), + j: field.audio({ url: 'https://cdn.example.com/s.mp3' }), + k: field.pdf({ url: 'https://cdn.example.com/d.pdf' }), + l: field.file({ url: 'https://cdn.example.com/d.zip' }), + }; + const json = serialization(meta); + const result = deserialize(json); + expect(result.ok).toBe(true); + }); +}); + +// ───────────────────────────────────────────── +// deserialize — casos de éxito +// ───────────────────────────────────────────── + +describe('deserialize — éxito', () => { + it('recupera exactamente el Metadata original', () => { + const meta: Metadata = { + brand: field.string('Toyota'), + price: field.currency({ amount: 25000, currency: 'EUR' }), + }; + const result = deserialize(serialization(meta)); + expect(result.ok).toBe(true); + if (result.ok) expect(result.metadata).toEqual(meta); + }); + + it('roundtrip con grupo anidado y order', () => { + const meta: Metadata = { + specs: field.group( + { hp: field.number(120), fuel: field.string('diesel') }, + ['fuel', 'hp'] + ), + }; + const result = deserialize(serialization(meta)); + expect(result.ok).toBe(true); + if (result.ok) expect(result.metadata).toEqual(meta); + }); + + it('roundtrip con hidden: true', () => { + const meta: Metadata = { + internal: { ...field.string('secret'), hidden: true }, + }; + const result = deserialize(serialization(meta)); + expect(result.ok).toBe(true); + if (result.ok) { + const f = result.metadata['internal'] as { hidden: boolean }; + expect(f.hidden).toBe(true); + } + }); + + it('roundtrip con metadata vacía', () => { + const result = deserialize(serialization({})); + expect(result.ok).toBe(true); + if (result.ok) expect(result.metadata).toEqual({}); + }); +}); + +// ───────────────────────────────────────────── +// deserialize — casos de error +// ───────────────────────────────────────────── + +describe('deserialize — errores', () => { + it('falla con JSON malformado', () => { + const result = deserialize('{invalid json}'); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error).toBe('Invalid JSON string'); + }); + + it('falla si el payload es un array', () => { + const result = deserialize('[]'); + expect(result.ok).toBe(false); + }); + + it('falla si falta __v', () => { + const result = deserialize(JSON.stringify({ data: {} })); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error).toContain('__v'); + }); + + it('falla si falta data', () => { + const result = deserialize(JSON.stringify({ __v: 1 })); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error).toContain('data'); + }); + + it('falla con versión desconocida', () => { + const result = deserialize(JSON.stringify({ __v: 999, data: {} })); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error).toContain('999'); + }); + + it('falla y devuelve ValidationError si data es Metadata inválido', () => { + const invalid = JSON.stringify({ + __v: 1, + data: { price: { type: 'currency', value: { amount: 100, currency: 'EU' } } }, + }); + const result = deserialize(invalid); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.validation).toBeDefined(); + expect(result.validation?.errors.length).toBeGreaterThan(0); + } + }); + + it('falla si un campo tiene type desconocido', () => { + const invalid = JSON.stringify({ + __v: 1, + data: { x: { type: 'unknown', value: 'test' } }, + }); + const result = deserialize(invalid); + expect(result.ok).toBe(false); + }); +}); + +// ───────────────────────────────────────────── +// Propiedades de roundtrip +// ───────────────────────────────────────────── + +describe('roundtrip serialization → deserialize', () => { + it('es idempotente: serializar dos veces produce el mismo resultado', () => { + const meta: Metadata = { x: field.number(1) }; + expect(serialization(meta)).toBe(serialization(meta)); + }); + + it('deserializar dos veces el mismo JSON produce el mismo Metadata', () => { + const json = serialization({ x: field.string('a') }); + const r1 = deserialize(json); + const r2 = deserialize(json); + expect(r1).toEqual(r2); + }); +}); diff --git a/src/mtas/tests/validation.test.ts b/src/mtas/tests/validation.test.ts new file mode 100644 index 0000000..7bd14a7 --- /dev/null +++ b/src/mtas/tests/validation.test.ts @@ -0,0 +1,150 @@ +import { describe, it, expect } from 'vitest'; +import { validateField, validateMetadata } from '../lib'; +import { field } from '../lib'; +import type { Metadata } from '../lib'; + +// ───────────────────────────────────────────── +// validateField — edge cases +// ───────────────────────────────────────────── + +describe('validateField — key edge cases', () => { + it('falla con key de solo espacios', () => { + expect(validateField(' ', field.string('x')).valid).toBe(false); + }); + + it('acepta key con caracteres especiales', () => { + expect(validateField('my-field_1', field.string('x'))).toEqual({ valid: true }); + }); + + it('falla si el valor no tiene type', () => { + expect(validateField('x', { value: 'hello' } as unknown as never).valid).toBe(false); + }); + + it('falla si el valor es null', () => { + expect(validateField('x', null).valid).toBe(false); + }); + + it('falla si el valor es un array', () => { + expect(validateField('x', []).valid).toBe(false); + }); + + it('falla si el valor es un primitivo', () => { + expect(validateField('x', 42).valid).toBe(false); + expect(validateField('x', 'string').valid).toBe(false); + }); +}); + +describe('validateField — number edge cases', () => { + it('acepta 0', () => expect(validateField('n', field.number(0))).toEqual({ valid: true })); + it('acepta negativos', () => expect(validateField('n', field.number(-99))).toEqual({ valid: true })); + it('acepta Infinity', () => expect(validateField('n', field.number(Infinity))).toEqual({ valid: true })); + it('falla con NaN', () => expect(validateField('n', field.number(NaN)).valid).toBe(false)); +}); + +describe('validateField — date edge cases', () => { + it('falla con fecha inválida que cumple el formato', () => { + // ISO_DATE_RE solo comprueba formato, no si la fecha existe + expect(validateField('d', field.date('9999-99-99')).valid).toBe(true); // formato ok + }); + + it('falla con datetime en campo date', () => { + expect(validateField('d', field.date('2024-01-15T10:00:00Z')).valid).toBe(false); + }); +}); + +describe('validateField — url edge cases', () => { + it('acepta URL con path y query', () => expect(validateField('u', field.url('https://example.com/path?q=1'))).toEqual({ valid: true })); + it('acepta URL con puerto', () => expect(validateField('u', field.url('http://localhost:3000'))).toEqual({ valid: true })); + it('falla con URL relativa', () => expect(validateField('u', field.url('/relative/path')).valid).toBe(false)); + it('falla con string vacío', () => expect(validateField('u', field.url('')).valid).toBe(false)); +}); + +describe('validateField — currency edge cases', () => { + it('acepta amount 0', () => expect(validateField('p', field.currency({ amount: 0, currency: 'EUR' }))).toEqual({ valid: true })); + it('acepta amount negativo', () => expect(validateField('p', field.currency({ amount: -10, currency: 'EUR' }))).toEqual({ valid: true })); + it('falla con currency vacío', () => expect(validateField('p', field.currency({ amount: 10, currency: '' })).valid).toBe(false)); + it('falla con currency 2 letras', () => expect(validateField('p', field.currency({ amount: 10, currency: 'EU' })).valid).toBe(false)); + it('falla con currency 4 letras', () => expect(validateField('p', field.currency({ amount: 10, currency: 'EURO' })).valid).toBe(false)); +}); + +describe('validateField — media edge cases', () => { + it('acepta MediaRef con campos opcionales', () => { + expect(validateField('img', field.image({ + url: 'https://cdn.example.com/img.jpg', + mimeType: 'image/jpeg', + size: 1024, + filename: 'img.jpg', + }))).toEqual({ valid: true }); + }); + + it('falla con url relativa en MediaRef', () => { + expect(validateField('img', field.image({ url: '/images/photo.jpg' })).valid).toBe(false); + }); +}); + +describe('validateField — group edge cases', () => { + it('acepta grupo vacío', () => expect(validateField('g', field.group({}))).toEqual({ valid: true })); + + it('acumula errores de múltiples campos internos', () => { + const g = field.group({ + hp: field.number(NaN), + price: field.currency({ amount: 10, currency: 'EU' }), + }); + const result = validateField('specs', g); + expect(result.valid).toBe(false); + if (!result.valid) expect(result.errors.length).toBeGreaterThanOrEqual(2); + }); + + it('los errores anidados incluyen el prefijo de la clave padre', () => { + const g = field.group({ hp: field.number(NaN) }); + const result = validateField('specs', g); + expect(result.valid).toBe(false); + if (!result.valid) expect(result.errors[0]).toContain('specs'); + }); + + it('soporta triple anidamiento', () => { + const deep = field.group({ + l1: field.group({ + l2: field.group({ + value: field.boolean(false), + }), + }), + }); + expect(validateField('root', deep)).toEqual({ valid: true }); + }); +}); + +// ───────────────────────────────────────────── +// validateMetadata — edge cases +// ───────────────────────────────────────────── + +describe('validateMetadata — edge cases', () => { + it('acepta metadata vacía', () => expect(validateMetadata({})).toEqual({ valid: true })); + + it('falla si metadata es null', () => expect(validateMetadata(null as unknown as Metadata).valid).toBe(false)); + + it('falla si metadata es un array', () => expect(validateMetadata([] as unknown as Metadata).valid).toBe(false)); + + it('falla si un valor no es MetadataField', () => { + const meta: Metadata = { x: 'not a field' }; + expect(validateMetadata(meta).valid).toBe(false); + }); + + it('acumula todos los errores sin parar en el primero', () => { + const meta: Metadata = { + a: field.number(NaN), + b: field.currency({ amount: 10, currency: 'EU' }), + c: field.url('bad-url'), + }; + const result = validateMetadata(meta); + expect(result.valid).toBe(false); + if (!result.valid) expect(result.errors.length).toBeGreaterThanOrEqual(3); + }); + + it('los mensajes de error identifican la clave afectada', () => { + const meta: Metadata = { myKey: field.number(NaN) }; + const result = validateMetadata(meta); + expect(result.valid).toBe(false); + if (!result.valid) expect(result.errors[0]).toContain('myKey'); + }); +}); diff --git a/src/tsat/index.ts b/src/tsat/lib/index.ts similarity index 100% rename from src/tsat/index.ts rename to src/tsat/lib/index.ts diff --git a/src/tsat/types/dates.ts b/src/tsat/lib/types/dates.ts similarity index 100% rename from src/tsat/types/dates.ts rename to src/tsat/lib/types/dates.ts diff --git a/src/tsat/types/ids.ts b/src/tsat/lib/types/ids.ts similarity index 100% rename from src/tsat/types/ids.ts rename to src/tsat/lib/types/ids.ts diff --git a/src/tsat/types/index.ts b/src/tsat/lib/types/index.ts similarity index 100% rename from src/tsat/types/index.ts rename to src/tsat/lib/types/index.ts diff --git a/src/vcen/editor/App.svelte b/src/vcen-edit/App.svelte similarity index 92% rename from src/vcen/editor/App.svelte rename to src/vcen-edit/App.svelte index f2f0f71..45e4dde 100644 --- a/src/vcen/editor/App.svelte +++ b/src/vcen-edit/App.svelte @@ -1,10 +1,10 @@