fix: recover saved investment context without resubmitting confirmation
This commit is contained in:
@@ -2152,3 +2152,14 @@ Corrigido no editor existente: os controlos referidos acompanham o estado ocupad
|
||||
Três casos novos falharam contra a revisão anterior e passaram na candidata: bloqueio/recuperação dos controlos durante comparação, recusa de resposta para rascunho alterado preservando a edição, e falha de nova comparação sem reutilização do plano anterior. Treze testes frontend dirigidos passaram. Tipos, lint, formatação e compilação QA passaram; provas históricas aceites reutilizadas, sem repetição integral. Detalhes/hashes em `context_preview_consistency_increment` do recibo existente `local-validation-20261029.json`.
|
||||
|
||||
**Conferência visual pendente pela equipa:** na candidata, comparar dados de um dossier autorizado, conferir bloqueio temporário e reposição dos controlos; preparar nova edição e repetir a comparação, garantindo que só a prévia actual permite guardar. Em falha de comunicação, os valores permanecem e exige-se nova comparação. Não gravar dados sintéticos no activo para este ensaio. Registar revisão, perfil, percurso, esperado/observado e evidência nesta matriz. Sem instalação ou avanço de main; os requisitos transversais continuam parciais.
|
||||
|
||||
|
||||
### 29/10/2026 — recuperar a versão guardada sem repetir a confirmação
|
||||
|
||||
Continuação sobre `26f6a0d3`, nos requisitos existentes V2-07/V2-10 e na orientação adaptativa da Tempo de 23/10. Reproduzida a diferença entre gravação e leitura: uma confirmação bem-sucedida seguida de falha na consulta era apresentada como falha genérica, com a prévia anterior ainda disponível. O rascunho consumido também podia ser reenviado ao sair antes da recuperação.
|
||||
|
||||
O editor invalida a prévia/corpo de confirmação e consome os parâmetros de entrada logo após resposta positiva da gravação. Até recuperar a versão guardada, suspende edição e envio de rascunho antigo. Se a leitura falhar, informa que os dados foram guardados e disponibiliza «Recuperar dados guardados», que repete apenas a consulta. A recuperação repõe valores/versão actuais e a edição normal, sem novo POST de confirmação. Se a própria gravação for recusada, conserva os dados e a possibilidade de tentar novamente sem afirmar sucesso. Mantida a confirmação/idempotência do backend; nenhuma mudança de schema, permissões ou regras fiscais.
|
||||
|
||||
Dois casos novos: um reproduziu a lacuna no código anterior e o caso de gravação recusada já passava. Na candidata passaram ambos e os treze casos existentes, total de quinze testes frontend dirigidos. Verificados consulta repetida sem regravação, prévia consumida, conservação do hash do URL, ausência de envio do rascunho antigo em pagehide, valores recuperados e uso da nova versão na comparação seguinte. Tipos, lint, formatação e compilação QA passaram. Provas aceites reutilizadas; recibo/hashes em `context_saved_recovery_increment` de `local-validation-20261029.json`.
|
||||
|
||||
**Visual continua pendente pela equipa:** na candidata QA, conferir gravação normal e recuperação após falha apenas da leitura, verificando mensagem, bloqueio temporário e recuperação sem nova gravação; distinguir de gravação recusada. Usar ensaio de falha controlado em QA, sem criar dados sintéticos no activo. Registar revisão, perfil, percurso, esperado/observado e evidência nesta matriz. Sem instalação ou avanço de main; o requisito transversal permanece parcial.
|
||||
|
||||
@@ -1330,5 +1330,51 @@
|
||||
"preservation": "Frontend request consistency only. No new business conditions, backend/schema/permissions/tax-rule changes or writes to active data. Existing accepted suites reused without full rerun.",
|
||||
"visual_acceptance": "Pending team execution and recording against candidate; mocked asynchronous component tests are not a browser-session acceptance.",
|
||||
"installation": "Not triggered; main and active release unchanged."
|
||||
},
|
||||
"context_saved_recovery_increment": {
|
||||
"recorded_at": "2026-10-29T21:04:25.183773+00:00",
|
||||
"base_commit": "26f6a0d348e9f234759f9bbf17d888f81c33173c",
|
||||
"requirements": [
|
||||
"V2-07",
|
||||
"V2-10",
|
||||
"Tempo adaptive journey decision 2026-10-23: preserve choices and deliberate edits"
|
||||
],
|
||||
"defect": "A successful confirmation followed by failed context retrieval was reported as a generic save failure, left the preceding plan available and could resend the consumed draft on page exit.",
|
||||
"behavior": [
|
||||
"After confirmed POST success, discard the preceding preview/confirmation body and consume entry URL parameters immediately.",
|
||||
"While waiting for the saved context, keep editing and draft writes suspended; pagehide does not resend the consumed draft.",
|
||||
"If subsequent retrieval fails, explicitly state that data was saved and offer GET-only recovery. Successful recovery restores current values/version and normal editing without another confirmation POST.",
|
||||
"A rejected save retains edits and does not claim success; existing retry semantics and operation key remain in force."
|
||||
],
|
||||
"checks": {
|
||||
"previous_revision_reproduction": {
|
||||
"cases": 2,
|
||||
"passed": 1,
|
||||
"failed": 1
|
||||
},
|
||||
"directed_frontend_passed": 15,
|
||||
"new_frontend_cases": 2,
|
||||
"existing_cases_reused": 13,
|
||||
"types": "passed",
|
||||
"lint": "passed",
|
||||
"format": "passed",
|
||||
"qa_build": "passed"
|
||||
},
|
||||
"source_sha256": {
|
||||
"resources/js/pages/investor-path/context.tsx": "cdd63b8d784346e01db180e43c827b51e2474712a9b8b09e8af7783e40bb7563",
|
||||
"tests/frontend/investor-usability-components.test.mjs": "dc79c2401952abaa169a0804f9d61901ce7e3bfccd321296b7f8917c81f6352e"
|
||||
},
|
||||
"evidence": {
|
||||
"outputs/validation-20261029/context-recovery-red.log": "6c47dae887ca74c11f296d04c83aaba05049b5930607cb9a311d7f6c6ce8ccc0",
|
||||
"outputs/validation-20261029/context-recovery-tests.log": "08038e93a13fc7c0d50c5a9ab54a913fc47447ce560a6b822f5588a2706d2ce2",
|
||||
"outputs/validation-20261029/context-recovery-types.log": "82962651e47f564de653ad2883f8fd34e7dae4b4468e4ac459cb3331962a3879",
|
||||
"outputs/validation-20261029/context-recovery-lint.log": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
|
||||
"outputs/validation-20261029/context-recovery-format-check.log": "49429eee939f43fd6719eb1b38510db064d73431b80457d456a38d166de944ae",
|
||||
"outputs/validation-20261029/context-recovery-build.log": "c30f74b414449450af18605e1e4d0b4d5923c585f1b6eda0218d6d12f8ae3d62",
|
||||
"outputs/validation-20261029/context-recovery-build-exit.json": "124fd50cb6d223cdd18fedc166d00af7254199b6e6dedf9be82f71db87af6d01"
|
||||
},
|
||||
"preservation": "Frontend recovery only. Backend confirmation/idempotency, data/schema, permissions and fiscal rules unchanged. No active writes or deployment. Historical accepted suites reused.",
|
||||
"visual_acceptance": "Pending team execution and recording; mocked asynchronous tests do not constitute browser-session acceptance.",
|
||||
"installation": "Not triggered; main and active release unchanged."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7689,3 +7689,14 @@ Corrigido no editor existente: os controlos referidos acompanham o estado ocupad
|
||||
Três casos novos falharam contra a revisão anterior e passaram na candidata: bloqueio/recuperação dos controlos durante comparação, recusa de resposta para rascunho alterado preservando a edição, e falha de nova comparação sem reutilização do plano anterior. Treze testes frontend dirigidos passaram. Tipos, lint, formatação e compilação QA passaram; provas históricas aceites reutilizadas, sem repetição integral. Detalhes/hashes em `context_preview_consistency_increment` do recibo existente `local-validation-20261029.json`.
|
||||
|
||||
**Conferência visual pendente pela equipa:** na candidata, comparar dados de um dossier autorizado, conferir bloqueio temporário e reposição dos controlos; preparar nova edição e repetir a comparação, garantindo que só a prévia actual permite guardar. Em falha de comunicação, os valores permanecem e exige-se nova comparação. Não gravar dados sintéticos no activo para este ensaio. Registar revisão, perfil, percurso, esperado/observado e evidência nesta matriz. Sem instalação ou avanço de main; os requisitos transversais continuam parciais.
|
||||
|
||||
|
||||
### 29/10/2026 — recuperar a versão guardada sem repetir a confirmação
|
||||
|
||||
Continuação sobre `26f6a0d3`, nos requisitos existentes V2-07/V2-10 e na orientação adaptativa da Tempo de 23/10. Reproduzida a diferença entre gravação e leitura: uma confirmação bem-sucedida seguida de falha na consulta era apresentada como falha genérica, com a prévia anterior ainda disponível. O rascunho consumido também podia ser reenviado ao sair antes da recuperação.
|
||||
|
||||
O editor invalida a prévia/corpo de confirmação e consome os parâmetros de entrada logo após resposta positiva da gravação. Até recuperar a versão guardada, suspende edição e envio de rascunho antigo. Se a leitura falhar, informa que os dados foram guardados e disponibiliza «Recuperar dados guardados», que repete apenas a consulta. A recuperação repõe valores/versão actuais e a edição normal, sem novo POST de confirmação. Se a própria gravação for recusada, conserva os dados e a possibilidade de tentar novamente sem afirmar sucesso. Mantida a confirmação/idempotência do backend; nenhuma mudança de schema, permissões ou regras fiscais.
|
||||
|
||||
Dois casos novos: um reproduziu a lacuna no código anterior e o caso de gravação recusada já passava. Na candidata passaram ambos e os treze casos existentes, total de quinze testes frontend dirigidos. Verificados consulta repetida sem regravação, prévia consumida, conservação do hash do URL, ausência de envio do rascunho antigo em pagehide, valores recuperados e uso da nova versão na comparação seguinte. Tipos, lint, formatação e compilação QA passaram. Provas aceites reutilizadas; recibo/hashes em `context_saved_recovery_increment` de `local-validation-20261029.json`.
|
||||
|
||||
**Visual continua pendente pela equipa:** na candidata QA, conferir gravação normal e recuperação após falha apenas da leitura, verificando mensagem, bloqueio temporário e recuperação sem nova gravação; distinguir de gravação recusada. Usar ensaio de falha controlado em QA, sem criar dados sintéticos no activo. Registar revisão, perfil, percurso, esperado/observado e evidência nesta matriz. Sem instalação ou avanço de main; o requisito transversal permanece parcial.
|
||||
|
||||
@@ -180,7 +180,10 @@ export default function ContextEditor({
|
||||
draft?.replace_fields ?? [],
|
||||
);
|
||||
const [plan, setPlan] = useState<ContextPlan | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [requestBusy, setBusy] = useState(false);
|
||||
const [needsRecovery, setNeedsRecovery] = useState(false);
|
||||
const awaitingSavedContext = useRef(false);
|
||||
const busy = requestBusy || needsRecovery;
|
||||
const [error, setError] = useState('');
|
||||
const [message, setMessage] = useState('');
|
||||
const [corridors, setCorridors] = useState<CorridorOption[]>([]);
|
||||
@@ -313,7 +316,11 @@ export default function ContextEditor({
|
||||
useEffect(() => {
|
||||
const flush = () => {
|
||||
const body = latestDraft.current;
|
||||
if (body !== confirmedDraftBody.current && body.length < 60000)
|
||||
if (
|
||||
!awaitingSavedContext.current &&
|
||||
body !== confirmedDraftBody.current &&
|
||||
body.length < 60000
|
||||
)
|
||||
void contextApi(
|
||||
'/investimentos/' + initial.case.id + '/contexto/rascunho',
|
||||
{
|
||||
@@ -423,8 +430,40 @@ export default function ContextEditor({
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
async function recoverSavedContext() {
|
||||
setBusy(true);
|
||||
setError('');
|
||||
try {
|
||||
const next = await contextApi<ContextView>(
|
||||
`/investimentos/${view.case.id}/contexto`,
|
||||
);
|
||||
skipNextDraft.current = true;
|
||||
setView(next);
|
||||
setChoices(next.context.opportunity_choices ?? []);
|
||||
setComplements(next.context.complements ?? []);
|
||||
setPreparation(next.private_investment);
|
||||
navigation.refresh();
|
||||
setChanges({});
|
||||
setDeclarations({});
|
||||
setDocumentLinks({});
|
||||
setReplace([]);
|
||||
setPlan(null);
|
||||
awaitingSavedContext.current = false;
|
||||
setNeedsRecovery(false);
|
||||
setOperation(createOperationKey());
|
||||
setMessage(
|
||||
'Dados guardados. Pode continuar a preparação do investimento.',
|
||||
);
|
||||
} catch {
|
||||
setError(
|
||||
'Os dados foram guardados, mas não foi possível recuperar a versão actual. Recupere os dados guardados para continuar.',
|
||||
);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
async function confirm() {
|
||||
if (!plan || !requestBody) return;
|
||||
if (!plan || !requestBody || busy) return;
|
||||
setBusy(true);
|
||||
await queue.current.catch(() => {});
|
||||
setError('');
|
||||
@@ -438,30 +477,17 @@ export default function ContextEditor({
|
||||
confirm: true,
|
||||
},
|
||||
);
|
||||
const next = await contextApi<ContextView>(
|
||||
`/investimentos/${view.case.id}/contexto`,
|
||||
);
|
||||
skipNextDraft.current = true;
|
||||
// The entry has been consumed: refreshing must recover saved choices, not reapply a removed association.
|
||||
awaitingSavedContext.current = true;
|
||||
setNeedsRecovery(true);
|
||||
setPlan(null);
|
||||
setRequestBody(null);
|
||||
// Consume entry parameters as soon as saving succeeds, even if recovery fails.
|
||||
window.history.replaceState(
|
||||
window.history.state,
|
||||
'',
|
||||
`/investimentos/${view.case.id}/contexto/editar${window.location.hash}`,
|
||||
);
|
||||
setView(next);
|
||||
setChoices(next.context.opportunity_choices ?? []);
|
||||
setComplements(next.context.complements ?? []);
|
||||
setPreparation(next.private_investment);
|
||||
navigation.refresh();
|
||||
setChanges({});
|
||||
setDeclarations({});
|
||||
setDocumentLinks({});
|
||||
setReplace([]);
|
||||
setPlan(null);
|
||||
setOperation(createOperationKey());
|
||||
setMessage(
|
||||
'Dados guardados. Pode continuar a preparação do investimento.',
|
||||
);
|
||||
await recoverSavedContext();
|
||||
} catch (e) {
|
||||
setError(
|
||||
e instanceof Error
|
||||
@@ -560,9 +586,16 @@ export default function ContextEditor({
|
||||
<button
|
||||
type="button"
|
||||
className="underline"
|
||||
onClick={() => window.location.reload()}
|
||||
disabled={requestBusy}
|
||||
onClick={() =>
|
||||
needsRecovery
|
||||
? void recoverSavedContext()
|
||||
: window.location.reload()
|
||||
}
|
||||
>
|
||||
Recuperar o dossier atual
|
||||
{needsRecovery
|
||||
? 'Recuperar dados guardados'
|
||||
: 'Recuperar o dossier atual'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -478,6 +478,7 @@ const preparationMocks = {
|
||||
};
|
||||
function contextPreviewHarness() {
|
||||
const memory = [];
|
||||
const effects = [];
|
||||
let cursor = 0;
|
||||
const hooks = {
|
||||
...React,
|
||||
@@ -499,7 +500,9 @@ function contextPreviewHarness() {
|
||||
if (!(index in memory)) memory[index] = { current: initial };
|
||||
return memory[index];
|
||||
},
|
||||
useEffect() {},
|
||||
useEffect(callback, dependencies) {
|
||||
effects.push({ callback, dependencies });
|
||||
},
|
||||
};
|
||||
const pending = [];
|
||||
const Editor = load('../../resources/js/pages/investor-path/context.tsx', {
|
||||
@@ -550,8 +553,15 @@ function contextPreviewHarness() {
|
||||
};
|
||||
return {
|
||||
pending,
|
||||
effects,
|
||||
savedView: {
|
||||
...props.initial,
|
||||
case: { ...props.initial.case, version: 2 },
|
||||
context: { ...props.initial.context, fields: { currency: 'USD' } },
|
||||
},
|
||||
render() {
|
||||
cursor = 0;
|
||||
effects.length = 0;
|
||||
return Editor(props);
|
||||
},
|
||||
result: {
|
||||
@@ -571,6 +581,129 @@ const buttonNamed = (tree, label) =>
|
||||
(node) => node.type === 'button' && node.props.children === label,
|
||||
);
|
||||
const settlePreview = () => new Promise((resolve) => setImmediate(resolve));
|
||||
void test('successful save followed by failed recovery offers read-only retry and never restores a stale draft', async () => {
|
||||
const previousWindow = globalThis.window;
|
||||
const listeners = new Map();
|
||||
globalThis.window = {
|
||||
history: {
|
||||
state: {},
|
||||
replaceState(_state, _title, url) {
|
||||
this.url = url;
|
||||
},
|
||||
},
|
||||
location: { hash: '#preparation-documents' },
|
||||
addEventListener: (name, fn) => listeners.set(name, fn),
|
||||
removeEventListener: (name) => listeners.delete(name),
|
||||
};
|
||||
let cleanup;
|
||||
try {
|
||||
const harness = contextPreviewHarness();
|
||||
const initial = harness.render();
|
||||
cleanup = harness.effects
|
||||
.find(
|
||||
(effect) =>
|
||||
effect.dependencies?.length === 1 &&
|
||||
effect.dependencies[0] === 'synthetic',
|
||||
)
|
||||
.callback();
|
||||
buttonNamed(initial, 'Rever dados antes de guardar').props.onClick();
|
||||
harness.render();
|
||||
harness.pending[0].resolve(harness.result);
|
||||
await settlePreview();
|
||||
buttonNamed(
|
||||
harness.render(),
|
||||
'Guardar dados do investimento',
|
||||
).props.onClick();
|
||||
await settlePreview();
|
||||
assert.match(harness.pending[1].url, /\/confirmar$/);
|
||||
harness.pending[1].resolve({});
|
||||
await settlePreview();
|
||||
assert.equal(
|
||||
globalThis.window.history.url,
|
||||
'/investimentos/synthetic/contexto/editar#preparation-documents',
|
||||
);
|
||||
assert.equal(harness.pending[2].body, undefined);
|
||||
harness.pending[2].reject(new Error('Synthetic recovery failure'));
|
||||
await settlePreview();
|
||||
const failed = harness.render();
|
||||
assert.equal(
|
||||
buttonNamed(failed, 'Guardar dados do investimento'),
|
||||
undefined,
|
||||
);
|
||||
assert.equal(
|
||||
buttonNamed(failed, 'Rever dados antes de guardar').props.disabled,
|
||||
true,
|
||||
);
|
||||
assert.ok(
|
||||
flat(failed).some(
|
||||
(node) =>
|
||||
node.type === 'p' &&
|
||||
String(node.props.children).includes(
|
||||
'Os dados foram guardados, mas',
|
||||
),
|
||||
),
|
||||
);
|
||||
listeners.get('pagehide')();
|
||||
assert.equal(harness.pending.length, 3);
|
||||
buttonNamed(failed, 'Recuperar dados guardados').props.onClick();
|
||||
assert.equal(harness.pending[3].body, undefined);
|
||||
harness.pending[3].resolve(harness.savedView);
|
||||
await settlePreview();
|
||||
const recovered = harness.render();
|
||||
assert.equal(
|
||||
buttonNamed(recovered, 'Rever dados antes de guardar').props
|
||||
.disabled,
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
flat(recovered).find(
|
||||
(node) => node.type === 'input' && node.props.maxLength === 3,
|
||||
).props.value,
|
||||
'USD',
|
||||
);
|
||||
assert.equal(
|
||||
harness.pending.filter((request) =>
|
||||
request.url.endsWith('/confirmar'),
|
||||
).length,
|
||||
1,
|
||||
);
|
||||
buttonNamed(recovered, 'Rever dados antes de guardar').props.onClick();
|
||||
assert.equal(harness.pending[4].body.version, 2);
|
||||
} finally {
|
||||
cleanup?.();
|
||||
globalThis.window = previousWindow;
|
||||
}
|
||||
});
|
||||
void test('failed save retains edits and permits retry without claiming that the context was saved', async () => {
|
||||
const harness = contextPreviewHarness();
|
||||
buttonNamed(
|
||||
harness.render(),
|
||||
'Rever dados antes de guardar',
|
||||
).props.onClick();
|
||||
harness.render();
|
||||
harness.pending[0].resolve(harness.result);
|
||||
await settlePreview();
|
||||
buttonNamed(
|
||||
harness.render(),
|
||||
'Guardar dados do investimento',
|
||||
).props.onClick();
|
||||
await settlePreview();
|
||||
harness.pending[1].reject(new Error('Synthetic save rejected'));
|
||||
await settlePreview();
|
||||
const failed = harness.render();
|
||||
assert.equal(buttonNamed(failed, 'Recuperar dados guardados'), undefined);
|
||||
assert.equal(
|
||||
buttonNamed(failed, 'Guardar dados do investimento').props.disabled,
|
||||
false,
|
||||
);
|
||||
assert.equal(harness.pending.length, 2);
|
||||
assert.equal(
|
||||
flat(failed).find(
|
||||
(node) => node.type === 'input' && node.props.maxLength === 3,
|
||||
).props.value,
|
||||
'MZN',
|
||||
);
|
||||
});
|
||||
void test('context comparison locks declaration and replacement controls until its matching preview is ready', async () => {
|
||||
const harness = contextPreviewHarness();
|
||||
buttonNamed(
|
||||
|
||||
Reference in New Issue
Block a user