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>
This commit is contained in:
344
lib/form-engine.test.mts
Normal file
344
lib/form-engine.test.mts
Normal file
@@ -0,0 +1,344 @@
|
|||||||
|
// 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)
|
||||||
|
})
|
||||||
|
})
|
||||||
197
lib/form-engine.ts
Normal file
197
lib/form-engine.ts
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
// Motor de resolución de flujo v3 (ADR-005) — módulo puro, sin React ni DOM.
|
||||||
|
// Reemplaza conceptualmente a lib/conditional.ts (v2); el componente (4D) decide
|
||||||
|
// cuándo migrar. Entrada→salida determinista: dado un FormSchema y un FormAnswers,
|
||||||
|
// siempre resuelve la misma lista de pantallas.
|
||||||
|
|
||||||
|
import type {
|
||||||
|
AnswerValue,
|
||||||
|
ConditionalRule,
|
||||||
|
FormAnswers,
|
||||||
|
FormSchema,
|
||||||
|
Question,
|
||||||
|
Screen,
|
||||||
|
WizardState,
|
||||||
|
} from './form-engine.types'
|
||||||
|
|
||||||
|
const DEFAULT_INSTANCE = 'default'
|
||||||
|
|
||||||
|
export function answerKey(code: string, instanceId: string = DEFAULT_INSTANCE): string {
|
||||||
|
return `${code}:${instanceId}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAnswer(answers: FormAnswers, code: string, instanceId = DEFAULT_INSTANCE): AnswerValue {
|
||||||
|
const v = answers[answerKey(code, instanceId)]
|
||||||
|
return v === undefined ? null : v
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Evalúa una regla individual. `if_question` siempre se resuelve contra
|
||||||
|
* instance_id='default': en el vocabulario v3 los disparadores (3.1, 4.1)
|
||||||
|
* nunca son preguntas de instancia. Si en el futuro una regla necesitara
|
||||||
|
* compararse contra la respuesta de la PROPIA instancia, este es el punto
|
||||||
|
* a extender (hoy no hace falta — no hay caso de uso en el seed v3).
|
||||||
|
*/
|
||||||
|
export function evaluateRule(rule: ConditionalRule, answers: FormAnswers): boolean {
|
||||||
|
switch (rule.op) {
|
||||||
|
case 'eq':
|
||||||
|
return getAnswer(answers, rule.if_question) === rule.value
|
||||||
|
case 'eq_any':
|
||||||
|
return rule.if_question.some((code) => getAnswer(answers, code) === rule.value)
|
||||||
|
case 'not_in': {
|
||||||
|
const v = getAnswer(answers, rule.if_question)
|
||||||
|
if (typeof v !== 'string') return false
|
||||||
|
return !rule.value.includes(v)
|
||||||
|
}
|
||||||
|
case 'contains': {
|
||||||
|
const v = getAnswer(answers, rule.if_question)
|
||||||
|
return Array.isArray(v) && v.includes(rule.value)
|
||||||
|
}
|
||||||
|
case 'eq_sole': {
|
||||||
|
const v = getAnswer(answers, rule.if_question)
|
||||||
|
return Array.isArray(v) && v.length === 1 && v[0] === rule.value
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Semántica de combinación show/hide (ver reporte en la conversación):
|
||||||
|
* 1. hide es veto absoluto — si CUALQUIER regla hide coincide, se oculta.
|
||||||
|
* 2. Si ninguna hide coincide y hay reglas show, se muestra solo si
|
||||||
|
* CUALQUIERA show coincide (OR); si hay show pero ninguna coincide, se oculta.
|
||||||
|
* 3. Si no hay ninguna regla show (solo hide, o array vacío/null), se
|
||||||
|
* muestra por defecto.
|
||||||
|
*/
|
||||||
|
export function resolveVisibility(question: Question, answers: FormAnswers): boolean {
|
||||||
|
const rules = question.conditional_rules
|
||||||
|
if (!rules || rules.length === 0) return true
|
||||||
|
|
||||||
|
const hideRules = rules.filter((r) => r.action === 'hide')
|
||||||
|
const showRules = rules.filter((r) => r.action === 'show')
|
||||||
|
|
||||||
|
if (hideRules.some((r) => evaluateRule(r, answers))) return false
|
||||||
|
if (showRules.length > 0) return showRules.some((r) => evaluateRule(r, answers))
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InstanceRef {
|
||||||
|
instanceId: string
|
||||||
|
optionValue: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deriva las instancias activas desde la respuesta del generador.
|
||||||
|
* El orden e id de cada instancia se basan en la POSICIÓN FIJA de la opción
|
||||||
|
* dentro de `generator.options` (definida por el esquema), nunca en el orden
|
||||||
|
* de selección del usuario ni en un índice del array de respuesta — así una
|
||||||
|
* instancia existente conserva su id estable aunque se agregue o quite otro
|
||||||
|
* vínculo (lección del mockup: el índice se desincroniza con las instancias).
|
||||||
|
*/
|
||||||
|
export function expandInstances(generator: Question, answers: FormAnswers): InstanceRef[] {
|
||||||
|
const selected = getAnswer(answers, generator.code)
|
||||||
|
if (!Array.isArray(selected) || !generator.options) return []
|
||||||
|
const refs: InstanceRef[] = []
|
||||||
|
generator.options.forEach((opt, idx) => {
|
||||||
|
if (selected.includes(opt.value)) {
|
||||||
|
refs.push({ instanceId: `familiar_${idx + 1}`, optionValue: opt.value })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return refs
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildFormSchema(rows: Question[]): FormSchema {
|
||||||
|
return { questions: [...rows].sort((a, b) => a.position - b.position) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resuelve la lista ordenada de pantallas visibles dado el esquema y el
|
||||||
|
* estado de respuestas actual. Pura: mismo input → mismo output siempre.
|
||||||
|
* Las preguntas instance_of se expanden agrupadas POR INSTANCIA (no por
|
||||||
|
* pregunta), para reproducir el orden familiar1: 4.2→4.3, familiar2: 4.2→4.3...
|
||||||
|
*/
|
||||||
|
export function resolveScreens(schema: FormSchema, answers: FormAnswers): Screen[] {
|
||||||
|
const screens: Screen[] = []
|
||||||
|
const consumedGroups = new Set<string>()
|
||||||
|
const byCode = new Map(schema.questions.map((q) => [q.code, q] as const))
|
||||||
|
|
||||||
|
for (const q of schema.questions) {
|
||||||
|
if (q.instance_of) {
|
||||||
|
if (consumedGroups.has(q.instance_of)) continue
|
||||||
|
consumedGroups.add(q.instance_of)
|
||||||
|
|
||||||
|
const generator = byCode.get(q.instance_of)
|
||||||
|
if (!generator) continue
|
||||||
|
|
||||||
|
const instances = expandInstances(generator, answers)
|
||||||
|
const groupRows = schema.questions
|
||||||
|
.filter((r) => r.instance_of === q.instance_of)
|
||||||
|
.sort((a, b) => a.position - b.position)
|
||||||
|
|
||||||
|
for (const instance of instances) {
|
||||||
|
for (const row of groupRows) {
|
||||||
|
if (resolveVisibility(row, answers)) {
|
||||||
|
screens.push({
|
||||||
|
id: `question:${row.code}:${instance.instanceId}`,
|
||||||
|
kind: 'question',
|
||||||
|
question: row,
|
||||||
|
instanceId: instance.instanceId,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (q.type === 'section') {
|
||||||
|
if (resolveVisibility(q, answers)) {
|
||||||
|
screens.push({ id: `section:${q.code}`, kind: 'section', question: q, instanceId: null })
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resolveVisibility(q, answers)) {
|
||||||
|
screens.push({ id: `question:${q.code}`, kind: 'question', question: q, instanceId: null })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
screens.push({ id: 'end', kind: 'end', question: null, instanceId: null })
|
||||||
|
return screens
|
||||||
|
}
|
||||||
|
|
||||||
|
export function initialState(schema: FormSchema): WizardState {
|
||||||
|
const screens = resolveScreens(schema, {})
|
||||||
|
return { answers: {}, currentScreenId: screens[0]?.id ?? 'end' }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setAnswer(
|
||||||
|
state: WizardState,
|
||||||
|
code: string,
|
||||||
|
value: AnswerValue,
|
||||||
|
instanceId: string = DEFAULT_INSTANCE
|
||||||
|
): WizardState {
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
answers: { ...state.answers, [answerKey(code, instanceId)]: value },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Avanza una pantalla. Recalcula la lista de pantallas visibles desde las
|
||||||
|
* respuestas actuales y busca la pantalla actual por ID (nunca por índice
|
||||||
|
* guardado de antes) — así, si el cambio de respuesta ocultó/agregó
|
||||||
|
* pantallas, next() sigue aterrizando en la pantalla correcta.
|
||||||
|
*/
|
||||||
|
export function next(state: WizardState, schema: FormSchema): WizardState {
|
||||||
|
const screens = resolveScreens(schema, state.answers)
|
||||||
|
const idx = screens.findIndex((s) => s.id === state.currentScreenId)
|
||||||
|
const nextIdx = Math.max(Math.min(idx + 1, screens.length - 1), 0)
|
||||||
|
return { ...state, currentScreenId: screens[nextIdx].id }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function back(state: WizardState, schema: FormSchema): WizardState {
|
||||||
|
const screens = resolveScreens(schema, state.answers)
|
||||||
|
const idx = screens.findIndex((s) => s.id === state.currentScreenId)
|
||||||
|
const prevIdx = idx <= 0 ? 0 : idx - 1
|
||||||
|
return { ...state, currentScreenId: screens[prevIdx]?.id ?? state.currentScreenId }
|
||||||
|
}
|
||||||
72
lib/form-engine.types.ts
Normal file
72
lib/form-engine.types.ts
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
// Tipos del motor de resolución de flujo v3 (ADR-005).
|
||||||
|
// Módulo aislado de lib/types.ts (v2) a propósito — el componente de
|
||||||
|
// producción (survey-form.tsx) no importa nada de aquí todavía. 4D decide
|
||||||
|
// cómo conviven o se unifican los dos juegos de tipos.
|
||||||
|
|
||||||
|
export type ConditionalAction = 'show' | 'hide'
|
||||||
|
|
||||||
|
export type ConditionalRule =
|
||||||
|
| { if_question: string; op: 'eq'; value: string; action: ConditionalAction }
|
||||||
|
| { if_question: string[]; op: 'eq_any'; value: string; action: ConditionalAction }
|
||||||
|
| { if_question: string; op: 'not_in'; value: string[]; action: ConditionalAction }
|
||||||
|
| { if_question: string; op: 'contains'; value: string; action: ConditionalAction }
|
||||||
|
| { if_question: string; op: 'eq_sole'; value: string; action: ConditionalAction }
|
||||||
|
|
||||||
|
export type QuestionType =
|
||||||
|
| 'text_short' | 'text_long' | 'single_choice' | 'multiple_choice'
|
||||||
|
| 'rating' | 'nps' | 'matrix' | 'ranking' | 'date' | 'slider' | 'yes_no' | 'section'
|
||||||
|
|
||||||
|
export interface QuestionOption {
|
||||||
|
value: string
|
||||||
|
label: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Question {
|
||||||
|
id: string
|
||||||
|
code: string
|
||||||
|
type: QuestionType
|
||||||
|
prompt: string
|
||||||
|
position: number
|
||||||
|
is_sensitive: boolean
|
||||||
|
required: boolean
|
||||||
|
hint: string | null
|
||||||
|
options: QuestionOption[] | null
|
||||||
|
max_select: number | null
|
||||||
|
conditional_rules: ConditionalRule[] | null
|
||||||
|
/** true solo en la pregunta que genera instancias (4.1 en v3). */
|
||||||
|
instance_generator: boolean
|
||||||
|
/** code de la pregunta generadora de la que depende esta fila (4.2/4.3 → '4.1'); null si no aplica. */
|
||||||
|
instance_of: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FormSchema {
|
||||||
|
/** Filas de `questions` ordenadas por position; incluye type='section'. */
|
||||||
|
questions: Question[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Valor de una respuesta: string (single_choice/text/yes_no) o array (multiple_choice). */
|
||||||
|
export type AnswerValue = string | string[] | number | null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Respuestas keyed por `${code}:${instance_id}`. Las preguntas normales usan
|
||||||
|
* instance_id='default'; las preguntas de instancia (instance_of) usan el id
|
||||||
|
* de su instancia (ej. 'familiar_1'), igual que `answers.instance_id` (Etapa 4A).
|
||||||
|
*/
|
||||||
|
export type FormAnswers = Record<string, AnswerValue>
|
||||||
|
|
||||||
|
export type ScreenKind = 'section' | 'question' | 'end'
|
||||||
|
|
||||||
|
export interface Screen {
|
||||||
|
/** ID estable. Nunca usar el índice de este array para identificar la pantalla actual. */
|
||||||
|
id: string
|
||||||
|
kind: ScreenKind
|
||||||
|
/** null solo para kind='end'. */
|
||||||
|
question: Question | null
|
||||||
|
/** id de instancia si la pantalla es una pregunta de instancia; null si no aplica. */
|
||||||
|
instanceId: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WizardState {
|
||||||
|
answers: FormAnswers
|
||||||
|
currentScreenId: string
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user