@bongos/core 1.23.17 → 1.23.19
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 +109 -54
- 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 +985 -934
- package/docs/copy-registry.json +1829 -1309
- package/docs/module-api-changelog.md +4 -0
- package/docs/page-inventory.json +10 -2
- package/docs/page-readings.json +601 -576
- package/modules/copy-desk/auto-close.js +9 -3
- package/modules/copy-desk/page-status.js +25 -13
- package/modules/copy-desk/tests/copy_page_auto_close.mjs +8 -0
- package/modules/copy-desk/tests/copy_page_status.mjs +2 -2
- package/modules/platform-identity/builder-of-the-week.js +48 -20
- package/modules/platform-identity/community-crowns.js +176 -0
- package/modules/platform-identity/community-leaderboard.js +1 -1
- package/modules/platform-identity/migrations/platform_identity_033_period_features.sql +43 -0
- package/modules/platform-identity/routes/community-leaderboard.js +25 -3
- package/modules/public-landing/public/account.html +3 -2
- package/modules/public-landing/public/community-crowns-first.states.json +15 -0
- package/modules/public-landing/public/community-crowns-new-year.states.json +15 -0
- package/modules/public-landing/public/community-crowns-none.states.json +15 -0
- package/modules/public-landing/public/community-empty.states.json +1 -1
- package/modules/public-landing/public/community-owner-two.states.json +2 -2
- package/modules/public-landing/public/community-owner.states.json +1 -1
- package/modules/public-landing/public/community-week-none.states.json +1 -1
- package/modules/public-landing/public/community.html +130 -22
- package/modules/public-landing/public/community.probes.json +20 -3
- package/modules/public-landing/public/community.states.json +8 -6
- package/modules/public-landing/public/connections.html +3 -2
- package/modules/public-landing/public/contact.html +3 -2
- package/modules/public-landing/public/guild.html +3 -2
- package/modules/public-landing/public/index.html +3 -2
- package/modules/public-landing/public/invitations.html +3 -2
- package/modules/public-landing/public/privacy.html +3 -2
- package/modules/public-landing/public/projects.html +3 -2
- package/modules/public-landing/public/terms.html +3 -2
- package/modules/ui-design/kit/fixtures/community-crowns-first.json +6 -0
- package/modules/ui-design/kit/fixtures/community-crowns-new-year.json +6 -0
- package/modules/ui-design/kit/fixtures/community-crowns-none.json +6 -0
- package/modules/ui-design/kit/fixtures/community-crowns.json +6 -0
- package/modules/ui-design/kit/serve.js +14 -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 +4 -3
- package/tests/community_crowns.mjs +369 -0
- package/tests/community_crowns_db.mjs +164 -0
- package/tests/copy_stuck_page_tweaks.mjs +39 -2
- package/tests/hub_account_page.mjs +2 -2
- package/tests/hub_community_page.mjs +144 -29
- package/tests/landing_legal_pages.mjs +2 -2
- package/tests/privacy_matrix.mjs +68 -9
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
// tests/community_crowns_db.mjs — task 1004902 (the builders of the month and the year;
|
|
2
|
+
// real DB).
|
|
3
|
+
//
|
|
4
|
+
// Proves against real SQL what a fake pool cannot (tests/community_crowns.mjs): the month
|
|
5
|
+
// keeps the week's rule (a lifetime brought into the month never counts), the year counts
|
|
6
|
+
// that same row from its first snapshot inside the year and never its lifetime, a row first
|
|
7
|
+
// seen in the year counts from zero, the month is pinned against a later report, a hide
|
|
8
|
+
// and a race, and the year that ended is crowned on the next year's first read.
|
|
9
|
+
//
|
|
10
|
+
// The crowns are global (every eligible builder on the hub), so every fixture period is in
|
|
11
|
+
// 2099: a shared database's real builders have no snapshot there, their delta is zero, and
|
|
12
|
+
// the shipped-work floor keeps them out.
|
|
13
|
+
//
|
|
14
|
+
// Real-DB integration test: self-skips without a reachable Postgres; in the INTEGRATION set
|
|
15
|
+
// (scripts/bongos/run-unit-tests.js) so the DB-free lane never runs it.
|
|
16
|
+
//
|
|
17
|
+
// Run: DATABASE_URL=... node tests/community_crowns_db.mjs
|
|
18
|
+
|
|
19
|
+
import { createRequire } from 'node:module';
|
|
20
|
+
import { readFileSync } from 'node:fs';
|
|
21
|
+
import { fileURLToPath } from 'node:url';
|
|
22
|
+
import { dirname, join } from 'node:path';
|
|
23
|
+
import { strict as assert } from 'node:assert';
|
|
24
|
+
const require = createRequire(import.meta.url);
|
|
25
|
+
const { Pool } = require('pg');
|
|
26
|
+
const { clearCrownMemo } = require('../modules/platform-identity/builder-of-the-week.js');
|
|
27
|
+
const { readCrowns, clearCrownsMemo } = require('../modules/platform-identity/community-crowns.js');
|
|
28
|
+
|
|
29
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
30
|
+
const MIG_DIR = join(here, '..', 'modules', 'platform-identity', 'migrations');
|
|
31
|
+
const migrationSql = (f) => readFileSync(join(MIG_DIR, f), 'utf8');
|
|
32
|
+
|
|
33
|
+
const pool = new Pool({ connectionString: process.env.DATABASE_URL || undefined });
|
|
34
|
+
try {
|
|
35
|
+
await pool.query('SELECT 1');
|
|
36
|
+
} catch (e) {
|
|
37
|
+
console.log(`community_crowns_db.mjs SKIPPED — no reachable Postgres (${e.code || e.message}).`);
|
|
38
|
+
await pool.end().catch(() => {});
|
|
39
|
+
process.exit(0);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const M = 90490201, L = 90490202, N = 90490203, Y = 90490204, HIDDEN = 90490205;
|
|
43
|
+
const IDS = [M, L, N, Y, HIDDEN];
|
|
44
|
+
const P1 = 't1004902-p1';
|
|
45
|
+
const NOW = new Date('2099-03-12T10:00:00.000Z');
|
|
46
|
+
const NEXT_YEAR = new Date('2100-01-02T10:00:00.000Z');
|
|
47
|
+
const d = (iso) => new Date(iso);
|
|
48
|
+
const q = (sql, params) => pool.query(sql, params);
|
|
49
|
+
|
|
50
|
+
async function cleanup() {
|
|
51
|
+
await q(`DELETE FROM platform_identity_period_features WHERE period_start >= '2099-01-01'`).catch(() => {});
|
|
52
|
+
await q(`DELETE FROM platform_identity_weekly_features WHERE week_start >= '2099-01-01'`).catch(() => {});
|
|
53
|
+
await q(`DELETE FROM platform_identity_activity_snapshots WHERE github_id = ANY($1::bigint[])`, [IDS]).catch(() => {});
|
|
54
|
+
await q(`DELETE FROM platform_identity_builder_project_activity WHERE github_id = ANY($1::bigint[])`, [IDS]).catch(() => {});
|
|
55
|
+
await q(`DELETE FROM platform_identity_accounts WHERE github_id = ANY($1::bigint[])`, [IDS]).catch(() => {});
|
|
56
|
+
}
|
|
57
|
+
async function account(id, handle, { hide = false } = {}) {
|
|
58
|
+
await q(
|
|
59
|
+
`INSERT INTO platform_identity_accounts
|
|
60
|
+
(github_id, github_login, display_name, handle, profile_state, account_visibility, hide_stats, terms_accepted_at)
|
|
61
|
+
VALUES ($1, $2, $2, $2, 'active', 'public', $3, now())`,
|
|
62
|
+
[id, handle, hide],
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
async function builder(id, history, firstSeen) {
|
|
66
|
+
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)`, [P1, id, firstSeen]);
|
|
67
|
+
for (const [when, credits, ships] of history) {
|
|
68
|
+
await q(
|
|
69
|
+
`INSERT INTO platform_identity_activity_snapshots (client_id, github_id, credits, tasks_shipped, karma, reported_at) VALUES ($1, $2, $3, $4, 0, $5)`,
|
|
70
|
+
[P1, id, credits, ships, d(when)],
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
const recorded = async (period, start) => (await q(
|
|
75
|
+
`SELECT github_id::text AS github_id, credits_delta::int AS credits, ships_delta::int AS ships
|
|
76
|
+
FROM platform_identity_period_features WHERE period = $1 AND period_start = $2::date`,
|
|
77
|
+
[period, start],
|
|
78
|
+
)).rows;
|
|
79
|
+
const read = (now = NOW, p = pool) => { clearCrownMemo(); clearCrownsMemo(); return readCrowns({ now, pool: p }); };
|
|
80
|
+
const who = (w) => w && [w.handle, w.credits, w.tasks_shipped];
|
|
81
|
+
|
|
82
|
+
let pass = 0;
|
|
83
|
+
const ok = (cond, msg) => { assert.ok(cond, msg); pass++; console.log(` ok - ${msg}`); };
|
|
84
|
+
const same = (actual, expected, msg) => { assert.deepEqual(actual, expected, msg); pass++; console.log(` ok - ${msg}`); };
|
|
85
|
+
|
|
86
|
+
try {
|
|
87
|
+
for (const f of [
|
|
88
|
+
'platform_identity_001_tables.sql', 'platform_identity_005_builder_activity.sql',
|
|
89
|
+
'platform_identity_007_profile_consent.sql', 'platform_identity_008_handle.sql',
|
|
90
|
+
'platform_identity_012_account_privacy.sql', 'platform_identity_019_terms_acceptance.sql',
|
|
91
|
+
'platform_identity_022_activity_snapshots.sql', 'platform_identity_032_weekly_features.sql',
|
|
92
|
+
'platform_identity_033_period_features.sql',
|
|
93
|
+
]) await q(migrationSql(f));
|
|
94
|
+
await cleanup();
|
|
95
|
+
|
|
96
|
+
await account(M, 't1004902-m');
|
|
97
|
+
await account(L, 't1004902-l');
|
|
98
|
+
await account(N, 't1004902-n');
|
|
99
|
+
await account(Y, 't1004902-y');
|
|
100
|
+
await account(HIDDEN, 't1004902-hidden', { hide: true });
|
|
101
|
+
|
|
102
|
+
// Y: history before the year — 400 credits and 3 works in it, all in March.
|
|
103
|
+
await builder(Y, [['2098-12-20T00:00:00Z', 0, 0], ['2099-03-05T00:00:00Z', 400, 3]], d('2098-06-01T00:00:00Z'));
|
|
104
|
+
// M: history before February — 250 credits and 2 works in February. In the year it has
|
|
105
|
+
// no snapshot before 1 January, so it counts from its first one inside (0).
|
|
106
|
+
await builder(M, [['2099-01-20T00:00:00Z', 0, 0], ['2099-02-10T00:00:00Z', 250, 2]], d('2098-06-01T00:00:00Z'));
|
|
107
|
+
// L: a lifetime brought into February — no history before it, seen before first_seen_at
|
|
108
|
+
// existed (NULL). Its first snapshot is 90000; it then earns 500 and two works. The month
|
|
109
|
+
// never counts it; the year counts 500, never 90500.
|
|
110
|
+
await builder(L, [['2099-02-03T00:00:00Z', 90000, 40], ['2099-02-12T00:00:00Z', 90500, 42]], null);
|
|
111
|
+
// N: first seen in the year, so counted from zero: 300 and a work.
|
|
112
|
+
await builder(N, [['2099-02-02T00:00:00Z', 300, 1]], d('2099-02-01T00:00:00Z'));
|
|
113
|
+
// HIDDEN out-earns everyone in both, and the leaderboard may not show them.
|
|
114
|
+
await builder(HIDDEN, [['2098-12-20T00:00:00Z', 0, 0], ['2099-02-05T00:00:00Z', 9000, 9]], d('2098-06-01T00:00:00Z'));
|
|
115
|
+
|
|
116
|
+
// 1 — the month: the week's rule
|
|
117
|
+
const first = await read();
|
|
118
|
+
same([first.month.start, first.month.end, first.month.first_crown], ['2099-02-01', '2099-03-01', null], 'the month is the last full calendar month');
|
|
119
|
+
same(who(first.month.winner), ['t1004902-n', 300, 1], 'the month: a row first seen in it counts from zero; a lifetime brought into it never counts; hidden never wins');
|
|
120
|
+
ok(!('github_id' in first.month.winner), 'github_id never crosses');
|
|
121
|
+
same(await recorded('month', '2099-02-01'), [{ github_id: String(N), credits: 300, ships: 1 }], 'the month is recorded on the first read');
|
|
122
|
+
|
|
123
|
+
// 2 — the year so far: the first snapshot inside the year is the baseline
|
|
124
|
+
same([first.year.start, first.year.since], ['2099-01-01', '2099-01-01'], 'history began before the year');
|
|
125
|
+
same(who(first.year.winner), ['t1004902-l', 500, 2], 'the year counts L from its first snapshot inside it — 500, not its 90500 lifetime — and that beats Y’s 400 and N’s 300');
|
|
126
|
+
same(await recorded('year', '2099-01-01'), [], 'a year so far is never written');
|
|
127
|
+
|
|
128
|
+
// 3 — pinned: a later report into the past month, or a builder who goes public, changes nothing
|
|
129
|
+
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, M, d('2099-02-20T00:00:00Z')]);
|
|
130
|
+
await q(`UPDATE platform_identity_accounts SET hide_stats = false WHERE github_id = $1`, [HIDDEN]);
|
|
131
|
+
same(who((await read()).month.winner), ['t1004902-n', 300, 1], 'the first decision stands');
|
|
132
|
+
await q(`UPDATE platform_identity_accounts SET hide_stats = true WHERE github_id = $1`, [HIDDEN]);
|
|
133
|
+
|
|
134
|
+
// 4 — a winner who hides is not shown, and their month goes to nobody else
|
|
135
|
+
await q(`UPDATE platform_identity_accounts SET hide_stats = true WHERE github_id = $1`, [N]);
|
|
136
|
+
same((await read()).month.winner, null, 'a hidden winner is not drawn');
|
|
137
|
+
same(await recorded('month', '2099-02-01'), [{ github_id: String(N), credits: 300, ships: 1 }], 'and the row is never re-crowned');
|
|
138
|
+
await q(`UPDATE platform_identity_accounts SET hide_stats = false WHERE github_id = $1`, [N]);
|
|
139
|
+
|
|
140
|
+
// 5 — the year that ended, crowned on the next year's first read by the year's rule
|
|
141
|
+
// (M's late February report now leads it), and shown as last
|
|
142
|
+
const later = await read(NEXT_YEAR);
|
|
143
|
+
same(await recorded('year', '2099-01-01'), [{ github_id: String(M), credits: 99999, ships: 50 }], 'the year is recorded on the next year’s first read');
|
|
144
|
+
same(later.year.last && [later.year.last.start, ...who(later.year.last.winner)], ['2099-01-01', 't1004902-m', 99999, 50], 'and shown as last year');
|
|
145
|
+
same(later.year.winner, null, 'nobody has earned in the new year yet');
|
|
146
|
+
|
|
147
|
+
// 6 — two hub processes crowning the same month at once: one row, no error
|
|
148
|
+
const other = new Pool({ connectionString: process.env.DATABASE_URL || undefined });
|
|
149
|
+
try {
|
|
150
|
+
clearCrownMemo();
|
|
151
|
+
clearCrownsMemo();
|
|
152
|
+
const at = d('2099-05-03T00:00:00Z');
|
|
153
|
+
const both = await Promise.all([readCrowns({ now: at, pool }), readCrowns({ now: at, pool: other })]);
|
|
154
|
+
same(both.map((b) => b.month.winner), [null, null], 'both answer');
|
|
155
|
+
same((await recorded('month', '2099-04-01')).length, 1, 'one row: ON CONFLICT DO NOTHING');
|
|
156
|
+
} finally { await other.end().catch(() => {}); }
|
|
157
|
+
|
|
158
|
+
console.log(`\ncommunity_crowns_db.mjs: ${pass} passed`);
|
|
159
|
+
} finally {
|
|
160
|
+
clearCrownMemo();
|
|
161
|
+
clearCrownsMemo();
|
|
162
|
+
await cleanup();
|
|
163
|
+
await pool.end().catch(() => {});
|
|
164
|
+
}
|
|
@@ -34,8 +34,8 @@ const TASKS = [
|
|
|
34
34
|
round(2, 'landing:account', { status: 'completed', grade_passed: true, updated_at: ago(5) }),
|
|
35
35
|
// submitted yesterday: NOT stuck
|
|
36
36
|
round(3, 'builders:studio', { status: 'ready', description: `${pages.roundTaskBody(pg('builders:studio'), 1)}\n\n${batch('builders:studio')}`, updated_at: ago(1) }),
|
|
37
|
-
// being written for
|
|
38
|
-
round(4, 'landing:projects', { status: 'active', holder_id: '7', holder_worktree: false, updated_at: ago(
|
|
37
|
+
// being written for 3 days: not overdue (that is 7 days, task 1004901)
|
|
38
|
+
round(4, 'landing:projects', { status: 'active', holder_id: '7', holder_worktree: false, updated_at: ago(3) }),
|
|
39
39
|
];
|
|
40
40
|
const stuck = () => status.stuckPageTweaks({ inventory: INVENTORY, tasks: TASKS, now: NOW });
|
|
41
41
|
|
|
@@ -46,6 +46,43 @@ await test('the composer: only stuck pages, each with where it sits, how long, a
|
|
|
46
46
|
]);
|
|
47
47
|
});
|
|
48
48
|
|
|
49
|
+
// task 1004901: every state names who moves it next and when it is overdue.
|
|
50
|
+
await test('every state has an owner and a deadline: overdue only past it, told to that owner', () => {
|
|
51
|
+
const D = 86400000;
|
|
52
|
+
const p = (id) => pg(id);
|
|
53
|
+
const desc = (id, blocks) => [pages.roundTaskBody(p(id), 1), ...blocks].join('\n\n');
|
|
54
|
+
const draftBlock = (id) => pages.composeDraftBlock({ page_id: id, reading_hash: 'h', saved_at: ago(1), lines: [] });
|
|
55
|
+
const batchBlock = (id) => pages.composeBatchBlock({ page_id: id, reading_hash: 'h', submitted_at: ago(1), lines: [] });
|
|
56
|
+
const approved = pages.composeWordsApprovedBlock({ at: ago(1), by: ARTIST });
|
|
57
|
+
// [state, task shape, owner, days until overdue]
|
|
58
|
+
const table = [
|
|
59
|
+
['draft', (id) => ({ status: 'ready', description: desc(id, [draftBlock(id)]) }), 'artist', 7],
|
|
60
|
+
['writing', () => ({ status: 'active', holder_id: ARTIST, holder_worktree: false }), 'artist', 7],
|
|
61
|
+
['words_review', (id) => ({ status: 'ready', description: desc(id, [batchBlock(id)]) }), 'artist', 3],
|
|
62
|
+
['submitted', (id) => ({ status: 'ready', description: desc(id, [batchBlock(id), approved]) }), 'appliers', 3],
|
|
63
|
+
['applying', () => ({ status: 'active', holder_id: '99', holder_worktree: true }), 'appliers', 1],
|
|
64
|
+
['waiting_for_artist', () => ({ status: 'completed', grade_passed: true }), 'artist', 3],
|
|
65
|
+
['landing', () => ({ status: 'confirmed' }), 'appliers', 1],
|
|
66
|
+
['unknown', () => ({ status: 'ready' }), 'appliers', 0],
|
|
67
|
+
];
|
|
68
|
+
for (const [state, shape, owner, days] of table) {
|
|
69
|
+
const id = 'landing:projects';
|
|
70
|
+
const at = (age) => status.composePages({ inventory: INVENTORY, readings: null, tasks: [round(50, id, { ...shape(id), updated_at: ago(age) })], now: NOW }).in_flight[0];
|
|
71
|
+
const before = at(Math.max(days - 0.5, 0));
|
|
72
|
+
assert.equal(before.state, state);
|
|
73
|
+
assert.equal(before.waits_on, owner, `${state} waits on ${owner}`);
|
|
74
|
+
assert.equal(Date.parse(before.due_at) - Date.parse(before.since), days * D, `${state} is overdue after ${days} day(s)`);
|
|
75
|
+
if (days > 0) assert.equal(before.stuck, false, `${state} is not overdue before its deadline`);
|
|
76
|
+
const after = at(days + 0.5);
|
|
77
|
+
assert.equal(after.stuck, true, `${state} is overdue after its deadline`);
|
|
78
|
+
const told = status.stuckPageTweaksFor({ builderId: ARTIST, rank: 'xenos', stuck: status.stuckPageTweaks({ inventory: INVENTORY, tasks: [round(50, id, { ...shape(id), updated_at: ago(days + 0.5) })], now: NOW }) });
|
|
79
|
+
assert.equal(told.length, owner === 'artist' ? 1 : 0, `${state}: its artist is told only when it waits on them`);
|
|
80
|
+
}
|
|
81
|
+
const draft = status.composePages({ inventory: INVENTORY, readings: null, tasks: [round(51, 'landing:projects', { status: 'ready', description: desc('landing:projects', [draftBlock('landing:projects')]), updated_at: ago(2) })], now: NOW }).in_flight[0];
|
|
82
|
+
assert.equal(Date.parse(draft.expires_at) - Date.parse(draft.since), status.DRAFT_EXPIRES_AFTER, 'a draft says when it expires');
|
|
83
|
+
assert.equal(status.DRAFT_EXPIRES_AFTER, 14 * D);
|
|
84
|
+
});
|
|
85
|
+
|
|
49
86
|
await test('submitted words left unreviewed wait on their artist, not the appliers (task 1004900)', () => {
|
|
50
87
|
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
88
|
const got = status.stuckPageTweaks({ inventory: INVENTORY, tasks: [words], now: NOW });
|
|
@@ -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'], ['Builder of the week', '/community/week'], ['Find a builder', '/community/find'], ['Open to recruiters', '/community/recruiters'],
|
|
138
|
+
// the Community menu's five sub-pages, folded into the band under 480px (tasks 1003363, 1002981, 1004902)
|
|
139
|
+
['Community home', '/community'], ['Leaderboard', '/community/leaderboard'], ['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')), [
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
// tests/hub_community_page.mjs — the hub's community pages (task 1003363; ADR 0217 D1,
|
|
2
2
|
// ADR 0337 D1/D3/D4.1, docs/specs/bongos-v1-community-tab.md D1/D3/D4/D8).
|
|
3
3
|
//
|
|
4
|
-
// One file served as
|
|
5
|
-
// /community the
|
|
6
|
-
// /community/
|
|
7
|
-
//
|
|
8
|
-
//
|
|
4
|
+
// One file served as five sub-pages of the bar's Community menu (owner, 2026-10-27):
|
|
5
|
+
// /community the community home (task 1004902: the builders of the week, month and year,
|
|
6
|
+
// and a door to each page), /community/leaderboard the leaderboard, /community/week the
|
|
7
|
+
// builder of the week (task 1002981), /community/find the builder search,
|
|
8
|
+
// /community/recruiters the name-only list of hidden builders open to recruiters. They
|
|
9
|
+
// render existing platform-identity reads and add no rule of their own. What this file pins:
|
|
9
10
|
// • the markup: the hub's bar with the Community menu as the current section, no top
|
|
10
11
|
// block, noindex, dark only, the routes' own windows and query limits
|
|
11
12
|
// • viewing is ungated: the leaderboard and the search read the same routes for every
|
|
@@ -15,11 +16,12 @@
|
|
|
15
16
|
// dropdown and the crafts, the empty, failed and rate-limited board; the search and
|
|
16
17
|
// its refusals; the recruiter list as a door signed out, a refusal on a 403, and rows
|
|
17
18
|
// only from a 200 — name, face and login, never the ranked half's numbers; the builder
|
|
18
|
-
// of the week as one line on the board and as its own page, drawn as the server sent it
|
|
19
|
+
// of the week as one line on the board and as its own page, drawn as the server sent it;
|
|
20
|
+
// the home's three crowns, its empty and failed states, and its doors
|
|
19
21
|
// • the Community menu on every page that wears the hub's bar (apex-only where the page
|
|
20
22
|
// also serves a child), folded into the Projects band on phones
|
|
21
|
-
// • the routes: the
|
|
22
|
-
// • the kit: the states files, and the stub answering as the
|
|
23
|
+
// • the routes: the five paths served on the hub's apex only
|
|
24
|
+
// • the kit: the states files, and the stub answering as the five reads do
|
|
23
25
|
//
|
|
24
26
|
// DB-free. Run: node --test tests/hub_community_page.mjs
|
|
25
27
|
|
|
@@ -46,8 +48,9 @@ const STYLE = PAGE.split('<style>')[1].split('</style>')[0].replace(/\/\*[\s\S]*
|
|
|
46
48
|
const MAIN = PAGE.split('<main class="acct" id="comm">')[1].split('</main>')[0];
|
|
47
49
|
const BOARD_JS = fs.readFileSync(path.join(PI, 'community-leaderboard.js'), 'utf8');
|
|
48
50
|
const SEARCH_JS = fs.readFileSync(path.join(PI, 'builder-search.js'), 'utf8');
|
|
49
|
-
const SUBPAGES = [['/community', 'Leaderboard'], ['/community/week', 'Builder of the week'], ['/community/find', 'Find a builder'], ['/community/recruiters', 'Open to recruiters']];
|
|
51
|
+
const SUBPAGES = [['/community', 'Community home'], ['/community/leaderboard', 'Leaderboard'], ['/community/week', 'Builder of the week'], ['/community/find', 'Find a builder'], ['/community/recruiters', 'Open to recruiters']];
|
|
50
52
|
const WEEK_JS = fs.readFileSync(path.join(PI, 'builder-of-the-week.js'), 'utf8');
|
|
53
|
+
const CROWNS_JS = fs.readFileSync(path.join(PI, 'community-crowns.js'), 'utf8');
|
|
51
54
|
|
|
52
55
|
// ── 1. the markup ────────────────────────────────────────────────────────────
|
|
53
56
|
|
|
@@ -73,9 +76,9 @@ test('the bar and bands are /connections’ but for the sign-in return and the c
|
|
|
73
76
|
assert.equal(bar(PAGE), bar(read('connections.html')));
|
|
74
77
|
});
|
|
75
78
|
|
|
76
|
-
test('no top block: one visually hidden h1, then one section per sub-page, only the
|
|
79
|
+
test('no top block: one visually hidden h1, then one section per sub-page, only the home shown in the markup', () => {
|
|
77
80
|
assert.match(MAIN, /^\s*<h1 class="vh">Community<\/h1>\s*(<!--[\s\S]*?-->\s*)?<div class="mineExtra">/, 'nothing is drawn above the first section (the /design floor)');
|
|
78
|
-
const order = ['<section id="board" data-view="leaderboard" aria-labelledby="boardH">', '<section id="week" data-view="week" aria-labelledby="weekH" hidden>', '<section id="find" data-view="find" aria-labelledby="findH" hidden>', '<section id="recruit" data-view="recruiters" aria-labelledby="recruitH" hidden>'].map((s) => MAIN.indexOf(s));
|
|
81
|
+
const order = ['<section id="home" data-view="home" aria-labelledby="homeH">', '<section id="board" data-view="leaderboard" aria-labelledby="boardH" hidden>', '<section id="week" data-view="week" aria-labelledby="weekH" hidden>', '<section id="find" data-view="find" aria-labelledby="findH" hidden>', '<section id="recruit" data-view="recruiters" aria-labelledby="recruitH" hidden>'].map((s) => MAIN.indexOf(s));
|
|
79
82
|
assert.ok(order.every((i) => i > 0), 'every sub-page is real markup the script only shows');
|
|
80
83
|
assert.deepEqual([...order].sort((a, b) => a - b), order);
|
|
81
84
|
assert.match(MAIN, /<ol class="commRows" id="boardList" data-unit="\{\{currency\}\}" aria-labelledby="boardH"><\/ol>/, 'the unit is the instance’s own word, stamped by the server');
|
|
@@ -100,10 +103,30 @@ test('the builder of the week states its rule in full, and the rule is the modul
|
|
|
100
103
|
assert.match(WEEK_JS, /ORDER BY sum\(credits\) DESC, sum\(tasks_shipped\) DESC, sum\(karma\) DESC,/, 'the tie order the page states is the query’s');
|
|
101
104
|
assert.match(rule, /Builders who hide their numbers or keep their account private are not counted\./);
|
|
102
105
|
assert.match(WEEK_JS, /rollupVisibleSql\('pa'\)/);
|
|
103
|
-
assert.match(rule, /Each week is decided once, the first time
|
|
106
|
+
assert.match(rule, /Each week is decided once, the first time a community page asks for it after it ends, and it does not change after that\./);
|
|
104
107
|
assert.match(WEEK_JS, /ON CONFLICT \(week_start\) DO NOTHING/);
|
|
105
108
|
});
|
|
106
109
|
|
|
110
|
+
test('the home states the month’s and year’s rule, and it is the module’s; its doors are the other pages (task 1004902)', () => {
|
|
111
|
+
const home = MAIN.split('<section id="home"')[1].split('</section>')[0];
|
|
112
|
+
const rule = home.split('<p class="commNote" id="homeRule">')[1].split('</p>')[0];
|
|
113
|
+
assert.match(rule, /the most \{\{currency\}\} across every project in that time, among the builders who shipped at least one work in it\./, 'the week’s formula, which the month and the year reuse');
|
|
114
|
+
assert.match(CROWNS_JS, /require\('\.\/builder-of-the-week'\)/);
|
|
115
|
+
assert.match(CROWNS_JS, /computeWinner\(start, end, pool, opts\)/, 'the month and the crowned year are the week’s statement');
|
|
116
|
+
assert.match(rule, /A tie goes to whoever shipped more works, then to whoever earned more karma\./);
|
|
117
|
+
assert.match(rule, /Builders who hide their numbers or keep their account private are not counted\./);
|
|
118
|
+
assert.match(CROWNS_JS, /rollupVisibleSql\('pa'\)/);
|
|
119
|
+
assert.match(rule, /The week and the month are decided once, when they end; the year shows the leader so far, and is decided on 1 January\./);
|
|
120
|
+
assert.match(CROWNS_JS, /ON CONFLICT \(period, period_start\) DO NOTHING/);
|
|
121
|
+
assert.match(CROWNS_JS, /computeWinner\(start, now, pool, \{ fromFirstSnapshot: true \}\)/, 'the year so far is live');
|
|
122
|
+
for (const id of ['homeFailed', 'homeCrowns', 'crownYearLast']) assert.match(home, new RegExp(` id="${id}"[^>]*hidden`), `${id} waits for the route’s answer`);
|
|
123
|
+
const doors = [...home.split('<ul class="homeDoors" aria-labelledby="doorsH">')[1].split('</ul>')[0].matchAll(/<li( class="knowCell" hidden)?><a class="btn ghost" href="([^"]+)">([^<]+)<\/a><\/li>/g)];
|
|
124
|
+
assert.deepEqual(doors.map((m) => [m[2], m[3], !!m[1]]), [
|
|
125
|
+
['/community/leaderboard', 'Leaderboard', false], ['/community/week', 'Builder of the week', false], ['/community/find', 'Find a builder', false],
|
|
126
|
+
['/community/recruiters', 'Open to recruiters', false], ['/connections#connSuggest', 'Builders you may know', true],
|
|
127
|
+
], 'one door per page, the menu’s order; Builders you may know signed in only, like its menu item');
|
|
128
|
+
});
|
|
129
|
+
|
|
107
130
|
test('the window dropdown and the query limits are the routes’ own (boundary-forced copies)', () => {
|
|
108
131
|
const days = BOARD_JS.match(/const WINDOW_DAYS = Object\.freeze\(\{([^}]*)\}\);/);
|
|
109
132
|
const windows = [...days[1].matchAll(/'?(\w+)'?:/g)].map((m) => m[1]);
|
|
@@ -121,12 +144,12 @@ test('the window dropdown and the query limits are the routes’ own (boundary-f
|
|
|
121
144
|
assert.match(MAIN, new RegExp(`<input id="findQ" type="search"[^>]* maxlength="${max}"`));
|
|
122
145
|
});
|
|
123
146
|
|
|
124
|
-
test('the script builds no markup from strings, reads only the
|
|
147
|
+
test('the script builds no markup from strings, reads only the six routes, and never re-ranks or reads the ranked tier', () => {
|
|
125
148
|
assert.doesNotMatch(SCRIPT, /innerHTML|insertAdjacentHTML|outerHTML|document\.write/);
|
|
126
|
-
// every path literal in the code: the API base, the
|
|
149
|
+
// every path literal in the code: the API base, the six reads, the five sub-pages, the
|
|
127
150
|
// sign-in door and the hall's invite box the Recruit door opens (task 1002292)
|
|
128
151
|
const paths = [...new Set([...CODE.matchAll(/'(\/[^'?]*)/g)].map((m) => m[1]))];
|
|
129
|
-
assert.deepEqual(paths.sort(), ['/api/bongos', '/api/bongos/auth/web/start', '/builders/watch', '/community', '/community/builder-of-the-week', '/community/find', '/community/leaderboard', '/community/recruiters', '/community/search', '/community/week', '/my-projects', '/scouting']);
|
|
152
|
+
assert.deepEqual(paths.sort(), ['/api/bongos', '/api/bongos/auth/web/start', '/builders/watch', '/community', '/community/builder-of-the-week', '/community/crowns', '/community/find', '/community/leaderboard', '/community/recruiters', '/community/search', '/community/week', '/my-projects', '/scouting']);
|
|
130
153
|
assert.doesNotMatch(CODE, /method:/, 'it only reads');
|
|
131
154
|
assert.doesNotMatch(CODE, /'\/me\b/, 'who is signed in is the bar’s question');
|
|
132
155
|
assert.doesNotMatch(CODE, /\.sort\(|\.count\b|_count\b/, 'the server ranks; every count is a list’s length');
|
|
@@ -172,7 +195,14 @@ const WEEK = () => ({ week: { start: '2026-10-19', end: '2026-10-26' },
|
|
|
172
195
|
past: [{ week_start: '2026-10-12', handle: 'bo', github_login: 'bo', display_name: null, avatar_url: 'data:x', credits: 300, tasks_shipped: 2 },
|
|
173
196
|
{ week_start: '2026-09-28', handle: 'cy', github_login: 'cy', display_name: 'Cy', avatar_url: null, credits: 90, tasks_shipped: 1 },
|
|
174
197
|
{ week_start: '2025-12-29', handle: 'di', github_login: 'di', display_name: 'Di', avatar_url: null, credits: 10, tasks_shipped: 1 }] });
|
|
198
|
+
const WINNER = (handle, credits, ships, extra = {}) => ({ handle, github_login: handle, display_name: handle.toUpperCase(), avatar_url: `https://avatars.example/${handle}`, credits, tasks_shipped: ships, ...extra });
|
|
199
|
+
const CROWNS = () => ({
|
|
200
|
+
week: { start: '2026-10-26', end: '2026-11-02', winner: WINNER('ana', 1240, 4, { display_name: 'Ana <i>L</i>' }) },
|
|
201
|
+
month: { start: '2026-10-01', end: '2026-11-01', first_crown: null, winner: WINNER('bo', 6120, 1, { display_name: null, avatar_url: 'javascript:alert(1)' }) },
|
|
202
|
+
year: { start: '2026-01-01', since: '2026-09-27', last: null, winner: WINNER('cy', 9870, 31) },
|
|
203
|
+
});
|
|
175
204
|
const READS = () => ({
|
|
205
|
+
'/community/crowns': { status: 200, body: CROWNS() },
|
|
176
206
|
'/community/builder-of-the-week': { status: 200, body: WEEK() },
|
|
177
207
|
'/community/leaderboard?window=all': { status: 200, body: { window: 'all', count: 99, results: [ROW(26, 'ana', 1200), ROW(27, 'bo', 1500, { tasks_shipped: 1, karma: 0, avatar_url: 'javascript:alert(1)', display_name: null })] } },
|
|
178
208
|
'/community/leaderboard?window=30d': { status: 200, body: { window: '30d', results: [ROW(1, 'cy', 40)] } },
|
|
@@ -182,7 +212,7 @@ const READS = () => ({
|
|
|
182
212
|
'/scouting': { status: 403, body: { error: { code: 'forbidden' } } },
|
|
183
213
|
});
|
|
184
214
|
|
|
185
|
-
async function boot({ pathname = '/community', me = null, reads = READS() } = {}) {
|
|
215
|
+
async function boot({ pathname = '/community/leaderboard', me = null, reads = READS() } = {}) {
|
|
186
216
|
const ids = new Map();
|
|
187
217
|
for (const m of PAGE.matchAll(/<(\w+)([^>]* id="([^"]+)"[^>]*)>/g)) {
|
|
188
218
|
const attrs = attrsOf(m[2]);
|
|
@@ -224,17 +254,84 @@ async function boot({ pathname = '/community', me = null, reads = READS() } = {}
|
|
|
224
254
|
return { ids, requests, document, gets: () => requests.map((r) => r.url), tab, craft, pick, submit, current, $: (id) => ids.get(id) };
|
|
225
255
|
}
|
|
226
256
|
|
|
227
|
-
test('each path shows its one section, marks its menu items current and names itself; any other shape is the
|
|
228
|
-
for (const [pathname,
|
|
257
|
+
test('each path shows its one section, marks its menu items current and names itself; any other shape is the home', async () => {
|
|
258
|
+
for (const [pathname, item, section] of [['/community', 'Community home', 'home'], ['/community/', 'Community home', 'home'], ['/community/everyone', 'Community home', 'home'], ['/community/leaderboard/', 'Leaderboard', 'board'], ['/community/week', 'Builder of the week', 'week'], ['/community/find', 'Find a builder', 'find'], ['/community/recruiters/', 'Open to recruiters', 'recruit']]) {
|
|
229
259
|
const ui = await boot({ pathname });
|
|
230
|
-
assert.deepEqual(['board', 'week', 'find', 'recruit'].filter((s) => !ui.$(s).hidden), [section], `${pathname}: one section`);
|
|
231
|
-
const href = SUBPAGES.find(([, t]) => t ===
|
|
260
|
+
assert.deepEqual(['home', 'board', 'week', 'find', 'recruit'].filter((s) => !ui.$(s).hidden), [section], `${pathname}: one section`);
|
|
261
|
+
const href = SUBPAGES.find(([, t]) => t === item)[0];
|
|
232
262
|
assert.deepEqual(ui.current(), [href, href], `${pathname}: its Community item and its folded twin in the Projects band`);
|
|
233
|
-
assert.equal(ui.document.title, `Cloud Bongos — Community · ${
|
|
263
|
+
assert.equal(ui.document.title, section === 'home' ? 'Cloud Bongos — Community' : `Cloud Bongos — Community · ${item}`, 'the home is the Community page itself');
|
|
234
264
|
assert.equal(ui.$('signinTop').attrs.href, `/api/bongos/auth/web/start?return=${href}&repos=1`, 'signing in comes back to this sub-page');
|
|
235
265
|
}
|
|
236
266
|
});
|
|
237
267
|
|
|
268
|
+
// ── 2c. the community home (task 1004902) ───────────────────────────────────────
|
|
269
|
+
// community-crowns.js decides and records each period; the home draws what it sent, the
|
|
270
|
+
// same for every viewer.
|
|
271
|
+
|
|
272
|
+
test('the home: one read, the three crowns with their periods, names as text, and nothing asked about projects', async () => {
|
|
273
|
+
const ui = await boot({ pathname: '/community' });
|
|
274
|
+
assert.deepEqual(ui.gets(), ['/api/bongos/community/crowns']);
|
|
275
|
+
assert.equal(ui.$('homeLoading').hidden, true);
|
|
276
|
+
assert.equal(ui.$('homeFailed').hidden, true);
|
|
277
|
+
assert.equal(ui.$('homeCrowns').hidden, false);
|
|
278
|
+
assert.equal(ui.$('crownWeekWhen').textContent, '26 Oct – 1 Nov 2026');
|
|
279
|
+
assert.equal(find(ui.$('crownWeek'), 'crownName').textContent, 'Ana <i>L</i>', 'a name is text, never markup');
|
|
280
|
+
assert.equal(find(ui.$('crownWeek'), 'crownNums').textContent, (1240).toLocaleString() + 'credits4works shipped');
|
|
281
|
+
assert.equal(ui.$('crownMonthWhen').textContent, 'October 2026');
|
|
282
|
+
assert.equal(find(ui.$('crownMonth'), 'crownName').textContent, 'bo', 'no display name: the handle stands in');
|
|
283
|
+
assert.equal(find(ui.$('crownMonth'), 'connAv').tag, 'span', 'an avatar that is not http(s) is never loaded');
|
|
284
|
+
assert.equal(find(ui.$('crownMonth'), 'crownNums').textContent, (6120).toLocaleString() + 'credits1work shipped', 'one work, singular');
|
|
285
|
+
assert.equal(ui.$('crownYearWhen').textContent, '2026 so far, since 27 Sep 2026', 'the year says when history began while that is inside it');
|
|
286
|
+
assert.equal(find(ui.$('crownYear'), 'mineRow__host').textContent, '@cy');
|
|
287
|
+
assert.equal(ui.$('crownYearLast').hidden, true, 'no year crowned yet');
|
|
288
|
+
const signedIn = await boot({ pathname: '/community', me: { github_login: 'owner' } });
|
|
289
|
+
assert.deepEqual(signedIn.gets(), ['/api/bongos/community/crowns'], 'signed in, the same one read: no Recruit door here');
|
|
290
|
+
assert.equal(signedIn.$('homeCrowns').textContent, ui.$('homeCrowns').textContent, 'the same crowns for every viewer');
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
test('the home: last year under the year so far, the first month’s date, and no one to show', async () => {
|
|
294
|
+
const reads = READS();
|
|
295
|
+
reads['/community/crowns'] = { status: 200, body: { ...CROWNS(),
|
|
296
|
+
year: { start: '2027-01-01', since: '2027-01-01', winner: WINNER('di', 310, 2), last: { start: '2026-01-01', winner: WINNER('cy', 11250, 38, { display_name: 'Cy <b>!</b>' }) } } } };
|
|
297
|
+
const next = await boot({ pathname: '/community', reads });
|
|
298
|
+
assert.equal(next.$('crownYearWhen').textContent, '2027 so far', 'history began before the year: no “since”');
|
|
299
|
+
assert.equal(next.$('crownYearLast').hidden, false);
|
|
300
|
+
assert.equal(next.$('crownYearLast').textContent, '2026: Cy <b>!</b>, ' + (11250).toLocaleString() + ' credits');
|
|
301
|
+
assert.equal(kids(next.$('crownYearLast'))[1].tag, 'strong');
|
|
302
|
+
reads['/community/crowns'] = { status: 200, body: {
|
|
303
|
+
week: { start: '2026-10-19', end: '2026-10-26', winner: null },
|
|
304
|
+
month: { start: '2026-09-01', end: '2026-10-01', first_crown: '2026-11-01', winner: null },
|
|
305
|
+
year: { start: '2026-01-01', since: '2026-09-27', winner: null, last: null } } };
|
|
306
|
+
const first = await boot({ pathname: '/community', reads });
|
|
307
|
+
const line = (id) => find(first.$(id), 'commMsg').textContent;
|
|
308
|
+
assert.equal(line('crownWeek'), 'No builder of the week for 19–25 Oct 2026.');
|
|
309
|
+
assert.equal(first.$('crownMonthWhen').textContent, 'September 2026');
|
|
310
|
+
assert.equal(line('crownMonth'), 'The first builder of the month is named on 1 Nov 2026.', 'a month before history is not “nobody”');
|
|
311
|
+
assert.equal(line('crownYear'), 'No builder of 2026 yet.');
|
|
312
|
+
assert.equal(find(first.$('homeCrowns'), 'crownName'), null);
|
|
313
|
+
reads['/community/crowns'] = { status: 200, body: { ...CROWNS(), month: { start: '2026-10-01', end: '2026-11-01', first_crown: null, winner: null } } };
|
|
314
|
+
assert.equal(find((await boot({ pathname: '/community', reads })).$('crownMonth'), 'commMsg').textContent, 'No builder of the month for October 2026.', 'a hidden winner and an empty month read the same (spec D8)');
|
|
315
|
+
reads['/community/crowns'] = { status: 200, body: { week: { start: 'soon' }, month: {}, year: { last: { start: 'x', winner: WINNER('ed', 1, 1) } } } };
|
|
316
|
+
const odd = await boot({ pathname: '/community', reads });
|
|
317
|
+
assert.deepEqual(['crownWeek', 'crownMonth', 'crownYear'].map((id) => find(odd.$(id), 'commMsg').textContent), ['No builder of the week yet.', 'No builder of the month yet.', 'No builder of the year yet.'], 'a date it cannot read is never drawn');
|
|
318
|
+
assert.equal(odd.$('crownYearLast').hidden, true);
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
test('the home: a failed read is an alert, never three empty crowns; a rate limit says to wait', async () => {
|
|
322
|
+
const reads = READS();
|
|
323
|
+
for (const answer of [{ status: 500, body: {} }, { reject: true }]) {
|
|
324
|
+
reads['/community/crowns'] = answer;
|
|
325
|
+
const failed = await boot({ pathname: '/community', reads });
|
|
326
|
+
assert.equal(failed.$('homeFailed').hidden, false);
|
|
327
|
+
assert.equal(failed.$('homeFailed').textContent, 'The top builders could not be read. Reload the page to try again.');
|
|
328
|
+
assert.equal(failed.$('homeCrowns').hidden, true);
|
|
329
|
+
assert.equal(failed.$('homeLoading').hidden, true);
|
|
330
|
+
}
|
|
331
|
+
reads['/community/crowns'] = { status: 429, body: {} };
|
|
332
|
+
assert.match((await boot({ pathname: '/community', reads })).$('homeFailed').textContent, /^Too many requests/);
|
|
333
|
+
});
|
|
334
|
+
|
|
238
335
|
test('the leaderboard, signed out: the all-time board in the server’s order with the server’s places, and nothing asked about the viewer', async () => {
|
|
239
336
|
const ui = await boot();
|
|
240
337
|
assert.deepEqual(ui.gets(), ['/api/bongos/community/leaderboard?window=all', '/api/bongos/community/builder-of-the-week']);
|
|
@@ -535,7 +632,8 @@ const SERVES_A_CHILD = ['privacy.html', 'terms.html', 'contact.html', 'index.htm
|
|
|
535
632
|
const HUB_ONLY = ['account.html', 'connections.html', 'invitations.html', 'guild.html', 'community.html'];
|
|
536
633
|
const wrap = (apex, s) => (apex ? `<!--apex-only:start-->${s}<!--apex-only:end-->` : s);
|
|
537
634
|
const ITEMS = [
|
|
538
|
-
['/community', '
|
|
635
|
+
['/community', 'Community home', 'the builders of the week, month and year'],
|
|
636
|
+
['/community/leaderboard', 'Leaderboard', 'who earned the most, across every project'],
|
|
539
637
|
['/community/week', 'Builder of the week', 'the top earner of each week, and how it is chosen'],
|
|
540
638
|
['/community/find', 'Find a builder', 'anyone building here, by name or role'],
|
|
541
639
|
['/community/recruiters', 'Open to recruiters', 'who is open to recruiting, for project owners'],
|
|
@@ -557,9 +655,9 @@ test('every page that wears the hub’s bar carries the Community menu before Pr
|
|
|
557
655
|
const at = html.indexOf(apex ? `<!--apex-only:start-->${trig}` : trig);
|
|
558
656
|
assert.ok(at > 0, `${f}: the Community trigger`);
|
|
559
657
|
assert.ok(html.indexOf('id="projTrig"') > at, `${f}: Community comes before Projects`);
|
|
560
|
-
assert.ok(html.includes(wrap(apex, band)), `${f}: the Community band, its
|
|
658
|
+
assert.ok(html.includes(wrap(apex, band)), `${f}: the Community band, its five sub-pages`);
|
|
561
659
|
assert.ok(html.indexOf('id="menuCommunity"') < html.indexOf('id="menuProjects"'), `${f}: the bands in the bar’s order`);
|
|
562
|
-
assert.ok(html.includes(wrap(apex, fold)), `${f}: the
|
|
660
|
+
assert.ok(html.includes(wrap(apex, fold)), `${f}: the five folded into the Projects band for phones`);
|
|
563
661
|
if (apex) assert.doesNotMatch(stripApexOnly(html, false).split('<body>')[1], /\/community|commTrig|menuCommunity|connSuggest/, `${f}: a child has no community pages, so no trace of the menu`);
|
|
564
662
|
}
|
|
565
663
|
});
|
|
@@ -617,7 +715,7 @@ function get(pathname, host) {
|
|
|
617
715
|
});
|
|
618
716
|
}
|
|
619
717
|
|
|
620
|
-
test('GET /community, /community/week, /community/find and /community/recruiters: one stamped page on the hub’s apex; never on a child or another host', async () => {
|
|
718
|
+
test('GET /community, /community/leaderboard, /community/week, /community/find and /community/recruiters: one stamped page on the hub’s apex; never on a child or another host', async () => {
|
|
621
719
|
modules.setModuleOverrides({ 'platform-identity': false });
|
|
622
720
|
for (const [p] of SUBPAGES) assert.equal((await get(p, 'game.test.local')).status, 404, `${p}: a child instance has none of the routes these pages read`);
|
|
623
721
|
modules.setModuleOverrides({ 'platform-identity': true });
|
|
@@ -630,7 +728,7 @@ test('GET /community, /community/week, /community/find and /community/recruiters
|
|
|
630
728
|
bodies.push(hub.body);
|
|
631
729
|
}
|
|
632
730
|
assert.ok(bodies.every((b) => b === bodies[0]), 'byte-identical for every sub-page: the script picks the view');
|
|
633
|
-
assert.doesNotMatch((await get('/community/everyone', 'game.test.local')).body, /— Community<\/title>/, 'only the
|
|
731
|
+
assert.doesNotMatch((await get('/community/everyone', 'game.test.local')).body, /— Community<\/title>/, 'only the five sub-pages');
|
|
634
732
|
for (const host of ['builders.test.local', 'status.test.local']) {
|
|
635
733
|
assert.doesNotMatch((await get('/community', host)).body, /— Community<\/title>/, `${host} must not serve /community`);
|
|
636
734
|
}
|
|
@@ -643,10 +741,17 @@ test('the kit renders each sub-page for everyone, the empty board, and a project
|
|
|
643
741
|
const states = json('community.states.json');
|
|
644
742
|
assert.equal(states.page, '/community');
|
|
645
743
|
assert.deepEqual(states.modes, ['dark']);
|
|
646
|
-
for (const s of ['out', 'out-30d', 'out-artists', 'out-week', 'in-week', 'out-find', 'out-find-search', 'out-recruiters', 'in-recruiters', 'in']) assert.ok(states.states[s], `kit state ${s}`);
|
|
744
|
+
for (const s of ['out', 'in', 'out-board', 'out-30d', 'out-artists', 'out-week', 'in-week', 'out-find', 'out-find-search', 'out-recruiters', 'in-recruiters', 'in-board']) assert.ok(states.states[s], `kit state ${s}`);
|
|
745
|
+
assert.equal(states.states.out.url, undefined, 'the home is /community itself');
|
|
746
|
+
for (const s of ['out-board', 'out-30d', 'out-artists', 'in-board']) assert.equal(states.states[s].url, '/community/leaderboard', `${s}: the board’s own address`);
|
|
647
747
|
assert.equal(states.states['out-find'].url, '/community/find');
|
|
648
748
|
assert.equal(states.states['out-week'].url, '/community/week');
|
|
649
|
-
assert.ok(states.states
|
|
749
|
+
assert.ok(states.states['out-board'].expect.visible.includes('#boardCrown'), 'the board carries the crown line');
|
|
750
|
+
// the home's other answers (task 1004902): before the first month, nobody, and a new year
|
|
751
|
+
for (const [file, fixture] of [['community-crowns-first.states.json', 'community-crowns-first'], ['community-crowns-none.states.json', 'community-crowns-none'], ['community-crowns-new-year.states.json', 'community-crowns-new-year']]) {
|
|
752
|
+
assert.equal(json(file).stub.fixtures['community-crowns'], fixture);
|
|
753
|
+
assert.ok(fs.existsSync(path.join(ROOT, 'modules', 'ui-design', 'kit', 'fixtures', fixture + '.json')), `fixture ${fixture}`);
|
|
754
|
+
}
|
|
650
755
|
assert.equal(states.states['in-recruiters'].url, '/community/recruiters');
|
|
651
756
|
assert.ok(states.states['in-recruiters'].expect.visible.includes('#recruitClosed'), 'signed in but not admitted: told, shown no one');
|
|
652
757
|
for (const [file, name, fixture] of [['community-empty.states.json', 'community-leaderboard', 'community-leaderboard-empty'], ['community-recruiter.states.json', 'scouting', 'scouting-recruiter'], ['community-owner.states.json', 'my-projects', 'my-projects-owner'], ['community-owner-two.states.json', 'my-projects', 'my-projects-owner-two'], ['community-week-none.states.json', 'community-week', 'community-week-none']]) {
|
|
@@ -685,9 +790,14 @@ async function call(origin, p) {
|
|
|
685
790
|
return { status: res.status, body: await res.json() };
|
|
686
791
|
}
|
|
687
792
|
|
|
688
|
-
test('the kit stub answers the board, the builder of the week, the search and the recruiting door the way the routes do', async () => {
|
|
793
|
+
test('the kit stub answers the crowns, the board, the builder of the week, the search and the recruiting door the way the routes do', async () => {
|
|
689
794
|
const s = await stub();
|
|
690
795
|
try {
|
|
796
|
+
const crowns = await call(s.origin, '/community/crowns');
|
|
797
|
+
assert.deepEqual(Object.keys(crowns.body), ['week', 'month', 'year'], 'the route’s three periods');
|
|
798
|
+
const { week: w, month: m, year: y } = crowns.body;
|
|
799
|
+
assert.deepEqual([Object.keys(w).sort(), Object.keys(m).sort(), Object.keys(y).sort()], [['end', 'start', 'winner'], ['end', 'first_crown', 'start', 'winner'], ['last', 'since', 'start', 'winner']], 'community-crowns.js’s shape');
|
|
800
|
+
assert.ok([w.winner, m.winner, y.winner].every((r) => r && Object.keys(r).sort().join() === 'avatar_url,credits,display_name,github_login,handle,tasks_shipped'), 'the module’s projection, no github_id');
|
|
691
801
|
const week = await call(s.origin, '/community/builder-of-the-week');
|
|
692
802
|
assert.deepEqual(Object.keys(week.body).sort(), ['past', 'week', 'winner'], 'the route’s three keys');
|
|
693
803
|
assert.ok(week.body.winner && week.body.past.length, 'a crown and the weeks before it');
|
|
@@ -717,6 +827,11 @@ test('the kit stub answers the board, the builder of the week, the search and th
|
|
|
717
827
|
assert.equal(r.body.winner, null);
|
|
718
828
|
assert.deepEqual(r.body.past, []);
|
|
719
829
|
} finally { none.stop(); }
|
|
830
|
+
const nobody = await stub({ FIXTURE_ALIAS: JSON.stringify({ 'community-crowns': 'community-crowns-none' }) });
|
|
831
|
+
try {
|
|
832
|
+
const r = await call(nobody.origin, '/community/crowns');
|
|
833
|
+
assert.deepEqual([r.body.week.winner, r.body.month.winner, r.body.year.winner, r.body.year.last], [null, null, null, null]);
|
|
834
|
+
} finally { nobody.stop(); }
|
|
720
835
|
const owner = await stub({ AUTH: '1', FIXTURE_ALIAS: JSON.stringify({ scouting: 'scouting-recruiter' }) });
|
|
721
836
|
try {
|
|
722
837
|
const r = await call(owner.origin, '/scouting');
|
|
@@ -193,8 +193,8 @@ for (const p of PAGES) {
|
|
|
193
193
|
assert.deepEqual(bandLinks(bandOf(html, 'menuProjects')), [
|
|
194
194
|
['Sky', '/projects?view=map'], ['Table', '/projects?view=map&sub=table'],
|
|
195
195
|
['Your projects', '/projects?view=mine'], ['New project', '/projects?view=new'],
|
|
196
|
-
// the Community menu's
|
|
197
|
-
['Leaderboard', '/community'], ['Builder of the week', '/community/week'], ['Find a builder', '/community/find'], ['Open to recruiters', '/community/recruiters'],
|
|
196
|
+
// the Community menu's five sub-pages, folded into the band under 480px (tasks 1003363, 1002981, 1004902); apex-only
|
|
197
|
+
['Community home', '/community'], ['Leaderboard', '/community/leaderboard'], ['Builder of the week', '/community/week'], ['Find a builder', '/community/find'], ['Open to recruiters', '/community/recruiters'],
|
|
198
198
|
['Builders you may know', '/connections#connSuggest'],
|
|
199
199
|
], 'this page has no views of its own, so every item names /projects in full');
|
|
200
200
|
assert.deepEqual(bandLinks(bandOf(stripApexOnly(html, false), 'menuProjects')).map((l) => l[0]), ['Sky', 'Table', 'Your projects', 'New project'], 'a child has no /community');
|