First Commit

master
dev 7 months ago
parent 75ec895b66
commit 850df4f151

@ -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<ConfigurationStore>('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<ObjectID | null>(null);
currentObject = $state<ObjectID | null>(null);
currentSection = $state<SectionID | null>(null);
currentView = $state<ViewID | null>(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<Map<AttrID, AttributeState>>(new Map());
price = $state<PricingResult | null>(null);
isValid = $state<boolean>(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<PricingResult | null>(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<Map<string, ReactiveView>>(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<boolean>(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<AttrID, Array<{ optionId: OptionID; reason: string }>> {
const result = new Map<AttrID, Array<{ optionId: OptionID; reason: string }>>();
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;
}
}

@ -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);
});
});

@ -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
// -------------------------------------------------------------------------

@ -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.
*/

@ -2,4 +2,4 @@
export * from './ids.ts';
export * from './option.ts';
export * from './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';
}
Loading…
Cancel
Save

Powered by TurnKey Linux.