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/); });