From 850df4f151dae9e499057c2710c8846821d40840 Mon Sep 17 00:00:00 2001 From: dev Date: Wed, 25 Feb 2026 22:55:26 +0100 Subject: [PATCH] First Commit --- .../svelte/stores/configuration.svelte.ts | 614 ++++++++++++++---- .../svelte/tests/Configuration.svelte.test.ts | 492 ++++++++++---- src/libs/vice/engines/configuration.ts | 9 + src/libs/vice/engines/selection-state.ts | 8 +- src/libs/vice/guards/index.ts | 2 +- src/libs/vice/guards/section.ts | 25 + 6 files changed, 873 insertions(+), 277 deletions(-) create mode 100644 src/libs/vice/guards/section.ts diff --git a/src/adapters/svelte/stores/configuration.svelte.ts b/src/adapters/svelte/stores/configuration.svelte.ts index ccf58a3..7320eb2 100644 --- a/src/adapters/svelte/stores/configuration.svelte.ts +++ b/src/adapters/svelte/stores/configuration.svelte.ts @@ -3,105 +3,224 @@ * CONFIGURATION STORE — Svelte 5 (Runas) * ============================================================================ * - * Store de clase reactivo que wrappea ConfigurationEngine. - * Se instancia una vez y se distribuye por el árbol de componentes via contexto. + * Jerarquía de navegación: + * currentObject → currentSection → currentView * - * Uso típico: + * - setObject() → activa objeto, primera sección, defaultView de esa sección + * - setSection() → activa sección, defaultView de esa sección + * - setView() → activa vista dentro de la sección actual * - * // +layout.svelte — instanciar y proveer - * import { setContext } from 'svelte'; - * import { ConfigurationStore } from '$lib/vice/configuration-store.svelte'; - * - * const store = new ConfigurationStore(catalog, logr); - * store.setObject('ob:apartamento'); - * setContext('configuration', store); - * - * // ComponenteHijo.svelte — consumir - * import { getContext } from 'svelte'; - * const store = getContext('configuration'); + * Todo el estado derivado es reactivo — cualquier página de la SPA + * que lea estos campos se actualiza automáticamente ante cualquier cambio. */ +import type { I18nString } from '@/libs/i18n'; +import type { Logr } from '@/libs/logr'; import type { ConfigurationCatalog, ObjectID, OptionID, SectionID, ViewID, - AttrID -} from '@/libs/vice/types'; -import type { Logr } from '@/libs/logr'; -import { ConfigurationEngine } from '@/libs/vice/engines'; + AttrID, +} from '@/libs/vice/types'; +import { ConfigurationEngine } from '@/libs/vice/engines'; import type { ChangeEvent, - SelectionOutcome -} from '@/libs/vice/engines/configuration'; + SelectionOutcome, +} from '@/libs/vice/engines/configuration'; import type { AttributeState, - PricingResult -} from '@/libs/vice/engines'; - + PricingResult, + RuleViolation, +} from '@/libs/vice/engines'; +import { isVisualSection } from '@/libs/vice/guards'; // ============================================================================ -// TYPES +// TYPES PÚBLICOS // ============================================================================ -/** - * Estado reactivo de una vista — URL resuelta + flag de fallback. - * Se actualiza automáticamente cuando cambia una opción visual. - */ export interface ReactiveView { url : string; isFallback: boolean; } +export interface SelectionLine { + sectionId : SectionID; + sectionName: I18nString; + attrId : AttrID; + attrName : I18nString; + optionName : I18nString; + optionCode : string; + quantity : number | undefined; + unit : I18nString | undefined; + amount : number; + isDynamic : boolean; +} + +export interface SectionSummary { + sectionId : SectionID; + name : I18nString; + isComplete : boolean; + hasWarnings : boolean; + hasErrors : boolean; + view : ReactiveView | null; +} + +export interface Incident { + ruleId : string; + severity : 'error' | 'warning' | 'info'; + message : I18nString; + attrId : AttrID; + attrName : I18nString; + optionId : OptionID | undefined; + optionName: I18nString | undefined; +} + +export interface SectionPriceBreakdown { + sectionId : SectionID; + sectionName: I18nString; + lines : PriceLine[]; + subtotal : number; +} + +export interface PriceLine { + attrId : AttrID; + attrName : I18nString; + optionName: I18nString; + quantity : number | undefined; + unit : I18nString | undefined; + amount : number; + isDynamic : boolean; +} + +export interface ConfigurationProgress { + total : number; + completed: number; + percent : number; +} + // ============================================================================ // CONFIGURATION STORE // ============================================================================ export class ConfigurationStore { - // ------------------------------------------------------------------------- - // Engine interno — no reactivo, no se expone directamente - // ------------------------------------------------------------------------- + readonly #engine: ConfigurationEngine; // ------------------------------------------------------------------------- - // Estado reactivo — $state - // Svelte trackea estos campos y re-renderiza los componentes que los leen + // Navegación — jerarquía currentObject > currentSection > currentView // ------------------------------------------------------------------------- - /** ID del objeto activo */ - activeObjectId = $state(null); + currentObject = $state(null); + currentSection = $state(null); + currentView = $state(null); + + // ------------------------------------------------------------------------- + // Estado base reactivo + // ------------------------------------------------------------------------- - /** - * Mapa de estados de atributos — AttrID → AttributeState. - * Contiene: valor actual, opciones disponibles, required, quantityConfig. - * La UI renderiza directamente desde aquí. - */ attributeStates = $state>(new Map()); + price = $state(null); + isValid = $state(true); + lastRejection = $state<{ attrId: AttrID; reason: string } | null>(null); - /** - * Resultado de precios — subtotal, tax, total, breakdown. - * Se recalcula tras cada selección que affectsPrice. - */ - price = $state(null); + // ------------------------------------------------------------------------- + // Estado derivado — navegación + // ------------------------------------------------------------------------- - /** - * Mapa de vistas resueltas — `${sectionId}:${viewId}` → ReactiveView. - * Se recalcula tras cada selección que affectsVisual. - * La UI suscribe la sección que le interesa. - */ - views = $state>(new Map()); + /** Definición del objeto activo */ + currentObjectDef = $derived( + this.currentObject ? this.#catalog?.objects[this.currentObject] : null + ); - /** - * Si el estado actual es válido (sin violaciones de error). - */ - isValid = $state(true); + /** Secciones del objeto activo en orden */ + sectionOrder = $derived( + this.currentObjectDef + ? (this.currentObjectDef.sectionOrder ?? Object.keys(this.currentObjectDef.sections)) as SectionID[] + : [] + ); + + /** Definición de la sección activa */ + currentSectionDef = $derived( + this.currentObject && this.currentSection + ? this.#catalog?.objects[this.currentObject]?.sections[this.currentSection] ?? null + : null + ); + + /** Atributos de la sección activa con su estado */ + currentSectionAttributes = $derived( + this.currentSectionDef + ? (this.currentSectionDef.attributes ?? []) + .map(a => this.attributeStates.get(a.id as AttrID)) + .filter((s): s is AttributeState => s !== undefined) + : [] + ); - /** - * Última selección rechazada — para mostrar feedback al usuario. - * Se limpia automáticamente en la siguiente selección exitosa. - */ - lastRejection = $state<{ attrId: AttrID; reason: string } | null>(null); + + + + + /** Vista resuelta de la sección y vista activas */ + currentSectionView = $derived(this.#resolveCurrentView()); + + /** Vistas disponibles en la sección activa */ + currentSectionViews = $derived( + this.currentSectionDef && isVisualSection(this.currentSectionDef) + ? Object.keys(this.currentSectionDef.views) as ViewID[] + : [] + ); + + // ------------------------------------------------------------------------- + // Estado derivado — global + // ------------------------------------------------------------------------- + + selectionSummary = $derived(this.#buildSelectionSummary()); + + incidents = $derived(this.#buildIncidents()); + + errors = $derived( + this.incidents.filter(i => i.severity === 'error') + ); + + warnings = $derived( + this.incidents.filter(i => i.severity === 'warning') + ); + + hasBlockingErrors = $derived(this.errors.length > 0); + + /** Incidencias de la sección activa */ + currentSectionIncidents = $derived( + this.currentSectionDef + ? this.incidents.filter(i => + this.currentSectionDef!.attributes?.some(a => a.id === i.attrId) + ) + : [] + ); + + priceBreakdown = $derived(this.#buildPriceBreakdown()); + + sections = $derived(this.#buildSections()); + + /** Estado completo de la sección activa */ + currentSectionState = $derived( + this.currentSection + ? this.sections.find(s => s.sectionId === this.currentSection) ?? null + : null + ); + + progress = $derived(this.#buildProgress()); + + unavailableOptions = $derived(this.#buildUnavailableOptions()); + + // ------------------------------------------------------------------------- + // Getters de precio + // ------------------------------------------------------------------------- + + get subtotal() : number { return this.price?.subtotal ?? 0; } + get total() : number { return this.price?.total ?? 0; } + get taxAmount(): number { return this.price?.taxAmount ?? 0; } + get taxRate() : number { return this.price?.tax?.rate ?? 0; } // ------------------------------------------------------------------------- // Constructor @@ -109,154 +228,365 @@ export class ConfigurationStore { constructor(catalog: ConfigurationCatalog, logr: Logr) { this.#engine = new ConfigurationEngine(catalog, logr); - - // Suscribirse a eventos del engine — actualizar estado reactivo this.#engine.onChange((event) => this.#handleChange(event)); } // ------------------------------------------------------------------------- - // API pública + // Navegación — API pública // ------------------------------------------------------------------------- /** - * Activa un objeto del catálogo e inicializa el estado reactivo. + * Activa un objeto. + * Selecciona automáticamente la primera sección y su defaultView. */ setObject(objectId: ObjectID): void { this.#engine.setActiveObject(objectId); - this.activeObjectId = objectId; + this.currentObject = objectId; this.#syncAll(); + + // Activar primera sección + const obj = this.#catalog?.objects[objectId]; + const firstSectionId = (obj?.sectionOrder?.[0] ?? Object.keys(obj?.sections ?? {})[0]) as SectionID | undefined; + if (firstSectionId) this.#activateSection(firstSectionId); + } + + /** + * Activa una sección del objeto actual. + * Activa automáticamente el defaultView de esa sección. + */ + setSection(sectionId: SectionID): void { + if (!this.currentObject) return; + const section = this.#catalog?.objects[this.currentObject]?.sections[sectionId]; + if (!section) return; + this.#activateSection(sectionId); + } + + /** + * ¿Es la sección actual de tipo visual? + */ + get isCurrentSectionVisual(): boolean { + if (!this.currentSectionDef) return false; + return isVisualSection(this.currentSectionDef); } /** - * Selecciona una opción para un atributo. - * Actualiza el estado reactivo si tiene éxito. + * Activa una vista dentro de la sección actual. */ + setView(viewId: ViewID): void { + if (!this.currentSectionDef) return; + if (!isVisualSection(this.currentSectionDef)) return; + if (!this.currentSectionDef.views?.[viewId]) return; + this.currentView = viewId; + } + + // ------------------------------------------------------------------------- + // Selección — API pública + // ------------------------------------------------------------------------- + select(attrId: AttrID, optionId: OptionID, quantity?: number): SelectionOutcome { const result = this.#engine.select(attrId, optionId, quantity); - if (result.success) { this.lastRejection = null; } else { this.lastRejection = { attrId, reason: result.reason ?? '' }; } - return result; } + // ------------------------------------------------------------------------- + // Helpers de consulta + // ------------------------------------------------------------------------- + + getAttributeState(attrId: AttrID): AttributeState | null { + return this.attributeStates.get(attrId) ?? null; + } + + getSectionAttributes(sectionId: SectionID): AttributeState[] { + if (!this.currentObject) return []; + const section = this.#catalog?.objects[this.currentObject]?.sections[sectionId]; + if (!section) return []; + return (section.attributes ?? []) + .map(a => this.attributeStates.get(a.id as AttrID)) + .filter((s): s is AttributeState => s !== undefined); + } + + getUnavailableReason(attrId: AttrID, optionId: OptionID): string | null { + const opt = this.attributeStates.get(attrId)?.options.find(o => o.optionId === optionId); + if (!opt || opt.available) return null; + return opt.reason ?? null; + } + + isOptionAvailable(attrId: AttrID, optionId: OptionID): boolean { + return this.attributeStates.get(attrId) + ?.options.find(o => o.optionId === optionId)?.available ?? true; + } + + isRequired(attrId: AttrID): boolean { + return this.attributeStates.get(attrId)?.required ?? false; + } + /** - * Registra una vista para seguimiento reactivo. - * La UI llama a esto al montar una sección — la vista se mantiene actualizada. - * Devuelve función de cleanup para llamar en onDestroy. + * Navega a la siguiente sección en el orden del objeto activo. + * No hace nada si ya está en la última. */ - trackView(sectionId: SectionID, viewId: ViewID): () => void { - const key = this.#viewKey(sectionId, viewId); - this.#resolveView(sectionId, viewId); - - // Cleanup — eliminar la vista del mapa al desmontar la sección - return () => { - this.views.delete(key); - }; + nextSection(): void { + const idx = this.sectionOrder.indexOf(this.currentSection!); + if (idx === -1 || idx === this.sectionOrder.length - 1) return; + this.setSection(this.sectionOrder[idx + 1]); } /** - * Devuelve el estado de un atributo — usado para acceso directo. - * Normalmente la UI lee de `attributeStates` directamente. + * Navega a la sección anterior. + * No hace nada si ya está en la primera. */ - getAttributeState(attrId: AttrID): AttributeState | null { - return this.attributeStates.get(attrId) ?? null; + prevSection(): void { + const idx = this.sectionOrder.indexOf(this.currentSection!); + if (idx <= 0) return; + this.setSection(this.sectionOrder[idx - 1]); + } + + /** ¿Hay sección siguiente? */ + get hasNextSection(): boolean { + const idx = this.sectionOrder.indexOf(this.currentSection!); + return idx !== -1 && idx < this.sectionOrder.length - 1; + } + + /** ¿Hay sección anterior? */ + get hasPrevSection(): boolean { + const idx = this.sectionOrder.indexOf(this.currentSection!); + return idx > 0; } // ------------------------------------------------------------------------- - // Derived — valores calculados desde el estado reactivo + // Builders de estado derivado — privado // ------------------------------------------------------------------------- - /** - * Subtotal formateado para mostrar en UI. - * Actualización automática cuando cambia `price`. - */ - get subtotal(): number { - return this.price?.subtotal ?? 0; + #resolveCurrentView(): ReactiveView { + if (!this.currentObject || !this.currentSection || !this.currentView) { + return { url: '', isFallback: true }; + } + if (!this.currentSectionDef || !isVisualSection(this.currentSectionDef)) { + return { url: '', isFallback: true }; + } + const result = this.#engine.resolveView(this.currentSection, this.currentView); + return { url: result.url, isFallback: result.isFallback }; + } + + #buildSelectionSummary(): SelectionLine[] { + if (!this.currentObject || !this.#catalog) return []; + + const obj = this.#catalog.objects[this.currentObject]; + const options = this.#catalog.options; + const lines: SelectionLine[] = []; + + // Atributos globales + for (const attr of obj.attributes ?? []) { + if (attr.type !== 'dynamic') continue; + const line = this.#buildLine(attr, { es: 'General', en: 'General' }, '' as SectionID, options); + if (line) lines.push(line); + } + + // Atributos por sección + for (const sectionId of this.sectionOrder) { + const section = obj.sections[sectionId]; + for (const attr of section.attributes ?? []) { + if (attr.type !== 'dynamic') continue; + const line = this.#buildLine(attr, section.name, sectionId, options); + if (line) lines.push(line); + } + } + + return lines; + } + + #buildLine(attr: any, sectionName: I18nString, sectionId: SectionID, options: any): SelectionLine | null { + const attrState = this.attributeStates.get(attr.id as AttrID); + if (!attrState) return null; + const value = attrState.value as any; + const optionId = value?.optionId as OptionID; + if (!optionId) return null; + const optDef = options[optionId]; + if (!optDef) return null; + const entry = this.price?.breakdown.find(b => b.attrId === attr.id); + const attrOption = attr.options?.find((o: any) => o.optionId === optionId); + return { + sectionId, + sectionName, + attrId : attr.id as AttrID, + attrName : attr.name, + optionName: optDef.name, + optionCode: optDef.code, + quantity : value?.quantity, + unit : attrOption?.quantity?.unit, + amount : entry?.amount ?? 0, + isDynamic : entry?.isDynamic ?? false, + }; + } + + #buildIncidents(): Incident[] { + if (!this.currentObject || !this.#catalog) return []; + + const violations = this.#engine.getViolations() ?? []; + const options = this.#catalog.options; + + return violations.map((v: RuleViolation) => { + const attrState = this.attributeStates.get(v.rule.action.targetAttr as AttrID); + const value = attrState?.value as any; + const optionId = value?.optionId as OptionID | undefined; + const optDef = optionId ? options[optionId] : undefined; + return { + ruleId : v.rule.id, + severity : v.severity, + message : v.rule.message ?? { es: '', en: '' }, + attrId : v.rule.action.targetAttr as AttrID, + attrName : attrState?.value ? { es: '', en: '' } : { es: '', en: '' }, + optionId, + optionName: optDef?.name, + }; + }).sort((a, b) => { + const order = { error: 0, warning: 1, info: 2 }; + return order[a.severity] - order[b.severity]; + }); } - get total(): number { - return this.price?.total ?? 0; + #buildPriceBreakdown(): SectionPriceBreakdown[] { + if (!this.currentObject || !this.#catalog || !this.price) return []; + + const obj = this.#catalog.objects[this.currentObject]; + const options = this.#catalog.options; + const result: SectionPriceBreakdown[] = []; + + for (const sectionId of this.sectionOrder) { + const section = obj.sections[sectionId]; + const lines: PriceLine[] = []; + + for (const attr of section.attributes ?? []) { + const entry = this.price.breakdown.find(b => b.attrId === attr.id); + if (!entry || entry.amount === 0) continue; + const attrState = this.attributeStates.get(attr.id as AttrID); + const value = attrState?.value as any; + const optionId = value?.optionId as OptionID; + const optDef = options[optionId]; + const attrOption = (attr as any).options?.find((o: any) => o.optionId === optionId); + lines.push({ + attrId : attr.id as AttrID, + attrName : attr.name, + optionName: optDef?.name ?? { es: optionId, en: optionId }, + quantity : value?.quantity, + unit : attrOption?.quantity?.unit, + amount : entry.amount, + isDynamic : entry.isDynamic, + }); + } + + if (lines.length === 0) continue; + result.push({ + sectionId : sectionId, + sectionName: section.name, + lines, + subtotal : lines.reduce((acc, l) => acc + l.amount, 0), + }); + } + + return result; } - get taxAmount(): number { - return this.price?.taxAmount ?? 0; + #buildSections(): SectionSummary[] { + if (!this.currentObject || !this.#catalog) return []; + const obj = this.#catalog.objects[this.currentObject]; + + return this.sectionOrder.map(sectionId => { + const section = obj.sections[sectionId]; + const incidents = this.incidents.filter(i => + section.attributes?.some(a => a.id === i.attrId) + ); + const isComplete = (section.attributes ?? []) + .filter(a => this.attributeStates.get(a.id as AttrID)?.required) + .every(a => { + const val = this.attributeStates.get(a.id as AttrID)?.value; + return val !== undefined && val !== null; + }); + + // Vista resuelta si esta sección es la activa + const isVisual = isVisualSection(section); + const view = isVisual && this.currentSection === sectionId && this.currentView + ? this.#resolveCurrentView() + : null; + + return { + sectionId, + name : section.name, + isComplete, + hasWarnings: incidents.some(i => i.severity === 'warning'), + hasErrors : incidents.some(i => i.severity === 'error'), + view, + }; + }); + } + + #buildProgress(): ConfigurationProgress { + let total = 0, completed = 0; + for (const [, attrState] of this.attributeStates) { + if (!attrState.required) continue; + total++; + const val = attrState.value; + if (val !== undefined && val !== null) completed++; + } + return { + total, + completed, + percent: total === 0 ? 100 : Math.round((completed / total) * 100), + }; + } + + #buildUnavailableOptions(): Map> { + const result = new Map>(); + for (const [attrId, attrState] of this.attributeStates) { + const blocked = attrState.options + .filter(o => !o.available && o.reason) + .map(o => ({ optionId: o.optionId as OptionID, reason: o.reason! })); + if (blocked.length > 0) result.set(attrId, blocked); + } + return result; } // ------------------------------------------------------------------------- - // Gestión de cambios — privado + // Helpers privados // ------------------------------------------------------------------------- - /** - * Manejador de eventos del engine. - * Actualiza solo el estado reactivo necesario según el tipo de cambio. - */ + #activateSection(sectionId: SectionID): void { + this.currentSection = sectionId; + const section = this.#catalog?.objects[this.currentObject!]?.sections[sectionId]; + this.currentView = section && isVisualSection(section) + ? section.defaultView as ViewID + : null; + } + #handleChange(event: ChangeEvent): void { if (event.type === 'object') { - // Cambio de objeto — sincronizar todo this.#syncAll(); return; } - - // Cambio de selección — actualizar selectivamente this.#syncAttributeStates(); - - if (event.affectsPrice) { - this.#syncPrice(); - } - - if (event.affectsVisual) { - this.#syncViews(); - } - + if (event.affectsPrice) this.#syncPrice(); this.isValid = this.#engine.isValid(); } - /** - * Sincroniza todo el estado reactivo desde el engine. - * Se usa al inicializar o cambiar de objeto. - */ #syncAll(): void { this.#syncAttributeStates(); this.#syncPrice(); - this.#syncViews(); this.isValid = this.#engine.isValid(); } #syncAttributeStates(): void { - const newStates = this.#engine.getAllAttributeStates(); - this.attributeStates.clear(); - for (const [attrId, state] of newStates) { - this.attributeStates.set(attrId, state); - } + this.attributeStates = this.#engine.getAllAttributeStates(); } #syncPrice(): void { this.price = this.#engine.getPrice(); } - /** - * Re-resuelve todas las vistas registradas. - * Solo actualiza las que la UI ha registrado con trackView(). - */ - #syncViews(): void { - for (const key of this.views.keys()) { - const [sectionId, viewId] = key.split(':') as [SectionID, ViewID]; - const result = this.#engine.resolveView(sectionId, viewId); - this.views.set(key, { url: result.url, isFallback: result.isFallback }); - } - } - - #resolveView(sectionId: SectionID, viewId: ViewID): void { - const key = this.#viewKey(sectionId, viewId); - const result = this.#engine.resolveView(sectionId, viewId); - this.views.set(key, { url: result.url, isFallback: result.isFallback }); - } - - #viewKey(sectionId: SectionID, viewId: ViewID): string { - return `${sectionId}:${viewId}`; + get #catalog(): ConfigurationCatalog { + return (this.#engine as any).catalog; } } \ No newline at end of file diff --git a/src/adapters/svelte/tests/Configuration.svelte.test.ts b/src/adapters/svelte/tests/Configuration.svelte.test.ts index 2ec3b0d..a051e9c 100644 --- a/src/adapters/svelte/tests/Configuration.svelte.test.ts +++ b/src/adapters/svelte/tests/Configuration.svelte.test.ts @@ -2,14 +2,10 @@ * ============================================================================ * CONFIGURATION STORE — TESTS (Svelte 5 / Runas) * ============================================================================ - * - * Los tests instancian el store directamente. - * $state fuera del compilador de Svelte se comporta como propiedad normal. - * La lógica del store se verifica sin necesitar DOM ni contexto de Svelte. */ import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { ConfigurationStore } from '../stores/configuration.svelte.ts'; +import { ConfigurationStore } from '../stores/configuration.svelte'; import type { Logr } from '@/libs/logr'; import { CATALOGO_VIVIENDAS, @@ -37,6 +33,7 @@ const { OPT_ARMARIO_MADERA, SEC_SALON, SEC_BANO, + SEC_ARMARIO, VIEW_FRONT, VIEW_360, } = IDS_TEST; @@ -71,44 +68,194 @@ beforeEach(() => { }); // ============================================================================ -// setObject +// setObject — navegación // ============================================================================ describe('setObject', () => { - it('actualiza activeObjectId', () => { - expect(store.activeObjectId).toBe(OBJ_APARTAMENTO); + it('establece currentObject', () => { + expect(store.currentObject).toBe(OBJ_APARTAMENTO); + }); + + it('activa automáticamente la primera sección', () => { + expect(store.currentSection).not.toBeNull(); + expect(store.currentSection).toBe(SEC_SALON); + }); + + it('activa automáticamente el defaultView de la primera sección', () => { + expect(store.currentView).not.toBeNull(); + expect(store.currentView).toBe(VIEW_FRONT); }); it('inicializa attributeStates', () => { expect(store.attributeStates.size).toBeGreaterThan(0); - expect(store.attributeStates.has(ATTR_CALIDAD)).toBe(true); - expect(store.attributeStates.has(ATTR_SUELO)).toBe(true); }); it('inicializa price', () => { expect(store.price).not.toBeNull(); - expect(typeof store.price?.subtotal).toBe('number'); + expect(typeof store.subtotal).toBe('number'); }); - it('cambia activeObjectId al cambiar de objeto', () => { + it('cambia currentObject al cambiar de objeto', () => { store.setObject(OBJ_DUPLEX); - expect(store.activeObjectId).toBe(OBJ_DUPLEX); + expect(store.currentObject).toBe(OBJ_DUPLEX); }); - it('preserva el estado al volver al objeto anterior', () => { + it('resetea currentSection al cambiar de objeto', () => { + store.setSection(SEC_BANO); + store.setObject(OBJ_DUPLEX); + // Dúplex tiene su propio sectionOrder — la primera será la del dúplex + expect(store.currentSection).not.toBe(SEC_BANO); + }); + + it('preserva el estado de selección al volver al objeto anterior', () => { store.select(ATTR_SUELO, OPT_SUELO_PARQUET); store.setObject(OBJ_DUPLEX); store.setObject(OBJ_APARTAMENTO); const attrState = store.attributeStates.get(ATTR_SUELO); expect((attrState?.value as any).optionId).toBe(OPT_SUELO_PARQUET); }); +}); + +// ============================================================================ +// setSection +// ============================================================================ + +describe('setSection', () => { + + it('actualiza currentSection', () => { + store.setSection(SEC_BANO); + expect(store.currentSection).toBe(SEC_BANO); + }); + + it('activa el defaultView de la sección', () => { + store.setSection(SEC_BANO); + expect(store.currentView).toBe(VIEW_FRONT); + }); + + it('actualiza currentSectionDef', () => { + store.setSection(SEC_BANO); + expect(store.currentSectionDef).not.toBeNull(); + expect(store.currentSectionDef?.id).toBe(SEC_BANO); + }); - it('attributeStates del dúplex son independientes del apartamento', () => { + it('actualiza currentSectionAttributes', () => { + store.setSection(SEC_BANO); + expect(store.currentSectionAttributes.length).toBeGreaterThan(0); + }); + + it('no hace nada si la sección no existe', () => { + store.setSection(SEC_SALON); + store.setSection('sc:noexiste' as any); + expect(store.currentSection).toBe(SEC_SALON); + }); + + it('no hace nada si no hay objeto activo', () => { + const s = makeStore(); + s.setSection(SEC_SALON); + expect(s.currentSection).toBeNull(); + }); +}); + +// ============================================================================ +// setView +// ============================================================================ + +describe('setView', () => { + + it('actualiza currentView', () => { + store.setSection(SEC_SALON); + store.setView(VIEW_FRONT); + expect(store.currentView).toBe(VIEW_FRONT); + }); + + it('no hace nada si la vista no existe en la sección', () => { + store.setSection(SEC_SALON); + store.setView('vw:noexiste' as any); + expect(store.currentView).toBe(VIEW_FRONT); + }); + + it('no hace nada si no hay sección activa', () => { + const s = makeStore(); + s.setView(VIEW_FRONT); + expect(s.currentView).toBeNull(); + }); +}); + +// ============================================================================ +// nextSection / prevSection +// ============================================================================ + +describe('nextSection / prevSection', () => { + + it('nextSection avanza a la siguiente sección', () => { + expect(store.currentSection).toBe(SEC_SALON); + store.nextSection(); + expect(store.currentSection).not.toBe(SEC_SALON); + }); + + it('prevSection retrocede a la sección anterior', () => { + store.nextSection(); + const second = store.currentSection; + store.prevSection(); + expect(store.currentSection).toBe(SEC_SALON); + }); + + it('nextSection no hace nada en la última sección', () => { + const last = store.sectionOrder[store.sectionOrder.length - 1]; + store.setSection(last); + store.nextSection(); + expect(store.currentSection).toBe(last); + }); + + it('prevSection no hace nada en la primera sección', () => { + store.prevSection(); + expect(store.currentSection).toBe(SEC_SALON); + }); + + it('hasNextSection true cuando no es la última', () => { + expect(store.hasNextSection).toBe(true); + }); + + it('hasPrevSection false en la primera sección', () => { + expect(store.hasPrevSection).toBe(false); + }); + + it('hasPrevSection true tras nextSection', () => { + store.nextSection(); + expect(store.hasPrevSection).toBe(true); + }); +}); + +// ============================================================================ +// currentSectionView +// ============================================================================ + +describe('currentSectionView', () => { + + it('devuelve URL no vacía para sección y vista válidas', () => { + expect(store.currentSectionView.url.length).toBeGreaterThan(0); + }); + + it('isFallback false para sección válida', () => { + expect(store.currentSectionView.isFallback).toBe(false); + }); + + it('la URL cambia al cambiar una opción visual', () => { + const before = store.currentSectionView.url; store.select(ATTR_SUELO, OPT_SUELO_PARQUET); - store.setObject(OBJ_DUPLEX); - const attrState = store.attributeStates.get(ATTR_SUELO); - expect((attrState?.value as any).optionId).not.toBe(OPT_SUELO_PARQUET); + expect(store.currentSectionView.url).not.toBe(before); + }); + + it('la URL cambia al cambiar de sección', () => { + const before = store.currentSectionView.url; + store.setSection(SEC_BANO); + expect(store.currentSectionView.url).not.toBe(before); + }); + + it('isFallback true sin objeto activo', () => { + const s = makeStore(); + expect(s.currentSectionView.isFallback).toBe(true); }); }); @@ -118,45 +265,30 @@ describe('setObject', () => { describe('select', () => { - it('acepta selección válida y devuelve success: true', () => { + it('acepta selección válida', () => { const result = store.select(ATTR_SUELO, OPT_SUELO_PARQUET); expect(result.success).toBe(true); }); - it('actualiza attributeStates tras selección válida', () => { - store.select(ATTR_SUELO, OPT_SUELO_PARQUET); - const attrState = store.attributeStates.get(ATTR_SUELO); - expect((attrState?.value as any).optionId).toBe(OPT_SUELO_PARQUET); - }); - - it('rechaza selección prohibida y devuelve success: false', () => { + it('rechaza selección prohibida', () => { const result = store.select(ATTR_REVEST_BANO, OPT_REVEST_MARMOL); expect(result.success).toBe(false); }); it('establece lastRejection al rechazar', () => { store.select(ATTR_REVEST_BANO, OPT_REVEST_MARMOL); - expect(store.lastRejection).not.toBeNull(); expect(store.lastRejection?.attrId).toBe(ATTR_REVEST_BANO); expect(store.lastRejection?.reason).toBeTruthy(); }); - it('limpia lastRejection tras selección exitosa', () => { + it('limpia lastRejection tras éxito', () => { store.select(ATTR_REVEST_BANO, OPT_REVEST_MARMOL); store.select(ATTR_SUELO, OPT_SUELO_PARQUET); expect(store.lastRejection).toBeNull(); }); - it('attributeStates no cambia tras selección rechazada', () => { - const before = store.attributeStates.get(ATTR_REVEST_BANO)?.value; - store.select(ATTR_REVEST_BANO, OPT_REVEST_MARMOL); - const after = store.attributeStates.get(ATTR_REVEST_BANO)?.value; - expect(after).toEqual(before); - }); - it('acepta selección con cantidad', () => { - const result = store.select(ATTR_ARMARIO_PUERTAS, OPT_ARMARIO_LACADO, 3); - expect(result.success).toBe(true); + store.select(ATTR_ARMARIO_PUERTAS, OPT_ARMARIO_LACADO, 3); const attrState = store.attributeStates.get(ATTR_ARMARIO_PUERTAS); expect((attrState?.value as any).quantity).toBe(3); }); @@ -168,166 +300,260 @@ describe('select', () => { describe('price', () => { - it('subtotal es un número', () => { - expect(typeof store.subtotal).toBe('number'); + it('subtotal es mayor que 0', () => { expect(store.subtotal).toBeGreaterThan(0); }); - it('total es un número', () => { - expect(typeof store.total).toBe('number'); + it('total >= subtotal', () => { + expect(store.total).toBeGreaterThanOrEqual(store.subtotal); }); - it('price se actualiza tras selección que affectsPrice', () => { - const before = store.subtotal; - store.select(ATTR_CALIDAD, OPT_CALIDAD_PREMIUM); - expect(store.subtotal).toBeGreaterThan(before); + it('subtotal + taxAmount ≈ total', () => { + expect(store.subtotal + store.taxAmount).toBeCloseTo(store.total, 2); }); - it('price no cambia tras selección que no affectsPrice', () => { - // ATTR_PARED tiene affectsPrice: false (precio 0 en todas sus opciones pero - // el flag affectsPrice es true en el fixture — verificamos que el subtotal no cambia) + it('se actualiza tras selección con precio', () => { const before = store.subtotal; - store.select(ATTR_PARED, IDS_TEST.OPT_PARED_GRIS); - // pared gris tiene baseAmount: 0 igual que blanco → subtotal igual - expect(store.subtotal).toBe(before); + store.select(ATTR_CALIDAD, OPT_CALIDAD_PREMIUM); + expect(store.subtotal).toBeGreaterThan(before); }); - it('price con armario 3 puertas lacadas suma 900', () => { + it('armario 3 puertas lacadas suma 200 más que 2', () => { const base = store.subtotal; store.select(ATTR_ARMARIO_PUERTAS, OPT_ARMARIO_LACADO, 3); - // 2 puertas → 700, 3 puertas → 900 → diferencia 200 expect(store.subtotal - base).toBe(200); }); +}); - it('getters subtotal/total/taxAmount son consistentes', () => { - expect(store.total).toBeGreaterThanOrEqual(store.subtotal); - expect(store.taxAmount).toBeGreaterThanOrEqual(0); - expect(store.subtotal + store.taxAmount).toBeCloseTo(store.total, 2); +// ============================================================================ +// selectionSummary +// ============================================================================ + +describe('selectionSummary', () => { + + it('devuelve líneas para atributos con valor', () => { + expect(store.selectionSummary.length).toBeGreaterThan(0); + }); + + it('cada línea tiene attrId, optionName y amount', () => { + for (const line of store.selectionSummary) { + expect(line.attrId).toBeTruthy(); + expect(line.optionName).toBeDefined(); + expect(typeof line.amount).toBe('number'); + } + }); + + it('se actualiza al cambiar selección', () => { + const before = store.selectionSummary.find(l => l.attrId === ATTR_SUELO)?.optionCode; + store.select(ATTR_SUELO, OPT_SUELO_PARQUET); + const after = store.selectionSummary.find(l => l.attrId === ATTR_SUELO)?.optionCode; + expect(after).not.toBe(before); + }); + + it('cada línea de summary tiene amount del breakdown', () => { + // selectionSummary puede tener atributos compartidos entre secciones + // (at:suelo aparece en salon, dormitorio y cocina) — el precio se deduplica + // en el breakdown pero selectionSummary muestra una línea por sección + for (const line of store.selectionSummary) { + expect(typeof line.amount).toBe('number'); + expect(line.amount).toBeGreaterThanOrEqual(0); + } + }); + + it('línea de armario incluye quantity y unit', () => { + const line = store.selectionSummary.find(l => l.attrId === ATTR_ARMARIO_PUERTAS); + expect(line?.quantity).toBeDefined(); + expect(line?.unit).toBeDefined(); }); }); // ============================================================================ -// attributeStates — disponibilidad de opciones +// incidents / errors / warnings // ============================================================================ -describe('attributeStates — disponibilidad', () => { +describe('incidents', () => { - it('mármol no disponible con calidad estándar', () => { - const s = store.attributeStates.get(ATTR_REVEST_BANO); - const marmol = s?.options.find(o => o.optionId === OPT_REVEST_MARMOL); - expect(marmol?.available).toBe(false); + it('sin violaciones en estado inicial', () => { + expect(store.incidents.length).toBe(0); + expect(store.hasBlockingErrors).toBe(false); }); - it('mármol disponible tras cambiar a premium', () => { - store.select(ATTR_CALIDAD, OPT_CALIDAD_PREMIUM); - const s = store.attributeStates.get(ATTR_REVEST_BANO); - const marmol = s?.options.find(o => o.optionId === OPT_REVEST_MARMOL); - expect(marmol?.available).toBe(true); + it('aparece warning al seleccionar lujo sin mármol', () => { + store.select(ATTR_CALIDAD, OPT_CALIDAD_LUJO); + expect(store.warnings.length).toBeGreaterThan(0); }); - it('opción no disponible tiene reason para tooltip', () => { - const s = store.attributeStates.get(ATTR_REVEST_BANO); - const marmol = s?.options.find(o => o.optionId === OPT_REVEST_MARMOL); - expect(marmol?.reason).toBeTruthy(); + it('cada incident tiene ruleId, severity y message', () => { + store.select(ATTR_CALIDAD, OPT_CALIDAD_LUJO); + for (const i of store.incidents) { + expect(i.ruleId).toBeTruthy(); + expect(i.severity).toMatch(/^(error|warning|info)$/); + expect(i.message).toBeDefined(); + } }); - it('sanitario negro no disponible con calidad estándar', () => { - const s = store.attributeStates.get(ATTR_SANITARIO); - const negro = s?.options.find(o => o.optionId === OPT_SANIT_NEGRO); - expect(negro?.available).toBe(false); + it('incidents ordenados — errors antes que warnings', () => { + store.select(ATTR_CALIDAD, OPT_CALIDAD_LUJO); + const severities = store.incidents.map(i => i.severity); + const errorIdx = severities.lastIndexOf('error'); + const warnIdx = severities.indexOf('warning'); + if (errorIdx !== -1 && warnIdx !== -1) { + expect(errorIdx).toBeLessThan(warnIdx); + } }); - it('quantityConfig disponible para opción cuantificable', () => { - const s = store.attributeStates.get(ATTR_ARMARIO_PUERTAS); - const lacado = s?.options.find(o => o.optionId === OPT_ARMARIO_LACADO); - expect(lacado?.quantityConfig).toBeDefined(); - expect(lacado?.quantityConfig?.min).toBe(2); - expect(lacado?.quantityConfig?.max).toBe(3); + it('currentSectionIncidents filtra por sección activa', () => { + store.select(ATTR_CALIDAD, OPT_CALIDAD_LUJO); + store.setSection(SEC_BANO); + // El warning de lujo→mármol afecta al baño + expect(store.currentSectionIncidents.length).toBeGreaterThan(0); + }); + + it('currentSectionIncidents vacío en sección sin incidencias', () => { + store.select(ATTR_CALIDAD, OPT_CALIDAD_LUJO); + store.setSection(SEC_SALON); + // El warning de lujo→mármol no afecta al salón + expect(store.currentSectionIncidents.length).toBe(0); }); }); // ============================================================================ -// isValid +// priceBreakdown // ============================================================================ -describe('isValid', () => { +describe('priceBreakdown', () => { - it('estado inicial es válido', () => { - expect(store.isValid).toBe(true); + it('devuelve secciones con coste', () => { + expect(store.priceBreakdown.length).toBeGreaterThan(0); }); - it('sigue válido tras selección permitida', () => { - store.select(ATTR_CALIDAD, OPT_CALIDAD_PREMIUM); - expect(store.isValid).toBe(true); + it('subtotal de sección = suma de líneas', () => { + for (const s of store.priceBreakdown) { + const sum = s.lines.reduce((acc, l) => acc + l.amount, 0); + expect(sum).toBeCloseTo(s.subtotal, 2); + } + }); + + it('se actualiza al cambiar precio de atributo de sección', () => { + // ATTR_CALIDAD es global — no aparece en secciones + // Usamos ATTR_SUELO que sí está en secciones + const before = store.priceBreakdown.reduce((acc, s) => acc + s.subtotal, 0); + store.select(ATTR_SUELO, OPT_SUELO_PARQUET); // parquet = 45€ vs cerámica = 30€ + const after = store.priceBreakdown.reduce((acc, s) => acc + s.subtotal, 0); + expect(after).toBeGreaterThan(before); }); }); // ============================================================================ -// trackView +// sections // ============================================================================ -describe('trackView', () => { +describe('sections', () => { - it('registra la vista y la resuelve', () => { - store.trackView(SEC_SALON, VIEW_FRONT); - const key = `${SEC_SALON}:${VIEW_FRONT}`; - const view = store.views.get(key); - expect(view).toBeDefined(); - expect(typeof view?.url).toBe('string'); - expect(view?.url.length).toBeGreaterThan(0); + it('devuelve las secciones del objeto activo en orden', () => { + expect(store.sections.length).toBe(store.sectionOrder.length); + expect(store.sections[0].sectionId).toBe(store.sectionOrder[0]); }); - it('vista no es fallback para sección válida', () => { - store.trackView(SEC_SALON, VIEW_FRONT); - const view = store.views.get(`${SEC_SALON}:${VIEW_FRONT}`); - expect(view?.isFallback).toBe(false); + it('sección con warning muestra hasWarnings true', () => { + store.select(ATTR_CALIDAD, OPT_CALIDAD_LUJO); + const bano = store.sections.find(s => s.sectionId === SEC_BANO); + expect(bano?.hasWarnings).toBe(true); }); - it('la URL cambia al cambiar una opción visual', () => { - store.trackView(SEC_SALON, VIEW_FRONT); - const before = store.views.get(`${SEC_SALON}:${VIEW_FRONT}`)?.url; - store.select(ATTR_SUELO, OPT_SUELO_PARQUET); - const after = store.views.get(`${SEC_SALON}:${VIEW_FRONT}`)?.url; - console.log('before:', before); - console.log('after:', after); - expect(after).not.toBe(before); + it('sección activa tiene view resuelta', () => { + const salon = store.sections.find(s => s.sectionId === SEC_SALON); + expect(salon?.view).not.toBeNull(); + expect(salon?.view?.url.length).toBeGreaterThan(0); }); - it('vistas no registradas no se actualizan', () => { - // SEC_BANO no está registrada — no debe aparecer en views - store.select(ATTR_SUELO, OPT_SUELO_PARQUET); - const banoKey = `${SEC_BANO}:${VIEW_FRONT}`; - expect(store.views.has(banoKey)).toBe(false); + it('sección no activa tiene view null', () => { + const bano = store.sections.find(s => s.sectionId === SEC_BANO); + expect(bano?.view).toBeNull(); }); - it('cleanup elimina la vista del mapa', () => { - const cleanup = store.trackView(SEC_SALON, VIEW_FRONT); - cleanup(); - expect(store.views.has(`${SEC_SALON}:${VIEW_FRONT}`)).toBe(false); + it('currentSectionState refleja la sección activa', () => { + expect(store.currentSectionState?.sectionId).toBe(store.currentSection); }); +}); - it('múltiples vistas registradas se actualizan independientemente', () => { - store.trackView(SEC_SALON, VIEW_FRONT); - store.trackView(SEC_BANO, VIEW_FRONT); - store.select(ATTR_SUELO, OPT_SUELO_PARQUET); - expect(store.views.has(`${SEC_SALON}:${VIEW_FRONT}`)).toBe(true); - expect(store.views.has(`${SEC_BANO}:${VIEW_FRONT}`)).toBe(true); +// ============================================================================ +// progress +// ============================================================================ + +describe('progress', () => { + + it('devuelve total, completed y percent', () => { + expect(typeof store.progress.total).toBe('number'); + expect(typeof store.progress.completed).toBe('number'); + expect(typeof store.progress.percent).toBe('number'); + }); + + it('percent entre 0 y 100', () => { + expect(store.progress.percent).toBeGreaterThanOrEqual(0); + expect(store.progress.percent).toBeLessThanOrEqual(100); }); }); // ============================================================================ -// getAttributeState +// unavailableOptions // ============================================================================ -describe('getAttributeState', () => { +describe('unavailableOptions', () => { + + it('mármol aparece como no disponible en estado inicial', () => { + const blocked = store.unavailableOptions.get(ATTR_REVEST_BANO); + expect(blocked?.some(o => o.optionId === OPT_REVEST_MARMOL)).toBe(true); + }); + + it('incluye reason para tooltip', () => { + const blocked = store.unavailableOptions.get(ATTR_REVEST_BANO); + const marmol = blocked?.find(o => o.optionId === OPT_REVEST_MARMOL); + expect(marmol?.reason).toBeTruthy(); + }); + + it('mármol desaparece tras cambiar a premium', () => { + store.select(ATTR_CALIDAD, OPT_CALIDAD_PREMIUM); + const blocked = store.unavailableOptions.get(ATTR_REVEST_BANO); + expect(blocked?.some(o => o.optionId === OPT_REVEST_MARMOL)).toBeFalsy(); + }); +}); + +// ============================================================================ +// helpers +// ============================================================================ + +describe('helpers', () => { + + it('getUnavailableReason devuelve string para opción bloqueada', () => { + expect(store.getUnavailableReason(ATTR_REVEST_BANO, OPT_REVEST_MARMOL)).toBeTruthy(); + }); + + it('getUnavailableReason null para opción disponible', () => { + expect(store.getUnavailableReason(ATTR_REVEST_BANO, OPT_REVEST_CERAMICA)).toBeNull(); + }); + + it('isOptionAvailable false para opción bloqueada', () => { + expect(store.isOptionAvailable(ATTR_REVEST_BANO, OPT_REVEST_MARMOL)).toBe(false); + }); + + it('isOptionAvailable true para opción disponible', () => { + expect(store.isOptionAvailable(ATTR_REVEST_BANO, OPT_REVEST_CERAMICA)).toBe(true); + }); + + it('isOptionAvailable true para atributo inexistente', () => { + expect(store.isOptionAvailable('at:noexiste' as any, OPT_REVEST_MARMOL)).toBe(true); + }); + + it('isRequired false sin regla de require activa', () => { + expect(store.isRequired(ATTR_SUELO)).toBe(false); + }); - it('devuelve el estado de un atributo', () => { - const s = store.getAttributeState(ATTR_SUELO); - expect(s).not.toBeNull(); - expect(s?.options.length).toBeGreaterThan(0); + it('getSectionAttributes devuelve atributos de la sección', () => { + expect(store.getSectionAttributes(SEC_SALON).length).toBeGreaterThan(0); }); - it('devuelve null para atributo inexistente', () => { - expect(store.getAttributeState('at:noexiste' as any)).toBeNull(); + it('getSectionAttributes vacío para sección inexistente', () => { + expect(store.getSectionAttributes('sc:noexiste' as any)).toHaveLength(0); }); }); \ No newline at end of file diff --git a/src/libs/vice/engines/configuration.ts b/src/libs/vice/engines/configuration.ts index 1d58ea5..f6db175 100644 --- a/src/libs/vice/engines/configuration.ts +++ b/src/libs/vice/engines/configuration.ts @@ -33,6 +33,7 @@ import { SelectionState } from './selection-state'; import { PricingEngine } from './pricing'; import { TemplateResolver } from './template-resolver'; import { ENGINE_CATEGORIES, SELECTION_ERRORS } from '../consts/messages'; +import type {RuleViolation} from "@/libs/vice/engines/rule.ts"; // ============================================================================ @@ -181,6 +182,14 @@ export class ConfigurationEngine { ); } + + // ------------------------------------------------------------------------- + // Violaciones + // ------------------------------------------------------------------------- + getViolations(): RuleViolation[] { + return this.activeState()?.getViolations() ?? []; + } + // ------------------------------------------------------------------------- // Visual // ------------------------------------------------------------------------- diff --git a/src/libs/vice/engines/selection-state.ts b/src/libs/vice/engines/selection-state.ts index 1f87530..f0abb9f 100644 --- a/src/libs/vice/engines/selection-state.ts +++ b/src/libs/vice/engines/selection-state.ts @@ -24,7 +24,7 @@ import type { QuantityConfig, Value } from '../types'; -import type {SelectionMap} from './rule'; +import type {RuleViolation, SelectionMap} from './rule'; import { isOptionSelection } from '../guards'; import {RuleEngine} from './rule'; @@ -172,6 +172,12 @@ export class SelectionState { return result; } + + getViolations(): RuleViolation[] { + const rules = Object.values(this.catalog.rules ?? {}); + return this.ruleEngine.getViolations(rules, this.selection); + } + /** * Verifica si el estado actual es completamente válido. */ diff --git a/src/libs/vice/guards/index.ts b/src/libs/vice/guards/index.ts index 0298bc9..469bd11 100644 --- a/src/libs/vice/guards/index.ts +++ b/src/libs/vice/guards/index.ts @@ -2,4 +2,4 @@ export * from './ids.ts'; export * from './option.ts'; - +export * from './section.ts'; diff --git a/src/libs/vice/guards/section.ts b/src/libs/vice/guards/section.ts new file mode 100644 index 0000000..e2a5bfa --- /dev/null +++ b/src/libs/vice/guards/section.ts @@ -0,0 +1,25 @@ +/** + * ============================================================================ + * SECTION GUARDS + * ============================================================================ + */ + +import type { Section, VisualSection, ConfigSection } from '@/libs/vice/types'; + +/** + * Determina si una sección es de tipo visual. + * Las secciones visuales tienen vistas, templates de imagen y defaultView. + */ +export function isVisualSection(section: Section): section is VisualSection { + return section.kind === 'visual'; +} + +/** + * Determina si una sección es de tipo config. + * Las secciones config contienen solo atributos sin representación visual. + */ +export function isConfigSection(section: Section): section is ConfigSection { + return section.kind === 'config'; +} + +