@bongos/core 1.20.10 → 1.20.11

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.
@@ -26,7 +26,9 @@
26
26
 
27
27
  // `walk` is the skills walkthrough in progress, or null (task 1004073):
28
28
  // { id, data, step, decisions, saving }. See walkHtml below.
29
- const state = { rows: [], suggestions: [], openId: null, busy: false, walk: null };
29
+ // `offerId` / `offerChoices`: the owner's "what it offers" editor that is open,
30
+ // and its rendered choices once the instance's skills have loaded (task 1004404).
31
+ const state = { rows: [], suggestions: [], openId: null, busy: false, walk: null, offerId: null, offerChoices: null };
30
32
 
31
33
  const el = (id) => document.getElementById(id);
32
34
  const statusLine = (msg) => { const s = el('specialities-status'); if (s) s.textContent = msg || ''; };
@@ -211,12 +213,112 @@
211
213
  focusWalk();
212
214
  }
213
215
 
216
+ // ── WHAT A SPECIALITY OFFERS (task 1004404; ADR 0310 §1) ──────────────────
217
+ //
218
+ // The OWNER's half: which of this instance's skills the speciality puts on
219
+ // its menu. Until this, only the API could set it, so nothing made in the hall
220
+ // offered anything and the walkthrough above never appeared. Each choice shows
221
+ // the skill's plain description — the same words the walkthrough will show the
222
+ // people who adopt it — so the owner is choosing what to teach, not ticking
223
+ // names. Offering turns nothing on for anyone: every adopter still starts with
224
+ // all of them off and decides one at a time.
225
+ //
226
+ // The choices come from GET /specialities/installed-skills, so only skills
227
+ // this instance has are offered; the server's unknown_skill check on POST/PATCH
228
+ // stays the rule for anything a stale page sends.
229
+
230
+ let installedList = null; // fetched once per page, on first need
231
+ async function installedSkillList() {
232
+ if (!installedList) {
233
+ const data = await call('GET', '/specialities/installed-skills');
234
+ installedList = (data && data.skills) || [];
235
+ }
236
+ return installedList;
237
+ }
238
+
239
+ function offerChoicesHtml(all, chosen) {
240
+ const picked = new Set(chosen || []);
241
+ const gone = (chosen || []).filter((n) => !all.some((k) => k.name === n));
242
+ return `
243
+ <p class="prow__hint">
244
+ Offering a skill turns nothing on. Everyone who uses this speciality is shown
245
+ each one and chooses for themselves — all start off.
246
+ </p>
247
+ <label class="speciality-field">
248
+ <span>Find a skill</span>
249
+ <input type="search" class="speciality-offer__filter" placeholder="Type to narrow the list" />
250
+ </label>
251
+ <ul class="speciality-offer__list">
252
+ ${all.map((k) => `
253
+ <li data-find="${escapeHtml(`${k.name} ${k.does || ''}`.toLowerCase())}">
254
+ <label class="speciality-field speciality-field--check">
255
+ <input type="checkbox" name="skill" value="${escapeHtml(k.name)}" ${picked.has(k.name) ? 'checked' : ''} />
256
+ <span>
257
+ <strong>${escapeHtml(k.name)}</strong> — ${escapeHtml(k.does || 'Its author gave no description.')}
258
+ <small>${escapeHtml(k.cost || '')}</small>
259
+ </span>
260
+ </label>
261
+ </li>`).join('')}
262
+ </ul>
263
+ ${gone.length ? `<p class="prow__hint">No longer on this project, so saving drops it from the offer: ${gone.map((n) => `<code>${escapeHtml(n)}</code>`).join(', ')}.</p>` : ''}`;
264
+ }
265
+
266
+ const chosenSkills = (form) => [...form.querySelectorAll('input[name="skill"]:checked')].map((i) => i.value);
267
+
268
+ function narrowOffer(input) {
269
+ const q = input.value.trim().toLowerCase();
270
+ const list = input.closest('form') && input.closest('form').querySelector('.speciality-offer__list');
271
+ if (!list) return;
272
+ for (const li of list.children) li.hidden = q !== '' && !String(li.dataset.find || '').includes(q);
273
+ }
274
+
275
+ function offerFormHtml(s) {
276
+ return `
277
+ <form class="speciality-offer" data-id="${escapeHtml(String(s.id))}">
278
+ <h4 class="speciality-create__h">What ${escapeHtml(s.name)} offers</h4>
279
+ <div class="speciality-offer__choices">${state.offerChoices || '<p class="prow__hint">Loading this project’s skills…</p>'}</div>
280
+ <div class="speciality-actions">
281
+ <button type="submit" class="btn" ${state.offerChoices ? '' : 'disabled'}>Save what it offers</button>
282
+ </div>
283
+ </form>`;
284
+ }
285
+
286
+ async function openOffer(id) {
287
+ if (String(state.offerId) === String(id)) { state.offerId = null; render(); return; }
288
+ state.offerId = String(id);
289
+ state.offerChoices = null;
290
+ state.openId = null;
291
+ state.walk = null;
292
+ render();
293
+ try {
294
+ const all = await installedSkillList();
295
+ const row = state.rows.find((r) => String(r.id) === String(id));
296
+ if (state.offerId !== String(id) || !row) return;
297
+ state.offerChoices = offerChoicesHtml(all, row.skills || []);
298
+ render();
299
+ } catch (err) { statusLine(`Could not load this project’s skills — ${err.message}`); }
300
+ }
301
+
302
+ // The create form's picker fills the first time it is opened, so a builder
303
+ // who never offers a skill never pays for the list.
304
+ async function fillCreateOffer(host) {
305
+ if (host.dataset.filled) return;
306
+ host.dataset.filled = '1';
307
+ try {
308
+ host.innerHTML = offerChoicesHtml(await installedSkillList(), []);
309
+ } catch (err) {
310
+ delete host.dataset.filled;
311
+ host.innerHTML = `<p class="prow__hint">Could not load this project’s skills — ${escapeHtml(err.message)}</p>`;
312
+ }
313
+ }
314
+
214
315
  function cardHtml(s) {
215
316
  const trained = Number(s.documentCount || 0);
216
317
  const learned = Number(s.learningCount || 0);
217
318
  const open = String(state.openId) === String(s.id);
218
319
  const offered = Array.isArray(s.skills) ? s.skills.length : 0;
219
320
  const walking = state.walk && state.walk.id === String(s.id);
321
+ const offering = s.mine && String(state.offerId) === String(s.id);
220
322
  return `
221
323
  <li class="prow speciality-card" data-id="${escapeHtml(String(s.id))}">
222
324
  <div class="prow__main">
@@ -237,11 +339,13 @@
237
339
  <div class="prow__actions">
238
340
  ${s.active && offered && !walking ? `<button type="button" class="btn btn--quiet" data-act="walk" data-id="${escapeHtml(String(s.id))}">Choose its skills</button>` : ''}
239
341
  ${s.mine ? `<button type="button" class="btn btn--quiet" data-act="train" data-id="${escapeHtml(String(s.id))}">${open ? 'Close' : 'Train'}</button>` : ''}
342
+ ${s.mine ? `<button type="button" class="btn btn--quiet" data-act="offer" data-id="${escapeHtml(String(s.id))}" aria-expanded="${offering}">${offering ? 'Close' : 'Offered skills'}</button>` : ''}
240
343
  ${s.active
241
344
  ? `<button type="button" class="btn btn--quiet" data-act="abandon" data-id="${escapeHtml(String(s.id))}">Stop using</button>`
242
345
  : `<button type="button" class="btn" data-act="adopt" data-id="${escapeHtml(String(s.id))}" ${s.adoptable ? '' : 'disabled'}>Use this</button>`}
243
346
  </div>
244
347
  ${open ? trainHtml(s) : ''}
348
+ ${offering ? offerFormHtml(s) : ''}
245
349
  ${walking ? walkHtml() : ''}
246
350
  </li>`;
247
351
  }
