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:
markosbenitez
2026-06-23 07:21:03 -03:00
parent 5987502664
commit ed524af16b
3 changed files with 613 additions and 0 deletions

344
lib/form-engine.test.mts Normal file
View 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 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)
})
})

197
lib/form-engine.ts Normal file
View 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
View 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
}