Files
Investidor-V2/tests/frontend/investor-usability-components.test.mjs
T

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,
);
});