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

@@ -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)
})
})