Files
bot_palmas/setup/tests/test_flujos.php
T
Lizandro GuarnizoandClaude Sonnet 4.6 2ef3091260 feat: ausentismo en dos pasos — tipo y despues motivo
17 motivos en una sola lista llegaban con el nombre cortado a 24 caracteres,
el limite de WhatsApp, y costaban de entender. Ahora primero se elige el tipo
—Incapacidad, Permiso, Licencia, Ausencia, Vacaciones...— y despues solo los
motivos de ese tipo.

Quitar el prefijo del grupo libera los caracteres que faltaban: "INCAPACIDAD
ENFERMEDAD < 3 DIAS" queda en "ENFERMEDAD < 3 DIAS". Sin siglas, que era la
otra opcion pero empeoraba justo lo que el cliente pidio arreglar. El nombre
completo va en la descripcion de la fila, asi no se pierde nada.

El ERP agrupa por nombre y no por id, para que una novedad nueva caiga sola en
su grupo; lo que no reconoce queda en 'Otros', visible.

desc_field es nuevo en los select: antes solo el multi_select podia describir.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-20 22:48:19 -05:00

266 lines
14 KiB
PHP

<?php
declare(strict_types=1);
/**
* Tests unitarios de los flujos de carga, ejecutando NormalBot de verdad.
*
* A diferencia de test_navegacion.php (que espeja la lógica), acá corre
* process()/processInteractive() reales: el curl pega contra fixtures_api.php
* y los POST capturados se comparan contra el contrato del ERP.
*
* Uso: php setup/tests/run.sh (levanta el servidor de fixtures y corre esto)
*/
require __DIR__ . '/bootstrap.php';
$co = empresa();
// ════ 1. Finca restringida: una sola → entra directo ═════════════════════════
echo "\nFinca — alcance por número\n";
$ctx = contexto('57300RESTRINGIDO');
$r = NormalBot::process($co, $ctx, 'hola');
check('con una sola finca no pregunta: cae al menú',
str_contains(textoDe($r), '¿Qué deseas hacer?'));
$meta = ConversationContext::getMetadata(ConversationContext::getOrCreate(1, '57300RESTRINGIDO')['id']);
check('la finca quedó elegida sola', $meta['finca']['finca_id'] ?? null, '4');
check('con su nombre para el footer', $meta['finca']['finca_label'] ?? null, 'REPOSO');
$p = json_decode($r['payload'], true);
check('y el footer la muestra', $p['interactive']['footer']['text'] ?? '', '📍 REPOSO');
$ctx = contexto('57300LIBRE');
$r = NormalBot::process($co, $ctx, 'hola');
check('sin restricción sí pregunta, con "Todas" primero',
(string)array_key_first(filasDe($r)), '0');
capturas();
// ════ 2. Ausentismo completo ═════════════════════════════════════════════════
echo "\nAusentismo — de la primera pregunta al POST\n";
$ctx = contexto('57300AUSEN');
$r = NormalBot::processInteractive($co, $ctx, 'registrar_ausentismo');
check('arranca pidiendo el trabajador', str_contains(textoDe($r), 'nombre o documento'));
$r = NormalBot::process($co, $ctx, 'julio');
check('varios homónimos: ofrece elegir', str_contains(textoDe($r), 'coincidencias'));
check('con los tres del catálogo', count(filasDe($r)), 3);
$r = NormalBot::processInteractive($co, $ctx, '412');
$filas = filasDe($r);
check('sigue el tipo, no los 17 motivos de una',
array_keys($filas), ['Incapacidad', 'Permiso', 'Vacaciones']);
$r = NormalBot::processInteractive($co, $ctx, 'Incapacidad');
$filas = filasDe($r);
check('el motivo se acota al tipo elegido', array_map('strval', array_keys($filas)), ['46', '47']);
check('sin el prefijo del grupo, entra en los 24 de WhatsApp',
max(array_map('mb_strlen', $filas)) <= 24);
$p = json_decode($r['payload'], true);
$fila0 = $p['interactive']['action']['sections'][0]['rows'][0];
check('y el nombre completo va en la descripción',
$fila0['description'] ?? null, 'INCAPACIDAD ENFERMEDAD < 3 DIAS');
$r = NormalBot::processInteractive($co, $ctx, '46');
check('sigue la fecha inicial', str_contains(textoDe($r), 'Desde qué fecha'));
$hoy = date('Y-m-d'); $ayer = date('Y-m-d', strtotime('-1 day'));
$r = NormalBot::processInteractive($co, $ctx, $hoy);
check('sigue la fecha final', str_contains(textoDe($r), 'Hasta qué fecha'));
WhatsAppSender::$enviados = [];
$r = NormalBot::processInteractive($co, $ctx, $ayer);
check('rechaza el rango invertido',
str_contains(WhatsAppSender::$enviados[0]['text'] ?? '', 'no puede ser anterior'));
check('y vuelve a mostrar el selector de fecha', str_contains(textoDe($r), 'Hasta qué fecha'));
$r = NormalBot::processInteractive($co, $ctx, $hoy);
check('resumen con confirmación', isset(filasDe($r)['__cap_confirm']));
capturas(); // limpia GETs de catálogos
NormalBot::processInteractive($co, $ctx, '__cap_confirm');
$post = array_values(array_filter(capturas(), fn($c) => ($c['peticion'] ?? '') === 'ausentismos_up'))[0] ?? null;
check('el POST llegó al ERP', $post !== null);
check('con el contrato exacto del procesador',
[$post['body']['empleado_id'] ?? null, $post['body']['novedad_id'] ?? null,
$post['body']['fecha_inicial'] ?? null, $post['body']['fecha_final'] ?? null],
['412', '46', $hoy, $hoy]);
check('y la trazabilidad', $post['body']['telefono'] ?? null, '57300AUSEN');
// ════ 3. Ausentismo según el perfil del número ═══════════════════════════════
echo "\nPerfil — trabajador vinculado y supervisor\n";
fakePhone('57300TRABAJADOR', 412, 0);
fakePhone('57300JEFE', 412, 1);
$ctx = contexto('57300TRABAJADOR');
$r = NormalBot::processInteractive($co, $ctx, 'registrar_ausentismo');
check('el trabajador vinculado no se pregunta: arranca por el tipo',
str_contains(textoDe($r), 'tipo de ausentismo'));
$ctx = contexto('57300JEFE');
$r = NormalBot::processInteractive($co, $ctx, 'registrar_ausentismo');
check('el supervisor elige aunque tenga tercero',
str_contains(textoDe($r), 'nombre o documento'));
capturas();
// ════ 4. Ciclos: apertura con selección múltiple ═════════════════════════════
echo "\nCiclos — apertura de varios lotes\n";
$ctx = contexto('57300CICLOS');
$id = ConversationContext::getOrCreate(1, '57300CICLOS')['id'];
ConversationContext::updateMetadata($id, ['finca' => ['finca_id' => '4', 'finca_label' => 'REPOSO']]);
$r = NormalBot::processInteractive($co, $ctx, 'registrar_ciclo');
check('pregunta el tipo de ciclo', str_contains(textoDe($r), 'Qué ciclo'));
$r = NormalBot::processInteractive($co, $ctx, 'cosecha');
check('después la acción', isset(filasDe($r)['apertura']));
$r = NormalBot::processInteractive($co, $ctx, 'apertura');
$r = NormalBot::processInteractive($co, $ctx, $hoy);
$filas = filasDe($r);
check('lista de lotes con "Otro lote" y "Listo"',
isset($filas['__todos']) && isset($filas['__listo']));
check('solo lotes de la finca elegida, ninguno ajeno',
!isset($filas['99']) && !str_contains(implode(' ', $filas), 'ROSA BLANCA'));
$r = NormalBot::processInteractive($co, $ctx, '1');
check('marcar redibuja con el check', str_contains(filasDe($r)['1'] ?? '', '✅'));
$r = NormalBot::processInteractive($co, $ctx, '2');
check('el contador acumula', str_contains(filasDe($r)['__listo'] ?? '', '(2)'));
$r = NormalBot::processInteractive($co, $ctx, '__todos');
check('"Otro lote" pasa al catálogo completo, paginado', isset(filasDe($r)['__mas']));
check('sin perder lo marcado', str_contains(filasDe($r)['1'] ?? '', '✅'));
$r = NormalBot::processInteractive($co, $ctx, '__mas');
check('y la página 2 alcanza el lote 12', isset(filasDe($r)['12']));
$r = NormalBot::processInteractive($co, $ctx, '__mas'); // vuelve: no hay página 3
$r = NormalBot::processInteractive($co, $ctx, '__mas');
$r = NormalBot::processInteractive($co, $ctx, '__listo');
check('resumen final', isset(filasDe($r)['__cap_confirm']));
capturas();
NormalBot::processInteractive($co, $ctx, '__cap_confirm');
$post = array_values(array_filter(capturas(), fn($c) => str_contains($c['peticion'] ?? '', 'ciclos_')))[0] ?? null;
check('la URL resolvió el tipo elegido', $post['peticion'] ?? null, 'ciclos_cosecha_up');
check('lote_ids como arreglo', $post['body']['lote_ids'] ?? null, ['1', '2']);
check('con la acción y la fecha',
[$post['body']['accion'] ?? null, $post['body']['fecha'] ?? null], ['apertura', $hoy]);
// ════ 5. Mantenimiento: requires/resolver + payload completo ═════════════════
echo "\nMantenimiento — el grupo se pide primero\n";
$ctx = contexto('57300MANT');
$r = NormalBot::processInteractive($co, $ctx, 'registrar_mantenimiento');
check('sin grupo elegido desvía al selector', isset(filasDe($r)['5']));
$r = NormalBot::processInteractive($co, $ctx, '5');
check('y vuelve al flujo: pide la fecha', str_contains(textoDe($r), 'De qué fecha'));
$r = NormalBot::processInteractive($co, $ctx, $hoy);
check('el lote muestra la cantidad faltante', str_contains(filasDe($r)['4'] ?? '', 'falta 12'));
$r = NormalBot::processInteractive($co, $ctx, '4');
check('la labor sale del grupo elegido', filasDe($r), ['77' => 'PLATEO MANUAL']);
$r = NormalBot::processInteractive($co, $ctx, '77');
$r = NormalBot::processInteractive($co, $ctx, '412');
$r = NormalBot::processInteractive($co, $ctx, '__listo');
$r = NormalBot::process($co, $ctx, '12');
check('resumen', isset(filasDe($r)['__cap_confirm']));
capturas();
NormalBot::processInteractive($co, $ctx, '__cap_confirm');
$post = array_values(array_filter(capturas(), fn($c) => ($c['peticion'] ?? '') === 'labores_up'))[0] ?? null;
check('postea todo lo que labores_up exige',
[$post['body']['novedad_id'] ?? null, $post['body']['lote_id'] ?? null,
$post['body']['empleados'] ?? null, $post['body']['cantidad'] ?? null],
['77', '4', ['412'], '12']);
// ════ 6. Labores fase 2 ═══════════════════════════════════════════════════════
echo "\nLabores diarias — cuadrilla completa\n";
$ctx = contexto('57300LABOR');
$id = ConversationContext::getOrCreate(1, '57300LABOR')['id'];
ConversationContext::updateMetadata($id, ['finca' => ['finca_id' => '4', 'finca_label' => 'REPOSO']]);
$r = NormalBot::processInteractive($co, $ctx, 'registrar_labor');
$r = NormalBot::processInteractive($co, $ctx, $hoy);
check('el catálogo viene filtrado por fase', filasDe($r), ['42' => 'TRACTORISTA', '43' => 'HORAS EXTRA']);
$r = NormalBot::processInteractive($co, $ctx, '42');
check('los lotes son los de su finca', isset(filasDe($r)['4']) && isset(filasDe($r)['9']));
check('sin lotes de otras fincas', !isset(filasDe($r)['99']));
$r = NormalBot::processInteractive($co, $ctx, '4');
$r = NormalBot::processInteractive($co, $ctx, '412');
$r = NormalBot::processInteractive($co, $ctx, '415');
$r = NormalBot::processInteractive($co, $ctx, '__listo');
$r = NormalBot::process($co, $ctx, '1');
capturas();
NormalBot::processInteractive($co, $ctx, '__cap_confirm');
$post = array_values(array_filter(capturas(), fn($c) => ($c['peticion'] ?? '') === 'labores_up'))[0] ?? null;
check('la cuadrilla viaja como arreglo', $post['body']['empleados'] ?? null, ['412', '415']);
// ════ 7. NLU: entity resuelve el grupo y ejecuta el informe ═══════════════════
echo "\nNLU — \"informe de mantenimiento de plateo\" en un mensaje\n";
$ctx = contexto('57300NLU');
$id = ConversationContext::getOrCreate(1, '57300NLU')['id'];
ConversationContext::updateMetadata($id, ['finca' => ['finca_id' => '4', 'finca_label' => 'REPOSO']]);
ConversationContext::updateNode($id, '__greeted');
AiBot::$respuestas = [[
'action' => 'route', 'key' => 'ciclo_mantenimiento_top',
'entities' => ['grupo' => 'plateo'],
]];
capturas();
$r = NormalBot::process($co, $ctx, 'quiero informe de mantenimiento de plateo');
check('el informe llegó sin menús intermedios', str_contains(textoDe($r), 'informe-de-prueba'));
$get = array_values(array_filter(capturas(), fn($c) => str_contains($c['peticion'] ?? '', 'mantenimiento_top')))[0] ?? null;
check('la entity resolvió el grupo en la URL', $get['query']['grupo'] ?? null, '5');
check('y la consulta dice quién pregunta', $get['query']['wa'] ?? null, '57300NLU');
// ════ 8. NLU coherente con los módulos del perfil ═════════════════════════════
echo "\nNLU — respeta los módulos habilitados\n";
// Este número solo puede cargar pluviometría
fakePhone('57300SOLOPLUVIO', null, 0);
FakeDb::$phones['57300SOLOPLUVIO']['modulos_json'] = json_encode(['carga' => ['pluviometria']]);
$ctx = contexto('57300SOLOPLUVIO');
$id = ConversationContext::getOrCreate(1, '57300SOLOPLUVIO')['id'];
ConversationContext::updateMetadata($id, ['finca' => ['finca_id' => '4', 'finca_label' => 'REPOSO']]);
ConversationContext::updateNode($id, '__greeted');
// "subir pluviometría" → directo a la fecha, un solo mensaje
AiBot::$respuestas = [['action' => 'route', 'key' => 'registrar_pluviometria']];
$r = NormalBot::process($co, $ctx, 'subir pluviometría');
check('"subir pluviometría" va de una a la fecha', str_contains(textoDe($r), 'De qué fecha'));
check('el NLU recibió su alcance de módulos', AiBot::$modulosRecibidos, ['pluviometria']);
// El modelo devuelve una clave fuera del alcance: el guard la corta
ConversationContext::updateNode($id, '__greeted');
ConversationContext::updateMetadata($id, ['finca' => ['finca_id' => '4', 'finca_label' => 'REPOSO']]);
WhatsAppSender::$enviados = [];
AiBot::$respuestas = [['action' => 'route', 'key' => 'registrar_ausentismo']];
$r = NormalBot::process($co, $ctx, 'registrar ausentismo');
check('un módulo vetado no ejecuta el flujo', !str_contains(textoDe($r), 'nombre o documento'));
check('avisa que no entendió',
str_contains(WhatsAppSender::$enviados[0]['text'] ?? '', 'No estoy seguro'));
// Sin restricción, "subir labores diarias" también entra directo
$ctx = contexto('57300LABORNLU');
$id = ConversationContext::getOrCreate(1, '57300LABORNLU')['id'];
ConversationContext::updateMetadata($id, ['finca' => ['finca_id' => '4', 'finca_label' => 'REPOSO']]);
ConversationContext::updateNode($id, '__greeted');
AiBot::$respuestas = [['action' => 'route', 'key' => 'registrar_labor']];
$r = NormalBot::process($co, $ctx, 'subir labores diarias');
check('"subir labores diarias" arranca el flujo directo', str_contains(textoDe($r), 'De qué fecha es la labor'));
capturas();
// ════ Resultado ═══════════════════════════════════════════════════════════════
echo "\n" . ($GLOBALS['fallas'] ? "{$GLOBALS['fallas']} falla(s)\n" : "Todos los flujos ejecutan de punta a punta\n");
exit($GLOBALS['fallas'] ? 1 : 0);