First Commit

master
dev 7 months ago
parent f0ba99b7dd
commit 75ec895b66

@ -13,10 +13,15 @@
],
"author": "ACTIVE THING",
"license": "EULA",
"dependencies": {},
"dependencies": {
"svelte": "^5.53.0"
},
"devDependencies": {
"@sveltejs/vite-plugin-svelte": "^6.2.4",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/svelte": "^5.3.1",
"@types/node": "^25.2.3",
"jsdom": "^28.1.0",
"ts-node": "^10.9.2",
"typescript": "^5.9.3",
"vite": "^7.3.1",

@ -0,0 +1,262 @@
/**
* ============================================================================
* 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.
*
* Uso típico:
*
* // +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');
*/
import type {
ConfigurationCatalog,
ObjectID,
OptionID,
SectionID,
ViewID,
AttrID
} from '@/libs/vice/types';
import type { Logr } from '@/libs/logr';
import { ConfigurationEngine } from '@/libs/vice/engines';
import type {
ChangeEvent,
SelectionOutcome
} from '@/libs/vice/engines/configuration';
import type {
AttributeState,
PricingResult
} from '@/libs/vice/engines';
// ============================================================================
// TYPES
// ============================================================================
/**
* 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;
}
// ============================================================================
// 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
// -------------------------------------------------------------------------
/** ID del objeto activo */
activeObjectId = $state<ObjectID | null>(null);
/**
* 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());
/**
* Resultado de precios — subtotal, tax, total, breakdown.
* Se recalcula tras cada selección que affectsPrice.
*/
price = $state<PricingResult | null>(null);
/**
* 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());
/**
* Si el estado actual es válido (sin violaciones de error).
*/
isValid = $state<boolean>(true);
/**
* Ú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);
// -------------------------------------------------------------------------
// Constructor
// -------------------------------------------------------------------------
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
// -------------------------------------------------------------------------
/**
* Activa un objeto del catálogo e inicializa el estado reactivo.
*/
setObject(objectId: ObjectID): void {
this.#engine.setActiveObject(objectId);
this.activeObjectId = objectId;
this.#syncAll();
}
/**
* Selecciona una opción para un atributo.
* Actualiza el estado reactivo si tiene éxito.
*/
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;
}
/**
* 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.
*/
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);
};
}
/**
* Devuelve el estado de un atributo — usado para acceso directo.
* Normalmente la UI lee de `attributeStates` directamente.
*/
getAttributeState(attrId: AttrID): AttributeState | null {
return this.attributeStates.get(attrId) ?? null;
}
// -------------------------------------------------------------------------
// Derived — valores calculados desde el estado reactivo
// -------------------------------------------------------------------------
/**
* Subtotal formateado para mostrar en UI.
* Actualización automática cuando cambia `price`.
*/
get subtotal(): number {
return this.price?.subtotal ?? 0;
}
get total(): number {
return this.price?.total ?? 0;
}
get taxAmount(): number {
return this.price?.taxAmount ?? 0;
}
// -------------------------------------------------------------------------
// Gestión de cambios — privado
// -------------------------------------------------------------------------
/**
* Manejador de eventos del engine.
* Actualiza solo el estado reactivo necesario según el tipo de cambio.
*/
#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();
}
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);
}
}
#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}`;
}
}

@ -0,0 +1,333 @@
/**
* ============================================================================
* 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 type { Logr } from '@/libs/logr';
import {
CATALOGO_VIVIENDAS,
IDS_TEST,
} from '@/libs/vice/fixtures';
const {
OBJ_APARTAMENTO,
OBJ_DUPLEX,
ATTR_CALIDAD,
ATTR_SUELO,
ATTR_PARED,
ATTR_REVEST_BANO,
ATTR_SANITARIO,
ATTR_ARMARIO_PUERTAS,
OPT_CALIDAD_ESTANDAR,
OPT_CALIDAD_PREMIUM,
OPT_CALIDAD_LUJO,
OPT_SUELO_PARQUET,
OPT_SUELO_CERAMICA,
OPT_REVEST_CERAMICA,
OPT_REVEST_MARMOL,
OPT_SANIT_NEGRO,
OPT_ARMARIO_LACADO,
OPT_ARMARIO_MADERA,
SEC_SALON,
SEC_BANO,
VIEW_FRONT,
VIEW_360,
} = IDS_TEST;
// ============================================================================
// SETUP
// ============================================================================
function makeLogr(): Logr {
return {
debug : vi.fn(),
info : vi.fn(),
warn : vi.fn(),
error : vi.fn(),
getLogs : vi.fn(),
clear : vi.fn(),
serialize: vi.fn(),
setLevel : vi.fn(),
setMaxLogs: vi.fn(),
} as unknown as Logr;
}
function makeStore() {
return new ConfigurationStore(CATALOGO_VIVIENDAS, makeLogr());
}
let store: ConfigurationStore;
beforeEach(() => {
store = makeStore();
store.setObject(OBJ_APARTAMENTO);
});
// ============================================================================
// setObject
// ============================================================================
describe('setObject', () => {
it('actualiza activeObjectId', () => {
expect(store.activeObjectId).toBe(OBJ_APARTAMENTO);
});
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');
});
it('cambia activeObjectId al cambiar de objeto', () => {
store.setObject(OBJ_DUPLEX);
expect(store.activeObjectId).toBe(OBJ_DUPLEX);
});
it('preserva el estado 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);
});
it('attributeStates del dúplex son independientes del apartamento', () => {
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);
});
});
// ============================================================================
// select
// ============================================================================
describe('select', () => {
it('acepta selección válida y devuelve success: true', () => {
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', () => {
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', () => {
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);
const attrState = store.attributeStates.get(ATTR_ARMARIO_PUERTAS);
expect((attrState?.value as any).quantity).toBe(3);
});
});
// ============================================================================
// price
// ============================================================================
describe('price', () => {
it('subtotal es un número', () => {
expect(typeof store.subtotal).toBe('number');
expect(store.subtotal).toBeGreaterThan(0);
});
it('total es un número', () => {
expect(typeof store.total).toBe('number');
});
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('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)
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);
});
it('price con armario 3 puertas lacadas suma 900', () => {
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);
});
});
// ============================================================================
// attributeStates — disponibilidad de opciones
// ============================================================================
describe('attributeStates — disponibilidad', () => {
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('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('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('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('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);
});
});
// ============================================================================
// isValid
// ============================================================================
describe('isValid', () => {
it('estado inicial es válido', () => {
expect(store.isValid).toBe(true);
});
it('sigue válido tras selección permitida', () => {
store.select(ATTR_CALIDAD, OPT_CALIDAD_PREMIUM);
expect(store.isValid).toBe(true);
});
});
// ============================================================================
// trackView
// ============================================================================
describe('trackView', () => {
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('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('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('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('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('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);
});
});
// ============================================================================
// getAttributeState
// ============================================================================
describe('getAttributeState', () => {
it('devuelve el estado de un atributo', () => {
const s = store.getAttributeState(ATTR_SUELO);
expect(s).not.toBeNull();
expect(s?.options.length).toBeGreaterThan(0);
});
it('devuelve null para atributo inexistente', () => {
expect(store.getAttributeState('at:noexiste' as any)).toBeNull();
});
});
Loading…
Cancel
Save

Powered by TurnKey Linux.