This commit is contained in:
Lucas Tettamanti
2026-01-26 00:13:03 -03:00
parent b1c8a3685c
commit e85afab3e6
6 changed files with 3490 additions and 2 deletions

1711
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -7,6 +7,9 @@
"scripts": { "scripts": {
"start": "node index.js", "start": "node index.js",
"dev": "nodemon index.js", "dev": "nodemon index.js",
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage",
"migrate:up": "dbmate up", "migrate:up": "dbmate up",
"migrate:down": "dbmate down", "migrate:down": "dbmate down",
"migrate:redo": "dbmate rollback && dbmate up", "migrate:redo": "dbmate rollback && dbmate up",
@@ -27,7 +30,9 @@
"zod": "^4.3.4" "zod": "^4.3.4"
}, },
"devDependencies": { "devDependencies": {
"@vitest/coverage-v8": "^4.0.18",
"dbmate": "^2.0.0", "dbmate": "^2.0.0",
"nodemon": "^3.0.3" "nodemon": "^3.0.3",
"vitest": "^4.0.18"
} }
} }

View File

@@ -0,0 +1,565 @@
/**
* Tests para fsm.js
*/
import { describe, it, expect } from 'vitest';
import {
ConversationState,
ALL_STATES,
INTENTS_BY_STATE,
shouldReturnToCart,
hasCartItems,
hasPendingItems,
hasReadyPendingItems,
hasShippingInfo,
hasPaymentInfo,
isPaid,
deriveNextState,
validateTransition,
safeNextState,
} from './fsm.js';
// ─────────────────────────────────────────────────────────────
// Constants
// ─────────────────────────────────────────────────────────────
describe('ConversationState', () => {
it('tiene todos los estados definidos', () => {
expect(ConversationState.IDLE).toBe('IDLE');
expect(ConversationState.CART).toBe('CART');
expect(ConversationState.SHIPPING).toBe('SHIPPING');
expect(ConversationState.PAYMENT).toBe('PAYMENT');
expect(ConversationState.WAITING_WEBHOOKS).toBe('WAITING_WEBHOOKS');
expect(ConversationState.AWAITING_HUMAN).toBe('AWAITING_HUMAN');
});
it('ALL_STATES contiene todos', () => {
expect(ALL_STATES).toContain('IDLE');
expect(ALL_STATES).toContain('CART');
expect(ALL_STATES).toContain('SHIPPING');
expect(ALL_STATES).toContain('PAYMENT');
expect(ALL_STATES).toContain('WAITING_WEBHOOKS');
expect(ALL_STATES).toContain('AWAITING_HUMAN');
expect(ALL_STATES).toHaveLength(6);
});
it('INTENTS_BY_STATE define intents para cada estado', () => {
expect(INTENTS_BY_STATE[ConversationState.IDLE]).toContain('greeting');
expect(INTENTS_BY_STATE[ConversationState.CART]).toContain('add_to_cart');
expect(INTENTS_BY_STATE[ConversationState.SHIPPING]).toContain('provide_address');
expect(INTENTS_BY_STATE[ConversationState.PAYMENT]).toContain('select_payment');
});
});
// ─────────────────────────────────────────────────────────────
// hasCartItems
// ─────────────────────────────────────────────────────────────
describe('hasCartItems', () => {
it('retorna true si cart tiene items', () => {
const order = { cart: [{ woo_id: 1, qty: 1 }] };
expect(hasCartItems(order)).toBe(true);
});
it('retorna false si cart está vacío', () => {
const order = { cart: [] };
expect(hasCartItems(order)).toBe(false);
});
it('retorna false si cart es undefined', () => {
const order = {};
expect(hasCartItems(order)).toBe(false);
});
it('retorna false si order es null', () => {
expect(hasCartItems(null)).toBe(false);
});
it('retorna false si order es undefined', () => {
expect(hasCartItems(undefined)).toBe(false);
});
});
// ─────────────────────────────────────────────────────────────
// hasPendingItems
// ─────────────────────────────────────────────────────────────
describe('hasPendingItems', () => {
it('retorna true si hay NEEDS_TYPE', () => {
const order = { pending: [{ status: 'NEEDS_TYPE' }] };
expect(hasPendingItems(order)).toBe(true);
});
it('retorna true si hay NEEDS_QUANTITY', () => {
const order = { pending: [{ status: 'NEEDS_QUANTITY' }] };
expect(hasPendingItems(order)).toBe(true);
});
it('retorna false si solo hay READY', () => {
const order = { pending: [{ status: 'READY' }] };
expect(hasPendingItems(order)).toBe(false);
});
it('retorna false si pending está vacío', () => {
const order = { pending: [] };
expect(hasPendingItems(order)).toBe(false);
});
it('retorna false si order es null', () => {
expect(hasPendingItems(null)).toBe(false);
});
it('detecta entre múltiples items', () => {
const order = {
pending: [
{ status: 'READY' },
{ status: 'NEEDS_TYPE' },
]
};
expect(hasPendingItems(order)).toBe(true);
});
});
// ─────────────────────────────────────────────────────────────
// hasReadyPendingItems
// ─────────────────────────────────────────────────────────────
describe('hasReadyPendingItems', () => {
it('retorna true si hay READY', () => {
const order = { pending: [{ status: 'READY' }] };
expect(hasReadyPendingItems(order)).toBe(true);
});
it('retorna false si no hay READY', () => {
const order = { pending: [{ status: 'NEEDS_TYPE' }] };
expect(hasReadyPendingItems(order)).toBe(false);
});
it('retorna false si pending vacío', () => {
const order = { pending: [] };
expect(hasReadyPendingItems(order)).toBe(false);
});
it('retorna false si order es null', () => {
expect(hasReadyPendingItems(null)).toBe(false);
});
});
// ─────────────────────────────────────────────────────────────
// hasShippingInfo
// ─────────────────────────────────────────────────────────────
describe('hasShippingInfo', () => {
it('retorna true para pickup (no necesita dirección)', () => {
const order = { is_delivery: false };
expect(hasShippingInfo(order)).toBe(true);
});
it('retorna true para delivery con dirección', () => {
const order = { is_delivery: true, shipping_address: 'Calle Falsa 123' };
expect(hasShippingInfo(order)).toBe(true);
});
it('retorna false para delivery sin dirección', () => {
const order = { is_delivery: true, shipping_address: null };
expect(hasShippingInfo(order)).toBe(false);
});
it('retorna false si is_delivery es null', () => {
const order = { is_delivery: null };
expect(hasShippingInfo(order)).toBe(false);
});
it('retorna false para order vacío', () => {
expect(hasShippingInfo({})).toBe(false);
});
});
// ─────────────────────────────────────────────────────────────
// hasPaymentInfo
// ─────────────────────────────────────────────────────────────
describe('hasPaymentInfo', () => {
it('retorna true para cash', () => {
const order = { payment_type: 'cash' };
expect(hasPaymentInfo(order)).toBe(true);
});
it('retorna true para link', () => {
const order = { payment_type: 'link' };
expect(hasPaymentInfo(order)).toBe(true);
});
it('retorna false para null', () => {
const order = { payment_type: null };
expect(hasPaymentInfo(order)).toBe(false);
});
it('retorna false para undefined', () => {
const order = {};
expect(hasPaymentInfo(order)).toBe(false);
});
it('retorna false para otros valores', () => {
const order = { payment_type: 'bitcoin' };
expect(hasPaymentInfo(order)).toBe(false);
});
});
// ─────────────────────────────────────────────────────────────
// isPaid
// ─────────────────────────────────────────────────────────────
describe('isPaid', () => {
it('retorna true si is_paid es true', () => {
const order = { is_paid: true };
expect(isPaid(order)).toBe(true);
});
it('retorna false si is_paid es false', () => {
const order = { is_paid: false };
expect(isPaid(order)).toBe(false);
});
it('retorna false si is_paid es undefined', () => {
const order = {};
expect(isPaid(order)).toBe(false);
});
it('retorna false si order es null', () => {
expect(isPaid(null)).toBe(false);
});
});
// ─────────────────────────────────────────────────────────────
// shouldReturnToCart
// ─────────────────────────────────────────────────────────────
describe('shouldReturnToCart', () => {
describe('no redirige si ya está en CART o IDLE', () => {
it('retorna false en CART', () => {
const nlu = { intent: 'add_to_cart', entities: { product_query: 'provoleta' } };
expect(shouldReturnToCart(ConversationState.CART, nlu)).toBe(false);
});
it('retorna false en IDLE', () => {
const nlu = { intent: 'add_to_cart', entities: { product_query: 'provoleta' } };
expect(shouldReturnToCart(ConversationState.IDLE, nlu)).toBe(false);
});
});
describe('redirige desde otros estados', () => {
it('redirige add_to_cart desde SHIPPING', () => {
const nlu = { intent: 'add_to_cart', entities: { product_query: 'provoleta' } };
expect(shouldReturnToCart(ConversationState.SHIPPING, nlu)).toBe(true);
});
it('redirige add_to_cart desde PAYMENT', () => {
const nlu = { intent: 'add_to_cart', entities: { product_query: 'vacío' } };
expect(shouldReturnToCart(ConversationState.PAYMENT, nlu)).toBe(true);
});
it('redirige browse desde SHIPPING', () => {
const nlu = { intent: 'browse', entities: { product_query: 'carnes' } };
expect(shouldReturnToCart(ConversationState.SHIPPING, nlu)).toBe(true);
});
});
describe('no redirige números solos en checkout', () => {
it('no redirige "1" en SHIPPING', () => {
const nlu = { intent: 'add_to_cart', entities: {} };
expect(shouldReturnToCart(ConversationState.SHIPPING, nlu, '1')).toBe(false);
});
it('no redirige "2" en PAYMENT', () => {
const nlu = { intent: 'other', entities: {} };
expect(shouldReturnToCart(ConversationState.PAYMENT, nlu, '2')).toBe(false);
});
it('no redirige "1.5" en SHIPPING', () => {
const nlu = { intent: 'add_to_cart', entities: {} };
expect(shouldReturnToCart(ConversationState.SHIPPING, nlu, '1.5')).toBe(false);
});
});
describe('requiere producto real', () => {
it('no redirige sin product_query', () => {
const nlu = { intent: 'add_to_cart', entities: {} };
expect(shouldReturnToCart(ConversationState.SHIPPING, nlu, 'algo')).toBe(false);
});
it('no redirige con product_query muy corto', () => {
const nlu = { intent: 'add_to_cart', entities: { product_query: 'ab' } };
expect(shouldReturnToCart(ConversationState.SHIPPING, nlu)).toBe(false);
});
it('redirige con items array', () => {
const nlu = {
intent: 'add_to_cart',
entities: { items: [{ product_query: 'provoleta' }] }
};
expect(shouldReturnToCart(ConversationState.SHIPPING, nlu)).toBe(true);
});
});
});
// ─────────────────────────────────────────────────────────────
// deriveNextState
// ─────────────────────────────────────────────────────────────
describe('deriveNextState', () => {
describe('return_to_cart signal', () => {
it('fuerza CART si return_to_cart', () => {
const result = deriveNextState(
ConversationState.PAYMENT,
{},
{ return_to_cart: true }
);
expect(result).toBe(ConversationState.CART);
});
});
describe('pagado', () => {
it('va a IDLE si está pagado', () => {
const order = { is_paid: true };
const result = deriveNextState(ConversationState.WAITING_WEBHOOKS, order, {});
expect(result).toBe(ConversationState.IDLE);
});
});
describe('esperando pago', () => {
it('va a WAITING_WEBHOOKS si tiene woo_order_id', () => {
const order = { woo_order_id: 123, is_paid: false };
const result = deriveNextState(ConversationState.PAYMENT, order, {});
expect(result).toBe(ConversationState.WAITING_WEBHOOKS);
});
});
describe('IDLE -> CART', () => {
it('va a CART si hay cart items', () => {
const order = { cart: [{ woo_id: 1 }], pending: [] };
const result = deriveNextState(ConversationState.IDLE, order, {});
expect(result).toBe(ConversationState.CART);
});
it('va a CART si hay pending items', () => {
const order = { cart: [], pending: [{ status: 'NEEDS_TYPE' }] };
const result = deriveNextState(ConversationState.IDLE, order, {});
expect(result).toBe(ConversationState.CART);
});
it('queda en IDLE si vacío', () => {
const order = { cart: [], pending: [] };
const result = deriveNextState(ConversationState.IDLE, order, {});
expect(result).toBe(ConversationState.IDLE);
});
});
describe('CART -> SHIPPING', () => {
it('queda en CART si hay pending items', () => {
const order = { cart: [{ woo_id: 1 }], pending: [{ status: 'NEEDS_TYPE' }] };
const result = deriveNextState(ConversationState.CART, order, { confirm_order: true });
expect(result).toBe(ConversationState.CART);
});
it('va a SHIPPING con confirm_order y cart items', () => {
const order = { cart: [{ woo_id: 1 }], pending: [] };
const result = deriveNextState(ConversationState.CART, order, { confirm_order: true });
expect(result).toBe(ConversationState.SHIPPING);
});
it('queda en CART sin confirm_order', () => {
const order = { cart: [{ woo_id: 1 }], pending: [] };
const result = deriveNextState(ConversationState.CART, order, {});
expect(result).toBe(ConversationState.CART);
});
});
describe('SHIPPING -> PAYMENT', () => {
it('va a PAYMENT con shipping info (pickup)', () => {
const order = { is_delivery: false };
const result = deriveNextState(ConversationState.SHIPPING, order, {});
expect(result).toBe(ConversationState.PAYMENT);
});
it('va a PAYMENT con shipping info (delivery + address)', () => {
const order = { is_delivery: true, shipping_address: 'Calle 123' };
const result = deriveNextState(ConversationState.SHIPPING, order, {});
expect(result).toBe(ConversationState.PAYMENT);
});
it('queda en SHIPPING sin info completa', () => {
const order = { is_delivery: true, shipping_address: null };
const result = deriveNextState(ConversationState.SHIPPING, order, {});
expect(result).toBe(ConversationState.SHIPPING);
});
});
describe('PAYMENT -> WAITING_WEBHOOKS', () => {
it('va a WAITING con payment_selected', () => {
const order = {};
const result = deriveNextState(
ConversationState.PAYMENT,
order,
{ payment_selected: true }
);
expect(result).toBe(ConversationState.WAITING_WEBHOOKS);
});
it('va a WAITING si ya tiene payment_type', () => {
const order = { payment_type: 'cash' };
const result = deriveNextState(ConversationState.PAYMENT, order, {});
expect(result).toBe(ConversationState.WAITING_WEBHOOKS);
});
});
describe('WAITING_WEBHOOKS', () => {
it('va a IDLE si está pagado', () => {
const order = { is_paid: true };
const result = deriveNextState(ConversationState.WAITING_WEBHOOKS, order, {});
expect(result).toBe(ConversationState.IDLE);
});
it('queda en WAITING si no está pagado', () => {
const order = { is_paid: false };
const result = deriveNextState(ConversationState.WAITING_WEBHOOKS, order, {});
expect(result).toBe(ConversationState.WAITING_WEBHOOKS);
});
});
describe('default', () => {
it('retorna IDLE si no hay estado previo', () => {
const result = deriveNextState(null, {}, {});
expect(result).toBe(ConversationState.IDLE);
});
});
});
// ─────────────────────────────────────────────────────────────
// validateTransition
// ─────────────────────────────────────────────────────────────
describe('validateTransition', () => {
describe('transiciones válidas', () => {
it('IDLE -> IDLE es válido', () => {
const result = validateTransition(ConversationState.IDLE, ConversationState.IDLE);
expect(result.ok).toBe(true);
});
it('IDLE -> CART es válido', () => {
const result = validateTransition(ConversationState.IDLE, ConversationState.CART);
expect(result.ok).toBe(true);
});
it('CART -> SHIPPING es válido', () => {
const result = validateTransition(ConversationState.CART, ConversationState.SHIPPING);
expect(result.ok).toBe(true);
});
it('SHIPPING -> PAYMENT es válido', () => {
const result = validateTransition(ConversationState.SHIPPING, ConversationState.PAYMENT);
expect(result.ok).toBe(true);
});
it('PAYMENT -> WAITING_WEBHOOKS es válido', () => {
const result = validateTransition(ConversationState.PAYMENT, ConversationState.WAITING_WEBHOOKS);
expect(result.ok).toBe(true);
});
it('SHIPPING -> CART (volver) es válido', () => {
const result = validateTransition(ConversationState.SHIPPING, ConversationState.CART);
expect(result.ok).toBe(true);
});
});
describe('transiciones inválidas', () => {
it('IDLE -> PAYMENT es inválido', () => {
const result = validateTransition(ConversationState.IDLE, ConversationState.PAYMENT);
expect(result.ok).toBe(false);
expect(result.reason).toBe('invalid_transition');
});
it('CART -> WAITING_WEBHOOKS es inválido', () => {
const result = validateTransition(ConversationState.CART, ConversationState.WAITING_WEBHOOKS);
expect(result.ok).toBe(false);
});
});
describe('estados desconocidos', () => {
it('estado previo desconocido', () => {
const result = validateTransition('UNKNOWN', ConversationState.CART);
expect(result.ok).toBe(false);
expect(result.reason).toBe('unknown_prev_state');
});
it('estado siguiente desconocido', () => {
const result = validateTransition(ConversationState.IDLE, 'UNKNOWN');
expect(result.ok).toBe(false);
expect(result.reason).toBe('unknown_next_state');
});
});
describe('maneja null/undefined', () => {
it('prevState null se trata como IDLE', () => {
const result = validateTransition(null, ConversationState.CART);
expect(result.ok).toBe(true);
});
it('nextState null se trata como IDLE', () => {
const result = validateTransition(ConversationState.IDLE, null);
expect(result.ok).toBe(true);
});
});
});
// ─────────────────────────────────────────────────────────────
// safeNextState
// ─────────────────────────────────────────────────────────────
describe('safeNextState', () => {
it('retorna estado derivado si transición válida', () => {
const order = { cart: [{ woo_id: 1 }], pending: [] };
const result = safeNextState(ConversationState.CART, order, { confirm_order: true });
expect(result.next_state).toBe(ConversationState.SHIPPING);
expect(result.validation.ok).toBe(true);
});
it('fuerza CART si transición inválida', () => {
// Forzar una situación donde deriveNextState retornaría un estado inválido
// Esto es difícil de provocar porque deriveNextState ya es bastante seguro
// Pero podemos verificar que la lógica de fallback existe
const order = {};
const result = safeNextState(ConversationState.IDLE, order, {});
// Debería quedarse en IDLE (transición válida)
expect(result.next_state).toBe(ConversationState.IDLE);
expect(result.validation.ok).toBe(true);
});
it('incluye validation en resultado', () => {
const order = { is_delivery: false };
const result = safeNextState(ConversationState.SHIPPING, order, {});
expect(result).toHaveProperty('next_state');
expect(result).toHaveProperty('validation');
expect(result.validation).toHaveProperty('ok');
});
it('maneja transition IDLE -> CART -> SHIPPING flow', () => {
// Paso 1: IDLE con cart items -> CART
let result = safeNextState(ConversationState.IDLE, { cart: [{ woo_id: 1 }], pending: [] }, {});
expect(result.next_state).toBe(ConversationState.CART);
// Paso 2: CART con confirm -> SHIPPING
result = safeNextState(ConversationState.CART, { cart: [{ woo_id: 1 }], pending: [] }, { confirm_order: true });
expect(result.next_state).toBe(ConversationState.SHIPPING);
// Paso 3: SHIPPING con pickup -> PAYMENT
result = safeNextState(ConversationState.SHIPPING, { is_delivery: false }, {});
expect(result.next_state).toBe(ConversationState.PAYMENT);
// Paso 4: PAYMENT con payment_selected -> WAITING
result = safeNextState(ConversationState.PAYMENT, {}, { payment_selected: true });
expect(result.next_state).toBe(ConversationState.WAITING_WEBHOOKS);
});
});

