480 lines
15 KiB
JavaScript
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 %</);
|
|
});
|