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>
345 lines
18 KiB
TypeScript
345 lines
18 KiB
TypeScript
// 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 S3–S10 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)
|
||
})
|
||
})
|