Files
Investidor-V2/tests/frontend/product-experiments.test.mjs
T

480 lines
15 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 { renderToStaticMarkup } from 'react-dom/server';
import ts from 'typescript';
const require = createRequire(import.meta.url);
function load(file, mocks = {}) {
const code = ts.transpileModule(
fs.readFileSync(new URL(file, import.meta.url), 'utf8'),
{
compilerOptions: {
module: ts.ModuleKind.CommonJS,
target: ts.ScriptTarget.ES2022,
jsx: ts.JsxEmit.ReactJSX,
},
},
).outputText;
const module = { exports: {} };
compileFunction(code, ['require', 'module', 'exports'])(
(key) => mocks[key] ?? require(key),
module,
module.exports,
);
return module.exports;
}
const lib = load('../../resources/js/lib/product-experiments.ts');
const usage = load('../../resources/js/lib/product-usage.ts');
const fields = [
'title',
'state',
'change_kind',
'reference',
'hypothesis',
'indicator_id',
'indicator_title',
'indicator_definition',
'indicator_unit',
'period_from',
'period_to',
'segment',
'control',
'variant',
'assignment',
'criterion',
'transparency',
'consent',
'security',
'equality',
'preserves_requirements',
'observed_from',
'observed_to',
'control_result',
'variant_result',
'result',
'limitations',
'evidence',
'decision',
'decision_reason',
'report_service',
];
const values = {
...Object.fromEntries(fields.map((k) => [k, ''])),
title: 'SYNTHETIC experiment',
state: 'planned',
change_kind: 'form',
hypothesis: 'SYNTHETIC base hypothesis',
segment: 'SYNTHETIC base segment',
indicator_id: 'submission_rate',
indicator_title: 'SYNTHETIC inactive custom title',
indicator_definition: 'SYNTHETIC inactive definition',
indicator_unit: '%',
period_from: '2026-11-02',
period_to: '2026-11-04',
preserves_requirements: 'yes',
};
const indicator = {
id: 'submission_rate',
title: 'Submission rate',
unit: '%',
source: 'SYNTHETIC frozen definition',
limits: 'SYNTHETIC limits',
definition_version: 'SYNTHETIC-v1',
};
const document = {
definition_version: 'SYNTHETIC registry',
version: 1,
at: '2026-11-03T12:00:00Z',
actor: { id: 1, name: 'SYNTHETIC actor' },
reason: 'SYNTHETIC reason',
values,
indicator,
plan_sha256: 'a'.repeat(64),
observation_plan_sha256: 'a'.repeat(64),
registration: {
first_at: '2026-11-03T12:00:00Z',
original_plan_sha256: 'a'.repeat(64),
amendments: 0,
amended_after_observation: false,
},
operational_report: null,
report_matches_observation: null,
report_scope: 'operational_cohort_not_experiment_arm',
};
const props = {
installed: true,
id: 'synthetic-id',
currentVersion: 1,
historical: false,
document,
values,
states: {
draft: 'Draft',
planned: 'Plan registered',
observing: 'Observing',
reviewed: 'Decision recorded',
cancelled: 'Cancelled',
},
changeKinds: { form: 'Form' },
decisions: { inconclusive: 'Inconclusive' },
labels: Object.fromEntries(fields.map((k) => [k, 'Label ' + k])),
catalogue: {
definition_version: 'SYNTHETIC-v2',
items: [{ ...indicator, source: 'SYNTHETIC current definition' }],
services: [],
},
history: [
{
version: 1,
at: document.at,
actor: 'SYNTHETIC actor',
state: 'planned',
reason: 'SYNTHETIC reason',
},
],
historyNext: null,
};
class ValidationError extends Error {
constructor(message, fields = {}) {
super(message);
this.fields = fields;
}
}
function harness(
initial = props,
respond = async () => ({ id: 'synthetic-id' }),
) {
const slots = [];
let cursor = 0,
key = 0;
const calls = [],
visits = [];
const page = load(
'../../resources/js/pages/management/experiments/edit.tsx',
{
react: {
...require('react'),
useState: (value) => {
const index = cursor++;
if (!(index in slots))
slots[index] =
typeof value === 'function' ? value() : value;
return [
slots[index],
(v) => {
slots[index] =
typeof v === 'function' ? v(slots[index]) : v;
},
];
},
},
'@inertiajs/react': {
Link: 'a',
router: { visit: (...args) => visits.push(args) },
},
'@/pages/investment/shared': {
Frame: 'main',
card: '',
input: '',
button: '',
},
'@/lib/profile-json': {
ProfileValidationError: ValidationError,
profileJson: async (...args) => {
calls.push(args);
return respond(...args);
},
},
'@/lib/operation-key': {
createOperationKey: () => `SYNTHETIC-operation-${++key}`,
},
'@/lib/product-experiments': lib,
'@/lib/product-usage': usage,
},
);
return {
calls,
visits,
render() {
cursor = 0;
const outer = page.default(initial);
return outer.type(outer.props);
},
};
}
function walk(node, predicate) {
if (Array.isArray(node)) return node.flatMap((n) => walk(n, predicate));
if (!node || typeof node !== 'object' || !node.props) return [];
return [
...(predicate(node) ? [node] : []),
...walk(node.props.children, predicate),
];
}
function content(node) {
if (Array.isArray(node)) return node.map(content).join('');
if (node === null || node === undefined || typeof node === 'boolean')
return '';
if (typeof node === 'object') return content(node.props?.children);
return String(node);
}
function button(tree, text) {
const node = walk(
tree,
(n) => n.type === 'button' && content(n).includes(text),
)[0];
assert.ok(node, 'button ' + text);
return node;
}
function inputValue(tree, value) {
const node = walk(
tree,
(n) =>
['input', 'textarea'].includes(n.type) && n.props.value === value,
)[0];
assert.ok(node, 'input ' + value);
return node;
}
function check(tree, text) {
const label = walk(
tree,
(n) => n.type === 'label' && content(n).includes(text),
)[0];
assert.ok(label, 'checkbox ' + text);
return walk(
label,
(n) => n.type === 'input' && n.props.type === 'checkbox',
)[0];
}
test('three-way comparison reports actual conflicts and merges untouched fields without losing deliberate edits', () => {
const base = { hypothesis: 'base', segment: 'old', title: 'same' };
const mine = { ...base, hypothesis: 'mine' };
const current = { ...base, hypothesis: 'theirs', segment: 'new' };
assert.deepEqual(lib.experimentDifferences(base, mine, current), [
'hypothesis',
]);
assert.deepEqual(
lib.experimentDifferences(base, base, current, ['hypothesis']),
['hypothesis'],
);
assert.equal(
lib.mergeExperimentValues(base, base, current, ['hypothesis'])
.hypothesis,
'base',
);
assert.deepEqual(lib.mergeExperimentValues(base, mine, current), {
hypothesis: 'mine',
segment: 'new',
title: 'same',
});
assert.deepEqual(base, {
hypothesis: 'base',
segment: 'old',
title: 'same',
});
});
test('stored indicator edition remains visible, explicit refresh can be reversed, and inactive custom fields are hidden', () => {
const h = harness();
let tree = h.render(),
html = renderToStaticMarkup(tree);
assert.match(html, /SYNTHETIC frozen definition/);
assert.doesNotMatch(
html,
/SYNTHETIC current definition|SYNTHETIC inactive definition/,
);
check(tree, 'Adoptar explicitamente').props.onChange({
target: { checked: true },
});
tree = h.render();
html = renderToStaticMarkup(tree);
assert.match(html, /SYNTHETIC current definition/);
check(tree, 'Adoptar explicitamente').props.onChange({
target: { checked: false },
});
assert.match(
renderToStaticMarkup(h.render()),
/SYNTHETIC frozen definition/,
);
const manual = harness({
...props,
values: { ...values, indicator_id: 'manual' },
document: { ...document, indicator: { ...indicator, id: 'manual' } },
});
assert.doesNotMatch(
renderToStaticMarkup(manual.render()),
/Adoptar explicitamente/,
);
});
test('conflict recovery preserves the draft, compares changed fields, and saves against the reviewed current version', async () => {
const current = {
...props,
currentVersion: 2,
values: {
...values,
hypothesis: 'SYNTHETIC their hypothesis',
segment: 'SYNTHETIC their segment',
},
document: { ...document, version: 2 },
};
let writes = 0;
const h = harness(props, async (url, data, method) => {
if (method === 'GET') return current;
if (++writes === 1) throw new ValidationError('SYNTHETIC conflict');
return { id: 'synthetic-id', version: 3 };
});
inputValue(h.render(), values.hypothesis).props.onChange({
target: { value: 'SYNTHETIC my deliberate hypothesis' },
});
walk(
h.render(),
(n) => n.type === 'textarea' && n.props.maxLength === 3000,
)[0].props.onChange({ target: { value: 'SYNTHETIC reviewed conflict' } });
walk(h.render(), (n) => n.type === 'form')[0].props.onSubmit({
preventDefault() {},
});
await new Promise(setImmediate);
assert.match(
renderToStaticMarkup(h.render()),
/SYNTHETIC my deliberate hypothesis/,
);
inputValue(h.render(), 'SYNTHETIC my deliberate hypothesis').props.onChange(
{ target: { value: values.hypothesis } },
);
await button(h.render(), 'Consultar versão actual').props.onClick();
const beforeReview = h.calls.length;
walk(h.render(), (n) => n.type === 'form')[0].props.onSubmit({
preventDefault() {},
});
await new Promise(setImmediate);
assert.equal(
h.calls.length,
beforeReview,
'Do not submit before the current version has been reviewed',
);
assert.match(
renderToStaticMarkup(h.render()),
/SYNTHETIC their hypothesis/,
);
button(h.render(), 'Manter a minha edição').props.onClick();
button(h.render(), 'Usar esta versão como base').props.onClick();
let tree = h.render();
assert.equal(
inputValue(tree, values.hypothesis).props.value,
values.hypothesis,
);
assert.equal(
inputValue(tree, 'SYNTHETIC their segment').props.value,
'SYNTHETIC their segment',
);
walk(tree, (n) => n.type === 'form')[0].props.onSubmit({
preventDefault() {},
});
await new Promise(setImmediate);
const requests = h.calls.filter((c) => c[2] !== 'GET');
assert.equal(requests[1][1].version, 2);
assert.notEqual(requests[0][1].operation_key, requests[1][1].operation_key);
assert.equal(requests[1][1].values.hypothesis, values.hypothesis);
assert.equal(requests[1][1].values.segment, 'SYNTHETIC their segment');
assert.equal(
requests[1][1].values.indicator_definition,
'SYNTHETIC inactive definition',
);
assert.ok(h.calls.every((c) => !c[0].includes('hypothesis')));
assert.equal(h.visits.length, 1);
});
test('historical versions are read only and an unavailable registry is distinct from an empty one', async () => {
const h = harness({ ...props, historical: true, currentVersion: 3 });
const tree = h.render(),
html = renderToStaticMarkup(tree);
assert.match(html, /Consulta da versão 1/);
assert.match(html, /SYNTHETIC frozen definition/);
assert.doesNotMatch(
html,
/Guardar versão do registo|Consultar versão actual para comparar/,
);
assert.equal(
walk(tree, (n) => n.type === 'fieldset' && !n.props.disabled).length,
0,
);
walk(tree, (n) => n.type === 'form')[0].props.onSubmit({
preventDefault() {},
});
await new Promise(setImmediate);
assert.equal(
h.calls.length,
0,
'Historical consultation cannot submit even if a submit event is dispatched',
);
const index = load(
'../../resources/js/pages/management/experiments/index.tsx',
{
'@inertiajs/react': { Link: 'a' },
'@/pages/investment/shared': {
Frame: 'main',
card: '',
input: '',
button: '',
},
},
).default;
const missing = renderToStaticMarkup(
index({
installed: false,
states: {},
filters: { state: '' },
items: [],
page: 1,
pages: 1,
total: 0,
}),
);
assert.match(missing, /ainda não está disponível/);
assert.doesNotMatch(
missing,
/0 registos|Preparar experiência|Ainda não existem registos/,
);
});
test('an attached operational report is labelled as context and retains unknown values without inventing arm results', () => {
const report = {
filters: { from: '2026-11-02', to: '2026-11-02', service_code: null },
generated_at: '2026-11-03T12:00:00Z',
metrics: [
{
id: 'rate',
title: 'SYNTHETIC rate',
value: null,
unit: '%',
state: 'unmeasured',
},
],
};
const h = harness({
...props,
values: {
...values,
state: 'reviewed',
result: 'SYNTHETIC declared result',
},
document: {
...document,
operational_report: report,
report_matches_observation: false,
},
});
const html = renderToStaticMarkup(h.render());
assert.match(
html,
/Não separa grupos da experiência nem comprova causalidade/,
);
assert.match(html, /filtros deste relatório diferem/);
assert.match(html, /Sem medição/);
assert.doesNotMatch(html, />0 %</);
});