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