355 lines
13 KiB
JavaScript
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');});
|