@@ -388,11 +492,28 @@
388
492
  if (!btn) return;
389
493
  const { act: action, id } = btn.dataset;
390
494
  if (action === 'walk') { openWalk(id); return; }
391
- if (action === 'train') { state.openId = String(state.openId) === String(id) ? null : id; state.walk = null; render(); return; }
495
+ if (action === 'offer') { openOffer(id); return; }
496
+ if (action === 'train') { state.openId = String(state.openId) === String(id) ? null : id; state.walk = null; state.offerId = null; render(); return; }
392
497
  act(action, id);
393
498
  });
394
499
 
500
+ host.addEventListener('input', (e) => {
501
+ if (e.target.matches('.speciality-offer__filter')) narrowOffer(e.target);
502
+ });
503
+
395
504
  host.addEventListener('submit', async (e) => {
505
+ const offer = e.target.closest('form.speciality-offer');
506
+ if (offer) {
507
+ e.preventDefault();
508
+ const skills = chosenSkills(offer);
509
+ try {
510
+ await call('PATCH', `/specialities/${offer.dataset.id}`, { skills });
511
+ state.offerId = null;
512
+ toast(skills.length ? `Saved — it offers ${skills.length} skill${skills.length === 1 ? '' : 's'}.` : 'Saved — it offers no skills.');
513
+ await load();
514
+ } catch (err) { statusLine(`Could not save what it offers — ${err.message}`); }
515
+ return;
516
+ }
396
517
  const form = e.target.closest('form.speciality-train');
397
518
  if (!form) return;
398
519
  e.preventDefault();
@@ -426,8 +547,10 @@
426
547
  contract_md: create.elements.contract_md.value.trim(),
427
548
  visibility: create.elements.visibility.value,
428
549
  sellable: create.elements.sellable.checked,
550
+ skills: chosenSkills(create),
429
551
  });
