1049 lines
36 KiB
JavaScript
1049 lines
36 KiB
JavaScript
import { compileFunction } from 'node:vm';
|
|
import assert from 'node:assert/strict';
|
|
import test from 'node:test';
|
|
import fs from 'node:fs';
|
|
import { createRequire } from 'node:module';
|
|
import React from 'react';
|
|
import { renderToStaticMarkup } from 'react-dom/server';
|
|
import ts from 'typescript';
|
|
import * as flow from '../../resources/js/lib/opportunity-fill-flow.ts';
|
|
import { fiscalPreparationGuide } from '../../resources/js/lib/fiscal-preparation-guide.ts';
|
|
const require = createRequire(import.meta.url);
|
|
function load(path, mocks = {}, exportName = 'default', extra = '') {
|
|
const source = fs.readFileSync(new URL(path, import.meta.url), 'utf8');
|
|
const output = ts.transpileModule(source, {
|
|
compilerOptions: {
|
|
module: ts.ModuleKind.CommonJS,
|
|
target: ts.ScriptTarget.ES2022,
|
|
jsx: ts.JsxEmit.ReactJSX,
|
|
},
|
|
}).outputText;
|
|
const module = { exports: {} };
|
|
compileFunction(output + extra, ['require', 'module', 'exports'])(
|
|
(name) =>
|
|
mocks[name] ??
|
|
(name.startsWith('@/') ? { default: () => null } : require(name)),
|
|
module,
|
|
module.exports,
|
|
);
|
|
return module.exports[exportName];
|
|
}
|
|
function stateHooks() {
|
|
const memory = [];
|
|
let cursor = 0;
|
|
return {
|
|
hooks: {
|
|
...React,
|
|
useState(initial) {
|
|
const i = cursor++;
|
|
if (!(i in memory))
|
|
memory[i] =
|
|
typeof initial === 'function' ? initial() : initial;
|
|
return [
|
|
memory[i],
|
|
(next) =>
|
|
(memory[i] =
|
|
typeof next === 'function'
|
|
? next(memory[i])
|
|
: next),
|
|
];
|
|
},
|
|
useRef: () => ({ current: null }),
|
|
useEffect: () => {},
|
|
},
|
|
reset: () => (cursor = 0),
|
|
};
|
|
}
|
|
const flat = (node) =>
|
|
!node || typeof node !== 'object'
|
|
? []
|
|
: [node, ...React.Children.toArray(node.props?.children).flatMap(flat)];
|
|
void test('free navigation keeps every field mounted and reports resource work in the current group', () => {
|
|
const state = stateHooks();
|
|
const Component = load(
|
|
'../../resources/js/components/opportunity-fill-flow.tsx',
|
|
{ react: state.hooks, '@/lib/opportunity-fill-flow': flow },
|
|
);
|
|
const groups = flow.opportunityFillGroups([
|
|
'official_title',
|
|
'locations',
|
|
'documents',
|
|
]);
|
|
const saved = {
|
|
official_title: 'Deliberate user title',
|
|
locations: ['saved-site'],
|
|
};
|
|
const opened = [];
|
|
let tree;
|
|
const render = () => {
|
|
state.reset();
|
|
tree = Component({
|
|
groups,
|
|
errors: {},
|
|
renderField: (field) =>
|
|
React.createElement('input', {
|
|
name: field,
|
|
value: JSON.stringify(saved[field] ?? ''),
|
|
readOnly: true,
|
|
}),
|
|
onOpenResources: (group) => opened.push(group),
|
|
});
|
|
};
|
|
const priorWindow = globalThis.window,
|
|
priorFrame = globalThis.requestAnimationFrame;
|
|
globalThis.window = {
|
|
history: {
|
|
state: { existing: 'inertia-state' },
|
|
replaceState(state, _title, url) {
|
|
assert.deepEqual(state, { existing: 'inertia-state' });
|
|
this.url = url;
|
|
},
|
|
},
|
|
};
|
|
globalThis.requestAnimationFrame = (callback) => callback();
|
|
try {
|
|
render();
|
|
assert.equal(
|
|
flat(tree).filter((node) => node.type === 'input').length,
|
|
3,
|
|
);
|
|
flat(tree)
|
|
.find(
|
|
(node) =>
|
|
node.type === 'a' && node.props.href === '#fill-territory',
|
|
)
|
|
.props.onClick({ preventDefault() {} });
|
|
render();
|
|
const sections = flat(tree).filter((node) => node.type === 'section');
|
|
assert.equal(
|
|
sections.find((node) => node.props.id === 'fill-territory').props
|
|
.hidden,
|
|
false,
|
|
);
|
|
assert.equal(
|
|
sections.find((node) => node.props.id === 'fill-idea').props.hidden,
|
|
true,
|
|
);
|
|
assert.equal(
|
|
flat(tree).filter((node) => node.type === 'input').length,
|
|
3,
|
|
);
|
|
flat(tree)
|
|
.find((node) => node.type === 'button')
|
|
.props.onClick();
|
|
assert.deepEqual(opened, ['territory']);
|
|
assert.equal(saved.official_title, 'Deliberate user title');
|
|
assert.deepEqual(saved.locations, ['saved-site']);
|
|
assert.equal(globalThis.window.history.url, '#fill-territory');
|
|
} finally {
|
|
globalThis.window = priorWindow;
|
|
globalThis.requestAnimationFrame = priorFrame;
|
|
}
|
|
});
|
|
void test('profile request renders readable labels without dumping every available document into the form', () => {
|
|
const Picker = load(
|
|
'../../resources/js/components/profile-proof-picker.tsx',
|
|
);
|
|
const Page = load('../../resources/js/pages/profiles/request.tsx', {
|
|
'@/lib/operation-key': { createOperationKey: () => 'synthetic-key' },
|
|
'@inertiajs/react': {
|
|
Head: () => null,
|
|
Link: ({ href, children }) =>
|
|
React.createElement('a', { href }, children),
|
|
router: {},
|
|
},
|
|
'@/components/profile-navigation': { default: () => null },
|
|
'@/lib/profile-json': {},
|
|
'@/pages/management/shared': { button: 'button', control: 'control' },
|
|
'@/components/opportunity-search-filters': {
|
|
modalityLabels: {
|
|
investment_project: 'Projecto de investimento',
|
|
land_rights: 'Direitos fundiários',
|
|
},
|
|
},
|
|
'@/components/profile-proof-picker': { default: Picker },
|
|
});
|
|
const html = renderToStaticMarkup(
|
|
React.createElement(Page, {
|
|
requests: [],
|
|
types: ['investment_project', 'land_rights'],
|
|
organizations: [],
|
|
documents: Array.from({ length: 80 }, (_, i) => ({
|
|
id: `doc-${i}`,
|
|
file_name: `UNSELECTED-DOCUMENT-${i}.txt`,
|
|
})),
|
|
}),
|
|
);
|
|
assert.match(html, /Em nome próprio/);
|
|
assert.match(html, /Entidade pública/);
|
|
assert.match(html, /Projecto de investimento/);
|
|
assert.match(html, /Direitos fundiários/);
|
|
assert.doesNotMatch(html, /UNSELECTED-DOCUMENT/);
|
|
assert.match(html, /Pesquisar nos meus ficheiros/);
|
|
const visible = html.replace(/<[^>]+>/g, '');
|
|
assert.doesNotMatch(
|
|
visible,
|
|
/investment_project|land_rights|public_entity/,
|
|
);
|
|
});
|
|
void test('proof selection is explicit and retained when the filename search changes', () => {
|
|
const state = stateHooks();
|
|
const Component = load(
|
|
'../../resources/js/components/profile-proof-picker.tsx',
|
|
{ react: state.hooks },
|
|
);
|
|
const documents = [
|
|
{ id: 'identity', file_name: 'Documento próprio' },
|
|
{ id: 'law', file_name: 'Lei.pdf' },
|
|
];
|
|
const values = [];
|
|
let tree;
|
|
const render = () => {
|
|
state.reset();
|
|
tree = Component({
|
|
documents,
|
|
value: values,
|
|
onChange: (ids) => {
|
|
values.splice(0, values.length, ...ids);
|
|
},
|
|
});
|
|
};
|
|
render();
|
|
assert.equal(
|
|
flat(tree).filter(
|
|
(node) => node.type === 'input' && node.props.type === 'checkbox',
|
|
).length,
|
|
0,
|
|
);
|
|
flat(tree)
|
|
.find((node) => node.type === 'input')
|
|
.props.onChange({ target: { value: 'próprio' } });
|
|
render();
|
|
flat(tree)
|
|
.find((node) => node.type === 'input' && node.props.type === 'checkbox')
|
|
.props.onChange();
|
|
render();
|
|
assert.deepEqual(values, ['identity']);
|
|
flat(tree)
|
|
.find((node) => node.type === 'input' && node.props.type === 'search')
|
|
.props.onChange({ target: { value: 'Lei' } });
|
|
render();
|
|
assert.deepEqual(values, ['identity']);
|
|
assert.equal(
|
|
flat(tree).filter(
|
|
(node) => node.type === 'input' && node.props.type === 'checkbox',
|
|
)[0].props.checked,
|
|
false,
|
|
);
|
|
});
|
|
void test('document entry leads to existing fillable preparation and keeps sources optional', () => {
|
|
const context = '10000000-0000-4000-8000-000000000001';
|
|
const inertia = {
|
|
Link: ({ href, children }) =>
|
|
React.createElement('a', { href }, children),
|
|
usePage: () => ({ url: `/documentos?context=${context}` }),
|
|
};
|
|
const Hub = load(
|
|
'../../resources/js/components/journey-document-resources.tsx',
|
|
{
|
|
'@inertiajs/react': inertia,
|
|
'@/lib/fiscal-preparation-guide': { fiscalPreparationGuide },
|
|
},
|
|
'JourneyDocumentResources',
|
|
);
|
|
const hub = renderToStaticMarkup(React.createElement(Hub, {}));
|
|
assert.match(
|
|
hub,
|
|
new RegExp(
|
|
`/investimentos/${context}/contexto/editar#preparation-questions`,
|
|
),
|
|
);
|
|
assert.match(hub, /href="\/terra-duat"/);
|
|
assert.match(hub, /Preparação digital e exemplar oficial/);
|
|
assert.doesNotMatch(hub, /<details[^>]*\bopen\b/);
|
|
const Editor = load(
|
|
'../../resources/js/components/private-investment-preparation.tsx',
|
|
{
|
|
'@inertiajs/react': inertia,
|
|
'@/pages/investment/shared': { card: 'card', input: 'input' },
|
|
},
|
|
'PreparationEditor',
|
|
);
|
|
const html = renderToStaticMarkup(
|
|
React.createElement(Editor, {
|
|
data: {
|
|
questions: [
|
|
{
|
|
key: 'investment_preparation',
|
|
label: 'Preparar pedido?',
|
|
options: { yes: 'Sim', no: 'Não' },
|
|
source: 'SYNTHETIC QA',
|
|
},
|
|
{
|
|
key: 'declared_amount',
|
|
label: 'Valor do investimento',
|
|
type: 'number',
|
|
source: 'SYNTHETIC QA',
|
|
},
|
|
],
|
|
requirements: [],
|
|
enabled: true,
|
|
mode: 'authorization',
|
|
missing_fields: ['declared_amount'],
|
|
},
|
|
fields: { investment_preparation: 'yes' },
|
|
changes: {},
|
|
documents: [],
|
|
links: {},
|
|
onChange() {},
|
|
onLink() {},
|
|
disabled: false,
|
|
}),
|
|
);
|
|
assert.match(html, /<input/);
|
|
assert.match(html, /Valor do investimento/);
|
|
assert.match(html, /id="preparation-questions"/);
|
|
});
|
|
void test('investment stages expose readable next actions while internal codes and sources remain consultable', () => {
|
|
const useForm = (data) => ({
|
|
data,
|
|
errors: {},
|
|
processing: false,
|
|
setData() {},
|
|
transform() {
|
|
return this;
|
|
},
|
|
post() {},
|
|
});
|
|
const Stage = load(
|
|
'../../resources/js/pages/investor-path/show.tsx',
|
|
{
|
|
'@inertiajs/react': {
|
|
useForm,
|
|
Link: ({ href, children }) =>
|
|
React.createElement('a', { href }, children),
|
|
},
|
|
'@/lib/investor-path': {
|
|
pathStates: { not_started: 'Por iniciar' },
|
|
},
|
|
},
|
|
'Stage',
|
|
'\nmodule.exports.Stage=Stage;',
|
|
);
|
|
const step = {
|
|
code: 'S01',
|
|
title: 'Sistematizar a ideia',
|
|
state: 'not_started',
|
|
detail: { scope: 'promoter_preparation' },
|
|
preparation_allowed: true,
|
|
action_gaps: {},
|
|
source_paragraph: 22,
|
|
applicability: 'applicable',
|
|
};
|
|
const html = renderToStaticMarkup(
|
|
React.createElement(Stage, {
|
|
step,
|
|
path: {
|
|
case: { id: 'synthetic', version: 1 },
|
|
next: { code: 'S01' },
|
|
canWrite: true,
|
|
canReview: false,
|
|
steps: [step],
|
|
documents: [],
|
|
},
|
|
}),
|
|
);
|
|
assert.match(html, /<details[^>]*id="S01"[^>]*open/);
|
|
assert.match(html, /<summary[^>]*>Sistematizar a ideia/);
|
|
assert.doesNotMatch(html, /<summary[^>]*>S01/);
|
|
assert.match(html, /Fonte e referência desta etapa/);
|
|
});
|
|
|
|
function preparationFixture() {
|
|
const requirement = (key, state, required = true) => ({
|
|
key,
|
|
label: key,
|
|
state,
|
|
required,
|
|
document_id: state === 'associated' ? 'saved-document' : null,
|
|
document: null,
|
|
reason: 'Synthetic QA',
|
|
source: { url: '/source', reference: 'Synthetic QA' },
|
|
});
|
|
return {
|
|
version: 'private-investment-preparation-2026-v1',
|
|
enabled: true,
|
|
mode: 'registration',
|
|
period_covered: true,
|
|
scope_covered: true,
|
|
missing_fields: [],
|
|
questions: [
|
|
{
|
|
key: 'investment_preparation',
|
|
label: 'Preparar?',
|
|
source: 'Synthetic QA',
|
|
},
|
|
],
|
|
next: 'Preparar documentos',
|
|
reason: [],
|
|
authority_source: { url: '/source' },
|
|
source: { url: '/source' },
|
|
requirements: [
|
|
requirement('MISSING', 'missing'),
|
|
requirement('ASSOCIATED', 'associated'),
|
|
requirement('UNDETERMINED', 'indeterminate', false),
|
|
requirement('HISTORICAL', 'not_applicable', false),
|
|
],
|
|
};
|
|
}
|
|
void test('impact exposes document replacement even when its state and filename stay unchanged', () => {
|
|
const Impact = load(
|
|
'../../resources/js/components/private-investment-preparation.tsx',
|
|
preparationMocks,
|
|
'PreparationImpact',
|
|
);
|
|
const before = preparationFixture();
|
|
before.requirements = [before.requirements[1]];
|
|
before.requirements[0].document = { file_name: 'ORIGINAL.pdf' };
|
|
const after = structuredClone(before);
|
|
after.requirements[0].document_id = 'replacement-document';
|
|
const original = JSON.stringify({ before, after });
|
|
const html = renderToStaticMarkup(
|
|
React.createElement(Impact, { before, after }),
|
|
);
|
|
const impactList = html.slice(html.indexOf('<ul'), html.indexOf('</ul>'));
|
|
assert.match(impactList, /ASSOCIATED/);
|
|
assert.match(
|
|
impactList,
|
|
/Substituição de documento: ORIGINAL.pdf → ORIGINAL.pdf/,
|
|
);
|
|
assert.doesNotMatch(impactList, /Vínculo documental preservado/);
|
|
assert.doesNotMatch(html, /Sem alterações às exigências/);
|
|
assert.equal(JSON.stringify({ before, after }), original);
|
|
});
|
|
void test('impact distinguishes detachment, unavailable names, preserved history and unchanged associations', () => {
|
|
const Impact = load(
|
|
'../../resources/js/components/private-investment-preparation.tsx',
|
|
preparationMocks,
|
|
'PreparationImpact',
|
|
);
|
|
const before = preparationFixture();
|
|
before.requirements = [before.requirements[1]];
|
|
before.requirements[0].document = { file_name: 'ORIGINAL.pdf' };
|
|
const render = (after) =>
|
|
renderToStaticMarkup(React.createElement(Impact, { before, after }));
|
|
const detached = structuredClone(before);
|
|
detached.requirements[0] = {
|
|
...detached.requirements[0],
|
|
document_id: null,
|
|
document: null,
|
|
state: 'missing',
|
|
};
|
|
assert.match(
|
|
render(detached),
|
|
/Documento: ORIGINAL.pdf → Sem documento associado/,
|
|
);
|
|
const replaced = structuredClone(before);
|
|
replaced.requirements[0] = {
|
|
...replaced.requirements[0],
|
|
document_id: 'new',
|
|
document: null,
|
|
};
|
|
assert.match(
|
|
render(replaced),
|
|
/Substituição de documento: ORIGINAL.pdf → Documento associado — nome indisponível/,
|
|
);
|
|
const historical = structuredClone(before);
|
|
historical.requirements[0].state = 'not_applicable';
|
|
const historicalList = render(historical).split('</ul>')[0];
|
|
assert.match(historicalList, /Vínculo documental preservado/);
|
|
assert.doesNotMatch(historicalList, /Substituição de documento/);
|
|
assert.match(
|
|
render(structuredClone(before)),
|
|
/Sem alterações às exigências ou associações documentais/,
|
|
);
|
|
const associated = structuredClone(detached);
|
|
const added = renderToStaticMarkup(
|
|
React.createElement(Impact, { before: associated, after: before }),
|
|
);
|
|
assert.match(added, /Documento: Sem documento associado → ORIGINAL.pdf/);
|
|
assert.equal(before.requirements[0].document_id, 'saved-document');
|
|
});
|
|
const preparationMocks = {
|
|
'@inertiajs/react': {
|
|
Link: ({ href, children }) =>
|
|
React.createElement('a', { href }, children),
|
|
},
|
|
'@/pages/investment/shared': { card: 'card', input: 'input' },
|
|
};
|
|
function contextPreviewHarness() {
|
|
const memory = [];
|
|
const effects = [];
|
|
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(callback, dependencies) {
|
|
effects.push({ callback, dependencies });
|
|
},
|
|
};
|
|
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,
|
|
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: {
|
|
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));
|
|
async function withDraftTimers(callback) {
|
|
const previousSetTimeout = globalThis.setTimeout;
|
|
const previousClearTimeout = globalThis.clearTimeout;
|
|
const timers = [];
|
|
globalThis.setTimeout = (callback) => {
|
|
timers.push(callback);
|
|
return timers.length;
|
|
};
|
|
globalThis.clearTimeout = () => {};
|
|
try {
|
|
await callback(timers);
|
|
} finally {
|
|
globalThis.setTimeout = previousSetTimeout;
|
|
globalThis.clearTimeout = previousClearTimeout;
|
|
}
|
|
}
|
|
async function readyDraftHarness() {
|
|
const harness = contextPreviewHarness();
|
|
harness.render();
|
|
harness.effects
|
|
.find((effect) => effect.dependencies?.length === 3)
|
|
.callback();
|
|
harness.pending[0].resolve({ corridors: [], parks: [] });
|
|
await settlePreview();
|
|
harness.render();
|
|
return harness;
|
|
}
|
|
const runAutosaveEffect = (harness) =>
|
|
harness.effects
|
|
.find((effect) => effect.dependencies?.length === 5)
|
|
.callback();
|
|
const changeDraftCurrency = (harness, value) => {
|
|
const tree = harness.render();
|
|
flat(tree)
|
|
.find((node) => node.type === 'input' && node.props.maxLength === 3)
|
|
.props.onChange({ target: { value } });
|
|
return harness.render();
|
|
};
|
|
void test('stale autosave timer never sends an old body with a newer draft sequence', async () => {
|
|
await withDraftTimers(async (timers) => {
|
|
const harness = await readyDraftHarness();
|
|
runAutosaveEffect(harness);
|
|
changeDraftCurrency(harness, 'USD');
|
|
timers[0]();
|
|
await settlePreview();
|
|
assert.equal(harness.pending.length, 1);
|
|
runAutosaveEffect(harness);
|
|
timers[1]();
|
|
await settlePreview();
|
|
assert.equal(harness.pending.length, 2);
|
|
assert.equal(harness.pending[1].body.declarations.currency, 'USD');
|
|
assert.ok(harness.pending[1].body.draft_sequence > 0);
|
|
harness.pending[1].resolve({ saved: true });
|
|
await settlePreview();
|
|
assert.ok(
|
|
flat(harness.render()).some(
|
|
(node) => node.props.children === 'Rascunho guardado.',
|
|
),
|
|
);
|
|
});
|
|
});
|
|
void test('queued obsolete draft and late completion cannot acknowledge a later revision with identical values', async () => {
|
|
await withDraftTimers(async (timers) => {
|
|
const harness = await readyDraftHarness();
|
|
changeDraftCurrency(harness, 'EUR');
|
|
runAutosaveEffect(harness);
|
|
timers[0]();
|
|
await settlePreview();
|
|
const firstSequence = harness.pending[1].body.draft_sequence;
|
|
changeDraftCurrency(harness, 'USD');
|
|
runAutosaveEffect(harness);
|
|
timers[1]();
|
|
await settlePreview();
|
|
changeDraftCurrency(harness, 'EUR');
|
|
harness.pending[1].resolve({ saved: true });
|
|
await settlePreview();
|
|
assert.equal(harness.pending.length, 2);
|
|
assert.equal(
|
|
flat(harness.render()).some(
|
|
(node) => node.props.children === 'Rascunho guardado.',
|
|
),
|
|
false,
|
|
);
|
|
runAutosaveEffect(harness);
|
|
timers[2]();
|
|
await settlePreview();
|
|
assert.equal(harness.pending[2].body.declarations.currency, 'EUR');
|
|
assert.ok(harness.pending[2].body.draft_sequence > firstSequence);
|
|
harness.pending[2].resolve({ saved: true });
|
|
await settlePreview();
|
|
assert.ok(
|
|
flat(harness.render()).some(
|
|
(node) => node.props.children === 'Rascunho guardado.',
|
|
),
|
|
);
|
|
});
|
|
});
|
|
void test('large multi-opportunity draft is offered to the transport on exit without truncation', () => {
|
|
const previousWindow = globalThis.window;
|
|
let flush;
|
|
globalThis.window = {
|
|
addEventListener(_name, callback) {
|
|
flush = callback;
|
|
},
|
|
removeEventListener() {},
|
|
};
|
|
let cleanup;
|
|
try {
|
|
const harness = contextPreviewHarness();
|
|
const initial = harness.render();
|
|
cleanup = harness.effects
|
|
.find(
|
|
(effect) =>
|
|
effect.dependencies?.length === 1 &&
|
|
effect.dependencies[0] === 'synthetic',
|
|
)
|
|
.callback();
|
|
const choices = Array.from({ length: 100 }, (_, index) => ({
|
|
opportunity: {
|
|
key: `opportunity:${index + 1}`,
|
|
title: `Synthetic ${index + 1}`,
|
|
},
|
|
territory: null,
|
|
location_reason: 'Descrição '.repeat(80),
|
|
}));
|
|
flat(initial)
|
|
.find(
|
|
(node) =>
|
|
Array.isArray(node.props.choices) && node.props.onChange,
|
|
)
|
|
.props.onChange(choices);
|
|
harness.render();
|
|
flush();
|
|
assert.equal(harness.pending.length, 1);
|
|
assert.match(harness.pending[0].url, /\/rascunho$/);
|
|
assert.equal(harness.pending[0].body.opportunity_choices.length, 100);
|
|
assert.equal(
|
|
harness.pending[0].body.opportunity_choices[99].location_reason,
|
|
choices[99].location_reason,
|
|
);
|
|
assert.ok(JSON.stringify(harness.pending[0].body).length > 60000);
|
|
} finally {
|
|
cleanup?.();
|
|
globalThis.window = previousWindow;
|
|
}
|
|
});
|
|
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(
|
|
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',
|
|
preparationMocks,
|
|
'PreparationSummary',
|
|
);
|
|
const data = preparationFixture();
|
|
const before = JSON.stringify(data);
|
|
const render = (overrides = {}) =>
|
|
renderToStaticMarkup(
|
|
React.createElement(Summary, {
|
|
data: { ...data, ...overrides },
|
|
caseId: 'synthetic',
|
|
}),
|
|
);
|
|
const visible = render().split('<details>')[0];
|
|
assert.match(visible, /Documentos por associar: 1/);
|
|
assert.match(visible, /Documentos associados: 1 — conteúdo por conferir/);
|
|
assert.match(visible, /Exigências documentais por determinar: 1/);
|
|
assert.match(visible, /#preparation-documents/);
|
|
assert.match(visible, /Associar documentos do dossier/);
|
|
for (const overrides of [
|
|
{ missing_fields: ['currency'] },
|
|
{ scope_covered: false },
|
|
{ period_covered: false },
|
|
{ enabled: false },
|
|
{
|
|
requirements: data.requirements.filter(
|
|
(r) => r.state !== 'missing',
|
|
),
|
|
},
|
|
]) {
|
|
assert.match(
|
|
render(overrides).split('<details>')[0],
|
|
/#preparation-questions/,
|
|
);
|
|
}
|
|
assert.equal(JSON.stringify(data), before);
|
|
});
|
|
void test('associated document controls remain editable in details and deliberate detachment survives rerender', () => {
|
|
const Editor = load(
|
|
'../../resources/js/components/private-investment-preparation.tsx',
|
|
preparationMocks,
|
|
'PreparationEditor',
|
|
);
|
|
const data = preparationFixture();
|
|
const links = {};
|
|
const props = {
|
|
data,
|
|
fields: { investment_preparation: 'yes' },
|
|
changes: {},
|
|
documents: [
|
|
{
|
|
document_id: 'saved-document',
|
|
file_name: 'ORIGINAL.pdf',
|
|
status: 'active',
|
|
},
|
|
],
|
|
links,
|
|
onChange() {},
|
|
onLink(key, value) {
|
|
links[key] = value;
|
|
},
|
|
disabled: false,
|
|
};
|
|
const tree = Editor(props);
|
|
const details = flat(tree).find(
|
|
(node) =>
|
|
node.type === 'details' &&
|
|
flat(node).some(
|
|
(child) =>
|
|
child.type === 'summary' &&
|
|
child.props.children ===
|
|
'Documentos já associados — consultar ou alterar',
|
|
),
|
|
);
|
|
assert.ok(details);
|
|
assert.ok(!details.props.open);
|
|
const existingSelect = flat(details).find((node) => node.type === 'select');
|
|
assert.equal(existingSelect.props.value, 'saved-document');
|
|
assert.equal(flat(tree).filter((node) => node.type === 'select').length, 2);
|
|
existingSelect.props.onChange({ target: { value: '' } });
|
|
assert.deepEqual(links, { ASSOCIATED: null });
|
|
const rerender = Editor(props);
|
|
assert.equal(
|
|
flat(rerender)
|
|
.filter((node) => node.type === 'select')
|
|
.every((node) => node.props.value === ''),
|
|
true,
|
|
);
|
|
assert.equal(data.requirements[1].document_id, 'saved-document');
|
|
const disabled = Editor({ ...props, disabled: true });
|
|
assert.equal(
|
|
flat(disabled)
|
|
.filter((node) => node.type === 'select')
|
|
.every((node) => node.props.disabled),
|
|
true,
|
|
);
|
|
});
|