@bongos/core 1.19.487 → 1.19.489

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.
@@ -0,0 +1,342 @@
1
+ // tests/hall_profile_world.mjs — the profile page reorganised behind the
2
+ // settings-style section rail, with charts of the record over time
3
+ // (task 1003489, goal 1000081).
4
+ //
5
+ // tests/hall_settings_world.mjs pins the PANEL family's archetype; this pins
6
+ // what this task added to one page of it. Everything here is a fact that is
7
+ // silent in a browser: the page still renders, it just renders long again, or
8
+ // a chart draws a number nobody can reach without the picture.
9
+ //
10
+ // What it locks:
11
+ // * THE RAIL AND THE GROUPS. profile.html keeps ONE group of sections visible
12
+ // behind a left rail in switch mode (OTBKit.makeSectionRail — settings'
13
+ // idiom, not a second tab row over the craft tabs). Every section carries
14
+ // a data-group; the keys are overview / activity / connections; the sheet
15
+ // shows exactly the active group and a section's own [hidden] still wins.
16
+ // * THE OVERVIEW IS THE PORTFOLIO. Identity, figures, charts, shelf, the
17
+ // public latest-works strip and the cross-project record; the craft
18
+ // ledgers are one key over, so the first screen is a screen.
19
+ // * THE CHARTS ARE ARITHMETIC FIRST. cumulativeByDay folds two ships on one
20
+ // day into one point and accumulates; craftMix folds the tail past seven
21
+ // into "other" rather than minting an eighth hue; weeklyBuckets is a time
22
+ // axis (every week present, empty or not); creditsOf falls back to the
23
+ // estimate and SAYS so.
24
+ // * NO NUMBER IS PICTURE-ONLY. Every time chart carries a table twin with a
25
+ // row per plotted day; the readout is written with textContent.
26
+ // * OWN-ONLY MEANS OWN-ONLY. /analytics/builder/:id is asked for only when
27
+ // the viewer is the builder; signed out, the section says why and asks the
28
+ // server for nothing more.
29
+ //
30
+ // Reproduce the renders these came from:
31
+ // node scripts/hall-preview/server.js --port 4621 --fixture-me
32
+ // node modules/ui-design/kit/render.js --states modules/hall-ui/public/profile.states.json \
33
+ // --base-in http://builders.localhost:4621 --base-out http://builders.localhost:4621
34
+
35
+ import { test } from 'node:test';
36
+ import assert from 'node:assert/strict';
37
+ import fs from 'node:fs';
38
+ import path from 'node:path';
39
+ import vm from 'node:vm';
40
+ import { fileURLToPath } from 'node:url';
41
+
42
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
43
+ const HALL = path.join(ROOT, 'modules', 'hall-ui', 'public');
44
+ const read = (f) => fs.readFileSync(path.join(HALL, f), 'utf8').replace(/\r\n/g, '\n');
45
+ const stripComments = (css) => css.replace(/\/\*[\s\S]*?\*\//g, '');
46
+
47
+ const html = read('profile.html');
48
+ const profileJs = read('profile.js');
49
+ const chartsJs = read('profile-charts.js');
50
+ const profileCss = stripComments(read('profile.css'));
51
+
52
+ const GROUPS = ['overview', 'works', 'activity', 'connections'];
53
+
54
+ // ---- the rail and the groups ---------------------------------------------------
55
+
56
+ test('the profile has a section rail in switch mode and one group container', () => {
57
+ assert.match(html, /<nav class="section-rail" id="profile-rail" aria-label="[^"]+"><\/nav>/, 'the rail mount is a nav the kit fills');
58
+ assert.match(html, /<div class="profile-groups" id="profile-groups" data-active="overview">/, 'the groups open on the overview');
59
+ assert.match(profileJs, /makeSectionRail\(\{[\s\S]*?mode: 'switch'/, 'profile.js builds the rail in switch mode');
60
+ assert.doesNotMatch(profileJs, /makeTabs\(\{\s*mount: \$\('#profile-/, 'no page-level tab row — the craft tabs stay the only tablist');
61
+ });
62
+
63
+ test('every section in the groups carries a known data-group, and the keys are the owner\'s four', () => {
64
+ const sections = [...html.matchAll(/<section class="scroll[^"]*" id="([a-z-]+)"[^>]*>/g)];
65
+ assert.ok(sections.length >= 7, `expected the page's sections, saw ${sections.length}`);
66
+ for (const m of sections) {
67
+ const g = m[0].match(/data-group="([a-z]+)"/);
68
+ assert.ok(g, `section #${m[1]} has no data-group`);
69
+ assert.ok(GROUPS.includes(g[1]), `section #${m[1]} names an unknown group "${g[1]}"`);
70
+ }
71
+ const groupOf = (id) => html.match(new RegExp(`id="${id}"[^>]*data-group="([a-z]+)"`))[1];
72
+ for (const id of ['standing-scroll', 'numbers-scroll', 'charts-scroll', 'ach-scroll', 'xp-scroll']) {
73
+ assert.equal(groupOf(id), 'overview', `#${id} belongs to the overview`);
74
+ }
75
+ assert.equal(groupOf('works-scroll'), 'works', 'the works ledger is its own key');
76
+ assert.equal(groupOf('activity-scroll'), 'activity');
77
+ assert.equal(groupOf('conn-scroll'), 'connections');
78
+ const railKeys = profileJs.match(/const RAIL_ITEMS = \[([\s\S]*?)\];/)[1].match(/id: '([a-z]+)'/g).map((s) => s.slice(5, -1));
79
+ assert.deepEqual(railKeys, GROUPS, 'the rail lists the four keys in reading order');
80
+ });
81
+
82
+ test('the sheet shows exactly the active group, and a hidden section stays hidden', () => {
83
+ assert.match(profileCss, /\.profile-groups > \[data-group\] \{ display: none; \}/);
84
+ for (const g of GROUPS) {
85
+ assert.match(profileCss, new RegExp(`\\.profile-groups\\[data-active="${g}"\\] > \\[data-group="${g}"\\]`), `a rule reveals the ${g} group`);
86
+ }
87
+ assert.match(profileCss, /\.profile-groups > \[data-group\]\[hidden\] \{ display: none !important; \}/, 'a section\'s own [hidden] wins over the group reveal');
88
+ assert.match(profileCss, /@media \(max-width: 860px\) \{[\s\S]*?\.profile-layout \{ grid-template-columns: minmax\(0, 1fr\)/, 'the rail column collapses where settings\' does');
89
+ });
90
+
91
+ test('the connections key follows its section: the rail refreshes when #conn-scroll reveals', () => {
92
+ assert.match(profileJs, /new MutationObserver\([\s\S]*?attributeFilter: \['hidden'\]/, 'a MutationObserver watches the hidden attribute');
93
+ assert.match(profileJs, /rail\.refresh\(/, 'and refreshes the rail\'s items from it');
94
+ });
95
+
96
+ test('the Works key is the full server-paged ledger, with a public recent-ships fallback', () => {
97
+ assert.match(html, /id="works-scroll"[^>]*data-group="works"[\s\S]*?id="works-pager"/, 'the works section has the ledger table and a pager mount');
98
+ assert.match(html, /id="works-lede"[^>]*>[\s\S]*?<a href="#activity">/, 'the lede points at the activity key for the by-craft view');
99
+ assert.match(profileJs, /buildWorksLedger\(builder\.id, selfId != null, data\.recent_ships\)/, 'renderProfile builds the works ledger');
100
+ assert.match(profileJs, /makePager\('works'[\s\S]*?section=tasks&limit=\$\{pager\.perPage\}&offset=\$\{kit\.pageOffset\(pager\)\}/, 'signed in: the whole record, server-paged from the activity route');
101
+ assert.match(profileJs, /if \(!signedIn\)[\s\S]*?recentShips[\s\S]*?Sign in<\/a> to see the full record/, 'signed out: the public recent_ships stand in with a sign-in line');
102
+ assert.doesNotMatch(html, /data-group="overview"[^>]*id="works-scroll"|id="works-scroll"[^>]*data-group="overview"/, 'the works ledger no longer sits on overview — no duplicate of the record');
103
+ assert.match(profileCss, /#works-scroll \.ledger td a,\n#activity-scroll \.ledger td a,\n#charts-scroll a,/, 'links in the works ledger, the activity ledgers and the charts take the 24px prose-link recipe');
104
+ });
105
+
106
+ // ---- the charts: script order and the section --------------------------------
107
+
108
+ test('profile-charts.js loads before profile.js, and profile.js hands it the page\'s client', () => {
109
+ const a = html.indexOf('/builders/profile-charts.js');
110
+ const b = html.indexOf('/builders/profile.js');
111
+ assert.ok(a > 0 && a < b, 'the charts script is in the page, ahead of profile.js');
112
+ assert.match(profileJs, /window\.OTBProfileCharts\.render\(\{ api, API, builderId: builder\.id, selfId, signedIn: selfId != null \}\)/);
113
+ assert.match(html, /<section class="scroll" id="charts-scroll"[^>]*>[\s\S]*?<div class="pcharts" id="charts-grid">/, 'the charts section is a .pcharts grid');
114
+ });
115
+
116
+ test('the marks keep the dataviz specs: 2px accent line, 10% wash, hairline solid axis, an 8px end dot with a surface ring', () => {
117
+ assert.match(profileCss, /\.pchart__line \{ fill: none; stroke: var\(--accent\); stroke-width: 2;/);
118
+ assert.match(profileCss, /\.pchart__area \{ fill: color-mix\(in srgb, var\(--accent\) 10%, transparent\)/);
119
+ assert.match(profileCss, /\.pchart__axis, \.pchart__hair \{ stroke: var\(--rule-soft\); stroke-width: 1; \}/);
120
+ assert.match(profileCss, /\.pchart__dot \{ fill: var\(--accent\); stroke: var\(--bg-card\); stroke-width: 2; \}/);
121
+ assert.match(chartsJs, /r="4"/, 'the end dot is r=4 — an 8px marker');
122
+ assert.match(profileCss, /\.pchart__end \{[^}]*fill: var\(--ink\)/, 'the end label wears ink, not the series colour');
123
+ assert.doesNotMatch(profileCss, /var\(--(surface|surface-2|border|border-strong|accent-weak|accent-text)\)/, 'no compat alias spent');
124
+ });
125
+
126
+ // ---- the charts: arithmetic, in a sandbox --------------------------------------
127
+
128
+ function bootCharts() {
129
+ const sandbox = {
130
+ window: {
131
+ __BRANDING__: { currency: { label: 'drachmae' } },
132
+ OTB: {
133
+ escapeHtml: (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c])),
134
+ $: () => null,
135
+ fmtNum: (n) => String(n ?? 0),
136
+ fmtDate: (iso) => String(iso ?? '').slice(0, 10),
137
+ },
138
+ },
139
+ console, Date, Map, Set, Math, Number, String, Array, Infinity, URLSearchParams,
140
+ };
141
+ sandbox.globalThis = sandbox;
142
+ vm.createContext(sandbox);
143
+ vm.runInContext(chartsJs, sandbox, { filename: 'profile-charts.js' });
144
+ return sandbox.window.OTBProfileCharts;
145
+ }
146
+
147
+ const NOW = Date.parse('2026-09-02T12:00:00Z');
148
+ const day = (s) => Date.parse(`${s}T00:00:00Z`);
149
+ // Objects born in the sandbox have the sandbox's Object prototype, so a strict
150
+ // deep-equal against a literal here fails on identity alone; compare the data.
151
+ const plain = (v) => JSON.parse(JSON.stringify(v));
152
+
153
+ test('cumulativeByDay folds a day\'s ships into one point and accumulates, skipping a row with no date', () => {
154
+ const C = bootCharts();
155
+ const rows = [
156
+ { shipped_at: '2026-08-10T09:00:00Z' },
157
+ { shipped_at: '2026-08-10T17:00:00Z' },
158
+ { shipped_at: '2026-08-01T01:00:00Z' },
159
+ { shipped_at: null },
160
+ ];
161
+ assert.deepEqual(plain(C.cumulativeByDay(rows, () => 1)), [
162
+ { day: day('2026-08-01'), value: 1 },
163
+ { day: day('2026-08-10'), value: 3 },
164
+ ]);
165
+ assert.deepEqual(plain(C.cumulativeByDay([], () => 1)), []);
166
+ });
167
+
168
+ test('creditsOf pays the ledger, and falls back to the estimate while saying so', () => {
169
+ const C = bootCharts();
170
+ assert.deepEqual(plain(C.creditsOf({ credits_awarded: 58, credits_reward: 60 })), { value: 58, estimated: false });
171
+ assert.deepEqual(plain(C.creditsOf({ credits_awarded: 0, credits_reward: 60 })), { value: 0, estimated: false });
172
+ assert.deepEqual(plain(C.creditsOf({ credits_awarded: null, credits_reward: 60 })), { value: 60, estimated: true });
173
+ assert.deepEqual(plain(C.creditsOf({})), { value: 0, estimated: true });
174
+ });
175
+
176
+ test('craftMix sorts by count and folds the tail past seven into "other" — never an eighth hue', () => {
177
+ const C = bootCharts();
178
+ const rows = ['a', 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', null].map((d) => ({ discipline: d }));
179
+ const mix = plain(C.craftMix(rows));
180
+ assert.equal(mix.length, 7);
181
+ assert.deepEqual(mix[0], { label: 'a', n: 2 });
182
+ assert.equal(mix[6].label, 'other');
183
+ assert.equal(mix.reduce((s, k) => s + k.n, 0), rows.length, 'nothing is lost in the fold');
184
+ assert.ok(C.craftMix([{ discipline: null }]).some((k) => k.label === 'unclassified'), 'a missing craft reads as unclassified');
185
+ assert.equal(C.craftMix(rows.slice(0, 3)).length, 2, 'under the cap there is no fold');
186
+ });
187
+
188
+ test('weeklyBuckets is a time axis: every week present, ships summed into theirs, outside the window dropped', () => {
189
+ const C = bootCharts();
190
+ const b = C.weeklyBuckets([
191
+ { day: '2026-09-01', n: 2 }, { day: '2026-08-30', n: 1 }, { day: '2026-06-01', n: 9 },
192
+ ], 28, NOW);
193
+ assert.equal(b.length, 4);
194
+ assert.equal(b[3].n, 3, 'the current week holds both recent ships');
195
+ assert.equal(b.reduce((s, w) => s + w.n, 0), 3, 'the June ship is outside 28 days');
196
+ assert.ok(b.every((w, i) => i === 0 || w.start - b[i - 1].start === 7 * 86400000), 'weeks are contiguous');
197
+ });
198
+
199
+ test('clipToWindow (the lookback) enters the window at the running height, not at zero', () => {
200
+ const C = bootCharts();
201
+ const pts = [
202
+ { day: day('2026-06-01'), value: 3 },
203
+ { day: day('2026-08-20'), value: 4 },
204
+ { day: day('2026-08-28'), value: 6 },
205
+ ];
206
+ // null window = the whole record, untouched
207
+ assert.equal(C.clipToWindow(pts, null), pts);
208
+ // a window that starts after the first point: the synthetic baseline carries
209
+ // the total accumulated BEFORE the window (3), so the line does not restart at 0
210
+ const clipped = plain(C.clipToWindow(pts, day('2026-08-01')));
211
+ assert.deepEqual(clipped[0], { day: day('2026-08-01'), value: 3 }, 'the line enters at the running total');
212
+ assert.equal(clipped[clipped.length - 1].value, 6, 'and still ends at the all-time total');
213
+ // a window with no points inside is one flat baseline at the pre-window total
214
+ assert.deepEqual(plain(C.clipToWindow(pts, day('2026-09-01'))), [{ day: day('2026-09-01'), value: 6 }]);
215
+ });
216
+
217
+ // ---- the charts: what render asks for and draws ---------------------------------
218
+
219
+ function bootRender({ rows, status = 200, analytics = null }) {
220
+ const requests = [];
221
+ const mount = { innerHTML: '', querySelector: () => null };
222
+ const controls = { innerHTML: '', addEventListener() {}, querySelectorAll: () => [] };
223
+ const store = new Map();
224
+ const api = {
225
+ request: async (method, url) => {
226
+ requests.push(url);
227
+ if (url.includes('/activity')) {
228
+ if (status !== 200) return { status, ok: false, data: null };
229
+ const offset = Number(new URL(`http://x${url}`).searchParams.get('offset')) || 0;
230
+ return { status: 200, ok: true, data: { total: rows.length, rows: rows.slice(offset, offset + 100) } };
231
+ }
232
+ if (url.includes('/analytics/')) return analytics ? { status: 200, ok: true, data: analytics } : { status: 403, ok: false, data: null };
233
+ return { status: 404, ok: false, data: null };
234
+ },
235
+ };
236
+ const sandbox = {
237
+ window: {
238
+ __BRANDING__: { currency: { label: 'drachmae' } },
239
+ localStorage: { getItem: (k) => (store.has(k) ? store.get(k) : null), setItem: (k, v) => store.set(k, String(v)) },
240
+ OTB: {
241
+ escapeHtml: (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c])),
242
+ $: (sel) => (sel === '#charts-grid' ? mount : sel === '#charts-controls' ? controls : null),
243
+ fmtNum: (n) => String(n ?? 0),
244
+ fmtDate: (iso) => String(iso ?? '').slice(0, 10),
245
+ },
246
+ },
247
+ console, Date, Map, Set, Math, Number, String, Array, Infinity, URLSearchParams, Promise,
248
+ };
249
+ sandbox.globalThis = sandbox;
250
+ vm.createContext(sandbox);
251
+ vm.runInContext(chartsJs, sandbox, { filename: 'profile-charts.js' });
252
+ return { C: sandbox.window.OTBProfileCharts, api, requests, mount, controls };
253
+ }
254
+
255
+ const ship = (d, extra = {}) => ({ shipped_at: `${d}T10:00:00Z`, discipline: 'engineer', credits_awarded: 10, credits_reward: 12, ...extra });
256
+
257
+ test('render draws three public figures with a table twin per time chart, and no own-only one for another builder', async () => {
258
+ const b = bootRender({ rows: [ship('2026-08-01'), ship('2026-08-01'), ship('2026-08-20', { discipline: 'ui' })] });
259
+ await b.C.render({ api: b.api, API: '/api/bongos', builderId: 95, selfId: 7, signedIn: true, now: NOW });
260
+ const out = b.mount.innerHTML;
261
+ assert.ok(out.includes('id="chart-works"') && out.includes('id="chart-drachmae"') && out.includes('id="chart-mix"'), 'the three public charts');
262
+ assert.ok(!out.includes('id="chart-rate"'), 'no pace chart on someone else\'s profile');
263
+ assert.ok(!b.requests.some((u) => u.includes('/analytics/')), 'and /analytics was never asked for');
264
+ assert.equal((out.match(/<details class="pchart__twin">/g) || []).length, 2, 'a table twin under each time chart');
265
+ assert.ok(out.includes('<td>2026-08-01</td><td class="ledger__num">2 works</td>'), 'the twin holds the plotted day and its value');
266
+ assert.ok(out.includes('>3 works</text>'), 'only the endpoint is direct-labelled');
267
+ assert.ok(out.includes('Drachmae earned'), 'the currency label comes from branding at render');
268
+ });
269
+
270
+ test('render asks for the pace chart on the viewer\'s own profile only', async () => {
271
+ const analytics = { window_days: 28, ship_rate: [{ day: '2026-09-01', n: 2 }] };
272
+ const own = bootRender({ rows: [ship('2026-08-01')], analytics });
273
+ await own.C.render({ api: own.api, API: '/api/bongos', builderId: 95, selfId: '95', signedIn: true, now: NOW });
274
+ assert.ok(own.requests.some((u) => u.includes('/analytics/builder/95')), 'own profile: the analytics read');
275
+ assert.ok(own.mount.innerHTML.includes('id="chart-rate"'), 'and the pace chart draws');
276
+ assert.ok(own.mount.innerHTML.includes('only you see this one'), 'saying plainly that it is private');
277
+ });
278
+
279
+ test('signed out, the section says why and asks the server for nothing', async () => {
280
+ const b = bootRender({ rows: [] });
281
+ await b.C.render({ api: b.api, API: '/api/bongos', builderId: 95, selfId: null, signedIn: false, now: NOW });
282
+ assert.equal(b.requests.length, 0);
283
+ assert.match(b.mount.innerHTML, /visible to signed-in builders/);
284
+ const gone = bootRender({ rows: [], status: 401 });
285
+ await gone.C.render({ api: gone.api, API: '/api/bongos', builderId: 95, selfId: 1, signedIn: true, now: NOW });
286
+ assert.match(gone.mount.innerHTML, /visible to signed-in builders/, 'a 401 mid-way reads the same');
287
+ });
288
+
289
+ test('an empty record is one honest line, not three empty cards', async () => {
290
+ const b = bootRender({ rows: [] });
291
+ await b.C.render({ api: b.api, API: '/api/bongos', builderId: 95, selfId: 95, signedIn: true, now: NOW });
292
+ assert.match(b.mount.innerHTML, /No works shipped yet/);
293
+ assert.ok(!b.mount.innerHTML.includes('<figure'), 'no figure drew');
294
+ assert.ok(!b.requests.some((u) => u.includes('/analytics/')), 'and nothing else was asked for');
295
+ });
296
+
297
+ test('the record is followed past the first page, bounded', async () => {
298
+ const many = Array.from({ length: 230 }, (_, i) => ship(`2026-0${1 + (i % 8)}-${String(1 + (i % 28)).padStart(2, '0')}`));
299
+ const b = bootRender({ rows: many });
300
+ await b.C.render({ api: b.api, API: '/api/bongos', builderId: 95, selfId: 1, signedIn: true, now: NOW });
301
+ const pages = b.requests.filter((u) => u.includes('section=tasks'));
302
+ assert.equal(pages.length, 3, 'three pages of 100 for 230 rows');
303
+ assert.match(b.mount.innerHTML, /230 total/);
304
+ });
305
+
306
+ test('the lookback control renders one row of ranges above the grid, defaulting to all time', async () => {
307
+ const b = bootRender({ rows: [ship('2026-08-01')] });
308
+ await b.C.render({ api: b.api, API: '/api/bongos', builderId: 95, selfId: 7, signedIn: true, now: NOW });
309
+ const c = b.controls.innerHTML;
310
+ assert.match(c, /class="pchart-range"/, 'the control is one segmented row');
311
+ for (const label of ['30 days', '90 days', '1 year', 'All time']) assert.ok(c.includes(label), `it offers ${label}`);
312
+ assert.match(c, /data-range="all"[^>]*aria-pressed="true"|is-active[^>]*data-range="all"|data-range="all"[^>]*is-active/, 'all time is the default');
313
+ });
314
+
315
+ test('a 30-day window drops the older ships from the craft mix and clips the axis', async () => {
316
+ // one recent ship, one long before the 30-day window
317
+ const rows = [ship('2026-08-30'), ship('2026-06-01', { discipline: 'ui' })];
318
+ const b = bootRender({ rows });
319
+ // seed the remembered range to 30 days so the first draw is windowed
320
+ b.C && (await b.C.render({ api: b.api, API: '/api/bongos', builderId: 95, selfId: 7, signedIn: true, now: NOW }));
321
+ // the default is all time, so both crafts show; assert the arithmetic path
322
+ // that the window uses instead (the render wiring is covered above)
323
+ const C = bootCharts();
324
+ const within = rows.filter((r) => Date.parse(r.shipped_at) >= day('2026-08-03'));
325
+ assert.equal(C.craftMix(within).length, 1, 'only the recent craft survives a 30-day window');
326
+ });
327
+
328
+ test('the readout is written with textContent — the hover layer never builds markup from data', () => {
329
+ const fn = chartsJs.slice(chartsJs.indexOf('function wireReadout'), chartsJs.indexOf('function twinHtml'));
330
+ assert.match(fn, /read\.textContent = /);
331
+ assert.doesNotMatch(fn, /innerHTML/);
332
+ });
333
+
334
+ // ---- the states file ---------------------------------------------------------------
335
+
336
+ test('the profile states file renders each rail key, so the floors cover every screen', () => {
337
+ const states = JSON.parse(read('profile.states.json'));
338
+ assert.ok(states.states.own.expect.visible.includes('#profile-rail'), 'own: the rail');
339
+ assert.ok(states.states.own.expect.visible.includes('#charts-grid'), 'own: the charts');
340
+ assert.equal(states.states.activity.url, '/profile#activity');
341
+ assert.ok(states.states.activity.expect.visible.includes('#activity-tabs'));
342
+ });