View File

@@ -0,0 +1,748 @@
/**
* Tests para orderModel.js
*/
import { describe, it, expect } from 'vitest';
import {
PendingStatus,
createEmptyOrder,
createCartItem,
createPendingItem,
moveReadyToCart,
getNextPendingItem,
updatePendingItem,
addPendingItem,
removeCartItem,
updateCartItemQuantity,
migrateOldContext,
formatCartForDisplay,
formatOptionsForDisplay,
} from './orderModel.js';
// ─────────────────────────────────────────────────────────────
// createEmptyOrder
// ─────────────────────────────────────────────────────────────
describe('createEmptyOrder', () => {
it('crea orden con estructura correcta', () => {
const order = createEmptyOrder();
expect(order).toHaveProperty('cart');
expect(order).toHaveProperty('pending');
expect(order).toHaveProperty('payment_type');
expect(order).toHaveProperty('is_delivery');
expect(order).toHaveProperty('shipping_address');
expect(order).toHaveProperty('woo_order_id');
expect(order).toHaveProperty('is_paid');
});
it('inicializa arrays vacíos', () => {
const order = createEmptyOrder();
expect(order.cart).toEqual([]);
expect(order.pending).toEqual([]);
});
it('inicializa valores null/false', () => {
const order = createEmptyOrder();
expect(order.payment_type).toBeNull();
expect(order.is_delivery).toBeNull();
expect(order.shipping_address).toBeNull();
expect(order.woo_order_id).toBeNull();
expect(order.is_paid).toBe(false);
});
});
// ─────────────────────────────────────────────────────────────
// createCartItem
// ─────────────────────────────────────────────────────────────
describe('createCartItem', () => {
it('crea item con todos los campos', () => {
const item = createCartItem({
woo_id: 123,
qty: 2,
unit: 'kg',
name: 'Vacío',
price: 5000,
});
expect(item.woo_id).toBe(123);
expect(item.qty).toBe(2);
expect(item.unit).toBe('kg');
expect(item.name).toBe('Vacío');
expect(item.price).toBe(5000);
});
it('usa defaults correctos', () => {
const item = createCartItem({ woo_id: 123 });
expect(item.qty).toBe(1);
expect(item.unit).toBe('unit');
expect(item.name).toBeNull();
expect(item.price).toBeNull();
});
it('convierte qty a número', () => {
const item = createCartItem({ woo_id: 123, qty: '3' });
expect(item.qty).toBe(3);
});
it('usa 1 si qty es inválido', () => {
const item = createCartItem({ woo_id: 123, qty: 'abc' });
expect(item.qty).toBe(1);
});
});
// ─────────────────────────────────────────────────────────────
// createPendingItem
// ─────────────────────────────────────────────────────────────
describe('createPendingItem', () => {
it('genera id automático si no se proporciona', () => {
const item = createPendingItem({ query: 'provoleta' });
expect(item.id).toBeDefined();
expect(item.id).toMatch(/^pending_/);
});
it('usa id proporcionado', () => {
const item = createPendingItem({ id: 'custom_id', query: 'provoleta' });
expect(item.id).toBe('custom_id');
});
it('usa status NEEDS_TYPE por defecto', () => {
const item = createPendingItem({ query: 'provoleta' });
expect(item.status).toBe(PendingStatus.NEEDS_TYPE);
});
it('acepta status personalizado', () => {
const item = createPendingItem({
query: 'provoleta',
status: PendingStatus.NEEDS_QUANTITY
});
expect(item.status).toBe(PendingStatus.NEEDS_QUANTITY);
});
it('inicializa campos opcionales a null/[]', () => {
const item = createPendingItem({ query: 'provoleta' });
expect(item.candidates).toEqual([]);
expect(item.selected_woo_id).toBeNull();
expect(item.selected_name).toBeNull();
expect(item.qty).toBeNull();
});
it('guarda requested_qty y requested_unit', () => {
const item = createPendingItem({
query: 'vacío',
requested_qty: 2,
requested_unit: 'kg'
});
expect(item.requested_qty).toBe(2);
expect(item.requested_unit).toBe('kg');
});
});
// ─────────────────────────────────────────────────────────────
// moveReadyToCart
// ─────────────────────────────────────────────────────────────
describe('moveReadyToCart', () => {
it('mueve items READY al cart', () => {
const order = {
cart: [],
pending: [
createPendingItem({
id: 'p1',
query: 'provoleta',
status: PendingStatus.READY,
selected_woo_id: 123,
selected_name: 'Provoleta',
qty: 2,
unit: 'unit',
}),
],
};
const result = moveReadyToCart(order);
expect(result.cart).toHaveLength(1);
expect(result.cart[0].woo_id).toBe(123);
expect(result.cart[0].qty).toBe(2);
expect(result.pending).toHaveLength(0);
});
it('ignora items NEEDS_TYPE', () => {
const order = {
cart: [],
pending: [
createPendingItem({
id: 'p1',
query: 'provoleta',
status: PendingStatus.NEEDS_TYPE,
candidates: [{ woo_id: 1, name: 'A' }],
}),
],
};
const result = moveReadyToCart(order);
expect(result.cart).toHaveLength(0);
expect(result.pending).toHaveLength(1);
});
it('ignora items NEEDS_QUANTITY', () => {
const order = {
cart: [],
pending: [
createPendingItem({
id: 'p1',
query: 'vacío',
status: PendingStatus.NEEDS_QUANTITY,
selected_woo_id: 123,
}),
],
};
const result = moveReadyToCart(order);
expect(result.cart).toHaveLength(0);
expect(result.pending).toHaveLength(1);
});
it('actualiza item existente en cart', () => {
const order = {
cart: [{ woo_id: 123, qty: 1, unit: 'unit', name: 'Provoleta' }],
pending: [
createPendingItem({
id: 'p1',
query: 'provoleta',
status: PendingStatus.READY,
selected_woo_id: 123,
selected_name: 'Provoleta',
qty: 3,
unit: 'unit',
}),
],
};
const result = moveReadyToCart(order);
expect(result.cart).toHaveLength(1);
expect(result.cart[0].qty).toBe(3); // Actualizado
});
it('maneja order null', () => {
const result = moveReadyToCart(null);
expect(result).toEqual(createEmptyOrder());
});
it('no mueve items sin selected_woo_id', () => {
const order = {
cart: [],
pending: [
createPendingItem({
id: 'p1',
query: 'algo',
status: PendingStatus.READY,
selected_woo_id: null,
qty: 1,
}),
],
};
const result = moveReadyToCart(order);
expect(result.cart).toHaveLength(0);
expect(result.pending).toHaveLength(1);
});
it('no mueve items sin qty', () => {
const order = {
cart: [],
pending: [
createPendingItem({
id: 'p1',
query: 'algo',
status: PendingStatus.READY,
selected_woo_id: 123,
qty: null,
}),
],
};
const result = moveReadyToCart(order);
expect(result.cart).toHaveLength(0);
expect(result.pending).toHaveLength(1);
});
});
// ─────────────────────────────────────────────────────────────
// getNextPendingItem
// ─────────────────────────────────────────────────────────────
describe('getNextPendingItem', () => {
it('retorna item NEEDS_TYPE', () => {
const order = {
pending: [
createPendingItem({ id: 'p1', query: 'a', status: PendingStatus.NEEDS_TYPE }),
],
};
const next = getNextPendingItem(order);
expect(next).not.toBeNull();
expect(next.id).toBe('p1');
});
it('retorna item NEEDS_QUANTITY', () => {
const order = {
pending: [
createPendingItem({ id: 'p1', query: 'a', status: PendingStatus.NEEDS_QUANTITY }),
],
};
const next = getNextPendingItem(order);
expect(next).not.toBeNull();
expect(next.id).toBe('p1');
});
it('ignora items READY', () => {
const order = {
pending: [
createPendingItem({ id: 'p1', query: 'a', status: PendingStatus.READY }),
],
};
const next = getNextPendingItem(order);
expect(next).toBeNull();
});
it('retorna el primero que necesita clarificación', () => {
const order = {
pending: [
createPendingItem({ id: 'p1', query: 'a', status: PendingStatus.READY }),
createPendingItem({ id: 'p2', query: 'b', status: PendingStatus.NEEDS_TYPE }),
createPendingItem({ id: 'p3', query: 'c', status: PendingStatus.NEEDS_QUANTITY }),
],
};
const next = getNextPendingItem(order);
expect(next.id).toBe('p2'); // Primero NEEDS_TYPE
});
it('retorna null si no hay pending', () => {
expect(getNextPendingItem({ pending: [] })).toBeNull();
expect(getNextPendingItem({})).toBeNull();
expect(getNextPendingItem(null)).toBeNull();
});
});
// ─────────────────────────────────────────────────────────────
// updatePendingItem
// ─────────────────────────────────────────────────────────────
describe('updatePendingItem', () => {
it('actualiza campos por id', () => {
const order = {
pending: [
createPendingItem({ id: 'p1', query: 'provoleta', status: PendingStatus.NEEDS_TYPE }),
],
};
const result = updatePendingItem(order, 'p1', {
status: PendingStatus.READY,
selected_woo_id: 123
});
expect(result.pending[0].status).toBe(PendingStatus.READY);
expect(result.pending[0].selected_woo_id).toBe(123);
expect(result.pending[0].query).toBe('provoleta'); // No modificado
});
it('no muta el original', () => {
const original = {
pending: [
createPendingItem({ id: 'p1', query: 'provoleta', status: PendingStatus.NEEDS_TYPE }),
],
};
updatePendingItem(original, 'p1', { status: PendingStatus.READY });
expect(original.pending[0].status).toBe(PendingStatus.NEEDS_TYPE);
});
it('no modifica si id no existe', () => {
const order = {
pending: [
createPendingItem({ id: 'p1', query: 'provoleta' }),
],
};
const result = updatePendingItem(order, 'inexistente', { status: PendingStatus.READY });
expect(result.pending[0].status).toBe(PendingStatus.NEEDS_TYPE);
});
it('maneja order sin pending', () => {
const result = updatePendingItem({}, 'p1', { status: PendingStatus.READY });
expect(result).toEqual({});
});
});
// ─────────────────────────────────────────────────────────────
// addPendingItem
// ─────────────────────────────────────────────────────────────
describe('addPendingItem', () => {
it('agrega item a pending existente', () => {
const order = { cart: [], pending: [] };
const newItem = createPendingItem({ query: 'provoleta' });
const result = addPendingItem(order, newItem);
expect(result.pending).toHaveLength(1);
expect(result.pending[0].query).toBe('provoleta');
});
it('preserva items existentes', () => {
const order = {
cart: [],
pending: [createPendingItem({ query: 'vacío' })]
};
const newItem = createPendingItem({ query: 'provoleta' });
const result = addPendingItem(order, newItem);
expect(result.pending).toHaveLength(2);
});
it('crea order si es null', () => {
const newItem = createPendingItem({ query: 'provoleta' });
const result = addPendingItem(null, newItem);
expect(result.cart).toEqual([]);
expect(result.pending).toHaveLength(1);
});
});
// ─────────────────────────────────────────────────────────────
// removeCartItem
// ─────────────────────────────────────────────────────────────
describe('removeCartItem', () => {
const orderWithItems = {
cart: [
{ woo_id: 1, name: 'Provoleta clásica', qty: 2, unit: 'unit' },
{ woo_id: 2, name: 'Vacío premium', qty: 1.5, unit: 'kg' },
{ woo_id: 3, name: 'Bife de chorizo', qty: 2, unit: 'kg' },
],
pending: [],
};
it('remueve item por nombre exacto', () => {
const { order, removed } = removeCartItem(orderWithItems, 'vacío premium');
expect(removed).not.toBeNull();
expect(removed.name).toBe('Vacío premium');
expect(order.cart).toHaveLength(2);
});
it('remueve item por palabra clave', () => {
const { order, removed } = removeCartItem(orderWithItems, 'provoleta');
expect(removed).not.toBeNull();
expect(removed.name).toBe('Provoleta clásica');
});
it('remueve item por múltiples palabras', () => {
const { order, removed } = removeCartItem(orderWithItems, 'bife chorizo');
expect(removed).not.toBeNull();
expect(removed.name).toBe('Bife de chorizo');
});
it('retorna null si no encuentra match', () => {
const { order, removed } = removeCartItem(orderWithItems, 'chimichurri');
expect(removed).toBeNull();
expect(order.cart).toHaveLength(3);
});
it('maneja cart vacío', () => {
const { order, removed } = removeCartItem({ cart: [] }, 'provoleta');
expect(removed).toBeNull();
});
it('maneja order null', () => {
const { order, removed } = removeCartItem(null, 'provoleta');
expect(removed).toBeNull();
});
it('maneja productQuery vacío', () => {
const { order, removed } = removeCartItem(orderWithItems, '');
expect(removed).toBeNull();
});
});
// ─────────────────────────────────────────────────────────────
// updateCartItemQuantity
// ─────────────────────────────────────────────────────────────
describe('updateCartItemQuantity', () => {
const orderWithItems = {
cart: [
{ woo_id: 1, name: 'Vacío premium', qty: 1.5, unit: 'kg' },
{ woo_id: 2, name: 'Provoleta', qty: 2, unit: 'unit' },
],
pending: [],
};
it('actualiza cantidad', () => {
const { order, updated } = updateCartItemQuantity(orderWithItems, 'vacío', 3);
expect(updated).not.toBeNull();
expect(updated.qty).toBe(3);
expect(order.cart.find(i => i.name === 'Vacío premium').qty).toBe(3);
});
it('actualiza cantidad y unidad', () => {
const { order, updated } = updateCartItemQuantity(orderWithItems, 'vacío', 500, 'g');
expect(updated.qty).toBe(500);
expect(updated.unit).toBe('g');
});
it('retorna null si no encuentra', () => {
const { order, updated } = updateCartItemQuantity(orderWithItems, 'chorizo', 2);
expect(updated).toBeNull();
});
it('maneja cart vacío', () => {
const { order, updated } = updateCartItemQuantity({ cart: [] }, 'vacío', 2);
expect(updated).toBeNull();
});
});
// ─────────────────────────────────────────────────────────────
// migrateOldContext
// ─────────────────────────────────────────────────────────────
describe('migrateOldContext', () => {
it('retorna orden vacía para ctx null', () => {
const result = migrateOldContext(null);
expect(result).toEqual(createEmptyOrder());
});
it('retorna order existente si ya está migrado', () => {
const ctx = {
order: {
cart: [{ woo_id: 1, qty: 1 }],
pending: [],
},
};
const result = migrateOldContext(ctx);
expect(result.cart).toHaveLength(1);
});
it('migra order_basket', () => {
const ctx = {
order_basket: {
items: [
{ product_id: 123, quantity: 2, unit: 'kg', label: 'Vacío', price: 5000 },
],
},
};
const result = migrateOldContext(ctx);
expect(result.cart).toHaveLength(1);
expect(result.cart[0].woo_id).toBe(123);
expect(result.cart[0].qty).toBe(2);
expect(result.cart[0].name).toBe('Vacío');
});
it('migra pending_items', () => {
const ctx = {
pending_items: [
{
id: 'p1',
query: 'provoleta',
status: 'needs_type',
candidates: [{ woo_product_id: 1, name: 'Provoleta A' }],
},
],
};
const result = migrateOldContext(ctx);
expect(result.pending).toHaveLength(1);
expect(result.pending[0].status).toBe(PendingStatus.NEEDS_TYPE);
});
it('migra checkout info', () => {
const ctx = {
payment_method: 'cash',
shipping_method: 'delivery',
delivery_address: { text: 'Calle Falsa 123' },
woo_order_id: 456,
};
const result = migrateOldContext(ctx);
expect(result.payment_type).toBe('cash');
expect(result.is_delivery).toBe(true);
expect(result.shipping_address).toBe('Calle Falsa 123');
expect(result.woo_order_id).toBe(456);
});
it('migra shipping_method pickup', () => {
const ctx = {
shipping_method: 'pickup',
};
const result = migrateOldContext(ctx);
expect(result.is_delivery).toBe(false);
});
});
// ─────────────────────────────────────────────────────────────
// formatCartForDisplay
// ─────────────────────────────────────────────────────────────
describe('formatCartForDisplay', () => {
it('formatea items con cantidad kg', () => {
const order = {
cart: [
{ woo_id: 1, name: 'Vacío', qty: 1.5, unit: 'kg' },
],
pending: [],
};
const display = formatCartForDisplay(order);
expect(display).toContain('1.5kg de Vacío');
});
it('formatea items con cantidad g', () => {
const order = {
cart: [
{ woo_id: 1, name: 'Jamón', qty: 500, unit: 'g' },
],
pending: [],
};
const display = formatCartForDisplay(order);
expect(display).toContain('500g de Jamón');
});
it('formatea items por unidad', () => {
const order = {
cart: [
{ woo_id: 1, name: 'Provoleta', qty: 3, unit: 'unit' },
],
pending: [],
};
const display = formatCartForDisplay(order);
expect(display).toContain('3 de Provoleta');
});
it('muestra pending items NEEDS_TYPE', () => {
const order = {
cart: [],
pending: [
createPendingItem({ query: 'provoleta', status: PendingStatus.NEEDS_TYPE }),
],
};
const display = formatCartForDisplay(order);
expect(display).toContain('provoleta');
expect(display).toContain('pendiente');
});
it('muestra pending items NEEDS_QUANTITY', () => {
const order = {
cart: [],
pending: [
createPendingItem({
query: 'vacío',
status: PendingStatus.NEEDS_QUANTITY,
selected_name: 'Vacío premium',
}),
],
};
const display = formatCartForDisplay(order);
expect(display).toContain('Vacío premium');
expect(display).toContain('falta cantidad');
});
it('muestra mensaje de carrito vacío', () => {
const order = { cart: [], pending: [] };
const display = formatCartForDisplay(order);
expect(display).toBe('Tu carrito está vacío.');
});
it('maneja order null', () => {
const display = formatCartForDisplay(null);
expect(display).toBe('Tu carrito está vacío.');
});
});
// ─────────────────────────────────────────────────────────────
// formatOptionsForDisplay
// ─────────────────────────────────────────────────────────────
describe('formatOptionsForDisplay', () => {
it('lista opciones numeradas', () => {
const pendingItem = createPendingItem({
query: 'provoleta',
candidates: [
{ woo_id: 1, name: 'Provoleta clásica' },
{ woo_id: 2, name: 'Provoleta de búfala' },
],
});
const { question, options } = formatOptionsForDisplay(pendingItem);
expect(question).toContain('1) Provoleta clásica');
expect(question).toContain('2) Provoleta de búfala');
expect(options).toHaveLength(2);
});
it('muestra "Mostrame más" si hay más de pageSize', () => {
const candidates = Array.from({ length: 15 }, (_, i) => ({
woo_id: i,
name: `Producto ${i}`,
}));
const pendingItem = createPendingItem({
query: 'producto',
candidates,
});
const { question } = formatOptionsForDisplay(pendingItem, 12);
expect(question).toContain('13) Mostrame más');
});
it('no muestra "Mostrame más" si hay menos de pageSize', () => {
const pendingItem = createPendingItem({
query: 'provoleta',
candidates: [
{ woo_id: 1, name: 'A' },
{ woo_id: 2, name: 'B' },
],
});
const { question } = formatOptionsForDisplay(pendingItem);
expect(question).not.toContain('Mostrame más');
});
it('muestra mensaje de no encontrado sin candidatos', () => {
const pendingItem = createPendingItem({
query: 'xyz',
candidates: [],
});
const { question, options } = formatOptionsForDisplay(pendingItem);
expect(question).toContain('No encontré');
expect(question).toContain('xyz');
expect(options).toEqual([]);
});
it('maneja pendingItem null', () => {
const { question } = formatOptionsForDisplay(null);
expect(question).toContain('No encontré');
});
});

