// 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 & Pick): 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): 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) }) })