430
552
  create.reset();
553
+ for (const li of create.querySelectorAll('.speciality-offer__list > li')) li.hidden = false;
431
554
  toast('Speciality created.');
432
555
  await load();
433
556
  } catch (err) { statusLine(err.message); }
@@ -445,7 +568,21 @@
445
568
  };
446
569
  vis.addEventListener('change', sync);
447
570
  sync();
571
+
572
+ // The skills picker (task 1004404): filled on first open, narrowed as you type.
573
+ const offerBox = el('speciality-create-offer');
574
+ const offerHost = el('speciality-create-skills');
575
+ if (offerBox && offerHost) offerBox.addEventListener('toggle', () => { if (offerBox.open) fillCreateOffer(offerHost); });
576
+ create.addEventListener('input', (e) => {
577
+ if (e.target.matches('.speciality-offer__filter')) narrowOffer(e.target);
578
+ });
448
579
  }
580
+
581
+ // Enter in a "Find a skill" box narrows the list; it must not submit the
582
+ // form around it half-chosen.
583
+ document.addEventListener('keydown', (e) => {
584
+ if (e.key === 'Enter' && e.target && e.target.matches && e.target.matches('.speciality-offer__filter')) e.preventDefault();
585
+ });
449
586
  }
450
587
 
451
588
  function boot() {
@@ -199,6 +199,32 @@ select.voices__select { min-width: 140px; }
199
199
  .speciality-field--check { flex-direction: row; align-items: flex-start; gap: 0.5rem; }
200
200
  .speciality-field--check input { margin-top: 0.2rem; }
201
201
  .speciality-actions { display: flex; gap: 0.5rem; }
202
+ /* What a speciality OFFERS (task 1004404): the owner's pick-list of this
203
+ instance's skills, each with its plain line. Sixty-odd rows, so the list
204
+ scrolls inside its own box rather than pushing the form's buttons off-screen;
205
+ the reading lines keep the measure. */
206
+ .speciality-field input[type='search'] { width: 100%; box-sizing: border-box; }
207
+ .speciality-offer,
208
+ .speciality-offer-box { flex-basis: 100%; display: flex; flex-direction: column; gap: 0.6rem; }
209
+ .speciality-offer { margin-top: 0.75rem; padding-top: 0.75rem; border-top: 1px solid var(--rule); }
210
+ .speciality-offer > *, .speciality-offer-box > * { max-width: var(--measure); }
211
+ .speciality-offer-box > summary { cursor: pointer; font-size: 0.85rem; min-height: 24px; padding-block: 2px; box-sizing: border-box; }
212
+ .speciality-offer-box > summary small { opacity: 0.7; }
213
+ .speciality-offer__choices, #speciality-create-skills { display: flex; flex-direction: column; gap: 0.6rem; }
214
+ .speciality-offer__list {
215
+ list-style: none;
216
+ margin: 0;
217
+ padding: 0 0.25rem 0 0;
218
+ display: flex;
219
+ flex-direction: column;
220
+ gap: 0.5rem;
221
+ max-height: 24rem;
222
+ overflow-y: auto;
223
+ overflow-x: hidden;
224
+ }
225
+ .speciality-offer__list li[hidden] { display: none; }
226
+ .speciality-offer__list strong { font-family: var(--font-mono); font-weight: 600; }
227
+ .speciality-offer__list .speciality-field--check > span { font-size: 0.85rem; line-height: 1.45; }
202
228
  /* The adopted contract, shown in full before "Use this" — another builder's prose
203
229
  reaching your sessions is the one thing here that must never be folded away. */
204
230
  .speciality-contract { flex-basis: 100%; margin-top: 0.5rem; font-size: 0.85rem; }
@@ -259,6 +259,10 @@
259
259
  <small>Records your decision only — there is no marketplace yet, and nothing is listed anywhere.</small>
260
260
  </span>
261
261
  </label>
262
+ <details id="speciality-create-offer" class="speciality-offer-box">
263
+ <summary>Skills it offers <small>(optional)</small></summary>
264
+ <div id="speciality-create-skills"><p class="prow__hint">Loading this project’s skills…</p></div>
265
+ </details>
262
266
  <div class="speciality-actions">
263
267
  <button type="submit" class="btn">Create speciality</button>
264
268
  </div>
@@ -11,6 +11,7 @@
11
11
  "access": { "auth": true, "url": "/settings#access", "actions": [["wait", 900]], "expect": { "visible": ["#cli-reissue-btn", "#sessions-list", "#artkey-card"] } },
12
12
  "preferences": { "auth": true, "url": "/settings#preferences", "actions": [["wait", 900]], "expect": { "visible": ["#wander-rows", "#render-rows", "#specialities-rows"] } },
13
13
  "skills-walkthrough": { "auth": true, "url": "/settings#preferences", "actions": [["wait", 900], ["click", "#specialities-rows button[data-act=\"adopt\"]"], ["wait", 700]], "expect": { "visible": [".skill-walk", ".skill-walk__facts", "button[data-walk=\"on\"]", "button[data-walk=\"off\"]"] } },
14
+ "offered-skills": { "auth": true, "url": "/settings#preferences", "actions": [["wait", 900], ["click", "#specialities-rows button[data-act=\"offer\"]"], ["wait", 700]], "expect": { "visible": ["form.speciality-offer", ".speciality-offer__list", ".speciality-offer__filter"] } },
14
15
  "sound": { "auth": true, "url": "/settings#sound", "actions": [["wait", 900]], "expect": { "visible": ["#voices-table", "#sound-rows", "#event-sound-rows"] } },
15
16
  "update": { "auth": true, "url": "/settings#software-update", "actions": [["wait", 1200]], "expect": { "visible": ["#software-update-h", "#su-body", "#su-update", ".su-versions"] } }
16
17
  },
