@bongos/core 1.24.27 → 1.24.28
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.bongos-core.json +40 -35
- package/docs/copy-inventory.md +175 -175
- package/docs/copy-registry.json +280 -280
- package/docs/module-api-changelog.md +2 -0
- package/docs/page-inventory.json +9 -3
- package/docs/page-readings.json +277 -214
- package/modules/hall-ui/public/my-agents.css +72 -1
- package/modules/hall-ui/public/my-agents.html +30 -6
- package/modules/hall-ui/public/my-agents.js +63 -54
- package/modules/hall-ui/public/my-agents.states.json +18 -0
- package/modules/hall-ui/public/settings.css +2 -132
- package/modules/hall-ui/public/settings.html +8 -48
- package/modules/hall-ui/public/speciality-tools.css +140 -0
- package/modules/hall-ui/public/{settings-specialities.js → speciality-tools.js} +83 -6
- package/modules/hall-ui/records/my-agents.md +4 -2
- package/modules/hall-ui/records/panel-family.md +2 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +6 -0
- package/scripts/hall-preview/server.js +4 -0
- package/src/module-api.js +1 -1
- package/tests/hall_loading.mjs +3 -1
- package/tests/hall_my_agents.mjs +104 -2
- package/tests/helpers.mjs +161 -0
- package/tests/speciality_offer_ui.mjs +182 -149
- package/tests/speciality_walkthrough_ui.mjs +180 -161
package/tests/helpers.mjs
CHANGED
|
@@ -1015,6 +1015,167 @@ export function bootSkyPage({
|
|
|
1015
1015
|
return out;
|
|
1016
1016
|
}
|
|
1017
1017
|
|
|
1018
|
+
// bootSpecialityTools — boot THE SPECIALITY TOOLS for real on a page's OWN markup
|
|
1019
|
+
// (task 1005052). One component, modules/hall-ui/public/speciality-tools.js, is
|
|
1020
|
+
// mounted by Settings and by My agents, and it finds every host by id. So, on the
|
|
1021
|
+
// bootSkyPage rule, the DOM here is the page's own: its specialities section
|
|
1022
|
+
// (`#specialities-scroll`) is parsed out of `<page>.html` into the mini-DOM above,
|
|
1023
|
+
// an id the page does not carry is null here too, and an id the component renders
|
|
1024
|
+
// itself (the create form's skills picker) exists only once its markup says so. A
|
|
1025
|
+
// host lost from either page fails that page's run.
|
|
1026
|
+
//
|
|
1027
|
+
// The fake API keeps the builder's adoptions as STATE: adopt adds one, abandon
|
|
1028
|
+
// drops it, the walkthrough's PUT sets its enabled skills, so a reload after a
|
|
1029
|
+
// write reads what the write did. Clicks, submits, typing and the details toggle
|
|
1030
|
+
// are dispatched on the real parsed element and bubble to the listener the
|
|
1031
|
+
// component wired, so `closest()` in its handlers walks the real tree.
|
|
1032
|
+
//
|
|
1033
|
+
// const h = await bootSpecialityTools({ page: 'my-agents', catalog, adoptions });
|
|
1034
|
+
// await h.click('#specialities-rows button[data-act="adopt"]');
|
|
1035
|
+
// h.writes() → the non-GET requests, with their bodies
|
|
1036
|
+
// h.text('specialities-rows'), h.toasts, h.events (the DOM events it dispatched)
|
|
1037
|
+
export const SPECIALITY_PAGES = ['settings', 'my-agents'];
|
|
1038
|
+
function specialitySection(page) {
|
|
1039
|
+
const html = fs.readFileSync(path.join(ROOT, 'modules/hall-ui/public', `${page}.html`), 'utf8').replace(/<!--[\s\S]*?-->/g, '');
|
|
1040
|
+
const at = html.indexOf('id="specialities-scroll"');
|
|
1041
|
+
if (at < 0) return '';
|
|
1042
|
+
const start = html.lastIndexOf('<section', at);
|
|
1043
|
+
return html.slice(start, html.indexOf('</section>', at) + '</section>'.length);
|
|
1044
|
+
}
|
|
1045
|
+
export function bootSpecialityTools({
|
|
1046
|
+
page = 'settings', catalog = [], adoptions = [], suggestions = [], skills = {}, installed = [], answer = null,
|
|
1047
|
+
} = {}) {
|
|
1048
|
+
const src = fs.readFileSync(path.join(ROOT, 'modules/hall-ui/public/speciality-tools.js'), 'utf8');
|
|
1049
|
+
let focused = null;
|
|
1050
|
+
const docListeners = new Map();
|
|
1051
|
+
function node(tag, attrs = {}) {
|
|
1052
|
+
const el = makeEl(tag, attrs);
|
|
1053
|
+
el.hidden = 'hidden' in attrs;
|
|
1054
|
+
el.open = 'open' in attrs;
|
|
1055
|
+
el.checked = 'checked' in attrs;
|
|
1056
|
+
el.focus = () => { focused = el; };
|
|
1057
|
+
el.matches = (sel) => matches(el, sel);
|
|
1058
|
+
// `:checked` is the one pseudo-class the component reads (the ticked skills)
|
|
1059
|
+
const all = el.querySelectorAll.bind(el);
|
|
1060
|
+
el.querySelectorAll = (sel) => (String(sel).endsWith(':checked') ? all(String(sel).slice(0, -':checked'.length)).filter((x) => x.checked) : all(sel));
|
|
1061
|
+
if (tag === 'form') {
|
|
1062
|
+
Object.defineProperty(el, 'elements', {
|
|
1063
|
+
get() { const o = {}; for (const f of el.querySelectorAll('input, select, textarea')) if (f.name) o[f.name] = f; return o; },
|
|
1064
|
+
});
|
|
1065
|
+
el.reset = () => { for (const f of el.querySelectorAll('input, select, textarea')) { f._value = null; f.checked = 'checked' in f.attrs; } };
|
|
1066
|
+
}
|
|
1067
|
+
return el;
|
|
1068
|
+
}
|
|
1069
|
+
function parse(host, html) {
|
|
1070
|
+
host.children = []; host._text = '';
|
|
1071
|
+
const stack = [host];
|
|
1072
|
+
const re = /<(\/?)([a-zA-Z][\w-]*)([^>]*?)(\/?)>/g;
|
|
1073
|
+
let last = 0; let m;
|
|
1074
|
+
const addText = (raw) => { if (raw) stack[stack.length - 1]._text += decodeEntities(raw); };
|
|
1075
|
+
while ((m = re.exec(html))) {
|
|
1076
|
+
addText(html.slice(last, m.index)); last = re.lastIndex;
|
|
1077
|
+
const tag = m[2].toLowerCase();
|
|
1078
|
+
if (m[1]) { for (let i = stack.length - 1; i > 0; i--) if (stack[i].tagName === tag) { stack.length = i; break; } continue; }
|
|
1079
|
+
const el = node(tag, parseAttrs(m[3]));
|
|
1080
|
+
stack[stack.length - 1].appendChild(el);
|
|
1081
|
+
if (!m[4] && !VOID_TAGS.has(tag)) stack.push(el);
|
|
1082
|
+
}
|
|
1083
|
+
addText(html.slice(last));
|
|
1084
|
+
}
|
|
1085
|
+
const patchInnerHtml = (el) => Object.defineProperty(el, 'innerHTML', {
|
|
1086
|
+
get() { return this._html === null ? '' : this._html; },
|
|
1087
|
+
set(v) { this._html = String(v); this._value = null; parse(this, this._html); walk(this, (c) => { patchInnerHtml(c); return null; }); },
|
|
1088
|
+
configurable: true,
|
|
1089
|
+
});
|
|
1090
|
+
const root = node('body', {});
|
|
1091
|
+
patchInnerHtml(root);
|
|
1092
|
+
parse(root, specialitySection(page));
|
|
1093
|
+
walk(root, (c) => { patchInnerHtml(c); return null; });
|
|
1094
|
+
const hostIds = [];
|
|
1095
|
+
walk(root, (c) => { if (c.id) hostIds.push(c.id); return null; });
|
|
1096
|
+
const byId = (id) => walk(root, (c) => (c.id === id ? c : null));
|
|
1097
|
+
const query = (sel) => {
|
|
1098
|
+
let scope = [root];
|
|
1099
|
+
for (const part of String(sel).trim().split(/\s+/)) {
|
|
1100
|
+
const next = [];
|
|
1101
|
+
for (const s of scope) walk(s, (c) => { if (matches(c, part)) next.push(c); return null; });
|
|
1102
|
+
scope = next;
|
|
1103
|
+
if (!scope.length) return null;
|
|
1104
|
+
}
|
|
1105
|
+
return scope[0];
|
|
1106
|
+
};
|
|
1107
|
+
function dispatch(target, type) {
|
|
1108
|
+
const ev = { type, target, defaultPrevented: false, preventDefault() { this.defaultPrevented = true; } };
|
|
1109
|
+
for (let n = target; n; n = n.parentNode) (n.listeners[type] || []).forEach((fn) => fn(ev));
|
|
1110
|
+
(docListeners.get(type) || []).forEach((fn) => fn(ev));
|
|
1111
|
+
return ev;
|
|
1112
|
+
}
|
|
1113
|
+
const events = [];
|
|
1114
|
+
const documentObj = {
|
|
1115
|
+
readyState: 'complete',
|
|
1116
|
+
getElementById: byId,
|
|
1117
|
+
querySelector: query,
|
|
1118
|
+
addEventListener: (type, fn) => { if (!docListeners.has(type)) docListeners.set(type, []); docListeners.get(type).push(fn); },
|
|
1119
|
+
dispatchEvent: (ev) => { events.push(ev.type); return true; },
|
|
1120
|
+
};
|
|
1121
|
+
|
|
1122
|
+
// ── the API: the catalogue fixed, the builder's adoptions as state ─────────
|
|
1123
|
+
const requests = [];
|
|
1124
|
+
const mine = adoptions.map((a) => ({ ...a }));
|
|
1125
|
+
const fetchFn = async (url, opts = {}) => {
|
|
1126
|
+
const method = opts.method || 'GET';
|
|
1127
|
+
const p = String(url).replace('/api/bongos', '');
|
|
1128
|
+
const body = opts.body ? JSON.parse(opts.body) : undefined;
|
|
1129
|
+
requests.push({ method, path: p, body });
|
|
1130
|
+
const custom = answer && answer(method, p, body);
|
|
1131
|
+
let status = 200;
|
|
1132
|
+
let data = {};
|
|
1133
|
+
const id = (/^\/specialities\/(\d+)/.exec(p) || [])[1];
|
|
1134
|
+
if (custom) ({ status, data } = custom);
|
|
1135
|
+
else if (method === 'GET' && p === '/specialities') data = { specialities: catalog };
|
|
1136
|
+
else if (method === 'GET' && p === '/me/specialities') data = { adoptions: mine.map((a) => ({ ...a })) };
|
|
1137
|
+
else if (method === 'GET' && p === '/specialities/offered') data = { suggestions };
|
|
1138
|
+
else if (method === 'GET' && p === '/specialities/installed-skills') data = { skills: installed };
|
|
1139
|
+
else if (method === 'GET' && /\/skills$/.test(p)) { data = skills[id] || {}; status = skills[id] ? 200 : 404; }
|
|
1140
|
+
else if (method === 'POST' && /\/adopt$/.test(p)) { if (!mine.some((a) => String(a.id) === id)) mine.push({ id: Number(id), active: true, enabled_skills: [] }); data = { adoption: { active: true } }; }
|
|
1141
|
+
else if (method === 'POST' && /\/abandon$/.test(p)) { const was = mine.some((a) => String(a.id) === id); mine.splice(0, mine.length, ...mine.filter((a) => String(a.id) !== id)); data = { ok: true, was_active: was }; }
|
|
1142
|
+
else if (method === 'POST' && /\/documents$/.test(p)) { status = 201; data = { document: { id: 1, title: body.title } }; }
|
|
1143
|
+
else if (method === 'PUT' && /\/skills$/.test(p)) { mine.forEach((a) => { if (String(a.id) === id) a.enabled_skills = body.enabled_skills; }); data = { adoption: { enabled_skills: body.enabled_skills } }; }
|
|
1144
|
+
else if (method === 'PATCH') data = { speciality: { id: Number(id) } };
|
|
1145
|
+
else if (method === 'POST' && p === '/specialities') { status = 201; data = { speciality: { id: 13 } }; }
|
|
1146
|
+
else status = 404;
|
|
1147
|
+
return { ok: status < 400, status, json: async () => data };
|
|
1148
|
+
};
|
|
1149
|
+
|
|
1150
|
+
const toasts = [];
|
|
1151
|
+
const out = { page, hostIds, requests, toasts, events, navigated: null };
|
|
1152
|
+
const windowObj = {
|
|
1153
|
+
OTB: { ...realOTB(), toast: (m) => toasts.push(m) },
|
|
1154
|
+
location: { assign: (u) => { out.navigated = u; } },
|
|
1155
|
+
};
|
|
1156
|
+
class CustomEvent { constructor(type, init) { this.type = type; this.detail = init && init.detail; } }
|
|
1157
|
+
const sandbox = { window: windowObj, document: documentObj, fetch: fetchFn, CustomEvent, JSON, Promise, Map, Set, String, Number, Array, Object, Math, RegExp, Error, console, setTimeout };
|
|
1158
|
+
vm.createContext(sandbox);
|
|
1159
|
+
vm.runInContext(src, sandbox, { filename: 'speciality-tools.js' });
|
|
1160
|
+
|
|
1161
|
+
const settleAll = async () => { for (let i = 0; i < 12; i++) await settle(); };
|
|
1162
|
+
const must = (sel) => { const el = query(sel); if (!el) throw new Error(`${page}: no ${sel}`); return el; };
|
|
1163
|
+
return Object.assign(out, {
|
|
1164
|
+
ready: settleAll,
|
|
1165
|
+
el: byId,
|
|
1166
|
+
find: query,
|
|
1167
|
+
html: (id) => (byId(id) ? byId(id).innerHTML : ''),
|
|
1168
|
+
text: (id) => (byId(id) ? byId(id).innerHTML.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ') : ''),
|
|
1169
|
+
writes: () => requests.filter((r) => r.method !== 'GET'),
|
|
1170
|
+
focused: () => focused,
|
|
1171
|
+
click: async (sel) => { const el = must(sel); dispatch(el, 'click'); await settleAll(); return el; },
|
|
1172
|
+
submit: async (sel) => { const el = must(sel); const ev = dispatch(el, 'submit'); await settleAll(); return ev; },
|
|
1173
|
+
type: async (sel, value) => { const el = must(sel); el.value = value; dispatch(el, 'input'); await settleAll(); return el; },
|
|
1174
|
+
tick: (sel) => { const el = must(sel); el.checked = true; return el; },
|
|
1175
|
+
toggle: async (id) => { const el = byId(id); if (!el) throw new Error(`${page}: no #${id}`); el.open = !el.open; dispatch(el, 'toggle'); await settleAll(); return el; },
|
|
1176
|
+
});
|
|
1177
|
+
}
|
|
1178
|
+
|
|
1018
1179
|
// namesFieldKey — does this source USE `key` as a Full Idea field key, as
|
|
1019
1180
|
// opposed to merely containing the word? (task 1004085 / BV2.R06.)
|
|
1020
1181
|
//
|
|
@@ -1,27 +1,30 @@
|
|
|
1
|
-
// tests/speciality_offer_ui.mjs —
|
|
2
|
-
//
|
|
1
|
+
// tests/speciality_offer_ui.mjs — the OWNER's side of the speciality tools:
|
|
2
|
+
// creating one, training it, and choosing what it offers (task 1004404; ADR 0310
|
|
3
|
+
// §1; on My agents too since task 1005052).
|
|
3
4
|
//
|
|
4
|
-
// Before
|
|
5
|
-
// made in the hall offered any and the adoption walkthrough never
|
|
6
|
-
// pin that the owner can choose on create AND afterwards, that
|
|
7
|
-
// the skill's plain description, that only installed skills are
|
|
8
|
-
// a skill removed since is named rather than silently kept
|
|
5
|
+
// Before task 1004404 only the API could set a speciality's offered skills, so
|
|
6
|
+
// nothing made in the hall offered any and the adoption walkthrough never
|
|
7
|
+
// appeared. These pin that the owner can choose on create AND afterwards, that
|
|
8
|
+
// each choice shows the skill's plain description, that only installed skills are
|
|
9
|
+
// choices, and that a skill removed since is named rather than silently kept —
|
|
10
|
+
// and that training adds a document in one request, refused inline when a field
|
|
11
|
+
// is empty.
|
|
9
12
|
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
+
// TWO PAGES, ONE COMPONENT (task 1005052). modules/hall-ui/public/speciality-tools.js
|
|
14
|
+
// is mounted by Settings and by My agents, and it renders its own create form, so
|
|
15
|
+
// neither page carries a second copy of that markup. Every case runs once per
|
|
16
|
+
// page, booted on THAT page's own markup (tests/helpers.mjs bootSpecialityTools),
|
|
17
|
+
// so a host lost from either page fails that page's run.
|
|
13
18
|
//
|
|
14
19
|
// Run: node tests/speciality_offer_ui.mjs
|
|
15
20
|
import assert from 'node:assert/strict';
|
|
16
21
|
import fs from 'node:fs';
|
|
17
22
|
import path from 'node:path';
|
|
18
|
-
import vm from 'node:vm';
|
|
19
23
|
import { fileURLToPath } from 'node:url';
|
|
20
|
-
import { makeRunner } from './helpers.mjs';
|
|
24
|
+
import { makeRunner, bootSpecialityTools, SPECIALITY_PAGES } from './helpers.mjs';
|
|
21
25
|
|
|
22
26
|
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
23
|
-
const SRC = fs.readFileSync(path.join(ROOT, 'modules/hall-ui/public/
|
|
24
|
-
const HTML = fs.readFileSync(path.join(ROOT, 'modules/hall-ui/public/settings.html'), 'utf8');
|
|
27
|
+
const SRC = fs.readFileSync(path.join(ROOT, 'modules/hall-ui/public/speciality-tools.js'), 'utf8');
|
|
25
28
|
const { test, summary } = makeRunner();
|
|
26
29
|
|
|
27
30
|
const INSTALLED = [
|
|
@@ -33,153 +36,183 @@ const INSTALLED = [
|
|
|
33
36
|
const mine = (over = {}) => ({ id: 12, name: 'Pricing', discipline: 'engineer', summary: '', contract_md: '', visibility: 'private', adoptable: true, mine: true, skills: ['recall', 'retired-skill'], ...over });
|
|
34
37
|
const theirs = (over = {}) => ({ id: 9, name: 'Economics', discipline: 'ideator', summary: '', contract_md: '', visibility: 'public', adoptable: true, mine: false, skills: ['recall'], ...over });
|
|
35
38
|
|
|
36
|
-
// The
|
|
39
|
+
// The ticked boxes in rendered markup.
|
|
37
40
|
const checkedIn = (html) => [...html.matchAll(/<input type="checkbox" name="skill" value="([^"]+)" checked/g)].map((m) => m[1]);
|
|
38
|
-
const fakeForm = (dataset, values) => ({
|
|
39
|
-
dataset,
|
|
40
|
-
querySelectorAll: (sel) => (sel === 'input[name="skill"]:checked' ? values.map((value) => ({ value })) : []),
|
|
41
|
-
});
|
|
42
41
|
|
|
43
|
-
async function boot({ catalog = [mine(), theirs()] } = {}) {
|
|
44
|
-
const
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
const handlers = new Map();
|
|
48
|
-
const makeEl = (id) => ({
|
|
49
|
-
id, hidden: id === 'specialities-scroll', innerHTML: '', textContent: '', elements: {}, dataset: {}, open: false,
|
|
50
|
-
addEventListener(type, fn) { handlers.set(`${id}:${type}`, fn); },
|
|
51
|
-
});
|
|
52
|
-
for (const id of ['specialities-scroll', 'specialities-rows', 'speciality-suggestions', 'specialities-status', 'speciality-create', 'speciality-create-offer', 'speciality-create-skills']) els.set(id, makeEl(id));
|
|
53
|
-
// The create form's fields, as settings.html declares them.
|
|
54
|
-
const create = els.get('speciality-create');
|
|
55
|
-
const field = (value) => ({ value, addEventListener() {} });
|
|
56
|
-
create.elements = { name: field(''), discipline: field(''), summary: field(''), contract_md: field(''), visibility: field('private'), sellable: { checked: false, disabled: false, addEventListener() {} } };
|
|
57
|
-
create.reset = () => { create.elements.name.value = ''; };
|
|
58
|
-
create.querySelectorAll = () => [];
|
|
59
|
-
|
|
60
|
-
const fetch = async (url, opts = {}) => {
|
|
61
|
-
const method = opts.method || 'GET';
|
|
62
|
-
const p = url.replace('/api/bongos', '');
|
|
63
|
-
requests.push({ method, path: p, body: opts.body ? JSON.parse(opts.body) : undefined });
|
|
64
|
-
let body = {};
|
|
65
|
-
if (method === 'GET' && p === '/specialities') body = { specialities: catalog };
|
|
66
|
-
else if (method === 'GET' && p === '/me/specialities') body = { adoptions: [] };
|
|
67
|
-
else if (method === 'GET' && p === '/specialities/offered') body = { suggestions: [] };
|
|
68
|
-
else if (method === 'GET' && p === '/specialities/installed-skills') body = { skills: INSTALLED };
|
|
69
|
-
else if (method === 'PATCH') body = { speciality: { id: 12 } };
|
|
70
|
-
else if (method === 'POST' && p === '/specialities') body = { speciality: { id: 13 } };
|
|
71
|
-
return { ok: true, status: 200, json: async () => body };
|
|
72
|
-
};
|
|
73
|
-
|
|
74
|
-
const document = { readyState: 'complete', getElementById: (id) => els.get(id) || null, addEventListener() {}, querySelector: () => null };
|
|
75
|
-
const window = {
|
|
76
|
-
OTB: {
|
|
77
|
-
escapeHtml: (s) => String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"'),
|
|
78
|
-
$: () => null, toast: (m) => toasts.push(m), roleAxisLabel: () => 'Role',
|
|
79
|
-
},
|
|
80
|
-
location: { assign() {} },
|
|
81
|
-
};
|
|
82
|
-
vm.runInNewContext(SRC, { window, document, fetch, JSON, Promise, Map, Set, String, Number, Array, Object, Math, RegExp, console });
|
|
83
|
-
const settle = async () => { for (let i = 0; i < 8; i++) await new Promise((r) => setTimeout(r, 0)); };
|
|
84
|
-
await settle();
|
|
85
|
-
|
|
86
|
-
const rows = els.get('specialities-rows');
|
|
87
|
-
async function click(act, id) {
|
|
88
|
-
const btn = { dataset: { act, id: String(id) } };
|
|
89
|
-
handlers.get('specialities-rows:click')({ target: { closest: (sel) => (sel === 'button[data-act]' ? btn : null) } });
|
|
90
|
-
await settle();
|
|
91
|
-
}
|
|
92
|
-
async function submitOffer(id, values) {
|
|
93
|
-
const form = fakeForm({ id: String(id) }, values);
|
|
94
|
-
handlers.get('specialities-rows:submit')({ target: { closest: (sel) => (sel === 'form.speciality-offer' ? form : null) }, preventDefault() {} });
|
|
95
|
-
await settle();
|
|
96
|
-
}
|
|
97
|
-
return { els, rows, requests, toasts, click, submitOffer, handlers, create, settle };
|
|
42
|
+
async function boot(page, { catalog = [mine(), theirs()], ...rest } = {}) {
|
|
43
|
+
const h = bootSpecialityTools({ page, catalog, installed: INSTALLED, ...rest });
|
|
44
|
+
await h.ready();
|
|
45
|
+
return h;
|
|
98
46
|
}
|
|
99
|
-
|
|
100
|
-
const
|
|
47
|
+
const ROWS = '#specialities-rows';
|
|
48
|
+
const OFFER = `${ROWS} form.speciality-offer`;
|
|
49
|
+
const writes = (h) => h.writes();
|
|
101
50
|
const installedReads = (h) => h.requests.filter((r) => r.path === '/specialities/installed-skills').length;
|
|
51
|
+
// every element id the component looks up
|
|
52
|
+
const HOSTS = [...new Set([...SRC.matchAll(/\bel\('([a-z-]+)'\)/g)].map((m) => m[1]))];
|
|
102
53
|
|
|
103
|
-
await test('
|
|
104
|
-
const
|
|
105
|
-
|
|
106
|
-
assert.deepEqual(buttons, ['12']);
|
|
107
|
-
});
|
|
108
|
-
|
|
109
|
-
await test('opening it lists every installed skill with its plain description and cost, the current offer ticked', async () => {
|
|
110
|
-
const h = await boot();
|
|
111
|
-
assert.equal(installedReads(h), 0, 'nothing is fetched until the owner asks');
|
|
112
|
-
await h.click('offer', 12);
|
|
113
|
-
assert.equal(installedReads(h), 1);
|
|
114
|
-
const html = h.rows.innerHTML;
|
|
115
|
-
for (const k of INSTALLED) {
|
|
116
|
-
assert.ok(html.includes(`value="${k.name}"`), `${k.name} is a choice`);
|
|
117
|
-
assert.ok(html.includes(k.does), `${k.name} shows what it does`);
|
|
118
|
-
assert.ok(html.includes(k.cost), `${k.name} shows what it costs`);
|
|
54
|
+
await test('the component names the hosts it reads (sanity: the scan below has something to check)', () => {
|
|
55
|
+
for (const id of ['specialities-scroll', 'specialities-rows', 'speciality-suggestions', 'specialities-status', 'speciality-create', 'speciality-create-offer', 'speciality-create-skills']) {
|
|
56
|
+
assert.ok(HOSTS.includes(id), `speciality-tools.js reads #${id}`);
|
|
119
57
|
}
|
|
120
|
-
assert.deepEqual(checkedIn(html), ['recall'], 'what it offers today is ticked');
|
|
121
|
-
assert.match(html, /Offering a skill turns nothing on/);
|
|
122
58
|
});
|
|
123
59
|
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
});
|
|
60
|
+
for (const page of SPECIALITY_PAGES) {
|
|
61
|
+
await test(`${page}: the page carries every host the component reads, and no second copy of the create form`, async () => {
|
|
62
|
+
const h = await boot(page);
|
|
63
|
+
for (const id of HOSTS) assert.ok(h.el(id), `${page}.html: #${id} is there once the component has run`);
|
|
64
|
+
assert.ok(h.hostIds.includes('speciality-create'), `${page}.html declares the create form's host`);
|
|
65
|
+
const html = fs.readFileSync(path.join(ROOT, 'modules/hall-ui/public', `${page}.html`), 'utf8');
|
|
66
|
+
const form = html.slice(html.indexOf('<form id="speciality-create"'), html.indexOf('</form>', html.indexOf('<form id="speciality-create"')));
|
|
67
|
+
assert.doesNotMatch(form, /<(input|select|textarea)\b/, `${page}.html: the create form is EMPTY — the component renders it, so there is one copy`);
|
|
68
|
+
assert.ok(html.includes('/builders/speciality-tools.js'), `${page}.html loads the component`);
|
|
69
|
+
assert.ok(html.includes('/builders/speciality-tools.css'), `${page}.html loads its sheet`);
|
|
70
|
+
});
|
|
130
71
|
|
|
131
|
-
await test(
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
assert.deepEqual(w[0].body, { skills: ['recall', 'status'] });
|
|
141
|
-
assert.ok(h.toasts.some((m) => /offers 2 skills/.test(m)));
|
|
142
|
-
assert.ok(!/class="speciality-offer"/.test(h.rows.innerHTML), 'the editor closes on save');
|
|
143
|
-
});
|
|
72
|
+
await test(`${page}: the create form the component renders carries every field, and the skills picker INSIDE the form it submits with`, async () => {
|
|
73
|
+
const h = await boot(page);
|
|
74
|
+
const form = h.el('speciality-create');
|
|
75
|
+
assert.deepEqual(Object.keys(form.elements).sort(), ['contract_md', 'discipline', 'name', 'sellable', 'summary', 'visibility']);
|
|
76
|
+
assert.deepEqual(form.elements.discipline.querySelectorAll('option').map((o) => o.attrs.value), ['', 'engineer', 'ideator', 'artist', 'ui']);
|
|
77
|
+
assert.deepEqual(form.elements.visibility.querySelectorAll('option').map((o) => o.attrs.value), ['private', 'project', 'public']);
|
|
78
|
+
assert.ok(form.querySelector('details#speciality-create-offer'), 'the picker is inside the form');
|
|
79
|
+
assert.ok(form.querySelector('#speciality-create-skills'), 'with the host its choices fill');
|
|
80
|
+
});
|
|
144
81
|
|
|
145
|
-
await test(
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
}
|
|
82
|
+
await test(`${page}: only a speciality you own has "Train" and "Offered skills"`, async () => {
|
|
83
|
+
const h = await boot(page);
|
|
84
|
+
for (const act of ['offer', 'train']) {
|
|
85
|
+
const ids = [...h.html('specialities-rows').matchAll(new RegExp(`data-act="${act}" data-id="(\\d+)"`, 'g'))].map((m) => m[1]);
|
|
86
|
+
assert.deepEqual(ids, ['12'], act);
|
|
87
|
+
}
|
|
88
|
+
});
|
|
151
89
|
|
|
152
|
-
await test(
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
90
|
+
await test(`${page}: opening "Offered skills" lists every installed skill with its plain description and cost, the current offer ticked`, async () => {
|
|
91
|
+
const h = await boot(page);
|
|
92
|
+
assert.equal(installedReads(h), 0, 'nothing is fetched until the owner asks');
|
|
93
|
+
await h.click(`${ROWS} button[data-act="offer"]`);
|
|
94
|
+
assert.equal(installedReads(h), 1);
|
|
95
|
+
const html = h.html('specialities-rows');
|
|
96
|
+
for (const k of INSTALLED) {
|
|
97
|
+
assert.ok(html.includes(`value="${k.name}"`), `${k.name} is a choice`);
|
|
98
|
+
assert.ok(html.includes(k.does), `${k.name} shows what it does`);
|
|
99
|
+
assert.ok(html.includes(k.cost), `${k.name} shows what it costs`);
|
|
100
|
+
}
|
|
101
|
+
assert.deepEqual(checkedIn(html), ['recall'], 'what it offers today is ticked');
|
|
102
|
+
assert.match(html, /Offering a skill turns nothing on/);
|
|
103
|
+
});
|
|
159
104
|
|
|
160
|
-
await test(
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
await h.settle();
|
|
167
|
-
for (const k of INSTALLED) assert.ok(host.innerHTML.includes(`value="${k.name}"`) && host.innerHTML.includes(k.does));
|
|
168
|
-
assert.deepEqual(checkedIn(host.innerHTML), [], 'a new speciality starts offering nothing');
|
|
169
|
-
h.create.elements.name.value = 'New one';
|
|
170
|
-
h.create.elements.discipline.value = 'engineer';
|
|
171
|
-
h.create.querySelectorAll = (sel) => (sel === 'input[name="skill"]:checked' ? [{ value: 'status' }] : []);
|
|
172
|
-
h.handlers.get('speciality-create:submit')({ preventDefault() {} });
|
|
173
|
-
await h.settle();
|
|
174
|
-
const post = writes(h).find((r) => r.method === 'POST' && r.path === '/specialities');
|
|
175
|
-
assert.ok(post, 'created');
|
|
176
|
-
assert.deepEqual(post.body.skills, ['status']);
|
|
177
|
-
});
|
|
105
|
+
await test(`${page}: a skill it offers that the instance no longer has is named, not offered as a choice`, async () => {
|
|
106
|
+
const h = await boot(page);
|
|
107
|
+
await h.click(`${ROWS} button[data-act="offer"]`);
|
|
108
|
+
assert.ok(!h.html('specialities-rows').includes('value="retired-skill"'), 'not a checkbox');
|
|
109
|
+
assert.match(h.html('specialities-rows'), /No longer on this project, so saving drops it from the offer: <code>retired-skill<\/code>/);
|
|
110
|
+
});
|
|
178
111
|
|
|
179
|
-
await test(
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
});
|
|
112
|
+
await test(`${page}: "Find a skill" narrows the list as you type`, async () => {
|
|
113
|
+
const h = await boot(page);
|
|
114
|
+
await h.click(`${ROWS} button[data-act="offer"]`);
|
|
115
|
+
await h.type(`${OFFER} .speciality-offer__filter`, 'version');
|
|
116
|
+
const shown = h.find(`${OFFER} .speciality-offer__list`).children.filter((li) => !li.hidden).map((li) => li.querySelector('input').attrs.value);
|
|
117
|
+
assert.deepEqual(shown, ['status'], 'matched on the plain description, not only the name');
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
await test(`${page}: saving sends exactly the ticked skills as one PATCH, and nothing is written before`, async () => {
|
|
121
|
+
const h = await boot(page);
|
|
122
|
+
await h.click(`${ROWS} button[data-act="offer"]`);
|
|
123
|
+
assert.equal(writes(h).length, 0);
|
|
124
|
+
h.tick(`${OFFER} input[value="status"]`);
|
|
125
|
+
const ev = await h.submit(OFFER);
|
|
126
|
+
assert.equal(ev.defaultPrevented, true, 'the page does not navigate');
|
|
127
|
+
const w = writes(h);
|
|
128
|
+
assert.equal(w.length, 1);
|
|
129
|
+
assert.equal(w[0].method, 'PATCH');
|
|
130
|
+
assert.equal(w[0].path, '/specialities/12');
|
|
131
|
+
assert.deepEqual(w[0].body, { skills: ['recall', 'status'] });
|
|
132
|
+
assert.ok(h.toasts.some((m) => /offers 2 skills/.test(m)));
|
|
133
|
+
assert.ok(!/class="speciality-offer"/.test(h.html('specialities-rows')), 'the editor closes on save');
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
await test(`${page}: offering nothing is a choice too`, async () => {
|
|
137
|
+
const h = await boot(page);
|
|
138
|
+
await h.click(`${ROWS} button[data-act="offer"]`);
|
|
139
|
+
h.find(`${OFFER} input[value="recall"]`).checked = false;
|
|
140
|
+
await h.submit(OFFER);
|
|
141
|
+
assert.deepEqual(writes(h)[0].body, { skills: [] });
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
await test(`${page}: the installed list is read once per page, however often the editor opens`, async () => {
|
|
145
|
+
const h = await boot(page);
|
|
146
|
+
await h.click(`${ROWS} button[data-act="offer"]`);
|
|
147
|
+
await h.click(`${ROWS} button[data-act="offer"]`); // close
|
|
148
|
+
await h.click(`${ROWS} button[data-act="offer"]`); // open again
|
|
149
|
+
assert.equal(installedReads(h), 1);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
await test(`${page}: training adds a document to your own speciality in one POST, and the form closes`, async () => {
|
|
153
|
+
const h = await boot(page);
|
|
154
|
+
await h.click(`${ROWS} button[data-act="train"]`);
|
|
155
|
+
const form = h.find(`${ROWS} form.speciality-train`);
|
|
156
|
+
assert.ok(form, 'Train opens the form in its card');
|
|
157
|
+
assert.match(form.textContent, /indexed\s+for recall, not pasted into every session/);
|
|
158
|
+
form.elements.title.value = ' How we price a swap ';
|
|
159
|
+
form.elements.body_md.value = 'Start from the spread.';
|
|
160
|
+
await h.submit(`${ROWS} form.speciality-train`);
|
|
161
|
+
assert.deepEqual(writes(h).map((r) => [r.method, r.path, r.body]), [['POST', '/specialities/12/documents', { title: 'How we price a swap', body_md: 'Start from the spread.' }]]);
|
|
162
|
+
assert.ok(h.toasts.includes('Added.'));
|
|
163
|
+
assert.equal(h.find(`${ROWS} form.speciality-train`), null, 'the form closes on save');
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
await test(`${page}: a document with no title or no body is refused inline, before any request`, async () => {
|
|
167
|
+
const h = await boot(page);
|
|
168
|
+
await h.click(`${ROWS} button[data-act="train"]`);
|
|
169
|
+
h.find(`${ROWS} form.speciality-train`).elements.title.value = 'Only a title';
|
|
170
|
+
await h.submit(`${ROWS} form.speciality-train`);
|
|
171
|
+
assert.equal(writes(h).length, 0);
|
|
172
|
+
assert.equal(h.el('specialities-status').textContent, 'A document needs both a title and some content.');
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
await test(`${page}: a document it already knows says so`, async () => {
|
|
176
|
+
const h = await boot(page, { answer: (m, p) => (m === 'POST' && /\/documents$/.test(p) ? { status: 200, data: { skipped: true, reason: 'already_trained' } } : null) });
|
|
177
|
+
await h.click(`${ROWS} button[data-act="train"]`);
|
|
178
|
+
const form = h.find(`${ROWS} form.speciality-train`);
|
|
179
|
+
form.elements.title.value = 'T';
|
|
180
|
+
form.elements.body_md.value = 'B';
|
|
181
|
+
await h.submit(`${ROWS} form.speciality-train`);
|
|
182
|
+
assert.ok(h.toasts.includes('It already knew that one.'));
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
await test(`${page}: the create form offers the same choices, filled on first open, and sends them with the new speciality`, async () => {
|
|
186
|
+
const h = await boot(page);
|
|
187
|
+
const host = h.el('speciality-create-skills');
|
|
188
|
+
await h.toggle('speciality-create-offer');
|
|
189
|
+
for (const k of INSTALLED) assert.ok(host.innerHTML.includes(`value="${k.name}"`) && host.innerHTML.includes(k.does));
|
|
190
|
+
assert.deepEqual(checkedIn(host.innerHTML), [], 'a new speciality starts offering nothing');
|
|
191
|
+
const form = h.el('speciality-create');
|
|
192
|
+
form.elements.name.value = 'New one';
|
|
193
|
+
form.elements.discipline.value = 'engineer';
|
|
194
|
+
h.tick('#speciality-create-skills input[value="status"]');
|
|
195
|
+
await h.submit('#speciality-create');
|
|
196
|
+
const post = writes(h).find((r) => r.method === 'POST' && r.path === '/specialities');
|
|
197
|
+
assert.ok(post, 'created');
|
|
198
|
+
assert.deepEqual(post.body, { name: 'New one', discipline: 'engineer', summary: '', contract_md: '', visibility: 'private', sellable: false, skills: ['status'] });
|
|
199
|
+
assert.ok(h.toasts.includes('Speciality created.'));
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
await test(`${page}: create refuses a nameless or roleless speciality inline, and a private one cannot be for sale`, async () => {
|
|
203
|
+
const h = await boot(page);
|
|
204
|
+
const form = h.el('speciality-create');
|
|
205
|
+
await h.submit('#speciality-create');
|
|
206
|
+
assert.equal(h.el('specialities-status').textContent, 'Give it a name.');
|
|
207
|
+
form.elements.name.value = 'X';
|
|
208
|
+
await h.submit('#speciality-create');
|
|
209
|
+
assert.match(h.el('specialities-status').textContent, /^Pick which role it sits under\.$/);
|
|
210
|
+
assert.equal(writes(h).length, 0);
|
|
211
|
+
assert.equal(form.elements.sellable.disabled, true, 'private (the default) cannot be sold');
|
|
212
|
+
form.elements.visibility.value = 'public';
|
|
213
|
+
form.elements.visibility.fire('change', {});
|
|
214
|
+
assert.equal(form.elements.sellable.disabled, false);
|
|
215
|
+
});
|
|
216
|
+
}
|
|
184
217
|
|
|
185
218
|
summary();
|