import type { ISODate, ISOTime, ISODatetime } from "@/tsat/types"; import type { Att, AttValue, AttMultiple, AttStringDomain, AttNumberDomain, AttCurrencyDomain, AttBooleanDomain, AttOptionDomain, OptID, DomainValidationResult, DomainViolation, AttOpt, } from "./types"; // ───────────────────────────────────────────────────────────────────────────── // HELPERS // ───────────────────────────────────────────────────────────────────────────── const ok: DomainValidationResult = { valid: true }; function err(reason: DomainViolation): DomainValidationResult { return { valid: false, level: 'error', reason }; } /** * Valida la cardinalidad de un valor contra la configuración `multiple`. * Si no hay `multiple`, el valor debe ser escalar (no array). */ function validateMultiple( value: unknown, multiple: AttMultiple | undefined, ): DomainValidationResult | null { if (multiple === undefined) { if (Array.isArray(value)) { return err({ code: 'SINGLE_NOT_SCALAR', value }); } return null; } if (!Array.isArray(value)) { return err({ code: 'MULTIPLE_NOT_ARRAY', value }); } if (value.length < multiple.min) { return err({ code: 'MULTIPLE_TOO_FEW', count: value.length, min: multiple.min }); } if (value.length > multiple.max) { return err({ code: 'MULTIPLE_TOO_MANY', count: value.length, max: multiple.max }); } return null; } function toArray(value: unknown): unknown[] { return Array.isArray(value) ? value : [value]; } // ───────────────────────────────────────────────────────────────────────────── // VALIDATORS POR KIND // ───────────────────────────────────────────────────────────────────────────── function validateString( domain: AttStringDomain, value: unknown, ): DomainValidationResult { const cardinalityResult = validateMultiple( value, domain.mode !== 'fixed' ? domain.multiple : undefined, ); if (cardinalityResult) return cardinalityResult; for (const v of toArray(value)) { if (typeof v !== 'string') { return err({ code: 'WRONG_TYPE', value: v, expected: 'string' }); } if (domain.mode === 'list' && !domain.list.includes(v)) { return err({ code: 'VALUE_NOT_IN_LIST', value: v, list: domain.list }); } if (domain.mode === 'pattern' && !new RegExp(domain.pattern).test(v)) { return err({ code: 'VALUE_PATTERN_MISMATCH', value: v, pattern: domain.pattern }); } if (domain.mode === 'fixed' && v !== domain.fixed) { return err({ code: 'VALUE_NOT_IN_LIST', value: v, list: [domain.fixed] }); } } return ok; } function validateNumber( domain: AttNumberDomain | AttCurrencyDomain, value: unknown, ): DomainValidationResult { const cardinalityResult = validateMultiple( value, domain.mode !== 'fixed' ? domain.multiple : undefined, ); if (cardinalityResult) return cardinalityResult; for (const v of toArray(value)) { if (typeof v !== 'number' || isNaN(v)) { return err({ code: 'WRONG_TYPE', value: v, expected: 'number' }); } if (domain.mode === 'range' && (v < domain.min || v > domain.max)) { return err({ code: 'VALUE_OUT_OF_RANGE', value: v, min: domain.min, max: domain.max }); } if (domain.mode === 'step') { if (v < domain.min || v > domain.max) { return err({ code: 'VALUE_OUT_OF_RANGE', value: v, min: domain.min, max: domain.max }); } // Tolerancia flotante para evitar errores de precisión const steps = (v - domain.min) / domain.step; const rounded = Math.round(steps); if (Math.abs(steps - rounded) > 1e-9) { return err({ code: 'VALUE_STEP_MISMATCH', value: v, min: domain.min, step: domain.step }); } } if (domain.mode === 'list' && !domain.list.includes(v)) { return err({ code: 'VALUE_NOT_IN_LIST', value: v, list: domain.list }); } if (domain.mode === 'fixed' && v !== domain.fixed) { return err({ code: 'VALUE_NOT_IN_LIST', value: v, list: [domain.fixed] }); } } return ok; } function validateBoolean( domain: AttBooleanDomain, value: unknown, ): DomainValidationResult { if (typeof value !== 'boolean') { return err({ code: 'WRONG_TYPE', value, expected: 'boolean' }); } if (domain.mode === 'fixed' && value !== domain.fixed) { return err({ code: 'VALUE_NOT_IN_LIST', value, list: [domain.fixed] }); } return ok; } function validateOption( domain: AttOptionDomain, value: unknown, ): DomainValidationResult { const cardinalityResult = validateMultiple( value, domain.mode !== 'fixed' ? domain.multiple : undefined, ); if (cardinalityResult) return cardinalityResult; for (const v of toArray(value)) { if (typeof v !== 'string') { return err({ code: 'WRONG_TYPE', value: v, expected: 'OptID' }); } if (domain.mode === 'list') { const optIds = domain.list.map(o => o.optId); if (!optIds.includes(v as OptID)) { return err({ code: 'VALUE_NOT_IN_LIST', value: v, list: optIds }); } } if (domain.mode === 'fixed' && v !== domain.fixed) { return err({ code: 'VALUE_NOT_IN_LIST', value: v, list: [domain.fixed] }); } } return ok; } /** * Valida un string con formato ISO (date, time, datetime). * La validación de formato es estructural — no semántica (ej. no valida * que el 31 de febrero no existe). */ function validateISOString( domain: { mode: string; multiple?: AttMultiple } & ( | { mode: 'range'; min: string; max: string } | { mode: 'list'; list: string[] } | { mode: 'fixed'; fixed: string } | { mode: 'input' } ), value: unknown, expected: string, ): DomainValidationResult { const cardinalityResult = validateMultiple( value, domain.mode !== 'fixed' ? domain.multiple : undefined, ); if (cardinalityResult) return cardinalityResult; for (const v of toArray(value)) { if (typeof v !== 'string') { return err({ code: 'WRONG_TYPE', value: v, expected }); } if (domain.mode === 'range' && (v < domain.min || v > domain.max)) { return err({ code: 'VALUE_OUT_OF_RANGE', value: v, min: domain.min, max: domain.max }); } if (domain.mode === 'list' && !(domain.list as string[]).includes(v)) { return err({ code: 'VALUE_NOT_IN_LIST', value: v, list: domain.list }); } if (domain.mode === 'fixed' && v !== domain.fixed) { return err({ code: 'VALUE_NOT_IN_LIST', value: v, list: [domain.fixed] }); } } return ok; } // ───────────────────────────────────────────────────────────────────────────── // ENTRY POINT // ───────────────────────────────────────────────────────────────────────────── /** * Valida un `AttValue` contra su dominio declarado (capa 0). * * Esta función es: * - **Pura** — sin efectos secundarios * - **Sin contexto** — no depende del estado del configurador * - **Determinista** — mismo input, mismo output siempre * * Solo valida que el valor sea estructuralmente coherente con el dominio. * La validación dinámica (reglas, dominio efectivo) es responsabilidad * del motor de evaluación (capa 1). * * @example * validateAttValue({ * domain: { kind: 'string', mode: 'list', list: ['tela', 'piel'] }, * value: 'madera' * }) * // → { valid: false, level: 'error', reason: { code: 'VALUE_NOT_IN_LIST', ... } } */ export function validateAttValue(attValue: AttValue): DomainValidationResult { const { domain, value } = attValue; switch (domain.kind) { case 'string': return validateString(domain, value as unknown); case 'number': case 'currency': return validateNumber(domain, value as unknown); case 'boolean': return validateBoolean(domain, value as unknown); case 'option': return validateOption(domain, value as unknown); case 'date': return validateISOString(domain, value as unknown, 'ISODate'); case 'time': return validateISOString(domain, value as unknown, 'ISOTime'); case 'datetime': return validateISOString(domain, value as unknown, 'ISODatetime'); default: { const _exhaustiveCheck: never = domain; throw new Error(`Unhandled domain kind: ${(_exhaustiveCheck as any).kind}`); } } } /** * Valida todos los atributos de un `Att` de forma directa. * Wrapper de conveniencia sobre `validateAttValue`. */ export function validateAtt(att: Att): DomainValidationResult { return validateAttValue(att.value); }