@bongos/core 1.24.27 → 1.24.29

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