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

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