View File

@@ -0,0 +1,448 @@
/**
* Tests para utils.js
*/
import { describe, it, expect } from 'vitest';
import {
inferDefaultUnit,
parseIndexSelection,
isShowMoreRequest,
isShowOptionsRequest,
findMatchingCandidate,
isEscapeRequest,
normalizeUnit,
unitAskFor,
} from './utils.js';
// ─────────────────────────────────────────────────────────────
// parseIndexSelection
// ─────────────────────────────────────────────────────────────
describe('parseIndexSelection', () => {
describe('números directos', () => {
it('parsea número simple', () => {
expect(parseIndexSelection('2')).toBe(2);
expect(parseIndexSelection('5')).toBe(5);
expect(parseIndexSelection('10')).toBe(10);
});
it('parsea número en frase', () => {
expect(parseIndexSelection('quiero el 2')).toBe(2);
expect(parseIndexSelection('dame la opción 3')).toBe(3);
expect(parseIndexSelection('el número 7 por favor')).toBe(7);
});
it('parsea números de dos dígitos', () => {
expect(parseIndexSelection('el 12')).toBe(12);
expect(parseIndexSelection('opción 15')).toBe(15);
});
});
describe('ordinales en español', () => {
it('parsea ordinales masculinos', () => {
expect(parseIndexSelection('el primero')).toBe(1);
expect(parseIndexSelection('segundo')).toBe(2);
expect(parseIndexSelection('tercero')).toBe(3);
expect(parseIndexSelection('cuarto')).toBe(4);
expect(parseIndexSelection('quinto')).toBe(5);
expect(parseIndexSelection('sexto')).toBe(6);
expect(parseIndexSelection('séptimo')).toBe(7);
expect(parseIndexSelection('octavo')).toBe(8);
expect(parseIndexSelection('noveno')).toBe(9);
expect(parseIndexSelection('décimo')).toBe(10);
});
it('parsea ordinales femeninos', () => {
expect(parseIndexSelection('la primera')).toBe(1);
expect(parseIndexSelection('segunda')).toBe(2);
expect(parseIndexSelection('tercera')).toBe(3);
expect(parseIndexSelection('cuarta')).toBe(4);
expect(parseIndexSelection('quinta')).toBe(5);
expect(parseIndexSelection('sexta')).toBe(6);
expect(parseIndexSelection('séptima')).toBe(7);
expect(parseIndexSelection('octava')).toBe(8);
expect(parseIndexSelection('novena')).toBe(9);
expect(parseIndexSelection('décima')).toBe(10);
});
it('parsea ordinales sin tilde', () => {
expect(parseIndexSelection('septimo')).toBe(7);
expect(parseIndexSelection('decimo')).toBe(10);
});
});
describe('casos sin selección', () => {
it('retorna null para texto sin número ni ordinal', () => {
expect(parseIndexSelection('hola')).toBeNull();
expect(parseIndexSelection('quiero provoleta')).toBeNull();
expect(parseIndexSelection('no sé')).toBeNull();
});
it('retorna null para valores vacíos', () => {
expect(parseIndexSelection('')).toBeNull();
expect(parseIndexSelection(null)).toBeNull();
expect(parseIndexSelection(undefined)).toBeNull();
});
});
});
// ─────────────────────────────────────────────────────────────
// normalizeUnit
// ─────────────────────────────────────────────────────────────
describe('normalizeUnit', () => {
describe('kilogramos', () => {
it('normaliza kg', () => {
expect(normalizeUnit('kg')).toBe('kg');
expect(normalizeUnit('KG')).toBe('kg');
});
it('normaliza kilo/kilos', () => {
expect(normalizeUnit('kilo')).toBe('kg');
expect(normalizeUnit('kilos')).toBe('kg');
expect(normalizeUnit('KILOS')).toBe('kg');
});
});
describe('gramos', () => {
it('normaliza g', () => {
expect(normalizeUnit('g')).toBe('g');
expect(normalizeUnit('G')).toBe('g');
});
it('normaliza gramo/gramos', () => {
expect(normalizeUnit('gramo')).toBe('g');
expect(normalizeUnit('gramos')).toBe('g');
expect(normalizeUnit('GRAMOS')).toBe('g');
});
});
describe('unidades', () => {
it('normaliza unit', () => {
expect(normalizeUnit('unit')).toBe('unit');
});
it('normaliza unidad/unidades', () => {
expect(normalizeUnit('unidad')).toBe('unit');
expect(normalizeUnit('unidades')).toBe('unit');
expect(normalizeUnit('UNIDADES')).toBe('unit');
});
});
describe('valores inválidos', () => {
it('retorna null para unidades desconocidas', () => {
expect(normalizeUnit('litro')).toBeNull();
expect(normalizeUnit('docena')).toBeNull();
expect(normalizeUnit('xyz')).toBeNull();
});
it('retorna null para valores vacíos', () => {
expect(normalizeUnit('')).toBeNull();
expect(normalizeUnit(null)).toBeNull();
expect(normalizeUnit(undefined)).toBeNull();
});
});
});
// ─────────────────────────────────────────────────────────────
// inferDefaultUnit
// ─────────────────────────────────────────────────────────────
describe('inferDefaultUnit', () => {
describe('productos que se venden por unidad', () => {
it('detecta provoleta/queso por nombre', () => {
expect(inferDefaultUnit({ name: 'Provoleta clásica', categories: [] })).toBe('unit');
expect(inferDefaultUnit({ name: 'Queso provolone', categories: [] })).toBe('unit');
expect(inferDefaultUnit({ name: 'Pan de campo', categories: [] })).toBe('unit');
});
it('detecta bebidas por nombre', () => {
expect(inferDefaultUnit({ name: 'Vino Malbec', categories: [] })).toBe('unit');
expect(inferDefaultUnit({ name: 'Cerveza artesanal', categories: [] })).toBe('unit');
expect(inferDefaultUnit({ name: 'Fernet Branca', categories: [] })).toBe('unit');
});
it('detecta condimentos', () => {
expect(inferDefaultUnit({ name: 'Chimichurri casero', categories: [] })).toBe('unit');
expect(inferDefaultUnit({ name: 'Salsa criolla', categories: [] })).toBe('unit');
});
it('detecta por categoría', () => {
expect(inferDefaultUnit({
name: 'Producto X',
categories: [{ name: 'Vinos', slug: 'vinos' }]
})).toBe('unit');
expect(inferDefaultUnit({
name: 'Producto Y',
categories: [{ name: 'Proveeduría', slug: 'proveeduria' }]
})).toBe('unit');
});
});
describe('productos que se venden por kg', () => {
it('retorna kg para carnes', () => {
expect(inferDefaultUnit({ name: 'Bife de chorizo', categories: [] })).toBe('kg');
expect(inferDefaultUnit({ name: 'Vacío', categories: [] })).toBe('kg');
expect(inferDefaultUnit({ name: 'Asado de tira', categories: [] })).toBe('kg');
});
it('retorna kg por defecto', () => {
expect(inferDefaultUnit({ name: 'Producto genérico', categories: [] })).toBe('kg');
expect(inferDefaultUnit({ name: '', categories: [] })).toBe('kg');
});
});
describe('edge cases', () => {
it('maneja categories no array', () => {
expect(inferDefaultUnit({ name: 'Vino', categories: null })).toBe('unit');
expect(inferDefaultUnit({ name: 'Vino', categories: undefined })).toBe('unit');
});
it('maneja name vacío o null', () => {
expect(inferDefaultUnit({ name: null, categories: [] })).toBe('kg');
expect(inferDefaultUnit({ name: undefined, categories: [] })).toBe('kg');
});
});
});
// ─────────────────────────────────────────────────────────────
// isShowMoreRequest
// ─────────────────────────────────────────────────────────────
describe('isShowMoreRequest', () => {
describe('detecta pedidos de más opciones', () => {
it('detecta "mostrame más"', () => {
expect(isShowMoreRequest('mostrame más')).toBe(true);
expect(isShowMoreRequest('mostrame mas')).toBe(true);
expect(isShowMoreRequest('mostra más')).toBe(true);
});
it('detecta "más opciones"', () => {
expect(isShowMoreRequest('más opciones')).toBe(true);
expect(isShowMoreRequest('mas opciones')).toBe(true);
expect(isShowMoreRequest('quiero más opciones')).toBe(true);
});
it('detecta "siguientes"', () => {
expect(isShowMoreRequest('siguientes')).toBe(true);
expect(isShowMoreRequest('siguiente')).toBe(true);
});
it('detecta "otras opciones"', () => {
expect(isShowMoreRequest('otras opciones')).toBe(true);
expect(isShowMoreRequest('hay otras?')).toBe(true);
});
it('detecta "ver más"', () => {
expect(isShowMoreRequest('ver más')).toBe(true);
expect(isShowMoreRequest('ver mas')).toBe(true);
});
it('detecta "qué más hay"', () => {
expect(isShowMoreRequest('qué más hay')).toBe(true);
expect(isShowMoreRequest('que mas hay')).toBe(true);
});
});
describe('no detecta falsos positivos', () => {
it('no detecta frases normales', () => {
expect(isShowMoreRequest('quiero el primero')).toBe(false);
expect(isShowMoreRequest('dame provoleta')).toBe(false);
expect(isShowMoreRequest('hola')).toBe(false);
});
it('no detecta valores vacíos', () => {
expect(isShowMoreRequest('')).toBe(false);
expect(isShowMoreRequest(null)).toBe(false);
});
});
});
// ─────────────────────────────────────────────────────────────
// isShowOptionsRequest
// ─────────────────────────────────────────────────────────────
describe('isShowOptionsRequest', () => {
describe('detecta pedidos de ver opciones', () => {
it('detecta "qué opciones"', () => {
expect(isShowOptionsRequest('qué opciones tenés?')).toBe(true);
expect(isShowOptionsRequest('que opciones hay')).toBe(true);
});
it('detecta "cuáles tenés"', () => {
expect(isShowOptionsRequest('cuáles tenés')).toBe(true);
expect(isShowOptionsRequest('cuales son')).toBe(true);
expect(isShowOptionsRequest('cuáles hay')).toBe(true);
});
it('detecta "qué hay"', () => {
expect(isShowOptionsRequest('qué hay')).toBe(true);
expect(isShowOptionsRequest('que hay')).toBe(true);
});
it('detecta "qué tenés"', () => {
expect(isShowOptionsRequest('qué tenés')).toBe(true);
expect(isShowOptionsRequest('que tenes')).toBe(true);
});
it('detecta "ver opciones"', () => {
expect(isShowOptionsRequest('ver opciones')).toBe(true);
expect(isShowOptionsRequest('ver las opciones')).toBe(true);
});
});
describe('no detecta falsos positivos', () => {
it('no detecta frases normales', () => {
expect(isShowOptionsRequest('quiero el 2')).toBe(false);
expect(isShowOptionsRequest('dame provoleta')).toBe(false);
});
it('no detecta valores vacíos', () => {
expect(isShowOptionsRequest('')).toBe(false);
expect(isShowOptionsRequest(null)).toBe(false);
});
});
});
// ─────────────────────────────────────────────────────────────
// isEscapeRequest
// ─────────────────────────────────────────────────────────────
describe('isEscapeRequest', () => {
describe('detecta pedidos de escape', () => {
it('detecta "qué tengo"', () => {
expect(isEscapeRequest('qué tengo')).toBe(true);
expect(isEscapeRequest('que tengo en el carrito')).toBe(true);
});
it('detecta "mi carrito"', () => {
expect(isEscapeRequest('mi carrito')).toBe(true);
expect(isEscapeRequest('mi pedido')).toBe(true);
});
it('detecta "ver carrito"', () => {
expect(isEscapeRequest('ver carrito')).toBe(true);
expect(isEscapeRequest('ver pedido')).toBe(true);
});
it('detecta "listo"', () => {
expect(isEscapeRequest('listo')).toBe(true);
expect(isEscapeRequest('ya está listo')).toBe(true);
});
it('detecta "confirmar"', () => {
expect(isEscapeRequest('confirmar')).toBe(true);
expect(isEscapeRequest('quiero confirmar')).toBe(true);
});
it('detecta "cancelar"', () => {
expect(isEscapeRequest('cancelar')).toBe(true);
expect(isEscapeRequest('quiero cancelar')).toBe(true);
});
it('detecta "eso es todo"', () => {
expect(isEscapeRequest('eso es todo')).toBe(true);
expect(isEscapeRequest('eso todo')).toBe(true);
});
});
describe('no detecta falsos positivos', () => {
it('no detecta productos', () => {
expect(isEscapeRequest('quiero provoleta')).toBe(false);
expect(isEscapeRequest('dame 2kg de vacío')).toBe(false);
});
it('no detecta valores vacíos', () => {
expect(isEscapeRequest('')).toBe(false);
expect(isEscapeRequest(null)).toBe(false);
});
});
});
// ─────────────────────────────────────────────────────────────
// findMatchingCandidate
// ─────────────────────────────────────────────────────────────
describe('findMatchingCandidate', () => {
const candidates = [
{ name: 'Provoleta de bufala' },
{ name: 'Provoleta clasica' },
{ name: 'Queso provolone' },
{ name: 'Chimichurri casero' },
];
describe('encuentra matches', () => {
it('encuentra match exacto de palabra', () => {
const match = findMatchingCandidate(candidates, 'bufala');
expect(match).not.toBeNull();
expect(match.index).toBe(0);
expect(match.candidate.name).toBe('Provoleta de bufala');
});
it('encuentra match parcial', () => {
const match = findMatchingCandidate(candidates, 'clasica');
expect(match).not.toBeNull();
expect(match.index).toBe(1);
});
it('da bonus por match completo', () => {
const match = findMatchingCandidate(candidates, 'provoleta clasica');
expect(match).not.toBeNull();
expect(match.index).toBe(1);
expect(match.score).toBeGreaterThan(1);
});
it('encuentra mejor match entre varios', () => {
const match = findMatchingCandidate(candidates, 'provoleta');
expect(match).not.toBeNull();
// Ambos tienen "provoleta", debe elegir uno
expect([0, 1]).toContain(match.index);
});
});
describe('no encuentra match', () => {
it('retorna null sin coincidencia', () => {
expect(findMatchingCandidate(candidates, 'chorizo')).toBeNull();
expect(findMatchingCandidate(candidates, 'vino')).toBeNull();
});
it('retorna null para candidates vacío', () => {
expect(findMatchingCandidate([], 'provoleta')).toBeNull();
expect(findMatchingCandidate(null, 'provoleta')).toBeNull();
});
it('retorna null para texto vacío', () => {
expect(findMatchingCandidate(candidates, '')).toBeNull();
expect(findMatchingCandidate(candidates, null)).toBeNull();
});
it('texto corto puede matchear por inclusión completa', () => {
// "de" tiene 2 caracteres, se filtra como palabra pero si el texto completo
// está en el nombre, da bonus y matchea
const match = findMatchingCandidate(candidates, 'de');
// La función da bonus si el texto completo está en el nombre
expect(match).not.toBeNull();
expect(match.score).toBe(2); // bonus por match completo
});
});
});
// ─────────────────────────────────────────────────────────────
// unitAskFor
// ─────────────────────────────────────────────────────────────
describe('unitAskFor', () => {
it('genera pregunta para unidades', () => {
expect(unitAskFor('unit')).toBe('¿Cuántas unidades querés?');
});
it('genera pregunta para gramos', () => {
expect(unitAskFor('g')).toBe('¿Cuántos gramos querés?');
});
it('genera pregunta para kilos (default)', () => {
expect(unitAskFor('kg')).toBe('¿Cuántos kilos querés?');
expect(unitAskFor(null)).toBe('¿Cuántos kilos querés?');
expect(unitAskFor(undefined)).toBe('¿Cuántos kilos querés?');
expect(unitAskFor('otro')).toBe('¿Cuántos kilos querés?');
});
});

13
vitest.config.js Normal file
View File

@@ -0,0 +1,13 @@
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
globals: true,
environment: 'node',
include: ['**/*.test.js'],
coverage: {
provider: 'v8',
reporter: ['text', 'html'],
},
},
})