368 lines
12 KiB
JavaScript
368 lines
12 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 { renderToStaticMarkup } from 'react-dom/server';
|
|
import ts from 'typescript';
|
|
|
|
const require = createRequire(import.meta.url);
|
|
function load(path, mocks) {
|
|
const output = ts.transpileModule(
|
|
fs.readFileSync(new URL(path, import.meta.url), 'utf8'),
|
|
{
|
|
compilerOptions: {
|
|
module: ts.ModuleKind.CommonJS,
|
|
target: ts.ScriptTarget.ES2022,
|
|
jsx: ts.JsxEmit.ReactJSX,
|
|
},
|
|
},
|
|
).outputText;
|
|
const module = { exports: {} };
|
|
compileFunction(output, ['require', 'module', 'exports'])(
|
|
(name) => mocks[name] ?? require(name),
|
|
module,
|
|
module.exports,
|
|
);
|
|
return module.exports;
|
|
}
|
|
function nodes(tree) {
|
|
if (!tree || typeof tree !== 'object') return [];
|
|
if (Array.isArray(tree)) return tree.flatMap(nodes);
|
|
return [tree, ...nodes(tree.props?.children)];
|
|
}
|
|
const emptyProps = {
|
|
today: '2026-11-02',
|
|
territories: [],
|
|
projects: [],
|
|
courses: [],
|
|
occupations: [],
|
|
skills: [],
|
|
draftsAvailable: true,
|
|
};
|
|
class ApiError extends Error {
|
|
constructor(status) {
|
|
super('Synthetic error');
|
|
this.status = status;
|
|
this.errors = {};
|
|
}
|
|
}
|
|
function setup(request, props = emptyProps, renderPage = false) {
|
|
const states = [];
|
|
let cursor = 0,
|
|
form;
|
|
const Snapshot = () => null;
|
|
const saved = [],
|
|
dirty = [];
|
|
const module = load('../../resources/js/pages/education/index.tsx', {
|
|
react: {
|
|
useState(initial) {
|
|
const index = cursor++;
|
|
if (!(index in states))
|
|
states[index] =
|
|
typeof initial === 'function' ? initial() : initial;
|
|
return [
|
|
states[index],
|
|
(value) => {
|
|
states[index] =
|
|
typeof value === 'function'
|
|
? value(states[index])
|
|
: value;
|
|
},
|
|
];
|
|
},
|
|
useEffect(fn) {
|
|
if (!renderPage) fn();
|
|
},
|
|
useCallback(fn) {
|
|
return fn;
|
|
},
|
|
},
|
|
'@inertiajs/react': {
|
|
Link: 'a',
|
|
router: {},
|
|
useForm(initial) {
|
|
if (renderPage)
|
|
return {
|
|
data: initial,
|
|
errors: {},
|
|
processing: false,
|
|
isDirty: false,
|
|
};
|
|
form ??= {
|
|
data: { ...initial },
|
|
defaults: { ...initial },
|
|
errors: {},
|
|
processing: false,
|
|
get isDirty() {
|
|
return (
|
|
JSON.stringify(this.data) !==
|
|
JSON.stringify(this.defaults)
|
|
);
|
|
},
|
|
setData(key, value) {
|
|
this.data = { ...this.data, [key]: value };
|
|
},
|
|
setDefaults(value) {
|
|
this.defaults = { ...value };
|
|
},
|
|
clearErrors() {
|
|
this.errors = {};
|
|
},
|
|
setError(errors) {
|
|
this.errors = errors;
|
|
},
|
|
post() {
|
|
throw new Error('Draft must not use legacy create');
|
|
},
|
|
};
|
|
return form;
|
|
},
|
|
},
|
|
'@/lib/portal-api': {
|
|
portalRequest: request,
|
|
PortalApiError: ApiError,
|
|
newSubmissionKey: () => 'synthetic-stable-key',
|
|
},
|
|
'@/components/education-submitted-snapshot': {
|
|
__esModule: true,
|
|
default: Snapshot,
|
|
},
|
|
'../management/shared': {
|
|
Management: 'main',
|
|
Field: 'label',
|
|
control: '',
|
|
button: '',
|
|
Pagination: () => null,
|
|
},
|
|
'@/components/investment-territory': {
|
|
TerritoryFields: 'territory-fields',
|
|
},
|
|
});
|
|
return {
|
|
page() {
|
|
cursor = 0;
|
|
return module.default(props);
|
|
},
|
|
render() {
|
|
cursor = 0;
|
|
return module.Intake({
|
|
kind: 'demands',
|
|
props,
|
|
onSaved: (item) => saved.push(item),
|
|
onDirty: (kind, value) => dirty.push([kind, value]),
|
|
});
|
|
},
|
|
get form() {
|
|
return form;
|
|
},
|
|
saved,
|
|
dirty,
|
|
Snapshot,
|
|
};
|
|
}
|
|
const saveButton = (tree) =>
|
|
nodes(tree).find(
|
|
(node) => node.type === 'button' && node.props.type === 'button',
|
|
);
|
|
const flush = () => new Promise((resolve) => setImmediate(resolve));
|
|
|
|
await test('the full education page renders the private draft list and both intake forms', () => {
|
|
const component = setup(
|
|
async () => {},
|
|
{
|
|
...emptyProps,
|
|
filters: {},
|
|
providers: [],
|
|
demands: { data: [] },
|
|
capacities: { data: [] },
|
|
indicators: {
|
|
demand_records: 0,
|
|
capacity_records: 0,
|
|
expired_demands: 0,
|
|
pending_demands: 0,
|
|
},
|
|
ownDrafts: {
|
|
data: [
|
|
{
|
|
id: 'qa-draft',
|
|
kind: 'demands',
|
|
state: 'draft',
|
|
version_no: 1,
|
|
label: 'Synthetic qualification',
|
|
updated_at: '2026-11-02T12:00:00Z',
|
|
},
|
|
],
|
|
},
|
|
},
|
|
true,
|
|
);
|
|
const html = renderToStaticMarkup(component.page());
|
|
assert.ok(html.includes('Os meus rascunhos e submissões'));
|
|
assert.ok(html.includes('/management/education?draft=qa-draft'));
|
|
assert.ok(html.includes('Synthetic qualification'));
|
|
assert.ok(html.includes('Declarar procura de trabalhadores'));
|
|
assert.ok(html.includes('Declarar vagas de formação'));
|
|
});
|
|
|
|
await test('incomplete save bypasses native required fields, keeps edits, and submits the saved identity with current values', async () => {
|
|
const requests = [];
|
|
const component = setup(async (url, options) => {
|
|
const data = JSON.parse(options.body);
|
|
requests.push({ url, method: options.method, data });
|
|
return {
|
|
id: 'qa-draft',
|
|
kind: 'demands',
|
|
version_no: data.version_no + 1,
|
|
state: url.endsWith('/submit') ? 'submitted' : 'draft',
|
|
payload: data.payload,
|
|
references: {},
|
|
record_id: null,
|
|
updated_at: '2026-11-02T12:00:00Z',
|
|
};
|
|
});
|
|
let tree = component.render();
|
|
component.form.setData('employer', 'Synthetic incomplete employer');
|
|
tree = component.render();
|
|
assert.equal(saveButton(tree).props.type, 'button');
|
|
saveButton(tree).props.onClick();
|
|
await flush();
|
|
assert.equal(requests[0].method, 'POST');
|
|
assert.equal(requests[0].data.payload.qualification, '');
|
|
assert.equal(component.form.data.employer, 'Synthetic incomplete employer');
|
|
assert.equal(component.form.isDirty, false);
|
|
tree = component.render();
|
|
component.form.setData('quantity', '12');
|
|
tree = component.render();
|
|
saveButton(tree).props.onClick();
|
|
await flush();
|
|
assert.equal(requests[1].method, 'PUT');
|
|
assert.equal(requests[1].data.version_no, 1);
|
|
assert.equal(requests[1].data.client_key, requests[0].data.client_key);
|
|
tree = component.render();
|
|
nodes(tree)
|
|
.find((node) => node.type === 'form')
|
|
.props.onSubmit({ preventDefault() {} });
|
|
await flush();
|
|
assert.equal(
|
|
requests[2].url,
|
|
'/management/education/drafts/demands/qa-draft/submit',
|
|
);
|
|
assert.equal(requests[2].data.version_no, 2);
|
|
assert.equal(requests[2].data.payload.quantity, '12');
|
|
tree = component.render();
|
|
assert.equal(tree.type, component.Snapshot);
|
|
assert.equal(tree.props.draft.state, 'submitted');
|
|
assert.equal(component.saved.length, 3);
|
|
});
|
|
|
|
await test('recovery preserves blank dates and reference IDs, and a conflict does not overwrite current edits', async () => {
|
|
const component = setup(
|
|
async () => {
|
|
throw new ApiError(409);
|
|
},
|
|
{
|
|
...emptyProps,
|
|
selectedDraft: {
|
|
id: 'recovered',
|
|
kind: 'demands',
|
|
version_no: 4,
|
|
state: 'draft',
|
|
payload: {
|
|
employer: 'Old',
|
|
reference_date: null,
|
|
geo_location_id: 'retired-location',
|
|
},
|
|
references: null,
|
|
},
|
|
},
|
|
);
|
|
let tree = component.render();
|
|
assert.equal(component.form.data.reference_date, '');
|
|
assert.equal(component.form.data.geo_location_id, 'retired-location');
|
|
component.form.setData('employer', 'Deliberate current edit');
|
|
tree = component.render();
|
|
saveButton(tree).props.onClick();
|
|
await flush();
|
|
tree = component.render();
|
|
assert.equal(component.form.data.employer, 'Deliberate current edit');
|
|
assert.equal(component.form.isDirty, true);
|
|
assert.ok(
|
|
nodes(tree).some(
|
|
(node) =>
|
|
node.props?.role === 'alert' &&
|
|
String(node.props.children).includes('noutra sessão'),
|
|
),
|
|
);
|
|
assert.equal(component.saved.length, 0);
|
|
});
|
|
|
|
await test('recovered territorial path remains stable while unrelated fields are edited', () => {
|
|
const territories = [
|
|
{
|
|
id: 'p',
|
|
name: 'Province',
|
|
parent_id: null,
|
|
location_type: 'province',
|
|
},
|
|
{
|
|
id: 'd',
|
|
name: 'District',
|
|
parent_id: 'p',
|
|
location_type: 'district',
|
|
},
|
|
];
|
|
const component = setup(async () => {}, {
|
|
...emptyProps,
|
|
territories,
|
|
selectedDraft: {
|
|
id: 'qa',
|
|
kind: 'demands',
|
|
version_no: 1,
|
|
state: 'draft',
|
|
payload: { geo_location_id: 'd' },
|
|
},
|
|
});
|
|
let tree = component.render();
|
|
const initial = nodes(tree).find((node) => node.type === 'territory-fields')
|
|
.props.initial;
|
|
assert.deepEqual(
|
|
initial.map((item) => item.id),
|
|
['p', 'd'],
|
|
);
|
|
component.form.setData('employer', 'New text');
|
|
tree = component.render();
|
|
assert.equal(
|
|
nodes(tree).find((node) => node.type === 'territory-fields').props
|
|
.initial,
|
|
initial,
|
|
);
|
|
});
|
|
|
|
await test('submitted view uses preserved reference names and escapes source text without another submission form', () => {
|
|
const Snapshot = load(
|
|
'../../resources/js/components/education-submitted-snapshot.tsx',
|
|
{ '@inertiajs/react': { Link: 'a' } },
|
|
).default;
|
|
const html = renderToStaticMarkup(
|
|
Snapshot({
|
|
draft: {
|
|
kind: 'demands',
|
|
record_id: 'qa-record',
|
|
payload: { quantity: 20, source: '<script>synthetic</script>' },
|
|
references: {
|
|
project_id: { name: 'Original project name' },
|
|
territory_path: [{ name: 'Original district' }],
|
|
},
|
|
},
|
|
}),
|
|
);
|
|
assert.ok(html.includes('Original project name'));
|
|
assert.ok(html.includes('Original district'));
|
|
assert.ok(html.includes('<script>'));
|
|
assert.ok(!html.includes('<form'));
|
|
assert.ok(
|
|
html.includes('/management/education/demands/qa-record/analysis'),
|
|
);
|
|
});
|