@@ -28,3 +28,5 @@ Also fixed here, as the board task handed forward: the two sub-24px links on Hom
28
28
  - **Kit:** `settings.states.json` `update`, ALL CLEAN at 1440/390/320 × dark/light against the harness with the fixture `software-update.json` (a live reading for 1.19.1033). Tests: `tests/software_update.mjs`.
29
29
 
30
30
  **Settings: adopting a speciality walks through its skills (task 1004073, ADR 0310 §3)** — the owner ruled an adopted speciality's skills start OFF "and you should walk through what comes with each". So adopting one that offers skills opens `.skill-walk` inside its card: one skill per step (what it does, when to reach for it, what it costs, what it needs, who can run it), "Leave it off" / "Turn it on" at EQUAL weight (plain `.pbtn`, no accent — the default is off, so the page must not lean toward on), Back, then a recap restating each skill in a line, then ONE `PUT` of the whole set. "Finish later" saves nothing. Focus moves to each new step heading. Two things found on the way: the specialities panel shipped `hidden` and nothing ever revealed it, so adoption was unreachable from the hall; and its contract `<summary>` measured 21px once visible (now 24). **Kit:** `settings.states.json` `skills-walkthrough` + `preferences`, ALL CLEAN at 1440/390/320 × dark/light against the harness, with fixtures `specialities*.json` built from the real skills on disk and canned answers for adopt and save. Tests: `tests/speciality_walkthrough_ui.mjs` (runs the real page script).
