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:
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user