@bongos/core 1.23.14 → 1.23.16
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 +96 -61
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +2 -0
- package/clients/bongos-client/index.cjs +2 -0
- package/clients/bongos-client/index.d.ts +2 -0
- package/clients/bongos-client/index.mjs +2 -0
- package/docs/api/openapi.json +22 -1
- package/docs/api-reference.md +3 -2
- package/docs/copy-inventory.md +932 -886
- package/docs/copy-registry.json +1807 -1336
- package/docs/module-api-changelog.md +4 -0
- package/docs/page-inventory.json +8 -3
- package/docs/page-readings.json +578 -558
- package/modules/copy-desk/page-decisions.js +32 -4
- package/modules/copy-desk/page-status.js +19 -10
- package/modules/copy-desk/pages.js +61 -0
- package/modules/copy-desk/routes/copy-desk.js +7 -3
- package/modules/copy-desk/skills/tweak/SKILL.md +1 -1
- package/modules/copy-desk/tests/copy_page_next.mjs +1 -1
- package/modules/copy-desk/tests/copy_page_status.mjs +9 -4
- package/modules/hall-ui/public/approval-queue-lib.js +10 -3
- package/modules/hall-ui/public/approval-queue.js +7 -5
- package/modules/hall-ui/public/studio-lib.js +3 -2
- package/modules/lifecycle/lifecycle.js +2 -0
- package/modules/lifecycle/page-tweak-approve.js +50 -2
- package/modules/platform-identity/builder-of-the-week.js +180 -0
- package/modules/platform-identity/migrations/platform_identity_032_weekly_features.sql +53 -0
- package/modules/platform-identity/routes/community-leaderboard.js +25 -1
- package/modules/public-landing/public/account.html +2 -1
- package/modules/public-landing/public/community-week-none.states.json +16 -0
- package/modules/public-landing/public/community.html +142 -13
- package/modules/public-landing/public/community.states.json +6 -4
- package/modules/public-landing/public/connections.html +2 -1
- package/modules/public-landing/public/contact.html +2 -1
- package/modules/public-landing/public/guild.html +2 -1
- package/modules/public-landing/public/index.html +2 -1
- package/modules/public-landing/public/invitations.html +2 -1
- package/modules/public-landing/public/privacy.html +2 -1
- package/modules/public-landing/public/projects.html +2 -1
- package/modules/public-landing/public/terms.html +2 -1
- package/modules/ui-design/kit/fixtures/community-week-none.json +6 -0
- package/modules/ui-design/kit/fixtures/community-week.json +11 -0
- package/modules/ui-design/kit/serve.js +8 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +12 -0
- package/scripts/bongos/run-unit-tests.js +4 -0
- package/src/module-api.js +1 -1
- package/src/platform-server.js +5 -5
- package/tests/builder_of_the_week.mjs +266 -0
- package/tests/builder_of_the_week_db.mjs +166 -0
- package/tests/copy_desk_page_approve.mjs +53 -0
- package/tests/copy_desk_page_editor.mjs +2 -2
- package/tests/copy_desk_page_next.mjs +1 -1
- package/tests/copy_desk_page_release.mjs +1 -1
- package/tests/copy_desk_page_submit.mjs +2 -2
- package/tests/copy_stuck_page_tweaks.mjs +12 -3
- package/tests/hall_approval_queue.mjs +6 -6
- package/tests/hub_account_page.mjs +2 -2
- package/tests/hub_community_page.mjs +136 -25
- package/tests/landing_legal_pages.mjs +2 -2
- package/tests/privacy_matrix.mjs +53 -8
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
// tests/builder_of_the_week_db.mjs — task 1002981 (community spec D5; real DB).
|
|
2
|
+
//
|
|
3
|
+
// Proves Builder of the Week against real SQL: which snapshots open and close the
|
|
4
|
+
// week, the shipped-work floor, the tie order, the leaderboard's eligibility, that
|
|
5
|
+
// the first decision is pinned (a later change, a hide, a race) and that a week
|
|
6
|
+
// nobody won is recorded empty. A fake pool can only prove the statements' shape
|
|
7
|
+
// (tests/builder_of_the_week.mjs).
|
|
8
|
+
//
|
|
9
|
+
// The crown is global (every eligible builder on the hub), so every fixture week is
|
|
10
|
+
// in 2099: a shared database's real builders have no snapshot there, their week's
|
|
11
|
+
// delta is zero, and the shipped-work floor keeps them out.
|
|
12
|
+
//
|
|
13
|
+
// Real-DB integration test: self-skips without a reachable Postgres; in the
|
|
14
|
+
// INTEGRATION set (scripts/bongos/run-unit-tests.js) so the DB-free lane never runs it.
|
|
15
|
+
//
|
|
16
|
+
// Run: DATABASE_URL=... node tests/builder_of_the_week_db.mjs
|
|
17
|
+
|
|
18
|
+
import { createRequire } from 'node:module';
|
|
19
|
+
import { readFileSync } from 'node:fs';
|
|
20
|
+
import { fileURLToPath } from 'node:url';
|
|
21
|
+
import { dirname, join } from 'node:path';
|
|
22
|
+
import { strict as assert } from 'node:assert';
|
|
23
|
+
const require = createRequire(import.meta.url);
|
|
24
|
+
const { Pool } = require('pg');
|
|
25
|
+
const { readBuilderOfTheWeek, crownedWeek, clearCrownMemo } = require('../modules/platform-identity/builder-of-the-week.js');
|
|
26
|
+
|
|
27
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
28
|
+
const MIG_DIR = join(here, '..', 'modules', 'platform-identity', 'migrations');
|
|
29
|
+
const migrationSql = (f) => readFileSync(join(MIG_DIR, f), 'utf8');
|
|
30
|
+
|
|
31
|
+
const pool = new Pool({ connectionString: process.env.DATABASE_URL || undefined });
|
|
32
|
+
try {
|
|
33
|
+
await pool.query('SELECT 1');
|
|
34
|
+
} catch (e) {
|
|
35
|
+
console.log(`builder_of_the_week_db.mjs SKIPPED — no reachable Postgres (${e.code || e.message}).`);
|
|
36
|
+
await pool.end().catch(() => {});
|
|
37
|
+
process.exit(0);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const A = 90298101, B = 90298102, C = 90298103, D = 90298104;
|
|
41
|
+
const HIDDEN = 90298105, PRIVATE = 90298106, E = 90298107;
|
|
42
|
+
const IDS = [A, B, C, D, E, HIDDEN, PRIVATE];
|
|
43
|
+
const P1 = 't1002981-p1', P2 = 't1002981-p2';
|
|
44
|
+
const DAY = 24 * 60 * 60 * 1000;
|
|
45
|
+
const NOW = new Date('2099-03-12T10:00:00.000Z');
|
|
46
|
+
const { start, end } = crownedWeek(NOW);
|
|
47
|
+
const at = (base, ms) => new Date(base.getTime() + ms);
|
|
48
|
+
const q = (sql, params) => pool.query(sql, params);
|
|
49
|
+
|
|
50
|
+
async function cleanup() {
|
|
51
|
+
await q(`DELETE FROM platform_identity_weekly_features WHERE week_start >= '2099-01-01'`).catch(() => {});
|
|
52
|
+
await q(`DELETE FROM platform_identity_activity_snapshots WHERE github_id = ANY($1::bigint[])`, [IDS]).catch(() => {});
|
|
53
|
+
await q(`DELETE FROM platform_identity_builder_project_activity WHERE github_id = ANY($1::bigint[])`, [IDS]).catch(() => {});
|
|
54
|
+
await q(`DELETE FROM platform_identity_accounts WHERE github_id = ANY($1::bigint[])`, [IDS]).catch(() => {});
|
|
55
|
+
}
|
|
56
|
+
async function account(id, handle, { visibility = 'public', hide = false } = {}) {
|
|
57
|
+
await q(
|
|
58
|
+
`INSERT INTO platform_identity_accounts
|
|
59
|
+
(github_id, github_login, display_name, handle, profile_state, account_visibility, hide_stats, terms_accepted_at)
|
|
60
|
+
VALUES ($1, $2, $2, $2, 'active', $3, $4, now())`,
|
|
61
|
+
[id, handle, visibility, hide],
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
// The live row (its numbers do not matter to a past week; first seen long before the
|
|
65
|
+
// week unless said) and a history of snapshots.
|
|
66
|
+
async function builder(id, client, history, firstSeen = at(start, -365 * DAY)) {
|
|
67
|
+
await q(`INSERT INTO platform_identity_builder_project_activity (client_id, github_id, credits, tasks_shipped, karma, first_seen_at) VALUES ($1, $2, 0, 0, 0, $3)`, [client, id, firstSeen]);
|
|
68
|
+
for (const [when, credits, ships, karma] of history) {
|
|
69
|
+
await q(
|
|
70
|
+
`INSERT INTO platform_identity_activity_snapshots (client_id, github_id, credits, tasks_shipped, karma, reported_at) VALUES ($1, $2, $3, $4, $5, $6)`,
|
|
71
|
+
[client, id, credits, ships, karma, when],
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
const recorded = async (week) => (await q(
|
|
76
|
+
`SELECT github_id::text AS github_id, credits_delta::int AS credits, ships_delta::int AS ships FROM platform_identity_weekly_features WHERE week_start = $1::date`,
|
|
77
|
+
[week],
|
|
78
|
+
)).rows;
|
|
79
|
+
const read = (now = NOW) => { clearCrownMemo(); return readBuilderOfTheWeek({ now, pool }); };
|
|
80
|
+
|
|
81
|
+
let pass = 0;
|
|
82
|
+
const ok = (cond, msg) => { assert.ok(cond, msg); pass++; console.log(` ok - ${msg}`); };
|
|
83
|
+
const same = (actual, expected, msg) => { assert.deepEqual(actual, expected, msg); pass++; console.log(` ok - ${msg}`); };
|
|
84
|
+
|
|
85
|
+
try {
|
|
86
|
+
for (const f of [
|
|
87
|
+
'platform_identity_001_tables.sql', 'platform_identity_005_builder_activity.sql',
|
|
88
|
+
'platform_identity_007_profile_consent.sql', 'platform_identity_008_handle.sql',
|
|
89
|
+
'platform_identity_012_account_privacy.sql', 'platform_identity_019_terms_acceptance.sql',
|
|
90
|
+
'platform_identity_022_activity_snapshots.sql', 'platform_identity_032_weekly_features.sql',
|
|
91
|
+
]) await q(migrationSql(f));
|
|
92
|
+
await cleanup();
|
|
93
|
+
|
|
94
|
+
await account(A, 't1002981-a');
|
|
95
|
+
await account(B, 't1002981-b');
|
|
96
|
+
await account(C, 't1002981-c');
|
|
97
|
+
await account(D, 't1002981-d');
|
|
98
|
+
await account(E, 't1002981-e');
|
|
99
|
+
await account(HIDDEN, 't1002981-hidden', { hide: true });
|
|
100
|
+
await account(PRIVATE, 't1002981-private', { visibility: 'private' });
|
|
101
|
+
|
|
102
|
+
// A: 350 credits, 3 works, no karma — across two projects. P1 has a snapshot before
|
|
103
|
+
// the week (the baseline); P2 has none but was first seen in the week (counts from
|
|
104
|
+
// zero). A snapshot a second AFTER the week closes is not the week's.
|
|
105
|
+
await builder(A, P1, [[at(start, -3 * DAY), 100, 5, 4], [at(start, 2 * DAY), 400, 7, 4], [at(end, 1000), 99999, 99, 4]]);
|
|
106
|
+
await builder(A, P2, [[at(start, 3 * DAY), 50, 1, 0]], at(start, 3 * DAY));
|
|
107
|
+
// E: a lifetime brought into the week — no history before it, and seen before it (or
|
|
108
|
+
// before first_seen_at existed, NULL). Counting from zero would crown E; neither counts.
|
|
109
|
+
await builder(E, P1, [[at(start, DAY), 90000, 40, 0]], null);
|
|
110
|
+
await builder(E, P2, [[at(start, DAY), 90000, 40, 0]]);
|
|
111
|
+
// C: the same credits and works as A, two more karma — C beats A on karma.
|
|
112
|
+
await builder(C, P1, [[at(start, -DAY), 0, 0, 0], [at(start, DAY), 360, 3, 2]]);
|
|
113
|
+
// D: the same credits, the most karma, but one work fewer — works come before karma.
|
|
114
|
+
await builder(D, P1, [[at(start, -DAY), 0, 0, 0], [at(start, DAY), 350, 2, 9]]);
|
|
115
|
+
// B: the most credits, but no work shipped in the week (the floor).
|
|
116
|
+
await builder(B, P1, [[at(start, -DAY), 0, 4, 0], [at(start, DAY), 900, 4, 0]]);
|
|
117
|
+
// HIDDEN and PRIVATE out-earn everyone, and the leaderboard may not show them.
|
|
118
|
+
await builder(HIDDEN, P1, [[at(start, -DAY), 0, 0, 0], [at(start, DAY), 5000, 5, 0]]);
|
|
119
|
+
await builder(PRIVATE, P1, [[at(start, -DAY), 0, 0, 0], [at(start, DAY), 5000, 5, 0]]);
|
|
120
|
+
// A snapshot exactly at the start belongs to the baseline: C's week is 360 - 10, not 360.
|
|
121
|
+
await q(`INSERT INTO platform_identity_activity_snapshots (client_id, github_id, credits, tasks_shipped, karma, reported_at) VALUES ($1, $2, 10, 0, 0, $3)`, [P1, C, start]);
|
|
122
|
+
|
|
123
|
+
// 1 — the crown, and the row that records it
|
|
124
|
+
const first = await read();
|
|
125
|
+
same(first.week, { start: start.toISOString().slice(0, 10), end: end.toISOString().slice(0, 10) }, 'the week is the last full Monday-to-Monday week');
|
|
126
|
+
same(first.winner && [first.winner.handle, first.winner.credits, first.winner.tasks_shipped], ['t1002981-c', 350, 3], 'ties go to works shipped, then karma; no work, hidden, private and a lifetime brought into the week never win');
|
|
127
|
+
ok(!('github_id' in first.winner), 'github_id never crosses');
|
|
128
|
+
same(await recorded(first.week.start), [{ github_id: String(C), credits: 350, ships: 3 }], 'the week is recorded on the first read');
|
|
129
|
+
|
|
130
|
+
// 2 — pinned: a later report into the past week, or a builder who goes public, changes nothing
|
|
131
|
+
await q(`INSERT INTO platform_identity_activity_snapshots (client_id, github_id, credits, tasks_shipped, karma, reported_at) VALUES ($1, $2, 99999, 50, 0, $3)`, [P1, A, at(start, 4 * DAY)]);
|
|
132
|
+
await q(`UPDATE platform_identity_accounts SET hide_stats = false WHERE github_id = $1`, [HIDDEN]);
|
|
133
|
+
same((await read()).winner.handle, 't1002981-c', 'the first decision stands');
|
|
134
|
+
await q(`UPDATE platform_identity_accounts SET hide_stats = true WHERE github_id = $1`, [HIDDEN]);
|
|
135
|
+
|
|
136
|
+
// 3 — a winner who hides is not shown, and their week goes to nobody else
|
|
137
|
+
await q(`UPDATE platform_identity_accounts SET hide_stats = true WHERE github_id = $1`, [C]);
|
|
138
|
+
same((await read()).winner, null, 'a hidden winner is not drawn');
|
|
139
|
+
same(await recorded(first.week.start), [{ github_id: String(C), credits: 350, ships: 3 }], 'and the row is never re-crowned');
|
|
140
|
+
|
|
141
|
+
// 4 — a later week nobody shipped in (two on, past A's late snapshot) is recorded empty;
|
|
142
|
+
// the crowned week is in its past list once its winner is shown again
|
|
143
|
+
const next = at(NOW, 14 * DAY);
|
|
144
|
+
const empty = await read(next);
|
|
145
|
+
same(empty.winner, null, 'a week nobody won has no crown');
|
|
146
|
+
same(await recorded(empty.week.start), [{ github_id: null, credits: 0, ships: 0 }], 'and is recorded empty, so it stays empty');
|
|
147
|
+
same(empty.past, [], 'the week before is left out while its winner hides');
|
|
148
|
+
await q(`UPDATE platform_identity_accounts SET hide_stats = false WHERE github_id = $1`, [C]);
|
|
149
|
+
same((await read(next)).past.map((p) => [p.week_start, p.handle]), [[first.week.start, 't1002981-c']], 'shown again, it is back in the past weeks');
|
|
150
|
+
|
|
151
|
+
// 5 — two hub processes crowning the same week at once: one row, no error
|
|
152
|
+
const third = at(NOW, 21 * DAY);
|
|
153
|
+
const other = new Pool({ connectionString: process.env.DATABASE_URL || undefined });
|
|
154
|
+
try {
|
|
155
|
+
clearCrownMemo();
|
|
156
|
+
const both = await Promise.all([readBuilderOfTheWeek({ now: third, pool }), readBuilderOfTheWeek({ now: third, pool: other })]);
|
|
157
|
+
same(both.map((b) => b.winner), [null, null], 'both answer');
|
|
158
|
+
same((await recorded(both[0].week.start)).length, 1, 'one row: ON CONFLICT DO NOTHING');
|
|
159
|
+
} finally { await other.end().catch(() => {}); }
|
|
160
|
+
|
|
161
|
+
console.log(`\nbuilder_of_the_week_db.mjs: ${pass} passed`);
|
|
162
|
+
} finally {
|
|
163
|
+
clearCrownMemo();
|
|
164
|
+
await cleanup();
|
|
165
|
+
await pool.end().catch(() => {});
|
|
166
|
+
}
|
|
@@ -288,6 +288,59 @@ try {
|
|
|
288
288
|
assert.equal(pages.parseSentBackBlocks(task(5002).description).length, 2, 'send-backs append');
|
|
289
289
|
});
|
|
290
290
|
|
|
291
|
+
// task 1004900: the WORDS of a submitted batch are reviewed before anything is
|
|
292
|
+
// applied. Approve puts the round in the /tweak queue; send-back withdraws the
|
|
293
|
+
// batch so the round is a draft its artist can change.
|
|
294
|
+
await test('words first: a submitted batch is in the queue as words, not applied, and only an approval sends it to /tweak', async () => {
|
|
295
|
+
const STUDIO = '/copy-desk/pages/builders:studio';
|
|
296
|
+
const line = { key: 'L0001', section: 'top', before: 'Welcome home', after: 'Welcome back', placement: 'placed', file: 'x.html', line: 1, string_id: 'abc' };
|
|
297
|
+
const desc = () => [
|
|
298
|
+
'Page tweak round 1 for builders:studio.',
|
|
299
|
+
pages.composeDraftBlock({ page_id: 'builders:studio', reading_hash: 'h', saved_at: '2026-09-23T00:00:00Z', lines: [line] }),
|
|
300
|
+
pages.composeBatchBlock({ page_id: 'builders:studio', reading_hash: 'h', submitted_at: '2026-09-23T00:00:00Z', lines: [line] }),
|
|
301
|
+
].join('\n\n');
|
|
302
|
+
db.tasks.push({ id: '7001', status: 'ready', title: 'Tweak: Studio', description: desc(), source: 'page-tweak', source_ref: pages.roundSourceRef('builders:studio', 1), touches: [], version_id: 'V9', goal_id: '77' });
|
|
303
|
+
db.claims.push({ id: String(++db.seq.claim), task_id: '7001', builder_id: ALICE, worktree_name: null, creator_session_id: null, claimed_at: stamp(), released_at: stamp(), outcome: 'partial' });
|
|
304
|
+
|
|
305
|
+
assert.equal((await call('GET', STUDIO, ALICE)).body.open_round.state, 'words_review');
|
|
306
|
+
const q = (await call('GET', '/copy-desk/approvals', BOB)).body;
|
|
307
|
+
const item = q.queue.find((x) => x.task_id === '7001');
|
|
308
|
+
assert.ok(item, 'the submitted words are in the queue');
|
|
309
|
+
assert.equal(item.kind, 'words');
|
|
310
|
+
assert.equal(item.applied_at, null);
|
|
311
|
+
assert.deepEqual(item.lines.map((l) => [l.before, l.after]), [['Welcome home', 'Welcome back']]);
|
|
312
|
+
assert.equal(q.not_applied.some((r) => r.task_id === '7001'), false, 'not listed as approved-and-waiting');
|
|
313
|
+
|
|
314
|
+
const dan = await call('POST', `${STUDIO}/approve`, DAN, { task_id: '7001' });
|
|
315
|
+
assert.equal(errCode(dan.body), 'not_an_artist');
|
|
316
|
+
const short = await call('POST', `${STUDIO}/send-back`, BOB, { note: 'nope' });
|
|
317
|
+
assert.equal(errCode(short.body), 'note_too_short');
|
|
318
|
+
|
|
319
|
+
const ok = await call('POST', `${STUDIO}/approve`, BOB, { task_id: '7001' });
|
|
320
|
+
assert.equal(ok.status, 200, JSON.stringify(ok.body));
|
|
321
|
+
assert.equal(ok.body.outcome, 'words_approved');
|
|
322
|
+
assert.deepEqual(ok.body.round, { task_id: '7001', state: 'submitted', status: 'ready' });
|
|
323
|
+
assert.equal(task(7001).status, 'ready', 'nothing applied or landed: the task only gained the approval');
|
|
324
|
+
assert.deepEqual(pages.parseWordsApprovedBlocks(task(7001).description).map((a) => a.by), [BOB]);
|
|
325
|
+
assert.equal((await call('GET', STUDIO, ALICE)).body.open_round.state, 'submitted', 'now in the /tweak queue');
|
|
326
|
+
const after = (await call('GET', '/copy-desk/approvals', BOB)).body;
|
|
327
|
+
assert.equal(after.queue.some((x) => x.task_id === '7001'), false);
|
|
328
|
+
assert.equal(after.not_applied.some((r) => r.task_id === '7001'), true, 'listed as approved, waiting to be applied');
|
|
329
|
+
const twice = await call('POST', `${STUDIO}/approve`, BOB);
|
|
330
|
+
assert.equal(errCode(twice.body), 'not_waiting', 'an approved batch is not decided again');
|
|
331
|
+
|
|
332
|
+
// A second round of words, sent back: the batch goes, the draft stays, the note rests on the task.
|
|
333
|
+
task(7001).description = desc();
|
|
334
|
+
const back = await call('POST', `${STUDIO}/send-back`, ALICE, { note: 'I want to say this differently.', task_id: '7001' });
|
|
335
|
+
assert.equal(back.status, 200, JSON.stringify(back.body));
|
|
336
|
+
assert.equal(back.body.outcome, 'words_sent_back');
|
|
337
|
+
assert.equal(pages.parseBatchBlock(task(7001).description).ok, false, 'the batch is withdrawn');
|
|
338
|
+
assert.equal(pages.parseDraftBlock(task(7001).description).ok, true, 'the draft stays for its writer');
|
|
339
|
+
assert.deepEqual(pages.parseSentBackBlocks(task(7001).description).map((s) => s.note), ['I want to say this differently.']);
|
|
340
|
+
assert.equal((await call('GET', STUDIO, ALICE)).body.open_round.state, 'draft', 'a draft again, open for writing');
|
|
341
|
+
assert.equal(pages.roundOpenForWriting(task(7001)), true);
|
|
342
|
+
});
|
|
343
|
+
|
|
291
344
|
await test('an ordinary task and a failed grade are never moved through the port', async () => {
|
|
292
345
|
db.tasks.push({ id: '6001', status: 'completed', title: 'x', description: '', source: 'planning-session', source_ref: 'x', touches: [] });
|
|
293
346
|
db.grades.set('6001', true);
|
|
@@ -144,10 +144,10 @@ try {
|
|
|
144
144
|
writeReadings(readingsWith('studio-h1'));
|
|
145
145
|
});
|
|
146
146
|
|
|
147
|
-
await test('after submit the round
|
|
147
|
+
await test('after submit the round waits for its words to be reviewed and nobody holds it', async () => {
|
|
148
148
|
assert.equal((await call('POST', `${STUDIO}/submit`, ALICE)).body.outcome, 'submitted');
|
|
149
149
|
const r = await call('GET', `${STUDIO}/draft`, ALICE);
|
|
150
|
-
assert.equal(r.body.round.state, '
|
|
150
|
+
assert.equal(r.body.round.state, 'words_review', 'task 1004900: reviewed before it is applied');
|
|
151
151
|
assert.equal(r.body.round.held_by_you, false);
|
|
152
152
|
assert.equal(r.body.round.holder, null);
|
|
153
153
|
});
|
|
@@ -154,7 +154,7 @@ try {
|
|
|
154
154
|
round.status = 'ready';
|
|
155
155
|
round.description += '\n\n' + pages.composeBatchBlock({ page_id: 'status:explore', reading_hash: 'h', submitted_at: 'x', lines: [{ key: 'L0001', section: 'hero', before: 'Explore', after: 'Look around', placement: 'placed', file: 'x', line: 1, string_id: 'a' }] });
|
|
156
156
|
const next = (await getNext(ALICE)).body;
|
|
157
|
-
assert.deepEqual(next.skipped, [{ page_id: 'status:explore', reason: 'page_in_flight', state: '
|
|
157
|
+
assert.deepEqual(next.skipped, [{ page_id: 'status:explore', reason: 'page_in_flight', state: 'words_review' }]);
|
|
158
158
|
const refused = await call('POST', '/copy-desk/pages/status:explore/claim', ALICE, {});
|
|
159
159
|
assert.equal(errCode(refused.body), 'page_in_flight');
|
|
160
160
|
});
|
|
@@ -104,7 +104,7 @@ try {
|
|
|
104
104
|
const flight = Object.fromEntries(r.body.in_flight.map((f) => [f.page_id, f]));
|
|
105
105
|
assert.deepEqual([flight['landing:index'].state, flight['landing:index'].stuck, flight['landing:index'].task_id], ['unknown', true, '7001']);
|
|
106
106
|
assert.deepEqual([flight['landing:projects'].state, flight['landing:projects'].stuck], ['unknown', true]);
|
|
107
|
-
assert.equal(flight['status:explore'].state, '
|
|
107
|
+
assert.equal(flight['status:explore'].state, 'words_review');
|
|
108
108
|
assert.equal(flight['status:explore'].stuck, false);
|
|
109
109
|
});
|
|
110
110
|
|
|
@@ -188,7 +188,7 @@ try {
|
|
|
188
188
|
const r = await call('POST', `${STUDIO}/submit`, ALICE);
|
|
189
189
|
assert.equal(r.status, 200, JSON.stringify(r.body));
|
|
190
190
|
assert.equal(r.body.outcome, 'submitted');
|
|
191
|
-
assert.deepEqual(r.body.round, { task_id: studioTask().id, state: '
|
|
191
|
+
assert.deepEqual(r.body.round, { task_id: studioTask().id, state: 'words_review', status: 'ready' });
|
|
192
192
|
assert.equal(r.body.batch.line_count, 2);
|
|
193
193
|
assert.deepEqual(r.body.next, { path: '/copy-desk/next' });
|
|
194
194
|
|
|
@@ -229,7 +229,7 @@ try {
|
|
|
229
229
|
await test('the page reads submitted, and nobody can claim it back into writing', async () => {
|
|
230
230
|
const page = await call('GET', STUDIO, BOB);
|
|
231
231
|
assert.equal(page.status, 200);
|
|
232
|
-
assert.equal(page.body.open_round.state, '
|
|
232
|
+
assert.equal(page.body.open_round.state, 'words_review');
|
|
233
233
|
const claim = await call('POST', `${STUDIO}/claim`, BOB);
|
|
234
234
|
assert.equal(errCode(claim.body), 'page_in_flight');
|
|
235
235
|
});
|
|
@@ -23,7 +23,8 @@ const ago = (d) => new Date(NOW.getTime() - d * 86400000).toISOString();
|
|
|
23
23
|
const pg = (id, title = id) => ({ id, surface: id.split(':')[0], title, path: '/', files: [] });
|
|
24
24
|
const PAGES = [pg('landing:index', '{{productName}}: {{tagline}}'), pg('landing:account', 'Account'), pg('builders:studio', 'Studio'), pg('landing:projects', 'Projects')];
|
|
25
25
|
const INVENTORY = { surfaces: [{ id: 'landing' }, { id: 'builders' }], pages: PAGES };
|
|
26
|
-
|
|
26
|
+
// Approved words (task 1004900): the round waits to be applied.
|
|
27
|
+
const batch = (id) => `${pages.composeBatchBlock({ page_id: id, reading_hash: 'h', submitted_at: ago(9), lines: [] })}\n\n${pages.composeWordsApprovedBlock({ at: ago(9), by: '7' })}`;
|
|
27
28
|
const round = (n, id, extra) => ({ id: String(n), source: 'page-tweak', source_ref: pages.roundSourceRef(id, 1), description: pages.roundTaskBody(pg(id), 1), artist_id: '7', ...extra });
|
|
28
29
|
const ARTIST = '7';
|
|
29
30
|
const TASKS = [
|
|
@@ -45,6 +46,14 @@ await test('the composer: only stuck pages, each with where it sits, how long, a
|
|
|
45
46
|
]);
|
|
46
47
|
});
|
|
47
48
|
|
|
49
|
+
await test('submitted words left unreviewed wait on their artist, not the appliers (task 1004900)', () => {
|
|
50
|
+
const words = round(9, 'landing:projects', { status: 'ready', description: `${pages.roundTaskBody(pg('landing:projects'), 1)}\n\n${pages.composeBatchBlock({ page_id: 'landing:projects', reading_hash: 'h', submitted_at: ago(4), lines: [] })}`, updated_at: ago(4) });
|
|
51
|
+
const got = status.stuckPageTweaks({ inventory: INVENTORY, tasks: [words], now: NOW });
|
|
52
|
+
assert.deepEqual(got.map((s) => [s.page_id, s.state, s.waits_on]), [['landing:projects', 'words_review', 'artist']]);
|
|
53
|
+
assert.deepEqual(status.stuckPageTweaksFor({ builderId: ARTIST, rank: 'xenos', stuck: got }).map((s) => s.page_id), ['landing:projects']);
|
|
54
|
+
assert.deepEqual(status.stuckPageTweaksFor({ builderId: '99', rank: 'metic', stuck: got }), [], 'an applier is not asked to review words');
|
|
55
|
+
});
|
|
56
|
+
|
|
48
57
|
await test('Home: the artist sees their own review; a Metic+ builder sees what waits to be applied', () => {
|
|
49
58
|
const forArtist = status.stuckPageTweaksFor({ builderId: ARTIST, rank: 'thetes', stuck: stuck() });
|
|
50
59
|
assert.deepEqual(forArtist.map((s) => s.page_id), ['landing:account']);
|
|
@@ -57,7 +66,7 @@ await test('the real needs roster: one Needs-attention line per page, where and
|
|
|
57
66
|
const r = needs.computeNeeds({ rank: 'archon', ownKeySet: true, stuckPageTweaks: status.stuckPageTweaksFor({ builderId: ARTIST, rank: 'archon', stuck: stuck() }) });
|
|
58
67
|
const lines = r.items.filter((n) => n.id.startsWith('page_tweak_stuck:'));
|
|
59
68
|
assert.deepEqual(lines.map((n) => [n.id, n.title, n.message, n.state, n.action.href]), [
|
|
60
|
-
['page_tweak_stuck:landing:index', 'Page tweak stuck: landing:index', '
|
|
69
|
+
['page_tweak_stuck:landing:index', 'Page tweak stuck: landing:index', 'Approved, waiting to be applied for 26 days.', 'action_needed', '/studio'],
|
|
61
70
|
['page_tweak_stuck:landing:account', 'Page tweak stuck: Account', 'Waiting for your review for 5 days.', 'action_needed', '/studio'],
|
|
62
71
|
]);
|
|
63
72
|
});
|
|
@@ -82,7 +91,7 @@ await test('the real digest: page names only, never copy, to the ship channel, a
|
|
|
82
91
|
assert.equal(posts[0].channel, 'ship_feed');
|
|
83
92
|
assert.equal(posts[0].text, [
|
|
84
93
|
'Page tweaks stuck (2). Open the Studio to move them:',
|
|
85
|
-
'- landing:index:
|
|
94
|
+
'- landing:index: approved, waiting to be applied, 26 days',
|
|
86
95
|
'- Account: waiting for your review, 5 days',
|
|
87
96
|
].join('\n'));
|
|
88
97
|
const again = await stuckTweaks.runDigest({ nowMs: NOW.getTime() + 3600_000, state, post, readStuckImpl });
|
|
@@ -141,8 +141,8 @@ await test('lib: the artboard\'s words, from the route\'s own shape', () => {
|
|
|
141
141
|
assert.equal(lib.displayTitle(home), 'Home', 'an apex template title is Home, the artboard\'s own word');
|
|
142
142
|
assert.equal(lib.subLine(home), '· landing · 1 line rewritten, applied · 1 not applied');
|
|
143
143
|
assert.equal(lib.subLine(projects), '· landing · 1 line rewritten, applied');
|
|
144
|
-
assert.equal(lib.doorDetail(2), '2 pages
|
|
145
|
-
assert.equal(lib.doorDetail(1), '1 page
|
|
144
|
+
assert.equal(lib.doorDetail(2), '2 pages waiting for you');
|
|
145
|
+
assert.equal(lib.doorDetail(1), '1 page waiting for you');
|
|
146
146
|
assert.equal(lib.doorDetail(0), 'nothing waiting for you');
|
|
147
147
|
assert.equal(lib.approvedLine(home, { status_after: 'Text tweaked 3x', next: { page: { title: 'Contact' } } }),
|
|
148
148
|
'Approved. It ships now, and Home reads text tweaked 3x. Next in the queue: Contact.', 'the artboard line, word for word');
|
|
@@ -346,22 +346,22 @@ await test('submitted but not applied: listed apart, marked, how long, no verbs,
|
|
|
346
346
|
const submitted = {
|
|
347
347
|
id: 60, status: 'ready', holder_id: null, artist_id: ME, artist_login: 'me', updated_at: '2026-09-29T08:00:00Z',
|
|
348
348
|
source_ref: pages.roundSourceRef('landing:projects', 2),
|
|
349
|
-
description: ['Page tweak round 2.', pages.composeBatchBlock({ page_id: 'landing:projects', reading_hash: 'h', submitted_at: '2026-09-29T08:00:00Z', lines: [{ key: 'L0002', section: 'list', before: 'Every project', after: 'All of them', placement: 'placed', file: 'x.html', line: 1, string_id: 'a' }] })].join('\n\n'),
|
|
349
|
+
description: ['Page tweak round 2.', pages.composeBatchBlock({ page_id: 'landing:projects', reading_hash: 'h', submitted_at: '2026-09-29T08:00:00Z', lines: [{ key: 'L0002', section: 'list', before: 'Every project', after: 'All of them', placement: 'placed', file: 'x.html', line: 1, string_id: 'a' }] }), pages.composeWordsApprovedBlock({ at: '2026-09-29T09:00:00Z', by: ME })].join('\n\n'),
|
|
350
350
|
};
|
|
351
351
|
const tasks = [...TASKS.filter((t) => t.id !== 42), submitted];
|
|
352
352
|
const data = pageStatus.composeApprovals({ inventory: INVENTORY, tasks, builderId: ME, crafts: ['artist'], now: NOW });
|
|
353
353
|
assert.equal(data.count, 1, 'the door counts reviewable rounds only');
|
|
354
354
|
assert.deepEqual(data.queue.map((q) => q.task_id), ['41']);
|
|
355
355
|
assert.deepEqual(data.not_applied.map((r) => [r.page_id, r.task_id, r.state_word, r.waited_days, r.line_count]),
|
|
356
|
-
[['landing:projects', '60', '
|
|
356
|
+
[['landing:projects', '60', 'Approved, waiting to be applied', 21, 1]]);
|
|
357
357
|
|
|
358
358
|
const b = bootPanel({ answers: { [APPROVALS]: { ok: true, ...data }, [vis(41)]: slotsFor(41) } });
|
|
359
359
|
await b.panel.open(); await settle(); await settle();
|
|
360
|
-
assert.equal(text(b.q('.aq-pending__h')), '
|
|
360
|
+
assert.equal(text(b.q('.aq-pending__h')), 'Approved, not applied yet (1)');
|
|
361
361
|
const row = b.q('.aq-pending__row');
|
|
362
362
|
assert.match(text(row), /Projects/);
|
|
363
363
|
assert.match(text(row), /not applied yet/);
|
|
364
|
-
assert.match(text(row), /
|
|
364
|
+
assert.match(text(row), /Approved, waiting to be applied · waiting 21 days\. The words are approved and waiting to be put on the page/);
|
|
365
365
|
assert.equal(row.querySelectorAll('button').length, 0, 'no approve or send-back on a page nobody applied');
|
|
366
366
|
assert.deepEqual(b.counts, [1]);
|
|
367
367
|
|
|
@@ -135,8 +135,8 @@ test('the bands say what the hub’s say and lead into /projects; Account is the
|
|
|
135
135
|
assert.deepEqual(bandLinks(bandOf(PAGE, 'menuProjects')), [
|
|
136
136
|
['Sky', '/projects?view=map'], ['Table', '/projects?view=map&sub=table'],
|
|
137
137
|
['Your projects', '/projects?view=mine'], ['New project', '/projects?view=new'],
|
|
138
|
-
// the Community menu's
|
|
139
|
-
['Leaderboard', '/community'], ['Find a builder', '/community/find'], ['Open to recruiters', '/community/recruiters'],
|
|
138
|
+
// the Community menu's four sub-pages, folded into the band under 480px (tasks 1003363, 1002981)
|
|
139
|
+
['Leaderboard', '/community'], ['Builder of the week', '/community/week'], ['Find a builder', '/community/find'], ['Open to recruiters', '/community/recruiters'],
|
|
140
140
|
['Builders you may know', '/connections#connSuggest'],
|
|
141
141
|
], 'this page has no views of its own, so every item names /projects in full');
|
|
142
142
|
assert.deepEqual(bandLinks(bandOf(PAGE, 'menuProfile')), [
|