351 lines
14 KiB
JavaScript
351 lines
14 KiB
JavaScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import JSZip from 'jszip';
|
|
import { serializeFinancialToolsSnapshot } from '../../resources/js/lib/financial-calculators.ts';
|
|
import {
|
|
createProjectDossierPrintHtml,
|
|
createProjectDossierReport,
|
|
dossierReportSections,
|
|
generateProjectDossierDocx,
|
|
generateProjectDossierPackage,
|
|
classifyDossierAttachment,
|
|
validateDossierAttachments,
|
|
} from '../../resources/js/lib/project-dossier-report.ts';
|
|
|
|
test('report separates inapplicable documents from missing and unknown requirements without changing the saved snapshot', async () => {
|
|
const requirements = [
|
|
{ label: 'Título DUAT', state: 'not_applicable', reason: 'Arrendamento declarado' },
|
|
{ label: 'Identificação', state: 'missing', reason: 'Pedido em preparação' },
|
|
{ label: 'Capital externo', state: 'indeterminate', reason: 'Origem por determinar' },
|
|
];
|
|
const snapshot = JSON.stringify({ version: 1, context_revision: 2, preparation: {
|
|
enabled: true, mode: 'registration', version: 'QA', next: 'Completar identificação',
|
|
calculation_notice: 'Cálculo separado', notice: 'Preparação', requirements,
|
|
} });
|
|
const draft = { context_applicability_snapshot: snapshot };
|
|
const report = createProjectDossierReport(draft, { selectedSections: new Set(['sources']) });
|
|
const section = report.sections.find((section) => section.id === 'sources');
|
|
assert.ok(section);
|
|
assert.equal(section.rows.some((row) => row.field === 'Título DUAT'), false);
|
|
assert.match(section.paragraphs.join(' '), /Não exigido neste contexto: Título DUAT \(Arrendamento declarado\)/);
|
|
assert.equal(section.rows.find((row) => row.field === 'Identificação').value, 'Documento por associar');
|
|
assert.equal(section.rows.find((row) => row.field === 'Capital externo').value, 'Informação em falta para decidir');
|
|
const print = await createProjectDossierPrintHtml(report, [], false);
|
|
assert.match(print, /Arrendamento declarado/);
|
|
assert.equal(draft.context_applicability_snapshot, snapshot);
|
|
});
|
|
|
|
test('executive summary exposes the same financial result and period without replacing missing data by zero', () => {
|
|
const draft = { currency:'MZN', financial_tools_snapshot:serializeFinancialToolsSnapshot({budgetPeriod:'Mensal',loan:{principal:'1200',rate:'0',months:'12',rateType:'nominal',periodMonths:'1',gracePeriods:'0',upfrontFee:'0',recurringFee:'0'},breakEven:{fixed:'100.01',price:'30',variable:'20'},funding:{total:'1000',secured:'800'},incomes:[{label:'Receita QA',amount:'1000'}],expenses:[{label:'Despesa QA',amount:'1100'}]}) };
|
|
const report = createProjectDossierReport(draft,{selectedSections:new Set(['summary','finance'])});
|
|
const summary=report.sections.find(s=>s.id==='summary');
|
|
const detail=report.sections.find(s=>s.id==='finance');
|
|
assert.equal(summary.rows.find(r=>r.field.startsWith('Saldo operacional')).value,detail.rows.find(r=>r.field==='Saldo operacional estimado').value);
|
|
assert.match(summary.rows.find(r=>r.field.startsWith('Saldo operacional')).field,/Mensal/);
|
|
const missing=createProjectDossierReport({}, {selectedSections:new Set(['summary'])});
|
|
assert.equal(missing.sections[0].rows.some(r=>r.field.startsWith('Saldo operacional')),false);
|
|
});
|
|
import {
|
|
serializeBusinessPlanSnapshot,
|
|
forecastBusinessPlan,
|
|
} from '../../resources/js/lib/business-financial-plan.ts';
|
|
|
|
const allSections = new Set(dossierReportSections.map((section) => section.id));
|
|
|
|
function simplePlan() {
|
|
return {
|
|
currency: 'MZN',
|
|
openingCash: '0',
|
|
products: [
|
|
{
|
|
label: 'Pão',
|
|
monthlyUnits: '100',
|
|
unitPrice: '50',
|
|
unitVariableCost: '20',
|
|
},
|
|
],
|
|
monthlyCollections: '100',
|
|
monthlyVariableCostPayments: '100',
|
|
monthlyOperatingExpenses: '500',
|
|
monthlyOperatingPayments: '500',
|
|
monthlyPayrollExpense: '0',
|
|
monthlyPayrollPayments: '0',
|
|
assetInvestment: '0',
|
|
inventory: '0',
|
|
receivables: '0',
|
|
payables: '0',
|
|
equity: '10000',
|
|
shareholderLoans: '0',
|
|
bankLoanProceeds: '0',
|
|
grants: '0',
|
|
assetDepreciation: '0',
|
|
depreciationLifeMonths: 12,
|
|
monthlyInterestExpense: '0',
|
|
monthlyInterestPayment: '0',
|
|
monthlyPrincipalRepayment: '0',
|
|
periods: 12,
|
|
};
|
|
}
|
|
|
|
void test('incomplete dossier identifies unknowns without inventing zeroes', () => {
|
|
const report = createProjectDossierReport(
|
|
{},
|
|
{
|
|
selectedSections: allSections,
|
|
generatedAt: '2026-10-04T10:00:00.000Z',
|
|
},
|
|
);
|
|
assert.equal(report.title, 'Não informado');
|
|
assert.equal(
|
|
report.sections.find((section) => section.id === 'summary').rows[0]
|
|
.value,
|
|
'Não informado',
|
|
);
|
|
assert.match(
|
|
report.sections.find((section) => section.id === 'resources').rows[0]
|
|
.value,
|
|
/Informação insuficiente/,
|
|
);
|
|
assert.doesNotMatch(JSON.stringify(report), /"value":"0(\.00)?"/);
|
|
});
|
|
|
|
void test('report sections are selected explicitly and preserve declared versus estimated provenance', () => {
|
|
const selected = new Set(['summary', 'market', 'sources']);
|
|
const report = createProjectDossierReport(
|
|
{
|
|
project_title: 'Moagem',
|
|
market_assumption: 'procura crescente',
|
|
market_source: 'entrevista própria; 2026-09-01',
|
|
},
|
|
{ selectedSections: selected, version: 'Revisão 3' },
|
|
);
|
|
assert.deepEqual(
|
|
report.sections.map((section) => section.id),
|
|
['summary', 'market', 'sources'],
|
|
);
|
|
const market = report.sections.find((section) => section.id === 'market');
|
|
assert.ok(
|
|
market.rows.some(
|
|
(row) =>
|
|
row.value === 'procura crescente' &&
|
|
row.classification === 'Informação declarada, não verificada',
|
|
),
|
|
);
|
|
assert.equal(report.version, 'Revisão 3');
|
|
});
|
|
|
|
void test('business plan values in the dossier equal the existing simulator forecast', () => {
|
|
const plan = simplePlan();
|
|
const expected = forecastBusinessPlan(plan);
|
|
const snapshot = serializeBusinessPlanSnapshot(plan);
|
|
const report = createProjectDossierReport(
|
|
{
|
|
project_title: 'Padaria',
|
|
currency: 'MZN',
|
|
business_plan_snapshot: snapshot,
|
|
},
|
|
{ selectedSections: allSections },
|
|
);
|
|
const finance = report.sections.find((section) => section.id === 'finance');
|
|
const row = (field) =>
|
|
finance.rows.find((item) => item.field === field).value;
|
|
const numeric = (value) => {
|
|
assert.match(value, /MZN$/);
|
|
return value.replace(/\s|MZN/g, '').replace(',', '.');
|
|
};
|
|
assert.equal(
|
|
numeric(row('Facturação no horizonte')),
|
|
expected.totalRevenue,
|
|
);
|
|
assert.equal(
|
|
numeric(row('Resultado antes de impostos')),
|
|
expected.totalProfitBeforeTax,
|
|
);
|
|
assert.equal(numeric(row('Caixa final projectada')), expected.endingCash);
|
|
assert.equal(expected.fundingStatus, 'surplus');
|
|
assert.equal(row('Reconciliação do financiamento'), 'Fontes superiores aos usos previstos');
|
|
});
|
|
|
|
void test('an attached simulator execution keeps its exact snapshot result, explanation and version', () => {
|
|
const report = createProjectDossierReport(
|
|
{ project_title: 'Pequena indústria' },
|
|
{
|
|
selectedSections: allSections,
|
|
currentCalculation: {
|
|
status: 'completed',
|
|
version: 4,
|
|
updatedAt: '2026-10-03T12:00:00Z',
|
|
reportChecksum: 'a'.repeat(64),
|
|
snapshot: {
|
|
contract_version: 1,
|
|
indicative_total: '1250.75',
|
|
formula_version: 'v2',
|
|
base: '2000',
|
|
rate_percent: '75',
|
|
reduction_percent: '16.6',
|
|
currency: 'MZN',
|
|
context: {
|
|
reference_date: '2026-10-03',
|
|
engine_version: 'engine-4',
|
|
scenario_version: 4,
|
|
},
|
|
result: {
|
|
status: 'calculated',
|
|
value: '1250.75',
|
|
unit: null,
|
|
explanation: 'Cálculo indicativo guardado.',
|
|
missing_inputs: [],
|
|
source_ids: [],
|
|
},
|
|
},
|
|
},
|
|
},
|
|
);
|
|
const finance = report.sections.find((section) => section.id === 'finance');
|
|
assert.equal(
|
|
finance.rows.find((row) => row.field === 'Resultado indicativo').value,
|
|
'1250.75',
|
|
);
|
|
assert.equal(
|
|
finance.rows.find((row) => row.field === 'Explicação e dados em falta')
|
|
.value,
|
|
'Cálculo indicativo guardado.',
|
|
);
|
|
assert.equal(
|
|
finance.rows.find((row) => row.field === 'Revisão do resultado').value,
|
|
'4',
|
|
);
|
|
assert.equal(
|
|
finance.rows.find(
|
|
(row) => row.field === 'Hash SHA-256 do relatório associado',
|
|
).value,
|
|
'a'.repeat(64),
|
|
);
|
|
});
|
|
|
|
void test('DOCX is a readable OpenXML package with Portuguese accents, tables and page fields', async () => {
|
|
const report = createProjectDossierReport(
|
|
{
|
|
project_title: 'Produção de café',
|
|
project_summary: 'Ideia com acção e localização.',
|
|
},
|
|
{ selectedSections: allSections, version: 'Revisão 2' },
|
|
);
|
|
const blob = await generateProjectDossierDocx(report, [], true);
|
|
const bytes = new Uint8Array(await blob.arrayBuffer());
|
|
assert.deepEqual(Array.from(bytes.subarray(0, 2)), [0x50, 0x4b]);
|
|
const zip = await JSZip.loadAsync(bytes);
|
|
const documentXml = await zip.file('word/document.xml').async('string');
|
|
const footerXml = await zip.file('word/footer1.xml').async('string');
|
|
assert.match(documentXml, /Produção de café/);
|
|
assert.match(documentXml, /Classificação/);
|
|
assert.match(documentXml, /projecto/);
|
|
assert.match(footerXml, /PAGE/);
|
|
assert.ok(zip.file('[Content_Types].xml'));
|
|
});
|
|
|
|
void test('CSV and plain-text annexes are embedded, while other originals get a ZIP index', async () => {
|
|
const csv = new File(['Produto;Quantidade\r\nCafé;12'], 'tabela.csv', {
|
|
type: 'text/csv',
|
|
});
|
|
const pdf = new File(['%PDF-1.7 example'], '../apoio.pdf', {
|
|
type: 'application/pdf',
|
|
});
|
|
assert.equal(classifyDossierAttachment(csv), 'csv');
|
|
assert.equal(classifyDossierAttachment(pdf), 'original-only');
|
|
const attachments = [
|
|
{ file: csv, include: true, caption: '' },
|
|
{ file: pdf, include: true, caption: '' },
|
|
];
|
|
const report = createProjectDossierReport(
|
|
{ project_title: 'Projecto de café' },
|
|
{ selectedSections: allSections },
|
|
);
|
|
const docx = await generateProjectDossierDocx(report, attachments, true);
|
|
const docxZip = await JSZip.loadAsync(await docx.arrayBuffer());
|
|
const xml = await docxZip.file('word/document.xml').async('string');
|
|
assert.match(xml, /Café/);
|
|
assert.match(xml, /Este formato não foi incorporado/);
|
|
const bundle = await generateProjectDossierPackage(docx, attachments, true);
|
|
const bundleZip = await JSZip.loadAsync(await bundle.arrayBuffer());
|
|
assert.ok(bundleZip.file('Anexos/tabela.csv'));
|
|
assert.ok(bundleZip.file('Anexos/apoio.pdf'));
|
|
const index = await bundleZip.file('indice-anexos.txt').async('string');
|
|
assert.match(index, /Conteúdo não incorporado/);
|
|
assert.match(index, /Conteúdo não incorporado; original preservado/);
|
|
});
|
|
|
|
void test('PNG photographs are embedded in the Word package with an image relationship and caption', async () => {
|
|
const pngBytes = Buffer.from(
|
|
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/aZkAAAAASUVORK5CYII=',
|
|
'base64',
|
|
);
|
|
const photo = new File([pngBytes], 'local.jpg', { type: 'image/png' });
|
|
const report = createProjectDossierReport(
|
|
{ project_title: 'Projecto com fotografia' },
|
|
{ selectedSections: allSections },
|
|
);
|
|
const docx = await generateProjectDossierDocx(
|
|
report,
|
|
[{ file: photo, include: true, caption: 'Localização declarada' }],
|
|
true,
|
|
);
|
|
const zip = await JSZip.loadAsync(await docx.arrayBuffer());
|
|
const mediaPath = Object.keys(zip.files).find((path) =>
|
|
/^word\/media\/.+\.png$/.test(path),
|
|
);
|
|
assert.ok(mediaPath);
|
|
assert.match(
|
|
await zip.file('word/document.xml').async('string'),
|
|
/Localização declarada/,
|
|
);
|
|
assert.match(
|
|
await zip.file('word/_rels/document.xml.rels').async('string'),
|
|
new RegExp(`Target="media/${mediaPath.split('/').at(-1)}"`),
|
|
);
|
|
});
|
|
|
|
void test('print/PDF HTML escapes user content and explains omitted annexes', async () => {
|
|
const report = createProjectDossierReport(
|
|
{
|
|
project_title: '<script>alert(1)</script>',
|
|
project_summary: 'Acentuação: localização.',
|
|
},
|
|
{ selectedSections: new Set(['summary', 'annexes']) },
|
|
);
|
|
const pdf = await createProjectDossierPrintHtml(report, [], false);
|
|
assert.doesNotMatch(pdf, /<script>alert\(1\)<\/script>/);
|
|
assert.match(pdf, /<script>/);
|
|
assert.match(pdf, /não foram seleccionados|secção de anexos foi omitida/);
|
|
assert.match(pdf, /@page\{size:A4/);
|
|
assert.match(pdf, /@bottom-left\{content:/);
|
|
assert.match(pdf, /@bottom-right\{content:counter\(page\)/);
|
|
assert.doesNotMatch(pdf, /position:fixed|bottom:-14mm/);
|
|
const escapedVersion = await createProjectDossierPrintHtml(
|
|
{ ...report, version: 'Revisão "QA" </style><script>alert(2)</script>' },
|
|
[],
|
|
false,
|
|
);
|
|
assert.doesNotMatch(escapedVersion, /<script>alert\(2\)<\/script>/);
|
|
});
|
|
|
|
void test('attachment limits block oversized or excessive selections', () => {
|
|
assert.equal(validateDossierAttachments([{ size: 1 }]), null);
|
|
assert.match(
|
|
validateDossierAttachments(
|
|
Array.from({ length: 11 }, () => ({ size: 1 })),
|
|
),
|
|
/máximo 10/,
|
|
);
|
|
assert.match(
|
|
validateDossierAttachments([{ size: 15 * 1024 * 1024 + 1 }]),
|
|
/15 MB/,
|
|
);
|
|
assert.match(
|
|
validateDossierAttachments(
|
|
Array.from({ length: 4 }, () => ({ size: 15 * 1024 * 1024 })),
|
|
),
|
|
/50 MB/,
|
|
);
|
|
});
|