@bongos/core 1.19.516 → 1.19.518
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 +38 -33
- package/clients/bongos-client/index.d.ts +1 -1
- package/docs/api/openapi.json +2 -0
- package/docs/copy-inventory.md +128 -121
- package/docs/copy-registry.json +197 -134
- package/docs/module-api-changelog.md +4 -0
- package/modules/builder-settings/builder-needs.js +28 -2
- package/modules/hall-ui/public/collab.html +19 -0
- package/modules/hall-ui/public/collab.js +78 -0
- package/modules/hall-ui/public/drachmae.html +3 -0
- package/modules/hall-ui/public/drachmae.js +138 -2
- package/modules/hall-ui/public/hall-kit.js +45 -7
- package/modules/hall-ui/public/hall-render.js +6 -0
- package/modules/hall-ui/public/profile-charts.js +6 -13
- package/modules/hall-ui/public/profile.css +6 -35
- package/modules/hall-ui/public/shell.js +30 -1
- package/modules/hall-ui/public/style.css +35 -0
- package/modules/lifecycle/lifecycle.js +8 -0
- package/modules/lifecycle/mingle.js +17 -1
- package/modules/lifecycle/routes/mingle.js +5 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/src/bongos/routes/me.js +39 -1
- package/src/module-api.js +1 -1
- package/tests/hall_kit_charts.mjs +49 -0
- package/tests/hall_reference_world.mjs +129 -0
- package/tests/mingle.mjs +26 -0
- package/tests/social_mode.mjs +181 -0
|
@@ -306,6 +306,22 @@ async function openPairsFor(builderId, deps = {}) {
|
|
|
306
306
|
return rows;
|
|
307
307
|
}
|
|
308
308
|
|
|
309
|
+
// How many open introductions are waiting on this builder — the count the hall's
|
|
310
|
+
// needs roster renders (task 1003178 / C08). A COUNT rather than the rows,
|
|
311
|
+
// because /me is the session-open hot path and the roster only needs a number;
|
|
312
|
+
// the rows are `openPairsFor`, read by the surface that shows them.
|
|
313
|
+
async function countOpenPairsFor(builderId, deps = {}) {
|
|
314
|
+
const activePool = deps.pool || pool;
|
|
315
|
+
const { rows } = await activePool.query(
|
|
316
|
+
`SELECT count(*)::int AS n
|
|
317
|
+
FROM lifecycle_mingle_pairs
|
|
318
|
+
WHERE status = 'proposed'
|
|
319
|
+
AND (builder_lo = $1 OR builder_hi = $1)`,
|
|
320
|
+
[builderId]
|
|
321
|
+
);
|
|
322
|
+
return rows[0] ? rows[0].n : 0;
|
|
323
|
+
}
|
|
324
|
+
|
|
309
325
|
// Accept or decline an introduction you are part of.
|
|
310
326
|
//
|
|
311
327
|
// ONE ROW, ONE TABLE. There is nothing else to write, and that absence IS the
|
|
@@ -333,5 +349,5 @@ module.exports = {
|
|
|
333
349
|
parseEnabled, projectEnabled, setProjectEnabled,
|
|
334
350
|
optInState, setOptIn, mingleStateFor,
|
|
335
351
|
eligibleBuilders, isCrossCraft, planPairs, pairKey, openPairKeys,
|
|
336
|
-
runMingleRound, openPairsFor, decidePair,
|
|
352
|
+
runMingleRound, openPairsFor, countOpenPairsFor, decidePair,
|
|
337
353
|
};
|
|
@@ -55,6 +55,11 @@ module.exports = function buildMingleRouter() {
|
|
|
55
55
|
return res.json({
|
|
56
56
|
mingle: state,
|
|
57
57
|
open_pairs: pairs,
|
|
58
|
+
// task 1003178 (C08): WHICH SIDE OF THE PAIR THE CALLER IS. A pair row
|
|
59
|
+
// carries builder_lo/builder_hi with no marker for "you", so a surface
|
|
60
|
+
// rendering it from the viewer's side would otherwise need a second /me
|
|
61
|
+
// fetch just to find out which id to name. The route already knows.
|
|
62
|
+
you: String(req.builder.id),
|
|
58
63
|
// Served VERBATIM from the module. The criterion asks for cross-craft to be
|
|
59
64
|
// chosen deliberately and RECORDED; this is where it is read, and a
|
|
60
65
|
// restatement here could drift from the matcher that actually runs.
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.518",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@bongos/core",
|
|
9
|
-
"version": "1.19.
|
|
9
|
+
"version": "1.19.518",
|
|
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.19.
|
|
3
|
+
"version": "1.19.518",
|
|
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",
|
package/src/bongos/routes/me.js
CHANGED
|
@@ -105,7 +105,7 @@ module.exports = function buildMeRouter() {
|
|
|
105
105
|
builder, activeClaims, estVsActual, unlocked, lockedWithProgress, streakInfo,
|
|
106
106
|
recentGrades, builderAvg, onboardingRow, shipCount, recentKarma, karma30d, discordLink, boxConnected, cliAuthed,
|
|
107
107
|
behaviorPrefs, geminiKeyRow, needsRebaseTasks, foundingBuilder, helpRequestCount,
|
|
108
|
-
taskRecommendationCount, boardVotesPending,
|
|
108
|
+
taskRecommendationCount, boardVotesPending, socialMode, mingleIntroductionCount,
|
|
109
109
|
] = await Promise.all([
|
|
110
110
|
db.getBuilderById(req.builder.id),
|
|
111
111
|
lc().getActiveClaimsForBuilder(req.builder.id),
|
|
@@ -205,6 +205,29 @@ module.exports = function buildMeRouter() {
|
|
|
205
205
|
return await government.pendingBoardVotesFor(req.builder.id);
|
|
206
206
|
} catch (_) { return null; }
|
|
207
207
|
})(),
|
|
208
|
+
// task 1003178 (C08): SOCIAL MODE — the pair of switches C07 resolved
|
|
209
|
+
// (the project enabled mingle AND this builder opted in). It rides /me
|
|
210
|
+
// because the criterion's whole point is that social changes the HALL
|
|
211
|
+
// rather than adding a destination: every page already loads /me, so the
|
|
212
|
+
// shell can stamp the mode on the document and any surface can read it.
|
|
213
|
+
// Same fan-out slot and FAIL-OPEN posture as the reads above — null, not a
|
|
214
|
+
// throw, and the shell treats null as OFF (a mode that cannot be read is
|
|
215
|
+
// not on).
|
|
216
|
+
(async () => {
|
|
217
|
+
try {
|
|
218
|
+
const lifecycle = lc();
|
|
219
|
+
if (!lifecycle || !lifecycle.socialModeFor) return null;
|
|
220
|
+
return await lifecycle.socialModeFor(req.builder.id);
|
|
221
|
+
} catch (_) { return null; }
|
|
222
|
+
})(),
|
|
223
|
+
// The introductions waiting on this builder, for the needs roster.
|
|
224
|
+
(async () => {
|
|
225
|
+
try {
|
|
226
|
+
const lifecycle = lc();
|
|
227
|
+
if (!lifecycle || !lifecycle.openMingleIntroductionsFor) return 0;
|
|
228
|
+
return await lifecycle.openMingleIntroductionsFor(req.builder.id);
|
|
229
|
+
} catch (_) { return 0; }
|
|
230
|
+
})(),
|
|
208
231
|
]);
|
|
209
232
|
// V3.R84 (#302), criterion C3: the builder's month-to-date spend vs their
|
|
210
233
|
// monthly budget. Surfaced here so the CLI (session-start ping) and the hall
|
|
@@ -257,6 +280,19 @@ module.exports = function buildMeRouter() {
|
|
|
257
280
|
// Resolved by economy (never re-derived); null if economy is disabled.
|
|
258
281
|
reward: rewardPolicySafe(),
|
|
259
282
|
est_vs_actual: estVsActual,
|
|
283
|
+
// task 1003178 (C08, goal 1000075): SOCIAL MODE. The owner's directive is
|
|
284
|
+
// that social is "scoped into the ENTIRE builders hall, not a single social
|
|
285
|
+
// tab", and this key is the mechanism: every hall page already loads /me,
|
|
286
|
+
// so the shell stamps the mode on the document and any surface reads it
|
|
287
|
+
// from there. A /social ROUTE is what the criterion explicitly does not
|
|
288
|
+
// accept, so there is deliberately no route to add — the mode travels with
|
|
289
|
+
// the identity the whole hall is already fetching.
|
|
290
|
+
//
|
|
291
|
+
// Shape: { enabled, project_enabled, opted_in, opted_in_at }, both switches
|
|
292
|
+
// reported separately so a surface can say WHICH half is off (C07). null
|
|
293
|
+
// when lifecycle is disabled or the read failed, and the shell treats null
|
|
294
|
+
// as OFF — a mode that cannot be read is not on.
|
|
295
|
+
social: socialMode,
|
|
260
296
|
unlocked,
|
|
261
297
|
locked_with_progress: lockedWithProgress,
|
|
262
298
|
streak: {
|
|
@@ -354,6 +390,8 @@ module.exports = function buildMeRouter() {
|
|
|
354
390
|
// this builder, read through the same `lifecycle` port so core never
|
|
355
391
|
// imports the module (ADR 0093).
|
|
356
392
|
openTaskRecommendations: taskRecommendationCount,
|
|
393
|
+
// task 1003178 (C08): open mingle introductions waiting on this builder.
|
|
394
|
+
openMingleIntroductions: mingleIntroductionCount,
|
|
357
395
|
}) : [],
|
|
358
396
|
};
|
|
359
397
|
// Module→core contribution seam (BV1.R42, ADR 0083). Let each enabled module
|
package/src/module-api.js
CHANGED
|
@@ -45,7 +45,7 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
|
|
|
45
45
|
// there. scripts/gds/bump-version.js still rewrites the literal below; it appends
|
|
46
46
|
// the entry to that file. Look for a version's history there, not here.
|
|
47
47
|
// ---------------------------------------------------------------------------
|
|
48
|
-
const CORE_VERSION = '1.19.
|
|
48
|
+
const CORE_VERSION = '1.19.518'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
|
|
49
49
|
|
|
50
50
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
51
51
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|
|
@@ -76,6 +76,55 @@ test('cumulate is a running total that survives holes', () => {
|
|
|
76
76
|
assert.deepEqual(kit.cumulate([]), []);
|
|
77
77
|
});
|
|
78
78
|
|
|
79
|
+
test('cumulateByDay groups a day, cumulates it, and drops what will not parse', () => {
|
|
80
|
+
// The third caller of this shape put it in the kit (task 1003446): the
|
|
81
|
+
// profile's two lines and /drachmae's treasury.
|
|
82
|
+
const pts = kit.cumulateByDay([
|
|
83
|
+
{ day: '2026-05-02T10:00:00Z', v: 1 },
|
|
84
|
+
{ day: '2026-05-02T22:00:00Z', v: 2 }, // same day → one point
|
|
85
|
+
{ day: '2026-05-04T00:00:00Z', v: 4 },
|
|
86
|
+
], { valueOf: (r) => r.v });
|
|
87
|
+
assert.deepEqual(pts.map((p) => p.value), [3, 7], 'summed within a day, cumulative across');
|
|
88
|
+
assert.equal(pts.length, 2, 'a day with no row is not invented');
|
|
89
|
+
assert.ok(pts[0].x < pts[1].x, 'ascending');
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test('an unparsable date is dropped, not counted at the epoch', () => {
|
|
93
|
+
// Counted at 0 it would stretch the axis back to 1970 and flatten every real
|
|
94
|
+
// point onto the floor — the failure mode is a chart, not an exception.
|
|
95
|
+
const pts = kit.cumulateByDay([
|
|
96
|
+
{ day: 'not a date', v: 99 },
|
|
97
|
+
{ day: '2026-05-02T10:00:00Z', v: 5 },
|
|
98
|
+
], { valueOf: (r) => r.v });
|
|
99
|
+
assert.deepEqual(pts.map((p) => p.value), [5]);
|
|
100
|
+
assert.ok(pts[0].x > Date.parse('2020-01-01'), 'and the axis starts in this decade');
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test('cumulateByDay reads the date column the caller names', () => {
|
|
104
|
+
// shipped_at on an activity row, recorded_at on a credits row, day on a
|
|
105
|
+
// gap-filled feed — only the caller knows which.
|
|
106
|
+
const pts = kit.cumulateByDay(
|
|
107
|
+
[{ recorded_at: '2026-05-02T10:00:00Z', delta: 8 }],
|
|
108
|
+
{ dateOf: (r) => r.recorded_at, valueOf: (r) => r.delta },
|
|
109
|
+
);
|
|
110
|
+
assert.deepEqual(pts.map((p) => p.value), [8]);
|
|
111
|
+
assert.deepEqual(kit.cumulateByDay(null), [], 'and no rows is no points');
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test('a plot with no readout ships no focus stop and no dead crosshair', () => {
|
|
115
|
+
// A role="img" with tabindex="0" is a focus stop a keyboard reader lands on
|
|
116
|
+
// with nothing to do; a crosshair nothing moves only looks like a feature.
|
|
117
|
+
const inert = kit.plotSvgHtml({ series: '<path/>', ariaLabel: 'Spent', interactive: false });
|
|
118
|
+
assert.doesNotMatch(inert, /tabindex/);
|
|
119
|
+
assert.doesNotMatch(inert, /kit-plot__hair/);
|
|
120
|
+
assert.match(inert, /aria-label="Spent"/, 'the label is still the reading');
|
|
121
|
+
assert.match(inert, /kit-plot__axis/, 'and the axis is still drawn');
|
|
122
|
+
// The default stays interactive, because the profile wires a readout.
|
|
123
|
+
const live = kit.plotSvgHtml({ series: '<path/>' });
|
|
124
|
+
assert.match(live, /tabindex="0"/);
|
|
125
|
+
assert.match(live, /kit-plot__hair/);
|
|
126
|
+
});
|
|
127
|
+
|
|
79
128
|
test('the line is scaled to its own min and max, and a flat series sits mid-box', () => {
|
|
80
129
|
const g = kit.linePoints([0, 10], { width: 100, height: 50, pad: 0 });
|
|
81
130
|
const ys = g.points.split(' ').map((p) => Number(p.split(',')[1]));
|
|
@@ -220,3 +220,132 @@ test('the reference renders exactly one h1, the page head\'s', () => {
|
|
|
220
220
|
assert.equal([...html.matchAll(/<h1\b/g)].length, 1);
|
|
221
221
|
assert.match(html, /<h1 class="page-head__h">/);
|
|
222
222
|
});
|
|
223
|
+
|
|
224
|
+
// ---- 9. the treasury section (task 1003446) --------------------------------
|
|
225
|
+
//
|
|
226
|
+
// The sharpest version of goal 1000072's thesis. /drachmae is labelled Economy
|
|
227
|
+
// in the nav and was a markdown reader with ZERO live numbers, while the
|
|
228
|
+
// signed-out status page already drew this very endpoint's cumulative line and
|
|
229
|
+
// the Work board fetched the same payload to read ONE scalar off it. The
|
|
230
|
+
// stranger got the chart; the builder got the number.
|
|
231
|
+
//
|
|
232
|
+
// The section's ARITHMETIC is not asserted here — it is the kit's
|
|
233
|
+
// (`cumulateByDay`, `timeStepSeriesHtml`, `barSeriesHtml`) and
|
|
234
|
+
// tests/hall_kit_charts.mjs runs the real kit against it. What these hold is
|
|
235
|
+
// the page's half of the contract, the half a kit test cannot see.
|
|
236
|
+
|
|
237
|
+
const costSummaryFixture = JSON.parse(
|
|
238
|
+
fs.readFileSync(path.join(ROOT, 'modules', 'ui-design', 'kit', 'fixtures', 'cost-summary.json'), 'utf8'),
|
|
239
|
+
);
|
|
240
|
+
|
|
241
|
+
test('the treasury reads the endpoint that already carried the series — no new route', () => {
|
|
242
|
+
// The whole point of the task: the payload had both series all along, and the
|
|
243
|
+
// route's own comment calls daily[] "the treasury chart's cumulative line".
|
|
244
|
+
assert.match(drachmaeJs, /TREASURY_URL = '\/api\/gds\/public\/cost-summary'/);
|
|
245
|
+
assert.ok(costSummaryFixture.daily.length, 'the payload really carries daily[]');
|
|
246
|
+
assert.ok(costSummaryFixture.daily_credits.length, 'and daily_credits[]');
|
|
247
|
+
assert.ok(Number(costSummaryFixture.total_usd) > 0, 'and the totals the prose states');
|
|
248
|
+
assert.ok(Number(costSummaryFixture.total_value_credits) > 0);
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
test('RUN: the cumulative line ends exactly on the total the prose states', async () => {
|
|
252
|
+
// The invariant that matters, and the one a source-text assertion cannot
|
|
253
|
+
// reach: the chart and the sentence above it are derived from the SAME
|
|
254
|
+
// payload, so they can never disagree. Run against the real kit and the real
|
|
255
|
+
// checked-in summary, which is where a units or gap-fill mistake would show
|
|
256
|
+
// up as a line ending somewhere other than the headline number.
|
|
257
|
+
const kitPath = path.join(HALL, 'hall-kit.js');
|
|
258
|
+
const kit = await import(`file://${kitPath.replace(/\\/g, '/')}`).then((m) => m.default);
|
|
259
|
+
const spend = kit.cumulateByDay(costSummaryFixture.daily, { valueOf: (r) => r.amount_usd });
|
|
260
|
+
const earned = kit.cumulateByDay(costSummaryFixture.daily_credits, { valueOf: (r) => r.credits });
|
|
261
|
+
assert.ok(spend.length > 1 && earned.length > 1, 'both series have depth');
|
|
262
|
+
assert.ok(Math.abs(spend[spend.length - 1].value - Number(costSummaryFixture.total_usd)) < 1,
|
|
263
|
+
'the spend line ends on total_usd');
|
|
264
|
+
assert.equal(earned[earned.length - 1].value, Number(costSummaryFixture.total_value_credits),
|
|
265
|
+
'and the awarded line ends on total_value_credits');
|
|
266
|
+
// Whatever the plot draws from that must be finite — a NaN in a path `d`
|
|
267
|
+
// renders as an invisible chart with no error anywhere.
|
|
268
|
+
const series = kit.timeStepSeriesHtml(spend, {
|
|
269
|
+
x0: spend[0].x, x1: spend[spend.length - 1].x, yMax: spend[spend.length - 1].value,
|
|
270
|
+
});
|
|
271
|
+
assert.doesNotMatch(series.body, /NaN|Infinity/);
|
|
272
|
+
assert.doesNotMatch(
|
|
273
|
+
kit.barSeriesHtml(costSummaryFixture.by_category.map((c) => ({ label: c.category, value: c.total_usd }))),
|
|
274
|
+
/NaN|Infinity/,
|
|
275
|
+
);
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
test('the page loads the chart primitive BEFORE the script that reads it', () => {
|
|
279
|
+
// window.OTBKit read at insert time with the kit loaded after it is an empty
|
|
280
|
+
// section in production and a green unit suite.
|
|
281
|
+
const loaded = [...drachmaeHtml.matchAll(/<script[^>]+src="([^"?]+)/g)].map((m) => m[1].split('/').pop());
|
|
282
|
+
const kitAt = loaded.indexOf('hall-kit.js');
|
|
283
|
+
const pageAt = loaded.indexOf('drachmae.js');
|
|
284
|
+
assert.ok(kitAt > -1, 'drachmae.html must load hall-kit.js');
|
|
285
|
+
assert.ok(pageAt > -1, 'and drachmae.js');
|
|
286
|
+
assert.ok(kitAt < pageAt, 'kit first');
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
test('every mark on the page is drawn by the kit, none by hand', () => {
|
|
290
|
+
const js = dejscomment(drachmaeJs);
|
|
291
|
+
for (const fn of ['cumulateByDay', 'timeStepSeriesHtml', 'plotSvgHtml', 'plotValueLabelHtml', 'barSeriesHtml']) {
|
|
292
|
+
assert.match(js, new RegExp(`(kit|window\\.OTBKit)\\.${fn}\\(`), `the treasury draws through ${fn}`);
|
|
293
|
+
}
|
|
294
|
+
// The §6b audit rule holds this globally; asserted here too because THIS is
|
|
295
|
+
// the page that had no chart at all and so the easiest place to hand-roll one.
|
|
296
|
+
assert.doesNotMatch(js, /<(?:path|polyline|rect)\b[^>]*\b(?:d|points)="\$\{/,
|
|
297
|
+
'no hand-built SVG geometry on the reference page');
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
test('the section degrades to NOTHING rather than an empty panel', () => {
|
|
301
|
+
const js = dejscomment(drachmaeJs);
|
|
302
|
+
// The bounty section's contract, kept: a failed read leaves the prose alone
|
|
303
|
+
// and adds no outline entry that leads nowhere.
|
|
304
|
+
const fn = js.slice(js.indexOf('async function insertTreasurySection'));
|
|
305
|
+
const body = fn.slice(0, fn.indexOf('\n }\n'));
|
|
306
|
+
assert.match(body, /console\.warn\([^)]*cost summary unavailable/);
|
|
307
|
+
assert.equal((body.match(/return false;/g) || []).length, 3,
|
|
308
|
+
'three honest exits: no kit, an unreadable summary, and a treasury with nothing in it');
|
|
309
|
+
assert.match(body, /if \(!spend\.length && !earned\.length\) return false;/,
|
|
310
|
+
'both series empty is one honest nothing, not two empty plots');
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
test('the plots ship no focus stop, because nothing here wires a readout', () => {
|
|
314
|
+
// A role="img" with tabindex="0" is a focus stop a keyboard reader lands on
|
|
315
|
+
// with nothing to do, and a crosshair nothing moves only looks like a
|
|
316
|
+
// feature. The kit half is asserted in tests/hall_kit_charts.mjs.
|
|
317
|
+
assert.match(dejscomment(drachmaeJs), /interactive: false/);
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
test('the currency is the branding pack\'s, never a hardcoded word', () => {
|
|
321
|
+
const js = dejscomment(drachmaeJs);
|
|
322
|
+
assert.match(js, /__BRANDING__ && window\.__BRANDING__\.currency/);
|
|
323
|
+
assert.match(js, /\|\| 'credits'/, 'with a neutral fallback, not a world-specific one');
|
|
324
|
+
assert.doesNotMatch(js.replace(/\|\| 'credits'/g, ''), /drachmae'/i,
|
|
325
|
+
'the instance\'s own currency name is not spelled into the core page');
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
test('the treasury lands ahead of the rate card, so the doc still ends on "dig deeper"', () => {
|
|
329
|
+
const js = dejscomment(drachmaeJs);
|
|
330
|
+
// The bounty section goes in first and the treasury anchors on its id, so the
|
|
331
|
+
// order is prose → what happened → the published rates → the closing links.
|
|
332
|
+
const boot = js.slice(js.indexOf('async function boot()'));
|
|
333
|
+
assert.ok(boot.indexOf('insertBountySection(article)') < boot.indexOf('insertTreasurySection(article)'),
|
|
334
|
+
'the rate card is inserted first, so the treasury can anchor ahead of it');
|
|
335
|
+
assert.match(js, /article\.querySelector\(`#\$\{BOUNTY_SECTION_ID\}`\)/);
|
|
336
|
+
assert.match(js, /CLOSING_H2\.test/, 'and it falls back to the closing links, then to appending');
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
test('the figure chrome is shared, because a second page now draws figures', () => {
|
|
340
|
+
// .pchart* moved from profile.css to style.css with this task. drachmae.html
|
|
341
|
+
// loads style.css and NOT profile.css, so a copy left behind would render
|
|
342
|
+
// this page's figures unstyled.
|
|
343
|
+
const style = decomment(read('style.css'));
|
|
344
|
+
const profile = decomment(read('profile.css'));
|
|
345
|
+
for (const sel of ['.pcharts', '.pchart ', '.pchart__title', '.pchart__twin', '.pchart__foot']) {
|
|
346
|
+
assert.ok(style.includes(sel), `${sel} is in the shared sheet`);
|
|
347
|
+
}
|
|
348
|
+
assert.ok(!/^\.pcharts\s*\{/m.test(profile), 'and not left duplicated in profile.css');
|
|
349
|
+
assert.match(drachmaeHtml, /style\.css/);
|
|
350
|
+
assert.doesNotMatch(drachmaeHtml, /profile\.css/, 'the reference page must not need the profile sheet');
|
|
351
|
+
});
|
package/tests/mingle.mjs
CHANGED
|
@@ -283,6 +283,32 @@ test('a declined pair may be introduced again — a decline is not a permanent s
|
|
|
283
283
|
assert.equal(mingle.planPairs(eligible, { excludePairs: new Set() }).length, 1);
|
|
284
284
|
});
|
|
285
285
|
|
|
286
|
+
test('the roster COUNT sees both sides of a pair, and only open ones', async () => {
|
|
287
|
+
// task 1003178 (C08) added countOpenPairsFor for the needs roster, and the
|
|
288
|
+
// grader was right that it shipped untested while every other DB function here
|
|
289
|
+
// is driven against the recording pool. A count is exactly the kind of query
|
|
290
|
+
// that goes wrong quietly: miss a side of the pair and half the hall never
|
|
291
|
+
// learns it has an introduction waiting.
|
|
292
|
+
const pool = {
|
|
293
|
+
queries: [],
|
|
294
|
+
async query(sql, params) {
|
|
295
|
+
this.queries.push({ sql, params });
|
|
296
|
+
return { rows: [{ n: 3 }] };
|
|
297
|
+
},
|
|
298
|
+
};
|
|
299
|
+
const n = await mingle.countOpenPairsFor(42, { pool });
|
|
300
|
+
assert.equal(n, 3);
|
|
301
|
+
const { sql, params } = pool.queries[0];
|
|
302
|
+
assert.match(sql, /builder_lo = \$1 OR builder_hi = \$1/, 'either side of the pair counts');
|
|
303
|
+
assert.match(sql, /status = 'proposed'/, 'a settled introduction is not waiting on anyone');
|
|
304
|
+
assert.deepEqual(params, [42]);
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
test('the count answers 0 rather than undefined on an empty read', async () => {
|
|
308
|
+
const pool = { async query() { return { rows: [] }; } };
|
|
309
|
+
assert.equal(await mingle.countOpenPairsFor(42, { pool }), 0);
|
|
310
|
+
});
|
|
311
|
+
|
|
286
312
|
test('the exclude set is built from OPEN pairs only, never from declines', async () => {
|
|
287
313
|
const pool = recordingPool({ openPairs: [['1', '2']] });
|
|
288
314
|
await mingle.openPairKeys({ pool });
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
// tests/social_mode.mjs — with social on, the HALL changes; it does not gain a
|
|
2
|
+
// tab (task 1003178 / C08, goal 1000075; criterion collab-hall-becomes-a-mode).
|
|
3
|
+
//
|
|
4
|
+
// The criterion is unusual in that it states its own DISQUALIFIER: "a /social
|
|
5
|
+
// route with the same hall around it does NOT satisfy it". So the first block
|
|
6
|
+
// below tests the absence — no route, no nav item, no page file — and the rest
|
|
7
|
+
// tests that existing surfaces read differently instead.
|
|
8
|
+
//
|
|
9
|
+
// The owner's directive, verbatim in the goal: social "is certainly something
|
|
10
|
+
// that should be scoped into the ENTIRE builders hall, not a single social tab".
|
|
11
|
+
import { strict as assert } from 'node:assert';
|
|
12
|
+
import { test } from 'node:test';
|
|
13
|
+
import { createRequire } from 'node:module';
|
|
14
|
+
import fs from 'node:fs';
|
|
15
|
+
import path from 'node:path';
|
|
16
|
+
import { fileURLToPath } from 'node:url';
|
|
17
|
+
|
|
18
|
+
const require = createRequire(import.meta.url);
|
|
19
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
20
|
+
const read = (...p) => fs.readFileSync(path.join(ROOT, ...p), 'utf8');
|
|
21
|
+
|
|
22
|
+
const SHELL = read('modules', 'hall-ui', 'public', 'shell.js');
|
|
23
|
+
const HALL_RENDER = read('modules', 'hall-ui', 'public', 'hall-render.js');
|
|
24
|
+
const COLLAB_JS = read('modules', 'hall-ui', 'public', 'collab.js');
|
|
25
|
+
const COLLAB_HTML = read('modules', 'hall-ui', 'public', 'collab.html');
|
|
26
|
+
const ME = read('src', 'bongos', 'routes', 'me.js');
|
|
27
|
+
const NEEDS = read('modules', 'builder-settings', 'builder-needs.js');
|
|
28
|
+
const needs = require(path.join(ROOT, 'modules', 'builder-settings', 'builder-needs.js'));
|
|
29
|
+
|
|
30
|
+
// ── 1. THE DISQUALIFIER: social is not a destination ────────────────────────
|
|
31
|
+
|
|
32
|
+
test('THE DISQUALIFIER: there is no /social page in the hall', () => {
|
|
33
|
+
const hall = fs.readdirSync(path.join(ROOT, 'modules', 'hall-ui', 'public'));
|
|
34
|
+
for (const f of hall) {
|
|
35
|
+
assert.ok(!/^social\.(html|js|css)$/.test(f),
|
|
36
|
+
`${f} exists — a social PAGE is exactly what this criterion refuses`);
|
|
37
|
+
}
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test('THE DISQUALIFIER: no /social route is served, and no page gate names one', () => {
|
|
41
|
+
const serve = read('src', 'bongos', 'serve-internal.js');
|
|
42
|
+
assert.ok(!/\/social\b/.test(serve), 'serve-internal routes nothing at /social');
|
|
43
|
+
const oracle = read('tests', 'hall_page_gate_map.mjs');
|
|
44
|
+
assert.ok(!/'\/builders\/social'/.test(oracle), 'the page-gate oracle has no /social row');
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test('THE DISQUALIFIER: social adds no nav item', () => {
|
|
48
|
+
// A nav entry would BE the tab. The whole mechanism is an attribute on the
|
|
49
|
+
// document, so there is nothing to link to.
|
|
50
|
+
//
|
|
51
|
+
// The end marker is NAV_COLLAPSE_KEY, which follows the model. An earlier
|
|
52
|
+
// draft sliced to `function icon(` — which is DEFINED ABOVE NAV, so the slice
|
|
53
|
+
// returned an empty string and the assertion passed over nothing. It only
|
|
54
|
+
// surfaced because adding a real Social tab as a mutation did not red it.
|
|
55
|
+
const start = SHELL.indexOf('var NAV = [');
|
|
56
|
+
const end = SHELL.indexOf('var NAV_COLLAPSE_KEY');
|
|
57
|
+
assert.ok(start > -1 && end > start, 'the NAV model was actually located');
|
|
58
|
+
const nav = SHELL.slice(start, end);
|
|
59
|
+
assert.ok(nav.includes("id: 'roster'"), 'and the slice really holds the nav items');
|
|
60
|
+
assert.ok(!/social/i.test(nav), 'the nav model must not mention social');
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
// ── 2. SOCIAL IS A MODE, STAMPED ON THE DOCUMENT ────────────────────────────
|
|
64
|
+
|
|
65
|
+
test('the shell stamps the mode on the document root, beside data-theme', () => {
|
|
66
|
+
// An attribute on <html> is what lets ANY surface — a stylesheet, a page
|
|
67
|
+
// script, a widget — read the mode without a route or a second fetch. That is
|
|
68
|
+
// the structural difference between a mode and a page.
|
|
69
|
+
assert.match(SHELL, /function applySocial\(social\)/);
|
|
70
|
+
assert.match(SHELL, /root\.dataset\.social = 'on'/);
|
|
71
|
+
assert.match(SHELL, /delete root\.dataset\.social/);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test('OFF is the default and the fallback — null is not on', () => {
|
|
75
|
+
// /me.social is null when lifecycle is disabled or the read failed. A mode
|
|
76
|
+
// that cannot be read is not on.
|
|
77
|
+
const fn = SHELL.slice(SHELL.indexOf('function applySocial'));
|
|
78
|
+
const body = fn.slice(0, fn.indexOf('\n }'));
|
|
79
|
+
assert.match(body, /social && social\.enabled === true/,
|
|
80
|
+
'only an explicit enabled:true turns the mode on');
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test('BOTH switches decide it, and the client never re-derives them', () => {
|
|
84
|
+
// C07 resolved the pair (project enabled AND builder opted in) server-side.
|
|
85
|
+
// A client that recomputed the mode could disagree with the rest of the hall.
|
|
86
|
+
assert.ok(!/opted_in|project_enabled/.test(SHELL),
|
|
87
|
+
'the shell reads `enabled`, never the two switches it came from');
|
|
88
|
+
assert.match(ME, /socialModeFor/);
|
|
89
|
+
assert.match(ME, /social: socialMode/);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test('the mode rides /me, which every hall page already fetches', () => {
|
|
93
|
+
// This is why no new route is needed — and why one would be the wrong shape.
|
|
94
|
+
assert.match(SHELL, /social: me\.social \|\| null/);
|
|
95
|
+
assert.match(HALL_RENDER, /social: \(ctx\.lastMeState && ctx\.lastMeState\.social\) \|\| null/);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test('the index passes the mode too — the busiest page is not the one it misses', () => {
|
|
99
|
+
// renderNav takes (builder, onboarding) and is called from five sites, so the
|
|
100
|
+
// value must come off the live ctx getter rather than a local `me`.
|
|
101
|
+
const fn = HALL_RENDER.slice(HALL_RENDER.indexOf('function renderNav('));
|
|
102
|
+
const body = fn.slice(0, fn.indexOf('\n }'));
|
|
103
|
+
assert.ok(!/\bme\?\./.test(body), 'renderNav has no `me` in scope — reading one would throw');
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test('a social read can never break a profile', () => {
|
|
107
|
+
// /me is the session-open hot path. Same fail-open posture as the C01/C03
|
|
108
|
+
// reads it sits beside.
|
|
109
|
+
const slot = ME.slice(ME.indexOf('socialModeFor') - 600, ME.indexOf('socialModeFor') + 200);
|
|
110
|
+
assert.match(slot, /catch \(_\) \{ return null; \}/);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
// ── 3. EXISTING SURFACES READ DIFFERENTLY ───────────────────────────────────
|
|
114
|
+
|
|
115
|
+
test('HOME changes: an introduction becomes a need in the roster', () => {
|
|
116
|
+
// The needs roster is how a collab feature reaches the whole hall — it is
|
|
117
|
+
// where C01 and C03 already live, and it renders on Home's Standing card and
|
|
118
|
+
// in the CLI nudge without either learning about mingle.
|
|
119
|
+
const roster = needs.computeNeeds({ openMingleIntroductions: 2 }).items;
|
|
120
|
+
const row = roster.find((n) => n.id === 'mingle_introductions');
|
|
121
|
+
assert.ok(row, 'the need is produced');
|
|
122
|
+
assert.equal(row.state, 'action_needed');
|
|
123
|
+
assert.match(row.action.href, /\/#\/collab/, 'it points at an EXISTING surface, not a new one');
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test('no introductions means no row — the roster is not padded', () => {
|
|
127
|
+
assert.equal(needs.computeNeeds({ openMingleIntroductions: 0 }).items.find((n) => n.id === 'mingle_introductions'), undefined);
|
|
128
|
+
assert.equal(needs.computeNeeds({}).items.find((n) => n.id === 'mingle_introductions'), undefined);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test('the invitation never pressures — declining is free, so the copy says so', () => {
|
|
132
|
+
// Mingle is declinable without penalty (C07, ADR 0187 §5). Wording that
|
|
133
|
+
// pressured the reader would BE the penalty the decision forbids — the same
|
|
134
|
+
// argument the recommendation need makes, and it matters more for a social
|
|
135
|
+
// invitation than for a task.
|
|
136
|
+
const row = needs.computeNeeds({ openMingleIntroductions: 1 }).items;
|
|
137
|
+
const n = row.find((x) => x.id === 'mingle_introductions');
|
|
138
|
+
assert.equal(n.severity, 'info', 'info, never a demand');
|
|
139
|
+
assert.match(n.message, /costs nothing/);
|
|
140
|
+
assert.match(n.message, /does not change how often you are paired/,
|
|
141
|
+
'and it names the no-future-weight half, which is the part a reader would doubt');
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test('COLLAB changes: the introductions panel is revealed BY THE MODE', () => {
|
|
145
|
+
// Not by a rank, not by a route, not by a second fetch — by the document
|
|
146
|
+
// attribute the shell stamped. That is what "the surface reads differently"
|
|
147
|
+
// means in practice.
|
|
148
|
+
assert.match(COLLAB_JS, /document\.documentElement\.dataset\.social === 'on'/);
|
|
149
|
+
assert.match(COLLAB_HTML, /id="cb-mingle-section"[^>]*hidden/);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test('with social OFF the page is exactly what it was, and costs nothing extra', () => {
|
|
153
|
+
// A page that fetched anyway would make "social is off" cost a request on
|
|
154
|
+
// every load, and a panel that rendered empty would still be a visible change.
|
|
155
|
+
const fn = COLLAB_JS.slice(COLLAB_JS.indexOf('async function loadMingle'));
|
|
156
|
+
const body = fn.slice(0, fn.indexOf('\n }'));
|
|
157
|
+
const guard = body.indexOf('if (!socialOn())');
|
|
158
|
+
assert.ok(guard > -1, 'the mode is checked');
|
|
159
|
+
assert.ok(guard < body.indexOf("api.request('GET', '/mingle')"),
|
|
160
|
+
'and it is checked BEFORE anything is fetched');
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test('the introductions read is best-effort — it cannot take the page down', () => {
|
|
164
|
+
// Help requests ARE the Collab page; the social half is additive.
|
|
165
|
+
assert.match(COLLAB_JS, /try \{ await loadMingle\(\); \} catch \(_\)/);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test('the row is written from the VIEWER\'s side, without a second /me', () => {
|
|
169
|
+
// A pair carries builder_lo/builder_hi with no marker for "you", so the route
|
|
170
|
+
// says which side the caller is rather than making the page fetch to find out.
|
|
171
|
+
const routes = read('modules', 'lifecycle', 'routes', 'mingle.js');
|
|
172
|
+
assert.match(routes, /you: String\(req\.builder\.id\)/);
|
|
173
|
+
assert.match(COLLAB_JS, /r\.data\.you/);
|
|
174
|
+
assert.match(COLLAB_JS, /function otherOf\(pair, meId\)/);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
test('answering an introduction reads neutrally either way', () => {
|
|
178
|
+
// "Declined" must not read as a failure. A disappointed toast would be the
|
|
179
|
+
// penalty C07 forbids, delivered by the UI instead of the schema.
|
|
180
|
+
assert.match(COLLAB_JS, /Not now\. Nothing changes for you\./);
|
|
181
|
+
});
|