879 lines
30 KiB
JavaScript
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, []);
|
|
});
|