diff --git a/lib/form-engine.test.mts b/lib/form-engine.test.mts new file mode 100644 index 0000000..37aef2f --- /dev/null +++ b/lib/form-engine.test.mts @@ -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 & 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) + }) +}) diff --git a/lib/form-engine.ts b/lib/form-engine.ts new file mode 100644 index 0000000..bc082d9 --- /dev/null +++ b/lib/form-engine.ts @@ -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() + 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 } +} diff --git a/lib/form-engine.types.ts b/lib/form-engine.types.ts new file mode 100644 index 0000000..3ef1e5b --- /dev/null +++ b/lib/form-engine.types.ts @@ -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 + +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 +}