31
+
32
+ **Settings: a speciality's owner chooses what it offers (task 1004404, ADR 0310 §1)** — until this only the API could set an offer, so the walkthrough above never appeared for a speciality made in the hall. The create form gains a `<details>` "Skills it offers" picker (filled on first open, so a builder who never offers one never pays for the list), and an owned card gains "Offered skills", an inline `form.speciality-offer` like Train. Both list `GET /specialities/installed-skills` — only what this instance has — each row a checkbox with the skill's plain line and cost, the words adopters will see, under a line saying offering turns nothing on. A "Find a skill" box narrows the sixty-odd rows (Enter never submits), the list scrolls in its own box, an offered skill removed since is named and dropped on save rather than silently kept, and saving is ONE `PATCH { skills }`. **The checkboxes take the shared 24px rule in `panel.css`** (the `.chip-ctl input` / `.craft-pref` block, one selector added) rather than a fourth copy of the `::before` box; without it 128 targets measured 13px. **Kit:** `settings.states.json` `offered-skills` + `preferences`, ALL CLEAN at 1440/390/320 × dark/light, with fixtures `specialities__installed-skills.json` (the real 64 skills, explained) and an owned speciality in `specialities.json`, plus canned POST/PATCH answers. Tests: `tests/speciality_offer_ui.mjs` (runs the real page script).
@@ -285,6 +285,22 @@ module.exports = function buildSpecialitiesRouter() {
285
285
  res.json({ suggestions });
286
286
  }));
287
287
 
