Files
motor-de-encuestas/lib/form-engine.test.mts
markosbenitez ed524af16b feat(engine): motor de resolución de flujo declarativo [4C]
Módulo puro que interpreta el esquema v3 y resuelve pantallas.
5 operadores (eq, eq_any, not_in, contains, eq_sole), acciones show/hide
(hide = veto absoluto). Instancias con ID estable por posición de opción,
secciones-pantalla. Navegación por ID estable. 16/16 tests Node en verde.
No reemplaza aún lib/conditional.ts de v2 (lo hace 4D al migrar el componente).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-23 07:21:03 -03:00

345 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Tests Node puros (sin navegador) del motor de resolución de flujo v3.
// Ejecutar: node --test lib/form-engine.test.mts
//
// Extensión .mts (no .ts): así Node ejecuta el archivo como ESM nativo con
// soporte de TypeScript sin configuración adicional, y el glob "**/*.ts" del
// tsconfig.json del proyecto (usado por `next build`/`tsc`) no lo incluye —
// evita el conflicto entre "Node necesita extensión .ts explícita en los
// imports" y "tsc rechaza imports con extensión .ts sin allowImportingTsExtensions".
import { test, describe } from 'node:test'
import assert from 'node:assert/strict'
import {
buildFormSchema,
evaluateRule,
resolveVisibility,
resolveScreens,
initialState,
setAnswer,
next,
back,
} from './form-engine.ts'
import type { ConditionalRule, FormAnswers, Question } from './form-engine.types.ts'
// ── Fixture: réplica fiel de S3S10 del seed v3 (supabase/seed.sql) ──────────
// S1/S2 no tienen conditional_rules y no aportan a estos tests; se omiten.
function q(partial: Partial<Question> & Pick<Question, 'code' | 'type' | 'prompt' | 'position'>): Question {
return {
id: partial.code,
is_sensitive: false,
required: false,
hint: null,
options: null,
max_select: null,
conditional_rules: null,
instance_generator: false,
instance_of: null,
...partial,
}
}
const VINCULOS = [
'Hijo menor sin discapacidad',
'Hijo menor con discapacidad',
'Hijo adulto con discapacidad',
'Familiar menor con discapacidad',
'Familiar mayor con discapacidad',
'Padre/madre con movilidad reducida o 100% dependiente',
'Otro / Especificar',
'Ninguna de las anteriores / No tengo vinculación de cuidado',
] as const
const NINGUNA = VINCULOS[7]
const SOLO_NINO = VINCULOS[0]
const r_show_si_s3_1: ConditionalRule[] = [
{ if_question: '3.1', op: 'eq', value: 'Sí', action: 'show' },
]
const r_hide_ning: ConditionalRule[] = [
{ if_question: '4.1', op: 'contains', value: NINGUNA, action: 'hide' },
]
const r_hide_ning_sn: ConditionalRule[] = [
{ if_question: '4.1', op: 'contains', value: NINGUNA, action: 'hide' },
{ if_question: '4.1', op: 'eq_sole', value: SOLO_NINO, action: 'hide' },
]
function buildFixtureSchema() {
const rows: Question[] = [
// S3 — Prevalencia de discapacidad
q({ code: 'S3', type: 'section', prompt: 'Prevalencia de discapacidad', position: 30 }),
q({ code: '3.1', type: 'single_choice', prompt: '¿Tenés discapacidad?', position: 31 }),
q({ code: '3.2', type: 'multiple_choice', prompt: 'Tipo', position: 32, conditional_rules: r_show_si_s3_1 }),
q({ code: '3.3', type: 'multiple_choice', prompt: 'Dispositivos', position: 33, conditional_rules: r_show_si_s3_1 }),
q({ code: '3.4', type: 'single_choice', prompt: 'Certificado', position: 34, conditional_rules: r_show_si_s3_1 }),
q({ code: '3.5', type: 'multiple_choice', prompt: 'Impacto', position: 35, conditional_rules: r_show_si_s3_1 }),
// S4 — Vinculación familiar (4.1 generador; 4.2/4.3 instancia)
q({ code: 'S4', type: 'section', prompt: 'Vinculación familiar', position: 40 }),
q({
code: '4.1', type: 'multiple_choice', prompt: 'Vínculos', position: 41,
instance_generator: true,
options: VINCULOS.map((v) => ({ value: v, label: v })),
}),
q({ code: '4.2', type: 'multiple_choice', prompt: 'Tipo familiar', position: 42, instance_of: '4.1', conditional_rules: r_hide_ning_sn }),
q({ code: '4.3', type: 'multiple_choice', prompt: 'Dispositivos familiar', position: 43, instance_of: '4.1', conditional_rules: r_hide_ning_sn }),
// S5 — Responsabilidad y cuidado
q({ code: 'S5', type: 'section', prompt: 'Responsabilidad y cuidado', position: 50, conditional_rules: r_hide_ning }),
q({ code: '5.1', type: 'multiple_choice', prompt: 'Rol', position: 51, conditional_rules: r_hide_ning_sn }),
q({ code: '5.2', type: 'multiple_choice', prompt: 'Momento', position: 52, conditional_rules: r_hide_ning_sn }),
q({ code: '5.3', type: 'multiple_choice', prompt: 'Quién cuida en horario laboral', position: 53, conditional_rules: r_hide_ning }),
q({ code: '5.4', type: 'single_choice', prompt: 'Desde cuándo', position: 54, conditional_rules: r_hide_ning_sn }),
// S6 — Intensidad del cuidado
q({ code: 'S6', type: 'section', prompt: 'Intensidad del cuidado', position: 60, conditional_rules: r_hide_ning_sn }),
q({ code: '6.1', type: 'single_choice', prompt: 'Días/semana', position: 61, conditional_rules: r_hide_ning_sn }),
q({ code: '6.2', type: 'single_choice', prompt: 'Horas/día', position: 62, conditional_rules: r_hide_ning_sn }),
q({ code: '6.3', type: 'multiple_choice', prompt: 'Momentos', position: 63, conditional_rules: r_hide_ning_sn }),
q({ code: '6.4', type: 'single_choice', prompt: 'Disponibilidad', position: 64, conditional_rules: r_hide_ning_sn }),
// S7 — Impacto en el trabajo
q({ code: 'S7', type: 'section', prompt: 'Impacto en el trabajo', position: 70, conditional_rules: r_hide_ning_sn }),
q({ code: '7.1', type: 'single_choice', prompt: 'Descanso', position: 71, conditional_rules: r_hide_ning_sn }),
q({ code: '7.2', type: 'single_choice', prompt: 'Concentración', position: 72, conditional_rules: r_hide_ning_sn }),
q({ code: '7.3', type: 'single_choice', prompt: 'Ausencias', position: 73, conditional_rules: r_hide_ning_sn }),
q({ code: '7.4', type: 'multiple_choice', prompt: 'Aspectos afectados', position: 74, conditional_rules: r_hide_ning_sn }),
q({ code: '7.5', type: 'multiple_choice', prompt: 'Consecuencias laborales', position: 75, conditional_rules: r_hide_ning_sn }),
q({ code: '7.6', type: 'single_choice', prompt: 'Probabilidad de dejar', position: 76, conditional_rules: r_hide_ning_sn }),
// S8 — Impacto económico
q({ code: 'S8', type: 'section', prompt: 'Impacto económico', position: 80, conditional_rules: r_hide_ning }),
q({ code: '8.1', type: 'single_choice', prompt: 'Gasto mensual', position: 81, conditional_rules: r_hide_ning_sn }),
q({ code: '8.2', type: 'multiple_choice', prompt: 'Conceptos', position: 82, conditional_rules: r_hide_ning_sn }),
q({ code: '8.3', type: 'multiple_choice', prompt: 'Cómo sostiene', position: 83, conditional_rules: r_hide_ning_sn }),
q({ code: '8.4', type: 'single_choice', prompt: 'Franja salarial', position: 84, is_sensitive: true, conditional_rules: r_hide_ning }),
// S9 — Conciencia organizacional
q({ code: 'S9', type: 'section', prompt: 'Conciencia organizacional', position: 90, conditional_rules: r_hide_ning }),
q({ code: '9.1', type: 'single_choice', prompt: 'Conciencia empresa', position: 91, conditional_rules: r_hide_ning }),
q({ code: '9.2', type: 'multiple_choice', prompt: 'Apoyos', position: 92, conditional_rules: r_hide_ning_sn }),
q({ code: '9.3', type: 'single_choice', prompt: 'Grupo de apoyo', position: 93, conditional_rules: r_hide_ning_sn }),
q({ code: '9.4', type: 'single_choice', prompt: 'Voluntariado', position: 94, conditional_rules: r_hide_ning }),
// S10 — Comentarios adicionales
q({ code: 'S10', type: 'section', prompt: 'Comentarios adicionales', position: 100 }),
q({ code: '10.1', type: 'text_long', prompt: 'Comentario', position: 101 }),
]
return buildFormSchema(rows)
}
function codesOf(screens: ReturnType<typeof resolveScreens>): string[] {
return screens.filter((s) => s.kind !== 'end').map((s) => s.question!.code)
}
// ── 1. Recorrido completo ────────────────────────────────────────────────
test('recorrido completo: todos los vínculos (2-7) muestran S5-S9 enteras + 12 pantallas de instancia', () => {
const schema = buildFixtureSchema()
const answers: FormAnswers = {
'4.1:default': VINCULOS.slice(1, 7), // opciones 2..7, sin soloNino ni Ninguna
}
const screens = resolveScreens(schema, answers)
const codes = codesOf(screens)
for (const expected of ['S5', '5.1', '5.2', '5.3', '5.4', 'S6', '6.1', '6.4', 'S7', '7.1', '7.6', 'S8', '8.1', '8.4', 'S9', '9.1', '9.4']) {
assert.ok(codes.includes(expected), `falta ${expected} en recorrido completo`)
}
const instanceScreens = screens.filter((s) => s.instanceId !== null)
assert.equal(instanceScreens.length, 12, '6 familiares x 2 preguntas (4.2+4.3) = 12 pantallas')
})
// ── 2. Solo niño (eq_sole) ───────────────────────────────────────────────
test('solo niño sin discapacidad (eq_sole): recorrido liviano 5.3, 8.4, 9.1, 9.4', () => {
const schema = buildFixtureSchema()
const answers: FormAnswers = { '4.1:default': [SOLO_NINO] }
const screens = resolveScreens(schema, answers)
const codes = codesOf(screens)
// visibles: S5(header)+5.3, S8(header)+8.4, S9(header)+9.1+9.4
for (const expected of ['S5', '5.3', 'S8', '8.4', 'S9', '9.1', '9.4']) {
assert.ok(codes.includes(expected), `falta ${expected} en soloNino`)
}
// ocultas
for (const hidden of ['5.1', '5.2', '5.4', 'S6', '6.1', 'S7', '7.1', '8.1', '8.2', '8.3', '9.2', '9.3']) {
assert.ok(!codes.includes(hidden), `${hidden} no debería aparecer en soloNino`)
}
// 4.2/4.3 no se muestran (la única instancia generada queda oculta por r_hide_ning_sn)
assert.equal(screens.filter((s) => s.instanceId !== null).length, 0)
})
// ── 3. Ninguna de las anteriores (contains) ──────────────────────────────
test('"Ninguna de las anteriores" (contains): salta directo a S10, incluso combinada con otra opción', () => {
const schema = buildFixtureSchema()
const answers: FormAnswers = { '4.1:default': [VINCULOS[2], NINGUNA] } // marcada junto con otra
const screens = resolveScreens(schema, answers)
const codes = codesOf(screens)
for (const hidden of ['S5', 'S6', 'S7', 'S8', '8.4', 'S9', '9.1', '9.4']) {
assert.ok(!codes.includes(hidden), `${hidden} no debería aparecer con Ninguna marcada`)
}
assert.ok(codes.includes('S10') && codes.includes('10.1'))
// el índice de S4 (4.1) debe ser inmediatamente anterior a S10 en la lista
const idxS4 = codes.indexOf('4.1')
const idxS10 = codes.indexOf('S10')
assert.equal(idxS10, idxS4 + 1, 'S10 debe seguir inmediatamente a 4.1')
})
// ── 4. 3.1='No' oculta 3.2-3.5 ────────────────────────────────────────────
test("3.1='No' oculta 3.2-3.5; 3.1='Sí' las muestra", () => {
const schema = buildFixtureSchema()
const codesNo = codesOf(resolveScreens(schema, { '3.1:default': 'No' }))
for (const hidden of ['3.2', '3.3', '3.4', '3.5']) assert.ok(!codesNo.includes(hidden))
assert.ok(codesNo.includes('3.1'))
const codesSi = codesOf(resolveScreens(schema, { '3.1:default': 'Sí' }))
for (const shown of ['3.2', '3.3', '3.4', '3.5']) assert.ok(codesSi.includes(shown))
})
// ── 5. 3 familiares → 6 pantallas de instancia en orden por familiar ─────
test('3 familiares (seleccionados fuera de orden) generan 6 pantallas en orden de esquema, no de selección', () => {
const schema = buildFixtureSchema()
// Selección deliberadamente "fuera de orden" respecto a VINCULOS: opción 6, luego 2, luego 4.
const answers: FormAnswers = { '4.1:default': [VINCULOS[5], VINCULOS[1], VINCULOS[3]] }
const screens = resolveScreens(schema, answers)
const instanceScreens = screens.filter((s) => s.instanceId !== null)
assert.equal(instanceScreens.length, 6)
// Orden esperado por posición FIJA en options (índices 1, 3, 5 → familiar_2, familiar_4, familiar_6)
assert.deepEqual(
instanceScreens.map((s) => s.id),
[
'question:4.2:familiar_2', 'question:4.3:familiar_2',
'question:4.2:familiar_4', 'question:4.3:familiar_4',
'question:4.2:familiar_6', 'question:4.3:familiar_6',
]
)
})
test('IDs de instancia son estables: agregar un vínculo nuevo no reindexa los existentes', () => {
const schema = buildFixtureSchema()
const before = resolveScreens(schema, { '4.1:default': [VINCULOS[1], VINCULOS[4]] })
.filter((s) => s.instanceId !== null)
.map((s) => s.id)
assert.deepEqual(before, [
'question:4.2:familiar_2', 'question:4.3:familiar_2',
'question:4.2:familiar_5', 'question:4.3:familiar_5',
])
// Se agrega un vínculo ANTERIOR en el orden de opciones (índice 2) — los ids de
// familiar_2 y familiar_5 deben seguir siendo exactamente los mismos.
const after = resolveScreens(schema, { '4.1:default': [VINCULOS[1], VINCULOS[2], VINCULOS[4]] })
.filter((s) => s.instanceId !== null)
.map((s) => s.id)
assert.deepEqual(after, [
'question:4.2:familiar_2', 'question:4.3:familiar_2',
'question:4.2:familiar_3', 'question:4.3:familiar_3',
'question:4.2:familiar_5', 'question:4.3:familiar_5',
])
})
// ── 6. Navegación next/back coherente con instancias ─────────────────────
describe('navegación next/back', () => {
test('next/back recorren secuencialmente section→pregunta→instancias→fin', () => {
const schema = buildFixtureSchema()
let state = initialState(schema)
assert.equal(state.currentScreenId, 'section:S3')
state = next(state, schema)
assert.equal(state.currentScreenId, 'question:3.1')
state = setAnswer(state, '3.1', 'No')
// 3.2-3.5 ocultas → next desde 3.1 debe saltar directo a S4
state = next(state, schema)
assert.equal(state.currentScreenId, 'section:S4')
})
test('next/back son estables cuando cambian las instancias activas', () => {
const schema = buildFixtureSchema()
let state = initialState(schema)
state = setAnswer(state, '4.1', [VINCULOS[1], VINCULOS[4]]) // familiar_2, familiar_5
state.currentScreenId = 'question:4.1'
// Avanza a la primera pregunta de instancia
state = next(state, schema)
assert.equal(state.currentScreenId, 'question:4.2:familiar_2')
state = next(state, schema)
assert.equal(state.currentScreenId, 'question:4.3:familiar_2')
// Mientras está parado en 4.3:familiar_2, el usuario (vía 4D) agrega un
// vínculo anterior en el esquema (índice 2) sin moverse de pantalla.
state = setAnswer(state, '4.1', [VINCULOS[1], VINCULOS[2], VINCULOS[4]])
// El ID actual sigue existiendo y sigue siendo válido — no se desincroniza.
const next1 = next(state, schema)
assert.equal(next1.currentScreenId, 'question:4.2:familiar_3', 'avanza al nuevo familiar_3 insertado en medio')
const back1 = back(state, schema)
assert.equal(back1.currentScreenId, 'question:4.2:familiar_2', 'retrocede a familiar_2, sin verse afectado por el nuevo familiar_3')
})
test('back() desde S10 tras "Ninguna" vuelve a 4.1, saltando las secciones ocultas', () => {
const schema = buildFixtureSchema()
let state = initialState(schema)
state = setAnswer(state, '4.1', [NINGUNA])
state.currentScreenId = 'section:S10'
state = back(state, schema)
assert.equal(state.currentScreenId, 'question:4.1')
})
})
// ── Operadores: cobertura unitaria directa (vocabulario completo) ───────
describe('evaluateRule — los 5 operadores', () => {
const answers: FormAnswers = {
'3.1:default': 'Sí',
'4.1:default': ['Hijo menor con discapacidad', 'Otro / Especificar'],
'2.6:default': 'Jefatura',
}
test('eq', () => {
assert.equal(evaluateRule({ if_question: '3.1', op: 'eq', value: 'Sí', action: 'show' }, answers), true)
assert.equal(evaluateRule({ if_question: '3.1', op: 'eq', value: 'No', action: 'show' }, answers), false)
})
test('eq_any', () => {
const rule: ConditionalRule = { if_question: ['3.1', '2.6'], op: 'eq_any', value: 'Jefatura', action: 'show' }
assert.equal(evaluateRule(rule, answers), true)
})
test('not_in', () => {
const rule: ConditionalRule = { if_question: '2.6', op: 'not_in', value: ['Directivo', 'Gerencial'], action: 'hide' }
assert.equal(evaluateRule(rule, answers), true)
const rule2: ConditionalRule = { if_question: '2.6', op: 'not_in', value: ['Jefatura'], action: 'hide' }
assert.equal(evaluateRule(rule2, answers), false)
})
test('contains', () => {
const rule: ConditionalRule = { if_question: '4.1', op: 'contains', value: 'Otro / Especificar', action: 'hide' }
assert.equal(evaluateRule(rule, answers), true)
})
test('eq_sole', () => {
const rule: ConditionalRule = { if_question: '4.1', op: 'eq_sole', value: 'Otro / Especificar', action: 'hide' }
assert.equal(evaluateRule(rule, answers), false) // hay 2 elementos, no es el único
})
})
// ── Semántica show/hide combinados ───────────────────────────────────────
describe('resolveVisibility — combinación show/hide', () => {
test('hide gana siempre sobre show, aunque show también coincida', () => {
const mixed: ConditionalRule[] = [
{ if_question: '3.1', op: 'eq', value: 'Sí', action: 'show' },
{ if_question: '4.1', op: 'contains', value: NINGUNA, action: 'hide' },
]
const question = q({ code: 'X', type: 'single_choice', prompt: 'x', position: 1, conditional_rules: mixed })
const answers: FormAnswers = { '3.1:default': 'Sí', '4.1:default': [NINGUNA] }
assert.equal(resolveVisibility(question, answers), false)
})
test('sin reglas hide, show gana si coincide', () => {
const onlyShow: ConditionalRule[] = [{ if_question: '3.1', op: 'eq', value: 'Sí', action: 'show' }]
const question = q({ code: 'X', type: 'single_choice', prompt: 'x', position: 1, conditional_rules: onlyShow })
assert.equal(resolveVisibility(question, { '3.1:default': 'Sí' }), true)
assert.equal(resolveVisibility(question, { '3.1:default': 'No' }), false)
})
})