// 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 }, } } /** Exportada porque también la usa el validador de submit server-side (4G). */ export function isAnswerEmpty(v: AnswerValue | undefined): boolean { return v === null || v === undefined || v === '' || (Array.isArray(v) && v.length === 0) } /** * Aplica una respuesta y limpia las respuestas de pantallas que dejaron de * ser visibles como CONSECUENCIA de ese cambio (ej.: sacar un vínculo de 4.1 * borra las respuestas de 4.2/4.3 de esa instancia puntual; las de las demás * instancias quedan intactas porque conservan su propio answerKey). Esto es * consecuencia de flujo (ADR-005), no presentación — por eso vive en el * motor y no en el componente. */ export function applyAnswer( state: WizardState, schema: FormSchema, code: string, value: AnswerValue, instanceId: string = DEFAULT_INSTANCE ): WizardState { const updated = setAnswer(state, code, value, instanceId) const before = resolveScreens(schema, state.answers) const after = resolveScreens(schema, updated.answers) const afterIds = new Set(after.map((s) => s.id)) const disappeared = before.filter((s) => !afterIds.has(s.id) && s.question) if (disappeared.length === 0) return updated const clearedAnswers = { ...updated.answers } for (const s of disappeared) { delete clearedAnswers[answerKey(s.question!.code, s.instanceId ?? undefined)] } return { ...updated, answers: clearedAnswers } } /** * true si la pantalla es una pregunta `required` y todavía no tiene * respuesta — incluye preguntas de instancia (cada instancia se evalúa por * su propio answerKey, independiente de las demás). El seed v3 actual no * marca ninguna pregunta de instancia como required, pero el camino queda * cubierto para cuando exista una. */ export function isRequiredAndUnanswered(screen: Screen, answers: FormAnswers): boolean { if (!screen.question?.required) return false return isAnswerEmpty(answers[answerKey(screen.question.code, screen.instanceId ?? undefined)]) } /** * 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 } }