Corrige validacao, recuperacao e acesso ao calculo do rascunho
This commit is contained in:
@@ -71,3 +71,19 @@ ClamD, identidade do operador, evidência dos links reais e integrações extern
|
||||
O [registo do primeiro percurso](primeiro-percurso-ativo.md) identifica a pendência de sessão de navegador e dados reais. A leitura detalhada esclareceu o contador: os dois registos publicados são versões 1 e 2 de `project-preparation`, com o mesmo título, e não dois serviços distintos. Nenhuma versão foi alterada; nenhum dossier, pedido ou responsável foi criado para testar.
|
||||
|
||||
**Correção do feedback do painel (V2-03):** criação de dossier confirma resultado/próxima ação; erros preservam campos; clique duplicado bloqueado durante o envio. QA: 19 testes PHP/287 asserções, navegador com persistência/recuperação e um único POST na fase de sucesso, sem submissão de pedido. Leitura ativa final às 10:13:50 UTC continua com zero dossiers/pedidos; nenhuma escrita real repetida. O motivo da tentativa anterior não é determinável apenas pela ausência de registo.
|
||||
|
||||
# Atualização da revisão final — 8 de outubro de 2026
|
||||
|
||||
V2-03/V2-05: acrescentada ação «Validar este rascunho» na revisão final do simulador, onde antes só existia «Limpar este rascunho». Reutiliza validação e cálculo existentes e distingue verificação local de execução no servidor e de submissão ao Balcão. QA: 23 testes de regras, TypeScript e ensaio de navegador do botão/mensagem/ausência de submissão. Ver [registo do primeiro percurso](primeiro-percurso-ativo.md). Leitura ativa às 15:00:18 UTC: um dossier e zero pedidos; nenhuma escrita ativa nesta correção. Primeiro pedido real continua pendente.
|
||||
|
||||
# Validação visível do rascunho — 8 de outubro
|
||||
|
||||
V2-03/V2-05: resumo de verificação local com foco/deslocação, dados verificados, pendências/inconsistências e próxima ação. O botão final deixa de iniciar cálculo ou mudar de etapa silenciosamente; cálculo determinístico usa ação própria. QA de navegador em 1440/390 confirma correção e recuperação dos campos, foco visível e ausência de submissão; registos HTTP privados distinguem gravação e cálculo. Inspeção ativa `READ ONLY` às 15:14:26 UTC: um dossier e zero pedidos associados. Ver [registo do percurso](primeiro-percurso-ativo.md).
|
||||
|
||||
# Gravação e recuperação do rascunho — diagnóstico do 422
|
||||
|
||||
V2-03/V2-05: o 422 do fixture QA foi identificado pela resposta HTTP como idioma `pt` inválido; corrigido para a opção existente `pt-MZ`. Implementada apresentação dos erros de API junto aos campos e aviso de gravação recusada sem apagar o rascunho. QA confirmou criação no servidor e recuperação numa nova sessão autenticada sem rascunho local, com validação e cálculo testados separadamente. Evidência privada `draft-422-browser.json`; ver [registo do percurso](primeiro-percurso-ativo.md). Nenhuma escrita ou submissão na base ativa.
|
||||
|
||||
# Acesso direto às ferramentas — diagnóstico do clique
|
||||
|
||||
V2-03/V2-05: `SimulationForm` em `/simulador-fiscal` distingue «Validar este rascunho», «Abrir cálculo» e «Consultar parâmetros fiscais». As ações de abertura deixam de depender de uma revisão sem pendências; têm destino focável e aviso se indisponível. QA demonstra revisão → ferramenta → execução/resultado, em computador e móvel, sem erros JavaScript; consulta fiscal independente preserva o modo. GET ativo confirma assets atuais servidos, mas o cache da sessão real do utilizador não foi observado. Ver [registo detalhado](primeiro-percurso-ativo.md). Nenhuma criação de dossier ou submissão na base ativa.
|
||||
|
||||
@@ -53,3 +53,61 @@ Não foi repetida nenhuma escrita na base ativa. O novo feedback está no códig
|
||||
A validação automatizada no navegador QA passou, sem erros JavaScript: validação, HTTP 409 e falha de ligação preservaram o título; duplo clique durante o envio originou **um único POST de criação bem-sucedida**; confirmação e próxima ação ficaram visíveis; sair para o painel e reabrir recuperou o mesmo dossier persistido; zero pedidos submetidos. Evidência: `panel-browser-evidence.json`. O contador de POST dessa evidência refere-se à fase de sucesso, não às tentativas de erro do teste.
|
||||
|
||||
Leitura ativa final às **10:13:50 UTC**: continuam zero dossiers e zero pedidos. O motivo concreto da tentativa real anterior permanece indeterminado sem captura da respetiva resposta na sessão do utilizador; não foi declarado como falha de consentimento ou de rede por inferência.
|
||||
|
||||
# Correção da revisão final do simulador — 8 de outubro de 2026
|
||||
|
||||
A indicação «só aparece Limpar este rascunho» identifica o rodapé de `SimulationForm`, distinto da criação de dossier no painel. Antes desta correção, a revisão final apenas permitia limpar; «Validar e calcular no servidor» aparecia apenas na etapa fiscal de cenários personalizados. A correção anterior do painel mantém-se.
|
||||
|
||||
Foi acrescentado «Validar este rascunho» na revisão final. Reutiliza as verificações existentes, conserva os campos e regressa à etapa com erros. A verificação local identifica explicitamente o seu âmbito e a próxima ação, sem afirmar gravação, elegibilidade ou aprovação. Em cenários personalizados encaminha para o cálculo determinístico existente, com bloqueio de execução duplicada; o resultado concluído indica a próxima ação. Nenhum botão desta correção submete um pedido ao Balcão.
|
||||
|
||||
QA isolada: 23 testes das regras do formulário passaram; verificação TypeScript passou. O ensaio de navegador em `investmoz_v2_20261008_test:55441` demonstrou o botão na revisão, mensagem de verificação local e próxima ação, com zero submissões ao Balcão. Evidências privadas: `review-rules.txt`, `review-browser-evidence.json` e `review-final-qa.png`. O ensaio final não certifica um cálculo fiscal legal nem o percurso autenticado real do utilizador.
|
||||
|
||||
Leitura ativa em **2026-10-08, 15:00:18 UTC**, em transação `READ ONLY`: um dossier, zero pedidos, 78 migrations; instalação anterior preservada. Não foi repetida qualquer escrita ativa e não é possível concluir, pelos totais, quem criou o dossier ou se a tentativa de validação chegou ao servidor. Continua pendente a preparação e confirmação explícita do primeiro pedido real. Restrições dos anexos/ClamD mantidas.
|
||||
|
||||
# Resumo visível e separação entre validação e cálculo — 8 de outubro
|
||||
|
||||
A implementação anterior podia deixar a mensagem fora do campo visível e, no modo personalizado, mudar para a etapa fiscal sem resumo. `SimulationForm` passa a apresentar um resumo focável, com deslocação até à mensagem, campos verificados, pendências/inconsistências com ligações para correção e próxima ação. Preserva os dados e mantém a revisão aberta. A validação é local: **não calcula valores e não envia HTTP de validação**; gravação automática, cálculo determinístico no servidor e submissão são operações distintas. O cálculo personalizado exige o botão próprio da etapa fiscal.
|
||||
|
||||
QA em navegador, larguras 1440 e 390: campo pendente, correção, nova validação, foco no resumo dentro do viewport e recuperação após guardar e recarregar demonstrados. O teste captura métodos, rotas e estados HTTP em prova privada `review-summary-browser.json`; nenhuma submissão ao Balcão. Regras do formulário: 23 testes passaram; TypeScript e verificação de código passaram. Evidências adicionais: `review-summary-*.txt` e capturas `review-summary-1440.png`/`review-summary-390.png`.
|
||||
|
||||
Inspeção ativa em **15:14:26 UTC**, exclusivamente `READ ONLY`: um dossier existente, com versão e referências preservadas, zero pedidos associados. Metadados privados em `active-dossier-inspection.json`; não foram repetidas escritas nem submetidos pedidos. Não houve acesso à sessão do navegador real do utilizador; as capturas HTTP correspondem a QA, não à tentativa ativa.
|
||||
|
||||
# Limite da prova de recuperação em QA
|
||||
|
||||
O registo HTTP do ensaio mostra `422` na criação automática do rascunho de teste: a recuperação demonstrada foi a dos dados locais da sessão, não uma gravação confirmada no servidor. Este resultado permanece explícito e não certifica recuperação numa nova sessão/dispositivo. O cálculo separado devolveu `202` e a consulta subsequente `200`, com resultado concluído. Nenhuma destas respostas corresponde a submissão ao Balcão.
|
||||
|
||||
# Diagnóstico e correção do HTTP 422 — 8 de outubro de 2026
|
||||
|
||||
Resposta real capturada em QA: `economic_snapshot.preferred_language` rejeitado com «The selected economic_snapshot.preferred_language is invalid.» O cenário de teste enviava `pt`; `StoreSimulationFormDraftRequest` e as opções do formulário aceitam `pt-MZ` ou `en`. Não se tratava de recusa da classificação aduaneira nem de falha do cálculo. O fixture anterior foi corrigido para `pt-MZ`, mantendo as regras do servidor.
|
||||
|
||||
Os erros de snapshots devolvidos pela API são agora associados aos campos de origem. A gravação recusada mostra um aviso, ligações para os campos e o erro junto ao respetivo controlo; não limpa os dados. Uma correção elimina o erro do campo editado e a confirmação de gravação elimina os erros anteriores. Há teste da extração de erros pessoais/económicos, índices de listas e respostas inválidas.
|
||||
|
||||
QA em navegador: reproduzido o 422 com `pt`, demonstrado `aria-invalid` no idioma, selecionado Português (Moçambique), confirmado POST `201`. Guardado também o snapshot numa conta QA existente com `If-Match`; uma nova sessão autenticada, sem armazenamento local do rascunho, recuperou do servidor a base introduzida. Validação local e cálculo continuam separados, sem submissão ao Balcão. Provas privadas: `draft-422-response.json`, `draft-422-browser.json`, `draft-422-rules.txt`, `draft-422-types.txt`, `draft-422-build.txt`. As provas anteriores de recuperação apenas local não foram convertidas retroativamente em sucesso no servidor.
|
||||
|
||||
Nenhuma escrita, submissão ou novo ensaio foi executado na base ativa neste diagnóstico.
|
||||
|
||||
# Próxima ação ajustada à finalidade
|
||||
|
||||
A revisão final já não encaminha uma simulação para preparar um pedido no Balcão. `simulate_only` orienta para ferramentas de cálculo e distingue consulta de parâmetros de cálculo; `request_facilitation` exige confirmação de gravação no servidor antes de continuar no Balcão. As restantes finalidades orientam para parceiros, financiamento, preparação da oportunidade ou dossier/plano, conforme a escolha. Pendências continuam a ter prioridade. Alteração de apresentação verificada por TypeScript e lint; nenhuma escrita ativa ou submissão.
|
||||
|
||||
# Resumo com nomes/valores e ação direta de cálculo
|
||||
|
||||
Os campos verificados são apresentados em pares de nome/valor, com texto legível, sem listas vazias. Valores pessoais não são repetidos: o resumo identifica que o conteúdo permanece disponível no campo original. Não envia nem publica esses valores. O resumo tem foco e deslocação própria, incluindo em ecrã móvel.
|
||||
|
||||
Para simulação, uma ação direta abre as ferramentas e a consulta fiscal sem alterar o modo escolhido. Se o utilizador já escolheu o modo personalizado, a ação calcula explicitamente a operação percentual com a base/taxa/redução declaradas; não calcula imposto legal. A consulta sem parâmetros aprovados identifica componente, período e âmbito ausentes e conserva o resultado indeterminado. Não inventa um catálogo de parâmetros obrigatórios nem preenche lacunas com zero.
|
||||
|
||||
QA de navegador: nomes e valores não vazios, correção e recuperação do rascunho, ação direta até ao resultado personalizado e consulta fiscal indeterminada sem mudança de modo. Evidências privadas: `review-summary-browser.json`, `summary-values-approved.json` e capturas `review-summary-*.png`. Nenhum pedido submetido nem alteração na base ativa. TypeScript e lint verificados.
|
||||
|
||||
# Ações exatas do formulário e compilação servida
|
||||
|
||||
Página do formulário: `/simulador-fiscal`; `portal/services.tsx` monta `SimulationForm`. «Validar este rascunho» chama `validateFinalReview`, uma verificação local; não é a ação de abertura/cálculo. A ação anterior estava condicionada a um resumo sem pendências e podia regressar silenciosamente quando não encontrava a etapa fiscal. Agora «Abrir cálculo» e «Consultar parâmetros fiscais» estão acessíveis independentemente das pendências da revisão, com eventos separados, destino focável e erro visível se a etapa não existir. Não mudam finalidade nem pressupostos; a consulta de parâmetros não converte o modo do rascunho. O cálculo continua a exigir execução explícita na ferramenta aberta.
|
||||
|
||||
GET ativo em **16:00:34 UTC** devolveu HTTP 200 e os assets atuais, incluindo `services-r6EkfdY-.js`, sem `public/hot`; evidência privada `exact-actions-active-assets.json`. Não foi possível observar o cache ou o clique do separador real do utilizador. Esta constatação valida o conteúdo servido, não certifica o estado da sessão do utilizador.
|
||||
|
||||
QA desde o ecrã de revisão: validação, clique em «Abrir cálculo», foco em `draft-tool-calculation`, execução explícita, resposta HTTP e resultado concluído; testado em 1440 e 390. Consulta fiscal desde a revisão também aberta, sem alteração de modo e com parâmetros ausentes/resultado indeterminado. Provas `review-summary-browser.json` e `summary-values-approved.json`; TypeScript/lint passaram. Nenhum dossier criado nem pedido submetido na base ativa durante este diagnóstico.
|
||||
|
||||
# Confirmação e consolidação das correções
|
||||
|
||||
Em 8 de outubro, o utilizador pediu o registo da confirmação e a consolidação das correções num commit. Fica confirmado o ensaio QA desde a revisão até à abertura da ferramenta e ao resultado, em computador e móvel, e a disponibilização da compilação atual pelo servidor. A consulta fiscal preserva o modo escolhido e mantém lacunas indeterminadas. A sessão/cache do navegador real do utilizador continua sem observação direta; não se declara por isso um percurso ativo concluído. Não foram criados dossiers ou submetidos pedidos ativos durante estas correções.
|
||||
|
||||
O commit reúne o formulário, a consulta fiscal, o tratamento de erros API, os testes e estes registos. As evidências privadas não entram no commit. A retoma do backlog V2 mantém separadas as pendências de ClamD real, identidade do operador, fontes/contactos institucionais e primeiro pedido real com confirmação do investidor; nenhum sucesso QA resolve automaticamente essas pendências.
|
||||
|
||||
@@ -198,7 +198,12 @@ export function SimulationFiscalStatus({
|
||||
) : parameters.length === 0 ? (
|
||||
<p className="text-sm">
|
||||
Indeterminado — nenhum parâmetro publicado
|
||||
aplicável.
|
||||
aplicável. Faltam parâmetros aprovados para{' '}
|
||||
{code}, no período {periodStart} a{' '}
|
||||
{periodEnd} e no âmbito indicado. A consulta
|
||||
não identifica um conjunto obrigatório de
|
||||
parâmetros; não é possível determinar o
|
||||
imposto nem substituir as lacunas por zero.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-2 text-sm">
|
||||
|
||||
@@ -57,6 +57,7 @@ import { ProjectReadiness } from '@/components/project-readiness';
|
||||
import {
|
||||
readSimulationApiJson,
|
||||
simulationApiErrorMessage,
|
||||
simulationApiFieldErrors,
|
||||
simulationPollingFailureMessage,
|
||||
simulationCatalogChoices,
|
||||
} from '@/lib/simulation-api';
|
||||
@@ -751,10 +752,59 @@ export function SimulationForm() {
|
||||
const [ready, setReady] = useState(false);
|
||||
const [step, setStep] = useState(0);
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
const [serverFieldErrors, setServerFieldErrors] = useState<
|
||||
Record<string, string>
|
||||
>({});
|
||||
const [saveStatus, setSaveStatus] = useState(
|
||||
'Rascunho ainda não guardado.',
|
||||
);
|
||||
const [serverError, setServerError] = useState('');
|
||||
const [reviewStatus, setReviewStatus] = useState('');
|
||||
const [openedTool, setOpenedTool] = useState<
|
||||
'calculation' | 'fiscal' | null
|
||||
>(null);
|
||||
const [toolError, setToolError] = useState('');
|
||||
useEffect(() => {
|
||||
if (!openedTool) return;
|
||||
const destination = document.getElementById(`draft-tool-${openedTool}`);
|
||||
if (!destination) {
|
||||
setToolError(
|
||||
'Não foi possível abrir a ferramenta. O rascunho foi preservado; volte a tentar.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
destination.focus();
|
||||
destination.scrollIntoView({ block: 'start' });
|
||||
}, [openedTool, step]);
|
||||
function openDraftTool(tool: 'calculation' | 'fiscal') {
|
||||
setToolError('');
|
||||
const index = journeySections.findIndex(
|
||||
(section) => section.id === 'tax',
|
||||
);
|
||||
if (index < 0) {
|
||||
setToolError(
|
||||
'Esta etapa não disponibiliza a operação fiscal. Reveja o percurso escolhido; os dados foram preservados.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
setStep(index);
|
||||
setOpenedTool(tool);
|
||||
}
|
||||
const [reviewDetails, setReviewDetails] = useState<{
|
||||
checked: { key: string; label: string; value: string }[];
|
||||
pending: {
|
||||
key: string;
|
||||
label: string;
|
||||
message: string;
|
||||
step: number;
|
||||
}[];
|
||||
}>({ checked: [], pending: [] });
|
||||
const reviewSummaryRef = useRef<HTMLDivElement>(null);
|
||||
useEffect(() => {
|
||||
if (!reviewStatus) return;
|
||||
reviewSummaryRef.current?.focus();
|
||||
reviewSummaryRef.current?.scrollIntoView({ block: 'start' });
|
||||
}, [reviewStatus, reviewDetails]);
|
||||
const [isCalculating, setIsCalculating] = useState(false);
|
||||
const [hasSaveConflict, setHasSaveConflict] = useState(false);
|
||||
const [result, setResult] = useState<ApiSimulationExecutionV1 | null>(null);
|
||||
@@ -1289,6 +1339,8 @@ export function SimulationForm() {
|
||||
}
|
||||
const payload: unknown =
|
||||
await readSimulationApiJson(response);
|
||||
if (response.status === 422)
|
||||
setServerFieldErrors(simulationApiFieldErrors(payload));
|
||||
if (!response.ok)
|
||||
throw new Error(
|
||||
simulationApiErrorMessage(payload) ??
|
||||
@@ -1299,6 +1351,7 @@ export function SimulationForm() {
|
||||
'O servidor devolveu uma confirmação de gravação inválida.',
|
||||
);
|
||||
draftIdRef.current = payload.data.id;
|
||||
setServerFieldErrors({});
|
||||
draftEtagRef.current = response.headers.get('ETag');
|
||||
setDraftVersionNo(payload.data.version_no);
|
||||
lastSavedFingerprintRef.current = fingerprint;
|
||||
@@ -1719,6 +1772,12 @@ export function SimulationForm() {
|
||||
label?: string | string[],
|
||||
) {
|
||||
setContinueLaterSaved(false);
|
||||
setServerFieldErrors((previous) => {
|
||||
const next = { ...previous };
|
||||
delete next[key];
|
||||
return next;
|
||||
});
|
||||
setReviewStatus('');
|
||||
setDraft((currentDraft) => {
|
||||
const next = replaceSimulationValue(currentDraft, key, value);
|
||||
if (label) next[`${key}_label`] = label;
|
||||
@@ -1835,6 +1894,7 @@ export function SimulationForm() {
|
||||
}
|
||||
|
||||
function navigate(nextStep: number) {
|
||||
setOpenedTool(null);
|
||||
if (nextStep > step) {
|
||||
for (let index = step; index < nextStep; index++) {
|
||||
const validation = validateSimulationDraft(
|
||||
@@ -1860,6 +1920,80 @@ export function SimulationForm() {
|
||||
document.getElementById('simulator-step-title')?.focus();
|
||||
}
|
||||
|
||||
function validateFinalReview() {
|
||||
const checked: { key: string; label: string; value: string }[] = [];
|
||||
const privateFields = partitionSimulationDraft(
|
||||
draft,
|
||||
true,
|
||||
).personal_snapshot;
|
||||
const pending: {
|
||||
key: string;
|
||||
label: string;
|
||||
message: string;
|
||||
step: number;
|
||||
}[] = [];
|
||||
const allErrors: Record<string, string> = {};
|
||||
for (let index = 0; index < journeySections.length; index++) {
|
||||
const validation = validateSimulationDraft(
|
||||
draft,
|
||||
journeySections[index].id,
|
||||
);
|
||||
if (
|
||||
journeySections[index].id === 'tax' &&
|
||||
draft.fiscal_scenario_mode === 'custom_planning'
|
||||
) {
|
||||
Object.assign(validation, validateCustomFiscalPlanning(draft));
|
||||
}
|
||||
Object.assign(allErrors, validation);
|
||||
const fields =
|
||||
simulationFieldGroups[journeySections[index].id] ?? [];
|
||||
for (const field of fields) {
|
||||
if (field.showWhen && !field.showWhen(draft)) continue;
|
||||
const value = draft[field.key];
|
||||
if (
|
||||
!validation[field.key] &&
|
||||
value !== undefined &&
|
||||
value !== '' &&
|
||||
(!Array.isArray(value) || value.length > 0)
|
||||
)
|
||||
checked.push({
|
||||
key: field.key,
|
||||
label: field.label || field.key,
|
||||
value: Object.prototype.hasOwnProperty.call(
|
||||
privateFields,
|
||||
field.key,
|
||||
)
|
||||
? 'Dado pessoal preservado; consulte-o no campo original.'
|
||||
: Array.isArray(value)
|
||||
? value
|
||||
.map((entry) => labelFor(String(entry)))
|
||||
.join(', ')
|
||||
: field.options
|
||||
? labelFor(String(value))
|
||||
: displayValue(
|
||||
value,
|
||||
'Valor não textual; consulte o campo original.',
|
||||
),
|
||||
});
|
||||
}
|
||||
for (const [key, message] of Object.entries(validation))
|
||||
pending.push({
|
||||
key,
|
||||
label:
|
||||
fields.find((field) => field.key === key)?.label ?? key,
|
||||
message,
|
||||
step: index,
|
||||
});
|
||||
}
|
||||
setErrors(allErrors);
|
||||
setReviewDetails({ checked, pending });
|
||||
setReviewStatus(
|
||||
pending.length
|
||||
? 'Verificação concluída com campos pendentes ou inconsistências. Os dados foram preservados.'
|
||||
: 'Verificação dos campos concluída neste navegador.',
|
||||
);
|
||||
}
|
||||
|
||||
async function runSimulation() {
|
||||
if (calculationInFlightRef.current) return;
|
||||
const validation = validateSimulationDraft(draft, 'tax');
|
||||
@@ -2153,6 +2287,58 @@ export function SimulationForm() {
|
||||
<output aria-live="polite" className="block text-sm">
|
||||
{saveStatus}
|
||||
</output>
|
||||
{Object.keys(serverFieldErrors).length > 0 && (
|
||||
<div
|
||||
role="alert"
|
||||
className="rounded border border-red-300 bg-red-50 p-3"
|
||||
>
|
||||
<p>
|
||||
O servidor não confirmou a gravação. Os dados
|
||||
preenchidos foram preservados. Corrija os campos
|
||||
indicados:
|
||||
</p>
|
||||
<ul>
|
||||
{Object.entries(serverFieldErrors).map(
|
||||
([key, message]) => {
|
||||
const index = journeySections.findIndex(
|
||||
(section) =>
|
||||
(
|
||||
simulationFieldGroups[
|
||||
section.id
|
||||
] ?? []
|
||||
).some(
|
||||
(field) =>
|
||||
field.key === key,
|
||||
),
|
||||
);
|
||||
const label =
|
||||
index >= 0
|
||||
? simulationFieldGroups[
|
||||
journeySections[index].id
|
||||
].find(
|
||||
(field) =>
|
||||
field.key === key,
|
||||
)?.label
|
||||
: key;
|
||||
return (
|
||||
<li key={key}>
|
||||
<button
|
||||
type="button"
|
||||
className="underline"
|
||||
onClick={() => {
|
||||
if (index >= 0)
|
||||
setStep(index);
|
||||
}}
|
||||
>
|
||||
{label}: {message}
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
},
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
{draft.purpose && (
|
||||
<button
|
||||
type="button"
|
||||
@@ -2796,7 +2982,8 @@ export function SimulationForm() {
|
||||
: typeof field.required === 'function'
|
||||
? field.required(draft)
|
||||
: Boolean(field.required);
|
||||
const fieldError = errors[field.key];
|
||||
const fieldError =
|
||||
serverFieldErrors[field.key] ?? errors[field.key];
|
||||
if (field.options) {
|
||||
const parentKey =
|
||||
field.key === 'district_id'
|
||||
@@ -2942,14 +3129,20 @@ export function SimulationForm() {
|
||||
{(current.id === 'financial' ||
|
||||
(draft.purpose === 'simulate_only' &&
|
||||
current.id === 'tax')) && (
|
||||
<SimulationFinancialTools
|
||||
currency={String(draft.currency ?? '')}
|
||||
snapshot={String(draft.financial_tools_snapshot ?? '')}
|
||||
onSnapshotChange={(value) =>
|
||||
change('financial_tools_snapshot', value)
|
||||
}
|
||||
onCurrencyChange={(value) => change('currency', value)}
|
||||
/>
|
||||
<div
|
||||
id="draft-tool-calculation"
|
||||
tabIndex={-1}
|
||||
aria-label="Ferramentas de cálculo"
|
||||
>
|
||||
<SimulationFinancialTools
|
||||
currency={String(draft.currency ?? '')}
|
||||
snapshot={String(draft.financial_tools_snapshot ?? '')}
|
||||
onSnapshotChange={(value) =>
|
||||
change('financial_tools_snapshot', value)
|
||||
}
|
||||
onCurrencyChange={(value) => change('currency', value)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{(current.id === 'financial' ||
|
||||
(draft.purpose === 'simulate_only' &&
|
||||
@@ -3172,30 +3365,41 @@ export function SimulationForm() {
|
||||
/>
|
||||
)}
|
||||
{current.id === 'tax' && (
|
||||
<SimulationFiscalStatus
|
||||
salaryView={String(draft.irps_salary_view ?? '')}
|
||||
scenarioMode={String(draft.fiscal_scenario_mode ?? '')}
|
||||
component={String(draft.fiscal_component ?? '')}
|
||||
periodStart={getFiscalPeriodRange(draft)?.start}
|
||||
periodEnd={getFiscalPeriodRange(draft)?.end}
|
||||
context={Object.fromEntries(
|
||||
[
|
||||
'fiscal_periodicity',
|
||||
'fiscal_operation',
|
||||
'fiscal_residency_assessment',
|
||||
'fiscal_residency_country',
|
||||
'fiscal_income_category',
|
||||
'fiscal_establishments',
|
||||
'fiscal_documentation',
|
||||
'fiscal_customs_code',
|
||||
'fiscal_vat_refund_state',
|
||||
].flatMap((key) =>
|
||||
typeof draft[key] === 'string' && draft[key] !== ''
|
||||
? [[key, draft[key] as string]]
|
||||
: [],
|
||||
),
|
||||
)}
|
||||
/>
|
||||
<div
|
||||
id="draft-tool-fiscal"
|
||||
tabIndex={-1}
|
||||
aria-label="Consulta de parâmetros fiscais"
|
||||
>
|
||||
<SimulationFiscalStatus
|
||||
salaryView={String(draft.irps_salary_view ?? '')}
|
||||
scenarioMode={
|
||||
openedTool === 'fiscal'
|
||||
? 'approved_parameters'
|
||||
: String(draft.fiscal_scenario_mode ?? '')
|
||||
}
|
||||
component={String(draft.fiscal_component ?? '')}
|
||||
periodStart={getFiscalPeriodRange(draft)?.start}
|
||||
periodEnd={getFiscalPeriodRange(draft)?.end}
|
||||
context={Object.fromEntries(
|
||||
[
|
||||
'fiscal_periodicity',
|
||||
'fiscal_operation',
|
||||
'fiscal_residency_assessment',
|
||||
'fiscal_residency_country',
|
||||
'fiscal_income_category',
|
||||
'fiscal_establishments',
|
||||
'fiscal_documentation',
|
||||
'fiscal_customs_code',
|
||||
'fiscal_vat_refund_state',
|
||||
].flatMap((key) =>
|
||||
typeof draft[key] === 'string' &&
|
||||
draft[key] !== ''
|
||||
? [[key, draft[key] as string]]
|
||||
: [],
|
||||
),
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{current.id === 'tax' &&
|
||||
draft.journey_type === 'investor' &&
|
||||
@@ -3307,6 +3511,14 @@ export function SimulationForm() {
|
||||
Simulação {simulationStatusLabel(result.status)}
|
||||
.
|
||||
</p>
|
||||
{result.status === 'completed' && (
|
||||
<p>
|
||||
Cálculo concluído no servidor. Próxima ação:
|
||||
reveja os pressupostos e o relatório antes
|
||||
de associar o resultado ao dossier. Nenhum
|
||||
pedido de apoio foi submetido.
|
||||
</p>
|
||||
)}
|
||||
{result.status === 'completed' && (
|
||||
<p>
|
||||
Resultado aritmético indicativo:{' '}
|
||||
@@ -3767,6 +3979,120 @@ export function SimulationForm() {
|
||||
</button>
|
||||
</p>
|
||||
)}
|
||||
{reviewStatus && (
|
||||
<div
|
||||
ref={reviewSummaryRef}
|
||||
tabIndex={-1}
|
||||
id="draft-validation-summary"
|
||||
role="status"
|
||||
aria-label="Resumo da validação do rascunho"
|
||||
className="max-h-[70vh] overflow-y-auto rounded border border-sky-300 bg-sky-50 p-3"
|
||||
>
|
||||
<h3 className="font-semibold">
|
||||
Resumo da validação do rascunho
|
||||
</h3>
|
||||
<p>{reviewStatus}</p>
|
||||
<p>
|
||||
Verificação local dos campos e regras disponíveis. Não
|
||||
calcula valores nem envia um pedido HTTP de validação. A
|
||||
gravação automática do rascunho tem um estado
|
||||
independente. Não confirma viabilidade, elegibilidade ou
|
||||
aprovação.
|
||||
</p>
|
||||
{reviewDetails.checked.length > 0 && (
|
||||
<div>
|
||||
<h4 className="font-semibold">
|
||||
Dados verificados (
|
||||
{reviewDetails.checked.length})
|
||||
</h4>
|
||||
<dl className="grid gap-2 text-slate-900">
|
||||
{reviewDetails.checked.map((field) => (
|
||||
<div
|
||||
key={field.key}
|
||||
className="rounded border bg-white p-2"
|
||||
>
|
||||
<dt className="font-medium">
|
||||
{field.label}
|
||||
</dt>
|
||||
<dd className="break-words">
|
||||
{field.value}
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</div>
|
||||
)}
|
||||
<p>
|
||||
Campos pendentes e inconsistências:{' '}
|
||||
{reviewDetails.pending.length}.
|
||||
</p>
|
||||
{reviewDetails.pending.length > 0 && (
|
||||
<ul>
|
||||
{reviewDetails.pending.map((item) => (
|
||||
<li key={item.key}>
|
||||
<button
|
||||
type="button"
|
||||
className="underline"
|
||||
onClick={() => {
|
||||
setStep(item.step);
|
||||
setErrors(
|
||||
Object.fromEntries(
|
||||
reviewDetails.pending.map(
|
||||
(entry) => [
|
||||
entry.key,
|
||||
entry.message,
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
window.requestAnimationFrame(() =>
|
||||
document
|
||||
.getElementById(
|
||||
`field-${item.key}`,
|
||||
)
|
||||
?.focus(),
|
||||
);
|
||||
}}
|
||||
>
|
||||
{item.label}: {item.message}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
<p>
|
||||
Próxima ação:{' '}
|
||||
{reviewDetails.pending.length
|
||||
? 'corrija os campos indicados e volte a validar.'
|
||||
: draft.purpose === 'simulate_only' &&
|
||||
draft.fiscal_scenario_mode === 'custom_planning'
|
||||
? 'abra a etapa fiscal e use “Validar e calcular no servidor” para obter um resultado separado.'
|
||||
: draft.purpose === 'simulate_only'
|
||||
? 'use a ferramenta de cálculo adequada aos resultados que pretende obter. A consulta de parâmetros fiscais não calcula impostos; para um cenário percentual hipotético, selecione o modo de planeamento personalizado na etapa fiscal.'
|
||||
: draft.purpose === 'request_facilitation'
|
||||
? 'confirme que o rascunho está guardado no servidor e continue no Balcão para preparar o pedido de apoio.'
|
||||
: draft.purpose === 'seek_partner'
|
||||
? 'confirme a gravação do rascunho e explore parceiros na Bolsa de Parcerias, preservando o contexto do projecto.'
|
||||
: draft.purpose === 'seek_investor'
|
||||
? 'confirme a gravação do rascunho e reveja o financiamento e a informação a apresentar a potenciais investidores.'
|
||||
: draft.purpose === 'present_opportunity'
|
||||
? 'confirme a gravação e reveja os dados e a autorização de divulgação antes de preparar a oportunidade.'
|
||||
: 'confirme a gravação e continue a preparar o dossier e o plano do seu investimento.'}{' '}
|
||||
Nenhum pedido foi submetido.
|
||||
</p>
|
||||
{draft.purpose === 'simulate_only' &&
|
||||
!reviewDetails.pending.length && (
|
||||
<div className="space-y-2">
|
||||
<p>
|
||||
{draft.fiscal_scenario_mode ===
|
||||
'custom_planning'
|
||||
? 'Modo personalizado escolhido: calcula uma operação percentual com a base, taxa e redução que introduziu. Não calcula um imposto legal.'
|
||||
: 'As ferramentas financeiras calculam prestações, ponto de equilíbrio e necessidades de financiamento com os seus pressupostos. A consulta fiscal identifica parâmetros e fontes; se faltarem parâmetros, o resultado fiscal permanece indeterminado. O modo escolhido será preservado.'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-wrap justify-between gap-3 border-t pt-4">
|
||||
<button
|
||||
type="button"
|
||||
@@ -3785,15 +4111,56 @@ export function SimulationForm() {
|
||||
Guardar e continuar
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void clearDraft()}
|
||||
className="rounded border px-4 py-3 focus-visible:outline-2"
|
||||
>
|
||||
Limpar este rascunho
|
||||
</button>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<button
|
||||
type="button"
|
||||
disabled={isCalculating}
|
||||
aria-busy={isCalculating}
|
||||
onClick={validateFinalReview}
|
||||
className="rounded-lg bg-emerald-800 px-5 py-3 font-semibold text-white disabled:opacity-60"
|
||||
>
|
||||
{isCalculating
|
||||
? 'A validar…'
|
||||
: 'Validar este rascunho'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void clearDraft()}
|
||||
className="rounded border px-4 py-3 focus-visible:outline-2"
|
||||
>
|
||||
Limpar este rascunho
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{draft.purpose === 'simulate_only' && (
|
||||
<div
|
||||
className="space-y-2 rounded border p-3"
|
||||
aria-label="Ações do simulador"
|
||||
>
|
||||
<p>
|
||||
Abrir cálculo apresenta as ferramentas financeiras e, se
|
||||
já escolhido, o cálculo percentual personalizado.
|
||||
Consultar parâmetros fiscais apresenta fontes e lacunas;
|
||||
não determina imposto devido.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded bg-emerald-800 px-4 py-3 font-semibold text-white"
|
||||
onClick={() => openDraftTool('calculation')}
|
||||
>
|
||||
Abrir cálculo
|
||||
</button>{' '}
|
||||
<button
|
||||
type="button"
|
||||
className="rounded border px-4 py-3"
|
||||
onClick={() => openDraftTool('fiscal')}
|
||||
>
|
||||
Consultar parâmetros fiscais
|
||||
</button>
|
||||
{toolError && <p role="alert">{toolError}</p>}
|
||||
</div>
|
||||
)}
|
||||
<p className="text-xs text-slate-600">
|
||||
Os pressupostos não identificativos podem ser guardados de forma
|
||||
cifrada para recuperar o rascunho. Os dados pessoais e contactos
|
||||
|
||||
@@ -29,6 +29,22 @@ export function simulationApiErrorMessage(value: unknown): string | undefined {
|
||||
: value.message;
|
||||
}
|
||||
|
||||
/** Keep snapshot field errors attached to their original form fields. */
|
||||
export function simulationApiFieldErrors(
|
||||
value: unknown,
|
||||
): Record<string, string> {
|
||||
if (!isApiErrorV1(value)) return {};
|
||||
const fields: Record<string, string> = {};
|
||||
for (const [path, messages] of Object.entries(value.errors ?? {})) {
|
||||
const key = path
|
||||
.replace(/^(economic_snapshot|personal_snapshot)\./, '')
|
||||
.split('.')[0];
|
||||
if (/^[a-z][a-z0-9_]*$/.test(key) && messages.length)
|
||||
fields[key] = messages.join(' ');
|
||||
}
|
||||
return fields;
|
||||
}
|
||||
|
||||
/** Explain a failed status check without implying the accepted job was lost. */
|
||||
export function simulationPollingFailureMessage(value: unknown): string {
|
||||
return (
|
||||
|
||||
@@ -3,10 +3,32 @@ import test from 'node:test';
|
||||
import {
|
||||
readSimulationApiJson,
|
||||
simulationApiErrorMessage,
|
||||
simulationApiFieldErrors,
|
||||
simulationPollingFailureMessage,
|
||||
simulationCatalogChoices,
|
||||
} from '../../resources/js/lib/simulation-api.ts';
|
||||
|
||||
void test('snapshot API errors retain field identity and reject malformed responses', () => {
|
||||
assert.deepEqual(
|
||||
simulationApiFieldErrors({
|
||||
message: 'Invalid',
|
||||
errors: {
|
||||
'economic_snapshot.preferred_language': [
|
||||
'Unsupported language',
|
||||
],
|
||||
'personal_snapshot.email': ['Invalid email'],
|
||||
'economic_snapshot.required_documents.0': ['Invalid document'],
|
||||
},
|
||||
}),
|
||||
{
|
||||
preferred_language: 'Unsupported language',
|
||||
email: 'Invalid email',
|
||||
required_documents: 'Invalid document',
|
||||
},
|
||||
);
|
||||
assert.deepEqual(simulationApiFieldErrors({ errors: 'invalid' }), {});
|
||||
});
|
||||
|
||||
void test('malformed and non-JSON API bodies do not leak parser exceptions', async () => {
|
||||
assert.equal(
|
||||
await readSimulationApiJson(
|
||||
|
||||
@@ -0,0 +1,259 @@
|
||||
const {
|
||||
chromium,
|
||||
} = require('C:/Users/macbook/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules/playwright');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const base = 'http://127.0.0.1:18092';
|
||||
(async () => {
|
||||
const b = await chromium.launch({
|
||||
headless: true,
|
||||
executablePath:
|
||||
'C:/Users/macbook/AppData/Local/ms-playwright/chromium-1243/chrome-win64/chrome.exe',
|
||||
});
|
||||
try {
|
||||
const c = await b.newContext();
|
||||
const p = await c.newPage();
|
||||
let posts = 0;
|
||||
let requestPayload;
|
||||
const responses = [];
|
||||
await c.route('**/*', (r) =>
|
||||
r.request().url().startsWith(base) ? r.continue() : r.abort(),
|
||||
);
|
||||
p.on('response', async (r) => {
|
||||
if (
|
||||
r.url().includes('/simulation-drafts') &&
|
||||
r.request().method() === 'POST'
|
||||
) {
|
||||
const body = await r.json();
|
||||
responses.push({
|
||||
status: r.status(),
|
||||
body,
|
||||
payload: r.request().postDataJSON(),
|
||||
});
|
||||
requestPayload = r.request().postDataJSON();
|
||||
}
|
||||
});
|
||||
p.on('request', (r) => {
|
||||
if (r.method() === 'POST' && r.url().includes('/balcao-virtual'))
|
||||
posts++;
|
||||
});
|
||||
await p.addInitScript(() => {
|
||||
if (!sessionStorage.getItem('investmoz-simulation-draft-v1'))
|
||||
sessionStorage.setItem(
|
||||
'investmoz-simulation-draft-v1',
|
||||
JSON.stringify({
|
||||
journey_type: 'personal',
|
||||
detail_level: 'simple',
|
||||
purpose: 'simulate_only',
|
||||
preferred_language: 'pt',
|
||||
fiscal_period: '2026',
|
||||
fiscal_periodicity: 'annual',
|
||||
fiscal_component: 'customs_ice',
|
||||
fiscal_customs_code: 'QA-NAO-CLASSIFICADO',
|
||||
fiscal_operation: 'import',
|
||||
fiscal_scenario_mode: 'custom_planning',
|
||||
base: '1000',
|
||||
rate: '5',
|
||||
reduction: '0',
|
||||
}),
|
||||
);
|
||||
});
|
||||
const initial = await p.goto(base + '/simulador-fiscal');
|
||||
assert.equal(
|
||||
initial.headers()['x-investmoz-qa-target'],
|
||||
'investmoz_v2_20261008_test:55441',
|
||||
);
|
||||
await p
|
||||
.getByText('O servidor não confirmou a gravação.', { exact: false })
|
||||
.waitFor();
|
||||
await p
|
||||
.getByRole('button', { name: '1. Finalidade', exact: true })
|
||||
.click();
|
||||
const field = p.getByRole('combobox').last();
|
||||
assert.equal(await field.getAttribute('aria-invalid'), 'true');
|
||||
await field.fill('');
|
||||
await field.press('ArrowDown');
|
||||
console.log(await p.getByRole('option').allTextContents());
|
||||
await p
|
||||
.getByRole('option', { name: /Portugu/ })
|
||||
.first()
|
||||
.click();
|
||||
await p
|
||||
.getByText('Rascunho guardado no servidor às', { exact: false })
|
||||
.waitFor();
|
||||
assert.equal(responses[0].status, 422);
|
||||
assert.equal(
|
||||
responses[0].body.errors['economic_snapshot.preferred_language'][0],
|
||||
'The selected economic_snapshot.preferred_language is invalid.',
|
||||
);
|
||||
assert.equal(
|
||||
requestPayload.economic_snapshot.preferred_language,
|
||||
'pt-MZ',
|
||||
);
|
||||
await p
|
||||
.getByRole('button', { name: '3. Revisão', exact: true })
|
||||
.click();
|
||||
await p
|
||||
.getByRole('button', { name: 'Validar este rascunho', exact: true })
|
||||
.click();
|
||||
await p.locator('#draft-validation-summary').waitFor();
|
||||
const saved = responses.find((x) => x.status === 201);
|
||||
assert.ok(saved);
|
||||
await c.close();
|
||||
async function login(page) {
|
||||
await page.goto(base + '/login');
|
||||
await page
|
||||
.locator('input[type=email]')
|
||||
.fill('guidance-qa-investor@example.invalid');
|
||||
await page
|
||||
.locator('input[type=password]')
|
||||
.fill('QA-Guidance-2026!');
|
||||
await page.locator('button[type=submit]').click();
|
||||
await page.waitForURL((u) => !u.pathname.startsWith('/login'));
|
||||
}
|
||||
const c2 = await b.newContext();
|
||||
const p2 = await c2.newPage();
|
||||
await login(p2);
|
||||
const result = await p2.evaluate(async (snapshots) => {
|
||||
const token = document.cookie
|
||||
.split('; ')
|
||||
.find((x) => x.startsWith('XSRF-TOKEN='))
|
||||
?.slice(11);
|
||||
const current = await fetch('/api/v1/simulation-drafts/current', {
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
const existing = await current.json();
|
||||
const r = await fetch(
|
||||
existing.data
|
||||
? '/api/v1/simulation-drafts/' + existing.data.id
|
||||
: '/api/v1/simulation-drafts',
|
||||
{
|
||||
method: existing.data ? 'PUT' : 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
'X-XSRF-TOKEN': decodeURIComponent(token),
|
||||
'Idempotency-Key': crypto.randomUUID(),
|
||||
...(existing.data
|
||||
? { 'If-Match': current.headers.get('ETag') }
|
||||
: {}),
|
||||
},
|
||||
body: JSON.stringify(snapshots),
|
||||
},
|
||||
);
|
||||
return { status: r.status, body: await r.json() };
|
||||
}, requestPayload);
|
||||
assert.ok([200, 201].includes(result.status), JSON.stringify(result));
|
||||
const id = result.body.data.id;
|
||||
await c2.close();
|
||||
const c3 = await b.newContext();
|
||||
const p3 = await c3.newPage();
|
||||
await login(p3);
|
||||
assert.equal(await p3.evaluate(() => sessionStorage.length), 0);
|
||||
await p3.goto(base + '/simulador-fiscal?draft=' + id);
|
||||
await p3
|
||||
.getByText('Dossier privado recuperado da sua conta.', {
|
||||
exact: false,
|
||||
})
|
||||
.waitFor();
|
||||
await p3
|
||||
.getByRole('button', { name: '2. Operação fiscal', exact: true })
|
||||
.click();
|
||||
assert.equal(
|
||||
await p3
|
||||
.getByLabel('Base hipotética para planeamento', {
|
||||
exact: false,
|
||||
})
|
||||
.inputValue(),
|
||||
'1000',
|
||||
);
|
||||
const uiSave = p3.waitForResponse(
|
||||
(r) =>
|
||||
r.request().method() === 'PUT' &&
|
||||
r.url().includes('/simulation-drafts/' + id) &&
|
||||
r.request().postDataJSON()?.economic_snapshot?.base === '1200',
|
||||
);
|
||||
await p3
|
||||
.getByLabel('Base hipotética para planeamento', { exact: false })
|
||||
.fill('1200');
|
||||
assert.equal((await uiSave).status(), 200);
|
||||
await p3.evaluate(() => {
|
||||
sessionStorage.clear();
|
||||
localStorage.clear();
|
||||
});
|
||||
await p3.reload();
|
||||
await p3
|
||||
.getByText('Dossier privado recuperado da sua conta.', {
|
||||
exact: false,
|
||||
})
|
||||
.waitFor();
|
||||
await p3
|
||||
.getByRole('button', { name: '2. Operação fiscal', exact: true })
|
||||
.click();
|
||||
assert.equal(
|
||||
await p3
|
||||
.getByLabel('Base hipotética para planeamento', {
|
||||
exact: false,
|
||||
})
|
||||
.inputValue(),
|
||||
'1200',
|
||||
);
|
||||
await p3
|
||||
.getByRole('button', { name: '3. Revisão', exact: true })
|
||||
.click();
|
||||
let calculations = 0;
|
||||
p3.on('request', (r) => {
|
||||
if (
|
||||
r.method() === 'POST' &&
|
||||
new URL(r.url()).pathname === '/api/v1/simulations'
|
||||
)
|
||||
calculations++;
|
||||
});
|
||||
await p3
|
||||
.getByRole('button', { name: 'Validar este rascunho', exact: true })
|
||||
.click();
|
||||
assert.equal(calculations, 0);
|
||||
await p3
|
||||
.getByRole('button', { name: '2. Operação fiscal', exact: true })
|
||||
.click();
|
||||
await p3
|
||||
.getByRole('button', {
|
||||
name: 'Validar e calcular no servidor',
|
||||
exact: true,
|
||||
})
|
||||
.click();
|
||||
await p3
|
||||
.getByText('Cálculo concluído no servidor.', { exact: false })
|
||||
.waitFor();
|
||||
assert.equal(calculations, 1);
|
||||
assert.equal(posts, 0);
|
||||
fs.writeFileSync(
|
||||
'storage/app/private/investmoz-v2/draft-422-browser.json',
|
||||
JSON.stringify(
|
||||
{
|
||||
qa: 'investmoz_v2_20261008_test:55441',
|
||||
responses,
|
||||
ownedDraft: id,
|
||||
ownedCreation: result.status,
|
||||
freshAuthenticatedSession: true,
|
||||
noLocalStorageRecovery: true,
|
||||
baseRecovered: '1200',
|
||||
authenticatedUiSave: 200,
|
||||
separateCalculationPosts: calculations,
|
||||
deskSubmissions: posts,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
await c3.close();
|
||||
console.log(
|
||||
'422 correction, field errors, server save and fresh account recovery passed',
|
||||
);
|
||||
} finally {
|
||||
await b.close();
|
||||
}
|
||||
})().catch((e) => {
|
||||
console.error(e);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
@@ -0,0 +1,268 @@
|
||||
const {
|
||||
chromium,
|
||||
} = require('C:/Users/macbook/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules/playwright');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
(async () => {
|
||||
const b = await chromium.launch({
|
||||
headless: true,
|
||||
executablePath:
|
||||
'C:/Users/macbook/AppData/Local/ms-playwright/chromium-1243/chrome-win64/chrome.exe',
|
||||
});
|
||||
const evidence = [];
|
||||
try {
|
||||
for (const width of [1440, 390]) {
|
||||
const context = await b.newContext({
|
||||
viewport: { width, height: 850 },
|
||||
});
|
||||
const p = await context.newPage();
|
||||
const consoleErrors = [];
|
||||
p.on('pageerror', (error) => consoleErrors.push(error.message));
|
||||
const base = 'http://127.0.0.1:18092';
|
||||
const http = [];
|
||||
let submissions = 0;
|
||||
let calculations = 0;
|
||||
await p.route('**/*', (r) => {
|
||||
const u = r.request().url();
|
||||
if (!u.startsWith(base)) return r.abort();
|
||||
if (
|
||||
r.request().method() === 'POST' &&
|
||||
u.includes('/balcao-virtual')
|
||||
)
|
||||
submissions++;
|
||||
if (
|
||||
r.request().method() === 'POST' &&
|
||||
new URL(u).pathname === '/api/v1/simulations'
|
||||
)
|
||||
calculations++;
|
||||
return r.continue();
|
||||
});
|
||||
p.on('response', (r) => {
|
||||
if (
|
||||
r.status() === 422 &&
|
||||
r.url().includes('/simulation-drafts')
|
||||
)
|
||||
void r.json().then((body) =>
|
||||
fs.writeFileSync(
|
||||
'storage/app/private/investmoz-v2/draft-422-response.json',
|
||||
JSON.stringify(
|
||||
{
|
||||
body,
|
||||
payload: r.request().postDataJSON(),
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
),
|
||||
);
|
||||
if (r.url().includes('/api/'))
|
||||
http.push({
|
||||
path: new URL(r.url()).pathname,
|
||||
status: r.status(),
|
||||
method: r.request().method(),
|
||||
});
|
||||
});
|
||||
await p.addInitScript(() => {
|
||||
if (sessionStorage.getItem('investmoz-simulation-draft-v1'))
|
||||
return;
|
||||
sessionStorage.setItem(
|
||||
'investmoz-simulation-draft-v1',
|
||||
JSON.stringify({
|
||||
journey_type: 'personal',
|
||||
detail_level: 'simple',
|
||||
purpose: 'simulate_only',
|
||||
preferred_language: 'pt-MZ',
|
||||
fiscal_period: '2026',
|
||||
fiscal_periodicity: 'annual',
|
||||
fiscal_component: 'customs_ice',
|
||||
fiscal_customs_code: 'QA-NAO-CLASSIFICADO',
|
||||
fiscal_operation: 'import',
|
||||
fiscal_scenario_mode: 'custom_planning',
|
||||
rate: '5',
|
||||
reduction: '0',
|
||||
}),
|
||||
);
|
||||
});
|
||||
const response = await p.goto(base + '/simulador-fiscal');
|
||||
assert.equal(
|
||||
response.headers()['x-investmoz-qa-target'],
|
||||
'investmoz_v2_20261008_test:55441',
|
||||
);
|
||||
await p
|
||||
.getByRole('button', { name: '3. Revisão', exact: true })
|
||||
.click();
|
||||
await p
|
||||
.getByRole('button', {
|
||||
name: 'Validar este rascunho',
|
||||
exact: true,
|
||||
})
|
||||
.click();
|
||||
const summary = p.locator('#draft-validation-summary');
|
||||
await summary.waitFor();
|
||||
await p.waitForFunction(
|
||||
() => document.activeElement?.id === 'draft-validation-summary',
|
||||
);
|
||||
assert.match(await summary.innerText(), /campos pendentes/);
|
||||
assert.match(await summary.innerText(), /Não calcula valores/);
|
||||
assert.equal(calculations, 0);
|
||||
assert.equal(submissions, 0);
|
||||
await p.waitForFunction(() => {
|
||||
const rect = document
|
||||
.getElementById('draft-validation-summary')
|
||||
?.getBoundingClientRect();
|
||||
return rect && rect.top >= -1 && rect.top < innerHeight;
|
||||
});
|
||||
assert.equal(
|
||||
await summary.evaluate((e) => {
|
||||
const r = e.getBoundingClientRect();
|
||||
return r.top >= -1 && r.top < innerHeight;
|
||||
}),
|
||||
true,
|
||||
);
|
||||
await summary.getByRole('button').first().click();
|
||||
await p
|
||||
.getByLabel('Base hipotética para planeamento', {
|
||||
exact: false,
|
||||
})
|
||||
.fill('1000');
|
||||
await p
|
||||
.getByRole('button', { name: '3. Revisão', exact: true })
|
||||
.click();
|
||||
await p
|
||||
.getByRole('button', {
|
||||
name: 'Validar este rascunho',
|
||||
exact: true,
|
||||
})
|
||||
.dblclick();
|
||||
await p.waitForFunction(
|
||||
() => document.activeElement?.id === 'draft-validation-summary',
|
||||
);
|
||||
assert.match(
|
||||
await summary.innerText(),
|
||||
/Campos pendentes e inconsistências: 0/,
|
||||
);
|
||||
assert.equal(calculations, 0);
|
||||
assert.equal(submissions, 0);
|
||||
await p
|
||||
.getByRole('button', {
|
||||
name: '2. Operação fiscal',
|
||||
exact: true,
|
||||
})
|
||||
.click();
|
||||
assert.equal(
|
||||
await p
|
||||
.getByLabel('Base hipotética para planeamento', {
|
||||
exact: false,
|
||||
})
|
||||
.inputValue(),
|
||||
'1000',
|
||||
);
|
||||
await p.waitForFunction(
|
||||
() =>
|
||||
JSON.parse(
|
||||
sessionStorage.getItem(
|
||||
'investmoz-simulation-draft-v1',
|
||||
) ?? '{}',
|
||||
).base === '1000',
|
||||
);
|
||||
await p
|
||||
.getByRole('button', {
|
||||
name: 'Guardar e continuar mais tarde',
|
||||
exact: true,
|
||||
})
|
||||
.click();
|
||||
await p.reload();
|
||||
await p
|
||||
.getByRole('button', {
|
||||
name: '2. Operação fiscal',
|
||||
exact: true,
|
||||
})
|
||||
.click();
|
||||
assert.equal(
|
||||
await p
|
||||
.getByLabel('Base hipotética para planeamento', {
|
||||
exact: false,
|
||||
})
|
||||
.inputValue(),
|
||||
'1000',
|
||||
);
|
||||
await p
|
||||
.getByRole('button', { name: '3. Revisão', exact: true })
|
||||
.click();
|
||||
await p
|
||||
.getByRole('button', {
|
||||
name: 'Validar este rascunho',
|
||||
exact: true,
|
||||
})
|
||||
.click();
|
||||
await p.screenshot({
|
||||
path: `storage/app/private/investmoz-v2/review-summary-${width}.png`,
|
||||
fullPage: true,
|
||||
});
|
||||
const names = await summary.locator('dt').allTextContents();
|
||||
const values = await summary.locator('dd').allTextContents();
|
||||
assert.ok(names.length > 0 && names.length === values.length);
|
||||
assert.ok(
|
||||
names.every((text) => text.trim()) &&
|
||||
values.every((text) => text.trim()),
|
||||
);
|
||||
const calculationResponse = p.waitForResponse(
|
||||
(r) =>
|
||||
new URL(r.url()).pathname === '/api/v1/simulations' &&
|
||||
r.request().method() === 'POST',
|
||||
);
|
||||
await p
|
||||
.getByRole('button', {
|
||||
name: 'Abrir cálculo',
|
||||
exact: true,
|
||||
})
|
||||
.click();
|
||||
await p.waitForFunction(
|
||||
() => document.activeElement?.id === 'draft-tool-calculation',
|
||||
);
|
||||
await p
|
||||
.getByRole('button', {
|
||||
name: 'Validar e calcular no servidor',
|
||||
exact: true,
|
||||
})
|
||||
.dblclick();
|
||||
const calculated = await calculationResponse;
|
||||
assert.ok([200, 201, 202].includes(calculated.status()));
|
||||
await p
|
||||
.getByText('Cálculo concluído no servidor.', { exact: false })
|
||||
.waitFor();
|
||||
assert.equal(calculations, 1);
|
||||
assert.equal(submissions, 0);
|
||||
evidence.push({
|
||||
width,
|
||||
passed: true,
|
||||
summaryFocused: true,
|
||||
summaryInViewport: true,
|
||||
pendingCorrection: true,
|
||||
dataRecovered: true,
|
||||
calculationPosts: calculations,
|
||||
deskSubmissions: submissions,
|
||||
consoleErrors,
|
||||
http,
|
||||
});
|
||||
assert.deepEqual(consoleErrors, []);
|
||||
await context.close();
|
||||
}
|
||||
fs.writeFileSync(
|
||||
'storage/app/private/investmoz-v2/review-summary-browser.json',
|
||||
JSON.stringify(
|
||||
{ qa: 'investmoz_v2_20261008_test:55441', evidence },
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
console.log(
|
||||
'QA summary: desktop/mobile, pending correction, focus, recovery and HTTP separation passed',
|
||||
);
|
||||
} finally {
|
||||
await b.close();
|
||||
}
|
||||
})().catch((e) => {
|
||||
console.error(e);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
@@ -0,0 +1,100 @@
|
||||
const {
|
||||
chromium,
|
||||
} = require('C:/Users/macbook/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules/playwright');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
(async () => {
|
||||
const b = await chromium.launch({
|
||||
headless: true,
|
||||
executablePath:
|
||||
'C:/Users/macbook/AppData/Local/ms-playwright/chromium-1243/chrome-win64/chrome.exe',
|
||||
});
|
||||
try {
|
||||
const p = await b.newPage();
|
||||
await p.addInitScript(() =>
|
||||
sessionStorage.setItem(
|
||||
'investmoz-simulation-draft-v1',
|
||||
JSON.stringify({
|
||||
journey_type: 'personal',
|
||||
detail_level: 'simple',
|
||||
purpose: 'simulate_only',
|
||||
preferred_language: 'pt-MZ',
|
||||
fiscal_period: '2026',
|
||||
fiscal_periodicity: 'annual',
|
||||
fiscal_component: 'inss',
|
||||
fiscal_scenario_mode: 'approved_parameters',
|
||||
}),
|
||||
),
|
||||
);
|
||||
let calculations = 0;
|
||||
p.on('request', (r) => {
|
||||
if (r.request) return;
|
||||
if (
|
||||
r.method() === 'POST' &&
|
||||
new URL(r.url()).pathname === '/api/v1/simulations'
|
||||
)
|
||||
calculations++;
|
||||
});
|
||||
const r = await p.goto('http://127.0.0.1:18092/simulador-fiscal');
|
||||
assert.equal(
|
||||
r.headers()['x-investmoz-qa-target'],
|
||||
'investmoz_v2_20261008_test:55441',
|
||||
);
|
||||
await p
|
||||
.getByRole('button', { name: '3. Revisão', exact: true })
|
||||
.click();
|
||||
await p
|
||||
.getByRole('button', { name: 'Validar este rascunho', exact: true })
|
||||
.click();
|
||||
const summary = p.locator('#draft-validation-summary');
|
||||
assert.ok(
|
||||
(await summary.locator('dt').allTextContents()).every(Boolean),
|
||||
);
|
||||
assert.ok(
|
||||
(await summary.locator('dd').allTextContents()).every(Boolean),
|
||||
);
|
||||
await p
|
||||
.getByRole('button', {
|
||||
name: 'Consultar parâmetros fiscais',
|
||||
exact: true,
|
||||
})
|
||||
.click();
|
||||
await p
|
||||
.getByText('Ferramentas de planeamento financeiro', { exact: true })
|
||||
.waitFor();
|
||||
await p
|
||||
.getByText('Faltam parâmetros aprovados para', { exact: false })
|
||||
.waitFor();
|
||||
assert.equal(
|
||||
await p.evaluate(
|
||||
() =>
|
||||
JSON.parse(
|
||||
sessionStorage.getItem('investmoz-simulation-draft-v1'),
|
||||
).fiscal_scenario_mode,
|
||||
),
|
||||
'approved_parameters',
|
||||
);
|
||||
assert.match(await p.locator('body').innerText(), /Indeterminado/);
|
||||
assert.equal(calculations, 0);
|
||||
fs.writeFileSync(
|
||||
'storage/app/private/investmoz-v2/summary-values-approved.json',
|
||||
JSON.stringify(
|
||||
{
|
||||
qa: true,
|
||||
modeUnchanged: true,
|
||||
calculationPosts: calculations,
|
||||
contents: (await p.locator('body').innerText()).slice(
|
||||
-5000,
|
||||
),
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
await b.close();
|
||||
}
|
||||
})().catch((e) => {
|
||||
console.error(e);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
Reference in New Issue
Block a user