From 61676e142368c788d41f55fa44cc70fea8bb079f Mon Sep 17 00:00:00 2001 From: markosbenitez Date: Tue, 23 Jun 2026 22:57:42 -0300 Subject: [PATCH] =?UTF-8?q?refactor(engine):=20mover=20limpieza=20de=20ins?= =?UTF-8?q?tancias=20y=20validaci=C3=B3n=20required=20al=20motor=20[4E]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit applyAnswer y isRequiredAndUnanswered pasan de wizard-form.tsx al motor como funciones puras — eran lógica de flujo en el presentador (viola ADR-005). Limpieza de respuestas huérfanas al remover un vínculo y validación required por instancia, ahora testeables. 20/20 tests. Verificado end-to-end con navegador. 4E sin construcción pendiente: el mini-wizard es propiedad emergente del motor. Co-Authored-By: Claude Sonnet 4.6 --- components/wizard-form.tsx | 20 ++------- lib/form-engine.test.mts | 91 ++++++++++++++++++++++++++++++++++++++ lib/form-engine.ts | 46 +++++++++++++++++++ 3 files changed, 141 insertions(+), 16 deletions(-) diff --git a/components/wizard-form.tsx b/components/wizard-form.tsx index 3a472bc..a7b98f8 100644 --- a/components/wizard-form.tsx +++ b/components/wizard-form.tsx @@ -4,12 +4,13 @@ import type { BrandConfig, Survey } from '@/lib/types' import type { AnswerValue, FormAnswers, FormSchema, Screen } from '@/lib/form-engine.types' import { answerKey, + applyAnswer, expandInstances, initialState, + isRequiredAndUnanswered, next, back, resolveScreens, - setAnswer, } from '@/lib/form-engine' import { loadDraft, saveDraft } from '@/lib/storage' import { ConsentScreen } from './consent-screen' @@ -137,20 +138,7 @@ export function WizardForm({ survey, schema }: WizardFormProps) { const code = screen.question.code const instanceId = screen.instanceId ?? undefined - setState((prev) => { - const updated = setAnswer(prev, code, value, instanceId) - // Limpia respuestas de pantallas que dejaron de ser visibles tras este cambio. - const before = resolveScreens(schema, prev.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 } - }) + setState((prev) => applyAnswer(prev, schema, code, value, instanceId)) setErrors((prev) => { if (!prev[code]) return prev @@ -160,7 +148,7 @@ export function WizardForm({ survey, schema }: WizardFormProps) { } function handleNext() { - if (screen.kind === 'question' && screen.question?.required && isEmpty(currentValue)) { + if (isRequiredAndUnanswered(screen, state.answers)) { setErrors((e) => ({ ...e, [screen.question!.code]: 'Este campo es obligatorio.' })) return } diff --git a/lib/form-engine.test.mts b/lib/form-engine.test.mts index 37aef2f..ac92c6a 100644 --- a/lib/form-engine.test.mts +++ b/lib/form-engine.test.mts @@ -15,6 +15,8 @@ import { resolveScreens, initialState, setAnswer, + applyAnswer, + isRequiredAndUnanswered, next, back, } from './form-engine.ts' @@ -342,3 +344,92 @@ describe('resolveVisibility — combinación show/hide', () => { assert.equal(resolveVisibility(question, { '3.1:default': 'No' }), false) }) }) + +// ── applyAnswer — integridad de datos al remover una instancia ────────── +// Esta lógica vivía en wizard-form.tsx (handleChange) hasta ahora; se movió +// al motor para que sea testeable como módulo puro (ver conversación 4E). +describe('applyAnswer — limpieza de respuestas al remover una instancia', () => { + test('quitar un vínculo de 4.1 borra las respuestas de esa instancia y deja intactas las del resto', () => { + const schema = buildFixtureSchema() + let state = { answers: {} as FormAnswers, currentScreenId: 'question:4.1' } + + // Selecciona 2 vínculos → familiar_2 (índice 1) y familiar_5 (índice 4) + state = applyAnswer(state, schema, '4.1', [VINCULOS[1], VINCULOS[4]]) + + // Responde 4.2/4.3 para AMBAS instancias + state = applyAnswer(state, schema, '4.2', ['Visual'], 'familiar_2') + state = applyAnswer(state, schema, '4.3', ['Bastón blanco'], 'familiar_2') + state = applyAnswer(state, schema, '4.2', ['Auditiva'], 'familiar_5') + state = applyAnswer(state, schema, '4.3', ['Audífonos'], 'familiar_5') + + assert.deepEqual(state.answers['4.2:familiar_2'], ['Visual']) + assert.deepEqual(state.answers['4.3:familiar_2'], ['Bastón blanco']) + assert.deepEqual(state.answers['4.2:familiar_5'], ['Auditiva']) + assert.deepEqual(state.answers['4.3:familiar_5'], ['Audífonos']) + + // Quita el vínculo de familiar_2 — deja solo familiar_5 en 4.1 + state = applyAnswer(state, schema, '4.1', [VINCULOS[4]]) + + // Las respuestas de la instancia removida desaparecen del estado + assert.equal('4.2:familiar_2' in state.answers, false) + assert.equal('4.3:familiar_2' in state.answers, false) + + // Las del familiar que queda permanecen intactas, sin reindexarse + assert.deepEqual(state.answers['4.2:familiar_5'], ['Auditiva']) + assert.deepEqual(state.answers['4.3:familiar_5'], ['Audífonos']) + }) + + test('aplicar la misma respuesta sin cambiar visibilidad no borra nada (caso sin disappeared)', () => { + const schema = buildFixtureSchema() + let state = { answers: {} as FormAnswers, currentScreenId: 'question:3.1' } + state = applyAnswer(state, schema, '3.1', 'Sí') + state = applyAnswer(state, schema, '3.2', ['Física / motora']) + state = applyAnswer(state, schema, '3.1', 'Sí') // mismo valor, no cambia nada visible + assert.deepEqual(state.answers['3.2:default'], ['Física / motora']) + }) +}) + +// ── isRequiredAndUnanswered — incluye preguntas de instancia ───────────── +// El seed v3 actual no marca ninguna pregunta de instancia (4.2/4.3) como +// required — este test usa un esquema sintético mínimo para cubrir el +// camino igual, ya que el código no debe asumir que nunca va a pasar. +function buildRequiredInstanceSchema() { + const rows: Question[] = [ + q({ + code: 'G1', type: 'multiple_choice', prompt: 'Generador', position: 1, + instance_generator: true, + options: [{ value: 'a', label: 'A' }, { value: 'b', label: 'B' }], + }), + q({ code: 'G1.1', type: 'text_short', prompt: 'Dato requerido por instancia', position: 2, instance_of: 'G1', required: true }), + ] + return buildFormSchema(rows) +} + +describe('isRequiredAndUnanswered — cubre preguntas de instancia required', () => { + test('cada instancia de una pregunta required se evalúa por separado', () => { + const schema = buildRequiredInstanceSchema() + const answers: FormAnswers = { 'G1:default': ['a', 'b'] } + const screens = resolveScreens(schema, answers) + + const screenA = screens.find((s) => s.id === 'question:G1.1:familiar_1') + const screenB = screens.find((s) => s.id === 'question:G1.1:familiar_2') + assert.ok(screenA && screenB, 'deben existir 2 pantallas de instancia para G1.1') + + // Sin responder ninguna instancia → unanswered en ambas + assert.equal(isRequiredAndUnanswered(screenA!, answers), true) + assert.equal(isRequiredAndUnanswered(screenB!, answers), true) + + // Responder SOLO la instancia 1 → deja de exigirse esa, la otra sigue pendiente + const partial = { ...answers, 'G1.1:familiar_1': 'dato' } + assert.equal(isRequiredAndUnanswered(screenA!, partial), false) + assert.equal(isRequiredAndUnanswered(screenB!, partial), true) + }) + + test('preguntas no-required nunca bloquean, tengan o no respuesta', () => { + const schema = buildFixtureSchema() + const screens = resolveScreens(schema, {}) + const screen = screens.find((s) => s.id === 'question:3.1') + assert.ok(screen) + assert.equal(isRequiredAndUnanswered(screen!, {}), false) + }) +}) diff --git a/lib/form-engine.ts b/lib/form-engine.ts index bc082d9..53d6e9b 100644 --- a/lib/form-engine.ts +++ b/lib/form-engine.ts @@ -176,6 +176,52 @@ export function setAnswer( } } +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