hub-dashboard-ia-shell.test.mjs
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