@bongos/core 1.20.1 → 1.20.3

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.
@@ -58,6 +58,46 @@ await test('the real tree resolves: the core ships skills a speciality can offer
58
58
  assert.ok(got.has('recall') && got.has('status'), 'core skills are found in this checkout');
59
59
  });
60
60
 
61
+ await test('a FOLDED description (`>-`) is read as its text, not as the fold marker (task 1004073)', () => {
62
+ const fm = skills.parseFrontmatter('---\nname: x\ndescription: >-\n One line\n and the next.\nrequires: [gh, droplet-ssh]\ncost: "Free."\n---\nbody');
63
+ assert.equal(fm.description, 'One line and the next.');
64
+ assert.deepEqual(fm.requires, ['gh', 'droplet-ssh']);
65
+ assert.equal(fm.cost, 'Free.');
66
+ assert.equal(skills.parseFrontmatter('---\ndescription: |\n a\n b\n---\n').description, 'a\nb', 'a literal block keeps its lines');
67
+ assert.deepEqual(skills.parseFrontmatter('no frontmatter'), {});
68
+ });
69
+
70
+ await test('every skill in the real tree reads a real description — none is a bare fold marker', () => {
71
+ const got = skills.installedSkills({ coreRoot: ROOT, isModuleEnabled: () => false });
72
+ const bad = [...got.values()].filter((s) => !s.description || /^[>|][+-]?$/.test(s.description)).map((s) => s.name);
73
+ assert.deepEqual(bad, [], `these would teach nothing in the walkthrough: ${bad.join(', ')}`);
74
+ });
75
+
76
+ await test('explainSkill splits a model-facing description into what it does, when, and what it costs', () => {
77
+ const e = skills.explainSkill({
78
+ name: 'goal-review', source: 'core',
79
+ description: 'Walk the review queue. Read-only. Metic+ only. Triggers: "/goal-review", "review goals", "confirm criteria", or a scheduled run.',
80
+ requires: ['gh'],
81
+ });
82
+ assert.equal(e.does, 'Walk the review queue. Read-only.', 'the trigger list and the rank note are not part of what it does');
83
+ assert.equal(e.rank, 'metic', 'the rank is kept apart: enabling a skill grants nothing');
84
+ assert.deepEqual(e.commands, ['/goal-review']);
85
+ assert.match(e.reachFor, /“review goals”, “confirm criteria”.*a scheduled run/);
86
+ assert.match(e.cost, /^Read-only/);
87
+ assert.deepEqual(e.needs, ['gh']);
88
+ assert.deepEqual(e.derived, { does: true, reachFor: true, cost: true });
89
+ });
90
+
91
+ await test('explainSkill prefers the author\'s people-facing words and says when it had to guess', () => {
92
+ const e = skills.explainSkill({ name: 'x', description: 'Model text. Triggers: "/x".', plain: 'Plain words.', cost: 'About a dollar.' });
93
+ assert.equal(e.does, 'Plain words.');
94
+ assert.equal(e.cost, 'About a dollar.');
95
+ assert.deepEqual(e.derived, { does: false, reachFor: true, cost: false });
96
+ const unknown = skills.explainSkill({ name: 'y', description: 'Does a thing.' });
97
+ assert.match(unknown.cost, /has not written down what it costs/, 'an unknown cost is said plainly, never invented');
98
+ assert.match(skills.explainSkill({ name: 'z', description: 'Read-only check. --probe runs a paid calibration.' }).cost, /paid option/);
99
+ });
100
+
61
101
  await test('an unreadable root contributes nothing rather than throwing', () => {
62
102
  const got = skills.installedSkills({ coreRoot: path.join(os.tmpdir(), 'no-such-root-' + Date.now()), isModuleEnabled: () => { throw new Error('boom'); } });
63
103
  assert.equal(got.size, 0);
@@ -146,7 +186,12 @@ db.setEnabledSkills = async (b, s, list) => (store.adoption ? { ...store.adoptio
146
186
  db.createSpeciality = async (row) => { store.created = row; return { id: 10, ...row }; };
147
187
  db.updateSpeciality = async (id, patch) => { store.updated = patch; return { ...store.speciality, ...patch }; };
148
188
  db.countTraining = async () => ({ documentCount: 0, learningCount: 0 });
149
- skills.installedSkillsCached = () => new Map([['recall', {}], ['status', {}], ['costing', {}]]);
189
+ skills.installedSkillsCached = () => new Map([
190
+ ['recall', { name: 'recall', description: 'Search what the project knows. Read-only. Triggers: "/recall", "what do we know about X".', source: 'core' }],
191
+ ['status', { name: 'status', description: 'Roll up a version.', source: 'core' }],
192
+ ['costing', { name: 'costing', description: 'Price a thing.', source: 'module:econ' }],
193
+ ]);
194
+ db.listAdoptions = async () => (store.adoption ? [{ id: 9, active: store.adoption.active, enabled_skills: store.adoption.enabled_skills }] : []);
150
195
  api.requireBuilder = (req, res, next) => { req.builder = { id: 1, rank: 'metic' }; next(); };
151
196
  api.enabledDisciplines = () => ['ideator', 'engineer', 'artist'];
152
197
 
@@ -187,6 +232,36 @@ try {
187
232
  store.speciality.skills = ['recall', 'status'];
188
233
  });
189
234
 
235
+ await test('GET /specialities/:id/skills explains each OFFERED skill for the walkthrough, and the caller\'s own set', async () => {
236
+ store.adoption = null;
237
+ store.speciality.skills = ['recall', 'gone-now'];
238
+ const r = await call('GET', '/specialities/9/skills');
239
+ assert.equal(r.status, 200, JSON.stringify(r.body));
240
+ assert.equal(r.body.adopted, false);
241
+ assert.deepEqual(r.body.enabled, []);
242
+ assert.deepEqual(r.body.skills.map((s) => s.name), ['recall', 'gone-now'], 'the offered set, in its order — not every installed skill');
243
+ const recall = r.body.skills[0];
244
+ assert.equal(recall.installed, true);
245
+ assert.equal(recall.does, 'Search what the project knows. Read-only.');
246
+ assert.match(recall.reachFor, /what do we know about X/);
247
+ assert.match(recall.cost, /^Read-only/);
248
+ assert.deepEqual(r.body.skills[1], { name: 'gone-now', installed: false }, 'a skill removed since is named, not explained or offered');
249
+
250
+ store.adoption = { builder_id: 1, speciality_id: 9, active: true, enabled_skills: ['recall'] };
251
+ const held = await call('GET', '/specialities/9/skills');
252
+ assert.equal(held.body.adopted, true);
253
+ assert.deepEqual(held.body.enabled, ['recall']);
254
+ store.speciality.skills = ['recall', 'status'];
255
+ });
256
+
257
+ await test('GET /specialities/:id/skills is 404 for a speciality the caller cannot see', async () => {
258
+ const saved = store.speciality;
259
+ store.speciality = { ...saved, owner_builder_id: 2, visibility: 'private' };
260
+ const r = await call('GET', '/specialities/9/skills');
261
+ assert.equal(r.status, 404);
262
+ store.speciality = saved;
263
+ });
264
+
190
265
  await test('PUT /specialities/:id/skills needs an active adoption', async () => {
191
266
  store.adoption = null;
192
267
  const r = await call('PUT', '/specialities/9/skills', { enabled_skills: ['recall'] });
@@ -0,0 +1,218 @@
1
+ // tests/speciality_walkthrough_ui.mjs — the skills walkthrough a person is taken
2
+ // through when they adopt a speciality (task 1004073, part 2 of 3 of ADR 0310).
3
+ //
4
+ // The owner's ruling: an adopted speciality's skills start OFF, "and you should
5
+ // walk through what comes with each, people need to learn the system". The task
6
+ // names its own failure mode — "a settings page with eleven unchecked boxes
7
+ // satisfies the letter and defeats the intent" — so these pin the SHAPE of the
8
+ // flow, not just that a PUT happens: one skill at a time, the three answers on
9
+ // every step, no control that decides without the person seeing each skill, and
10
+ // nothing written until the recap is saved.
11
+ //
12
+ // This EXECUTES the real modules/hall-ui/public/settings-specialities.js in a vm
13
+ // under a minimal DOM stub (the tests/goal_criterion_confirm_ui.mjs precedent —
14
+ // there is no jsdom here), against a fake API, and drives real clicks through the
15
+ // delegated handler the page wires.
16
+ //
17
+ // Run: node tests/speciality_walkthrough_ui.mjs
18
+ import assert from 'node:assert/strict';
19
+ import fs from 'node:fs';
20
+ import path from 'node:path';
21
+ import vm from 'node:vm';
22
+ import { fileURLToPath } from 'node:url';
23
+ import { makeRunner } from './helpers.mjs';
24
+
25
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
26
+ const SRC = fs.readFileSync(path.join(ROOT, 'modules/hall-ui/public/settings-specialities.js'), 'utf8');
27
+ const { test, summary } = makeRunner();
28
+
29
+ const explained = (name, extra = {}) => ({
30
+ name, installed: true, source: 'core',
31
+ does: `${name} does its one thing. And a second sentence.`,
32
+ reachFor: `When you would say something like “${name} please”.`,
33
+ cost: 'Read-only: it looks things up and changes nothing.',
34
+ commands: [`/${name}`], needs: [], rank: null,
35
+ derived: { does: true, reachFor: true, cost: true },
36
+ ...extra,
37
+ });
38
+
39
+ function speciality(over = {}) {
40
+ return {
41
+ id: 9, name: 'Economics', discipline: 'ideator', summary: 'Markets.', contract_md: '',
42
+ visibility: 'public', adoptable: true, mine: false, skills: ['recall', 'status', 'goal-review', 'gone'],
43
+ ...over,
44
+ };
45
+ }
46
+
47
+ const SKILLS_BODY = {
48
+ speciality: { id: 9, name: 'Economics', discipline: 'ideator' },
49
+ adopted: true,
50
+ enabled: [],
51
+ skills: [
52
+ explained('recall'),
53
+ explained('status'),
54
+ explained('goal-review', { rank: 'metic', needs: ['gh'] }),
55
+ { name: 'gone', installed: false },
56
+ ],
57
+ };
58
+
59
+ // Boot the page script and return a handle to drive it.
60
+ async function boot({ catalog = [speciality()], skillsBody = SKILLS_BODY } = {}) {
61
+ const requests = [];
62
+ const toasts = [];
63
+ const els = new Map();
64
+ const handlers = new Map();
65
+ const makeEl = (id) => ({
66
+ id, hidden: id === 'specialities-scroll', innerHTML: '', textContent: '', elements: {},
67
+ addEventListener(type, fn) { handlers.set(`${id}:${type}`, fn); },
68
+ });
69
+ for (const id of ['specialities-scroll', 'specialities-rows', 'speciality-suggestions', 'specialities-status']) els.set(id, makeEl(id));
70
+ let focused = null;
71
+
72
+ const fetch = async (url, opts = {}) => {
73
+ const method = opts.method || 'GET';
74
+ const p = url.replace('/api/bongos', '');
75
+ requests.push({ method, path: p, body: opts.body ? JSON.parse(opts.body) : undefined });
76
+ let body = {};
77
+ if (method === 'GET' && p === '/specialities') body = { specialities: catalog };
78
+ else if (method === 'GET' && p === '/me/specialities') body = { adoptions: [] };
79
+ else if (method === 'GET' && p === '/specialities/offered') body = { suggestions: [] };
80
+ else if (method === 'GET' && /\/skills$/.test(p)) body = skillsBody;
81
+ else if (method === 'POST' && /\/adopt$/.test(p)) body = { adoption: { active: true } };
82
+ else if (method === 'PUT' && /\/skills$/.test(p)) body = { adoption: { enabled_skills: JSON.parse(opts.body).enabled_skills } };
83
+ return { ok: true, status: 200, json: async () => body };
84
+ };
85
+
86
+ const document = {
87
+ readyState: 'complete',
88
+ getElementById: (id) => els.get(id) || null,
89
+ addEventListener() {},
90
+ querySelector: (sel) => (sel.includes('data-walk-focus') ? { focus() { focused = 'walk-heading'; } } : null),
91
+ };
92
+ const window = {
93
+ OTB: {
94
+ escapeHtml: (s) => String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;'),
95
+ $: () => null,
96
+ toast: (m) => toasts.push(m),
97
+ roleAxisLabel: () => 'Role',
98
+ },
99
+ location: { assign() {} },
100
+ };
101
+ vm.runInNewContext(SRC, { window, document, fetch, JSON, Promise, Map, Set, String, Number, Array, Object, Math, RegExp, console });
102
+ const settle = () => new Promise((r) => setTimeout(r, 0));
103
+ for (let i = 0; i < 5; i++) await settle();
104
+
105
+ const rows = els.get('specialities-rows');
106
+ // A click through the delegated handler, on the first button whose attribute matches.
107
+ async function click(attr, value) {
108
+ const re = new RegExp(`<button[^>]*${attr}="${value}"[^>]*>`);
109
+ const tag = re.exec(rows.innerHTML);
110
+ assert.ok(tag, `no ${attr}="${value}" button in:\n${rows.innerHTML}`);
111
+ const dataset = {};
112
+ for (const [, k, v] of tag[0].matchAll(/data-([a-z]+)="([^"]*)"/g)) dataset[k] = v;
113
+ const btn = { dataset };
114
+ const target = { closest: (sel) => (sel === `button[${attr}]` ? btn : null) };
115
+ handlers.get('specialities-rows:click')({ target });
116
+ for (let i = 0; i < 6; i++) await settle();
117
+ }
118
+ const text = () => rows.innerHTML.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ');
119
+ return { els, rows, requests, toasts, click, text, focused: () => focused };
120
+ }
121
+
122
+ const writes = (h) => h.requests.filter((r) => r.method !== 'GET');
123
+
124
+ await test('the specialities panel is revealed once its reads answer — it shipped hidden and nothing ever showed it', async () => {
125
+ const h = await boot();
126
+ assert.equal(h.els.get('specialities-scroll').hidden, false);
127
+ });
128
+
129
+ await test('adopting a speciality that offers skills opens the walkthrough on the FIRST skill, with nothing written yet', async () => {
130
+ const h = await boot();
131
+ await h.click('data-act', 'adopt');
132
+ assert.ok(h.requests.some((r) => r.method === 'GET' && r.path === '/specialities/9/skills'), 'the walkthrough read its skills');
133
+ assert.match(h.text(), /Skill 1 of 3 in Economics/, 'only INSTALLED skills are steps');
134
+ assert.equal(h.focused(), 'walk-heading', 'focus moves to the step, not left on a button that is gone');
135
+ assert.deepEqual(writes(h).map((r) => r.path), ['/specialities/9/adopt'], 'no enabled set was written by adopting');
136
+ });
137
+
138
+ await test('it is not a page of checkboxes: one skill per step, and no control decides for several at once', async () => {
139
+ const h = await boot();
140
+ await h.click('data-act', 'adopt');
141
+ assert.ok(!/type="checkbox"/.test(h.rows.innerHTML), 'no checkbox anywhere in the walkthrough');
142
+ assert.ok(!/(turn|enable|select) (them )?all/i.test(h.text()), 'no "turn them all on"');
143
+ assert.ok(h.text().includes('recall') && !h.text().includes('status does its one thing'), 'the next skill is not shown yet');
144
+ });
145
+
146
+ await test('every step answers what it does, when to reach for it, and what it costs', async () => {
147
+ const h = await boot();
148
+ await h.click('data-act', 'adopt');
149
+ for (let step = 0; step < 3; step++) {
150
+ const t = h.text();
151
+ for (const q of ['What it does', 'When to reach for it', 'What it costs']) assert.ok(t.includes(q), `step ${step + 1} is missing "${q}"`);
152
+ await h.click('data-walk', 'off');
153
+ }
154
+ });
155
+
156
+ await test('a rank-gated skill says so BEFORE the choice — turning it on grants nothing', async () => {
157
+ const h = await boot();
158
+ await h.click('data-act', 'adopt');
159
+ await h.click('data-walk', 'off');
160
+ await h.click('data-walk', 'off');
161
+ const t = h.text();
162
+ assert.match(t, /Skill 3 of 3/);
163
+ assert.match(t, /Metic rank or above.*does not change that/);
164
+ assert.match(t, /What it needs on your machine gh/);
165
+ });
166
+
167
+ await test('the recap names every skill with what it does and the choice made, then saves exactly the ones turned on', async () => {
168
+ const h = await boot();
169
+ await h.click('data-act', 'adopt');
170
+ await h.click('data-walk', 'on');
171
+ await h.click('data-walk', 'off');
172
+ await h.click('data-walk', 'on');
173
+ const t = h.text();
174
+ assert.match(t, /Your choices for Economics/);
175
+ assert.match(t, /2 of 3 turned on/);
176
+ for (const n of ['recall', 'status', 'goal-review']) assert.ok(t.includes(`${n} does its one thing.`), `the recap restates what ${n} does`);
177
+ assert.ok(!t.includes('And a second sentence'), 'one line each, not the whole description again');
178
+ assert.match(t, /no longer on this project.*gone/, 'an offered skill removed since is named, not offered');
179
+ assert.equal(writes(h).length, 1, 'still nothing written before Save');
180
+ await h.click('data-walk', 'save');
181
+ const put = writes(h).find((r) => r.method === 'PUT');
182
+ assert.ok(put, 'Save wrote the set');
183
+ assert.equal(put.path, '/specialities/9/skills');
184
+ assert.deepEqual(put.body, { enabled_skills: ['recall', 'goal-review'] });
185
+ assert.ok(h.toasts.some((m) => /2 of 3/.test(m)));
186
+ });
187
+
188
+ await test('Back revisits a decision and the new choice is the one saved', async () => {
189
+ const h = await boot();
190
+ await h.click('data-act', 'adopt');
191
+ await h.click('data-walk', 'on');
192
+ await h.click('data-walk', 'back');
193
+ assert.match(h.text(), /Skill 1 of 3/);
194
+ assert.match(h.rows.innerHTML, /data-walk="on" aria-pressed="true"/, 'the earlier choice shows when you step back to it');
195
+ await h.click('data-walk', 'off');
196
+ await h.click('data-walk', 'off');
197
+ await h.click('data-walk', 'off');
198
+ await h.click('data-walk', 'save');
199
+ assert.deepEqual(writes(h).find((r) => r.method === 'PUT').body, { enabled_skills: [] });
200
+ });
201
+
202
+ await test('finishing later writes nothing, so every skill stays off — the default the ruling asked for', async () => {
203
+ const h = await boot();
204
+ await h.click('data-act', 'adopt');
205
+ await h.click('data-walk', 'on');
206
+ await h.click('data-walk', 'close');
207
+ assert.ok(!/skill-walk/.test(h.rows.innerHTML), 'the walkthrough closed');
208
+ assert.ok(!writes(h).some((r) => r.method === 'PUT'), 'a half-finished walk saves nothing');
209
+ });
210
+
211
+ await test('a speciality that offers no skills adopts without a walkthrough', async () => {
212
+ const h = await boot({ catalog: [speciality({ skills: [] })] });
213
+ await h.click('data-act', 'adopt');
214
+ assert.ok(!h.requests.some((r) => /\/skills$/.test(r.path)), 'no skills read for a speciality with none');
215
+ assert.ok(!/skill-walk/.test(h.rows.innerHTML));
216
+ });
217
+
218
+ summary();