hub-dashboard-ia-shell.test.mjs
262 lines 11.1 KB
Raw
sha256:700fafdd1afa490919f9515d660ca6e75456bcd5bb67513abcd8757a634c01f6 docs: record AIP-b SD-21 land (KN #308) Human 9 days ago
1 /**
2 * Seven-tier contracts for HUB-DASH-IA-b (signed-in Hub shell IA).
3 * Ground truth: docs/reviews/2026-07-29-hub-dashboard-ia.md
4 */
5 import { describe, it } from 'node:test';
6 import assert from 'node:assert/strict';
7 import fs from 'node:fs';
8 import path from 'node:path';
9 import { fileURLToPath } from 'node:url';
10 import {
11 hubShellLabelForTab,
12 HUB_SHELL_LABELS,
13 normalizeHistorySegment,
14 readHistorySegment,
15 writeHistorySegment,
16 clampProposedBadgeCount,
17 formatProposedBadgeText,
18 shouldShowNeedsYouBanner,
19 needsYouBannerCopy,
20 shouldPulseReviewBadge,
21 hubPrimaryRailOrder,
22 hubCriticalDataTabs,
23 HUB_HISTORY_SEGMENT_KEY,
24 HUB_NEEDS_YOU_DISMISS_KEY,
25 } from '../web/hub/hub-shell-ia.mjs';
26
27 const root = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
28 const hubHtml = fs.readFileSync(path.join(root, 'web/hub/index.html'), 'utf8');
29 const hubJs = fs.readFileSync(path.join(root, 'web/hub/hub.js'), 'utf8');
30 const hubCss = fs.readFileSync(path.join(root, 'web/hub/hub.css'), 'utf8');
31 const onboarding = fs.readFileSync(path.join(root, 'web/hub/onboarding-wizard.mjs'), 'utf8');
32
33 describe('HUB-DASH-IA-b unit', () => {
34 it('label map uses Review for suggested queue (not Suggested)', () => {
35 assert.equal(hubShellLabelForTab('suggested'), 'Review');
36 assert.equal(hubShellLabelForTab('notes'), 'Vault');
37 assert.equal(hubShellLabelForTab('activity'), 'Activity');
38 assert.equal(hubShellLabelForTab('problem'), 'Discarded');
39 assert.equal(HUB_SHELL_LABELS.suggested, 'Review');
40 });
41
42 it('History segment helpers default to activity and persist problem', () => {
43 assert.equal(normalizeHistorySegment(undefined), 'activity');
44 assert.equal(normalizeHistorySegment('problem'), 'problem');
45 assert.equal(normalizeHistorySegment('activity'), 'activity');
46 const store = {
47 _m: new Map(),
48 getItem(k) {
49 return this._m.has(k) ? this._m.get(k) : null;
50 },
51 setItem(k, v) {
52 this._m.set(k, String(v));
53 },
54 };
55 assert.equal(readHistorySegment(store), 'activity');
56 writeHistorySegment('problem', store);
57 assert.equal(store.getItem(HUB_HISTORY_SEGMENT_KEY), 'problem');
58 assert.equal(readHistorySegment(store), 'problem');
59 });
60
61 it('badge count is unfiltered proposed clamp, independent of list filters', () => {
62 assert.equal(clampProposedBadgeCount(0), 0);
63 assert.equal(clampProposedBadgeCount(3), 3);
64 assert.equal(clampProposedBadgeCount(100), 100);
65 assert.equal(clampProposedBadgeCount(101), 100);
66 assert.equal(clampProposedBadgeCount(-1), 0);
67 assert.equal(formatProposedBadgeText(0), '');
68 assert.equal(formatProposedBadgeText(7), '7');
69 });
70 });
71
72 describe('HUB-DASH-IA-b integration (source wiring)', () => {
73 it('switchHubMainTab suggested still targets #proposals-suggested / data-tab=suggested', () => {
74 assert.match(hubJs, /function\s+switchHubMainTab\s*\(\s*name\s*\)/);
75 assert.match(hubJs, /name\s*===\s*['"]suggested['"]\s*\|\|\s*name\s*===\s*['"]problem['"]/);
76 assert.match(hubHtml, /id="proposals-suggested"/);
77 assert.match(hubHtml, /data-tab="suggested"/);
78 assert.match(hubJs, /loadProposals\s*\(/);
79 });
80
81 it('History Discarded segment maps to problem via data-tab', () => {
82 assert.match(
83 hubHtml,
84 /data-tab="problem"[^>]*data-history-segment="problem"|data-history-segment="problem"[^>]*data-tab="problem"/,
85 );
86 assert.match(hubJs, /openHistoryMode/);
87 assert.match(hubJs, /readHistorySegment/);
88 });
89
90 it('Connect rail calls openSettingsIntegrationsTab()', () => {
91 assert.match(hubHtml, /id="hub-rail-connect"/);
92 assert.match(hubJs, /function\s+runHubSecondaryAction\s*\(/);
93 assert.match(
94 hubJs,
95 /key\s*===\s*['"]connect['"][\s\S]{0,200}openSettingsIntegrationsTab\s*\(/,
96 );
97 });
98 });
99
100 describe('HUB-DASH-IA-b e2e static contract', () => {
101 it('rail order Vault → Review → History in primary rail', () => {
102 const primary = hubHtml.match(/hub-rail-primary[\s\S]*?hub-rail-secondary/);
103 assert.ok(primary, 'primary rail markup present');
104 const chunk = primary[0];
105 const vault = chunk.indexOf('>Vault<');
106 const review = chunk.indexOf('>Review');
107 const history = chunk.indexOf('>History<');
108 assert.ok(vault >= 0 && review > vault && history > review, 'Vault then Review then History');
109 assert.deepEqual(hubPrimaryRailOrder(), ['Vault', 'Review', 'History']);
110 });
111
112 it('Needs-you banner markup targets Review / suggested', () => {
113 assert.match(hubHtml, /id="hub-needs-you-banner"/);
114 assert.match(hubHtml, /id="hub-needs-you-open"/);
115 assert.match(hubJs, /hub-needs-you-open[\s\S]{0,200}switchHubMainTab\(\s*['"]suggested['"]\s*\)/);
116 assert.equal(HUB_NEEDS_YOU_DISMISS_KEY, 'hub_needs_you_dismissed');
117 assert.equal(shouldShowNeedsYouBanner(2, false), true);
118 assert.equal(shouldShowNeedsYouBanner(2, true), false);
119 assert.equal(shouldShowNeedsYouBanner(0, false), false);
120 assert.match(needsYouBannerCopy(3), /3 proposals waiting in Review/);
121 });
122
123 it('no user-facing Suggested queue/tab label in web/hub chrome', () => {
124 const blobs = [hubHtml, hubJs, onboarding];
125 for (const blob of blobs) {
126 // Allow internal ids / path-typo helpers; forbid queue chrome labels.
127 assert.doesNotMatch(blob, />Suggested</);
128 assert.doesNotMatch(blob, /Open Suggested tab/);
129 assert.doesNotMatch(blob, /Suggested tab/);
130 assert.doesNotMatch(blob, /Suggested queue/);
131 assert.doesNotMatch(blob, /the Suggested/);
132 }
133 assert.match(hubHtml, /id="btn-header-suggested"[^>]*>Review/);
134 });
135 });
136
137 describe('HUB-DASH-IA-b stress', () => {
138 it('badge helpers safe for proposed count 0–100', () => {
139 for (let i = 0; i <= 100; i++) {
140 assert.equal(clampProposedBadgeCount(i), i);
141 const t = formatProposedBadgeText(i);
142 if (i === 0) assert.equal(t, '');
143 else assert.equal(t, String(i));
144 }
145 assert.equal(shouldPulseReviewBadge(0, 1), true);
146 assert.equal(shouldPulseReviewBadge(5, 5), false);
147 assert.equal(shouldPulseReviewBadge(5, 4), false);
148 });
149 });
150
151 describe('HUB-DASH-IA-b data-integrity', () => {
152 it('critical data-tab values and IDs unchanged', () => {
153 assert.deepEqual(hubCriticalDataTabs(), ['notes', 'suggested', 'activity', 'problem']);
154 for (const tab of hubCriticalDataTabs()) {
155 assert.match(hubHtml, new RegExp('data-tab="' + tab + '"'));
156 assert.match(hubHtml, new RegExp('id="tab-' + tab + '"'));
157 }
158 const ids = [
159 'btn-header-suggested',
160 'proposals-suggested',
161 'proposals-problem',
162 'proposals-activity',
163 'btn-import',
164 'btn-settings',
165 'btn-how-to-use',
166 'modal-import',
167 'modal-settings',
168 'modal-how-to-use',
169 'hub-list-sort',
170 'notes-view-graph',
171 'consolidation-card',
172 'detail-panel',
173 'vault-switcher',
174 'vault-switcher-wrap',
175 ];
176 for (const id of ids) {
177 assert.match(hubHtml, new RegExp('id="' + id + '"'));
178 }
179 assert.match(hubJs, /function\s+openSettingsIntegrationsTab\s*\(/);
180 });
181
182 it('vault switcher lives in left rail and stays visible with a single vault', () => {
183 const rail = hubHtml.match(/id="hub-rail"[\s\S]*?id="hub-bottom-nav"/);
184 assert.ok(rail, 'rail markup present');
185 assert.match(rail[0], /id="vault-switcher-wrap"/);
186 assert.match(rail[0], /id="vault-switcher"/);
187 assert.match(rail[0], /hub-rail-top/);
188 assert.match(rail[0], /hub-rail-primary/);
189 assert.match(rail[0], /hub-rail-secondary/);
190 assert.match(rail[0], />Vault</);
191 assert.match(rail[0], />Review/);
192 assert.match(rail[0], />History</);
193 assert.match(rail[0], />Insights</);
194 assert.match(rail[0], />Import</);
195 assert.match(rail[0], />Connect</);
196 assert.match(rail[0], />Settings</);
197 assert.match(rail[0], />Help</);
198 assert.doesNotMatch(hubHtml, /id="hub-auth-buttons"[\s\S]{0,400}id="vault-switcher"/);
199 assert.match(hubJs, /wrap\.classList\.toggle\(\s*['"]hidden['"]\s*,\s*options\.length\s*<\s*1\s*\)/);
200 });
201
202 it('rail clusters primary near top; secondary linked with divider (no tall space-between gap)', () => {
203 const railBlock = hubCss.match(/\.hub-rail\s*\{[^}]+\}/);
204 assert.ok(railBlock, 'hub-rail rule present');
205 assert.match(railBlock[0], /justify-content:\s*flex-start/);
206 assert.doesNotMatch(railBlock[0], /justify-content:\s*space-between/);
207 assert.match(hubCss, /\.hub-rail-secondary\s*\{[\s\S]*?border-top:/);
208 assert.match(hubJs, /scheduleMaybeShowOnboardingWizard[\s\S]{0,200}return;/);
209 });
210
211 it('detail panel sits below sticky header; brand uses small gray HUB', () => {
212 assert.match(hubCss, /\.hub-header\s*\{[\s\S]*?z-index:\s*40/);
213 assert.match(hubCss, /\.detail-panel\s*\{[\s\S]*?top:\s*var\(--hub-header-offset/);
214 assert.match(hubJs, /function\s+syncHubHeaderOffset\s*\(/);
215 assert.match(hubHtml, /class="hub-brand"/);
216 assert.match(hubHtml, /class="hub-brand-hub">HUB</);
217 assert.match(hubCss, /\.hub-brand-hub\s*\{/);
218 });
219
220 it('Quick tags label is distinct from key glossary; notes default half-page', () => {
221 assert.match(hubJs, /label\.textContent\s*=\s*['"]Quick tags['"]/);
222 assert.match(hubHtml, /search-key-term">Quick tags</);
223 assert.doesNotMatch(hubJs, /label\.textContent\s*=\s*['"]Quick['"]\s*;/);
224 assert.match(hubCss, /\.detail-panel\s*\{[\s\S]*?width:\s*50vw/);
225 assert.match(hubJs, /panel\.style\.width\s*=\s*['"]['"]/);
226 });
227 });
228
229 describe('HUB-DASH-IA-b performance (Vault browse disclosure)', () => {
230 it('Vault browse shows List|Calendar only; Overview/graph is Insights (hidden view-tab)', () => {
231 const browse = hubHtml.match(/id="browse-toolbar"[\s\S]*?<\/section>/);
232 assert.ok(browse);
233 const visibleViews = [...browse[0].matchAll(/class="view-tab(?![^"]*hidden)[^"]*"[^>]*data-view="([^"]+)"/g)].map(
234 (m) => m[1],
235 );
236 // Hidden graph tab still present for Insights
237 assert.match(browse[0], /data-view="graph"[^>]*class="[^"]*hidden|class="[^"]*hidden[^"]*"[^>]*data-view="graph"/);
238 assert.ok(visibleViews.includes('list'));
239 assert.ok(visibleViews.includes('calendar'));
240 assert.ok(!visibleViews.includes('graph'), 'graph must not be a visible browse segment');
241 assert.match(hubHtml, /id="hub-rail-insights"/);
242 assert.match(hubJs, /function\s+runHubSecondaryAction\s*\(/);
243 assert.match(
244 hubJs,
245 /key\s*===\s*['"]insights['"][\s\S]{0,200}switchNotesView\(\s*['"]graph['"]\s*\)/,
246 );
247 });
248 });
249
250 describe('HUB-DASH-IA-b security', () => {
251 it('badge/Needs-you use textContent paths; no proposal RBAC changes in shell slice', () => {
252 assert.match(hubJs, /badge\.textContent\s*=/);
253 assert.match(hubJs, /textEl\.textContent\s*=/);
254 assert.doesNotMatch(hubJs, /hub-needs-you-text[\s\S]{0,80}innerHTML\s*=/);
255 assert.doesNotMatch(hubJs, /hub-review-badge[\s\S]{0,80}innerHTML\s*=/);
256 // Approve/discard still go through existing API helpers (unchanged contracts)
257 assert.match(hubJs, /\/api\/v1\/proposals\/['"]\s*\+\s*encodeURIComponent\(id\)\s*\+\s*['"]\/approve/);
258 assert.match(hubJs, /\/api\/v1\/proposals\/['"]\s*\+\s*encodeURIComponent\(id\)\s*\+\s*['"]\/discard/);
259 assert.match(hubCss, /hub-rail-item\.active::before/);
260 assert.match(hubCss, /prefers-reduced-motion/);
261 });
262 });
File History 1 commit
sha256:700fafdd1afa490919f9515d660ca6e75456bcd5bb67513abcd8757a634c01f6 docs: record AIP-b SD-21 land (KN #308) Human 9 days ago