You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
293 lines
9.8 KiB
293 lines
9.8 KiB
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);
|
|
}
|