612 lines
21 KiB
JavaScript
612 lines
21 KiB
JavaScript
import test from 'node:test';
|
||
import assert from 'node:assert/strict';
|
||
import {
|
||
harness,
|
||
props,
|
||
original,
|
||
nodes,
|
||
text,
|
||
} from './helpers/partnership-candidacy.mjs';
|
||
const ownProps = (patch = {}) =>
|
||
props({
|
||
owner: true,
|
||
canEdit: true,
|
||
actorId: 1,
|
||
interests: [{ ...structuredClone(original), can_edit: false }],
|
||
...patch,
|
||
});
|
||
test('current candidacy card links to its history without changing a prepared submission', () => {
|
||
const h = harness();
|
||
h.change('submission:10', 'Proposta curta', 'SYNTHETIC unsaved');
|
||
assert.ok(
|
||
nodes(h.render()).some(
|
||
(node) =>
|
||
node.props?.href === '/parcerias/candidaturas/10/historico',
|
||
),
|
||
);
|
||
assert.equal(h.submit('submission:10').data.proposal, 'SYNTHETIC unsaved');
|
||
});
|
||
const row = (scope, label) =>
|
||
nodes(scope).find(
|
||
(node) => node.type === 'tr' && text(node).startsWith(label),
|
||
);
|
||
function consent(h, name, checked) {
|
||
const input = nodes(h.form(name)).find(
|
||
(node) => node.type === 'input' && node.props.type === 'checkbox',
|
||
);
|
||
input.props.onChange({ target: { checked } });
|
||
h.render();
|
||
}
|
||
function saved(h, request = h.sent.at(-1), patch = {}) {
|
||
const action = request.data.action ?? 'submit';
|
||
const entry = structuredClone(
|
||
h.props.interests.find(
|
||
(interest) => interest.id === request.data.interest_id,
|
||
) ??
|
||
h.props.interests[0] ??
|
||
original,
|
||
);
|
||
entry.version = Number(request.data.version ?? 0) + 1;
|
||
if (action === 'submit') {
|
||
entry.organization_id = request.data.organization_id || null;
|
||
entry.proposal = request.data.proposal.trim();
|
||
entry.payload = Object.fromEntries(
|
||
Object.entries(request.data.payload).map(([key, value]) => [
|
||
key,
|
||
typeof value === 'string' ? value.trim() || null : value,
|
||
]),
|
||
);
|
||
entry.assessment = [];
|
||
entry.status = 'submitted';
|
||
entry.can_edit = true;
|
||
} else if (action === 'assess')
|
||
entry.assessment = structuredClone(request.data.assessment);
|
||
else
|
||
entry.status = {
|
||
clarify: 'clarification',
|
||
shortlist: 'shortlisted',
|
||
reject: 'rejected',
|
||
connect: 'negotiation',
|
||
withdraw: 'withdrawn',
|
||
}[action];
|
||
entry.commands = [
|
||
...(entry.commands ?? []),
|
||
{ key: request.data.client_key, version: entry.version, action },
|
||
];
|
||
return {
|
||
...h.props,
|
||
canStartInterest: false,
|
||
interests: [{ ...entry, ...patch }],
|
||
};
|
||
}
|
||
test('editing identifies the exact candidature and current proposal version, preserving accepted consent', () => {
|
||
const h = harness();
|
||
h.change('submission:10', 'Proposta curta', 'SYNTHETIC revised proposal');
|
||
const request = h.submit('submission:10');
|
||
assert.equal(request.data.interest_id, 10);
|
||
assert.equal(request.data.version, 1);
|
||
assert.equal(request.data.partnership_version, 4);
|
||
assert.equal(request.data.payload.consent, true);
|
||
assert.equal(request.data.payload.capacity, original.payload.capacity);
|
||
assert.match(text(h.form('submission:10')), /reinicia a avaliação actual/);
|
||
});
|
||
test('batched declaration and consent edits do not erase siblings', () => {
|
||
const h = harness();
|
||
h.change(
|
||
'submission:10',
|
||
'Capacidade disponível',
|
||
'SYNTHETIC new capacity',
|
||
false,
|
||
);
|
||
h.change(
|
||
'submission:10',
|
||
'Disponibilidade',
|
||
'SYNTHETIC new availability',
|
||
false,
|
||
);
|
||
h.render();
|
||
consent(h, 'submission:10', false);
|
||
const request = h.submit('submission:10');
|
||
assert.equal(request.data.payload.capacity, 'SYNTHETIC new capacity');
|
||
assert.equal(
|
||
request.data.payload.availability,
|
||
'SYNTHETIC new availability',
|
||
);
|
||
assert.equal(
|
||
request.data.payload.contribution,
|
||
original.payload.contribution,
|
||
);
|
||
assert.equal(request.data.payload.consent, false);
|
||
});
|
||
test('read only representation offers consultation without update or withdrawal forms', () => {
|
||
const h = harness(
|
||
props({
|
||
interests: [{ ...structuredClone(original), can_edit: false }],
|
||
}),
|
||
);
|
||
assert.equal(h.form('submission:10'), undefined);
|
||
assert.equal(h.form('decision:10'), undefined);
|
||
assert.equal(h.form('submission:new'), undefined);
|
||
assert.match(text(h.tree), /SYNTHETIC original proposal/);
|
||
});
|
||
test('remote edits require an explicit choice and merge untouched declarations only after adoption', () => {
|
||
const h = harness();
|
||
h.change(
|
||
'submission:10',
|
||
'Capacidade disponível',
|
||
'SYNTHETIC local capacity',
|
||
);
|
||
h.render({
|
||
...h.props,
|
||
interests: [
|
||
{
|
||
...structuredClone(original),
|
||
version: 2,
|
||
payload: {
|
||
...original.payload,
|
||
capacity: 'SYNTHETIC remote capacity',
|
||
availability: 'SYNTHETIC remote availability',
|
||
},
|
||
},
|
||
],
|
||
});
|
||
assert.equal(
|
||
h.button('Guardar interesse', h.form('submission:10')).props.disabled,
|
||
true,
|
||
);
|
||
h.click(
|
||
'Manter minha alteração',
|
||
row(h.form('submission:10'), 'Capacidade disponível'),
|
||
);
|
||
h.click(
|
||
'Usar este contexto e conservar as alterações escolhidas',
|
||
h.form('submission:10'),
|
||
);
|
||
const request = h.submit('submission:10');
|
||
assert.equal(request.data.version, 2);
|
||
assert.equal(request.data.payload.capacity, 'SYNTHETIC local capacity');
|
||
assert.equal(
|
||
request.data.payload.availability,
|
||
'SYNTHETIC remote availability',
|
||
);
|
||
});
|
||
test('a deliberate return to the original declaration is not treated as untouched', () => {
|
||
const h = harness();
|
||
h.change('submission:10', 'Proposta curta', 'SYNTHETIC temporary');
|
||
h.change('submission:10', 'Proposta curta', original.proposal);
|
||
h.render({
|
||
...h.props,
|
||
interests: [
|
||
{
|
||
...structuredClone(original),
|
||
version: 2,
|
||
proposal: 'SYNTHETIC remote proposal',
|
||
},
|
||
],
|
||
});
|
||
assert.match(
|
||
text(row(h.form('submission:10'), 'Proposta curta')),
|
||
/Escolha o valor a conservar/,
|
||
);
|
||
});
|
||
test('creation becomes the same editable candidature and preserves input made after sending', () => {
|
||
const h = harness(props({ interests: [], canStartInterest: true }));
|
||
h.change('submission:new', 'Proposta curta', 'SYNTHETIC first proposal');
|
||
consent(h, 'submission:new', true);
|
||
const first = h.submit('submission:new');
|
||
h.change('submission:new', 'Capacidade disponível', 'SYNTHETIC after send');
|
||
h.success(saved(h, first, { id: 12 }), first);
|
||
assert.equal(h.form('submission:12'), undefined);
|
||
assert.equal(
|
||
h.field('submission:new', 'Capacidade disponível').props.value,
|
||
'SYNTHETIC after send',
|
||
);
|
||
const next = h.submit('submission:new');
|
||
assert.equal(next.data.interest_id, 12);
|
||
assert.equal(next.data.version, 1);
|
||
assert.notEqual(next.data.client_key, first.data.client_key);
|
||
});
|
||
test('losing access to a candidature keeps only its local preparation mounted and disables commands', () => {
|
||
const h = harness();
|
||
h.change(
|
||
'submission:10',
|
||
'Capacidade disponível',
|
||
'SYNTHETIC retained private preparation',
|
||
);
|
||
h.render({ ...h.props, interests: [], canStartInterest: false });
|
||
assert.equal(
|
||
h.field('submission:10', 'Capacidade disponível').props.value,
|
||
'SYNTHETIC retained private preparation',
|
||
);
|
||
assert.equal(
|
||
h.button('Guardar interesse', h.form('submission:10')).props.disabled,
|
||
true,
|
||
);
|
||
assert.match(text(h.tree), /deixou de estar disponível/);
|
||
assert.equal(h.form('submission:new'), undefined);
|
||
});
|
||
test('withdrawal remains available when the proposal changes or its submission window closes', () => {
|
||
const h = harness();
|
||
h.change(
|
||
'decision:10',
|
||
'Motivo da retirada',
|
||
'SYNTHETIC voluntary withdrawal',
|
||
);
|
||
h.render({
|
||
...h.props,
|
||
acceptingInterests: false,
|
||
record: { ...h.props.record, version: 9, status: 'archived' },
|
||
});
|
||
assert.equal(h.button('Retirar candidatura').props.disabled, false);
|
||
const request = h.click('Retirar candidatura');
|
||
assert.equal(request.data.action, 'withdraw');
|
||
assert.equal(request.data.partnership_version, undefined);
|
||
assert.equal(request.data.weights_hash, undefined);
|
||
});
|
||
test('assessment edits keep state and evidence atomic across batched events', () => {
|
||
const h = harness(ownProps());
|
||
h.change('decision:10', 'Capacidade técnica/produtiva', 'met', false);
|
||
h.change(
|
||
'decision:10',
|
||
'Referência da evidência — Capacidade técnica/produtiva',
|
||
'SYNTHETIC checked evidence',
|
||
false,
|
||
);
|
||
h.render();
|
||
h.change(
|
||
'decision:10',
|
||
'Motivo da decisão ou pedido de esclarecimento',
|
||
'SYNTHETIC evidence reviewed',
|
||
);
|
||
const request = h.click('Guardar avaliação');
|
||
assert.deepEqual(request.data.assessment.capacity, {
|
||
state: 'met',
|
||
evidence: 'SYNTHETIC checked evidence',
|
||
});
|
||
assert.equal(request.data.partnership_version, 4);
|
||
assert.equal(request.data.weights_hash, 'a'.repeat(64));
|
||
});
|
||
test('proposal and criteria changes force review without silently changing prepared assessment', () => {
|
||
const h = harness(ownProps());
|
||
h.change('decision:10', 'Capacidade técnica/produtiva', 'met');
|
||
h.change(
|
||
'decision:10',
|
||
'Referência da evidência — Capacidade técnica/produtiva',
|
||
'SYNTHETIC own evidence',
|
||
);
|
||
h.change(
|
||
'decision:10',
|
||
'Motivo da decisão ou pedido de esclarecimento',
|
||
'SYNTHETIC review',
|
||
);
|
||
h.render({
|
||
...h.props,
|
||
record: { ...h.props.record, version: 5 },
|
||
interests: [
|
||
{
|
||
...h.props.interests[0],
|
||
match: {
|
||
...h.props.interests[0].match,
|
||
weights_hash: 'b'.repeat(64),
|
||
},
|
||
},
|
||
],
|
||
});
|
||
assert.equal(h.button('Guardar avaliação').props.disabled, true);
|
||
assert.equal(
|
||
h.field('decision:10', 'Capacidade técnica/produtiva').props.value,
|
||
'met',
|
||
);
|
||
h.click(
|
||
'Usar este contexto e conservar as alterações escolhidas',
|
||
h.form('decision:10'),
|
||
);
|
||
const request = h.click('Guardar avaliação');
|
||
assert.equal(request.data.partnership_version, 5);
|
||
assert.equal(request.data.weights_hash, 'b'.repeat(64));
|
||
});
|
||
test('separate criterion conflicts retain independent choices while the reason is edited', () => {
|
||
const h = harness(ownProps());
|
||
h.change('decision:10', 'Capacidade técnica/produtiva', 'met');
|
||
h.change('decision:10', 'Experiência comprovada', 'met');
|
||
h.render({
|
||
...h.props,
|
||
interests: [
|
||
{
|
||
...h.props.interests[0],
|
||
version: 2,
|
||
assessment: {
|
||
capacity: {
|
||
state: 'unmet',
|
||
evidence: 'SYNTHETIC remote capacity',
|
||
},
|
||
experience: {
|
||
state: 'unmet',
|
||
evidence: 'SYNTHETIC remote experience',
|
||
},
|
||
},
|
||
},
|
||
],
|
||
});
|
||
h.click(
|
||
'Manter minha alteração',
|
||
row(h.form('decision:10'), 'Capacidade técnica/produtiva'),
|
||
);
|
||
h.click(
|
||
'Usar valor actual',
|
||
row(h.form('decision:10'), 'Experiência comprovada'),
|
||
);
|
||
h.change(
|
||
'decision:10',
|
||
'Motivo da decisão ou pedido de esclarecimento',
|
||
'SYNTHETIC combined review',
|
||
);
|
||
assert.equal(
|
||
h.button(
|
||
'Usar este contexto e conservar as alterações escolhidas',
|
||
h.form('decision:10'),
|
||
).props.disabled,
|
||
false,
|
||
);
|
||
h.click(
|
||
'Usar este contexto e conservar as alterações escolhidas',
|
||
h.form('decision:10'),
|
||
);
|
||
assert.equal(
|
||
h.field('decision:10', 'Capacidade técnica/produtiva').props.value,
|
||
'met',
|
||
);
|
||
assert.equal(
|
||
h.field('decision:10', 'Experiência comprovada').props.value,
|
||
'unmet',
|
||
);
|
||
});
|
||
test('uncertain submission repeats the frozen command and retains later declarations', () => {
|
||
const h = harness();
|
||
h.change('submission:10', 'Proposta curta', 'SYNTHETIC first command');
|
||
const first = h.submit('submission:10');
|
||
h.fail(first);
|
||
h.change('submission:10', 'Proposta curta', 'SYNTHETIC later text');
|
||
const retry = h.submit('submission:10');
|
||
assert.deepEqual(retry.data, first.data);
|
||
h.success(saved(h, retry), retry);
|
||
assert.equal(
|
||
h.field('submission:10', 'Proposta curta').props.value,
|
||
'SYNTHETIC later text',
|
||
);
|
||
const next = h.submit('submission:10');
|
||
assert.equal(next.data.version, 2);
|
||
assert.notEqual(next.data.client_key, first.data.client_key);
|
||
});
|
||
test('uncertain owner decisions expose one recovery action and do not resend later changes', () => {
|
||
const h = harness(ownProps());
|
||
h.change(
|
||
'decision:10',
|
||
'Motivo da decisão ou pedido de esclarecimento',
|
||
'SYNTHETIC original decision',
|
||
);
|
||
const first = h.click('Guardar avaliação');
|
||
h.fail(first);
|
||
h.change(
|
||
'decision:10',
|
||
'Motivo da decisão ou pedido de esclarecimento',
|
||
'SYNTHETIC later reason',
|
||
);
|
||
assert.equal(h.button('Adicionar à lista curta'), undefined);
|
||
const retry = h.click('Recuperar acção enviada');
|
||
assert.deepEqual(retry.data, first.data);
|
||
h.success(saved(h, retry), retry);
|
||
assert.equal(
|
||
h.field('decision:10', 'Motivo da decisão ou pedido de esclarecimento')
|
||
.props.value,
|
||
'SYNTHETIC later reason',
|
||
);
|
||
});
|
||
test('non-assessment decisions use saved criteria and preserve unsubmitted assessment edits', () => {
|
||
const h = harness(ownProps());
|
||
h.change('decision:10', 'Capacidade técnica/produtiva', 'met');
|
||
h.change(
|
||
'decision:10',
|
||
'Motivo da decisão ou pedido de esclarecimento',
|
||
'SYNTHETIC shortlist saved unknown assessment',
|
||
);
|
||
assert.match(
|
||
text(h.form('decision:10')),
|
||
/outras decisões usam a avaliação actualmente guardada/,
|
||
);
|
||
const request = h.click('Adicionar à lista curta');
|
||
assert.deepEqual(request.data.assessment, {});
|
||
h.success(saved(h, request), request);
|
||
assert.equal(
|
||
h.field('decision:10', 'Capacidade técnica/produtiva').props.value,
|
||
'met',
|
||
);
|
||
assert.equal(
|
||
h.field('decision:10', 'Motivo da decisão ou pedido de esclarecimento')
|
||
.props.value,
|
||
'',
|
||
);
|
||
});
|
||
test('obsolete criteria remain in local preparation but are excluded from the current command', () => {
|
||
const h = harness(ownProps());
|
||
h.change(
|
||
'decision:10',
|
||
'Referência da evidência — Experiência comprovada',
|
||
'SYNTHETIC retired evidence',
|
||
);
|
||
h.render({
|
||
...h.props,
|
||
interests: [
|
||
{
|
||
...h.props.interests[0],
|
||
match: {
|
||
...h.props.interests[0].match,
|
||
weights_hash: 'b'.repeat(64),
|
||
criteria: [h.props.interests[0].match.criteria[0]],
|
||
},
|
||
},
|
||
],
|
||
});
|
||
assert.match(text(h.form('decision:10')), /SYNTHETIC retired evidence/);
|
||
h.click(
|
||
'Usar este contexto e conservar as alterações escolhidas',
|
||
h.form('decision:10'),
|
||
);
|
||
h.change(
|
||
'decision:10',
|
||
'Motivo da decisão ou pedido de esclarecimento',
|
||
'SYNTHETIC current criteria only',
|
||
);
|
||
const request = h.click('Guardar avaliação');
|
||
assert.equal(request.data.assessment.experience, undefined);
|
||
h.success(saved(h, request), request);
|
||
assert.equal(
|
||
h.button(
|
||
'Usar este contexto e conservar as alterações escolhidas',
|
||
h.form('decision:10'),
|
||
),
|
||
undefined,
|
||
);
|
||
h.click(
|
||
'Descartar preparação dos critérios anteriores',
|
||
h.form('decision:10'),
|
||
);
|
||
assert.equal(h.unload(), false);
|
||
});
|
||
test('an acknowledgement of an older committed operation cannot accept someone else’s later values', () => {
|
||
const h = harness();
|
||
h.change('submission:10', 'Proposta curta', 'SYNTHETIC submitted text');
|
||
const request = h.submit('submission:10');
|
||
h.change(
|
||
'submission:10',
|
||
'Contribuição proposta',
|
||
'SYNTHETIC unsent contribution',
|
||
);
|
||
const later = saved(h, request, {
|
||
version: 3,
|
||
payload: {
|
||
...request.data.payload,
|
||
contribution: 'SYNTHETIC remote contribution',
|
||
},
|
||
});
|
||
h.success(later, request);
|
||
assert.equal(
|
||
h.field('submission:10', 'Contribuição proposta').props.value,
|
||
'SYNTHETIC unsent contribution',
|
||
);
|
||
assert.equal(
|
||
h.button('Guardar interesse', h.form('submission:10')).props.disabled,
|
||
true,
|
||
);
|
||
assert.match(text(h.form('submission:10')), /dados posteriores/);
|
||
});
|
||
test('wrong record acknowledgement conserves preparation and never authorizes another target', () => {
|
||
const h = harness();
|
||
h.change('submission:10', 'Proposta curta', 'SYNTHETIC local preparation');
|
||
const request = h.submit('submission:10');
|
||
h.success({ ...h.props, record: { ...h.props.record, id: 999 } }, request);
|
||
assert.equal(
|
||
h.field('submission:10', 'Proposta curta').props.value,
|
||
'SYNTHETIC local preparation',
|
||
);
|
||
assert.equal(
|
||
h.button('Recuperar submissão enviada', h.form('submission:10')).props
|
||
.disabled,
|
||
true,
|
||
);
|
||
});
|
||
test('one navigation guard covers independent candidature drafts and permits preserved comparison refresh', () => {
|
||
const h = harness();
|
||
h.change('submission:10', 'Capacidade disponível', 'SYNTHETIC draft');
|
||
h.change('decision:10', 'Motivo da retirada', 'SYNTHETIC reason');
|
||
assert.equal(h.leave(), true);
|
||
assert.equal(h.confirmCalls, 1);
|
||
assert.equal(h.leave(true), false);
|
||
assert.equal(h.unload(), true);
|
||
});
|
||
test('a different authenticated account receives no private unsent preparation', () => {
|
||
const h = harness();
|
||
h.change(
|
||
'submission:10',
|
||
'Proposta curta',
|
||
'SYNTHETIC private unsent text',
|
||
);
|
||
h.render({ ...h.props, actorId: 3 });
|
||
assert.equal(
|
||
h.field('submission:10', 'Proposta curta').props.value,
|
||
original.proposal,
|
||
);
|
||
});
|
||
test('real API empty-array assessments and legacy accepted consent remain editable without fabricated assessment', () => {
|
||
const h = harness(
|
||
props({
|
||
interests: [
|
||
{
|
||
...structuredClone(original),
|
||
assessment: [],
|
||
payload: { ...original.payload, consent: 'yes' },
|
||
},
|
||
],
|
||
}),
|
||
);
|
||
assert.ok(h.form('submission:10'));
|
||
const input = nodes(h.form('submission:10')).find(
|
||
(node) => node.type === 'input' && node.props.type === 'checkbox',
|
||
);
|
||
assert.equal(input.props.checked, true);
|
||
const owner = harness(ownProps());
|
||
assert.equal(
|
||
owner.field('decision:10', 'Capacidade técnica/produtiva').props.value,
|
||
'unknown',
|
||
);
|
||
});
|
||
test('an indeterminate matching range is explained without a false percentage', () => {
|
||
const h = harness(
|
||
props({
|
||
interests: [
|
||
{
|
||
...structuredClone(original),
|
||
match: {
|
||
...original.match,
|
||
score_min: null,
|
||
score_max: null,
|
||
},
|
||
},
|
||
],
|
||
}),
|
||
);
|
||
assert.match(text(h.tree), /Correspondência: Por determinar/);
|
||
assert.doesNotMatch(text(h.tree), /–%/);
|
||
});
|
||
test('saved matching weights, mandatory status, evidence and explanation remain visible to the parties', () => {
|
||
const criterion = {
|
||
...original.match.criteria[0],
|
||
state: 'met',
|
||
evidence: 'SYNTHETIC preserved source',
|
||
explanation: 'SYNTHETIC preserved explanation',
|
||
};
|
||
const h = harness(
|
||
props({
|
||
interests: [
|
||
{
|
||
...structuredClone(original),
|
||
assessment: {
|
||
capacity: {
|
||
state: 'met',
|
||
evidence: criterion.evidence,
|
||
},
|
||
},
|
||
match: { ...original.match, criteria: [criterion] },
|
||
},
|
||
],
|
||
}),
|
||
);
|
||
const item = nodes(h.tree).find(
|
||
(node) => node.type === 'li' && text(node).includes(criterion.label),
|
||
);
|
||
assert.match(text(item), /peso 20/);
|
||
assert.match(text(item), /Obrigatório/);
|
||
assert.match(text(item), /Evidência: SYNTHETIC preserved source/);
|
||
assert.match(text(item), /SYNTHETIC preserved explanation/);
|
||
});
|