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

7 months ago
import type { ISODate, ISOTime, ISODatetime } from "@/tsat/types";
import type {
Att,
AttValue,
AttMultiple,
AttStringDomain,
AttNumberDomain,
AttCurrencyDomain,
AttBooleanDomain,
AttOptionDomain,
7 months ago
OptID, DomainValidationResult, DomainViolation, AttOpt,
7 months ago
} 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' });
}
7 months ago
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 });
}
7 months ago
}
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)) {
7 months ago
return err({ code: 'VALUE_OUT_OF_RANGE', value: v, min: domain.min, max: domain.max });
7 months ago
}
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);
7 months ago
7 months ago
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');
7 months ago
default: {
const _exhaustiveCheck: never = domain;
throw new Error(`Unhandled domain kind: ${(_exhaustiveCheck as any).kind}`);
}
7 months ago
}
}
/**
* 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);
7 months ago
}

Powered by TurnKey Linux.