From 85a72b7f12909a9bb34494b82bebe1edf71ada87 Mon Sep 17 00:00:00 2001 From: dev Date: Tue, 24 Feb 2026 22:26:46 +0100 Subject: [PATCH] First Commit --- src/libs/vice/consts/messages.ts | 12 +- .../{evaluator.engine.ts => evaluator.ts} | 0 src/libs/vice/engines/pricing.ts | 254 ++++++ .../vice/engines/{rule.engine.ts => rule.ts} | 6 +- src/libs/vice/engines/selection-state.ts | 275 ++++++ .../vice/engines/template-resolver.engine.ts | 290 ------- src/libs/vice/engines/template-resolver.ts | 446 ++++++++++ .../vice/tests/housing-catalog.fixture.ts | 798 ++++++++++++++++++ src/libs/vice/tests/pricing-dynamic.test.ts | 797 +++++++++++++++++ src/libs/vice/tests/pricing.test.ts | 272 ++++++ src/libs/vice/tests/selection-state.test.ts | 304 +++++++ src/libs/vice/tests/template-resolver.test.ts | 346 ++++++++ src/libs/vice/types/{api.types.ts => api.ts} | 18 +- .../{attribute.types.ts => attribute.ts} | 8 +- .../types/{catalog.types.ts => catalog.ts} | 10 +- .../vice/types/{datas.types.ts => datas.ts} | 0 .../types/{hotspot.types.ts => hotspot.ts} | 4 +- src/libs/vice/types/{ids.types.ts => ids.ts} | 2 +- src/libs/vice/types/index.ts | 28 +- .../{json-logic.types.ts => json-logic.ts} | 0 .../vice/types/{object.types.ts => object.ts} | 10 +- .../vice/types/{option.types.ts => option.ts} | 8 +- .../types/{pricing.types.ts => pricing.ts} | 2 +- .../{primitives.types.ts => primitives.ts} | 0 .../vice/types/{rule.types.ts => rule.ts} | 4 +- .../types/{section.types.ts => section.ts} | 14 +- .../vice/types/{view.types.ts => view.ts} | 100 ++- 27 files changed, 3639 insertions(+), 369 deletions(-) rename src/libs/vice/engines/{evaluator.engine.ts => evaluator.ts} (100%) create mode 100644 src/libs/vice/engines/pricing.ts rename src/libs/vice/engines/{rule.engine.ts => rule.ts} (98%) create mode 100644 src/libs/vice/engines/selection-state.ts delete mode 100644 src/libs/vice/engines/template-resolver.engine.ts create mode 100644 src/libs/vice/engines/template-resolver.ts create mode 100644 src/libs/vice/tests/housing-catalog.fixture.ts create mode 100644 src/libs/vice/tests/pricing-dynamic.test.ts create mode 100644 src/libs/vice/tests/pricing.test.ts create mode 100644 src/libs/vice/tests/selection-state.test.ts create mode 100644 src/libs/vice/tests/template-resolver.test.ts rename src/libs/vice/types/{api.types.ts => api.ts} (59%) rename src/libs/vice/types/{attribute.types.ts => attribute.ts} (94%) rename src/libs/vice/types/{catalog.types.ts => catalog.ts} (87%) rename src/libs/vice/types/{datas.types.ts => datas.ts} (100%) rename src/libs/vice/types/{hotspot.types.ts => hotspot.ts} (94%) rename src/libs/vice/types/{ids.types.ts => ids.ts} (95%) rename src/libs/vice/types/{json-logic.types.ts => json-logic.ts} (100%) rename src/libs/vice/types/{object.types.ts => object.ts} (87%) rename src/libs/vice/types/{option.types.ts => option.ts} (84%) rename src/libs/vice/types/{pricing.types.ts => pricing.ts} (97%) rename src/libs/vice/types/{primitives.types.ts => primitives.ts} (100%) rename src/libs/vice/types/{rule.types.ts => rule.ts} (97%) rename src/libs/vice/types/{section.types.ts => section.ts} (90%) rename src/libs/vice/types/{view.types.ts => view.ts} (56%) diff --git a/src/libs/vice/consts/messages.ts b/src/libs/vice/consts/messages.ts index 932b5b1..3fff5b1 100644 --- a/src/libs/vice/consts/messages.ts +++ b/src/libs/vice/consts/messages.ts @@ -104,11 +104,14 @@ export const TEMPLATE_RESOLVER_WARNINGS = { export const PRICING_ERRORS = { - OPTION_NOT_FOUND: (optionId: OptionID): string => + OBJECT_NOT_FOUND: (objectId: string): string => + `Object "${objectId}" not found in catalog — cannot calculate price`, + + OPTION_NOT_FOUND: (optionId: string): string => `Option "${optionId}" not found in catalog — cannot calculate price`, - EXPRESSION_FAILED: (optionId: OptionID, error: string): string => - `Error evaluating price expression for option "${optionId}": ${error}`, + EXPRESSION_FAILED: (attrId: string, error: string): string => + `Error evaluating price expression for attribute "${attrId}": ${error}`, } as const; @@ -118,6 +121,9 @@ export const PRICING_ERRORS = { export const SELECTION_ERRORS = { + OBJECT_NOT_FOUND: (objectId: string): string => + `Object "${objectId}" not found in catalog`, + ATTRIBUTE_NOT_FOUND: (attrId: AttrID): string => `Attribute "${attrId}" not found`, diff --git a/src/libs/vice/engines/evaluator.engine.ts b/src/libs/vice/engines/evaluator.ts similarity index 100% rename from src/libs/vice/engines/evaluator.engine.ts rename to src/libs/vice/engines/evaluator.ts diff --git a/src/libs/vice/engines/pricing.ts b/src/libs/vice/engines/pricing.ts new file mode 100644 index 0000000..2f18095 --- /dev/null +++ b/src/libs/vice/engines/pricing.ts @@ -0,0 +1,254 @@ +/** + * ============================================================================ + * PRICING ENGINE + * ============================================================================ + * + * Calcula el precio total de una configuración sumando el precio + * de cada opción seleccionada con affectsPrice: true. + * + * Dos tipos de precio por opción: + * - Fijo → Pricing.baseAmount + * - Variable → Pricing.dynamicExpression evaluada con { quantity, ...state } + * + * El tax se aplica al total, no por opción. + */ + +import type { + ConfigurationCatalog, + ConfigurableObject, + Attribute, + QuantifiableAttribute, + TaxInfo, ObjectID, +} from '../types'; +import type { Logr } from '@/libs/logr'; +import type { SelectionMap } from './rule'; +import { JsonLogicEvaluator } from './evaluator'; +import { ENGINE_CATEGORIES, PRICING_ERRORS } from '../consts/messages'; + + + +// ============================================================================ +// TYPES +// ============================================================================ + +/** + * Precio desglosado de una opción individual. + */ +export interface OptionPricingResult { + attrId : string; + optionId: string; + amount : number; + isDynamic: boolean; +} + +/** + * Resultado de precio total de la configuración. + */ +export interface PricingResult { + /** Suma de todas las opciones antes de impuestos */ + subtotal : number; + /** Importe del impuesto — 0 si tax.included o no hay tax */ + taxAmount : number; + /** subtotal + taxAmount */ + total : number; + /** Desglose por opción */ + breakdown : OptionPricingResult[]; + /** Moneda — del primer atributo con pricing.currency o undefined */ + currency? : string; + /** Info fiscal aplicada */ + tax? : TaxInfo; + /** Si alguna opción tiene baseAmount 'consultation' */ + hasConsultation : boolean; +} + +// ============================================================================ +// PRICING ENGINE +// ============================================================================ + +export class PricingEngine { + private readonly logr : Logr; + private readonly evaluator: JsonLogicEvaluator; + + constructor(logr: Logr) { + this.logr = logr; + this.evaluator = new JsonLogicEvaluator(logr); + } + + /** + * Calcula el precio total de la configuración actual. + * + * Proceso: + * 1. Recorre todos los atributos del objeto (globales + secciones) + * con affectsPrice: true + * 2. Para cada atributo obtiene la opción seleccionada del estado + * 3. Calcula el precio de esa opción (fijo o dinámico) + * 4. Suma todos los precios + * 5. Aplica el tax al total + */ + calculate( + objectId: ObjectID, + state : SelectionMap, + catalog : ConfigurationCatalog, + tax? : TaxInfo, + ): PricingResult { + const object = catalog.objects[objectId as keyof typeof catalog.objects]; + + if (!object) { + this.logr.error( + ENGINE_CATEGORIES.PRICING, + PRICING_ERRORS.OBJECT_NOT_FOUND(objectId) + ); + return this.emptyResult(tax); + } + + const breakdown : OptionPricingResult[] = []; + let subtotal = 0; + let hasConsultation = false; + let currency : string | undefined; + + // Recopilar todos los atributos — globales + de cada sección + const allAttrs = this.collectAttrs(object); + + for (const attr of allAttrs) { + if (!attr.display.affectsPrice) continue; + + const selectedValue = state[attr.id]; + if (!selectedValue) continue; + + const optionId = String(selectedValue); + const optionDef = catalog.options[optionId as keyof typeof catalog.options]; + + if (!optionDef) { + this.logr.error( + ENGINE_CATEGORIES.PRICING, + PRICING_ERRORS.OPTION_NOT_FOUND(optionId), + { attrId: attr.id, optionId } + ); + continue; + } + + const { pricing } = optionDef; + if (!pricing) continue; + + // Capturar currency del primer atributo que la tenga + if (!currency && pricing.currency) currency = pricing.currency; + + if (pricing.baseAmount === 'consultation') { + hasConsultation = true; + continue; + } + + const amount = this.resolveAmount(attr, pricing.baseAmount, pricing.dynamicExpression, state); + subtotal += amount; + + if (amount !== 0) { + breakdown.push({ + attrId : attr.id, + optionId, + amount, + isDynamic: !!pricing.dynamicExpression, + }); + } + } + + return this.buildResult(subtotal, breakdown, currency, tax, hasConsultation); + } + + // ------------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------------- + + /** + * Resuelve el importe de una opción. + * Si tiene dynamicExpression la evalúa con quantity + state. + * Si no, devuelve baseAmount directamente. + */ + private resolveAmount( + attr : Attribute, + baseAmount : number, + dynamicExpression: unknown, + state : SelectionMap, + ): number { + if (!dynamicExpression) return baseAmount; + + try { + const quantity = this.isQuantifiable(attr) ? attr.quantity : undefined; + const data = { + quantity, + baseAmount, + attributes: this.normalizeStateKeys(state), + }; + + const result = this.evaluator.evaluate(dynamicExpression as any, data); + return typeof result === 'number' ? result : baseAmount; + + } catch (error) { + this.logr.error( + ENGINE_CATEGORIES.PRICING, + PRICING_ERRORS.EXPRESSION_FAILED(attr.id, String(error)), + { attrId: attr.id } + ); + return baseAmount; + } + } + + /** + * Recoge todos los atributos del objeto — globales + de cada sección. + * Deduplica por attrId — el mismo atributo puede estar en varias secciones + * (ej: at:suelo en salón, dormitorio y cocina) pero solo se suma una vez. + */ + private collectAttrs(object: ConfigurableObject): Attribute[] { + const seen = new Set(); + const attrs : Attribute[] = []; + + for (const attr of [ + ...object.attributes, + ...Object.values(object.sections).flatMap(s => s.attributes ?? []) + ]) { + if (!seen.has(attr.id)) { + seen.add(attr.id); + attrs.push(attr); + } + } + + return attrs; + } + + private isQuantifiable(attr: Attribute): attr is QuantifiableAttribute { + return attr.type === 'quantifiable'; + } + + private normalizeStateKeys(state: SelectionMap): Record { + const normalized: Record = {}; + for (const [key, value] of Object.entries(state)) { + normalized[key.replace(':', '_')] = value; + } + return normalized; + } + + private buildResult( + subtotal : number, + breakdown : OptionPricingResult[], + currency? : string, + tax? : TaxInfo, + hasConsultation = false, + ): PricingResult { + const taxAmount = tax && !tax.included + ? Math.round(subtotal * tax.rate * 100) / 100 + : 0; + + return { + subtotal, + taxAmount, + total: Math.round((subtotal + taxAmount) * 100) / 100, + breakdown, + currency, + tax, + hasConsultation, + }; + } + + private emptyResult(tax?: TaxInfo): PricingResult { + return this.buildResult(0, [], undefined, tax); + } +} \ No newline at end of file diff --git a/src/libs/vice/engines/rule.engine.ts b/src/libs/vice/engines/rule.ts similarity index 98% rename from src/libs/vice/engines/rule.engine.ts rename to src/libs/vice/engines/rule.ts index 8d4e895..2e4a3f0 100644 --- a/src/libs/vice/engines/rule.engine.ts +++ b/src/libs/vice/engines/rule.ts @@ -17,7 +17,7 @@ import type { Value, Severity } from '../types'; -import { JsonLogicEvaluator } from './evaluator.engine'; +import { JsonLogicEvaluator } from './evaluator'; import { ENGINE_CATEGORIES, RULE_ENGINE_ERRORS } from '../consts/messages'; @@ -67,7 +67,7 @@ export interface RuleViolation { // RULE ENGINE // ============================================================================ -export class RuleEngine { +export class Rule { private readonly evaluator: JsonLogicEvaluator; private readonly logr : Logr; @@ -295,4 +295,4 @@ function severityOrder(severity: Severity): number { // ============================================================================ // El singleton se crea en el wiring del engine, no aquí. -// Ejemplo: export const ruleEngine = new RuleEngine(logr); \ No newline at end of file +// Ejemplo: export const ruleEngine = new Rule(logr); \ No newline at end of file diff --git a/src/libs/vice/engines/selection-state.ts b/src/libs/vice/engines/selection-state.ts new file mode 100644 index 0000000..7e3124f --- /dev/null +++ b/src/libs/vice/engines/selection-state.ts @@ -0,0 +1,275 @@ +/** + * ============================================================================ + * SELECTION STATE + * ============================================================================ + * + * Gestiona el estado de selección del usuario. + * El estado siempre es válido — modelo preventivo. + * + * Las opciones no disponibles se calculan en tiempo real via RuleEngine. + * El usuario nunca puede seleccionar un valor prohibido. + */ + +import type { + ConfigurationCatalog, + ConfigurableObject, + Attribute, + DynamicAttribute, + OptionDefinition, + ObjectID, +} from '../types'; +import type { AttrID, OptionID } from '../types'; +import type { Value } from '../types'; +import type { Logr } from '@/libs/logr'; +import { Rule } from './rule.ts'; +import type { SelectionMap } from './rule.ts'; +import { ENGINE_CATEGORIES, SELECTION_ERRORS } from '../consts/messages.ts'; + +// ============================================================================ +// TYPES +// ============================================================================ + +/** + * Opción de un atributo con su disponibilidad calculada. + */ +export interface AvailableOption { + optionId : OptionID; + option : OptionDefinition; + available : boolean; + /** Razón por la que no está disponible — mensaje de la regla que la prohíbe */ + reason? : string; +} + +/** + * Estado calculado de un atributo — valor actual + opciones disponibles. + */ +export interface AttributeState { + attr : Attribute; + value : Value; + options : AvailableOption[]; // solo para DynamicAttribute + required: boolean; +} + +/** + * Resultado de intentar seleccionar un valor. + */ +export type SelectionResult = + | { success: true } + | { success: false; reason: string }; + +// ============================================================================ +// SELECTION STATE +// ============================================================================ + +export class SelectionState { + private readonly logr : Logr; + private readonly ruleEngine : Rule; + private readonly catalog : ConfigurationCatalog; + private readonly object : ConfigurableObject; + private selection : SelectionMap; + + constructor( + objectId: ObjectID, + catalog : ConfigurationCatalog, + logr : Logr, + ) { + this.logr = logr; + this.ruleEngine = new Rule(logr); + this.catalog = catalog; + this.selection = {}; + + const object = catalog.objects[objectId]; + if (!object) { + this.logr.error( + ENGINE_CATEGORIES.SELECTION, + SELECTION_ERRORS.OBJECT_NOT_FOUND(objectId), + { objectId } + ); + throw new Error(`Object "${objectId}" not found in catalog`); + } + + this.object = object; + this.selection = this.buildInitialSelection(); + } + + // ------------------------------------------------------------------------- + // API pública + // ------------------------------------------------------------------------- + + /** + * Selecciona un valor para un atributo. + * Solo acepta valores que el RuleEngine permite dado el estado actual. + * Modelo preventivo — el estado siempre es válido tras la selección. + */ + select(attrId: AttrID, value: Value): SelectionResult { + const rules = Object.values(this.catalog.rules ?? {}); + + if (!this.ruleEngine.isValueAllowed(attrId, value, rules, this.selection)) { + const reason = this.getProhibitedReason(attrId, value); + this.logr.warn( + ENGINE_CATEGORIES.SELECTION, + SELECTION_ERRORS.INVALID_OPTION(attrId, String(value) as OptionID), + { attrId, value } + ); + return { success: false, reason }; + } + + this.selection = { ...this.selection, [attrId]: value }; + return { success: true }; + } + + /** + * Devuelve el valor actual de un atributo. + */ + getValue(attrId: AttrID): Value { + return this.selection[attrId]; + } + + /** + * Devuelve una copia inmutable del estado de selección actual. + */ + getSelection(): SelectionMap { + return { ...this.selection }; + } + + /** + * Devuelve el estado calculado de un atributo: + * valor actual + opciones con su disponibilidad. + */ + getAttributeState(attrId: AttrID): AttributeState | null { + const attr = this.findAttr(attrId); + if (!attr) return null; + + const rules = Object.values(this.catalog.rules ?? {}); + const options = attr.type === 'dynamic' + ? this.buildAvailableOptions(attr as DynamicAttribute, rules) + : []; + + return { + attr, + value : this.selection[attrId], + options, + required: this.ruleEngine.isRequired(attrId, rules, this.selection), + }; + } + + /** + * Devuelve el estado calculado de todos los atributos del objeto. + */ + getAllAttributeStates(): Map { + const result = new Map(); + + for (const attr of this.collectAttrs()) { + const state = this.getAttributeState(attr.id); + if (state) result.set(attr.id, state); + } + + return result; + } + + /** + * Verifica si el estado actual es completamente válido. + */ + isValid(): boolean { + const rules = Object.values(this.catalog.rules ?? {}); + return this.ruleEngine.isValid(rules, this.selection); + } + + // ------------------------------------------------------------------------- + // Inicialización + // ------------------------------------------------------------------------- + + /** + * Construye el estado inicial a partir de los defaultValues de los atributos. + * Garantiza que el estado inicial es válido. + */ + private buildInitialSelection(): SelectionMap { + const selection: SelectionMap = {}; + + for (const attr of this.collectAttrs()) { + if (attr.type === 'dynamic' || attr.type === 'quantifiable') { + const dynamic = attr as DynamicAttribute; + if (dynamic.defaultValue !== undefined) { + selection[attr.id] = dynamic.defaultValue; + } + } + } + + return selection; + } + + // ------------------------------------------------------------------------- + // Opciones disponibles + // ------------------------------------------------------------------------- + + /** + * Calcula la disponibilidad de cada opción de un atributo dinámico. + * Las opciones no disponibles incluyen el mensaje de la regla que las prohíbe. + */ + private buildAvailableOptions( + attr : DynamicAttribute, + rules: any[] + ): AvailableOption[] { + return attr.options.map(({ optionId }) => { + const option = this.catalog.options[optionId]; + const available = this.ruleEngine.isValueAllowed(attr.id, optionId, rules, this.selection); + const reason = available ? undefined : this.getProhibitedReason(attr.id, optionId); + + return { optionId, option, available, reason }; + }); + } + + /** + * Obtiene el mensaje de la regla que prohíbe un valor. + * Usado como sugerencia en el tooltip de opciones deshabilitadas. + */ + private getProhibitedReason(attrId: AttrID, value: Value): string { + const rules = Object.values(this.catalog.rules ?? {}); + const results = this.ruleEngine.getAttributeResults(rules, this.selection); + const attrResult = results.get(attrId); + + if (!attrResult) return ''; + + // Buscar regla activa que afecte este atributo con este valor + const triggeredRule = rules.find(rule => { + if (rule.action.targetAttr !== attrId) return false; + if (rule.action.type === 'forbid' && rule.action.values.includes(value)) return true; + return rule.action.type === 'allow' && !rule.action.values.includes(value); + + }); + + return triggeredRule?.message + ? (typeof triggeredRule.message === 'object' + ? Object.values(triggeredRule.message)[0] as string + : String(triggeredRule.message)) + : ''; + } + + // ------------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------------- + + private findAttr(attrId: AttrID): Attribute | undefined { + return this.collectAttrs().find(a => a.id === attrId); + } + + /** + * Recoge todos los atributos del objeto deduplicados por id. + */ + private collectAttrs(): Attribute[] { + const seen = new Set(); + const attrs : Attribute[] = []; + + for (const attr of [ + ...this.object.attributes, + ...Object.values(this.object.sections).flatMap(s => s.attributes ?? []) + ]) { + if (!seen.has(attr.id)) { + seen.add(attr.id); + attrs.push(attr); + } + } + + return attrs; + } +} \ No newline at end of file diff --git a/src/libs/vice/engines/template-resolver.engine.ts b/src/libs/vice/engines/template-resolver.engine.ts deleted file mode 100644 index 68fc539..0000000 --- a/src/libs/vice/engines/template-resolver.engine.ts +++ /dev/null @@ -1,290 +0,0 @@ -/** - * ============================================================================ - * TEMPLATE RESOLVER - * ============================================================================ - * - * Resuelve templates de imagen a URLs completas combinando: - * - basePath heredado en cascada (vista → sección → objeto → catálogo) - * - template con placeholders {at:} resueltos al code de la opción activa - */ - -import type { Logr } from '@/libs/logr'; -import { - type ConfigurationCatalog, - type ConfigurableObject, - type VisualSection, - type SectionView, - type StaticImageConfig, - type ObjectID, - type SectionID, - type ViewID, isOptionID, type OptionID -} from '../types'; -import { - ENGINE_CATEGORIES, - TEMPLATE_RESOLVER_ERRORS, - TEMPLATE_RESOLVER_WARNINGS -} from '../consts/messages'; -import type { SelectionMap } from './rule.engine'; - -// ============================================================================ -// TYPES -// ============================================================================ - -/** - * Contexto necesario para resolver un template. - * Identifica el objeto, sección y vista activos. - */ -export interface ResolveContext { - objectId : ObjectID; - sectionId: SectionID; - viewId : ViewID; -} - -/** - * Resultado de la resolución de un template. - */ -export interface ResolveResult { - /** URL completa resuelta — null si se usó fallback */ - url : string; - /** Si se usó el fallbackImage en lugar de la URL generada */ - isFallback : boolean; -} - -// ============================================================================ -// TEMPLATE RESOLVER -// ============================================================================ - -/** Regex para extraer placeholders del tipo {at:code} */ -const PLACEHOLDER_RE = /\{at:([^}]+)\}/g; - -export class TemplateResolver { - private readonly logr: Logr; - - constructor(logr: Logr) { - this.logr = logr; - } - - /** - * Resuelve un template de imagen a una URL completa. - * - * Proceso: - * 1. Resuelve el basePath en cascada (vista → sección → objeto → catálogo) - * 2. Extrae los placeholders {at:} del template - * 3. Resuelve cada placeholder al code de la opción activa en el catálogo - * 4. Concatena basePath + template resuelto - * - * Si algún placeholder no se puede resolver, usa fallbackImage. - */ - resolve( - context : ResolveContext, - state : SelectionMap, - catalog : ConfigurationCatalog - ): ResolveResult { - const object = catalog.objects[context.objectId]; - const section = object?.sections[context.sectionId]; - - if (!object || !section) { - this.logr.error(ENGINE_CATEGORIES.TEMPLATE_RESOLVER, TEMPLATE_RESOLVER_ERRORS.OBJECT_NOT_FOUND(context.objectId), { context }); - return this.fallback(catalog, object, section as VisualSection | undefined); - } - - if (section.kind !== 'visual') { - this.logr.error(ENGINE_CATEGORIES.TEMPLATE_RESOLVER, TEMPLATE_RESOLVER_ERRORS.SECTION_NOT_VISUAL(context.sectionId), { context }); - return this.fallback(catalog, object, undefined); - } - - const view = section.views[context.viewId]; - - if (!view) { - this.logr.error(ENGINE_CATEGORIES.TEMPLATE_RESOLVER, TEMPLATE_RESOLVER_ERRORS.VIEW_NOT_FOUND(context.viewId, context.sectionId), { context }); - return this.fallback(catalog, object, section); - } - - if (view.visualConfig.strategy !== 'static_image') { - this.logr.warn(ENGINE_CATEGORIES.TEMPLATE_RESOLVER, TEMPLATE_RESOLVER_WARNINGS.UNSUPPORTED_STRATEGY(view.visualConfig.strategy), { context }); - return this.fallback(catalog, object, section); - } - - return this.resolveStaticImage( - view.visualConfig, - view, - section, - object, - catalog, - state, - context - ); - } - - // ------------------------------------------------------------------------- - // Resolución static_image - // ------------------------------------------------------------------------- - - private resolveStaticImage( - config : StaticImageConfig, - view : SectionView, - section : VisualSection, - object : ConfigurableObject, - catalog : ConfigurationCatalog, - state : SelectionMap, - context : ResolveContext - ): ResolveResult { - const basePath = this.resolveBasePath(config, view, section, object, catalog); - const resolved = this.resolveTemplate(config.template, state, catalog, context); - - if (resolved === null) { - this.logr.error( - ENGINE_CATEGORIES.TEMPLATE_RESOLVER, - TEMPLATE_RESOLVER_ERRORS.TEMPLATE_UNRESOLVABLE(config.template), - { context } - ); - return this.fallback(catalog, object, section); - } - - return { url: `${basePath}${resolved}`, isFallback: false }; - } - - // ------------------------------------------------------------------------- - // Resolución de template - // ------------------------------------------------------------------------- - - /** - * Sustituye cada {at:} por el code de la opción activa. - * Devuelve null si algún placeholder no se puede resolver. - */ - private resolveTemplate( - template: string, - state : SelectionMap, - catalog : ConfigurationCatalog, - context : ResolveContext - ): string | null { - let result = template; - let success = true; - - result = result.replace(PLACEHOLDER_RE, (_, attrCode: string) => { - const resolved = this.resolvePlaceholder(attrCode, state, catalog, context); - - if (resolved === null) { - success = false; - return ''; - } - - return resolved; - }); - - return success ? result : null; - } - - /** - * Resuelve un placeholder {at:} al code de la opción seleccionada. - * - * Busca el atributo por code en: - * 1. Atributos de la sección - * 2. Atributos globales del objeto - * - * Luego resuelve el OptionID seleccionado al code de la OptionDefinition. - */ - private resolvePlaceholder( - attrCode: string, - state : SelectionMap, - catalog : ConfigurationCatalog, - context : ResolveContext - ): string | null { - const object = catalog.objects[context.objectId]; - const section = object?.sections[context.sectionId]; - - // Buscar atributo por code — primero en sección, luego en objeto - const sectionAttrs = section?.kind === 'visual' ? section.attributes : []; - const allAttrs = [...sectionAttrs, ...object.attributes]; - const attr = allAttrs.find(a => a.code === attrCode); - - if (!attr) { - this.logr.error( - ENGINE_CATEGORIES.TEMPLATE_RESOLVER, - TEMPLATE_RESOLVER_ERRORS.ATTRIBUTE_NOT_FOUND(attrCode), - { context, attrCode } - ); - return null; - } - - // Obtener el valor seleccionado del estado - const selectedValue = state[attr.id]; - - if (!selectedValue) { - this.logr.error( - ENGINE_CATEGORIES.TEMPLATE_RESOLVER, - TEMPLATE_RESOLVER_ERRORS.ATTRIBUTE_NO_VALUE(attr.id), - { context, attrId: attr.id } - ); - return null; - } - - // Resolver OptionID → code de la OptionDefinition - if (!isOptionID(selectedValue)) { - this.logr.error( - ENGINE_CATEGORIES.TEMPLATE_RESOLVER, - TEMPLATE_RESOLVER_ERRORS.OPTION_NOT_FOUND(String(selectedValue)), - { context, optionId: String(selectedValue) } - ); - return null; - } - const optionDef = catalog.options[selectedValue]; - - return optionDef.code; - } - - // ------------------------------------------------------------------------- - // basePath en cascada - // ------------------------------------------------------------------------- - - /** - * Resuelve el basePath en cascada: - * vista → sección → objeto → catálogo - */ - private resolveBasePath( - config : StaticImageConfig, - view : SectionView, - section : VisualSection, - object : ConfigurableObject, - catalog : ConfigurationCatalog - ): string { - return config.basePath - ?? section.basePath - ?? object.basePath - ?? catalog.basePath - ?? ''; - } - - // ------------------------------------------------------------------------- - // Fallback - // ------------------------------------------------------------------------- - - /** - * Devuelve la imagen de fallback. - * Busca en cascada entre las views de la sección, y si no hay ninguna - * configurada logea un warning y devuelve string vacío. - */ - private fallback( - catalog : ConfigurationCatalog, - object? : ConfigurableObject, - section?: VisualSection, - ): ResolveResult { - // Busca fallbackImage en cualquiera de las vistas de la sección - const viewFallback = section - ? Object.values(section.views) - .map(v => (v.visualConfig as StaticImageConfig).fallbackImage) - .find(Boolean) - : undefined; - - const fallbackImage = viewFallback ?? ''; - - if (!fallbackImage) { - this.logr.warn(ENGINE_CATEGORIES.TEMPLATE_RESOLVER, TEMPLATE_RESOLVER_WARNINGS.NO_FALLBACK_IMAGE(object?.id, section?.id), { - objectId : object?.id, - sectionId: section?.id, - }); - } - - return { url: fallbackImage, isFallback: true }; - } -} \ No newline at end of file diff --git a/src/libs/vice/engines/template-resolver.ts b/src/libs/vice/engines/template-resolver.ts new file mode 100644 index 0000000..601c4ec --- /dev/null +++ b/src/libs/vice/engines/template-resolver.ts @@ -0,0 +1,446 @@ +/** + * ============================================================================ + * TEMPLATE RESOLVER + * ============================================================================ + * + * Resuelve templates de imagen a URLs completas combinando: + * - basePath heredado en cascada (vista → sección → objeto → catálogo) + * - template con placeholders {at:} resueltos al code de la opción activa + */ + +import type { Logr } from '@/libs/logr'; +import type { + ConfigurationCatalog, + ConfigurableObject, + VisualSection, + SectionView, + StaticImageConfig, + ObjectID, + SectionID, + ViewID, + CompositeLayersConfig, + ApiGeneratedConfig, +} from '../types'; +import { isOptionID } from '../types'; + +import { + ENGINE_CATEGORIES, + TEMPLATE_RESOLVER_ERRORS, + TEMPLATE_RESOLVER_WARNINGS +} from '../consts/messages'; +import type { SelectionMap } from './rule.ts'; + + +// ============================================================================ +// TYPES +// ============================================================================ + +/** + * Contexto necesario para resolver un template. + */ +export interface ResolveContext { + objectId : ObjectID; + sectionId: SectionID; + viewId : ViewID; +} + +/** + * Resultado de la resolución — URL o parámetros API. + */ +export interface ResolveResult { + /** URL completa resuelta, o string vacío si no hay fallback */ + url : string; + /** Si se usó el fallbackImage en lugar de la URL generada */ + isFallback: boolean; +} + +/** + * Resultado de resolución para api_generated. + * Los parámetros ya están resueltos — listos para enviar al servidor. + */ +export interface ApiResolveResult { + endpoint : string; + method : 'GET' | 'POST'; + params : Record; + isFallback: boolean; + fallbackImage?: string; +} + +// ============================================================================ +// HELPERS INTERNOS +// ============================================================================ + +/** Regex para placeholders de contexto: {object}, {section}, {view} */ +const CONTEXT_PLACEHOLDER_RE = /\{(object|section|view)}/g; + +/** Regex para placeholders de atributo: {at:} */ +const ATTR_PLACEHOLDER_RE = /\{at:([^}]+)}/g; + +// ============================================================================ +// TEMPLATE RESOLVER +// ============================================================================ + +export class TemplateResolver { + private readonly logr: Logr; + + constructor(logr: Logr) { + this.logr = logr; + } + + // ------------------------------------------------------------------------- + // Entrada principal — despacha por estrategia + // ------------------------------------------------------------------------- + + /** + * Resuelve la vista activa según su estrategia de renderizado. + * Despacha a `resolveStaticImage`, `resolveCompositeLayers` o `resolveApi`. + */ + resolve( + context : ResolveContext, + state : SelectionMap, + catalog : ConfigurationCatalog + ): ResolveResult { + const { object, section, view } = this.resolveEntities(context, catalog); + if (!object || !section || !view) return this.fallback(object, section); + + switch (view.visualConfig.strategy) { + case 'static_image': + return this.resolveStaticImage(view.visualConfig, view, section, object, catalog, state, context); + + case 'composite_layers': + return this.resolveCompositeLayers(view.visualConfig, view, section, object, catalog, state, context); + + case 'api_generated': + // api_generated devuelve ResolveResult con la URL del fallback + // El caller usa resolveApi() si necesita los params completos + return { + url : view.visualConfig.fallbackImage ?? '', + isFallback: true, + }; + + case 'three_d': + // three_d no genera URLs — lo gestiona el componente 3D directamente + return { url: '', isFallback: false }; + + default: + this.logr.warn( + ENGINE_CATEGORIES.TEMPLATE_RESOLVER, + TEMPLATE_RESOLVER_WARNINGS.UNSUPPORTED_STRATEGY((view.visualConfig as any).strategy), + { context } + ); + return this.fallback(object, section); + } + } + + /** + * Resuelve los parámetros para una vista api_generated. + * Usar este método cuando se necesita construir la llamada HTTP. + */ + resolveApi( + context : ResolveContext, + state : SelectionMap, + catalog : ConfigurationCatalog + ): ApiResolveResult | null { + const { object, section, view } = this.resolveEntities(context, catalog); + if (!object || !section || !view) return null; + + if (view.visualConfig.strategy !== 'api_generated') { + this.logr.warn( + ENGINE_CATEGORIES.TEMPLATE_RESOLVER, + TEMPLATE_RESOLVER_WARNINGS.UNSUPPORTED_STRATEGY(view.visualConfig.strategy), + { context } + ); + return null; + } + + return this.buildApiResult(view.visualConfig, view, section, object, catalog, state, context); + } + + // ------------------------------------------------------------------------- + // static_image + // ------------------------------------------------------------------------- + + private resolveStaticImage( + config : StaticImageConfig, + view : SectionView, + section : VisualSection, + object : ConfigurableObject, + catalog : ConfigurationCatalog, + state : SelectionMap, + context : ResolveContext + ): ResolveResult { + const basePath = this.resolveBasePath(config.basePath, section, object, catalog); + const resolved = this.resolveTemplate(config.template, view, section, object, state, catalog, context); + + if (resolved === null) { + this.logr.error( + ENGINE_CATEGORIES.TEMPLATE_RESOLVER, + TEMPLATE_RESOLVER_ERRORS.TEMPLATE_UNRESOLVABLE(config.template), + { context } + ); + return this.fallback(object, section); + } + + return { url: `${basePath}${resolved}`, isFallback: false }; + } + + // ------------------------------------------------------------------------- + // composite_layers + // ------------------------------------------------------------------------- + + private resolveCompositeLayers( + config : CompositeLayersConfig, + view : SectionView, + section : VisualSection, + object : ConfigurableObject, + catalog : ConfigurationCatalog, + state : SelectionMap, + context : ResolveContext + ): ResolveResult { + const basePath = this.resolveBasePath(config.basePath, section, object, catalog); + const urls: string[] = []; + + for (const layer of config.layers) { + const resolved = this.resolveTemplate(layer.template, view, section, object, state, catalog, context); + + if (resolved === null) { + if (!layer.optional) { + this.logr.error( + ENGINE_CATEGORIES.TEMPLATE_RESOLVER, + TEMPLATE_RESOLVER_ERRORS.TEMPLATE_UNRESOLVABLE(layer.template), + { context } + ); + return this.fallback(object, section); + } + // Capa opcional sin resolver — se omite + continue; + } + + urls.push(`${basePath}${resolved}`); + } + + // Devolvemos las URLs de capas separadas por '|' como convención + // El componente UI las parsea y las superpone en orden + return { url: urls.join('|'), isFallback: false }; + } + + // ------------------------------------------------------------------------- + // api_generated + // ------------------------------------------------------------------------- + + private buildApiResult( + config : ApiGeneratedConfig, + view : SectionView, + section : VisualSection, + object : ConfigurableObject, + catalog : ConfigurationCatalog, + state : SelectionMap, + context : ResolveContext + ): ApiResolveResult { + const paramKeys = config.params ?? this.buildDefaultParams(section, object); + const params: Record = {}; + + for (const key of paramKeys) { + if (key === 'object') { params['object'] = object.code; continue; } + if (key === 'section') { params['section'] = section.code; continue; } + if (key === 'view') { params['view'] = view.code; continue; } + + // {at:} → resolver atributo + const match = key.match(/^at:(.+)$/); + if (match) { + const resolved = this.resolvePlaceholder(match[1], state, catalog, context); + if (resolved !== null) params[match[1]] = resolved; + continue; + } + + this.logr.warn( + ENGINE_CATEGORIES.TEMPLATE_RESOLVER, + TEMPLATE_RESOLVER_WARNINGS.UNSUPPORTED_STRATEGY(key), + { context } + ); + } + + return { + endpoint : config.apiConfig.endpoint, + method : config.apiConfig.method ?? 'POST', + params, + isFallback : false, + fallbackImage: config.fallbackImage, + }; + } + + /** + * Si no se especifican params, incluye contexto + todos los atributos visualizables. + */ + private buildDefaultParams( + section: VisualSection, + object : ConfigurableObject + ): string[] { + const attrParams = [...section.attributes, ...object.attributes] + .filter(a => a.display.affectsVisual === true) + .map(a => `at:${a.code}`); + + return ['object', 'section', 'view', ...attrParams]; + } + + // ------------------------------------------------------------------------- + // Resolución de template — compartida por static_image y composite_layers + // ------------------------------------------------------------------------- + + private resolveTemplate( + template: string, + view : SectionView, + section : VisualSection, + object : ConfigurableObject, + state : SelectionMap, + catalog : ConfigurationCatalog, + context : ResolveContext + ): string | null { + let result = template; + let success = true; + + // 1. Placeholders de contexto — siempre disponibles + result = result.replace(CONTEXT_PLACEHOLDER_RE, (_, key: string) => { + switch (key) { + case 'object' : return object.code; + case 'section': return section.code; + case 'view' : return view.code; + default : return ''; + } + }); + + // 2. Placeholders de atributos {at:} + result = result.replace(ATTR_PLACEHOLDER_RE, (_, attrCode: string) => { + const resolved = this.resolvePlaceholder(attrCode, state, catalog, context); + if (resolved === null) { success = false; return ''; } + return resolved; + }); + + return success ? result : null; + } + + /** + * Resuelve {at:} al code de la opción seleccionada. + * Busca el atributo por code: primero en sección, luego en objeto global. + */ + private resolvePlaceholder( + attrCode: string, + state : SelectionMap, + catalog : ConfigurationCatalog, + context : ResolveContext + ): string | null { + const object = catalog.objects[context.objectId]; + const section = object?.sections[context.sectionId]; + + const sectionAttrs = section?.kind === 'visual' ? section.attributes : []; + const attr = [...sectionAttrs, ...object.attributes].find(a => a.code === attrCode); + + if (!attr) { + this.logr.error( + ENGINE_CATEGORIES.TEMPLATE_RESOLVER, + TEMPLATE_RESOLVER_ERRORS.ATTRIBUTE_NOT_FOUND(attrCode), + { context, attrCode } + ); + return null; + } + + const selectedValue = state[attr.id]; + + if (!selectedValue) { + this.logr.error( + ENGINE_CATEGORIES.TEMPLATE_RESOLVER, + TEMPLATE_RESOLVER_ERRORS.ATTRIBUTE_NO_VALUE(attr.id), + { context, attrId: attr.id } + ); + return null; + } + + if (!isOptionID(selectedValue)) { + this.logr.error( + ENGINE_CATEGORIES.TEMPLATE_RESOLVER, + TEMPLATE_RESOLVER_ERRORS.OPTION_NOT_FOUND(String(selectedValue)), + { context, optionId: String(selectedValue) } + ); + return null; + } + + return catalog.options[selectedValue].code; + } + + // ------------------------------------------------------------------------- + // basePath en cascada + // ------------------------------------------------------------------------- + + private resolveBasePath( + configBasePath: string | undefined, + section : VisualSection, + object : ConfigurableObject, + catalog : ConfigurationCatalog + ): string { + return configBasePath + ?? section.basePath + ?? object.basePath + ?? catalog.basePath + ?? ''; + } + + // ------------------------------------------------------------------------- + // Entidades — lookup compartido + // ------------------------------------------------------------------------- + + private resolveEntities( + context: ResolveContext, + catalog: ConfigurationCatalog + ): { object?: ConfigurableObject; section?: VisualSection; view?: SectionView } { + const object = catalog.objects[context.objectId]; + if (!object) { + this.logr.error(ENGINE_CATEGORIES.TEMPLATE_RESOLVER, TEMPLATE_RESOLVER_ERRORS.OBJECT_NOT_FOUND(context.objectId), { context }); + return {}; + } + + const section = object.sections[context.sectionId]; + if (!section) { + this.logr.error(ENGINE_CATEGORIES.TEMPLATE_RESOLVER, TEMPLATE_RESOLVER_ERRORS.SECTION_NOT_FOUND(context.sectionId, context.objectId), { context }); + return { object }; + } + + if (section.kind !== 'visual') { + this.logr.error(ENGINE_CATEGORIES.TEMPLATE_RESOLVER, TEMPLATE_RESOLVER_ERRORS.SECTION_NOT_VISUAL(context.sectionId), { context }); + return { object }; + } + + const view = section.views[context.viewId]; + if (!view) { + this.logr.error(ENGINE_CATEGORIES.TEMPLATE_RESOLVER, TEMPLATE_RESOLVER_ERRORS.VIEW_NOT_FOUND(context.viewId, context.sectionId), { context }); + return { object, section }; + } + + return { object, section, view }; + } + + // ------------------------------------------------------------------------- + // Fallback + // ------------------------------------------------------------------------- + + private fallback( + object? : ConfigurableObject, + section?: VisualSection, + ): ResolveResult { + const viewFallback = section + ? Object.values(section.views) + .map(v => (v.visualConfig as StaticImageConfig).fallbackImage) + .find(Boolean) + : undefined; + + const fallbackImage = viewFallback ?? ''; + + if (!fallbackImage) { + this.logr.warn( + ENGINE_CATEGORIES.TEMPLATE_RESOLVER, + TEMPLATE_RESOLVER_WARNINGS.NO_FALLBACK_IMAGE(object?.id, section?.id) + ); + } + + return { url: fallbackImage, isFallback: true }; + } +} \ No newline at end of file diff --git a/src/libs/vice/tests/housing-catalog.fixture.ts b/src/libs/vice/tests/housing-catalog.fixture.ts new file mode 100644 index 0000000..cb4891c --- /dev/null +++ b/src/libs/vice/tests/housing-catalog.fixture.ts @@ -0,0 +1,798 @@ +/** + * ============================================================================ + * CATÁLOGO DE VIVIENDAS - FIXTURE ACTUALIZADO + * ============================================================================ + * + * Modelo A: Apartamento (salón, baño, dormitorio, cocina) + * Modelo B: Dúplex (salón, baño, aseo, 2 dormitorios) + */ + +import type { + ConfigurationCatalog, + ConfigurableObject, + OptionDefinition, + ValidationRule, + VisualSection, + DynamicAttribute, + FixedAttribute, + SectionView, QuantifiableAttribute, +} from '../types'; + +import type { AttrID, SectionID, OptionID, ObjectID, RuleID, ViewID } from '../types'; + + + +// ============================================================================ +// IDs — OPCIONES +// ============================================================================ + +const OPT_SUELO_PARQUET : OptionID = 'op:suelo_parquet'; +const OPT_SUELO_PORCELANICO : OptionID = 'op:suelo_porcelanico'; +const OPT_SUELO_MICROCEMENTO : OptionID = 'op:suelo_microcemento'; +const OPT_SUELO_CERAMICA : OptionID = 'op:suelo_ceramica'; + +const OPT_PARED_BLANCO : OptionID = 'op:pared_blanco'; +const OPT_PARED_GRIS : OptionID = 'op:pared_gris'; +const OPT_PARED_BEIGE : OptionID = 'op:pared_beige'; +const OPT_PARED_PIEDRA : OptionID = 'op:pared_piedra'; + +const OPT_PUERTA_ROBLE : OptionID = 'op:puerta_roble'; +const OPT_PUERTA_LACADA : OptionID = 'op:puerta_lacada'; +const OPT_PUERTA_CRISTAL : OptionID = 'op:puerta_cristal'; + +const OPT_ENCIMERA_GRANITO : OptionID = 'op:encimera_granito'; +const OPT_ENCIMERA_SILESTONE : OptionID = 'op:encimera_silestone'; +const OPT_ENCIMERA_MADERA : OptionID = 'op:encimera_madera'; + +const OPT_MUEBLE_BLANCO : OptionID = 'op:mueble_blanco'; +const OPT_MUEBLE_GRIS : OptionID = 'op:mueble_gris'; +const OPT_MUEBLE_MADERA : OptionID = 'op:mueble_madera'; + +const OPT_SANIT_BLANCO : OptionID = 'op:sanit_blanco'; +const OPT_SANIT_NEGRO : OptionID = 'op:sanit_negro'; + +const OPT_REVEST_MARMOL : OptionID = 'op:revest_marmol'; +const OPT_REVEST_MICROCEMENTO: OptionID = 'op:revest_microcemento'; +const OPT_REVEST_CERAMICA : OptionID = 'op:revest_ceramica'; + +const OPT_CALIDAD_ESTANDAR : OptionID = 'op:calidad_estandar'; + +// Armario — material puertas +const OPT_ARMARIO_LACADO : OptionID = 'op:armario_lacado'; +const OPT_ARMARIO_MADERA : OptionID = 'op:armario_madera'; +const OPT_CALIDAD_PREMIUM : OptionID = 'op:calidad_premium'; +const OPT_CALIDAD_LUJO : OptionID = 'op:calidad_lujo'; + +// ============================================================================ +// IDs — ATRIBUTOS +// ============================================================================ + +const ATTR_CALIDAD : AttrID = 'at:calidad'; +const ATTR_PUERTA : AttrID = 'at:puerta'; +const ATTR_SUELO : AttrID = 'at:suelo'; +const ATTR_PARED : AttrID = 'at:pared'; +const ATTR_ENCIMERA : AttrID = 'at:encimera'; +const ATTR_MUEBLE_COC : AttrID = 'at:mueble_cocina'; +const ATTR_M2_COCINA : AttrID = 'at:m2_cocina'; +const ATTR_SANITARIO : AttrID = 'at:sanitario'; +const ATTR_REVEST_BANO: AttrID = 'at:revest_bano'; +const ATTR_M2_BANO : AttrID = 'at:m2_bano'; +const ATTR_M2_DORM : AttrID = 'at:m2_dormitorio'; +const ATTR_M2_SALON : AttrID = 'at:m2_salon'; + +// Armario +const ATTR_ARMARIO_PUERTAS : AttrID = 'at:armario_puertas'; +const ATTR_ARMARIO_MATERIAL : AttrID = 'at:armario_material'; + +// ============================================================================ +// IDs — SECCIONES +// ============================================================================ + +const SEC_SALON : SectionID = 'sc:salon'; +const SEC_BANO : SectionID = 'sc:bano'; +const SEC_ASEO : SectionID = 'sc:aseo'; +const SEC_DORMITORIO1: SectionID = 'sc:dormitorio1'; +const SEC_DORMITORIO2: SectionID = 'sc:dormitorio2'; +const SEC_COCINA : SectionID = 'sc:cocina'; +const SEC_ARMARIO : SectionID = 'sc:armario'; + +// ============================================================================ +// IDs — VISTAS +// ============================================================================ + +const VIEW_FRONT: ViewID = 'vw:front'; +const VIEW_360 : ViewID = 'vw:360'; + +// ============================================================================ +// IDs — OBJETOS +// ============================================================================ + +const OBJ_APARTAMENTO: ObjectID = 'ob:apartamento'; +const OBJ_DUPLEX : ObjectID = 'ob:duplex'; + +// ============================================================================ +// IDs — REGLAS +// ============================================================================ + +const RULE_LUJO_MARMOL : RuleID = 'rl:lujo_requiere_marmol'; +const RULE_ESTANDAR_NO_LUJO : RuleID = 'rl:estandar_no_lujo'; +const RULE_MICRO_CONSISTENTE: RuleID = 'rl:microcemento_consistente'; +const RULE_MADERA_NO_BANO : RuleID = 'rl:madera_no_bano'; +const RULE_NEGRO_PREMIUM : RuleID = 'rl:negro_requiere_premium'; +const RULE_ESTANDAR_NO_NEGRO: RuleID = 'rl:estandar_no_negro'; + +// ============================================================================ +// OPCIONES +// ============================================================================ + +export const OPTIONS: Record = { + [OPT_SUELO_PARQUET]: { + id: OPT_SUELO_PARQUET, code: 'parquet', + name: { es: 'Parquet', en: 'Parquet' }, + description: { es: 'Suelo de madera natural', en: 'Natural wood floor' }, + pricing: { baseAmount: 45 }, tags: ['madera', 'calido'] + }, + [OPT_SUELO_PORCELANICO]: { + id: OPT_SUELO_PORCELANICO, code: 'porcelanico', + name: { es: 'Porcelánico', en: 'Porcelain tile' }, + description: { es: 'Suelo porcelánico de gran formato', en: 'Large format porcelain tile' }, + pricing: { baseAmount: 55 }, tags: ['ceramica', 'moderno'] + }, + [OPT_SUELO_MICROCEMENTO]: { + id: OPT_SUELO_MICROCEMENTO, code: 'microcemento', + name: { es: 'Microcemento', en: 'Microcement' }, + description: { es: 'Microcemento continuo', en: 'Continuous microcement' }, + pricing: { baseAmount: 75 }, tags: ['moderno', 'premium'] + }, + [OPT_SUELO_CERAMICA]: { + id: OPT_SUELO_CERAMICA, code: 'ceramica', + name: { es: 'Cerámica', en: 'Ceramic tile' }, + description: { es: 'Cerámica estándar', en: 'Standard ceramic tile' }, + pricing: { baseAmount: 30 }, tags: ['ceramica', 'estandar'] + }, + [OPT_PARED_BLANCO]: { + id: OPT_PARED_BLANCO, code: 'blanco', + name: { es: 'Blanco roto', en: 'Off white' }, + description: { es: 'Pintura blanca rota', en: 'Off white paint' }, + pricing: { baseAmount: 0 } + }, + [OPT_PARED_GRIS]: { + id: OPT_PARED_GRIS, code: 'gris', + name: { es: 'Gris perla', en: 'Pearl grey' }, + description: { es: 'Pintura gris perla', en: 'Pearl grey paint' }, + pricing: { baseAmount: 0 } + }, + [OPT_PARED_BEIGE]: { + id: OPT_PARED_BEIGE, code: 'beige', + name: { es: 'Beige', en: 'Beige' }, + description: { es: 'Pintura beige', en: 'Beige paint' }, + pricing: { baseAmount: 0 } + }, + [OPT_PARED_PIEDRA]: { + id: OPT_PARED_PIEDRA, code: 'piedra', + name: { es: 'Efecto piedra', en: 'Stone effect' }, + description: { es: 'Pintura efecto piedra', en: 'Stone effect paint' }, + pricing: { baseAmount: 800 } + }, + [OPT_PUERTA_ROBLE]: { + id: OPT_PUERTA_ROBLE, code: 'roble', + name: { es: 'Roble natural', en: 'Natural oak' }, + description: { es: 'Puerta en madera de roble', en: 'Oak wood door' }, + pricing: { baseAmount: 350 } + }, + [OPT_PUERTA_LACADA]: { + id: OPT_PUERTA_LACADA, code: 'lacada', + name: { es: 'Lacada blanca', en: 'White lacquered' }, + description: { es: 'Puerta lacada en blanco', en: 'White lacquered door' }, + pricing: { baseAmount: 250 } + }, + [OPT_PUERTA_CRISTAL]: { + id: OPT_PUERTA_CRISTAL, code: 'cristal', + name: { es: 'Cristal', en: 'Glass' }, + description: { es: 'Puerta de cristal templado', en: 'Tempered glass door' }, + pricing: { baseAmount: 500 } + }, + [OPT_ENCIMERA_GRANITO]: { + id: OPT_ENCIMERA_GRANITO, code: 'granito', + name: { es: 'Granito', en: 'Granite' }, + description: { es: 'Encimera de granito natural', en: 'Natural granite countertop' }, + pricing: { baseAmount: 400 } + }, + [OPT_ENCIMERA_SILESTONE]: { + id: OPT_ENCIMERA_SILESTONE, code: 'silestone', + name: { es: 'Silestone', en: 'Silestone' }, + description: { es: 'Encimera de Silestone', en: 'Silestone countertop' }, + pricing: { baseAmount: 600 } + }, + [OPT_ENCIMERA_MADERA]: { + id: OPT_ENCIMERA_MADERA, code: 'madera', + name: { es: 'Madera', en: 'Wood' }, + description: { es: 'Encimera de madera maciza', en: 'Solid wood countertop' }, + pricing: { baseAmount: 500 } + }, + [OPT_MUEBLE_BLANCO]: { + id: OPT_MUEBLE_BLANCO, code: 'blanco', + name: { es: 'Blanco mate', en: 'Matte white' }, + description: { es: 'Muebles lacados en blanco mate', en: 'Matte white lacquered cabinets' }, + pricing: { baseAmount: 3500 } + }, + [OPT_MUEBLE_GRIS]: { + id: OPT_MUEBLE_GRIS, code: 'gris', + name: { es: 'Gris antracita', en: 'Anthracite grey' }, + description: { es: 'Muebles en gris antracita', en: 'Anthracite grey cabinets' }, + pricing: { baseAmount: 4000 } + }, + [OPT_MUEBLE_MADERA]: { + id: OPT_MUEBLE_MADERA, code: 'madera', + name: { es: 'Madera natural', en: 'Natural wood' }, + description: { es: 'Muebles en madera natural', en: 'Natural wood cabinets' }, + pricing: { baseAmount: 5000 } + }, + [OPT_SANIT_BLANCO]: { + id: OPT_SANIT_BLANCO, code: 'blanco', + name: { es: 'Blanco', en: 'White' }, + description: { es: 'Sanitarios blancos', en: 'White sanitary ware' }, + pricing: { baseAmount: 0 } + }, + [OPT_SANIT_NEGRO]: { + id: OPT_SANIT_NEGRO, code: 'negro', + name: { es: 'Negro mate', en: 'Matte black' }, + description: { es: 'Sanitarios en negro mate', en: 'Matte black sanitary ware' }, + pricing: { baseAmount: 1200 } + }, + [OPT_REVEST_MARMOL]: { + id: OPT_REVEST_MARMOL, code: 'marmol', + name: { es: 'Mármol', en: 'Marble' }, + description: { es: 'Revestimiento de mármol natural', en: 'Natural marble cladding' }, + pricing: { baseAmount: 120 } + }, + [OPT_REVEST_MICROCEMENTO]: { + id: OPT_REVEST_MICROCEMENTO, code: 'microcemento', + name: { es: 'Microcemento', en: 'Microcement' }, + description: { es: 'Revestimiento de microcemento', en: 'Microcement cladding' }, + pricing: { baseAmount: 90 } + }, + [OPT_REVEST_CERAMICA]: { + id: OPT_REVEST_CERAMICA, code: 'ceramica', + name: { es: 'Cerámica', en: 'Ceramic tile' }, + description: { es: 'Revestimiento cerámico', en: 'Ceramic tile cladding' }, + pricing: { baseAmount: 40 } + }, + // Armario — material puertas + [OPT_ARMARIO_LACADO]: { + id: OPT_ARMARIO_LACADO, code: 'lacado', + name: { es: 'Lacado blanco', en: 'White lacquered' }, + description: { es: 'Puertas lacadas en blanco mate', en: 'Matte white lacquered doors' }, + pricing: { + baseAmount : 0, + // Precio por puerta con descuento por volumen: + // 2 puertas → 350€/u, 3 puertas → 300€/u + dynamicExpression: { + 'if': [ + { '>=': [{ var: 'quantity' }, 3] }, + { '*': [{ var: 'quantity' }, 300] }, + { '*': [{ var: 'quantity' }, 350] } + ] + } + } + }, + [OPT_ARMARIO_MADERA]: { + id: OPT_ARMARIO_MADERA, code: 'madera', + name: { es: 'Madera natural', en: 'Natural wood' }, + description: { es: 'Puertas en madera natural', en: 'Natural wood doors' }, + pricing: { + baseAmount : 0, + // Precio fijo por puerta sin descuento + dynamicExpression: { '*': [{ var: 'quantity' }, 500] } + } + }, + [OPT_CALIDAD_ESTANDAR]: { + id: OPT_CALIDAD_ESTANDAR, code: 'estandar', + name: { es: 'Estándar', en: 'Standard' }, + description: { es: 'Acabados estándar', en: 'Standard quality finishes' }, + pricing: { baseAmount: 0 } + }, + [OPT_CALIDAD_PREMIUM]: { + id: OPT_CALIDAD_PREMIUM, code: 'premium', + name: { es: 'Premium', en: 'Premium' }, + description: { es: 'Acabados de alta calidad', en: 'High quality finishes' }, + pricing: { baseAmount: 15000 } + }, + [OPT_CALIDAD_LUJO]: { + id: OPT_CALIDAD_LUJO, code: 'lujo', + name: { es: 'Lujo', en: 'Luxury' }, + description: { es: 'Acabados de lujo exclusivos', en: 'Exclusive luxury finishes' }, + pricing: { baseAmount: 35000 } + }, +}; + +// ============================================================================ +// ATRIBUTOS COMUNES +// ============================================================================ + +const attrSuelo = (): DynamicAttribute => ({ + id: ATTR_SUELO, code: 'suelo', + name: { es: 'Suelo', en: 'Flooring' }, + description: { es: 'Material del suelo', en: 'Floor material' }, + type: 'dynamic', dataType: 'reference', + defaultValue: OPT_SUELO_CERAMICA, + options: [ + { optionId: OPT_SUELO_CERAMICA, priority: 1 }, + { optionId: OPT_SUELO_PARQUET, priority: 2 }, + { optionId: OPT_SUELO_PORCELANICO, priority: 3 }, + { optionId: OPT_SUELO_MICROCEMENTO, priority: 4 }, + ], + display: { uiVisible: true, affectsVisual: true, affectsPrice: true } +}); + +const attrPared = (): DynamicAttribute => ({ + id: ATTR_PARED, code: 'pared', + name: { es: 'Color paredes', en: 'Wall color' }, + description: { es: 'Color de la pintura de paredes', en: 'Wall paint color' }, + type: 'dynamic', dataType: 'reference', + defaultValue: OPT_PARED_BLANCO, + options: [ + { optionId: OPT_PARED_BLANCO, priority: 1 }, + { optionId: OPT_PARED_GRIS, priority: 2 }, + { optionId: OPT_PARED_BEIGE, priority: 3 }, + { optionId: OPT_PARED_PIEDRA, priority: 4 }, + ], + display: { uiVisible: true, affectsVisual: true, affectsPrice: true } +}); + +// ============================================================================ +// VISTAS HELPERS +// ============================================================================ + +const viewFront = (template: string, globalAttrDependencies?: AttrID[]): SectionView => ({ + id : VIEW_FRONT, + code : 'front', + name : { es: 'Vista frontal', en: 'Front view' }, + type : 'front', + visualConfig: { + strategy: 'static_image', + template, + globalAttrDependencies, + basePath : '/renders/vivienda/', + fallbackImage: '/renders/fallback.jpg', + }, +}); + +const view360 = (template: string, globalAttrDependencies?: AttrID[]): SectionView => ({ + id : VIEW_360, + code : '360', + name : { es: 'Vista 360°', en: '360° view' }, + type : 'panoramic', + visualConfig: { + strategy: 'static_image', + template, + globalAttrDependencies, + basePath : '/renders/vivienda/', + fallbackImage: '/renders/fallback.jpg', + }, +}); + +// ============================================================================ +// SECCIONES +// ============================================================================ + +const seccionSalon = (): VisualSection => ({ + kind : 'visual', + id : SEC_SALON, + code : 'salon', + name : { es: 'Salón', en: 'Living room' }, + description: { es: 'Salón - comedor principal', en: 'Main living - dining room' }, + attributes : [ + attrSuelo(), + attrPared(), + { + id: ATTR_M2_SALON, code: 'm2_salon', + name: { es: 'Superficie', en: 'Surface' }, + description: { es: 'Superficie del salón en m²', en: 'Living room surface in m²' }, + type: 'fixed', dataType: 'number', value: 25, + unit: { es: 'm²', en: 'sqm' }, + display: { uiVisible: false, readonly: true } + } as FixedAttribute, + { + id: ATTR_PUERTA, code: 'puerta', + name: { es: 'Puerta', en: 'Door' }, + description: { es: 'Tipo de puerta', en: 'Door type' }, + type: 'dynamic', dataType: 'reference', + defaultValue: OPT_PUERTA_LACADA, + options: [ + { optionId: OPT_PUERTA_LACADA, priority: 1 }, + { optionId: OPT_PUERTA_ROBLE, priority: 2 }, + { optionId: OPT_PUERTA_CRISTAL, priority: 3 }, + ], + display: { uiVisible: true, affectsVisual: true, affectsPrice: true } + } as DynamicAttribute, + ], + views : { + [VIEW_FRONT]: viewFront('{object}/{section}/{view}/{at:suelo}_{at:pared}.jpg', [ATTR_CALIDAD]), + [VIEW_360] : view360 ('{object}/{section}/{view}/{at:suelo}_{at:pared}.jpg', [ATTR_CALIDAD]), + }, + defaultView : VIEW_FRONT, + availability: { mode: 'required' }, +}); + +const seccionBano = (m2: number = 6): VisualSection => ({ + kind : 'visual', + id : SEC_BANO, + code : 'bano', + name : { es: 'Baño', en: 'Bathroom' }, + description: { es: 'Baño principal', en: 'Main bathroom' }, + attributes : [ + { + id: ATTR_SANITARIO, code: 'sanitario', + name: { es: 'Sanitarios', en: 'Sanitary ware' }, + description: { es: 'Color de los sanitarios', en: 'Sanitary ware color' }, + type: 'dynamic', dataType: 'reference', + defaultValue: OPT_SANIT_BLANCO, + options: [ + { optionId: OPT_SANIT_BLANCO, priority: 1 }, + { optionId: OPT_SANIT_NEGRO, priority: 2 }, + ], + display: { uiVisible: true, affectsVisual: true, affectsPrice: true } + } as DynamicAttribute, + { + id: ATTR_REVEST_BANO, code: 'revest_bano', + name: { es: 'Revestimiento', en: 'Cladding' }, + description: { es: 'Revestimiento de paredes del baño', en: 'Bathroom wall cladding' }, + type: 'dynamic', dataType: 'reference', + defaultValue: OPT_REVEST_CERAMICA, + options: [ + { optionId: OPT_REVEST_CERAMICA, priority: 1 }, + { optionId: OPT_REVEST_MICROCEMENTO, priority: 2 }, + { optionId: OPT_REVEST_MARMOL, priority: 3 }, + ], + display: { uiVisible: true, affectsVisual: true, affectsPrice: true } + } as DynamicAttribute, + { + id: ATTR_M2_BANO, code: 'm2_bano', + name: { es: 'Superficie', en: 'Surface' }, + description: { es: 'Superficie del baño en m²', en: 'Bathroom surface in m²' }, + type: 'fixed', dataType: 'number', value: m2, + unit: { es: 'm²', en: 'sqm' }, + display: { uiVisible: false, readonly: true } + } as FixedAttribute, + ], + views : { + [VIEW_FRONT]: viewFront('{object}/{section}/{view}/{at:sanitario}_{at:revest_bano}.jpg', [ATTR_CALIDAD]), + }, + defaultView : VIEW_FRONT, + availability: { mode: 'required' }, +}); + +const seccionAseo = (): VisualSection => ({ + kind : 'visual', + id : SEC_ASEO, + code : 'aseo', + name : { es: 'Aseo', en: 'Toilet' }, + description: { es: 'Aseo de cortesía', en: 'Guest toilet' }, + attributes : [ + { + id: ATTR_SANITARIO, code: 'sanitario', + name: { es: 'Sanitarios', en: 'Sanitary ware' }, + description: { es: 'Color de los sanitarios', en: 'Sanitary ware color' }, + type: 'dynamic', dataType: 'reference', + defaultValue: OPT_SANIT_BLANCO, + options: [ + { optionId: OPT_SANIT_BLANCO, priority: 1 }, + { optionId: OPT_SANIT_NEGRO, priority: 2 }, + ], + display: { uiVisible: true, affectsVisual: true, affectsPrice: true } + } as DynamicAttribute, + { + id: ATTR_REVEST_BANO, code: 'revest_bano', + name: { es: 'Revestimiento', en: 'Cladding' }, + description: { es: 'Revestimiento', en: 'Cladding' }, + type: 'dynamic', dataType: 'reference', + defaultValue: OPT_REVEST_CERAMICA, + options: [ + { optionId: OPT_REVEST_CERAMICA, priority: 1 }, + { optionId: OPT_REVEST_MICROCEMENTO, priority: 2 }, + ], + display: { uiVisible: true, affectsVisual: true, affectsPrice: true } + } as DynamicAttribute, + ], + views : { + [VIEW_FRONT]: viewFront('{object}/{section}/{view}/{at:sanitario}_{at:revest_bano}.jpg', [ATTR_CALIDAD]), + }, + defaultView : VIEW_FRONT, + availability: { mode: 'required' }, +}); + +const seccionDormitorio = (id: SectionID, code: string, nombre: string, m2: number): VisualSection => ({ + kind : 'visual', + id, + code, + name : { es: nombre, en: nombre.replace('Dormitorio', 'Bedroom') }, + description: { es: `${nombre}`, en: `${nombre.replace('Dormitorio', 'Bedroom')}` }, + attributes : [ + attrSuelo(), + attrPared(), + { + id: ATTR_M2_DORM, code: 'm2_dormitorio', + name: { es: 'Superficie', en: 'Surface' }, + description: { es: 'Superficie del dormitorio en m²', en: 'Bedroom surface in m²' }, + type: 'fixed', dataType: 'number', value: m2, + unit: { es: 'm²', en: 'sqm' }, + display: { uiVisible: false, readonly: true } + } as FixedAttribute, + ], + views : { + [VIEW_FRONT]: viewFront(`{object}/{section}/{view}/{at:suelo}_{at:pared}.jpg`, [ATTR_CALIDAD]), + }, + defaultView : VIEW_FRONT, + availability: { mode: 'required' }, +}); + +const seccionCocina = (): VisualSection => ({ + kind : 'visual', + id : SEC_COCINA, + code : 'cocina', + name : { es: 'Cocina', en: 'Kitchen' }, + description: { es: 'Cocina equipada', en: 'Equipped kitchen' }, + attributes : [ + { + id: ATTR_ENCIMERA, code: 'encimera', + name: { es: 'Encimera', en: 'Countertop' }, + description: { es: 'Material de la encimera', en: 'Countertop material' }, + type: 'dynamic', dataType: 'reference', + defaultValue: OPT_ENCIMERA_GRANITO, + options: [ + { optionId: OPT_ENCIMERA_GRANITO, priority: 1 }, + { optionId: OPT_ENCIMERA_SILESTONE, priority: 2 }, + { optionId: OPT_ENCIMERA_MADERA, priority: 3 }, + ], + display: { uiVisible: true, affectsVisual: true, affectsPrice: true } + } as DynamicAttribute, + { + id: ATTR_MUEBLE_COC, code: 'mueble_cocina', + name: { es: 'Muebles', en: 'Cabinets' }, + description: { es: 'Muebles de cocina', en: 'Kitchen cabinets' }, + type: 'dynamic', dataType: 'reference', + defaultValue: OPT_MUEBLE_BLANCO, + options: [ + { optionId: OPT_MUEBLE_BLANCO, priority: 1 }, + { optionId: OPT_MUEBLE_GRIS, priority: 2 }, + { optionId: OPT_MUEBLE_MADERA, priority: 3 }, + ], + display: { uiVisible: true, affectsVisual: true, affectsPrice: true } + } as DynamicAttribute, + attrSuelo(), + { + id: ATTR_M2_COCINA, code: 'm2_cocina', + name: { es: 'Superficie', en: 'Surface' }, + description: { es: 'Superficie de la cocina en m²', en: 'Kitchen surface in m²' }, + type: 'fixed', dataType: 'number', value: 10, + unit: { es: 'm²', en: 'sqm' }, + display: { uiVisible: false, readonly: true } + } as FixedAttribute, + ], + views : { + [VIEW_FRONT]: viewFront('{object}/{section}/{view}/{at:encimera}_{at:mueble_cocina}.jpg', [ATTR_CALIDAD]), + }, + defaultView : VIEW_FRONT, + availability: { mode: 'required' }, +}); + +const seccionArmario = (): VisualSection => ({ + kind : 'visual', + id : SEC_ARMARIO, + code : 'armario', + name : { es: 'Armario', en: 'Wardrobe' }, + description: { es: 'Armario empotrado con puertas correderas', en: 'Built-in wardrobe with sliding doors' }, + attributes : [ + { + id : ATTR_ARMARIO_PUERTAS, + code : 'armario_puertas', + name : { es: 'Puertas de armario', en: 'Wardrobe doors' }, + description : { es: 'Número y material de las puertas del armario', en: 'Number and material of wardrobe doors' }, + type : 'quantifiable', + dataType : 'reference', + defaultValue: OPT_ARMARIO_LACADO, + quantity : 2, + minQuantity : 2, + maxQuantity : 3, + unit : { es: 'puertas', en: 'doors' }, + options : [ + { optionId: OPT_ARMARIO_LACADO, priority: 1 }, + { optionId: OPT_ARMARIO_MADERA, priority: 2 }, + ], + display : { uiVisible: true, affectsVisual: true, affectsPrice: true } + } as QuantifiableAttribute, + { + id : ATTR_ARMARIO_MATERIAL, + code : 'armario_material', + name : { es: 'Material interior', en: 'Interior material' }, + description : { es: 'Material del interior del armario', en: 'Wardrobe interior material' }, + type : 'dynamic', + dataType : 'reference', + defaultValue: OPT_ARMARIO_LACADO, + options : [ + { optionId: OPT_ARMARIO_LACADO, priority: 1 }, + { optionId: OPT_ARMARIO_MADERA, priority: 2 }, + ], + display : { uiVisible: true, affectsVisual: false, affectsPrice: false } + } as DynamicAttribute, + ], + views : { + [VIEW_FRONT]: viewFront('{object}/{section}/{view}/{at:armario_puertas}.jpg'), + }, + defaultView : VIEW_FRONT, + availability: { mode: 'optional' }, +}); + +// ============================================================================ +// ATRIBUTO GLOBAL — CALIDAD +// ============================================================================ + +const attrCalidadGlobal: DynamicAttribute = { + id: ATTR_CALIDAD, code: 'calidad', + name: { es: 'Calidad de acabados', en: 'Finish quality' }, + description: { es: 'Nivel de calidad general', en: 'General finish quality level' }, + type: 'dynamic', dataType: 'reference', + defaultValue: OPT_CALIDAD_ESTANDAR, + options: [ + { optionId: OPT_CALIDAD_ESTANDAR, priority: 1 }, + { optionId: OPT_CALIDAD_PREMIUM, priority: 2 }, + { optionId: OPT_CALIDAD_LUJO, priority: 3 }, + ], + controls: [], + display : { uiVisible: true, affectsVisual: true, affectsPrice: true } +}; + +// ============================================================================ +// OBJETOS +// ============================================================================ + +export const APARTAMENTO: ConfigurableObject = { + id : OBJ_APARTAMENTO, + code : 'apartamento', + name : { es: 'Apartamento', en: 'Apartment' }, + description: { es: 'Apartamento de 1 dormitorio (55 m²)', en: '1 bedroom apartment (55 sqm)' }, + attributes : [attrCalidadGlobal], + sections : { + [SEC_SALON] : seccionSalon(), + [SEC_BANO] : seccionBano(6), + [SEC_DORMITORIO1]: seccionDormitorio(SEC_DORMITORIO1, 'dormitorio', 'Dormitorio', 14), + [SEC_COCINA] : seccionCocina(), + [SEC_ARMARIO] : seccionArmario(), + }, + sectionOrder: [SEC_SALON, SEC_COCINA, SEC_DORMITORIO1, SEC_BANO, SEC_ARMARIO], + basePath : '/renders/vivienda/', + category : 'residencial', +}; + +export const DUPLEX: ConfigurableObject = { + id : OBJ_DUPLEX, + code : 'duplex', + name : { es: 'Dúplex', en: 'Duplex' }, + description: { es: 'Dúplex de 2 dormitorios (90 m²)', en: '2 bedroom duplex (90 sqm)' }, + attributes : [attrCalidadGlobal], + sections : { + [SEC_SALON] : seccionSalon(), + [SEC_BANO] : seccionBano(8), + [SEC_ASEO] : seccionAseo(), + [SEC_DORMITORIO1]: seccionDormitorio(SEC_DORMITORIO1, 'dormitorio1', 'Dormitorio principal', 18), + [SEC_DORMITORIO2]: seccionDormitorio(SEC_DORMITORIO2, 'dormitorio2', 'Dormitorio secundario', 12), + }, + sectionOrder: [SEC_SALON, SEC_DORMITORIO1, SEC_DORMITORIO2, SEC_BANO, SEC_ASEO], + basePath : '/renders/vivienda/', + category : 'residencial', +}; + +// ============================================================================ +// REGLAS +// ============================================================================ + +export const RULES: Record = { + [RULE_LUJO_MARMOL]: { + id : RULE_LUJO_MARMOL, + name : { es: 'Lujo requiere mármol', en: 'Luxury requires marble' }, + condition: { '==': [{ var: 'attributes.at_calidad' }, OPT_CALIDAD_LUJO] }, + action : { type: 'require', targetAttr: ATTR_REVEST_BANO, values: [OPT_REVEST_MARMOL] }, + priority : 10, + affects : [ATTR_CALIDAD, ATTR_REVEST_BANO], + severity : 'warning', + message : { es: 'El acabado de lujo recomienda mármol en el baño', en: 'Luxury finish recommends marble in bathroom' } + }, + [RULE_ESTANDAR_NO_LUJO]: { + id : RULE_ESTANDAR_NO_LUJO, + name : { es: 'Estándar sin lujo', en: 'Standard without luxury' }, + condition: { '==': [{ var: 'attributes.at_calidad' }, OPT_CALIDAD_ESTANDAR] }, + action : { type: 'forbid', targetAttr: ATTR_REVEST_BANO, values: [OPT_REVEST_MARMOL] }, + priority : 9, + affects : [ATTR_CALIDAD, ATTR_REVEST_BANO], + severity : 'error', + message : { es: 'El mármol no está disponible en calidad estándar', en: 'Marble is not available in standard quality' } + }, + [RULE_MICRO_CONSISTENTE]: { + id : RULE_MICRO_CONSISTENTE, + name : { es: 'Microcemento consistente', en: 'Consistent microcement' }, + condition: { '==': [{ var: 'attributes.at_suelo' }, OPT_SUELO_MICROCEMENTO] }, + action : { type: 'allow', targetAttr: ATTR_REVEST_BANO, values: [OPT_REVEST_MICROCEMENTO, OPT_REVEST_CERAMICA] }, + priority : 5, + affects : [ATTR_SUELO, ATTR_REVEST_BANO], + severity : 'warning', + message : { es: 'Con microcemento en suelo se recomienda microcemento o cerámica en baño', en: 'With microcement floor, microcement or ceramic is recommended in bathroom' } + }, + [RULE_MADERA_NO_BANO]: { + id : RULE_MADERA_NO_BANO, + name : { es: 'Madera sin negro', en: 'Wood without black' }, + condition: { '==': [{ var: 'attributes.at_encimera' }, OPT_ENCIMERA_MADERA] }, + action : { type: 'forbid', targetAttr: ATTR_SANITARIO, values: [OPT_SANIT_NEGRO] }, + priority : 7, + affects : [ATTR_ENCIMERA, ATTR_SANITARIO], + severity : 'warning', + message : { es: 'La encimera de madera no combina con sanitarios negros', en: 'Wood countertop does not pair well with black sanitary ware' } + }, + [RULE_NEGRO_PREMIUM]: { + id : RULE_NEGRO_PREMIUM, + name : { es: 'Negro requiere premium', en: 'Black requires premium' }, + condition: { '==': [{ var: 'attributes.at_sanitario' }, OPT_SANIT_NEGRO] }, + action : { type: 'forbid', targetAttr: ATTR_CALIDAD, values: [OPT_CALIDAD_ESTANDAR] }, + priority : 8, + affects : [ATTR_SANITARIO, ATTR_CALIDAD], + severity : 'error', + message : { es: 'Los sanitarios negros requieren calidad premium o superior', en: 'Black sanitary ware requires premium quality or higher' } + }, + [RULE_ESTANDAR_NO_NEGRO]: { + id : RULE_ESTANDAR_NO_NEGRO, + name : { es: 'Estándar prohíbe negro', en: 'Standard forbids black' }, + condition: { '==': [{ var: 'attributes.at_calidad' }, OPT_CALIDAD_ESTANDAR] }, + action : { type: 'forbid', targetAttr: ATTR_SANITARIO, values: [OPT_SANIT_NEGRO] }, + priority : 8, + affects : [ATTR_CALIDAD, ATTR_SANITARIO], + severity : 'error', + message : { es: 'La calidad estándar no permite sanitarios en negro', en: 'Standard quality does not allow black sanitary ware' } + }, +}; + +// ============================================================================ +// CATÁLOGO COMPLETO +// ============================================================================ + +export const CATALOGO_VIVIENDAS: ConfigurationCatalog = { + id : 'ct:viviendas', + code : 'viviendas', + name : { es: 'Catálogo de Viviendas', en: 'Housing Catalog' }, + description: { es: 'Configurador de acabados para viviendas de obra nueva', en: 'Finish configurator for new build homes' }, + basePath : '/renders/', + options : OPTIONS, + objects : { + [OBJ_APARTAMENTO]: APARTAMENTO, + [OBJ_DUPLEX] : DUPLEX, + }, + rules : RULES, +}; + +// ============================================================================ +// EXPORTS DE IDs — para uso en tests +// ============================================================================ + +export const IDS_TEST = { + OBJ_APARTAMENTO, OBJ_DUPLEX, + SEC_SALON, SEC_BANO, SEC_ASEO, SEC_DORMITORIO1, SEC_DORMITORIO2, SEC_COCINA, + VIEW_FRONT, VIEW_360, + ATTR_CALIDAD, ATTR_SUELO, ATTR_PARED, ATTR_PUERTA, + ATTR_ENCIMERA, ATTR_MUEBLE_COC, ATTR_SANITARIO, ATTR_REVEST_BANO, + ATTR_M2_SALON, ATTR_M2_BANO, ATTR_M2_DORM, ATTR_M2_COCINA, + OPT_CALIDAD_ESTANDAR, OPT_CALIDAD_PREMIUM, OPT_CALIDAD_LUJO, + OPT_SUELO_PARQUET, OPT_SUELO_PORCELANICO, OPT_SUELO_MICROCEMENTO, OPT_SUELO_CERAMICA, + OPT_PARED_BLANCO, OPT_PARED_GRIS, OPT_PARED_BEIGE, OPT_PARED_PIEDRA, + OPT_PUERTA_ROBLE, OPT_PUERTA_LACADA, OPT_PUERTA_CRISTAL, + OPT_ENCIMERA_GRANITO, OPT_ENCIMERA_SILESTONE, OPT_ENCIMERA_MADERA, + OPT_MUEBLE_BLANCO, OPT_MUEBLE_GRIS, OPT_MUEBLE_MADERA, + OPT_SANIT_BLANCO, OPT_SANIT_NEGRO, + OPT_REVEST_MARMOL, OPT_REVEST_MICROCEMENTO, OPT_REVEST_CERAMICA, + RULE_LUJO_MARMOL, RULE_ESTANDAR_NO_LUJO, RULE_MICRO_CONSISTENTE, + RULE_MADERA_NO_BANO, RULE_NEGRO_PREMIUM, RULE_ESTANDAR_NO_NEGRO, + // Armario + SEC_ARMARIO, + ATTR_ARMARIO_PUERTAS, + ATTR_ARMARIO_MATERIAL, + OPT_ARMARIO_LACADO, + OPT_ARMARIO_MADERA, +}; \ No newline at end of file diff --git a/src/libs/vice/tests/pricing-dynamic.test.ts b/src/libs/vice/tests/pricing-dynamic.test.ts new file mode 100644 index 0000000..493fcab --- /dev/null +++ b/src/libs/vice/tests/pricing-dynamic.test.ts @@ -0,0 +1,797 @@ +/** + * ============================================================================ + * CATÁLOGO DE VIVIENDAS - FIXTURE ACTUALIZADO + * ============================================================================ + * + * Modelo A: Apartamento (salón, baño, dormitorio, cocina) + * Modelo B: Dúplex (salón, baño, aseo, 2 dormitorios) + */ + +import type { + ConfigurationCatalog, + ConfigurableObject, + OptionDefinition, + ValidationRule, + VisualSection, + DynamicAttribute, + FixedAttribute, + QuantifiableAttribute, + SectionView, +} from '../types'; + +import type { AttrID, SectionID, OptionID, ObjectID, RuleID, ViewID } from '../types'; + +// ============================================================================ +// IDs — OPCIONES +// ============================================================================ + +const OPT_SUELO_PARQUET : OptionID = 'op:suelo_parquet'; +const OPT_SUELO_PORCELANICO : OptionID = 'op:suelo_porcelanico'; +const OPT_SUELO_MICROCEMENTO : OptionID = 'op:suelo_microcemento'; +const OPT_SUELO_CERAMICA : OptionID = 'op:suelo_ceramica'; + +const OPT_PARED_BLANCO : OptionID = 'op:pared_blanco'; +const OPT_PARED_GRIS : OptionID = 'op:pared_gris'; +const OPT_PARED_BEIGE : OptionID = 'op:pared_beige'; +const OPT_PARED_PIEDRA : OptionID = 'op:pared_piedra'; + +const OPT_PUERTA_ROBLE : OptionID = 'op:puerta_roble'; +const OPT_PUERTA_LACADA : OptionID = 'op:puerta_lacada'; +const OPT_PUERTA_CRISTAL : OptionID = 'op:puerta_cristal'; + +const OPT_ENCIMERA_GRANITO : OptionID = 'op:encimera_granito'; +const OPT_ENCIMERA_SILESTONE : OptionID = 'op:encimera_silestone'; +const OPT_ENCIMERA_MADERA : OptionID = 'op:encimera_madera'; + +const OPT_MUEBLE_BLANCO : OptionID = 'op:mueble_blanco'; +const OPT_MUEBLE_GRIS : OptionID = 'op:mueble_gris'; +const OPT_MUEBLE_MADERA : OptionID = 'op:mueble_madera'; + +const OPT_SANIT_BLANCO : OptionID = 'op:sanit_blanco'; +const OPT_SANIT_NEGRO : OptionID = 'op:sanit_negro'; + +const OPT_REVEST_MARMOL : OptionID = 'op:revest_marmol'; +const OPT_REVEST_MICROCEMENTO: OptionID = 'op:revest_microcemento'; +const OPT_REVEST_CERAMICA : OptionID = 'op:revest_ceramica'; + +const OPT_CALIDAD_ESTANDAR : OptionID = 'op:calidad_estandar'; + +// Armario — material puertas +const OPT_ARMARIO_LACADO : OptionID = 'op:armario_lacado'; +const OPT_ARMARIO_MADERA : OptionID = 'op:armario_madera'; +const OPT_CALIDAD_PREMIUM : OptionID = 'op:calidad_premium'; +const OPT_CALIDAD_LUJO : OptionID = 'op:calidad_lujo'; + +// ============================================================================ +// IDs — ATRIBUTOS +// ============================================================================ + +const ATTR_CALIDAD : AttrID = 'at:calidad'; +const ATTR_PUERTA : AttrID = 'at:puerta'; +const ATTR_SUELO : AttrID = 'at:suelo'; +const ATTR_PARED : AttrID = 'at:pared'; +const ATTR_ENCIMERA : AttrID = 'at:encimera'; +const ATTR_MUEBLE_COC : AttrID = 'at:mueble_cocina'; +const ATTR_M2_COCINA : AttrID = 'at:m2_cocina'; +const ATTR_SANITARIO : AttrID = 'at:sanitario'; +const ATTR_REVEST_BANO: AttrID = 'at:revest_bano'; +const ATTR_M2_BANO : AttrID = 'at:m2_bano'; +const ATTR_M2_DORM : AttrID = 'at:m2_dormitorio'; +const ATTR_M2_SALON : AttrID = 'at:m2_salon'; + +// Armario +const ATTR_ARMARIO_PUERTAS : AttrID = 'at:armario_puertas'; +const ATTR_ARMARIO_MATERIAL : AttrID = 'at:armario_material'; + +// ============================================================================ +// IDs — SECCIONES +// ============================================================================ + +const SEC_SALON : SectionID = 'sc:salon'; +const SEC_BANO : SectionID = 'sc:bano'; +const SEC_ASEO : SectionID = 'sc:aseo'; +const SEC_DORMITORIO1: SectionID = 'sc:dormitorio1'; +const SEC_DORMITORIO2: SectionID = 'sc:dormitorio2'; +const SEC_COCINA : SectionID = 'sc:cocina'; +const SEC_ARMARIO : SectionID = 'sc:armario'; + +// ============================================================================ +// IDs — VISTAS +// ============================================================================ + +const VIEW_FRONT: ViewID = 'vw:front'; +const VIEW_360 : ViewID = 'vw:360'; + +// ============================================================================ +// IDs — OBJETOS +// ============================================================================ + +const OBJ_APARTAMENTO: ObjectID = 'ob:apartamento'; +const OBJ_DUPLEX : ObjectID = 'ob:duplex'; + +// ============================================================================ +// IDs — REGLAS +// ============================================================================ + +const RULE_LUJO_MARMOL : RuleID = 'rl:lujo_requiere_marmol'; +const RULE_ESTANDAR_NO_LUJO : RuleID = 'rl:estandar_no_lujo'; +const RULE_MICRO_CONSISTENTE: RuleID = 'rl:microcemento_consistente'; +const RULE_MADERA_NO_BANO : RuleID = 'rl:madera_no_bano'; +const RULE_NEGRO_PREMIUM : RuleID = 'rl:negro_requiere_premium'; +const RULE_ESTANDAR_NO_NEGRO: RuleID = 'rl:estandar_no_negro'; + +// ============================================================================ +// OPCIONES +// ============================================================================ + +export const OPTIONS: Record = { + [OPT_SUELO_PARQUET]: { + id: OPT_SUELO_PARQUET, code: 'parquet', + name: { es: 'Parquet', en: 'Parquet' }, + description: { es: 'Suelo de madera natural', en: 'Natural wood floor' }, + pricing: { baseAmount: 45 }, tags: ['madera', 'calido'] + }, + [OPT_SUELO_PORCELANICO]: { + id: OPT_SUELO_PORCELANICO, code: 'porcelanico', + name: { es: 'Porcelánico', en: 'Porcelain tile' }, + description: { es: 'Suelo porcelánico de gran formato', en: 'Large format porcelain tile' }, + pricing: { baseAmount: 55 }, tags: ['ceramica', 'moderno'] + }, + [OPT_SUELO_MICROCEMENTO]: { + id: OPT_SUELO_MICROCEMENTO, code: 'microcemento', + name: { es: 'Microcemento', en: 'Microcement' }, + description: { es: 'Microcemento continuo', en: 'Continuous microcement' }, + pricing: { baseAmount: 75 }, tags: ['moderno', 'premium'] + }, + [OPT_SUELO_CERAMICA]: { + id: OPT_SUELO_CERAMICA, code: 'ceramica', + name: { es: 'Cerámica', en: 'Ceramic tile' }, + description: { es: 'Cerámica estándar', en: 'Standard ceramic tile' }, + pricing: { baseAmount: 30 }, tags: ['ceramica', 'estandar'] + }, + [OPT_PARED_BLANCO]: { + id: OPT_PARED_BLANCO, code: 'blanco', + name: { es: 'Blanco roto', en: 'Off white' }, + description: { es: 'Pintura blanca rota', en: 'Off white paint' }, + pricing: { baseAmount: 0 } + }, + [OPT_PARED_GRIS]: { + id: OPT_PARED_GRIS, code: 'gris', + name: { es: 'Gris perla', en: 'Pearl grey' }, + description: { es: 'Pintura gris perla', en: 'Pearl grey paint' }, + pricing: { baseAmount: 0 } + }, + [OPT_PARED_BEIGE]: { + id: OPT_PARED_BEIGE, code: 'beige', + name: { es: 'Beige', en: 'Beige' }, + description: { es: 'Pintura beige', en: 'Beige paint' }, + pricing: { baseAmount: 0 } + }, + [OPT_PARED_PIEDRA]: { + id: OPT_PARED_PIEDRA, code: 'piedra', + name: { es: 'Efecto piedra', en: 'Stone effect' }, + description: { es: 'Pintura efecto piedra', en: 'Stone effect paint' }, + pricing: { baseAmount: 800 } + }, + [OPT_PUERTA_ROBLE]: { + id: OPT_PUERTA_ROBLE, code: 'roble', + name: { es: 'Roble natural', en: 'Natural oak' }, + description: { es: 'Puerta en madera de roble', en: 'Oak wood door' }, + pricing: { baseAmount: 350 } + }, + [OPT_PUERTA_LACADA]: { + id: OPT_PUERTA_LACADA, code: 'lacada', + name: { es: 'Lacada blanca', en: 'White lacquered' }, + description: { es: 'Puerta lacada en blanco', en: 'White lacquered door' }, + pricing: { baseAmount: 250 } + }, + [OPT_PUERTA_CRISTAL]: { + id: OPT_PUERTA_CRISTAL, code: 'cristal', + name: { es: 'Cristal', en: 'Glass' }, + description: { es: 'Puerta de cristal templado', en: 'Tempered glass door' }, + pricing: { baseAmount: 500 } + }, + [OPT_ENCIMERA_GRANITO]: { + id: OPT_ENCIMERA_GRANITO, code: 'granito', + name: { es: 'Granito', en: 'Granite' }, + description: { es: 'Encimera de granito natural', en: 'Natural granite countertop' }, + pricing: { baseAmount: 400 } + }, + [OPT_ENCIMERA_SILESTONE]: { + id: OPT_ENCIMERA_SILESTONE, code: 'silestone', + name: { es: 'Silestone', en: 'Silestone' }, + description: { es: 'Encimera de Silestone', en: 'Silestone countertop' }, + pricing: { baseAmount: 600 } + }, + [OPT_ENCIMERA_MADERA]: { + id: OPT_ENCIMERA_MADERA, code: 'madera', + name: { es: 'Madera', en: 'Wood' }, + description: { es: 'Encimera de madera maciza', en: 'Solid wood countertop' }, + pricing: { baseAmount: 500 } + }, + [OPT_MUEBLE_BLANCO]: { + id: OPT_MUEBLE_BLANCO, code: 'blanco', + name: { es: 'Blanco mate', en: 'Matte white' }, + description: { es: 'Muebles lacados en blanco mate', en: 'Matte white lacquered cabinets' }, + pricing: { baseAmount: 3500 } + }, + [OPT_MUEBLE_GRIS]: { + id: OPT_MUEBLE_GRIS, code: 'gris', + name: { es: 'Gris antracita', en: 'Anthracite grey' }, + description: { es: 'Muebles en gris antracita', en: 'Anthracite grey cabinets' }, + pricing: { baseAmount: 4000 } + }, + [OPT_MUEBLE_MADERA]: { + id: OPT_MUEBLE_MADERA, code: 'madera', + name: { es: 'Madera natural', en: 'Natural wood' }, + description: { es: 'Muebles en madera natural', en: 'Natural wood cabinets' }, + pricing: { baseAmount: 5000 } + }, + [OPT_SANIT_BLANCO]: { + id: OPT_SANIT_BLANCO, code: 'blanco', + name: { es: 'Blanco', en: 'White' }, + description: { es: 'Sanitarios blancos', en: 'White sanitary ware' }, + pricing: { baseAmount: 0 } + }, + [OPT_SANIT_NEGRO]: { + id: OPT_SANIT_NEGRO, code: 'negro', + name: { es: 'Negro mate', en: 'Matte black' }, + description: { es: 'Sanitarios en negro mate', en: 'Matte black sanitary ware' }, + pricing: { baseAmount: 1200 } + }, + [OPT_REVEST_MARMOL]: { + id: OPT_REVEST_MARMOL, code: 'marmol', + name: { es: 'Mármol', en: 'Marble' }, + description: { es: 'Revestimiento de mármol natural', en: 'Natural marble cladding' }, + pricing: { baseAmount: 120 } + }, + [OPT_REVEST_MICROCEMENTO]: { + id: OPT_REVEST_MICROCEMENTO, code: 'microcemento', + name: { es: 'Microcemento', en: 'Microcement' }, + description: { es: 'Revestimiento de microcemento', en: 'Microcement cladding' }, + pricing: { baseAmount: 90 } + }, + [OPT_REVEST_CERAMICA]: { + id: OPT_REVEST_CERAMICA, code: 'ceramica', + name: { es: 'Cerámica', en: 'Ceramic tile' }, + description: { es: 'Revestimiento cerámico', en: 'Ceramic tile cladding' }, + pricing: { baseAmount: 40 } + }, + // Armario — material puertas + [OPT_ARMARIO_LACADO]: { + id: OPT_ARMARIO_LACADO, code: 'lacado', + name: { es: 'Lacado blanco', en: 'White lacquered' }, + description: { es: 'Puertas lacadas en blanco mate', en: 'Matte white lacquered doors' }, + pricing: { + baseAmount : 0, + // Precio por puerta con descuento por volumen: + // 2 puertas → 350€/u, 3 puertas → 300€/u + dynamicExpression: { + 'if': [ + { '>=': [{ var: 'quantity' }, 3] }, + { '*': [{ var: 'quantity' }, 300] }, + { '*': [{ var: 'quantity' }, 350] } + ] + } + } + }, + [OPT_ARMARIO_MADERA]: { + id: OPT_ARMARIO_MADERA, code: 'madera', + name: { es: 'Madera natural', en: 'Natural wood' }, + description: { es: 'Puertas en madera natural', en: 'Natural wood doors' }, + pricing: { + baseAmount : 0, + // Precio fijo por puerta sin descuento + dynamicExpression: { '*': [{ var: 'quantity' }, 500] } + } + }, + [OPT_CALIDAD_ESTANDAR]: { + id: OPT_CALIDAD_ESTANDAR, code: 'estandar', + name: { es: 'Estándar', en: 'Standard' }, + description: { es: 'Acabados estándar', en: 'Standard quality finishes' }, + pricing: { baseAmount: 0 } + }, + [OPT_CALIDAD_PREMIUM]: { + id: OPT_CALIDAD_PREMIUM, code: 'premium', + name: { es: 'Premium', en: 'Premium' }, + description: { es: 'Acabados de alta calidad', en: 'High quality finishes' }, + pricing: { baseAmount: 15000 } + }, + [OPT_CALIDAD_LUJO]: { + id: OPT_CALIDAD_LUJO, code: 'lujo', + name: { es: 'Lujo', en: 'Luxury' }, + description: { es: 'Acabados de lujo exclusivos', en: 'Exclusive luxury finishes' }, + pricing: { baseAmount: 35000 } + }, +}; + +// ============================================================================ +// ATRIBUTOS COMUNES +// ============================================================================ + +const attrSuelo = (): DynamicAttribute => ({ + id: ATTR_SUELO, code: 'suelo', + name: { es: 'Suelo', en: 'Flooring' }, + description: { es: 'Material del suelo', en: 'Floor material' }, + type: 'dynamic', dataType: 'reference', + defaultValue: OPT_SUELO_CERAMICA, + options: [ + { optionId: OPT_SUELO_CERAMICA, priority: 1 }, + { optionId: OPT_SUELO_PARQUET, priority: 2 }, + { optionId: OPT_SUELO_PORCELANICO, priority: 3 }, + { optionId: OPT_SUELO_MICROCEMENTO, priority: 4 }, + ], + display: { uiVisible: true, affectsVisual: true, affectsPrice: true } +}); + +const attrPared = (): DynamicAttribute => ({ + id: ATTR_PARED, code: 'pared', + name: { es: 'Color paredes', en: 'Wall color' }, + description: { es: 'Color de la pintura de paredes', en: 'Wall paint color' }, + type: 'dynamic', dataType: 'reference', + defaultValue: OPT_PARED_BLANCO, + options: [ + { optionId: OPT_PARED_BLANCO, priority: 1 }, + { optionId: OPT_PARED_GRIS, priority: 2 }, + { optionId: OPT_PARED_BEIGE, priority: 3 }, + { optionId: OPT_PARED_PIEDRA, priority: 4 }, + ], + display: { uiVisible: true, affectsVisual: true, affectsPrice: true } +}); + +// ============================================================================ +// VISTAS HELPERS +// ============================================================================ + +const viewFront = (template: string, globalAttrDependencies?: AttrID[]): SectionView => ({ + id : VIEW_FRONT, + code : 'front', + name : { es: 'Vista frontal', en: 'Front view' }, + type : 'front', + visualConfig: { + strategy: 'static_image', + template, + globalAttrDependencies, + basePath : '/renders/vivienda/', + fallbackImage: '/renders/fallback.jpg', + }, +}); + +const view360 = (template: string, globalAttrDependencies?: AttrID[]): SectionView => ({ + id : VIEW_360, + code : '360', + name : { es: 'Vista 360°', en: '360° view' }, + type : 'panoramic', + visualConfig: { + strategy: 'static_image', + template, + globalAttrDependencies, + basePath : '/renders/vivienda/', + fallbackImage: '/renders/fallback.jpg', + }, +}); + +// ============================================================================ +// SECCIONES +// ============================================================================ + +const seccionSalon = (): VisualSection => ({ + kind : 'visual', + id : SEC_SALON, + code : 'salon', + name : { es: 'Salón', en: 'Living room' }, + description: { es: 'Salón - comedor principal', en: 'Main living - dining room' }, + attributes : [ + attrSuelo(), + attrPared(), + { + id: ATTR_M2_SALON, code: 'm2_salon', + name: { es: 'Superficie', en: 'Surface' }, + description: { es: 'Superficie del salón en m²', en: 'Living room surface in m²' }, + type: 'fixed', dataType: 'number', value: 25, + unit: { es: 'm²', en: 'sqm' }, + display: { uiVisible: false, readonly: true } + } as FixedAttribute, + { + id: ATTR_PUERTA, code: 'puerta', + name: { es: 'Puerta', en: 'Door' }, + description: { es: 'Tipo de puerta', en: 'Door type' }, + type: 'dynamic', dataType: 'reference', + defaultValue: OPT_PUERTA_LACADA, + options: [ + { optionId: OPT_PUERTA_LACADA, priority: 1 }, + { optionId: OPT_PUERTA_ROBLE, priority: 2 }, + { optionId: OPT_PUERTA_CRISTAL, priority: 3 }, + ], + display: { uiVisible: true, affectsVisual: true, affectsPrice: true } + } as DynamicAttribute, + ], + views : { + [VIEW_FRONT]: viewFront('{object}/{section}/{view}/{at:suelo}_{at:pared}.jpg', [ATTR_CALIDAD]), + [VIEW_360] : view360 ('{object}/{section}/{view}/{at:suelo}_{at:pared}.jpg', [ATTR_CALIDAD]), + }, + defaultView : VIEW_FRONT, + availability: { mode: 'required' }, +}); + +const seccionBano = (m2: number = 6): VisualSection => ({ + kind : 'visual', + id : SEC_BANO, + code : 'bano', + name : { es: 'Baño', en: 'Bathroom' }, + description: { es: 'Baño principal', en: 'Main bathroom' }, + attributes : [ + { + id: ATTR_SANITARIO, code: 'sanitario', + name: { es: 'Sanitarios', en: 'Sanitary ware' }, + description: { es: 'Color de los sanitarios', en: 'Sanitary ware color' }, + type: 'dynamic', dataType: 'reference', + defaultValue: OPT_SANIT_BLANCO, + options: [ + { optionId: OPT_SANIT_BLANCO, priority: 1 }, + { optionId: OPT_SANIT_NEGRO, priority: 2 }, + ], + display: { uiVisible: true, affectsVisual: true, affectsPrice: true } + } as DynamicAttribute, + { + id: ATTR_REVEST_BANO, code: 'revest_bano', + name: { es: 'Revestimiento', en: 'Cladding' }, + description: { es: 'Revestimiento de paredes del baño', en: 'Bathroom wall cladding' }, + type: 'dynamic', dataType: 'reference', + defaultValue: OPT_REVEST_CERAMICA, + options: [ + { optionId: OPT_REVEST_CERAMICA, priority: 1 }, + { optionId: OPT_REVEST_MICROCEMENTO, priority: 2 }, + { optionId: OPT_REVEST_MARMOL, priority: 3 }, + ], + display: { uiVisible: true, affectsVisual: true, affectsPrice: true } + } as DynamicAttribute, + { + id: ATTR_M2_BANO, code: 'm2_bano', + name: { es: 'Superficie', en: 'Surface' }, + description: { es: 'Superficie del baño en m²', en: 'Bathroom surface in m²' }, + type: 'fixed', dataType: 'number', value: m2, + unit: { es: 'm²', en: 'sqm' }, + display: { uiVisible: false, readonly: true } + } as FixedAttribute, + ], + views : { + [VIEW_FRONT]: viewFront('{object}/{section}/{view}/{at:sanitario}_{at:revest_bano}.jpg', [ATTR_CALIDAD]), + }, + defaultView : VIEW_FRONT, + availability: { mode: 'required' }, +}); + +const seccionAseo = (): VisualSection => ({ + kind : 'visual', + id : SEC_ASEO, + code : 'aseo', + name : { es: 'Aseo', en: 'Toilet' }, + description: { es: 'Aseo de cortesía', en: 'Guest toilet' }, + attributes : [ + { + id: ATTR_SANITARIO, code: 'sanitario', + name: { es: 'Sanitarios', en: 'Sanitary ware' }, + description: { es: 'Color de los sanitarios', en: 'Sanitary ware color' }, + type: 'dynamic', dataType: 'reference', + defaultValue: OPT_SANIT_BLANCO, + options: [ + { optionId: OPT_SANIT_BLANCO, priority: 1 }, + { optionId: OPT_SANIT_NEGRO, priority: 2 }, + ], + display: { uiVisible: true, affectsVisual: true, affectsPrice: true } + } as DynamicAttribute, + { + id: ATTR_REVEST_BANO, code: 'revest_bano', + name: { es: 'Revestimiento', en: 'Cladding' }, + description: { es: 'Revestimiento', en: 'Cladding' }, + type: 'dynamic', dataType: 'reference', + defaultValue: OPT_REVEST_CERAMICA, + options: [ + { optionId: OPT_REVEST_CERAMICA, priority: 1 }, + { optionId: OPT_REVEST_MICROCEMENTO, priority: 2 }, + ], + display: { uiVisible: true, affectsVisual: true, affectsPrice: true } + } as DynamicAttribute, + ], + views : { + [VIEW_FRONT]: viewFront('{object}/{section}/{view}/{at:sanitario}_{at:revest_bano}.jpg', [ATTR_CALIDAD]), + }, + defaultView : VIEW_FRONT, + availability: { mode: 'required' }, +}); + +const seccionDormitorio = (id: SectionID, code: string, nombre: string, m2: number): VisualSection => ({ + kind : 'visual', + id, + code, + name : { es: nombre, en: nombre.replace('Dormitorio', 'Bedroom') }, + description: { es: `${nombre}`, en: `${nombre.replace('Dormitorio', 'Bedroom')}` }, + attributes : [ + attrSuelo(), + attrPared(), + { + id: ATTR_M2_DORM, code: 'm2_dormitorio', + name: { es: 'Superficie', en: 'Surface' }, + description: { es: 'Superficie del dormitorio en m²', en: 'Bedroom surface in m²' }, + type: 'fixed', dataType: 'number', value: m2, + unit: { es: 'm²', en: 'sqm' }, + display: { uiVisible: false, readonly: true } + } as FixedAttribute, + ], + views : { + [VIEW_FRONT]: viewFront(`{object}/{section}/{view}/{at:suelo}_{at:pared}.jpg`, [ATTR_CALIDAD]), + }, + defaultView : VIEW_FRONT, + availability: { mode: 'required' }, +}); + +const seccionCocina = (): VisualSection => ({ + kind : 'visual', + id : SEC_COCINA, + code : 'cocina', + name : { es: 'Cocina', en: 'Kitchen' }, + description: { es: 'Cocina equipada', en: 'Equipped kitchen' }, + attributes : [ + { + id: ATTR_ENCIMERA, code: 'encimera', + name: { es: 'Encimera', en: 'Countertop' }, + description: { es: 'Material de la encimera', en: 'Countertop material' }, + type: 'dynamic', dataType: 'reference', + defaultValue: OPT_ENCIMERA_GRANITO, + options: [ + { optionId: OPT_ENCIMERA_GRANITO, priority: 1 }, + { optionId: OPT_ENCIMERA_SILESTONE, priority: 2 }, + { optionId: OPT_ENCIMERA_MADERA, priority: 3 }, + ], + display: { uiVisible: true, affectsVisual: true, affectsPrice: true } + } as DynamicAttribute, + { + id: ATTR_MUEBLE_COC, code: 'mueble_cocina', + name: { es: 'Muebles', en: 'Cabinets' }, + description: { es: 'Muebles de cocina', en: 'Kitchen cabinets' }, + type: 'dynamic', dataType: 'reference', + defaultValue: OPT_MUEBLE_BLANCO, + options: [ + { optionId: OPT_MUEBLE_BLANCO, priority: 1 }, + { optionId: OPT_MUEBLE_GRIS, priority: 2 }, + { optionId: OPT_MUEBLE_MADERA, priority: 3 }, + ], + display: { uiVisible: true, affectsVisual: true, affectsPrice: true } + } as DynamicAttribute, + attrSuelo(), + { + id: ATTR_M2_COCINA, code: 'm2_cocina', + name: { es: 'Superficie', en: 'Surface' }, + description: { es: 'Superficie de la cocina en m²', en: 'Kitchen surface in m²' }, + type: 'fixed', dataType: 'number', value: 10, + unit: { es: 'm²', en: 'sqm' }, + display: { uiVisible: false, readonly: true } + } as FixedAttribute, + ], + views : { + [VIEW_FRONT]: viewFront('{object}/{section}/{view}/{at:encimera}_{at:mueble_cocina}.jpg', [ATTR_CALIDAD]), + }, + defaultView : VIEW_FRONT, + availability: { mode: 'required' }, +}); + +const seccionArmario = (): VisualSection => ({ + kind : 'visual', + id : SEC_ARMARIO, + code : 'armario', + name : { es: 'Armario', en: 'Wardrobe' }, + description: { es: 'Armario empotrado con puertas correderas', en: 'Built-in wardrobe with sliding doors' }, + attributes : [ + { + id : ATTR_ARMARIO_PUERTAS, + code : 'armario_puertas', + name : { es: 'Puertas de armario', en: 'Wardrobe doors' }, + description : { es: 'Número y material de las puertas del armario', en: 'Number and material of wardrobe doors' }, + type : 'quantifiable', + dataType : 'reference', + defaultValue: OPT_ARMARIO_LACADO, + quantity : 2, + minQuantity : 2, + maxQuantity : 3, + unit : { es: 'puertas', en: 'doors' }, + options : [ + { optionId: OPT_ARMARIO_LACADO, priority: 1 }, + { optionId: OPT_ARMARIO_MADERA, priority: 2 }, + ], + display : { uiVisible: true, affectsVisual: true, affectsPrice: true } + } as QuantifiableAttribute, + { + id : ATTR_ARMARIO_MATERIAL, + code : 'armario_material', + name : { es: 'Material interior', en: 'Interior material' }, + description : { es: 'Material del interior del armario', en: 'Wardrobe interior material' }, + type : 'dynamic', + dataType : 'reference', + defaultValue: OPT_ARMARIO_LACADO, + options : [ + { optionId: OPT_ARMARIO_LACADO, priority: 1 }, + { optionId: OPT_ARMARIO_MADERA, priority: 2 }, + ], + display : { uiVisible: true, affectsVisual: false, affectsPrice: false } + } as DynamicAttribute, + ], + views : { + [VIEW_FRONT]: viewFront('{object}/{section}/{view}/{at:armario_puertas}.jpg'), + }, + defaultView : VIEW_FRONT, + availability: { mode: 'optional' }, +}); + +// ============================================================================ +// ATRIBUTO GLOBAL — CALIDAD +// ============================================================================ + +const attrCalidadGlobal: DynamicAttribute = { + id: ATTR_CALIDAD, code: 'calidad', + name: { es: 'Calidad de acabados', en: 'Finish quality' }, + description: { es: 'Nivel de calidad general', en: 'General finish quality level' }, + type: 'dynamic', dataType: 'reference', + defaultValue: OPT_CALIDAD_ESTANDAR, + options: [ + { optionId: OPT_CALIDAD_ESTANDAR, priority: 1 }, + { optionId: OPT_CALIDAD_PREMIUM, priority: 2 }, + { optionId: OPT_CALIDAD_LUJO, priority: 3 }, + ], + controls: [], + display : { uiVisible: true, affectsVisual: true, affectsPrice: true } +}; + +// ============================================================================ +// OBJETOS +// ============================================================================ + +export const APARTAMENTO: ConfigurableObject = { + id : OBJ_APARTAMENTO, + code : 'apartamento', + name : { es: 'Apartamento', en: 'Apartment' }, + description: { es: 'Apartamento de 1 dormitorio (55 m²)', en: '1 bedroom apartment (55 sqm)' }, + attributes : [attrCalidadGlobal], + sections : { + [SEC_SALON] : seccionSalon(), + [SEC_BANO] : seccionBano(6), + [SEC_DORMITORIO1]: seccionDormitorio(SEC_DORMITORIO1, 'dormitorio', 'Dormitorio', 14), + [SEC_COCINA] : seccionCocina(), + [SEC_ARMARIO] : seccionArmario(), + }, + sectionOrder: [SEC_SALON, SEC_COCINA, SEC_DORMITORIO1, SEC_BANO, SEC_ARMARIO], + basePath : '/renders/vivienda/', + category : 'residencial', +}; + +export const DUPLEX: ConfigurableObject = { + id : OBJ_DUPLEX, + code : 'duplex', + name : { es: 'Dúplex', en: 'Duplex' }, + description: { es: 'Dúplex de 2 dormitorios (90 m²)', en: '2 bedroom duplex (90 sqm)' }, + attributes : [attrCalidadGlobal], + sections : { + [SEC_SALON] : seccionSalon(), + [SEC_BANO] : seccionBano(8), + [SEC_ASEO] : seccionAseo(), + [SEC_DORMITORIO1]: seccionDormitorio(SEC_DORMITORIO1, 'dormitorio1', 'Dormitorio principal', 18), + [SEC_DORMITORIO2]: seccionDormitorio(SEC_DORMITORIO2, 'dormitorio2', 'Dormitorio secundario', 12), + }, + sectionOrder: [SEC_SALON, SEC_DORMITORIO1, SEC_DORMITORIO2, SEC_BANO, SEC_ASEO], + basePath : '/renders/vivienda/', + category : 'residencial', +}; + +// ============================================================================ +// REGLAS +// ============================================================================ + +export const RULES: Record = { + [RULE_LUJO_MARMOL]: { + id : RULE_LUJO_MARMOL, + name : { es: 'Lujo requiere mármol', en: 'Luxury requires marble' }, + condition: { '==': [{ var: 'attributes.at_calidad' }, OPT_CALIDAD_LUJO] }, + action : { type: 'require', targetAttr: ATTR_REVEST_BANO, values: [OPT_REVEST_MARMOL] }, + priority : 10, + affects : [ATTR_CALIDAD, ATTR_REVEST_BANO], + severity : 'warning', + message : { es: 'El acabado de lujo recomienda mármol en el baño', en: 'Luxury finish recommends marble in bathroom' } + }, + [RULE_ESTANDAR_NO_LUJO]: { + id : RULE_ESTANDAR_NO_LUJO, + name : { es: 'Estándar sin lujo', en: 'Standard without luxury' }, + condition: { '==': [{ var: 'attributes.at_calidad' }, OPT_CALIDAD_ESTANDAR] }, + action : { type: 'forbid', targetAttr: ATTR_REVEST_BANO, values: [OPT_REVEST_MARMOL] }, + priority : 9, + affects : [ATTR_CALIDAD, ATTR_REVEST_BANO], + severity : 'error', + message : { es: 'El mármol no está disponible en calidad estándar', en: 'Marble is not available in standard quality' } + }, + [RULE_MICRO_CONSISTENTE]: { + id : RULE_MICRO_CONSISTENTE, + name : { es: 'Microcemento consistente', en: 'Consistent microcement' }, + condition: { '==': [{ var: 'attributes.at_suelo' }, OPT_SUELO_MICROCEMENTO] }, + action : { type: 'allow', targetAttr: ATTR_REVEST_BANO, values: [OPT_REVEST_MICROCEMENTO, OPT_REVEST_CERAMICA] }, + priority : 5, + affects : [ATTR_SUELO, ATTR_REVEST_BANO], + severity : 'warning', + message : { es: 'Con microcemento en suelo se recomienda microcemento o cerámica en baño', en: 'With microcement floor, microcement or ceramic is recommended in bathroom' } + }, + [RULE_MADERA_NO_BANO]: { + id : RULE_MADERA_NO_BANO, + name : { es: 'Madera sin negro', en: 'Wood without black' }, + condition: { '==': [{ var: 'attributes.at_encimera' }, OPT_ENCIMERA_MADERA] }, + action : { type: 'forbid', targetAttr: ATTR_SANITARIO, values: [OPT_SANIT_NEGRO] }, + priority : 7, + affects : [ATTR_ENCIMERA, ATTR_SANITARIO], + severity : 'warning', + message : { es: 'La encimera de madera no combina con sanitarios negros', en: 'Wood countertop does not pair well with black sanitary ware' } + }, + [RULE_NEGRO_PREMIUM]: { + id : RULE_NEGRO_PREMIUM, + name : { es: 'Negro requiere premium', en: 'Black requires premium' }, + condition: { '==': [{ var: 'attributes.at_sanitario' }, OPT_SANIT_NEGRO] }, + action : { type: 'forbid', targetAttr: ATTR_CALIDAD, values: [OPT_CALIDAD_ESTANDAR] }, + priority : 8, + affects : [ATTR_SANITARIO, ATTR_CALIDAD], + severity : 'error', + message : { es: 'Los sanitarios negros requieren calidad premium o superior', en: 'Black sanitary ware requires premium quality or higher' } + }, + [RULE_ESTANDAR_NO_NEGRO]: { + id : RULE_ESTANDAR_NO_NEGRO, + name : { es: 'Estándar prohíbe negro', en: 'Standard forbids black' }, + condition: { '==': [{ var: 'attributes.at_calidad' }, OPT_CALIDAD_ESTANDAR] }, + action : { type: 'forbid', targetAttr: ATTR_SANITARIO, values: [OPT_SANIT_NEGRO] }, + priority : 8, + affects : [ATTR_CALIDAD, ATTR_SANITARIO], + severity : 'error', + message : { es: 'La calidad estándar no permite sanitarios en negro', en: 'Standard quality does not allow black sanitary ware' } + }, +}; + +// ============================================================================ +// CATÁLOGO COMPLETO +// ============================================================================ + +export const CATALOGO_VIVIENDAS: ConfigurationCatalog = { + id : 'ct:viviendas', + code : 'viviendas', + name : { es: 'Catálogo de Viviendas', en: 'Housing Catalog' }, + description: { es: 'Configurador de acabados para viviendas de obra nueva', en: 'Finish configurator for new build homes' }, + basePath : '/renders/', + options : OPTIONS, + objects : { + [OBJ_APARTAMENTO]: APARTAMENTO, + [OBJ_DUPLEX] : DUPLEX, + }, + rules : RULES, +}; + +// ============================================================================ +// EXPORTS DE IDs — para uso en tests +// ============================================================================ + +export const IDS_TEST = { + OBJ_APARTAMENTO, OBJ_DUPLEX, + SEC_SALON, SEC_BANO, SEC_ASEO, SEC_DORMITORIO1, SEC_DORMITORIO2, SEC_COCINA, + VIEW_FRONT, VIEW_360, + ATTR_CALIDAD, ATTR_SUELO, ATTR_PARED, ATTR_PUERTA, + ATTR_ENCIMERA, ATTR_MUEBLE_COC, ATTR_SANITARIO, ATTR_REVEST_BANO, + ATTR_M2_SALON, ATTR_M2_BANO, ATTR_M2_DORM, ATTR_M2_COCINA, + OPT_CALIDAD_ESTANDAR, OPT_CALIDAD_PREMIUM, OPT_CALIDAD_LUJO, + OPT_SUELO_PARQUET, OPT_SUELO_PORCELANICO, OPT_SUELO_MICROCEMENTO, OPT_SUELO_CERAMICA, + OPT_PARED_BLANCO, OPT_PARED_GRIS, OPT_PARED_BEIGE, OPT_PARED_PIEDRA, + OPT_PUERTA_ROBLE, OPT_PUERTA_LACADA, OPT_PUERTA_CRISTAL, + OPT_ENCIMERA_GRANITO, OPT_ENCIMERA_SILESTONE, OPT_ENCIMERA_MADERA, + OPT_MUEBLE_BLANCO, OPT_MUEBLE_GRIS, OPT_MUEBLE_MADERA, + OPT_SANIT_BLANCO, OPT_SANIT_NEGRO, + OPT_REVEST_MARMOL, OPT_REVEST_MICROCEMENTO, OPT_REVEST_CERAMICA, + RULE_LUJO_MARMOL, RULE_ESTANDAR_NO_LUJO, RULE_MICRO_CONSISTENTE, + RULE_MADERA_NO_BANO, RULE_NEGRO_PREMIUM, RULE_ESTANDAR_NO_NEGRO, + // Armario + SEC_ARMARIO, + ATTR_ARMARIO_PUERTAS, + ATTR_ARMARIO_MATERIAL, + OPT_ARMARIO_LACADO, + OPT_ARMARIO_MADERA, +}; \ No newline at end of file diff --git a/src/libs/vice/tests/pricing.test.ts b/src/libs/vice/tests/pricing.test.ts new file mode 100644 index 0000000..1021a48 --- /dev/null +++ b/src/libs/vice/tests/pricing.test.ts @@ -0,0 +1,272 @@ +/** + * ============================================================================ + * PRICING ENGINE — TESTS + * ============================================================================ + */ +import type { Logr } from '@/libs/logr'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { PricingEngine } from '../engines/pricing'; +import type { SelectionMap } from '../engines/rule'; +import type { TaxInfo } from '../types'; +import { + CATALOGO_VIVIENDAS, + IDS_TEST, +} from './housing-catalog.fixture'; + +const { + OBJ_APARTAMENTO, + ATTR_CALIDAD, + ATTR_SUELO, + ATTR_PARED, + ATTR_SANITARIO, + ATTR_REVEST_BANO, + ATTR_ENCIMERA, + ATTR_MUEBLE_COC, + ATTR_PUERTA, + ATTR_M2_SALON, + OPT_CALIDAD_ESTANDAR, + OPT_CALIDAD_PREMIUM, + OPT_CALIDAD_LUJO, + OPT_SUELO_CERAMICA, + OPT_SUELO_PARQUET, + OPT_PARED_BLANCO, + OPT_PARED_PIEDRA, + OPT_SANIT_BLANCO, + OPT_SANIT_NEGRO, + OPT_REVEST_CERAMICA, + OPT_REVEST_MARMOL, + OPT_ENCIMERA_GRANITO, + OPT_ENCIMERA_SILESTONE, + OPT_MUEBLE_BLANCO, + OPT_PUERTA_LACADA, + OPT_PUERTA_CRISTAL, +} = IDS_TEST; + +// ============================================================================ +// SETUP +// ============================================================================ + +function makeLogr(): Logr { + return { + debug : vi.fn(), + info : vi.fn(), + warn : vi.fn(), + error : vi.fn(), + getLogs : vi.fn(), + clear : vi.fn(), + serialize: vi.fn(), + setLevel : vi.fn(), + setMaxLogs: vi.fn(), + } as unknown as Logr; +} + +/** Estado base — opciones de precio conocido para verificar sumas */ +function baseState(): SelectionMap { + return { + [ATTR_CALIDAD] : OPT_CALIDAD_ESTANDAR, // 0 + [ATTR_SUELO] : OPT_SUELO_CERAMICA, // 30 + [ATTR_PARED] : OPT_PARED_BLANCO, // 0 + [ATTR_SANITARIO] : OPT_SANIT_BLANCO, // 0 + [ATTR_REVEST_BANO]: OPT_REVEST_CERAMICA, // 40 + [ATTR_ENCIMERA] : OPT_ENCIMERA_GRANITO, // 400 + [ATTR_MUEBLE_COC] : OPT_MUEBLE_BLANCO, // 3500 + [ATTR_PUERTA] : OPT_PUERTA_LACADA, // 250 + }; + // subtotal base = 30 + 40 + 400 + 3500 + 250 = 4220 +} + +const IVA_ES: TaxInfo = { + included: false, + rate : 0.21, + label : { es: 'IVA', en: 'VAT' }, +}; + +const IVA_INCLUIDO: TaxInfo = { + included: true, + rate : 0.21, + label : { es: 'IVA', en: 'VAT' }, +}; + +let logr : Logr; +let engine: PricingEngine; + +beforeEach(() => { + logr = makeLogr(); + engine = new PricingEngine(logr); +}); + +// ============================================================================ +// CÁLCULO BÁSICO +// ============================================================================ + +describe('cálculo básico', () => { + + it('suma los precios de todas las opciones con affectsPrice: true', () => { + const result = engine.calculate(OBJ_APARTAMENTO, baseState(), CATALOGO_VIVIENDAS); + expect(result.subtotal).toBe(4220); + }); + + it('sin tax — taxAmount es 0 y total igual a subtotal', () => { + const result = engine.calculate(OBJ_APARTAMENTO, baseState(), CATALOGO_VIVIENDAS); + expect(result.taxAmount).toBe(0); + expect(result.total).toBe(result.subtotal); + }); + + it('calidad premium suma 15000 al total', () => { + const state = { ...baseState(), [ATTR_CALIDAD]: OPT_CALIDAD_PREMIUM }; // +15000 + const result = engine.calculate(OBJ_APARTAMENTO, state, CATALOGO_VIVIENDAS); + expect(result.subtotal).toBe(4220 + 15000); + }); + + it('calidad lujo suma 35000 al total', () => { + const state = { ...baseState(), [ATTR_CALIDAD]: OPT_CALIDAD_LUJO }; // +35000 + const result = engine.calculate(OBJ_APARTAMENTO, state, CATALOGO_VIVIENDAS); + expect(result.subtotal).toBe(4220 + 35000); + }); + + it('cambiar suelo de cerámica a parquet suma 30 - 30 + 45', () => { + const base = engine.calculate(OBJ_APARTAMENTO, baseState(), CATALOGO_VIVIENDAS); + const state = { ...baseState(), [ATTR_SUELO]: OPT_SUELO_PARQUET }; // 45 en lugar de 30 + const result = engine.calculate(OBJ_APARTAMENTO, state, CATALOGO_VIVIENDAS); + expect(result.subtotal).toBe(base.subtotal - 30 + 45); + }); + + it('pared efecto piedra añade 800', () => { + const state = { ...baseState(), [ATTR_PARED]: OPT_PARED_PIEDRA }; // +800 + const result = engine.calculate(OBJ_APARTAMENTO, state, CATALOGO_VIVIENDAS); + expect(result.subtotal).toBe(4220 + 800); + }); + + it('sanitario negro añade 1200', () => { + const state = { ...baseState(), [ATTR_SANITARIO]: OPT_SANIT_NEGRO }; // +1200 + const result = engine.calculate(OBJ_APARTAMENTO, state, CATALOGO_VIVIENDAS); + expect(result.subtotal).toBe(4220 + 1200); + }); + + it('puerta cristal en lugar de lacada — diferencia de 250', () => { + const state = { ...baseState(), [ATTR_PUERTA]: OPT_PUERTA_CRISTAL }; // 500 en lugar de 250 + const result = engine.calculate(OBJ_APARTAMENTO, state, CATALOGO_VIVIENDAS); + expect(result.subtotal).toBe(4220 - 250 + 500); + }); +}); + +// ============================================================================ +// TAX — impuesto no incluido +// ============================================================================ + +describe('tax — no incluido', () => { + + it('calcula taxAmount como subtotal * rate', () => { + const result = engine.calculate(OBJ_APARTAMENTO, baseState(), CATALOGO_VIVIENDAS, IVA_ES); + expect(result.taxAmount).toBeCloseTo(4220 * 0.21, 2); + }); + + it('total es subtotal + taxAmount', () => { + const result = engine.calculate(OBJ_APARTAMENTO, baseState(), CATALOGO_VIVIENDAS, IVA_ES); + expect(result.total).toBeCloseTo(result.subtotal + result.taxAmount, 2); + }); + + it('devuelve el tax info en el resultado', () => { + const result = engine.calculate(OBJ_APARTAMENTO, baseState(), CATALOGO_VIVIENDAS, IVA_ES); + expect(result.tax).toEqual(IVA_ES); + }); + + it('total con IVA 21% sobre 4220 = 5106.20', () => { + const result = engine.calculate(OBJ_APARTAMENTO, baseState(), CATALOGO_VIVIENDAS, IVA_ES); + expect(result.total).toBeCloseTo(5106.20, 2); + }); +}); + +// ============================================================================ +// TAX — impuesto incluido +// ============================================================================ + +describe('tax — incluido en precio', () => { + + it('taxAmount es 0 cuando included: true', () => { + const result = engine.calculate(OBJ_APARTAMENTO, baseState(), CATALOGO_VIVIENDAS, IVA_INCLUIDO); + expect(result.taxAmount).toBe(0); + }); + + it('total igual a subtotal cuando included: true', () => { + const result = engine.calculate(OBJ_APARTAMENTO, baseState(), CATALOGO_VIVIENDAS, IVA_INCLUIDO); + expect(result.total).toBe(result.subtotal); + }); +}); + +// ============================================================================ +// DESGLOSE +// ============================================================================ + +describe('breakdown', () => { + + it('el breakdown contiene una entrada por opción con precio > 0', () => { + const result = engine.calculate(OBJ_APARTAMENTO, baseState(), CATALOGO_VIVIENDAS); + expect(result.breakdown.length).toBeGreaterThan(0); + }); + + it('cada entrada del breakdown tiene attrId, optionId y amount', () => { + const result = engine.calculate(OBJ_APARTAMENTO, baseState(), CATALOGO_VIVIENDAS); + for (const entry of result.breakdown) { + expect(entry.attrId).toBeTruthy(); + expect(entry.optionId).toBeTruthy(); + expect(typeof entry.amount).toBe('number'); + } + }); + + it('la suma del breakdown es igual al subtotal', () => { + const result = engine.calculate(OBJ_APARTAMENTO, baseState(), CATALOGO_VIVIENDAS); + const sum = result.breakdown.reduce((acc, e) => acc + e.amount, 0); + expect(sum).toBeCloseTo(result.subtotal, 2); + }); + + it('opciones con precio 0 no aparecen en el breakdown', () => { + // pared blanco = 0, sanit blanco = 0, calidad estandar = 0 + const result = engine.calculate(OBJ_APARTAMENTO, baseState(), CATALOGO_VIVIENDAS); + const zeros = result.breakdown.filter(e => e.amount === 0); + expect(zeros).toHaveLength(0); + }); + + it('el breakdown refleja el cambio de opción', () => { + const base = engine.calculate(OBJ_APARTAMENTO, baseState(), CATALOGO_VIVIENDAS); + const state = { ...baseState(), [ATTR_REVEST_BANO]: OPT_REVEST_MARMOL }; // 120 en lugar de 40 + const result = engine.calculate(OBJ_APARTAMENTO, state, CATALOGO_VIVIENDAS); + + const baseEntry = base.breakdown.find(e => e.attrId === ATTR_REVEST_BANO); + const resultEntry = result.breakdown.find(e => e.attrId === ATTR_REVEST_BANO); + + expect(baseEntry?.amount).toBe(40); + expect(resultEntry?.amount).toBe(120); + }); +}); + +// ============================================================================ +// CASOS LÍMITE +// ============================================================================ + +describe('casos límite', () => { + + it('objeto inexistente → subtotal 0 y logea error', () => { + const result = engine.calculate('ob:noexiste' as any, baseState(), CATALOGO_VIVIENDAS); + expect(result.subtotal).toBe(0); + expect(result.total).toBe(0); + expect(logr.error).toHaveBeenCalledTimes(1); + }); + + it('estado vacío → subtotal 0', () => { + const result = engine.calculate(OBJ_APARTAMENTO, {}, CATALOGO_VIVIENDAS); + expect(result.subtotal).toBe(0); + }); + + it('hasConsultation false en estado base', () => { + const result = engine.calculate(OBJ_APARTAMENTO, baseState(), CATALOGO_VIVIENDAS); + expect(result.hasConsultation).toBe(false); + }); + + it('encimera silestone suma 600', () => { + const state = { ...baseState(), [ATTR_ENCIMERA]: OPT_ENCIMERA_SILESTONE }; + const result = engine.calculate(OBJ_APARTAMENTO, state, CATALOGO_VIVIENDAS); + const entry = result.breakdown.find(e => e.attrId === ATTR_ENCIMERA); + expect(entry?.amount).toBe(600); + }); +}); \ No newline at end of file diff --git a/src/libs/vice/tests/selection-state.test.ts b/src/libs/vice/tests/selection-state.test.ts new file mode 100644 index 0000000..e17feb0 --- /dev/null +++ b/src/libs/vice/tests/selection-state.test.ts @@ -0,0 +1,304 @@ +/** + * ============================================================================ + * SELECTION STATE — TESTS + * ============================================================================ + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { SelectionState } from '../engines/selection-state.ts'; +import type { Logr } from '@/libs/logr'; +import { + CATALOGO_VIVIENDAS, + IDS_TEST, +} from './housing-catalog.fixture'; + +const { + OBJ_APARTAMENTO, + ATTR_CALIDAD, + ATTR_SUELO, + ATTR_PARED, + ATTR_SANITARIO, + ATTR_REVEST_BANO, + ATTR_ENCIMERA, + OPT_CALIDAD_ESTANDAR, + OPT_CALIDAD_PREMIUM, + OPT_CALIDAD_LUJO, + OPT_SUELO_PARQUET, + OPT_SUELO_CERAMICA, + OPT_SUELO_MICROCEMENTO, + OPT_PARED_BLANCO, + OPT_SANIT_BLANCO, + OPT_SANIT_NEGRO, + OPT_REVEST_CERAMICA, + OPT_REVEST_MARMOL, + OPT_REVEST_MICROCEMENTO, + OPT_ENCIMERA_GRANITO, + OPT_ENCIMERA_MADERA, +} = IDS_TEST; + +// ============================================================================ +// SETUP +// ============================================================================ + +function makeLogr(): Logr { + return { + debug : vi.fn(), + info : vi.fn(), + warn : vi.fn(), + error : vi.fn(), + getLogs : vi.fn(), + clear : vi.fn(), + serialize: vi.fn(), + setLevel : vi.fn(), + setMaxLogs: vi.fn(), + } as unknown as Logr; +} + +let logr : Logr; + +function makeState() { + return new SelectionState(OBJ_APARTAMENTO, CATALOGO_VIVIENDAS, makeLogr()); +} + +beforeEach(() => { + logr = makeLogr(); +}); + +// ============================================================================ +// INICIALIZACIÓN +// ============================================================================ + +describe('inicialización', () => { + + it('carga los defaultValues de todos los atributos', () => { + const state = makeState(); + // Todos los atributos dynamic tienen defaultValue en el fixture + expect(state.getValue(ATTR_CALIDAD)).toBe(OPT_CALIDAD_ESTANDAR); + expect(state.getValue(ATTR_SUELO)).toBe(OPT_SUELO_CERAMICA); + expect(state.getValue(ATTR_PARED)).toBe(OPT_PARED_BLANCO); + expect(state.getValue(ATTR_SANITARIO)).toBe(OPT_SANIT_BLANCO); + expect(state.getValue(ATTR_REVEST_BANO)).toBe(OPT_REVEST_CERAMICA); + }); + + it('el estado inicial es válido', () => { + const state = makeState(); + expect(state.isValid()).toBe(true); + }); + + it('lanza error si el objeto no existe en el catálogo', () => { + expect(() => + new SelectionState('ob:noexiste' as any, CATALOGO_VIVIENDAS, makeLogr()) + ).toThrow(); + }); +}); + +// ============================================================================ +// select — valores permitidos +// ============================================================================ + +describe('select — valores permitidos', () => { + + it('acepta un valor permitido y actualiza el estado', () => { + const state = makeState(); + const result = state.select(ATTR_SUELO, OPT_SUELO_PARQUET); + expect(result.success).toBe(true); + expect(state.getValue(ATTR_SUELO)).toBe(OPT_SUELO_PARQUET); + }); + + it('acepta calidad premium', () => { + const state = makeState(); + const result = state.select(ATTR_CALIDAD, OPT_CALIDAD_PREMIUM); + expect(result.success).toBe(true); + expect(state.getValue(ATTR_CALIDAD)).toBe(OPT_CALIDAD_PREMIUM); + }); + + it('el estado sigue siendo válido tras selección permitida', () => { + const state = makeState(); + state.select(ATTR_CALIDAD, OPT_CALIDAD_PREMIUM); + expect(state.isValid()).toBe(true); + }); + + it('getSelection devuelve copia inmutable del estado', () => { + const state = makeState(); + const snapshot = state.getSelection(); + state.select(ATTR_SUELO, OPT_SUELO_PARQUET); + // snapshot no debe haber cambiado + expect(snapshot[ATTR_SUELO]).toBe(OPT_SUELO_CERAMICA); + }); +}); + +// ============================================================================ +// select — valores prohibidos (modelo preventivo) +// ============================================================================ + +describe('select — valores prohibidos', () => { + + it('rechaza mármol con calidad estándar', () => { + const state = makeState(); // calidad = estandar por defecto + const result = state.select(ATTR_REVEST_BANO, OPT_REVEST_MARMOL); + expect(result.success).toBe(false); + }); + + it('el valor no cambia al rechazar', () => { + const state = makeState(); + state.select(ATTR_REVEST_BANO, OPT_REVEST_MARMOL); + expect(state.getValue(ATTR_REVEST_BANO)).toBe(OPT_REVEST_CERAMICA); + }); + + it('rechaza sanitario negro con calidad estándar', () => { + const state = makeState(); + const result = state.select(ATTR_SANITARIO, OPT_SANIT_NEGRO); + expect(result.success).toBe(false); + }); + + it('devuelve reason con el mensaje de la regla', () => { + const state = makeState(); + const result = state.select(ATTR_REVEST_BANO, OPT_REVEST_MARMOL); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.reason).toBeTruthy(); + expect(typeof result.reason).toBe('string'); + } + }); + + it('el estado sigue siendo válido tras selección rechazada', () => { + const state = makeState(); + state.select(ATTR_REVEST_BANO, OPT_REVEST_MARMOL); + expect(state.isValid()).toBe(true); + }); + + it('logea warning al rechazar selección', () => { + const logr = makeLogr(); + const state = new SelectionState(OBJ_APARTAMENTO, CATALOGO_VIVIENDAS, logr); + state.select(ATTR_REVEST_BANO, OPT_REVEST_MARMOL); + expect(logr.warn).toHaveBeenCalledTimes(1); + }); +}); + +// ============================================================================ +// select — permite tras cambio de contexto +// ============================================================================ + +describe('select — disponibilidad cambia con el contexto', () => { + + it('mármol permitido tras cambiar calidad a premium', () => { + const state = makeState(); + state.select(ATTR_CALIDAD, OPT_CALIDAD_PREMIUM); + const result = state.select(ATTR_REVEST_BANO, OPT_REVEST_MARMOL); + expect(result.success).toBe(true); + }); + + it('sanitario negro permitido con calidad premium', () => { + const state = makeState(); + state.select(ATTR_CALIDAD, OPT_CALIDAD_PREMIUM); + const result = state.select(ATTR_SANITARIO, OPT_SANIT_NEGRO); + expect(result.success).toBe(true); + }); + + it('microcemento en baño restringido al seleccionar suelo microcemento', () => { + const state = makeState(); + state.select(ATTR_SUELO, OPT_SUELO_MICROCEMENTO); + // mármol queda fuera de la lista allow → rechazado + const result = state.select(ATTR_REVEST_BANO, OPT_REVEST_MARMOL); + expect(result.success).toBe(false); + }); + + it('microcemento en baño permitido con suelo microcemento', () => { + const state = makeState(); + state.select(ATTR_SUELO, OPT_SUELO_MICROCEMENTO); + const result = state.select(ATTR_REVEST_BANO, OPT_REVEST_MICROCEMENTO); + expect(result.success).toBe(true); + }); +}); + +// ============================================================================ +// getAttributeState +// ============================================================================ + +describe('getAttributeState', () => { + + it('devuelve el valor actual del atributo', () => { + const state = makeState(); + const s = state.getAttributeState(ATTR_SUELO); + expect(s?.value).toBe(OPT_SUELO_CERAMICA); + }); + + it('devuelve las opciones del atributo', () => { + const state = makeState(); + const s = state.getAttributeState(ATTR_SUELO); + expect(s?.options.length).toBeGreaterThan(0); + }); + + it('marca mármol como no disponible con calidad estándar', () => { + const state = makeState(); + const s = state.getAttributeState(ATTR_REVEST_BANO); + const marmol = s?.options.find(o => o.optionId === OPT_REVEST_MARMOL); + expect(marmol?.available).toBe(false); + }); + + it('marca mármol como disponible con calidad premium', () => { + const state = makeState(); + state.select(ATTR_CALIDAD, OPT_CALIDAD_PREMIUM); + const s = state.getAttributeState(ATTR_REVEST_BANO); + const marmol = s?.options.find(o => o.optionId === OPT_REVEST_MARMOL); + expect(marmol?.available).toBe(true); + }); + + it('opción no disponible tiene reason', () => { + const state = makeState(); + const s = state.getAttributeState(ATTR_REVEST_BANO); + const marmol = s?.options.find(o => o.optionId === OPT_REVEST_MARMOL); + expect(marmol?.reason).toBeTruthy(); + }); + + it('opción disponible no tiene reason', () => { + const state = makeState(); + const s = state.getAttributeState(ATTR_REVEST_BANO); + const ceramica = s?.options.find(o => o.optionId === OPT_REVEST_CERAMICA); + expect(ceramica?.available).toBe(true); + expect(ceramica?.reason).toBeUndefined(); + }); + + it('devuelve null para atributo inexistente', () => { + const state = makeState(); + expect(state.getAttributeState('at:noexiste' as any)).toBeNull(); + }); + + it('required true cuando calidad lujo activa la regla require', () => { + const state = makeState(); + state.select(ATTR_CALIDAD, OPT_CALIDAD_LUJO); + const s = state.getAttributeState(ATTR_REVEST_BANO); + expect(s?.required).toBe(true); + }); + + it('required false sin regla de require activa', () => { + const state = makeState(); + const s = state.getAttributeState(ATTR_SUELO); + expect(s?.required).toBe(false); + }); +}); + +// ============================================================================ +// getAllAttributeStates +// ============================================================================ + +describe('getAllAttributeStates', () => { + + it('devuelve un estado por cada atributo único', () => { + const state = makeState(); + const states = state.getAllAttributeStates(); + expect(states.size).toBeGreaterThan(0); + expect(states.has(ATTR_CALIDAD)).toBe(true); + expect(states.has(ATTR_SUELO)).toBe(true); + expect(states.has(ATTR_SANITARIO)).toBe(true); + }); + + it('no hay atributos duplicados', () => { + const state = makeState(); + const states = state.getAllAttributeStates(); + // ATTR_SUELO aparece en salón, dormitorio y cocina — solo debe aparecer una vez + expect(states.has(ATTR_SUELO)).toBe(true); + expect([...states.keys()].filter(k => k === ATTR_SUELO)).toHaveLength(1); + }); +}); + diff --git a/src/libs/vice/tests/template-resolver.test.ts b/src/libs/vice/tests/template-resolver.test.ts new file mode 100644 index 0000000..5b8754b --- /dev/null +++ b/src/libs/vice/tests/template-resolver.test.ts @@ -0,0 +1,346 @@ +/** + * ============================================================================ + * TEMPLATE RESOLVER — TESTS + * ============================================================================ + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { TemplateResolver } from '../engines/template-resolver'; +import type { Logr } from '@/libs/logr'; +import type { SelectionMap } from '../engines/rule'; +import { + CATALOGO_VIVIENDAS, + IDS_TEST, +} from './housing-catalog.fixture'; + +const { + OBJ_APARTAMENTO, + SEC_SALON, + SEC_BANO, + SEC_COCINA, + VIEW_FRONT, + VIEW_360, + ATTR_CALIDAD, + ATTR_SUELO, + ATTR_PARED, + ATTR_SANITARIO, + ATTR_REVEST_BANO, + ATTR_ENCIMERA, + ATTR_MUEBLE_COC, + OPT_CALIDAD_PREMIUM, + OPT_SUELO_PARQUET, + OPT_SUELO_MICROCEMENTO, + OPT_PARED_BLANCO, + OPT_PARED_GRIS, + OPT_SANIT_BLANCO, + OPT_SANIT_NEGRO, + OPT_REVEST_CERAMICA, + OPT_REVEST_MICROCEMENTO, + OPT_ENCIMERA_GRANITO, + OPT_ENCIMERA_MADERA, + OPT_MUEBLE_BLANCO, + OPT_MUEBLE_GRIS, + OPT_PUERTA_LACADA, +} = IDS_TEST; + +// ============================================================================ +// SETUP +// ============================================================================ + +function makeLogr(): Logr { + return { + debug : vi.fn(), + info : vi.fn(), + warn : vi.fn(), + error : vi.fn(), + getLogs : vi.fn(), + clear : vi.fn(), + serialize: vi.fn(), + setLevel : vi.fn(), + setMaxLogs: vi.fn(), + } as unknown as Logr; +} + +/** Estado base válido para el apartamento */ +function baseState(): SelectionMap { + return { + [ATTR_CALIDAD] : OPT_CALIDAD_PREMIUM, + [ATTR_SUELO] : OPT_SUELO_PARQUET, + [ATTR_PARED] : OPT_PARED_BLANCO, + [ATTR_SANITARIO] : OPT_SANIT_BLANCO, + [ATTR_REVEST_BANO]: OPT_REVEST_CERAMICA, + [ATTR_ENCIMERA] : OPT_ENCIMERA_GRANITO, + [ATTR_MUEBLE_COC] : OPT_MUEBLE_BLANCO, + [IDS_TEST.ATTR_PUERTA]: OPT_PUERTA_LACADA, + }; +} + +let logr : Logr; +let resolver: TemplateResolver; + +beforeEach(() => { + logr = makeLogr(); + resolver = new TemplateResolver(logr); +}); + +// ============================================================================ +// RESOLUCIÓN BÁSICA — static_image +// ============================================================================ + +describe('static_image — resolución básica', () => { + + it('resuelve template con placeholders de contexto y atributos', () => { + const result = resolver.resolve( + { objectId: OBJ_APARTAMENTO, sectionId: SEC_SALON, viewId: VIEW_FRONT }, + baseState(), + CATALOGO_VIVIENDAS + ); + expect(result.isFallback).toBe(false); + expect(result.url).toBe('/renders/vivienda/apartamento/salon/front/parquet_blanco.jpg'); + }); + + it('cambia la URL al cambiar el suelo', () => { + const state = { ...baseState(), [ATTR_SUELO]: OPT_SUELO_MICROCEMENTO }; + const result = resolver.resolve( + { objectId: OBJ_APARTAMENTO, sectionId: SEC_SALON, viewId: VIEW_FRONT }, + state, + CATALOGO_VIVIENDAS + ); + expect(result.url).toBe('/renders/vivienda/apartamento/salon/front/microcemento_blanco.jpg'); + }); + + it('cambia la URL al cambiar la pared', () => { + const state = { ...baseState(), [ATTR_PARED]: OPT_PARED_GRIS }; + const result = resolver.resolve( + { objectId: OBJ_APARTAMENTO, sectionId: SEC_SALON, viewId: VIEW_FRONT }, + state, + CATALOGO_VIVIENDAS + ); + expect(result.url).toBe('/renders/vivienda/apartamento/salon/front/parquet_gris.jpg'); + }); + + it('resuelve la vista 360 con su propio code', () => { + const result = resolver.resolve( + { objectId: OBJ_APARTAMENTO, sectionId: SEC_SALON, viewId: VIEW_360 }, + baseState(), + CATALOGO_VIVIENDAS + ); + expect(result.url).toBe('/renders/vivienda/apartamento/salon/360/parquet_blanco.jpg'); + }); + + it('resuelve el baño — sección con distintos atributos', () => { + const result = resolver.resolve( + { objectId: OBJ_APARTAMENTO, sectionId: SEC_BANO, viewId: VIEW_FRONT }, + baseState(), + CATALOGO_VIVIENDAS + ); + expect(result.url).toBe('/renders/vivienda/apartamento/bano/front/blanco_ceramica.jpg'); + }); + + it('resuelve el baño con sanitario negro y microcemento', () => { + const state = { + ...baseState(), + [ATTR_SANITARIO] : OPT_SANIT_NEGRO, + [ATTR_REVEST_BANO]: OPT_REVEST_MICROCEMENTO, + }; + const result = resolver.resolve( + { objectId: OBJ_APARTAMENTO, sectionId: SEC_BANO, viewId: VIEW_FRONT }, + state, + CATALOGO_VIVIENDAS + ); + expect(result.url).toBe('/renders/vivienda/apartamento/bano/front/negro_microcemento.jpg'); + }); + + it('resuelve la cocina con encimera madera y muebles gris', () => { + const state = { + ...baseState(), + [ATTR_ENCIMERA] : OPT_ENCIMERA_MADERA, + [ATTR_MUEBLE_COC]: OPT_MUEBLE_GRIS, + }; + const result = resolver.resolve( + { objectId: OBJ_APARTAMENTO, sectionId: SEC_COCINA, viewId: VIEW_FRONT }, + state, + CATALOGO_VIVIENDAS + ); + expect(result.url).toBe('/renders/vivienda/apartamento/cocina/front/madera_gris.jpg'); + }); +}); + +// ============================================================================ +// PLACEHOLDERS DE CONTEXTO +// ============================================================================ + +describe('placeholders de contexto', () => { + + it('{object} se resuelve al code del objeto', () => { + const result = resolver.resolve( + { objectId: OBJ_APARTAMENTO, sectionId: SEC_SALON, viewId: VIEW_FRONT }, + baseState(), + CATALOGO_VIVIENDAS + ); + expect(result.url).toContain('apartamento'); + }); + + it('{section} se resuelve al code de la sección', () => { + const result = resolver.resolve( + { objectId: OBJ_APARTAMENTO, sectionId: SEC_SALON, viewId: VIEW_FRONT }, + baseState(), + CATALOGO_VIVIENDAS + ); + expect(result.url).toContain('salon'); + }); + + it('{view} se resuelve al code de la vista', () => { + const result = resolver.resolve( + { objectId: OBJ_APARTAMENTO, sectionId: SEC_SALON, viewId: VIEW_FRONT }, + baseState(), + CATALOGO_VIVIENDAS + ); + expect(result.url).toContain('front'); + }); + + it('la vista 360 tiene code distinto a front', () => { + const front = resolver.resolve( + { objectId: OBJ_APARTAMENTO, sectionId: SEC_SALON, viewId: VIEW_FRONT }, + baseState(), + CATALOGO_VIVIENDAS + ); + const panoramic = resolver.resolve( + { objectId: OBJ_APARTAMENTO, sectionId: SEC_SALON, viewId: VIEW_360 }, + baseState(), + CATALOGO_VIVIENDAS + ); + expect(front.url).not.toBe(panoramic.url); + expect(panoramic.url).toContain('360'); + }); +}); + +// ============================================================================ +// FALLBACK — errores de contexto +// ============================================================================ + +describe('fallback — contexto inválido', () => { + + it('objeto inexistente → isFallback true y logea error', () => { + const result = resolver.resolve( + { objectId: 'ob:noexiste' as any, sectionId: SEC_SALON, viewId: VIEW_FRONT }, + baseState(), + CATALOGO_VIVIENDAS + ); + expect(result.isFallback).toBe(true); + expect(logr.error).toHaveBeenCalledTimes(1); + }); + + it('sección inexistente → isFallback true y logea error', () => { + const result = resolver.resolve( + { objectId: OBJ_APARTAMENTO, sectionId: 'sc:noexiste' as any, viewId: VIEW_FRONT }, + baseState(), + CATALOGO_VIVIENDAS + ); + expect(result.isFallback).toBe(true); + expect(logr.error).toHaveBeenCalledTimes(1); + }); + + it('vista inexistente → isFallback true y logea error', () => { + const result = resolver.resolve( + { objectId: OBJ_APARTAMENTO, sectionId: SEC_SALON, viewId: 'vw:noexiste' as any }, + baseState(), + CATALOGO_VIVIENDAS + ); + expect(result.isFallback).toBe(true); + expect(logr.error).toHaveBeenCalledTimes(1); + }); + + it('fallback usa la fallbackImage configurada en la vista', () => { + const result = resolver.resolve( + { objectId: OBJ_APARTAMENTO, sectionId: SEC_SALON, viewId: 'vw:noexiste' as any }, + baseState(), + CATALOGO_VIVIENDAS + ); + expect(result.url).toBe('/renders/fallback.jpg'); + }); +}); + +// ============================================================================ +// FALLBACK — atributo sin valor +// ============================================================================ + +describe('fallback — atributo sin valor en estado', () => { + + it('atributo sin valor en estado → isFallback true y logea error', () => { + const state = { ...baseState() }; + delete (state as any)[ATTR_SUELO]; + + const result = resolver.resolve( + { objectId: OBJ_APARTAMENTO, sectionId: SEC_SALON, viewId: VIEW_FRONT }, + state, + CATALOGO_VIVIENDAS + ); + expect(result.isFallback).toBe(true); + expect(logr.error).toHaveBeenCalled(); + }); + + it('atributo con valor que no es OptionID válido → isFallback true', () => { + const state = { ...baseState(), [ATTR_SUELO]: 42 as any }; + + const result = resolver.resolve( + { objectId: OBJ_APARTAMENTO, sectionId: SEC_SALON, viewId: VIEW_FRONT }, + state, + CATALOGO_VIVIENDAS + ); + expect(result.isFallback).toBe(true); + expect(logr.error).toHaveBeenCalled(); + }); +}); + +// ============================================================================ +// resolveApi +// ============================================================================ + +describe('resolveApi', () => { + + it('devuelve null si la vista no es api_generated', () => { + const result = resolver.resolveApi( + { objectId: OBJ_APARTAMENTO, sectionId: SEC_SALON, viewId: VIEW_FRONT }, + baseState(), + CATALOGO_VIVIENDAS + ); + expect(result).toBeNull(); + expect(logr.warn).toHaveBeenCalledTimes(1); + }); +}); + +// ============================================================================ +// URLs únicas — no colisión entre secciones +// ============================================================================ + +describe('no colisión entre secciones', () => { + + it('salón y baño generan URLs distintas', () => { + const salon = resolver.resolve( + { objectId: OBJ_APARTAMENTO, sectionId: SEC_SALON, viewId: VIEW_FRONT }, + baseState(), + CATALOGO_VIVIENDAS + ); + const bano = resolver.resolve( + { objectId: OBJ_APARTAMENTO, sectionId: SEC_BANO, viewId: VIEW_FRONT }, + baseState(), + CATALOGO_VIVIENDAS + ); + expect(salon.url).not.toBe(bano.url); + }); + + it('salón y cocina generan URLs distintas', () => { + const salon = resolver.resolve( + { objectId: OBJ_APARTAMENTO, sectionId: SEC_SALON, viewId: VIEW_FRONT }, + baseState(), + CATALOGO_VIVIENDAS + ); + const cocina = resolver.resolve( + { objectId: OBJ_APARTAMENTO, sectionId: SEC_COCINA, viewId: VIEW_FRONT }, + baseState(), + CATALOGO_VIVIENDAS + ); + expect(salon.url).not.toBe(cocina.url); + }); +}); \ No newline at end of file diff --git a/src/libs/vice/types/api.types.ts b/src/libs/vice/types/api.ts similarity index 59% rename from src/libs/vice/types/api.types.ts rename to src/libs/vice/types/api.ts index ac97779..5e4de19 100644 --- a/src/libs/vice/types/api.types.ts +++ b/src/libs/vice/types/api.ts @@ -10,20 +10,20 @@ export type APIMethod = 'GET' | 'POST'; /** - * Configuración de llamada a una API externa. - * Usada por la estrategia de renderizado `api_generated`. + * Configuración de conexión a una API externa. + * Los parámetros a enviar se definen en cada `ApiGeneratedConfig` + * usando el sistema de placeholders compartido con los templates de imagen. */ export interface APIConfig { /** URL del endpoint */ endpoint: string; - /** Método HTTP — GET por defecto */ - method? : APIMethod; /** - * Parámetros a incluir en la llamada. - * El engine los resuelve desde el estado de configuración actual. + * Método HTTP. + * - GET → parámetros como querystring + * - POST → parámetros como JSON body + * @default 'POST' */ - params? : string[]; + method? : APIMethod; /** Timeout en milisegundos */ timeout?: number; -} - +} \ No newline at end of file diff --git a/src/libs/vice/types/attribute.types.ts b/src/libs/vice/types/attribute.ts similarity index 94% rename from src/libs/vice/types/attribute.types.ts rename to src/libs/vice/types/attribute.ts index f40d5cb..55e2240 100644 --- a/src/libs/vice/types/attribute.types.ts +++ b/src/libs/vice/types/attribute.ts @@ -4,11 +4,11 @@ * ============================================================================ */ -import type { Code, Value, DataType } from './primitives.types'; -import type { AttrID, OptionID, SectionID } from './ids.types'; +import type { Code, Value, DataType } from './primitives.ts'; +import type { AttrID, OptionID, SectionID } from './ids.ts'; import type { I18nString } from '@/libs/i18n'; -import type { Metadata } from './datas.types'; -import type { JsonLogic } from './json-logic.types'; +import type { Metadata } from './datas.ts'; +import type { JsonLogic } from './json-logic.ts'; // ============================================================================ // ATTRIBUTE DISPLAY diff --git a/src/libs/vice/types/catalog.types.ts b/src/libs/vice/types/catalog.ts similarity index 87% rename from src/libs/vice/types/catalog.types.ts rename to src/libs/vice/types/catalog.ts index 8f6197b..e686232 100644 --- a/src/libs/vice/types/catalog.types.ts +++ b/src/libs/vice/types/catalog.ts @@ -4,12 +4,12 @@ * ============================================================================ */ -import type { Code } from './primitives.types'; -import type { CatalogID, OptionID, ObjectID, RuleID } from './ids.types'; +import type { Code } from './primitives.ts'; +import type { CatalogID, OptionID, ObjectID, RuleID } from './ids.ts'; import type { I18nString } from '@/libs/i18n'; -import type { OptionDefinition } from './option.types'; -import type { ConfigurableObject } from './object.types'; -import type { ValidationRule } from './rule.types'; +import type { OptionDefinition } from './option.ts'; +import type { ConfigurableObject } from './object.ts'; +import type { ValidationRule } from './rule.ts'; /** * Catálogo de configuración. diff --git a/src/libs/vice/types/datas.types.ts b/src/libs/vice/types/datas.ts similarity index 100% rename from src/libs/vice/types/datas.types.ts rename to src/libs/vice/types/datas.ts diff --git a/src/libs/vice/types/hotspot.types.ts b/src/libs/vice/types/hotspot.ts similarity index 94% rename from src/libs/vice/types/hotspot.types.ts rename to src/libs/vice/types/hotspot.ts index ea0c0cb..d377140 100644 --- a/src/libs/vice/types/hotspot.types.ts +++ b/src/libs/vice/types/hotspot.ts @@ -4,9 +4,9 @@ * ============================================================================ */ -import type { AttrID, HotspotID } from './ids.types'; +import type { AttrID, HotspotID } from './ids.ts'; import type { I18nString } from '@/libs/i18n'; -import type { JsonLogic } from './json-logic.types'; +import type { JsonLogic } from './json-logic.ts'; /** * Punto interactivo sobre una imagen de vista. diff --git a/src/libs/vice/types/ids.types.ts b/src/libs/vice/types/ids.ts similarity index 95% rename from src/libs/vice/types/ids.types.ts rename to src/libs/vice/types/ids.ts index aafcf49..741f3de 100644 --- a/src/libs/vice/types/ids.types.ts +++ b/src/libs/vice/types/ids.ts @@ -4,7 +4,7 @@ * ============================================================================ */ -import type { ID } from './primitives.types'; +import type { ID } from './primitives.ts'; export type AttrID = ID<'at'>; export type SectionID = ID<'sc'>; diff --git a/src/libs/vice/types/index.ts b/src/libs/vice/types/index.ts index 168ea28..93f625b 100644 --- a/src/libs/vice/types/index.ts +++ b/src/libs/vice/types/index.ts @@ -1,16 +1,16 @@ -export * from './primitives.types.ts'; -export * from './ids.types.ts'; -export * from './datas.types.ts'; -export * from './api.types.ts'; -export * from './attribute.types.ts'; -export * from './hotspot.types.ts'; -export * from './json-logic.types.ts'; -export * from './option.types.ts'; -export * from './pricing.types.ts'; -export * from './rule.types.ts'; -export * from './section.types.ts'; -export * from './view.types.ts'; -export * from './object.types.ts'; -export * from './catalog.types.ts'; \ No newline at end of file +export * from './primitives.ts'; +export * from './ids.ts'; +export * from './datas.ts'; +export * from './api.ts'; +export * from './attribute.ts'; +export * from './hotspot.ts'; +export * from './json-logic.ts'; +export * from './option.ts'; +export * from './pricing.ts'; +export * from './rule.ts'; +export * from './section.ts'; +export * from './view.ts'; +export * from './object.ts'; +export * from './catalog.ts'; \ No newline at end of file diff --git a/src/libs/vice/types/json-logic.types.ts b/src/libs/vice/types/json-logic.ts similarity index 100% rename from src/libs/vice/types/json-logic.types.ts rename to src/libs/vice/types/json-logic.ts diff --git a/src/libs/vice/types/object.types.ts b/src/libs/vice/types/object.ts similarity index 87% rename from src/libs/vice/types/object.types.ts rename to src/libs/vice/types/object.ts index 4324be0..db6c788 100644 --- a/src/libs/vice/types/object.types.ts +++ b/src/libs/vice/types/object.ts @@ -4,11 +4,11 @@ * ============================================================================ */ import type { I18nString } from '@/libs/i18n'; -import type { Code } from './primitives.types'; -import type { ObjectID, SectionID } from './ids.types'; -import type { Metadata } from './datas.types'; -import type { Attribute } from './attribute.types'; -import type { Section } from './section.types'; +import type { Code } from './primitives.ts'; +import type { ObjectID, SectionID } from './ids.ts'; +import type { Metadata } from './datas.ts'; +import type { Attribute } from './attribute.ts'; +import type { Section } from './section.ts'; // ============================================================================ // CONFIGURABLE OBJECT diff --git a/src/libs/vice/types/option.types.ts b/src/libs/vice/types/option.ts similarity index 84% rename from src/libs/vice/types/option.types.ts rename to src/libs/vice/types/option.ts index 855c4f5..ac6ed8a 100644 --- a/src/libs/vice/types/option.types.ts +++ b/src/libs/vice/types/option.ts @@ -4,10 +4,10 @@ * ============================================================================ */ import type { I18nString } from '@/libs/i18n'; -import type { Code } from './primitives.types'; -import type { OptionID } from './ids.types'; -import type { Media, Metadata } from './datas.types'; -import type { Pricing } from './pricing.types'; +import type { Code } from './primitives.ts'; +import type { OptionID } from './ids.ts'; +import type { Media, Metadata } from './datas.ts'; +import type { Pricing } from './pricing.ts'; /** * Opción seleccionable para un atributo dinámico. diff --git a/src/libs/vice/types/pricing.types.ts b/src/libs/vice/types/pricing.ts similarity index 97% rename from src/libs/vice/types/pricing.types.ts rename to src/libs/vice/types/pricing.ts index aa4efbb..c8561fa 100644 --- a/src/libs/vice/types/pricing.types.ts +++ b/src/libs/vice/types/pricing.ts @@ -4,7 +4,7 @@ * ============================================================================ */ -import type { JsonLogic } from './json-logic.types'; +import type { JsonLogic } from './json-logic.ts'; import type { I18nString } from '@/libs/i18n'; /** diff --git a/src/libs/vice/types/primitives.types.ts b/src/libs/vice/types/primitives.ts similarity index 100% rename from src/libs/vice/types/primitives.types.ts rename to src/libs/vice/types/primitives.ts diff --git a/src/libs/vice/types/rule.types.ts b/src/libs/vice/types/rule.ts similarity index 97% rename from src/libs/vice/types/rule.types.ts rename to src/libs/vice/types/rule.ts index 89c0358..e6894b5 100644 --- a/src/libs/vice/types/rule.types.ts +++ b/src/libs/vice/types/rule.ts @@ -6,7 +6,7 @@ import type { AttrID, RuleID, Value } from './index'; import type { I18nString } from '@/libs/i18n'; -import type { JsonLogic } from './json-logic.types'; +import type { JsonLogic } from './json-logic.ts'; /** @@ -116,7 +116,7 @@ export interface ValidationRule { * Atributos involucrados en esta regla. * Incluye los atributos de la condición Y el targetAttr de la acción. * - * Usado por el RuleEngine para indexar reglas por atributo — + * Usado por el Rule para indexar reglas por atributo — * permite evaluar solo las reglas relevantes para un atributo * sin recorrer todas las condiciones JsonLogic. * diff --git a/src/libs/vice/types/section.types.ts b/src/libs/vice/types/section.ts similarity index 90% rename from src/libs/vice/types/section.types.ts rename to src/libs/vice/types/section.ts index ca90500..a3e94b7 100644 --- a/src/libs/vice/types/section.types.ts +++ b/src/libs/vice/types/section.ts @@ -4,13 +4,13 @@ * ============================================================================ */ import type { I18nString } from '@/libs/i18n'; -import type { Code } from './primitives.types'; -import type { AttrID, SectionID, ViewID } from './ids.types'; -import type { Metadata } from './datas.types'; -import type { Attribute } from './attribute.types'; -import type { JsonLogic } from './json-logic.types'; -import type { ViewType, ViewVisualConfig } from './view.types'; -import type { Hotspot } from './hotspot.types'; +import type { Code } from './primitives.ts'; +import type { AttrID, SectionID, ViewID } from './ids.ts'; +import type { Metadata } from './datas.ts'; +import type { Attribute } from './attribute.ts'; +import type { JsonLogic } from './json-logic.ts'; +import type { ViewType, ViewVisualConfig } from './view.ts'; +import type { Hotspot } from './hotspot.ts'; // ============================================================================ // SECTION VIEW diff --git a/src/libs/vice/types/view.types.ts b/src/libs/vice/types/view.ts similarity index 56% rename from src/libs/vice/types/view.types.ts rename to src/libs/vice/types/view.ts index ecf108a..de74184 100644 --- a/src/libs/vice/types/view.types.ts +++ b/src/libs/vice/types/view.ts @@ -4,8 +4,8 @@ * ============================================================================ */ -import type { AttrID } from './ids.types'; -import type { APIConfig } from './api.types'; +import type { AttrID } from './ids.ts'; +import type { APIConfig } from './api.ts'; // ============================================================================ // VIEW TYPE @@ -57,19 +57,40 @@ export interface StaticImageConfig { strategy: 'static_image'; /** - * Template de nombre de fichero. - * Solo contiene placeholders de atributos visualizables. - * El sistema resuelve cada {at:} al code de la opción activa. + * Template de la URL de imagen. + * Placeholders disponibles: * - * @example '{at:suelo}_{at:pintura}.png' + * Contexto (resueltos automáticamente): + * {object} → code del ConfigurableObject + * {section} → code de la VisualSection + * {view} → code de la SectionView + * + * Atributos (resueltos al code de la opción seleccionada): + * {at:} → code de la OptionDefinition activa para ese atributo + * + * @example + * '{object}/{section}/{view}/{at:suelo}_{at:pintura}.png' + * → 'vivienda/salon/frontal/madera_blanco.png' */ - template : string; + template : string; - /** Override local de basePath. Hereda del catálogo si no se especifica. */ - basePath? : string; + /** + * Atributos GLOBALES del objeto que afectan esta vista + * aunque no aparezcan en el template. + * + * Útil cuando el servidor usa atributos globales (ej: at:calidad) + * para determinar la imagen sin que aparezcan explícitamente en la URL. + * También permite a la UI saber cuándo regenerar la imagen. + * + * @example ['at:calidad'] + */ + globalAttrDependencies?: AttrID[]; + + /** Override local de basePath. Hereda en cascada si no se especifica. */ + basePath? : string; /** Imagen de fallback si la URL generada no existe */ - fallbackImage?: string; + fallbackImage? : string; } // ============================================================================ @@ -84,7 +105,13 @@ export interface CompositeLayersConfig { /** Capas en orden de renderizado (de abajo a arriba) */ layers : LayerDefinition[]; - /** Override local de basePath */ + /** + * Atributos GLOBALES del objeto que afectan esta vista. + * @example ['at:calidad'] + */ + globalAttrDependencies?: AttrID[]; + + /** Override local de basePath. Hereda en cascada si no se especifica. */ basePath? : string; /** Imagen de fallback */ @@ -93,22 +120,29 @@ export interface CompositeLayersConfig { /** * Definición de una capa de composición. - * El template funciona igual que en StaticImageConfig. + * + * El template usa los mismos placeholders que StaticImageConfig: + * {object}, {section}, {view}, {at:} + * + * @example + * // Capa de suelo + * { attrId: 'at:suelo', template: '{object}/{section}/{view}/suelo_{at:suelo}.png' } */ export interface LayerDefinition { /** Atributo que controla esta capa */ attrId : AttrID; /** - * Template del fichero de capa. - * @example 'suelo_{at:suelo}.png' + * Template de la URL de esta capa. + * Mismos placeholders que StaticImageConfig. + * @example '{object}/{section}/{view}/suelo_{at:suelo}.png' */ - template: string; + template : string; /** Orden z-index de la capa */ - zIndex? : number; + zIndex? : number; - /** Si la capa puede estar ausente (atributo no seleccionado) */ + /** Si la capa puede estar ausente cuando el atributo no tiene valor */ optional?: boolean; } @@ -118,8 +152,36 @@ export interface LayerDefinition { // ============================================================================ export interface ApiGeneratedConfig { - strategy : 'api_generated'; - apiConfig : APIConfig; + strategy : 'api_generated'; + apiConfig: APIConfig; + + /** + * Parámetros a enviar al servidor. + * Mismos placeholders que en StaticImageConfig: + * + * Contexto: + * 'object' → code del ConfigurableObject + * 'section' → code de la VisualSection + * 'view' → code de la SectionView + * + * Atributos: + * 'at:' → code de la opción seleccionada para ese atributo + * + * GET → construye querystring: ?object=vivienda§ion=salon&at_suelo=madera + * POST → construye JSON body: { "object": "vivienda", "suelo": "madera" } + * + * Si no se especifica, envía todos los atributos visualizables. + * + * @example ['object', 'section', 'view', 'at:suelo', 'at:pintura'] + */ + params? : string[]; + + /** + * Atributos GLOBALES del objeto que afectan esta vista. + * @example ['at:calidad'] + */ + globalAttrDependencies?: AttrID[]; + /** Imagen de fallback si la API falla o tarda demasiado */ fallbackImage?: string; }