Files
Investidor-V2/tests/frontend/partnership-editing.test.mjs
T

879 lines
30 KiB
JavaScript

import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import { createRequire } from 'node:module';
import { compileFunction } from 'node:vm';
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,
jsx: ts.JsxEmit.ReactJSX,
target: ts.ScriptTarget.ES2022,
},
},
).outputText;
const module = { exports: {} };
compileFunction(code, ['require', 'module', 'exports'])(
(name) => mocks[name] ?? require(name),
module,
module.exports,
);
return module.exports;
}
const clone = structuredClone;
test('an unconfirmed edit exposes recovery of the same submitted command', () => {
const h = harness();
h.set('title', 'SYNTHETIC frozen revision');
h.edit.save();
h.sent[0].options.onNetworkError();
h.render();
assert.equal(h.edit.retryingSave, true);
});
test('uncertain edit repeats the frozen operation and retains later typing', () => {
const h = harness();
h.set('title', 'SYNTHETIC sent');
h.edit.save();
const first = h.sent[0];
h.set('offer', 'SYNTHETIC later offer');
first.options.onNetworkError();
h.render();
h.edit.recoverSave();
assert.deepEqual(h.sent[1].data, first.data);
h.success(1, saved(h.sent[1].data));
assert.equal(h.data.offer, 'SYNTHETIC later offer');
assert.equal(h.edit.retryingSave, false);
});
test('refresh recognises an already committed edit from its own operation metadata', () => {
const h = harness();
h.set('title', 'SYNTHETIC saved');
h.edit.save();
h.sent[0].options.onNetworkError();
h.render();
h.edit.refresh();
const next = saved(h.sent[0].data);
h.render(next);
h.reloads[0].onSuccess({ props: next });
h.reloads[0].onFinish();
h.render();
assert.equal(h.edit.retryingSave, false);
assert.equal(h.data.version, 3);
assert.equal(h.edit.dirty, false);
});
test('recognising an edit preserves post-send preparation and does not adopt a later state', () => {
const h = harness();
h.set('title', 'SYNTHETIC committed');
h.edit.save();
h.set('title', 'SYNTHETIC later preparation');
h.success(0, saved(h.sent[0].data, { version: 4, status: 'submitted' }));
assert.equal(h.data.title, 'SYNTHETIC later preparation');
assert.equal(h.data.version, 3);
assert.equal(h.edit.stale, true);
assert.equal(h.edit.editable, false);
h.edit.adopt();
h.render();
assert.equal(h.data.version, 4);
assert.equal(h.data.title, 'SYNTHETIC later preparation');
assert.equal(h.sent.length, 1);
});
test('lost access disables recovery until a successful authorized refresh', () => {
const h = harness();
h.set('title', 'SYNTHETIC retained');
h.edit.save();
h.sent[0].options.onHttpException({ status: 403 });
h.render();
assert.equal(h.edit.canRecoverSave, false);
h.edit.recoverSave();
assert.equal(h.sent.length, 1);
h.edit.refresh();
h.reloads[0].onSuccess({ props: props() });
h.reloads[0].onFinish();
h.render();
assert.equal(h.edit.canRecoverSave, true);
});
test('new attempt after comparison gets a new key and ignores late failure of the abandoned attempt', () => {
const h = harness();
h.set('title', 'SYNTHETIC original attempt');
h.edit.save();
const first = h.sent[0];
first.options.onNetworkError();
h.render();
h.set('title', 'SYNTHETIC new preparation');
h.edit.adopt();
h.render();
assert.equal(h.edit.retryingSave, true);
h.edit.refresh();
h.reloads[0].onSuccess({ props: props() });
h.reloads[0].onFinish();
h.render();
h.edit.adopt();
h.render();
h.edit.save();
assert.equal(h.sent.length, 2);
assert.notEqual(h.sent[1].data.operation_key, first.data.operation_key);
assert.equal(h.sent[1].data.title, 'SYNTHETIC new preparation');
first.options.onNetworkError();
h.render();
assert.equal(h.edit.retryingSave, false);
});
test('another account starts a separate preparation and ignores an old acknowledgement', () => {
const h = harness();
h.set('title', 'SYNTHETIC former actor draft');
h.edit.save();
const first = h.sent[0],
next = props({ editorActorId: 99 });
h.render(next);
assert.equal(h.data.title, 'SYNTHETIC original');
first.options.onSuccess({ props: saved(first.data) });
h.render(next);
assert.equal(h.data.title, 'SYNTHETIC original');
assert.equal(h.edit.retryingSave, false);
});
test('response without matching operation metadata cannot clear the preparation', () => {
const h = harness();
h.set('title', 'SYNTHETIC conserved');
h.edit.save();
const next = saved(h.sent[0].data);
next.saveCommands = [];
h.success(0, next);
assert.equal(h.data.title, 'SYNTHETIC conserved');
assert.equal(h.edit.retryingSave, true);
assert.equal(h.edit.blocked, true);
});
test('recovery is offered outside the disabled editing fieldset in a later read-only state', () => {
const h = harness();
h.set('title', 'SYNTHETIC committed');
h.edit.save();
const first = h.sent[0];
first.options.onNetworkError();
const next = saved(first.data, { version: 4, status: 'submitted' });
next.saveCommands = [];
h.render(next);
assert.equal(h.edit.editable, false);
assert.equal(h.edit.canRecoverSave, true);
assert.ok(
nodes(h.panel()).some(
(n) =>
n.type === 'button' && text(n) === 'Recuperar a mesma gravação',
),
);
h.edit.recoverSave();
assert.deepEqual(h.sent[1].data, first.data);
h.success(1, saved(first.data, { version: 4, status: 'submitted' }));
assert.equal(h.edit.retryingSave, false);
assert.equal(h.edit.editable, false);
});
test('retired catalogue selections remain in the frozen recovery command', () => {
const h = harness();
h.payload('mandatory', ['capacity']);
h.edit.save();
const first = h.sent[0];
first.options.onNetworkError();
h.render({ ...props(), types: {}, criteria: {} });
assert.ok(
h
.all()
.some(
(node) =>
node.type === 'option' &&
text(node).includes('Tipo anteriormente indicado'),
),
);
const remove = h
.all()
.find(
(node) =>
node.type === 'button' &&
text(node) === 'Retirar da preparação',
);
assert.ok(remove);
remove.props.onClick();
h.render();
assert.deepEqual(h.data.payload.mandatory, []);
h.edit.recoverSave();
assert.deepEqual(h.sent[1].data, first.data);
assert.deepEqual(h.sent[1].data.payload.mandatory, ['capacity']);
h.success(1, saved(h.sent[1].data));
assert.deepEqual(h.data.payload.mandatory, []);
});
test('cancelled uncertain edits remain recoverable and protect navigation even with unchanged values', () => {
const h = harness();
h.edit.save();
h.sent[0].options.onCancel();
h.render();
assert.equal(h.edit.retryingSave, true);
assert.equal(h.edit.dirty, true);
assert.equal(h.before(), true);
assert.equal(h.unload(), true);
});
test('empty API dictionaries are valid while malformed operation metadata suspends writing', () => {
const p = props();
p.record.payload = [];
const h = harness(p);
assert.equal(h.edit.invalid, false);
h.set('title', 'SYNTHETIC draft without payload');
h.edit.save();
assert.equal(h.sent.length, 1);
const invalid = harness(props({ saveCommands: null }));
assert.equal(invalid.edit.invalid, true);
invalid.edit.save();
assert.equal(invalid.sent.length, 0);
});
const key = '11111111-1111-4111-8111-111111111111';
const catalogues = {
types: { joint_venture: 'Consórcio' },
criteria: {
capacity: 'Capacidade',
experience: 'Experiência',
impact: 'Impacto',
},
sectors: [{ id: 1, name: 'Agricultura' }],
territories: [{ id: 'place', name: 'Local de ensaio' }],
organizations: [],
sources: [],
};
function props(patch = {}) {
return {
...catalogues,
editorActorId: 2,
saveCommands: [],
record: {
id: 1,
version: 2,
status: 'draft',
client_key: key,
title: 'SYNTHETIC original',
offer: 'SYNTHETIC offer',
seeking: 'SYNTHETIC collaboration',
organization_id: null,
sector_id: 1,
kind: 'seeking',
partnership_type: 'joint_venture',
deadline: null,
payload: {
summary: 'SYNTHETIC summary',
requirements: 'SYNTHETIC requirements',
mandatory: ['capacity'],
confidentiality: 'private',
consent: true,
availability: 'SYNTHETIC November availability',
},
},
origins: [],
locations: [],
...patch,
};
}
function saved(data, { id = 1, version = 3, status = 'draft' } = {}) {
const trim = (value) => (typeof value === 'string' ? value.trim() : value);
const record = {
id,
version,
status,
client_key: data.client_key,
title: trim(data.title),
offer: trim(data.offer),
seeking: trim(data.seeking),
organization_id: data.organization_id || null,
sector_id: data.sector_id ? Number(data.sector_id) : null,
kind: data.kind,
partnership_type: data.partnership_type || null,
deadline: data.deadline || null,
payload: Object.fromEntries(
Object.entries(data.payload).map(([k, v]) => [k, trim(v)]),
),
};
return props({
saveCommands: data.operation_key
? [{ key: data.operation_key, version: Number(data.version) + 1 }]
: [],
record,
origins: clone(data.origins),
locations: data.locations.map((item) => ({
...item,
source_reference: trim(item.source_reference),
})),
});
}
function nodes(tree) {
return Array.isArray(tree)
? tree.flatMap(nodes)
: tree && typeof tree === 'object'
? [tree, ...nodes(tree.props?.children)]
: [];
}
function text(tree) {
return typeof tree === 'string'
? tree
: Array.isArray(tree)
? tree.map(text).join('')
: tree?.props
? text(tree.props.children)
: typeof tree === 'number'
? String(tree)
: '';
}
function harness(initial = props()) {
let cells = [],
forms = [],
cursor = 0,
formCursor = 0,
currentProps = initial,
currentKey,
initialized = false,
tree,
editor;
const sent = [],
reloads = [],
effects = [],
before = new Set(),
unload = new Set();
let confirmCalls = 0,
operationSequence = 0;
const windowMock = {
confirm() {
confirmCalls++;
return false;
},
addEventListener(name, fn) {
if (name === 'beforeunload') unload.add(fn);
},
removeEventListener(name, fn) {
if (name === 'beforeunload') unload.delete(fn);
},
};
const mocks = {
react: {
useRef(value) {
const i = cursor++;
cells[i] ??= { current: value };
return cells[i];
},
useState(value) {
const i = cursor++;
if (!(i in cells))
cells[i] = typeof value === 'function' ? value() : value;
return [
cells[i],
(value) => {
cells[i] =
typeof value === 'function'
? value(cells[i])
: value;
},
];
},
useEffect(callback, deps) {
const i = cursor++;
if (
!cells[i] ||
!deps.every((v, j) => v === cells[i].deps[j])
) {
cells[i]?.cleanup?.();
cells[i] = { deps };
effects.push(() => {
const original = globalThis.window;
globalThis.window = windowMock;
try {
cells[i].cleanup = callback();
} finally {
globalThis.window = original;
}
});
}
},
},
'@inertiajs/react': {
Link: 'a',
router: {
reload(options) {
reloads.push(options);
},
on(name, callback) {
if (name === 'before') before.add(callback);
return () => before.delete(callback);
},
},
useForm(initial) {
const i = formCursor++;
forms[i] ??= {
data: clone(initial),
errors: {},
processing: false,
setData(key, value) {
this.data =
typeof key === 'function'
? key(this.data)
: typeof key === 'object'
? key
: { ...this.data, [key]: value };
},
setDefaults(value) {
this.defaults = clone(value);
},
clearErrors() {
this.errors = {};
},
transform(callback) {
this.transformer = callback;
},
post(url, options) {
sent.push({
method: 'post',
url,
options,
data: clone(this.transformer(this.data)),
});
},
put(url, options) {
sent.push({
method: 'put',
url,
options,
data: clone(this.transformer(this.data)),
});
},
};
return forms[i];
},
},
'@/lib/operation-key': {
createOperationKey: () =>
`00000000-0000-4000-8000-${String(++operationSequence).padStart(12, '0')}`,
},
'./shared': {
Frame: 'main',
Field: 'label',
Errors: 'errors',
control: '',
button: '',
},
'@/pages/partnerships/shared': {
labels: {
draft: 'Rascunho',
submitted: 'Submetida',
published: 'Publicada',
archived: 'Arquivada',
},
},
};
const actual = load('../../resources/js/lib/partnership-edit.ts', mocks);
mocks['@/lib/partnership-edit'] = {
usePartnershipEdit(p) {
editor = actual.usePartnershipEdit(p);
return editor;
},
};
const Panel = load(
'../../resources/js/components/partnership-edit-comparison.tsx',
mocks,
).default;
mocks['@/components/partnership-edit-comparison'] = { default: Panel };
const Page = load(
'../../resources/js/pages/partnerships/editor.tsx',
mocks,
).default;
function render(next = currentProps) {
const original = globalThis.window;
globalThis.window = windowMock;
try {
currentProps = next;
const root = Page(next);
if (initialized && root.key !== currentKey) {
for (const cell of cells) cell?.cleanup?.();
cells = [];
forms = [];
}
currentKey = root.key;
initialized = true;
cursor = 0;
formCursor = 0;
tree = root.type(root.props);
while (effects.length) effects.shift()();
return editor;
} finally {
globalThis.window = original;
}
}
render();
return {
sent,
reloads,
render,
get edit() {
return editor;
},
get data() {
return editor.form.data;
},
all: () => nodes(tree),
panel: () => Panel({ editor, catalogues: currentProps }),
set(key, value) {
editor.setData(key, value);
render();
},
payload(key, value) {
editor.setData('payload', {
...editor.form.data.payload,
[key]: value,
});
render();
},
success(index, next) {
render(next);
sent[index].options.onSuccess({ props: next });
render();
},
before(preserveState = false) {
let prevented = false;
const original = globalThis.window;
globalThis.window = windowMock;
try {
for (const fn of before)
fn({
detail: { visit: { method: 'get', preserveState } },
preventDefault() {
prevented = true;
},
});
} finally {
globalThis.window = original;
}
return prevented;
},
unload() {
let prevented = false;
for (const fn of unload)
fn({
preventDefault() {
prevented = true;
},
returnValue: null,
});
return prevented;
},
get confirmCalls() {
return confirmCalls;
},
};
}
test('availability is presented and sent independently from the deadline', () => {
const h = harness();
assert.equal(
h.data.payload.availability,
'SYNTHETIC November availability',
);
const availability = h
.all()
.find(
(n) =>
n.type === 'textarea' &&
n.props.value === 'SYNTHETIC November availability',
);
assert.equal(availability.props.maxLength, 1000);
h.payload('availability', 'SYNTHETIC December resources');
h.edit.save();
assert.equal(
h.sent[0].data.payload.availability,
'SYNTHETIC December resources',
);
assert.equal(h.sent[0].data.deadline, '');
});
test('refreshing another version preserves edits and merges independent fields only after adoption', () => {
const h = harness();
h.set('title', 'SYNTHETIC deliberate title');
const next = props();
next.record.version = 3;
next.record.payload.availability = 'SYNTHETIC remote availability';
h.render(next);
assert.equal(h.data.title, 'SYNTHETIC deliberate title');
h.edit.save();
assert.equal(h.sent.length, 0);
assert.deepEqual(h.edit.unresolved, []);
h.edit.adopt();
h.render();
assert.equal(h.data.payload.availability, 'SYNTHETIC remote availability');
assert.equal(h.data.title, 'SYNTHETIC deliberate title');
assert.equal(h.data.version, 3);
});
test('choices for multiple conflicting sections are explicit and independent', () => {
const h = harness();
h.set('title', 'mine');
h.payload('availability', 'my availability');
const next = props();
next.record.version = 3;
next.record.title = 'remote';
next.record.payload.availability = 'remote availability';
h.render(next);
assert.equal(h.edit.unresolved.length, 2);
h.edit.choose('title', 'mine');
h.render();
h.edit.choose('availability', 'current');
h.render();
assert.deepEqual(h.edit.unresolved, []);
h.edit.adopt();
h.render();
assert.equal(h.data.title, 'mine');
assert.equal(h.data.payload.availability, 'remote availability');
});
test('returning deliberately to the original text does not count as permission to overwrite it', () => {
const h = harness();
h.set('title', 'temporary');
h.set('title', 'SYNTHETIC original');
const next = props();
next.record.version = 3;
next.record.title = 'remote';
h.render(next);
assert.deepEqual(h.edit.unresolved, ['title']);
assert.equal(h.edit.dirty, true);
h.edit.choose('title', 'mine');
h.render();
h.edit.adopt();
h.render();
assert.equal(h.data.title, 'SYNTHETIC original');
});
test('sets ignore database order while locations, origins and criteria keep their own conflicts', () => {
const initial = props({
origins: [
{ type: 'project', target_id: 'a' },
{ type: 'project', target_id: 'b' },
],
locations: [
{
geo_location_id: 'place',
role: 'primary',
source_reference: 'SYNTHETIC original reference',
},
],
});
const h = harness(initial);
h.payload('mandatory', ['capacity', 'impact']);
const next = clone(initial);
next.record.version = 3;
next.origins.reverse();
next.locations[0].source_reference = 'SYNTHETIC current source';
next.record.payload.mandatory = ['experience'];
h.render(next);
assert.ok(!h.edit.changed.includes('origins'));
assert.deepEqual(h.edit.unresolved, ['mandatory']);
h.edit.choose('mandatory', 'mine');
h.render();
h.edit.adopt();
h.render();
assert.deepEqual(h.data.payload.mandatory, ['capacity', 'impact']);
assert.equal(
h.data.locations[0].source_reference,
'SYNTHETIC current source',
);
});
test('success uses normalized server values while preserving typing during the request', () => {
const h = harness();
h.set('title', ' SYNTHETIC saved title ');
h.edit.save();
h.set('offer', 'SYNTHETIC after send');
const next = saved(h.sent[0].data);
h.success(0, next);
assert.equal(h.data.title, 'SYNTHETIC saved title');
assert.equal(h.data.offer, 'SYNTHETIC after send');
assert.equal(h.data.version, 3);
assert.equal(h.edit.dirty, true);
});
test('new creation becomes the same editor with an assigned identity without losing in-flight edits', () => {
const h = harness(props({ record: null }));
h.set('title', 'SYNTHETIC first draft');
h.edit.save();
h.set('title', 'SYNTHETIC typed during creation');
h.success(0, saved(h.sent[0].data, { id: 73, version: 2 }));
assert.equal(h.data.title, 'SYNTHETIC typed during creation');
h.edit.save();
assert.equal(h.sent[1].method, 'put');
assert.equal(h.sent[1].url, '/parcerias/73');
assert.equal(h.sent[1].data.version, 2);
});
test('an unconfirmed creation repeats the exact original command and keeps edits made before retry', () => {
const h = harness(props({ record: null }));
h.set('title', 'SYNTHETIC original command');
h.edit.save();
h.sent[0].options.onNetworkError(new Error('synthetic timeout'));
h.render();
h.set('title', 'SYNTHETIC later draft');
h.edit.save();
assert.deepEqual(h.sent[1].data, h.sent[0].data);
h.success(1, saved(h.sent[1].data, { id: 74, version: 2 }));
assert.equal(h.data.title, 'SYNTHETIC later draft');
h.edit.save();
assert.equal(h.sent[2].url, '/parcerias/74');
assert.equal(h.sent[2].data.title, 'SYNTHETIC later draft');
});
test('a version conflict is handled without discarding fields or showing a generic HTTP modal', () => {
const h = harness();
h.set('title', 'draft');
h.edit.save();
assert.equal(h.sent[0].options.onHttpException({ status: 409 }), false);
h.render();
assert.equal(h.edit.blocked, true);
h.edit.save();
assert.equal(h.sent.length, 1);
h.edit.refresh();
assert.deepEqual(h.reloads[0].only, [
'record',
'locations',
'origins',
'editorActorId',
'saveCommands',
]);
h.reloads[0].onSuccess({ props: props() });
h.reloads[0].onFinish();
h.render();
assert.equal(h.data.title, 'draft');
assert.equal(h.edit.blocked, false);
});
test('submitted or archived proposals preserve the draft but disable writing in the existing workflow', () => {
for (const status of ['submitted', 'archived']) {
const h = harness();
h.set('title', 'unsaved');
const next = props();
next.record.version = 3;
next.record.status = status;
h.render(next);
assert.equal(h.data.title, 'unsaved');
assert.equal(h.edit.editable, false);
assert.equal(
h.all().find((n) => n.type === 'fieldset').props.disabled,
true,
);
h.edit.save();
assert.equal(h.sent.length, 0);
assert.match(text(h.panel()), /não pode ser gravada neste estado/);
}
});
test('a newer server revision after saving requires comparison instead of acknowledging another persons edits', () => {
const h = harness();
h.set('title', 'SYNTHETIC sent');
h.edit.save();
h.set('offer', 'SYNTHETIC in flight');
const next = saved(h.sent[0].data, { version: 4 });
next.record.title = 'SYNTHETIC another author';
h.success(0, next);
assert.equal(h.data.title, 'SYNTHETIC sent');
assert.equal(h.data.offer, 'SYNTHETIC in flight');
assert.equal(h.edit.blocked, true);
assert.ok(h.edit.changed.includes('title'));
assert.ok(!h.edit.unresolved.includes('title'));
h.edit.adopt();
h.render();
assert.equal(h.data.version, 4);
assert.equal(h.data.title, 'SYNTHETIC another author');
assert.equal(h.data.offer, 'SYNTHETIC in flight');
});
test('unsaved navigation and unload are protected while a preserving refresh stays available', () => {
const h = harness();
assert.equal(h.unload(), false);
h.set('title', 'dirty');
assert.equal(h.unload(), true);
assert.equal(h.before(), true);
assert.equal(h.confirmCalls, 1);
assert.equal(h.before(true), false);
h.edit.save();
h.success(0, saved(h.sent[0].data));
assert.equal(h.unload(), false);
assert.equal(h.before(), false);
});
test('wrong or malformed responses cannot reset or submit another record', () => {
const h = harness();
h.set('title', 'draft');
h.edit.save();
const wrong = saved(h.sent[0].data, { id: 99 });
h.success(0, wrong);
assert.equal(h.data.title, 'draft');
assert.equal(h.edit.invalid, true);
h.edit.save();
assert.equal(h.sent.length, 1);
const invalid = harness();
const bad = props();
bad.record.version = '3';
invalid.render(bad);
assert.equal(invalid.edit.invalid, true);
invalid.edit.save();
assert.equal(invalid.sent.length, 0);
});
test('published revision impact and an unavailable prior sector are visible without silently changing values', () => {
const initial = props();
initial.record.status = 'published';
initial.record.sector_id = 99;
const h = harness(initial);
assert.match(text(h.panel()), /volta a rascunho/);
assert.match(text(h.panel()), /nova submissão e revisão/);
assert.ok(
h.all().some((n) => n.type === 'option' && n.props.value === '99'),
);
assert.equal(h.data.sector_id, '99');
assert.ok(
h
.all()
.some(
(n) =>
n.type === 'button' &&
text(n).includes('Guardar revisão em rascunho'),
),
);
});
test('comparison actions cannot accidentally submit the form', () => {
const h = harness();
h.set('title', 'mine');
const next = props();
next.record.version = 3;
next.record.title = 'remote';
h.render(next);
const buttons = nodes(h.panel()).filter((n) => n.type === 'button');
assert.ok(buttons.every((n) => n.props.type === 'button'));
assert.equal(buttons.at(-1).props.disabled, true);
assert.match(text(h.panel()), /Actualmente guardado/);
});
test('batched payload edits and removals preserve all deliberate changes', () => {
const h = harness(
props({
locations: [
{
geo_location_id: 'place-a',
role: 'primary',
source_reference: 'SYNTHETIC a',
},
{
geo_location_id: 'place-b',
role: 'coverage',
source_reference: 'SYNTHETIC b',
},
],
origins: [
{ type: 'project', target_id: 'a' },
{ type: 'project', target_id: 'b' },
],
}),
);
h.edit.setData('payload', (previous) => ({
...previous,
summary: 'SYNTHETIC first change',
}));
h.edit.setData('payload', (previous) => ({
...previous,
availability: 'SYNTHETIC second change',
}));
h.render();
assert.equal(h.data.payload.summary, 'SYNTHETIC first change');
assert.equal(h.data.payload.availability, 'SYNTHETIC second change');
const remove = h
.all()
.filter((node) => node.type === 'button' && text(node) === 'Remover');
assert.equal(remove.length, 4);
for (const button of remove) button.props.onClick();
h.render();
assert.deepEqual(h.data.locations, []);
assert.deepEqual(h.data.origins, []);
});