Files
Investidor-V2/tests/frontend/investment-preparation.test.mjs
T

355 lines
13 KiB
JavaScript

import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import { compileFunction } from 'node:vm';
import { createRequire } from 'node:module';
import React from 'react';
import ts from 'typescript';
import {
PreparationAttempt,
swotDimensions,
} from '../../resources/js/lib/investment-preparation.ts';
import { createOperationKey } from '../../resources/js/lib/operation-key.ts';
const require = createRequire(import.meta.url),
flush = () => new Promise((resolve) => setImmediate(resolve));
const fixture = () => ({
case: { id: 'qa-case', title: 'SYNTHETIC preparation', closed: false },
opportunity: null,
version: 0,
apiex: {
note: 'Saved APIEX note',
additional_documents: [],
review: null,
exports: [],
external: [],
},
swot: {
object_key: 'investment:qa-case',
scope: 'Saved SWOT scope',
rows: [],
source_fingerprint: 'a'.repeat(64),
review: null,
actions: [],
},
api_state: 'preparing',
api_issues: [],
api_fingerprint: 'b'.repeat(64),
swot_fingerprint: 'a'.repeat(64),
swot_review_required: true,
can_write: true,
can_review: true,
links: {
dossier: '/investimentos/qa-case',
context: '/investimentos/qa-case/contexto/editar',
documents: '/balcao-virtual/dossiers/qa-case',
editorial: '/acesso-gestao',
},
objects: [
{
key: 'investment:qa-case',
label: 'SYNTHETIC preparation',
kind: 'investment',
},
],
sources: [],
context: { revision: 1, fields: { project_title: 'SYNTHETIC' } },
assessment: { requirements: [] },
documents: [],
history: [],
});
const profileModule = { exports: {} };
compileFunction(
ts.transpileModule(
fs.readFileSync(
new URL('../../resources/js/lib/profile-json.ts', import.meta.url),
'utf8',
),
{
compilerOptions: {
module: ts.ModuleKind.CommonJS,
target: ts.ScriptTarget.ES2022,
},
},
).outputText,
['require', 'module', 'exports'],
)(require, profileModule, profileModule.exports);
const { ProfileValidationError } = profileModule.exports;
const source = fs.readFileSync(
new URL(
'../../resources/js/pages/investor-path/preparation.tsx',
import.meta.url,
),
'utf8',
),
compiled = ts.transpileModule(source, {
compilerOptions: {
module: ts.ModuleKind.CommonJS,
target: ts.ScriptTarget.ES2022,
jsx: ts.JsxEmit.ReactJSX,
},
}).outputText;
const text = (n) =>
typeof n === 'string' || typeof n === 'number'
? String(n)
: React.isValidElement(n)
? React.Children.toArray(n.props.children).map(text).join('')
: '';
const walk = (node, parents = []) =>
React.isValidElement(node)
? [
{ node, parents },
...React.Children.toArray(node.props.children).flatMap((n) =>
walk(n, [...parents, node]),
),
]
: [];
function harness(initial = fixture()) {
let cursor = 0;
const slots = [],
calls = [],
mod = { exports: {} };
const hookReact = {
...React,
useEffect: () => {},
useState: (initial) => {
const i = cursor++;
if (!(i in slots))
slots[i] = typeof initial === 'function' ? initial() : initial;
return [
slots[i],
(value) => {
slots[i] =
typeof value === 'function' ? value(slots[i]) : value;
},
];
},
useRef: (initial) => {
const i = cursor++;
if (!(i in slots)) slots[i] = { current: initial };
return slots[i];
},
};
compileFunction(compiled, ['require', 'module', 'exports'])(
(name) =>
name === 'react'
? hookReact
: name === '@inertiajs/react'
? { Link: 'a' }
: name === '@/pages/investment/shared'
? {
Frame: 'div',
card: 'card',
input: 'input',
button: 'button',
}
: name === '@/lib/profile-json'
? {
ProfileValidationError,
profileJson: (url, body, method) =>
new Promise((resolve, reject) =>
calls.push({
url,
body: structuredClone(body),
method,
resolve,
reject,
}),
),
}
: name === '@/lib/operation-key'
? { createOperationKey }
: name === '@/lib/investment-preparation'
? { PreparationAttempt, swotDimensions }
: require(name),
mod,
mod.exports,
);
const h = {
calls,
render() {
cursor = 0;
const page = mod.exports.default({ workspace: initial });
return page.type(page.props);
},
findButton(label) {
return walk(this.render()).find(
({ node }) => node.type === 'button' && text(node) === label,
);
},
click(label) {
const entry = this.findButton(label);
assert.ok(entry, label);
if (
entry.node.props.disabled ||
entry.parents.some(
(p) => p.type === 'fieldset' && p.props.disabled,
)
)
return false;
entry.node.props.onClick();
return true;
},
field(label) {
const parent = walk(this.render()).find(
({ node }) =>
node.type === 'label' && text(node).startsWith(label),
);
assert.ok(parent, label);
const item = walk(parent.node).find(({ node }) =>
['input', 'textarea', 'select'].includes(node.type),
);
assert.ok(item);
return item.node;
},
change(label, value) {
this.field(label).props.onChange({ target: { value } });
},
async reply(value, index = calls.length - 1) {
calls[index].resolve(value);
await flush();
},
async fail(error, index = calls.length - 1) {
calls[index].reject(error);
await flush();
},
};
return h;
}
void test('uncertain preparation retries use an immutable operation and require a safe explicit recovery', () => {
const a = new PreparationAttempt(),
data = { draft: { scope: 'Deliberate' } };
const first = a.start(2, 'opportunity:7', 'swot_save', data, 'QA');
data.draft.scope = 'Later edit';
assert.equal(a.current().draft.scope, 'Deliberate');
assert.deepEqual(a.current(), first);
assert.throws(() => a.start(2, null, 'external', {}, 'other'));
assert.equal(a.canAdopt({ version: 2, history: [] }), false);
assert.equal(a.canAdopt({ version: 3, history: [] }), true);
assert.equal(
a.canAdopt({
version: 2,
history: [{ operation_key: first.operation_key }],
}),
true,
);
a.complete();
assert.equal(a.current(), null);
});
void test('actual APIEX form preserves local edits on lost response and retries the same body once', async () => {
const h = harness();
h.change('Nota para organizar a preparação', 'Deliberate local APIEX');
h.click('Guardar preparação documental');
assert.equal(h.calls.length, 1);
const first = structuredClone(h.calls[0].body);
assert.equal(h.click('Guardar preparação documental'), false);
await h.fail(new Error('Lost response'));
assert.equal(
h.field('Nota para organizar a preparação').props.value,
'Deliberate local APIEX',
);
h.click('Repetir a mesma tentativa');
assert.deepEqual(h.calls[1].body, first);
const next = fixture();
next.version = 1;
next.apiex.note = 'Deliberate local APIEX';
await h.reply(next);
assert.equal(
h.field('Nota para organizar a preparação').props.value,
'Deliberate local APIEX',
);
});
void test('saving APIEX does not clear unsaved SWOT changes or let them be reviewed as a saved server version', async () => {
const h = harness();
h.click('Análise SWOT');
h.change('Âmbito e fronteiras do objecto', 'Deliberate unsaved SWOT');
h.click('Documentos APIEX');
h.change('Nota para organizar a preparação', 'Another APIEX note');
h.click('Guardar preparação documental');
const next = fixture();
next.version = 1;
next.apiex.note = 'Another APIEX note';
await h.reply(next);
h.click('Análise SWOT');
assert.equal(
h.field('Âmbito e fronteiras do objecto').props.value,
'Deliberate unsaved SWOT',
);
assert.equal(
h.findButton('Conferi objecto,quatro dimensões e fontes desta versão')
.node.props.disabled,
true,
);
});
void test('consulting a concurrent version does not replace local conclusions; explicit keep-local adopts only the base', async () => {
const h = harness();
h.click('Análise SWOT');
h.change(
'Âmbito e fronteiras do objecto',
'Keep this deliberate local scope',
);
h.click('Consultar versão do servidor');
const next = fixture();
next.version = 4;
next.swot.scope = 'Other server scope';
await h.reply(next);
assert.equal(
h.field('Âmbito e fronteiras do objecto').props.value,
'Keep this deliberate local scope',
);
h.click(
'Manter os meus campos e usar esta versão como base para nova revisão',
);
assert.equal(
h.field('Âmbito e fronteiras do objecto').props.value,
'Keep this deliberate local scope',
);
h.click('Guardar análise privada');
assert.equal(h.calls[1].body.version, 4);
assert.equal(
h.calls[1].body.draft.scope,
'Keep this deliberate local scope',
);
});
void test('publication is never an automatic save/review effect; four dimensions remain explicit and uncertain by default', () => {
const h = harness();
h.click('Análise SWOT');
for (const label of [
'Acrescentar forças',
'Acrescentar fraquezas',
'Acrescentar oportunidades',
'Acrescentar ameaças',
])
assert.ok(h.findButton(label));
h.click('Acrescentar forças');
assert.equal(h.field('Classificação declarada').props.value, 'unconfirmed');
assert.equal(h.calls.length, 0);
const links = walk(h.render())
.filter(({ node }) => node.type === 'a')
.map(({ node }) => node.props.href);
assert.ok(links.includes('/acesso-gestao'));
});
void test('team review keeps existing owner-only inputs read-only while retaining the review action', () => {
const view = fixture();
view.can_write = false;
view.can_review = true;
const h = harness(view);
h.click('Análise SWOT');
assert.equal(
h.field('Âmbito e fronteiras do objecto').props.readOnly,
true,
);
assert.equal(
h.findButton('Guardar análise privada').node.props.disabled,
true,
);
assert.equal(
h.findButton('Conferi objecto,quatro dimensões e fontes desta versão')
.node.props.disabled,
false,
);
});
void test('a definite HTTP conflict permits comparison and correction without treating a rejected write as an uncertain commit',async()=>{const h=harness();h.change('Nota para organizar a preparação','Preserve after source conflict');h.click('Guardar preparação documental');const first=h.calls[0].body.operation_key;await h.fail(new ProfileValidationError('Fonte alterada',{},false,409));assert.equal(h.field('Nota para organizar a preparação').props.value,'Preserve after source conflict');h.click('Consultar versão do servidor');const next=fixture();next.api_fingerprint='c'.repeat(64);await h.reply(next);h.click('Manter os meus campos e usar esta versão como base para nova revisão');h.click('Guardar preparação documental');assert.notEqual(h.calls[2].body.operation_key,first);assert.equal(h.calls[2].body.note,'Preserve after source conflict');});