589 lines
22 KiB
JavaScript
589 lines
22 KiB
JavaScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import fs from 'node:fs';
|
|
import path from 'node:path';
|
|
import { createRequire } from 'node:module';
|
|
import { compileFunction } from 'node:vm';
|
|
import React from 'react';
|
|
import { renderToStaticMarkup } from 'react-dom/server';
|
|
import ts from 'typescript';
|
|
|
|
const require = createRequire(import.meta.url);
|
|
const root = path.resolve('resources/js');
|
|
function load(file, mocks = {}) {
|
|
const absolute = path.resolve(root, file);
|
|
const module = { exports: {} };
|
|
const code = ts.transpileModule(fs.readFileSync(absolute, 'utf8'), {
|
|
compilerOptions: {
|
|
module: ts.ModuleKind.CommonJS,
|
|
target: ts.ScriptTarget.ES2022,
|
|
jsx: ts.JsxEmit.ReactJSX,
|
|
},
|
|
}).outputText;
|
|
compileFunction(code, ['require', 'module', 'exports'])(
|
|
(id) => {
|
|
if (id in mocks) return mocks[id];
|
|
if (id === '@inertiajs/react')
|
|
return {
|
|
Link: ({ children, ...props }) =>
|
|
React.createElement('a', props, children),
|
|
usePage: () => ({ url: '/sectores/test' }),
|
|
};
|
|
if (id.startsWith('.') || id.startsWith('@/')) {
|
|
const base = id.startsWith('@/')
|
|
? path.resolve(root, id.slice(2))
|
|
: path.resolve(path.dirname(absolute), id);
|
|
const found = [base, base + '.tsx', base + '.ts'].find(
|
|
(p) => fs.existsSync(p) && fs.statSync(p).isFile(),
|
|
);
|
|
if (found) return load(path.relative(root, found), mocks);
|
|
}
|
|
return require(id);
|
|
},
|
|
module,
|
|
module.exports,
|
|
);
|
|
return module.exports;
|
|
}
|
|
const render = (file, props) =>
|
|
renderToStaticMarkup(React.createElement(load(file).default, props));
|
|
|
|
test('demographic comparison retains four saved places and requires a province before searching districts', () => {
|
|
const Page = load('pages/investment/demography.tsx', {
|
|
'./shared': {
|
|
Frame: ({ children }) =>
|
|
React.createElement('main', null, children),
|
|
button: '',
|
|
},
|
|
'@/components/demographic-evidence': { default: () => null },
|
|
}).default;
|
|
const territories = [
|
|
{
|
|
id: 'p1',
|
|
name: 'Província A',
|
|
location_type: 'province',
|
|
parent_id: null,
|
|
},
|
|
{
|
|
id: 'p2',
|
|
name: 'Província B',
|
|
location_type: 'province',
|
|
parent_id: null,
|
|
},
|
|
{
|
|
id: 'd1',
|
|
name: 'Distrito A',
|
|
location_type: 'district',
|
|
parent_id: 'p1',
|
|
},
|
|
{
|
|
id: 'd2',
|
|
name: 'Distrito B',
|
|
location_type: 'district',
|
|
parent_id: 'p2',
|
|
},
|
|
];
|
|
const html = renderToStaticMarkup(
|
|
React.createElement(Page, {
|
|
evidence: { territories: [] },
|
|
filters: {
|
|
territories: territories.map((t) => t.id),
|
|
start_year: 2026,
|
|
end_year: 2027,
|
|
},
|
|
territories,
|
|
sectors: { general: 'Geral' },
|
|
}),
|
|
);
|
|
assert.match(html, /aria-label="Pesquisar Província da comparação"/);
|
|
assert.match(
|
|
html,
|
|
/aria-label="Pesquisar Distrito da comparação \(opcional\)"[^>]*disabled/,
|
|
);
|
|
assert.doesNotMatch(html, /<select[^>]*multiple/);
|
|
for (const t of territories)
|
|
assert.ok(html.includes(`Retirar ${t.name} —`));
|
|
assert.match(html, /disabled=""[^>]*>Acrescentar à comparação/);
|
|
assert.match(html, /value="2026"/);
|
|
assert.match(html, /value="2027"/);
|
|
});
|
|
|
|
test('the real preparation editor opens an own idea with its budget and no mandatory advert or duplicate reference selector', () => {
|
|
const Empty = () => null;
|
|
const Editor = load('pages/investor-path/context.tsx', {
|
|
'@/pages/investment/shared': {
|
|
Frame: ({ children }) =>
|
|
React.createElement('main', null, children),
|
|
card: '',
|
|
button: '',
|
|
input: '',
|
|
},
|
|
'@/components/navigation-context': { useNavigationContext: () => ({}) },
|
|
'@/components/investment-territory': { TerritoryFields: Empty },
|
|
'@/components/private-investment-preparation': {
|
|
PreparationEditor: Empty,
|
|
PreparationImpact: Empty,
|
|
},
|
|
'@/components/investment-complements': {
|
|
default: Empty,
|
|
SelectedComplements: Empty,
|
|
complementInput: (value) => value,
|
|
},
|
|
'@/components/context-documents': { default: Empty },
|
|
'@/components/investment-opportunity-choices': { default: Empty },
|
|
'@/components/investment-requirements': { default: Empty },
|
|
'@/lib/operation-key': { createOperationKey: () => 'synthetic-key' },
|
|
}).default;
|
|
const html = renderToStaticMarkup(
|
|
React.createElement(Editor, {
|
|
initial: {
|
|
case: {
|
|
id: 'synthetic-hotel',
|
|
title: 'Criar Hotel',
|
|
version: 1,
|
|
},
|
|
context: {
|
|
revision: 0,
|
|
fields: { project_title: 'Criar Hotel' },
|
|
selection: {},
|
|
provenance: {},
|
|
opportunity_choices: [],
|
|
},
|
|
private_investment: null,
|
|
can_write: true,
|
|
results: [],
|
|
requests: [],
|
|
preparation_documents: [],
|
|
},
|
|
source: null,
|
|
options: {
|
|
countries: [
|
|
{ code: 'MZ', name: 'Moçambique', name_en: 'Mozambique' },
|
|
],
|
|
sectors: [{ id: 7, name: 'Turismo QA' }],
|
|
parks: [],
|
|
search_limited: false,
|
|
opportunities: [
|
|
{
|
|
key: 'opportunity:2',
|
|
title: 'Açúcar sem pertinência',
|
|
kind: 'opportunity',
|
|
},
|
|
],
|
|
},
|
|
entryIdea: {
|
|
sector_id: '7',
|
|
currency: 'MZN',
|
|
investment_budget_min: '1000000',
|
|
investment_budget_max: '5000000',
|
|
},
|
|
}),
|
|
);
|
|
assert.match(html, /Criar Hotel/);
|
|
assert.match(html, /value="1000000"/);
|
|
assert.match(html, /value="5000000"/);
|
|
assert.match(html, /value="sector:7" selected=""/);
|
|
assert.match(html, /Oportunidades relacionadas \(opcional\)/);
|
|
assert.doesNotMatch(html, /Referência individual|Açúcar sem pertinência/);
|
|
if (process.env.INVESTMOZ_INTENT_PREVIEW === '1') {
|
|
const manifest = JSON.parse(
|
|
fs.readFileSync(
|
|
'public/build-qa-opportunities/manifest.json',
|
|
'utf8',
|
|
),
|
|
);
|
|
const css = fs.readFileSync(
|
|
'public/build-qa-opportunities/' +
|
|
manifest['resources/css/app.css'].file,
|
|
'utf8',
|
|
);
|
|
fs.writeFileSync(
|
|
'outputs/investor-intent-20261115/editor-preview.html',
|
|
`<!doctype html><html lang="pt"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>InvesteMoz — prévia sintética de preparação</title><style>${css}</style><body><div class="mx-auto max-w-4xl p-4"><p class="rounded border p-3">QA sintético: editor renderizado, sem autenticação, gravação ou consultas. A cartografia foi substituída no ensaio. Esta prévia não demonstra o percurso autenticado.</p>${html}</div></body></html>`,
|
|
'utf8',
|
|
);
|
|
}
|
|
});
|
|
|
|
test('changing the consulted territory remounts its evidence and aborts a late response', async () => {
|
|
const state = [],
|
|
refs = [];
|
|
let cursor = 0,
|
|
refCursor = 0,
|
|
cleanup;
|
|
const hooks = {
|
|
useState(initial) {
|
|
const index = cursor++;
|
|
if (!(index in state)) state[index] = initial;
|
|
return [
|
|
state[index],
|
|
(next) => {
|
|
state[index] = next;
|
|
},
|
|
];
|
|
},
|
|
useRef(initial) {
|
|
const index = refCursor++;
|
|
refs[index] ??= { current: initial };
|
|
return refs[index];
|
|
},
|
|
useEffect(callback) {
|
|
cleanup = callback();
|
|
},
|
|
};
|
|
const Component = load('components/demographic-context.tsx', {
|
|
react: hooks,
|
|
}).default;
|
|
const a = Component({ territoryId: 'synthetic-A' });
|
|
const b = Component({ territoryId: 'synthetic-B' });
|
|
assert.notEqual(a.key, b.key);
|
|
assert.equal(Component({ territoryId: null }), null);
|
|
const tree = a.type(a.props);
|
|
const nodes = (n) =>
|
|
!n || typeof n !== 'object'
|
|
? []
|
|
: Array.isArray(n)
|
|
? n.flatMap(nodes)
|
|
: [n, ...nodes(n.props?.children)];
|
|
const button = nodes(tree).find((n) => n.type === 'button');
|
|
const originalFetch = globalThis.fetch;
|
|
let resolve, signal;
|
|
globalThis.fetch = (url, options) => {
|
|
assert.match(url, /synthetic-A/);
|
|
signal = options.signal;
|
|
return new Promise((done) => {
|
|
resolve = done;
|
|
});
|
|
};
|
|
try {
|
|
const pending = button.props.onClick();
|
|
cleanup();
|
|
assert.equal(signal.aborted, true);
|
|
resolve({ ok: true, json: async () => ({ territories: ['old A'] }) });
|
|
await pending;
|
|
assert.equal(
|
|
state[0],
|
|
null,
|
|
'late evidence must not be attached to another territory',
|
|
);
|
|
} finally {
|
|
globalThis.fetch = originalFetch;
|
|
}
|
|
});
|
|
|
|
test('shared evidence is closed by default, preserves content and wraps long references', () => {
|
|
const html = render('components/evidence-details.tsx', {
|
|
children: 'source-version-hash',
|
|
title: 'Fonte',
|
|
});
|
|
assert.match(html, /<details[^>]*>/);
|
|
assert.doesNotMatch(html, /<details[^>]*\bopen/);
|
|
assert.match(html, /source-version-hash/);
|
|
assert.match(html, /overflow-wrap:anywhere/);
|
|
});
|
|
test('public document keeps applicability, usage limitations and actions visible, with source metadata in detail', () => {
|
|
const { PublicDocumentContent } = load(
|
|
'components/public-document-content.tsx',
|
|
);
|
|
const resource = {
|
|
id: 1,
|
|
title: 'Diploma QA',
|
|
description: 'Objecto documentado',
|
|
issuer: null,
|
|
document_number: null,
|
|
document_date: null,
|
|
applicable_from: '2025-01-01',
|
|
applicable_until: null,
|
|
source_url: null,
|
|
source_confirmation: 'unknown',
|
|
usage_notice: 'Confirmar o âmbito aplicável',
|
|
view_url: null,
|
|
download_url: '/documents/1',
|
|
context_actions: [],
|
|
};
|
|
const html = renderToStaticMarkup(
|
|
React.createElement(PublicDocumentContent, { resource }),
|
|
);
|
|
assert.match(html, /2025-01-01/);
|
|
assert.match(html, /Confirmar o âmbito aplicável/);
|
|
assert.match(html, /href="\/documents\/1"/);
|
|
assert.doesNotMatch(html, /Entidade emissora|Consultar ficha/);
|
|
assert.ok(html.indexOf('Período indicado') > html.indexOf('</details>'));
|
|
});
|
|
test('territorial references retain exact citation and provenance without a primary internal-process block', () => {
|
|
const source = {
|
|
association: 'Corredor QA',
|
|
role: 'Ligação',
|
|
source_reference: 'Diploma QA §2',
|
|
source_quote: 'Relação documentada',
|
|
supports: '',
|
|
traceability: 'original hash/version',
|
|
official_source_accepted: true,
|
|
existing_validation: 'validated',
|
|
};
|
|
const html = render('components/territorial-source-evidence.tsx', {
|
|
sources: [source],
|
|
});
|
|
assert.match(html, /Diploma QA §2/);
|
|
assert.match(html, /original hash\/version/);
|
|
assert.doesNotMatch(
|
|
html,
|
|
/não retira a aceitação existente|campo específico/,
|
|
);
|
|
assert.equal(
|
|
render('components/territorial-source-evidence.tsx', { sources: [] }),
|
|
'',
|
|
);
|
|
});
|
|
test('empty published catalogue does not claim absent companies, resources or economic potential', () => {
|
|
const html = render('components/published-opportunities-empty.tsx', {
|
|
href: '/oportunidades?sector=qa',
|
|
});
|
|
assert.match(html, /Sem anúncios publicados/);
|
|
assert.match(
|
|
html,
|
|
/não indica ausência de empresas, recursos ou potencial/,
|
|
);
|
|
assert.match(html, /sector=qa/);
|
|
});
|
|
const value = (
|
|
year,
|
|
age_group = 'all',
|
|
data_kind = 'projected',
|
|
unit = 'pessoas',
|
|
) => ({
|
|
year,
|
|
indicator: 'population',
|
|
age_group,
|
|
residence: 'all',
|
|
value: '1234',
|
|
original_value: '1 234',
|
|
unit,
|
|
data_kind,
|
|
sheet: 'sheet',
|
|
cell: 'B' + year,
|
|
validation_status: 'valid',
|
|
});
|
|
test('demographic highlights never sum dimensions, mix units or silently replace a source/version', () => {
|
|
const { demographicHighlights } = load(
|
|
'components/demographic-evidence.tsx',
|
|
);
|
|
const values = [
|
|
value(2024),
|
|
value(2025),
|
|
value(2026, '0-4'),
|
|
value(2024, 'all', 'observed'),
|
|
value(2023, 'all', 'projected', 'unspecified'),
|
|
];
|
|
assert.deepEqual(demographicHighlights(values), [
|
|
values[1],
|
|
values[3],
|
|
values[4],
|
|
]);
|
|
assert.deepEqual(demographicHighlights([value(2025, '0-4')]), []);
|
|
});
|
|
test('demography shows original latest total, period, projection and decision warning; technical cells remain closed', () => {
|
|
const evidence = {
|
|
state: 'available',
|
|
start_year: 2024,
|
|
end_year: 2025,
|
|
limitations: ['Não prova procura'],
|
|
comparisons: [],
|
|
differences: [],
|
|
territories: [
|
|
{
|
|
id: 'qa',
|
|
name: 'Território QA',
|
|
level: 'province',
|
|
factors_to_assess: [],
|
|
series: [
|
|
{
|
|
import_id: 'qa',
|
|
source: 'Fonte QA',
|
|
version: 'Edição QA',
|
|
sha256: '123abc',
|
|
file_name: 'QA.xlsx',
|
|
missing_years: [],
|
|
issue_counts: {},
|
|
values: [value(2024), value(2025)],
|
|
},
|
|
],
|
|
},
|
|
],
|
|
};
|
|
const html = render('components/demographic-evidence.tsx', { evidence });
|
|
assert.match(html, /1 234/);
|
|
assert.match(html, /não demonstram procura ou rentabilidade/);
|
|
assert.match(html, /Projecção/);
|
|
assert.match(html, /SHA256/);
|
|
assert.ok(html.indexOf('SHA256') > html.indexOf('<summary'));
|
|
assert.doesNotMatch(html, /<details[^>]*\bopen/);
|
|
});
|
|
test('cluster analysis omits empty field blocks but keeps documented evidence and uncertainty', () => {
|
|
const { forces } = load('lib/cluster-competitiveness.ts');
|
|
const rows = Object.fromEntries(
|
|
forces.map((f) => [
|
|
f,
|
|
{
|
|
pressure: '',
|
|
evidence: 'Facto QA',
|
|
source: '',
|
|
period: '',
|
|
rationale: '',
|
|
limitations: 'Por confirmar',
|
|
opportunity: '',
|
|
},
|
|
]),
|
|
);
|
|
const html = render('components/cluster-competitiveness-public.tsx', {
|
|
analysis: {
|
|
version: 1,
|
|
at: '2026-11-15',
|
|
draft: { sector: 'QA', region: 'Local QA', forces: rows },
|
|
},
|
|
});
|
|
assert.match(html, /Facto QA/);
|
|
assert.match(html, /Por confirmar/);
|
|
assert.doesNotMatch(html, /<dt[^>]*>Fonte e referência/);
|
|
});
|
|
test('simulator keeps original calculation correspondence and source behind explicit details', () => {
|
|
const { SimulationModeloA } = load('components/simulation-modelo-a.tsx');
|
|
const html = renderToStaticMarkup(
|
|
React.createElement(SimulationModeloA, { draft: {} }),
|
|
);
|
|
assert.match(html, /Modelo A/);
|
|
assert.match(html, /SHA256/);
|
|
assert.ok(
|
|
html.indexOf('SHA256') >
|
|
html.indexOf('Exemplar e integridade da fonte'),
|
|
);
|
|
assert.match(html, /não é imposto final/);
|
|
});
|
|
|
|
// Optional visual fixture: the real components with explicit synthetic data,
|
|
// not an authenticated application or an operational publication.
|
|
if (process.env.INVESTMOZ_RECONCILIATION_PREVIEW === '1') {
|
|
const population = (year, age, amount, residence = 'all') => ({
|
|
...value(year, age, 'projected', 'pessoas'),
|
|
residence,
|
|
value: String(amount),
|
|
original_value: String(amount),
|
|
cell: `${year}-${age}-${residence}`,
|
|
});
|
|
const values = [
|
|
population(2026, 'all', 1000),
|
|
population(2028, 'all', 1200),
|
|
population(2028, 'all', 480, 'urban'),
|
|
population(2028, 'all', 720, 'rural'),
|
|
];
|
|
for (const age of ['0-4', '5-9', '10-14'])
|
|
values.push(population(2028, age, 120));
|
|
for (const age of [
|
|
'15-19',
|
|
'20-24',
|
|
'25-29',
|
|
'30-34',
|
|
'35-39',
|
|
'40-44',
|
|
'45-49',
|
|
'50-54',
|
|
'55-59',
|
|
'60-64',
|
|
])
|
|
values.push(population(2028, age, 60));
|
|
const evidence = {
|
|
state: 'available',
|
|
start_year: 2026,
|
|
end_year: 2028,
|
|
sector: 'general',
|
|
limitations: [
|
|
'Exclusivamente dados sintéticos. Não representam população real.',
|
|
],
|
|
comparisons: [],
|
|
differences: [],
|
|
territories: [
|
|
{
|
|
id: 'synthetic-qa',
|
|
name: 'Território sintético QA',
|
|
level: 'district',
|
|
factors_to_assess: [],
|
|
series: [
|
|
{
|
|
import_id: 'qa',
|
|
source: 'Fonte sintética de ensaio',
|
|
version: 'QA 2026',
|
|
sha256: 'a'.repeat(64),
|
|
file_name: 'exemplo-sintetico.xlsx',
|
|
missing_years: [2027],
|
|
issue_counts: {},
|
|
values,
|
|
},
|
|
],
|
|
},
|
|
],
|
|
};
|
|
const demographicHtml = render('components/demographic-evidence.tsx', {
|
|
evidence,
|
|
});
|
|
const emptyHtml = render('components/published-opportunities-empty.tsx', {
|
|
href: '#demografia',
|
|
label: 'Estudar a localização',
|
|
});
|
|
const { PublicDocumentContent } = load(
|
|
'components/public-document-content.tsx',
|
|
);
|
|
const documentHtml = renderToStaticMarkup(
|
|
React.createElement(PublicDocumentContent, {
|
|
resource: {
|
|
id: 1,
|
|
title: 'Guia sintético QA',
|
|
description:
|
|
'Documento de ensaio para conferir a apresentação.',
|
|
issuer: 'Entidade sintética',
|
|
document_number: 'QA-1',
|
|
document_date: '2026-11-15',
|
|
applicable_from: '2026-11-15',
|
|
applicable_until: null,
|
|
source_url: null,
|
|
source_confirmation: 'unknown',
|
|
usage_notice:
|
|
'Exemplo de limitação relevante, conservada junto do documento.',
|
|
view_url: null,
|
|
download_url: '#documento',
|
|
context_actions: [],
|
|
},
|
|
}),
|
|
);
|
|
const sourceHtml = render('components/territorial-source-evidence.tsx', {
|
|
sources: [
|
|
{
|
|
association: 'Relação territorial sintética',
|
|
role: 'Contexto',
|
|
source_reference: 'Fonte sintética §1',
|
|
source_quote: 'Relação apenas para ensaio.',
|
|
supports: '',
|
|
traceability: 'Versão QA e original conservados',
|
|
official_source_accepted: false,
|
|
existing_validation: null,
|
|
},
|
|
],
|
|
});
|
|
const { SimulationModeloA } = load('components/simulation-modelo-a.tsx');
|
|
const simulatorHtml = renderToStaticMarkup(
|
|
React.createElement(SimulationModeloA, { draft: {} }),
|
|
);
|
|
const manifest = JSON.parse(
|
|
fs.readFileSync('public/build-qa-opportunities/manifest.json', 'utf8'),
|
|
);
|
|
const css = fs.readFileSync(
|
|
'public/build-qa-opportunities/' +
|
|
manifest['resources/css/app.css'].file,
|
|
'utf8',
|
|
);
|
|
fs.writeFileSync(
|
|
'outputs/demography-reconciliation-20261115/visual-components.html',
|
|
`<!doctype html><html lang="pt"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>InvesteMoz QA — conferência de componentes</title><style>${css}</style><body class="bg-slate-50 text-slate-900"><main class="mx-auto max-w-5xl space-y-8 p-4"><header><h1 class="text-2xl font-bold">InvesteMoz — prévia QA</h1><p>Componentes reais com dados sintéticos. Não é uma sessão autenticada nem publicação no portal.</p><nav class="flex flex-wrap gap-4 py-4"><a href="#demografia">Demografia</a><a href="#catalogo">Catálogo</a><a href="#territorio">Território</a><a href="#documento">Biblioteca</a><a href="#simulador">Simulador</a></nav></header><section id="demografia">${demographicHtml}</section><section id="catalogo"><h2 class="text-xl font-semibold">Catálogo — sem anúncios publicados</h2>${emptyHtml}</section><section id="territorio"><h2 class="text-xl font-semibold">Contexto territorial</h2>${sourceHtml}</section><section id="documento">${documentHtml}</section><section id="simulador">${simulatorHtml}</section></main></body></html>`,
|
|
'utf8',
|
|
);
|
|
}
|