refactor(engine): mover limpieza de instancias y validación required al motor [4E]

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 <noreply@anthropic.com>
This commit is contained in:
markosbenitez
2026-06-23 22:57:42 -03:00
parent ce679ec1c3
commit 61676e1423
3 changed files with 141 additions and 16 deletions

View File

@@ -4,12 +4,13 @@ import type { BrandConfig, Survey } from '@/lib/types'
import type { AnswerValue, FormAnswers, FormSchema, Screen } from '@/lib/form-engine.types' import type { AnswerValue, FormAnswers, FormSchema, Screen } from '@/lib/form-engine.types'
import { import {
answerKey, answerKey,
applyAnswer,
expandInstances, expandInstances,
initialState, initialState,
isRequiredAndUnanswered,
next, next,
back, back,
resolveScreens, resolveScreens,
setAnswer,
} from '@/lib/form-engine' } from '@/lib/form-engine'
import { loadDraft, saveDraft } from '@/lib/storage' import { loadDraft, saveDraft } from '@/lib/storage'
import { ConsentScreen } from './consent-screen' import { ConsentScreen } from './consent-screen'
@@ -137,20 +138,7 @@ export function WizardForm({ survey, schema }: WizardFormProps) {
const code = screen.question.code const code = screen.question.code
const instanceId = screen.instanceId ?? undefined const instanceId = screen.instanceId ?? undefined
setState((prev) => { setState((prev) => applyAnswer(prev, schema, code, value, instanceId))
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 }
})
setErrors((prev) => { setErrors((prev) => {
if (!prev[code]) return prev if (!prev[code]) return prev
@@ -160,7 +148,7 @@ export function WizardForm({ survey, schema }: WizardFormProps) {
} }
function handleNext() { 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.' })) setErrors((e) => ({ ...e, [screen.question!.code]: 'Este campo es obligatorio.' }))
return return
} }

View File

@@ -15,6 +15,8 @@ import {
resolveScreens, resolveScreens,
initialState, initialState,
setAnswer, setAnswer,
applyAnswer,
isRequiredAndUnanswered,
next, next,
back, back,
} from './form-engine.ts' } from './form-engine.ts'
@@ -342,3 +344,92 @@ describe('resolveVisibility — combinación show/hide', () => {
assert.equal(resolveVisibility(question, { '3.1:default': 'No' }), false) 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)
})
})

View File

@@ -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 * Avanza una pantalla. Recalcula la lista de pantallas visibles desde las
* respuestas actuales y busca la pantalla actual por ID (nunca por índice * respuestas actuales y busca la pantalla actual por ID (nunca por índice