@bongos/core 1.20.51 → 1.20.53
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 +144 -24
- package/docs/copy-inventory.md +19 -18
- package/docs/copy-registry.json +28 -19
- package/docs/module-api-changelog.md +4 -0
- package/docs/page-readings.json +6 -6
- package/migrations/core_267_page_tweak_author_of_record.sql +41 -0
- package/modules/hall-ui/public/builders.js +3 -1
- package/modules/lifecycle/db-analytics.js +6 -1
- package/modules/lifecycle/db-ship.js +10 -3
- package/modules/lifecycle/page-tweak-artist.js +86 -0
- package/modules/lifecycle/page-tweak-reads.js +7 -5
- package/modules/lifecycle/routes/tasks.js +3 -0
- package/modules/provisioning/soft-limits.js +17 -0
- package/modules/public-landing/public/assets/world/r5-life-1-primordial-sea-cut.webp +0 -0
- package/modules/public-landing/public/assets/world/r5-life-1-primordial-sea-cut.webp.json +16 -0
- package/modules/public-landing/public/assets/world/r5-life-1-primordial-sea.jpg +0 -0
- package/modules/public-landing/public/assets/world/r5-life-1-primordial-sea.jpg.json +16 -0
- package/modules/public-landing/public/assets/world/r5-life-2-first-cells-cut.webp +0 -0
- package/modules/public-landing/public/assets/world/r5-life-2-first-cells-cut.webp.json +16 -0
- package/modules/public-landing/public/assets/world/r5-life-2-first-cells.jpg +0 -0
- package/modules/public-landing/public/assets/world/r5-life-2-first-cells.jpg.json +16 -0
- package/modules/public-landing/public/assets/world/r5-life-3-genome-cut.webp +0 -0
- package/modules/public-landing/public/assets/world/r5-life-3-genome-cut.webp.json +16 -0
- package/modules/public-landing/public/assets/world/r5-life-3-genome.jpg +0 -0
- package/modules/public-landing/public/assets/world/r5-life-3-genome.jpg.json +16 -0
- package/modules/public-landing/public/assets/world/r5-life-4-membrane-cut.webp +0 -0
- package/modules/public-landing/public/assets/world/r5-life-4-membrane-cut.webp.json +16 -0
- package/modules/public-landing/public/assets/world/r5-life-4-membrane.jpg +0 -0
- package/modules/public-landing/public/assets/world/r5-life-4-membrane.jpg.json +16 -0
- package/modules/public-landing/public/assets/world/r5-life-5-nervous-system-cut.webp +0 -0
- package/modules/public-landing/public/assets/world/r5-life-5-nervous-system-cut.webp.json +16 -0
- package/modules/public-landing/public/assets/world/r5-life-5-nervous-system.jpg +0 -0
- package/modules/public-landing/public/assets/world/r5-life-5-nervous-system.jpg.json +16 -0
- package/modules/sessions/db.js +8 -0
- package/modules/status-ui/public/status.js +3 -1
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +16 -0
- package/src/module-api.js +1 -1
- package/tests/genesis_life_plates.mjs +63 -0
- package/tests/page_tweak_artist.mjs +236 -0
- package/tests/provisioning_planet_physics.mjs +14 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.20.
|
|
3
|
+
"version": "1.20.53",
|
|
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/release-notes.json
CHANGED
|
@@ -8419,5 +8419,21 @@
|
|
|
8419
8419
|
"id": "1004430",
|
|
8420
8420
|
"text": "Hall navigation is more standard. The top bar now has a Back button when you open a page from inside another one. A builder's profile opened from the Builders list shows \"Back to Builders\" and returns you to the same spot in t"
|
|
8421
8421
|
}
|
|
8422
|
+
],
|
|
8423
|
+
"1.20.52": [
|
|
8424
|
+
{
|
|
8425
|
+
"id": "1004425",
|
|
8426
|
+
"text": "The project-startup flow now has its five life-stage pictures, one per stage of a new project's genesis: a primordial sea, first cells, a genome, a membrane and a nervous system, each in the chrome world's style with a dark an"
|
|
8427
|
+
},
|
|
8428
|
+
{
|
|
8429
|
+
"id": "1004475",
|
|
8430
|
+
"text": "Equity or shares is now also greyed out for a single-owner company and for a research entity, each with its own short reason. Only a regular for-profit company or a division of a corporation is offered equity. As before, it's"
|
|
8431
|
+
}
|
|
8432
|
+
],
|
|
8433
|
+
"1.20.53": [
|
|
8434
|
+
{
|
|
8435
|
+
"id": "1004432",
|
|
8436
|
+
"text": "An approved page now counts as the artist's own work everywhere the project shows who did what: their profile, their shipped count, their activity, the status page and the leaderboard. Before, the page was credited to the sess"
|
|
8437
|
+
}
|
|
8422
8438
|
]
|
|
8423
8439
|
}
|
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.
|
|
78
|
+
const CORE_VERSION = '1.20.53'; // 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,63 @@
|
|
|
1
|
+
// tests/genesis_life_plates.mjs — the five genesis life-stage plates (task 1004425 /
|
|
2
|
+
// BV2.PS12, goal 1000121).
|
|
3
|
+
//
|
|
4
|
+
// One plate per genesis stage, shipped as the world's pair: a black-ground JPEG for
|
|
5
|
+
// dark mode and an alpha WebP cutout for light, each with the provenance sidecar every
|
|
6
|
+
// world plate carries. The genesis home's life strip (the GenesisHall mock today, the
|
|
7
|
+
// live genesis home in PS08) reads them by these names, so a renamed or half-shipped
|
|
8
|
+
// pair breaks the strip silently: a missing image is an empty square, not an error.
|
|
9
|
+
|
|
10
|
+
import { test } from 'node:test';
|
|
11
|
+
import assert from 'node:assert/strict';
|
|
12
|
+
import fs from 'node:fs';
|
|
13
|
+
import path from 'node:path';
|
|
14
|
+
import { fileURLToPath } from 'node:url';
|
|
15
|
+
|
|
16
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
17
|
+
const WORLD = path.join(ROOT, 'modules', 'public-landing', 'public', 'assets', 'world');
|
|
18
|
+
const MOCK = path.join(ROOT, 'docs', 'design', 'mocks', 'project-startup', 'GenesisHall.dc.html');
|
|
19
|
+
|
|
20
|
+
// The stages in genesis order, with the label the strip captions them by.
|
|
21
|
+
const PLATES = [
|
|
22
|
+
['scope', 'primordial sea', 'r5-life-1-primordial-sea'],
|
|
23
|
+
['strategize', 'first cells', 'r5-life-2-first-cells'],
|
|
24
|
+
['charter', 'the genome', 'r5-life-3-genome'],
|
|
25
|
+
['legalize', 'the membrane', 'r5-life-4-membrane'],
|
|
26
|
+
['legislation', 'the nervous system', 'r5-life-5-nervous-system'],
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
// The first bytes of each format, so a mislabelled file (a PNG saved as .jpg) fails here.
|
|
30
|
+
const isJpeg = (b) => b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff;
|
|
31
|
+
const isWebp = (b) => b.toString('ascii', 0, 4) === 'RIFF' && b.toString('ascii', 8, 12) === 'WEBP';
|
|
32
|
+
|
|
33
|
+
test('each genesis stage ships the world pair: a dark JPEG and a light WebP cutout', () => {
|
|
34
|
+
for (const [, , slug] of PLATES) {
|
|
35
|
+
const jpg = fs.readFileSync(path.join(WORLD, `${slug}.jpg`));
|
|
36
|
+
const cut = fs.readFileSync(path.join(WORLD, `${slug}-cut.webp`));
|
|
37
|
+
assert.ok(isJpeg(jpg), `${slug}.jpg is a JPEG`);
|
|
38
|
+
assert.ok(isWebp(cut), `${slug}-cut.webp is a WebP`);
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test('every plate file carries a provenance sidecar naming its stage and label', () => {
|
|
43
|
+
for (const [stage, label, slug] of PLATES) {
|
|
44
|
+
for (const [file, kind] of [[`${slug}.jpg`, 'plate'], [`${slug}-cut.webp`, 'cutout']]) {
|
|
45
|
+
const side = JSON.parse(fs.readFileSync(path.join(WORLD, `${file}.json`), 'utf8'));
|
|
46
|
+
assert.equal(side.kind, kind, `${file}: kind`);
|
|
47
|
+
assert.equal(side.task, 1004425, `${file}: task`);
|
|
48
|
+
assert.equal(side.stage, stage, `${file}: stage`);
|
|
49
|
+
assert.equal(side.label, label, `${file}: label`);
|
|
50
|
+
assert.ok(side.prompt && side.model, `${file}: prompt and model recorded`);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test('the genesis mock life strip shows all five plates and no placeholder', () => {
|
|
56
|
+
const html = fs.readFileSync(MOCK, 'utf8');
|
|
57
|
+
assert.ok(!/plate to render/.test(html), 'no "plate to render" placeholder left');
|
|
58
|
+
for (const [stage, label, slug] of PLATES) {
|
|
59
|
+
const tile = html.split('\n').find((l) => l.includes(`<p class="cap">${stage}<b>${label}</b>`));
|
|
60
|
+
assert.ok(tile, `a tile for ${stage}`);
|
|
61
|
+
assert.ok(tile.includes(`/assets/world/${slug}-cut.webp`), `${stage} tile uses ${slug}`);
|
|
62
|
+
}
|
|
63
|
+
});
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
// tests/page_tweak_artist.mjs
|
|
2
|
+
//
|
|
3
|
+
// Task 1004432 / WA6.RP02: an approved page tweak counts as the ARTIST's work,
|
|
4
|
+
// not the applier's.
|
|
5
|
+
//
|
|
6
|
+
// The fixture round the task's DONE WHEN names: artist A writes the page (a WEB
|
|
7
|
+
// claim), session B applies it (a CLI claim in a worktree), A approves. Through
|
|
8
|
+
// the REAL db.shipTask over a small stateful ledger:
|
|
9
|
+
// * the shipped row names A (tasks.shipped_by), not B, even though confirm had
|
|
10
|
+
// already attributed it to B;
|
|
11
|
+
// * the onboarding count, first_ship_at and the achievement reconcile follow A;
|
|
12
|
+
// * A's credit is the one artist row it always was, and B is paid nothing more;
|
|
13
|
+
// * an ordinary task and a round with no web claim keep their shipper;
|
|
14
|
+
// * before the round ships, shipped_by still names B, so the rebase gate keeps
|
|
15
|
+
// asking the session that can do the rebase.
|
|
16
|
+
// Then the READERS, run for real against the ledger: A's profile works, shipped
|
|
17
|
+
// count, activity and the status page's recent list show the page as A's, and
|
|
18
|
+
// B's do not count it; the recent list names B as the helper.
|
|
19
|
+
// And the one rule: the helper's predicate is the one economy pays on, the one
|
|
20
|
+
// the Tweak Mode reads use, and the one the backfill migration spells out.
|
|
21
|
+
//
|
|
22
|
+
// Run: node tests/page_tweak_artist.mjs
|
|
23
|
+
|
|
24
|
+
import { strict as assert } from 'node:assert';
|
|
25
|
+
import { readFileSync } from 'node:fs';
|
|
26
|
+
import { createRequire } from 'node:module';
|
|
27
|
+
import { makeRunner, makeSqlAwareClient } from './helpers.mjs';
|
|
28
|
+
|
|
29
|
+
process.env.NODE_ENV = 'test';
|
|
30
|
+
|
|
31
|
+
const require = createRequire(import.meta.url);
|
|
32
|
+
const achievements = require('../modules/economy/achievements.js');
|
|
33
|
+
achievements.evaluateAfterShip = async () => [];
|
|
34
|
+
achievements.evaluateAfterCreditAward = async () => [];
|
|
35
|
+
const reconciled = [];
|
|
36
|
+
achievements.reconcileBuilderAchievements = async (_client, builderId) => { reconciled.push(String(builderId)); return []; };
|
|
37
|
+
const seams = require('../src/module-seams.js');
|
|
38
|
+
if (!seams.hasProvider('reward')) seams.registerProvider('reward', require('../modules/economy/reward.js'));
|
|
39
|
+
const api = require('../src/module-api.js');
|
|
40
|
+
const db = require('../modules/lifecycle/db.js');
|
|
41
|
+
const activity = require('../modules/lifecycle/activity.js');
|
|
42
|
+
const rule = require('../modules/lifecycle/page-tweak-artist.js');
|
|
43
|
+
|
|
44
|
+
const { test, summary } = makeRunner();
|
|
45
|
+
|
|
46
|
+
const A = '41'; // the artist
|
|
47
|
+
const B = '42'; // the applier
|
|
48
|
+
const RECONCILER = '999';
|
|
49
|
+
const LAND_PROOF = { kind: 'proof', reason: 'test fixture: land proven' };
|
|
50
|
+
const norm = (s) => String(s).replace(/\s+/g, ' ').trim();
|
|
51
|
+
|
|
52
|
+
const web = (builder, at) => ({ builder_id: builder, claimed_at: at, worktree_name: null, creator_session_id: null });
|
|
53
|
+
const cli = (builder, at) => ({ builder_id: builder, claimed_at: at, worktree_name: `tw-${builder}-${at}`, creator_session_id: `s-${builder}` });
|
|
54
|
+
|
|
55
|
+
// A one-task ledger. The ship UPDATE is evaluated from the SQL it is sent: the
|
|
56
|
+
// page-tweak branch takes the artist only when the statement actually carries
|
|
57
|
+
// the helper's artist subquery, so reverting db-ship.js to a plain COALESCE
|
|
58
|
+
// makes the shipped row keep B and this file red.
|
|
59
|
+
function ledger({ source = 'page-tweak', shippedBy = B, claims = [web(A, 1), cli(B, 2)] } = {}) {
|
|
60
|
+
const state = {
|
|
61
|
+
task: { id: 7001, title: 'Tweak: Home (landing:index)', source, status: 'confirmed', credits_reward: 30, kind: 'feature', shipped_by: shippedBy },
|
|
62
|
+
claims: claims.map((c, i) => ({ id: 9000 + i, ...c })),
|
|
63
|
+
credit_log: [], firstShip: [], counted: [],
|
|
64
|
+
};
|
|
65
|
+
const latest = (list) => list.slice().sort((x, y) => (x.claimed_at - y.claimed_at) || (x.id - y.id)).slice(-1)[0] || null;
|
|
66
|
+
const artistSubquery = norm(rule.artistIdSql('tasks.id'));
|
|
67
|
+
const pool = makeSqlAwareClient((s, p) => {
|
|
68
|
+
const t = state.task;
|
|
69
|
+
if (/^UPDATE tasks SET status = 'shipped'/.test(s)) {
|
|
70
|
+
if (t.status !== 'confirmed') return { rows: [] };
|
|
71
|
+
t.status = 'shipped';
|
|
72
|
+
const pageBranch = s.includes(`CASE WHEN source = '${rule.PAGE_TWEAK_SOURCE}' THEN COALESCE(${artistSubquery}, shipped_by, $2)`);
|
|
73
|
+
const w = latest(state.claims.filter((c) => c.worktree_name == null && c.creator_session_id == null));
|
|
74
|
+
t.shipped_by = (pageBranch && t.source === 'page-tweak' && w) ? w.builder_id : (t.shipped_by ?? p[1]);
|
|
75
|
+
return { rows: [{ id: t.id, title: t.title, status: 'shipped', shipped_at: new Date().toISOString(), shipped_by: t.shipped_by,
|
|
76
|
+
security_sensitive: false, value_summary: null, credits_reward: t.credits_reward, kind: t.kind, source: t.source }] };
|
|
77
|
+
}
|
|
78
|
+
if (/^UPDATE claims/.test(s)) return { rows: [] };
|
|
79
|
+
if (/^UPDATE builders SET first_ship_at/.test(s)) { state.firstShip.push(String(p[0])); return { rows: [] }; }
|
|
80
|
+
if (/^SELECT count\(\*\)::int AS n FROM tasks WHERE shipped_by = \$1/.test(s)) { state.counted.push(String(p[0])); return { rows: [{ n: 1 }] }; }
|
|
81
|
+
if (/^SELECT builder_id FROM claims WHERE task_id = \$1/.test(s)) {
|
|
82
|
+
const c = latest(state.claims);
|
|
83
|
+
return { rows: c ? [{ builder_id: c.builder_id }] : [] };
|
|
84
|
+
}
|
|
85
|
+
if (/^SELECT t\.source, t\.status, t\.credits_reward, t\.kind/.test(s)) {
|
|
86
|
+
const w = latest(state.claims.filter((c) => c.worktree_name == null && c.creator_session_id == null));
|
|
87
|
+
return { rows: [{ source: t.source, status: t.status, credits_reward: t.credits_reward, kind: t.kind, artist_id: w ? w.builder_id : null }] };
|
|
88
|
+
}
|
|
89
|
+
if (/^INSERT INTO credit_log/.test(s)) {
|
|
90
|
+
const [builder_id, task_id, delta, reason] = p;
|
|
91
|
+
if (state.credit_log.some((r) => r.task_id === task_id && r.reason === reason)) return { rows: [], rowCount: 0 };
|
|
92
|
+
state.credit_log.push({ builder_id: String(builder_id), task_id, delta, reason });
|
|
93
|
+
return { rows: [], rowCount: 1 };
|
|
94
|
+
}
|
|
95
|
+
return { rows: [] };
|
|
96
|
+
});
|
|
97
|
+
return { pool, state };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const ship = (pool) => db.shipTask({ taskId: 7001, builderId: RECONCILER, landAssurance: LAND_PROOF }, { pool });
|
|
101
|
+
|
|
102
|
+
// =========================================================================
|
|
103
|
+
await test('A writes, B applies, A approves: the shipped row names A, not B', async () => {
|
|
104
|
+
reconciled.length = 0;
|
|
105
|
+
const { pool, state } = ledger();
|
|
106
|
+
assert.equal(state.task.shipped_by, B, 'precondition: confirm attributed the round to the applier');
|
|
107
|
+
const out = await ship(pool);
|
|
108
|
+
assert.equal(String(out.shipped_by), A, 'the author of record is the artist');
|
|
109
|
+
assert.equal(String(state.task.shipped_by), A);
|
|
110
|
+
assert.deepEqual(state.firstShip, [A], 'first_ship_at is stamped on the artist');
|
|
111
|
+
assert.ok(state.counted.every((id) => id === A) && state.counted.length > 0, 'the ship count that drives onboarding is the artist\'s');
|
|
112
|
+
assert.deepEqual(reconciled, [A], 'the achievements this ship can unlock are the artist\'s');
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
await test('A\'s credits are unchanged: one artist row of 30, nothing to B', async () => {
|
|
116
|
+
const { pool, state } = ledger();
|
|
117
|
+
await ship(pool);
|
|
118
|
+
assert.deepEqual(state.credit_log.map((r) => [r.builder_id, r.delta, r.reason]), [[A, 30, 'tweak.page_approved:7001']]);
|
|
119
|
+
assert.ok(!state.credit_log.some((r) => r.builder_id === B), 'the applier is paid nothing on the land');
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
await test('a self-applied round names its one author', async () => {
|
|
123
|
+
const { pool, state } = ledger({ shippedBy: A, claims: [web(A, 1), cli(A, 2)] });
|
|
124
|
+
await ship(pool);
|
|
125
|
+
assert.equal(String(state.task.shipped_by), A);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
await test('a round with no web claim keeps its applier; an ordinary task keeps its shipper', async () => {
|
|
129
|
+
const noWeb = ledger({ claims: [cli(B, 2)] });
|
|
130
|
+
await ship(noWeb.pool);
|
|
131
|
+
assert.equal(String(noWeb.state.task.shipped_by), B, 'no artist on record: the applier stays');
|
|
132
|
+
const ordinary = ledger({ source: null, claims: [web(A, 1), cli(B, 2)] });
|
|
133
|
+
await ship(ordinary.pool);
|
|
134
|
+
assert.equal(String(ordinary.state.task.shipped_by), B, 'an ordinary task is never re-attributed, web claim or not');
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
await test('the rewrite happens only at the shipped flip: confirm still names the applier', () => {
|
|
138
|
+
const src = readFileSync(new URL('../modules/lifecycle/db-ship.js', import.meta.url), 'utf8');
|
|
139
|
+
const confirmUpdate = src.slice(src.indexOf('async function confirmTask'));
|
|
140
|
+
assert.match(confirmUpdate, /shipped_by = COALESCE\(shipped_by, \$2\)/, 'confirmTask attributes to the claim holder, unchanged');
|
|
141
|
+
for (const f of ['db-grade.js', 'db-overrides.js']) {
|
|
142
|
+
const s = readFileSync(new URL(`../modules/lifecycle/${f}`, import.meta.url), 'utf8');
|
|
143
|
+
assert.ok(!s.includes('artistIdSql'), `${f} confirms; it must not move attribution before the round ships`);
|
|
144
|
+
}
|
|
145
|
+
assert.match(src, /WHERE needs_rebase = true AND status = 'confirmed' AND shipped_by = \$1/, 'the rebase gate still reads the confirmed row\'s shipper');
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
// =========================================================================
|
|
149
|
+
// The readers, for real. A shipped page tweak is now a row whose shipped_by is
|
|
150
|
+
// A; every reader below must key on shipped_by for that to reach the person.
|
|
151
|
+
const shippedRow = { id: 7001, title: 'Tweak: Home (landing:index)', value_summary: 'Home reads in the artist\'s words', visual_url: null, visual_alt: null,
|
|
152
|
+
version_id: 'BONGOS-V2', goal_id: null, kind: 'feature', status: 'shipped', shipped_at: '2026-09-30T12:00:00Z', discipline: 'artist',
|
|
153
|
+
credits_reward: 30, credits_awarded: 30, source: 'page-tweak', shipped_by: A, github_login: 'artist-a', display_name: 'Artist A',
|
|
154
|
+
helper_login: 'applier-b', helper_display_name: 'Applier B' };
|
|
155
|
+
const readerSql = [];
|
|
156
|
+
api.pool.query = async (sql, params = []) => {
|
|
157
|
+
const s = norm(sql);
|
|
158
|
+
readerSql.push(s);
|
|
159
|
+
const mine = /shipped_by = \$1/.test(s) ? String(params[0]) === A : true;
|
|
160
|
+
if (/^SELECT count\(\*\)::int AS (n|total) FROM tasks/.test(s)) return { rows: [{ n: mine ? 1 : 0, total: mine ? 1 : 0 }] };
|
|
161
|
+
return { rows: mine ? [shippedRow] : [] };
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
await test('A\'s profile works, shipped count and activity hold the page; B\'s do not', async () => {
|
|
165
|
+
assert.equal((await db.recentShippedTasksByBuilder(A)).length, 1, 'A\'s profile lists the page');
|
|
166
|
+
assert.equal((await db.recentShippedTasksByBuilder(B)).length, 0, 'B\'s profile does not');
|
|
167
|
+
assert.equal(await db.countShippedTasksForBuilder(A), 1);
|
|
168
|
+
assert.equal(await db.countShippedTasksForBuilder(B), 0, 'B\'s shipped count does not count it');
|
|
169
|
+
assert.equal((await activity.activityTasksByBuilder(A)).total, 1, 'A\'s activity holds it');
|
|
170
|
+
assert.equal((await activity.activityTasksByBuilder(B)).total, 0, 'B\'s activity does not');
|
|
171
|
+
assert.ok((await activity.activityTasksByBuilder(A, { role: 'artist' })).total === 1, 'and under the Artist craft');
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
await test('the status page\'s recent list shows the page as A\'s, with B as the helper', async () => {
|
|
175
|
+
readerSql.length = 0;
|
|
176
|
+
const rows = await db.recentShippedTasks(30);
|
|
177
|
+
assert.equal(rows[0].github_login, 'artist-a');
|
|
178
|
+
const sql = readerSql[0];
|
|
179
|
+
assert.match(sql, /LEFT JOIN builders b ON b\.id = t\.shipped_by/, 'the shipper column is the author of record');
|
|
180
|
+
assert.ok(sql.includes(norm(rule.helperJoinSql('t'))), 'the helper is the shared join, not a second derivation');
|
|
181
|
+
assert.match(sql, /helper\.helper_login, helper\.helper_display_name/);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
await test('the helper join names only a DIFFERENT applier, and only on a page tweak', () => {
|
|
185
|
+
const j = norm(rule.helperJoinSql('t'));
|
|
186
|
+
assert.match(j, /t\.source = 'page-tweak'/, 'never on an ordinary task');
|
|
187
|
+
assert.match(j, /c\.worktree_name IS NOT NULL/, 'the applier is the worktree claim');
|
|
188
|
+
assert.match(j, /ap\.builder_id IS DISTINCT FROM t\.shipped_by/, 'a self-applied round names no helper');
|
|
189
|
+
assert.match(j, /WHERE t\.source = 'page-tweak' AND c\.task_id = t\.id/, 'the source test guards the claims scan itself, so an ordinary row reads no claim');
|
|
190
|
+
const idx = readFileSync(new URL('../migrations/core_263_closeness_history_indexes.sql', import.meta.url), 'utf8');
|
|
191
|
+
assert.match(idx, /CREATE INDEX IF NOT EXISTS idx_claims_task_all\s+ON claims \(task_id\)/, 'a page tweak\'s per-row lookup is an index probe');
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
await test('the status page and the acclaim feed render the helper', () => {
|
|
195
|
+
const status = readFileSync(new URL('../modules/status-ui/public/status.js', import.meta.url), 'utf8');
|
|
196
|
+
assert.match(status, /t\.helper_display_name \|\| t\.helper_login/);
|
|
197
|
+
assert.match(status, /with help from \$\{helper\}/);
|
|
198
|
+
const hall = readFileSync(new URL('../modules/hall-ui/public/builders.js', import.meta.url), 'utf8');
|
|
199
|
+
assert.match(hall, /with help from \$\{escapeHtml\(helperName\)\}/, 'escaped before it reaches innerHTML');
|
|
200
|
+
const feed = readFileSync(new URL('../modules/lifecycle/routes/tasks.js', import.meta.url), 'utf8');
|
|
201
|
+
assert.match(feed, /helper\.helper_login, helper\.helper_display_name/, 'the acclaim feed carries the helper');
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
await test('session upload coverage skips page tweaks: an artist runs no session', () => {
|
|
205
|
+
const s = readFileSync(new URL('../modules/sessions/db.js', import.meta.url), 'utf8');
|
|
206
|
+
const n = (s.match(/AND t\.source IS DISTINCT FROM 'page-tweak'/g) || []).length;
|
|
207
|
+
assert.equal(n, 2, 'both the coverage verdict and the missing-record list');
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
// =========================================================================
|
|
211
|
+
// ONE RULE. Economy cannot require the lifecycle helper, so its copy of the
|
|
212
|
+
// predicate is held to the helper's here; so are the Tweak Mode reads and the
|
|
213
|
+
// backfill migration.
|
|
214
|
+
await test('economy pays the same artist the ship now names', () => {
|
|
215
|
+
const credits = norm(readFileSync(new URL('../modules/economy/credits.js', import.meta.url), 'utf8'));
|
|
216
|
+
assert.ok(credits.includes(`WHERE c.task_id = t.id AND ${rule.webClaimPredicate('c')} ORDER BY ${rule.latestFirst('c')} LIMIT 1`),
|
|
217
|
+
'awardArtistPageCredit reads the payee by the helper\'s predicate and order');
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
await test('the Tweak Mode reads take the predicate from the helper, not a literal', () => {
|
|
221
|
+
const reads = readFileSync(new URL('../modules/lifecycle/page-tweak-reads.js', import.meta.url), 'utf8');
|
|
222
|
+
assert.match(reads, /\$\{artistRule\.webClaimPredicate\('c'\)\}/);
|
|
223
|
+
assert.match(reads, /\$\{artistRule\.latestFirst\('c'\)\}/);
|
|
224
|
+
assert.ok(!/worktree_name IS NULL AND c\.creator_session_id IS NULL/.test(reads), 'no second spelling of the rule');
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
await test('the backfill brings already-shipped rounds to the same artist, idempotently', () => {
|
|
228
|
+
const mig = norm(readFileSync(new URL('../migrations/core_267_page_tweak_author_of_record.sql', import.meta.url), 'utf8').replace(/--[^\n]*/g, ''));
|
|
229
|
+
assert.ok(mig.includes(rule.webClaimPredicate('c')), 'the web-claim predicate');
|
|
230
|
+
assert.ok(mig.includes(`ORDER BY c.task_id, ${rule.latestFirst('c')}`), 'the latest-first order');
|
|
231
|
+
assert.match(mig, /pt\.source = 'page-tweak' AND pt\.status = 'shipped'/, 'shipped page tweaks only');
|
|
232
|
+
assert.match(mig, /t\.shipped_by IS DISTINCT FROM w\.builder_id/, 'a second run matches nothing');
|
|
233
|
+
assert.ok(!/credit_log/.test(mig), 'money is not touched');
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
summary();
|
|
@@ -188,6 +188,20 @@ await t("the spec's three examples fire, each with its why line", () => {
|
|
|
188
188
|
assert.equal(find({ entity: 'for-profit' }, 'reward_intents:equity').length, 0);
|
|
189
189
|
});
|
|
190
190
|
|
|
191
|
+
await t('equity is greyed for a single-owner LLC and a research entity, each with its own reason (task 1004475, owner 2026-09-30)', () => {
|
|
192
|
+
const equity = (entity) => softLimitsFor({ entity }).limits.filter((l) => l.option === 'reward_intents:equity');
|
|
193
|
+
const llc = equity('single-owner-llc');
|
|
194
|
+
assert.equal(llc.length, 1); assert.equal(llc[0].effect, 'grey');
|
|
195
|
+
assert.match(llc[0].reason, /one owner/);
|
|
196
|
+
const research = equity('research');
|
|
197
|
+
assert.equal(research.length, 1); assert.equal(research[0].effect, 'grey');
|
|
198
|
+
assert.match(research[0].reason, /research/i);
|
|
199
|
+
assert.notEqual(llc[0].reason, research[0].reason, 'each says its own why');
|
|
200
|
+
// The two companies that can give a builder a stake are still offered it.
|
|
201
|
+
assert.equal(equity('for-profit').length, 0);
|
|
202
|
+
assert.equal(equity('corporate-division').length, 0);
|
|
203
|
+
});
|
|
204
|
+
|
|
191
205
|
await t('a rule never fires on an answer nobody gave — only the platform-wide facts apply to a skipped interview', () => {
|
|
192
206
|
const skipped = softLimitsFor({});
|
|
193
207
|
for (const limit of skipped.limits) {
|