fix: reject stale context previews and protect pending edits

This commit is contained in:
2026-10-29 23:01:36 +02:00
parent a10dd83d8d
commit 26f6a0d348
5 changed files with 265 additions and 0 deletions
@@ -2141,3 +2141,14 @@ Corrigido no componente existente: comparação por estado e identificador docum
Dois novos casos falharam contra o código anterior e passaram na candidata. Dez testes frontend dirigidos passaram, cobrindo também troca com o mesmo nome, retirada, nova associação, nome indisponível, histórico sem aplicação, ausência de alterações e preservação da origem. Tipos, lint, formatação e compilação QA passaram. Provas históricas aceites reutilizadas, sem repetição integral. Recibo e hashes no campo `adaptive_document_impact_increment` de `local-validation-20261029.json`.
**Visual pendente pela equipa:** na candidata e num dossier autorizado, preparar troca/retirada de documento, comparar antes de confirmar e conferir nomes/estado anterior e posterior; confirmar que associação mantida não surge como substituição e que o histórico permanece. Não é necessário confirmar uma alteração real para observar a prévia. Registar revisão, percurso, perfil, esperado/observado e evidência nesta matriz. Nenhuma instalação ou avanço de main; o incremento não encerra o percurso adaptativo transversal.
### 29/10/2026 — comparação de contexto: proteger edições e recusar prévias desactualizadas
Continuação sobre `a10dd83d`, nos requisitos existentes V2-07/V2-10 e na orientação adaptativa da Tempo de 23/10. Alguns controlos de declarações, adição de oportunidade e substituição explícita continuavam editáveis durante o pedido de comparação. A resposta podia tornar confirmável um plano anterior às escolhas apresentadas; iniciar outra comparação também conservava o plano precedente em caso de falha.
Corrigido no editor existente: os controlos referidos acompanham o estado ocupado dos restantes; uma nova comparação invalida o plano e corpo de confirmação anteriores; antes de aceitar a resposta compara-se o snapshot do rascunho pedido com o actual. Se diferirem, a resposta não pode ser confirmada e as edições posteriores ficam conservadas, com pedido explícito para comparar de novo. É consistência técnica da confirmação existente, sem nova condição organizacional e sem alteração de backend, schema, permissões ou cálculo.
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.
@@ -1290,5 +1290,45 @@
"preservation": "Existing before/after backend preview and confirmation flow retained; no data writes, schema, permissions, tax rules or backend changes. Historical accepted suites reused.",
"visual_acceptance": "Pending team execution and recording against candidate.",
"installation": "Not triggered; main and active release unchanged."
},
"context_preview_consistency_increment": {
"recorded_at": "2026-10-29T21:01:36.362775+00:00",
"base_commit": "a10dd83d8dda0eab50d1c77ef3920ba9d5daf172",
"requirements": [
"V2-07",
"V2-10",
"Tempo adaptive journey decision 2026-10-23: preserve deliberate edits and compare impact before confirmation"
],
"behavior": [
"Opportunity addition, common declarations and explicit replacement checkboxes are disabled while a context operation is busy, consistently with existing preparation controls.",
"Starting a new preview clears the preceding plan and confirmation body, so a failed new preview cannot leave an older preview confirmable.",
"Before accepting the asynchronous response, compare the current draft snapshot to the requested draft; reject stale responses and preserve later edits with an explicit request to compare again."
],
"checks": {
"new_cases_reproduced_against_previous_revision": 3,
"directed_frontend_passed": 13,
"new_frontend_cases": 3,
"existing_cases_reused": 10,
"types": "passed",
"lint": "passed",
"format": "passed",
"qa_build": "passed"
},
"source_sha256": {
"resources/js/pages/investor-path/context.tsx": "41c919e19246d0e913603cbbb747b82d7ed41f0d93caaadd072443704891eb0d",
"tests/frontend/investor-usability-components.test.mjs": "21b23496b8bb009cc52e13e0061e32c18338d8a325eb74c8fbf5e865de8db99f"
},
"evidence": {
"outputs/validation-20261029/context-preview-red.log": "6524233d96ce46217274fd174d2764b4c9b9d2289cbdfd0b48413913fcebded8",
"outputs/validation-20261029/context-preview-tests.log": "584f31229dca89319d25fe6f981813bed010b621bb28165f11fbc9f9f44b5915",
"outputs/validation-20261029/context-preview-types.log": "82962651e47f564de653ad2883f8fd34e7dae4b4468e4ac459cb3331962a3879",
"outputs/validation-20261029/context-preview-lint.log": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
"outputs/validation-20261029/context-preview-format-check.log": "90f26a8036b540babd912c8093386e9f09e41d13b5debbd3ed1a5c6e4bcbd3c6",
"outputs/validation-20261029/context-preview-build.log": "4aafb7a9bf6062810cdbbcd000a49bea2043c89540af876ae1b5d77f397c0ad6",
"outputs/validation-20261029/context-preview-build-exit.json": "124fd50cb6d223cdd18fedc166d00af7254199b6e6dedf9be82f71db87af6d01"
},
"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."
}
}
@@ -7678,3 +7678,14 @@ Corrigido no componente existente: comparação por estado e identificador docum
Dois novos casos falharam contra o código anterior e passaram na candidata. Dez testes frontend dirigidos passaram, cobrindo também troca com o mesmo nome, retirada, nova associação, nome indisponível, histórico sem aplicação, ausência de alterações e preservação da origem. Tipos, lint, formatação e compilação QA passaram. Provas históricas aceites reutilizadas, sem repetição integral. Recibo e hashes no campo `adaptive_document_impact_increment` de `local-validation-20261029.json`.
**Visual pendente pela equipa:** na candidata e num dossier autorizado, preparar troca/retirada de documento, comparar antes de confirmar e conferir nomes/estado anterior e posterior; confirmar que associação mantida não surge como substituição e que o histórico permanece. Não é necessário confirmar uma alteração real para observar a prévia. Registar revisão, percurso, perfil, esperado/observado e evidência nesta matriz. Nenhuma instalação ou avanço de main; o incremento não encerra o percurso adaptativo transversal.
### 29/10/2026 — comparação de contexto: proteger edições e recusar prévias desactualizadas
Continuação sobre `a10dd83d`, nos requisitos existentes V2-07/V2-10 e na orientação adaptativa da Tempo de 23/10. Alguns controlos de declarações, adição de oportunidade e substituição explícita continuavam editáveis durante o pedido de comparação. A resposta podia tornar confirmável um plano anterior às escolhas apresentadas; iniciar outra comparação também conservava o plano precedente em caso de falha.
Corrigido no editor existente: os controlos referidos acompanham o estado ocupado dos restantes; uma nova comparação invalida o plano e corpo de confirmação anteriores; antes de aceitar a resposta compara-se o snapshot do rascunho pedido com o actual. Se diferirem, a resposta não pode ser confirmada e as edições posteriores ficam conservadas, com pedido explícito para comparar de novo. É consistência técnica da confirmação existente, sem nova condição organizacional e sem alteração de backend, schema, permissões ou cálculo.
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.
@@ -372,6 +372,8 @@ export default function ContextEditor({
};
async function preview() {
setBusy(true);
setPlan(null);
setRequestBody(null);
setError('');
setMessage('');
const body = {
@@ -403,6 +405,11 @@ export default function ContextEditor({
`/investimentos/${view.case.id}/contexto/previa`,
body,
);
if (latestDraft.current !== draftBody) {
throw new Error(
'Os dados mudaram durante a comparação. Reveja novamente antes de guardar.',
);
}
setRequestBody(body);
setPlan(result);
setPreparation(
@@ -656,6 +663,7 @@ export default function ContextEditor({
<select
className={input}
value=""
disabled={busy}
onChange={(e) => {
const o = options.opportunities.find(
(o) => o.key === e.target.value,
@@ -955,6 +963,7 @@ export default function ContextEditor({
{Array.isArray(choices) ? (
<select
className={input}
disabled={busy}
value={
declarations[String(key)] ??
view.context.fields[String(key)] ??
@@ -978,6 +987,7 @@ export default function ContextEditor({
) : (
<input
className={input}
disabled={busy}
type={
key === 'expected_start_date'
? 'date'
@@ -1041,6 +1051,7 @@ export default function ContextEditor({
<label className="block" key={k}>
<input
type="checkbox"
disabled={busy}
checked={replace.includes(k)}
onChange={(e) => {
setReplace(
@@ -476,6 +476,198 @@ const preparationMocks = {
},
'@/pages/investment/shared': { card: 'card', input: 'input' },
};
function contextPreviewHarness() {
const memory = [];
let cursor = 0;
const hooks = {
...React,
useState(initial) {
const index = cursor++;
if (!(index in memory))
memory[index] =
typeof initial === 'function' ? initial() : initial;
return [
memory[index],
(next) => {
memory[index] =
typeof next === 'function' ? next(memory[index]) : next;
},
];
},
useRef(initial) {
const index = cursor++;
if (!(index in memory)) memory[index] = { current: initial };
return memory[index];
},
useEffect() {},
};
const pending = [];
const Editor = load('../../resources/js/pages/investor-path/context.tsx', {
react: hooks,
'@inertiajs/react': { Link: () => null, router: {} },
'@/components/navigation-context': {
useNavigationContext: () => ({ refresh() {} }),
},
'@/lib/operation-key': {
createOperationKey: () => 'synthetic-operation',
},
'@/lib/investor-context': {
contextFieldNames: {
project_title: 'Título',
project_summary: 'Resumo',
},
contextApi: (url, body) =>
new Promise((resolve, reject) =>
pending.push({ url, body, resolve, reject }),
),
},
'@/pages/investment/shared': {
Frame: () => null,
card: 'card',
input: 'input',
button: 'button',
},
'@/components/investment-complements': {
default: () => null,
SelectedComplements: () => null,
complementInput: (value) => value,
},
});
const props = {
initial: {
case: { id: 'synthetic', version: 1, title: 'Synthetic' },
context: {
selection: {},
fields: { currency: 'MZN' },
revision: 1,
},
can_write: true,
results: [],
requests: [],
},
options: { opportunities: [], sectors: [], parks: [] },
source: null,
};
return {
pending,
render() {
cursor = 0;
return Editor(props);
},
result: {
preview_hash: 'synthetic-preview',
selection: {},
maintained: [],
updated: [],
dependent_results: [],
review_steps: [],
requests: [],
documents_preserved: 0,
},
};
}
const buttonNamed = (tree, label) =>
flat(tree).find(
(node) => node.type === 'button' && node.props.children === label,
);
const settlePreview = () => new Promise((resolve) => setImmediate(resolve));
void test('context comparison locks declaration and replacement controls until its matching preview is ready', async () => {
const harness = contextPreviewHarness();
buttonNamed(
harness.render(),
'Rever dados antes de guardar',
).props.onClick();
const busy = harness.render();
assert.equal(
flat(busy)
.filter((node) => node.type === 'select')
.every((node) => node.props.disabled),
true,
);
assert.equal(
flat(busy)
.filter(
(node) =>
node.type === 'input' &&
(node.props.maxLength || node.props.type === 'checkbox'),
)
.every((node) => node.props.disabled),
true,
);
assert.equal(harness.pending[0].body.version, 1);
harness.pending[0].resolve(harness.result);
await settlePreview();
const ready = harness.render();
assert.equal(
buttonNamed(ready, 'Guardar dados do investimento').props.disabled,
false,
);
assert.equal(
flat(ready).find(
(node) => node.type === 'input' && node.props.maxLength === 3,
).props.disabled,
false,
);
});
void test('context comparison rejects a response for a draft changed while the request was pending', async () => {
const harness = contextPreviewHarness();
buttonNamed(
harness.render(),
'Rever dados antes de guardar',
).props.onClick();
const busy = harness.render();
// Simulate a queued state change, independently of native disabled controls.
flat(busy)
.find((node) => node.type === 'input' && node.props.maxLength === 3)
.props.onChange({ target: { value: 'USD' } });
harness.render();
harness.pending[0].resolve(harness.result);
await settlePreview();
const stale = harness.render();
assert.equal(
buttonNamed(stale, 'Guardar dados do investimento'),
undefined,
);
assert.ok(
flat(stale).some(
(node) =>
node.type === 'p' &&
node.props.children ===
'Os dados mudaram durante a comparação. Reveja novamente antes de guardar.',
),
);
assert.equal(
flat(stale).find(
(node) => node.type === 'input' && node.props.maxLength === 3,
).props.value,
'USD',
);
});
void test('a failed replacement comparison cannot leave the earlier preview confirmable', async () => {
const harness = contextPreviewHarness();
buttonNamed(
harness.render(),
'Rever dados antes de guardar',
).props.onClick();
harness.render();
harness.pending[0].resolve(harness.result);
await settlePreview();
assert.ok(buttonNamed(harness.render(), 'Guardar dados do investimento'));
buttonNamed(
harness.render(),
'Rever dados antes de guardar',
).props.onClick();
assert.equal(
buttonNamed(harness.render(), 'Guardar dados do investimento'),
undefined,
);
harness.pending[1].reject(new Error('Synthetic request failed'));
await settlePreview();
assert.equal(
buttonNamed(harness.render(), 'Guardar dados do investimento'),
undefined,
);
});
void test('preparation directs complete context to missing documents without treating association or uncertainty as approval', () => {
const Summary = load(
'../../resources/js/components/private-investment-preparation.tsx',