288
+ // GET /specialities/installed-skills — every skill THIS instance has, each
289
+ // explained the way the adoption walkthrough explains it, so a speciality's
290
+ // owner can choose what it offers from the hall (task 1004404). The CHOICES
291
+ // only: the POST/PATCH `skills` check (unknown_skill) is still the rule, so a
292
+ // stale page that sends a name removed since is refused, not trusted. Nothing
293
+ // here is per-builder and the list is public knowledge of the install — every
294
+ // name is already materialised into each checkout's .claude/skills/.
295
+ // Mounted before '/specialities/:id' would capture it as an id. Read-only;
296
+ // rank: any builder.
297
+ router.get('/specialities/installed-skills', api.requireBuilder, asyncHandler('GET /specialities/installed-skills', async (_req, res) => {
298
+ const skills = [...installedSkills()]
299
+ .map(([name, entry]) => skillsLib.explainSkill({ name, ...entry }))
300
+ .sort((a, b) => a.name.localeCompare(b.name));
301
+ res.json({ skills });
302
+ }));
303
+
288
304
  router.get('/specialities/:id', api.requireBuilder, asyncHandler('GET /specialities/:id', async (req, res) => {
289
305
  const id = parseId(req, res, { code: 'bad_id' });
290
306
  if (id === null) return;
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.20.10",
3
+ "version": "1.20.11",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.20.10",
9
+ "version": "1.20.11",
10
10
  "license": "AGPL-3.0-or-later",
11
11
  "dependencies": {
12
12
  "express": "^4.21.2",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.20.10",
3
+ "version": "1.20.11",
4
4
  "description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "main": "src/platform-server.js",
@@ -8112,5 +8112,11 @@
8112
8112
  "id": "1004403",
8113
8113
  "text": "When you adopt a speciality, each skill it offers is now explained in plain words: what it does, when you would use it, and what it costs, including which ones spend money. Before, those explanations were guessed from technica"
8114
8114
  }
8115
+ ],
8116
+ "1.20.11": [
8117
+ {
8118
+ "id": "1004404",
8119
+ "text": "You can now choose which skills your speciality offers from Settings, not just through the API. You can do it when you create the speciality or later from its card. Each skill is listed with a plain description and what it cos"
8120
+ }
8115
8121
  ]
8116
8122
  }
@@ -164,6 +164,10 @@ const CANNED_WRITES = [
164
164
  // opens the skills walkthrough and its recap can save in the harness.
165
165
  ['POST', /^specialities\/\d+\/adopt$/, 'specialities__9__adopt.post'],
166
166
  ['PUT', /^specialities\/\d+\/skills$/, 'specialities__9__skills.put'],
167
+ // the owner choosing what a speciality offers (task 1004404): on create, and
168
+ // afterwards from the card's "Offered skills" editor.
169
+ ['POST', /^specialities$/, 'specialities.post'],
170
+ ['PATCH', /^specialities\/\d+$/, 'specialities__12.patch'],
167
171
  ];
168
172
 
169
173
  // A DATE THAT ROTS CANNOT DEMO AN AGE SIGNAL (task 1004041). The help-request
package/src/module-api.js CHANGED
@@ -75,7 +75,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
75
75
  // MAJOR (see allowBoxScope below): passes the request through untouched.
76
76
  function deprecatedNoopMiddleware(_req, _res, next) { next(); }
77
77
 
78
- const CORE_VERSION = '1.20.10'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
78
+ const CORE_VERSION = '1.20.11'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
79
79
 
80
80
  // A namespaced logger so a module's log lines are attributable + consistent.
81
81
  // Usage: const log = api.logger('discord'); log.info('mounted');
@@ -0,0 +1,185 @@
1
+ // tests/speciality_offer_ui.mjs — a speciality's OWNER chooses what it offers,
2
+ // from the hall (task 1004404; ADR 0310 §1).
3
+ //
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.
9
+ //
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
+ //
14
+ // Run: node tests/speciality_offer_ui.mjs
15
+ import assert from 'node:assert/strict';
16
+ import fs from 'node:fs';
17
+ import path from 'node:path';
18
+ import vm from 'node:vm';
19
+ import { fileURLToPath } from 'node:url';
20
+ import { makeRunner } from './helpers.mjs';
21
+
22
+ 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');
25
+ const { test, summary } = makeRunner();
26
+
27
+ const INSTALLED = [
28
+ { name: 'builder-cost', does: 'Records money spent on the project.', cost: 'Free. It only adds an entry.' },
29
+ { name: 'recall', does: 'Searches everything the project already knows.', cost: 'Free, and it changes nothing.' },
30
+ { name: 'status', does: 'Shows how far along a version is.', cost: 'Free, and it changes nothing.' },
31
+ ];
32
+
33
+ const mine = (over = {}) => ({ id: 12, name: 'Pricing', discipline: 'engineer', summary: '', contract_md: '', visibility: 'private', adoptable: true, mine: true, skills: ['recall', 'retired-skill'], ...over });
34
+ const theirs = (over = {}) => ({ id: 9, name: 'Economics', discipline: 'ideator', summary: '', contract_md: '', visibility: 'public', adoptable: true, mine: false, skills: ['recall'], ...over });
35
+
36
+ // The checked boxes a form would submit, read off the rendered markup.
37
+ 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
+
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 };
98
+ }
99
+
100
+ const writes = (h) => h.requests.filter((r) => r.method !== 'GET');
101
+ const installedReads = (h) => h.requests.filter((r) => r.path === '/specialities/installed-skills').length;
102
+
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`);
119
+ }
120
+ assert.deepEqual(checkedIn(html), ['recall'], 'what it offers today is ticked');
121
+ assert.match(html, /Offering a skill turns nothing on/);
122
+ });
123
+
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
+ });
130
+
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
+ });
144
+
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
+ });
151
+
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
+ });
159
+
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
+ });
178
+
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
+ });
184
+
185
+ summary();
@@ -304,6 +304,15 @@ try {
304
304
  store.speciality = saved;
305
305
  });
306
306
 
307
+ await test('GET /specialities/installed-skills lists every installed skill, explained, for the owner\'s picker (task 1004404)', async () => {
308
+ const r = await call('GET', '/specialities/installed-skills');
309
+ assert.equal(r.status, 200, `not captured by /specialities/:id: ${JSON.stringify(r.body)}`);
310
+ assert.deepEqual(r.body.skills.map((s) => s.name), ['costing', 'recall', 'status'], 'every installed skill, by name — the choices are what the instance has');
311
+ const recall = r.body.skills.find((s) => s.name === 'recall');
312
+ assert.equal(recall.does, 'Search what the project knows. Read-only.', 'the same explanation the walkthrough shows');
313
+ assert.match(recall.cost, /^Read-only/);
314
+ });
315
+
307
316
  await test('PUT /specialities/:id/skills needs an active adoption', async () => {
308
317
  store.adoption = null;
309
318
  const r = await call('PUT', '/specialities/9/skills', { enabled_skills: ['recall'] });