From f28a3146da101256d62a5689c5c021a8143c5390 Mon Sep 17 00:00:00 2001 From: Kpa-clawbot Date: Mon, 30 Mar 2026 20:25:11 -0700 Subject: [PATCH] Fix channel region crosstalk in frontend (#307) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Fixes frontend region crosstalk on Channels page by applying region filtering to message fetches and live WS GRP_TXT handling. ## Changes - Append `region` query param to channel message API calls in `selectChannel` and `refreshMessages`. - Add WS region guard in `public/channels.js` using observer→IATA map with selected-region snapshot at handler entry. - On region switch, reload channels and re-fetch selected channel messages; if empty under selected region, clear pane and show `Channel not available in selected region`. - Bump cache busters in `public/index.html`. - Add frontend helper tests for extracted WS region filter helper in `test-frontend-helpers.js`. ## Validation - `node test-frontend-helpers.js` - `node test-packet-filter.js` - `node test-aging.js` Refs #280 --------- Co-authored-by: Kpa-clawbot <259247574+Kpa-clawbot@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- public/channels.js | 159 ++++++++++++++++- public/index.html | 57 +++--- test-frontend-helpers.js | 362 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 543 insertions(+), 35 deletions(-) diff --git a/public/channels.js b/public/channels.js index 2cc9282d..6000e286 100644 --- a/public/channels.js +++ b/public/channels.js @@ -9,8 +9,93 @@ let autoScroll = true; let nodeCache = {}; let selectedNode = null; + let observerIataById = {}; + let observerIataByName = {}; + let messageRequestId = 0; var _nodeCacheTTL = 5 * 60 * 1000; // 5 minutes + function getSelectedRegionsSnapshot() { + var rp = RegionFilter.getRegionParam(); + return rp ? rp.split(',').filter(Boolean) : null; + } + + function normalizeObserverNameKey(name) { + if (!name) return ''; + return String(name).trim().toLowerCase(); + } + + function shouldProcessWSMessageForRegion(msg, selectedRegions, observerRegionsById, observerRegionsByName) { + if (!selectedRegions || !selectedRegions.length) return true; + if (observerRegionsById && observerRegionsById.byId) { + observerRegionsByName = observerRegionsById.byName || {}; + observerRegionsById = observerRegionsById.byId || {}; + } + observerRegionsById = observerRegionsById || {}; + observerRegionsByName = observerRegionsByName || {}; + + var observerId = msg?.data?.packet?.observer_id || msg?.data?.observer_id || null; + var observerRegion = observerId ? observerRegionsById[observerId] : null; + if (!observerRegion) { + var observerName = msg?.data?.packet?.observer_name || msg?.data?.observer_name || msg?.data?.observer || null; + var observerNameKey = normalizeObserverNameKey(observerName); + if (observerName) observerRegion = observerRegionsByName[observerName]; + if (!observerRegion && observerNameKey) observerRegion = observerRegionsByName[observerNameKey]; + } + if (!observerRegion) return false; + return selectedRegions.indexOf(observerRegion) !== -1; + } + + async function loadObserverRegions() { + try { + var data = await api('/observers', { ttl: CLIENT_TTL.observers }); + var list = data && data.observers ? data.observers : []; + var byId = {}; + var byName = {}; + for (var i = 0; i < list.length; i++) { + var o = list[i]; + var id = o.id || o.observer_id; + var name = o.name || o.observer_name; + if (!o.iata) continue; + if (id) byId[id] = o.iata; + if (name) { + byName[name] = o.iata; + var key = normalizeObserverNameKey(name); + if (key) byName[key] = o.iata; + } + } + observerIataById = byId; + observerIataByName = byName; + } catch {} + } + + function beginMessageRequest(hash, regionParam) { + return { id: ++messageRequestId, hash: hash, regionParam: regionParam || '' }; + } + + function isStaleMessageRequest(req) { + if (!req) return true; + var currentRegion = RegionFilter.getRegionParam() || ''; + if (req.id !== messageRequestId) return true; + if (selectedHash !== req.hash) return true; + if (currentRegion !== req.regionParam) return true; + return false; + } + + function reconcileSelectionAfterChannelRefresh() { + if (!selectedHash || channels.some(ch => ch.hash === selectedHash)) return false; + selectedHash = null; + messages = []; + history.replaceState(null, '', '#/channels'); + renderChannelList(); + const header = document.getElementById('chHeader'); + if (header) header.querySelector('.ch-header-text').textContent = 'Select a channel'; + const msgEl = document.getElementById('chMessages'); + if (msgEl) msgEl.innerHTML = '
Choose a channel from the sidebar to view messages
'; + document.querySelector('.ch-layout')?.classList.remove('ch-show-main'); + document.getElementById('chScrollBtn')?.classList.add('hidden'); + return true; + } + async function lookupNode(name) { var cached = nodeCache[name]; if (cached !== undefined) { @@ -251,8 +336,14 @@ `; RegionFilter.init(document.getElementById('chRegionFilter')); - regionChangeHandler = RegionFilter.onChange(function () { loadChannels(); }); + regionChangeHandler = RegionFilter.onChange(function () { + loadChannels(true).then(async function () { + if (!selectedHash) return; + await refreshMessages({ regionSwitch: true, forceNoCache: true }); + }); + }); + loadObserverRegions(); loadChannels().then(() => { if (routeParam) selectChannel(routeParam); }); @@ -382,7 +473,7 @@ } }); - wsHandler = debouncedOnWS(function (msgs) { + function processWSBatch(msgs, selectedRegions) { var dominated = msgs.filter(function (m) { return m.type === 'message' || (m.type === 'packet' && m.data?.decoded?.header?.payloadTypeName === 'GRP_TXT'); }); @@ -394,6 +485,7 @@ for (var i = 0; i < dominated.length; i++) { var m = dominated[i]; + if (!shouldProcessWSMessageForRegion(m, selectedRegions, observerIataById, observerIataByName)) continue; var payload = m.data?.decoded?.payload; if (!payload) continue; @@ -494,7 +586,18 @@ if (liveEl) liveEl.textContent = 'New message received'; } } + } + + function handleWSBatch(msgs) { + var selectedRegions = getSelectedRegionsSnapshot(); + processWSBatch(msgs, selectedRegions); + } + + wsHandler = debouncedOnWS(function (msgs) { + handleWSBatch(msgs); }); + window._channelsHandleWSBatchForTest = handleWSBatch; + window._channelsProcessWSBatchForTest = processWSBatch; // Tick relative timestamps every 1s — iterates channels array, updates DOM text only timeAgoTimer = setInterval(function () { @@ -536,6 +639,7 @@ return ch; }).sort((a, b) => (b.lastActivityMs || 0) - (a.lastActivityMs || 0)); renderChannelList(); + reconcileSelectionAfterChannelRefresh(); } catch (e) { if (!silent) { const el = document.getElementById('chList'); @@ -578,6 +682,8 @@ } async function selectChannel(hash) { + const rp = RegionFilter.getRegionParam() || ''; + const request = beginMessageRequest(hash, rp); selectedHash = hash; history.replaceState(null, '', `#/channels/${encodeURIComponent(hash)}`); renderChannelList(); @@ -593,23 +699,42 @@ msgEl.innerHTML = '
Loading messages…
'; try { - const data = await api(`/channels/${encodeURIComponent(hash)}/messages?limit=200`, { ttl: CLIENT_TTL.channelMessages }); + const regionQs = rp ? '®ion=' + encodeURIComponent(rp) : ''; + const data = await api(`/channels/${encodeURIComponent(hash)}/messages?limit=200${regionQs}`, { ttl: CLIENT_TTL.channelMessages }); + if (isStaleMessageRequest(request)) return; messages = data.messages || []; - renderMessages(); - scrollToBottom(); + if (messages.length === 0 && rp) { + msgEl.innerHTML = '
Channel not available in selected region
'; + } else { + renderMessages(); + scrollToBottom(); + } } catch (e) { + if (isStaleMessageRequest(request)) return; msgEl.innerHTML = `
Failed to load messages: ${e.message}
`; } } - async function refreshMessages() { + async function refreshMessages(opts) { if (!selectedHash) return; + opts = opts || {}; const msgEl = document.getElementById('chMessages'); if (!msgEl) return; const wasAtBottom = msgEl.scrollHeight - msgEl.scrollTop - msgEl.clientHeight < 60; try { - const data = await api(`/channels/${encodeURIComponent(selectedHash)}/messages?limit=200`, { ttl: CLIENT_TTL.channelMessages }); + const requestHash = selectedHash; + const rp = RegionFilter.getRegionParam() || ''; + const request = beginMessageRequest(requestHash, rp); + const regionQs = rp ? '®ion=' + encodeURIComponent(rp) : ''; + const data = await api(`/channels/${encodeURIComponent(requestHash)}/messages?limit=200${regionQs}`, { ttl: CLIENT_TTL.channelMessages, bust: !!opts.forceNoCache }); + if (isStaleMessageRequest(request)) return; const newMsgs = data.messages || []; + if (opts.regionSwitch && rp && newMsgs.length === 0) { + messages = []; + msgEl.innerHTML = '
Channel not available in selected region
'; + document.getElementById('chScrollBtn')?.classList.add('hidden'); + return; + } // #92: Use message ID/hash for change detection instead of count + timestamp var _getLastId = function (arr) { var m = arr.length ? arr[arr.length - 1] : null; return m ? (m.id || m.packetId || m.timestamp || '') : ''; }; if (newMsgs.length === messages.length && _getLastId(newMsgs) === _getLastId(messages)) return; @@ -665,5 +790,25 @@ if (msgEl) { msgEl.scrollTop = msgEl.scrollHeight; autoScroll = true; document.getElementById('chScrollBtn')?.classList.add('hidden'); } } + window._channelsSetStateForTest = function (state) { + if (!state) return; + if (Array.isArray(state.channels)) channels = state.channels; + if (Array.isArray(state.messages)) messages = state.messages; + if (Object.prototype.hasOwnProperty.call(state, 'selectedHash')) selectedHash = state.selectedHash; + }; + window._channelsSetObserverRegionsForTest = function (byId, byName) { + observerIataById = byId || {}; + observerIataByName = byName || {}; + }; + window._channelsSelectChannelForTest = selectChannel; + window._channelsRefreshMessagesForTest = refreshMessages; + window._channelsLoadChannelsForTest = loadChannels; + window._channelsBeginMessageRequestForTest = beginMessageRequest; + window._channelsIsStaleMessageRequestForTest = isStaleMessageRequest; + window._channelsReconcileSelectionForTest = reconcileSelectionAfterChannelRefresh; + window._channelsGetStateForTest = function () { + return { channels: channels, messages: messages, selectedHash: selectedHash }; + }; + window._channelsShouldProcessWSMessageForRegion = shouldProcessWSMessageForRegion; registerPage('channels', { init, destroy }); })(); diff --git a/public/index.html b/public/index.html index fb43f146..39d1836c 100644 --- a/public/index.html +++ b/public/index.html @@ -22,9 +22,9 @@ - - - + + + @@ -81,30 +81,31 @@
- - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/test-frontend-helpers.js b/test-frontend-helpers.js index 1a94f9da..cd071748 100644 --- a/test-frontend-helpers.js +++ b/test-frontend-helpers.js @@ -1927,6 +1927,368 @@ console.log('\n=== customize.js: initState merge behavior ==='); assert.strictEqual(state.theme.navBg, '#fedcba'); }); } + +// ===== CHANNELS.JS: WS Region Filter helper ===== +console.log('\n=== channels.js: shouldProcessWSMessageForRegion ==='); +{ + const ctx = makeSandbox(); + ctx.registerPage = () => {}; + ctx.RegionFilter = { init() {}, onChange() { return () => {}; }, offChange() {}, getRegionParam() { return ''; } }; + ctx.onWS = () => {}; + ctx.offWS = () => {}; + ctx.debouncedOnWS = (fn) => fn; + ctx.api = () => Promise.resolve({}); + ctx.CLIENT_TTL = { observers: 120000, channels: 15000, channelMessages: 10000 }; + ctx.history = { replaceState() {} }; + ctx.btoa = (s) => Buffer.from(String(s), 'utf8').toString('base64'); + ctx.atob = (s) => Buffer.from(String(s), 'base64').toString('utf8'); + loadInCtx(ctx, 'public/channels.js'); + const shouldProcess = ctx.window._channelsShouldProcessWSMessageForRegion; + + test('helper is exported', () => assert.ok(typeof shouldProcess === 'function')); + + test('allows all when no region selected', () => { + const msg = { data: { packet: { observer_id: 'obs1' } } }; + assert.strictEqual(shouldProcess(msg, null, { obs1: 'SJC' }), true); + assert.strictEqual(shouldProcess(msg, [], { obs1: 'SJC' }), true); + }); + + test('allows message when observer region matches selection', () => { + const msg = { data: { packet: { observer_id: 'obs1' } } }; + assert.strictEqual(shouldProcess(msg, ['SJC', 'SFO'], { obs1: 'SJC' }), true); + }); + + test('drops message when observer region is outside selection', () => { + const msg = { data: { packet: { observer_id: 'obs2' } } }; + assert.strictEqual(shouldProcess(msg, ['SJC'], { obs2: 'LAX' }), false); + }); + + test('drops message when observer_id is missing under selected region', () => { + const msg = { data: {} }; + assert.strictEqual(shouldProcess(msg, ['SJC'], { obs1: 'SJC' }), false); + }); + + test('falls back to observer_name mapping when observer_id is missing', () => { + const msg = { data: { packet: { observer_name: 'Observer Alpha' } } }; + assert.strictEqual(shouldProcess(msg, ['SJC'], { obs1: 'LAX' }, { 'Observer Alpha': 'SJC' }), true); + }); + + test('drops message when observer region lookup missing', () => { + const msg = { data: { packet: { observer_id: 'obs9' } } }; + assert.strictEqual(shouldProcess(msg, ['SJC'], { obs1: 'SJC' }), false); + }); +} + +console.log('\n=== channels.js: WS batch + region snapshot integration ==='); +{ + function makeChannelsWsSandbox(regionParam) { + const ctx = makeSandbox(); + const dom = {}; + function makeEl(id) { + if (dom[id]) return dom[id]; + dom[id] = { + id, + innerHTML: '', + textContent: '', + value: '', + scrollTop: 0, + scrollHeight: 100, + clientHeight: 80, + style: {}, + dataset: {}, + classList: { add() {}, remove() {}, toggle() {}, contains() { return false; } }, + addEventListener() {}, + removeEventListener() {}, + querySelector() { return null; }, + querySelectorAll() { return []; }, + getBoundingClientRect() { return { left: 0, bottom: 0, width: 0 }; }, + setAttribute() {}, + removeAttribute() {}, + focus() {}, + }; + return dom[id]; + } + + const headerText = { textContent: '' }; + makeEl('chHeader').querySelector = (sel) => (sel === '.ch-header-text' ? headerText : null); + makeEl('chMessages'); + makeEl('chList'); + makeEl('chScrollBtn'); + makeEl('chAriaLive'); + makeEl('chBackBtn'); + makeEl('chRegionFilter'); + + const appEl = { + innerHTML: '', + querySelector(sel) { + if (sel === '.ch-sidebar' || sel === '.ch-sidebar-resize' || sel === '.ch-main') return makeEl(sel); + if (sel === '.ch-layout') return { classList: { add() {}, remove() {}, contains() { return false; } } }; + return makeEl(sel); + }, + addEventListener() {}, + }; + + ctx.document.getElementById = makeEl; + ctx.document.querySelector = (sel) => { + if (sel === '.ch-layout') return { classList: { add() {}, remove() {}, contains() { return false; } } }; + return null; + }; + ctx.document.querySelectorAll = () => []; + ctx.document.addEventListener = () => {}; + ctx.document.removeEventListener = () => {}; + ctx.document.documentElement = { getAttribute: () => null, setAttribute: () => {} }; + ctx.document.body = { appendChild() {}, removeChild() {}, contains() { return false; } }; + ctx.history = { replaceState() {} }; + ctx.matchMedia = () => ({ matches: false }); + ctx.window.matchMedia = ctx.matchMedia; + ctx.MutationObserver = function () { this.observe = () => {}; this.disconnect = () => {}; }; + ctx.RegionFilter = { + init() {}, + onChange() { return () => {}; }, + offChange() {}, + getRegionParam() { return regionParam || ''; }, + }; + ctx.debouncedOnWS = (fn) => fn; + ctx.onWS = () => {}; + ctx.offWS = () => {}; + ctx.api = (path) => { + if (path.indexOf('/observers') === 0) return Promise.resolve({ observers: [] }); + if (path.indexOf('/channels') === 0) return Promise.resolve({ channels: [] }); + return Promise.resolve({ messages: [] }); + }; + ctx.CLIENT_TTL = { observers: 120000, channels: 15000, channelMessages: 10000, nodeDetail: 10000 }; + ctx.ROLE_EMOJI = {}; + ctx.ROLE_LABELS = {}; + ctx.timeAgo = () => '1m ago'; + ctx.registerPage = (name, handlers) => { ctx._pageHandlers = handlers; }; + ctx.btoa = (s) => Buffer.from(String(s), 'utf8').toString('base64'); + ctx.atob = (s) => Buffer.from(String(s), 'base64').toString('utf8'); + + loadInCtx(ctx, 'public/channels.js'); + ctx._pageHandlers.init(appEl); + return { ctx, dom }; + } + + test('WS batch respects region snapshot and observer_name fallback', () => { + const env = makeChannelsWsSandbox('SJC'); + env.ctx.window._channelsSetObserverRegionsForTest({ obs1: 'SJC' }, { 'Observer Beta': 'SJC' }); + env.ctx.window._channelsSetStateForTest({ + selectedHash: 'general', + channels: [{ hash: 'general', name: 'general', messageCount: 0, lastActivityMs: 0 }], + messages: [], + }); + + env.ctx.window._channelsHandleWSBatchForTest([ + { + type: 'packet', + data: { + hash: 'hash1', + decoded: { header: { payloadTypeName: 'GRP_TXT' }, payload: { channel: 'general', text: 'Alice: hello world' } }, + packet: { observer_name: 'Observer Beta' }, + }, + }, + { + type: 'packet', + data: { + hash: 'hash2', + decoded: { header: { payloadTypeName: 'GRP_TXT' }, payload: { channel: 'general', text: 'Bob: dropped' } }, + packet: { observer_name: 'Observer Zeta' }, + }, + }, + ]); + + const state = env.ctx.window._channelsGetStateForTest(); + assert.strictEqual(state.messages.length, 1, 'only matching-region message should be appended'); + assert.strictEqual(state.messages[0].sender, 'Alice'); + assert.strictEqual(state.channels[0].messageCount, 1, 'channel count increments only for accepted message'); + }); + + test('stale selectChannel response is discarded after region change', async () => { + const ctx = makeSandbox(); + const dom = {}; + function makeEl(id) { + if (dom[id]) return dom[id]; + dom[id] = { + id, + innerHTML: '', + textContent: '', + value: '', + scrollTop: 0, + scrollHeight: 100, + clientHeight: 80, + style: {}, + dataset: {}, + classList: { add() {}, remove() {}, toggle() {}, contains() { return false; } }, + addEventListener() {}, + removeEventListener() {}, + querySelector() { return null; }, + querySelectorAll() { return []; }, + getBoundingClientRect() { return { left: 0, bottom: 0, width: 0 }; }, + setAttribute() {}, + removeAttribute() {}, + focus() {}, + }; + return dom[id]; + } + const headerText = { textContent: '' }; + makeEl('chHeader').querySelector = (sel) => (sel === '.ch-header-text' ? headerText : null); + makeEl('chMessages'); + makeEl('chList'); + makeEl('chScrollBtn'); + makeEl('chAriaLive'); + makeEl('chBackBtn'); + makeEl('chRegionFilter'); + const appEl = { + innerHTML: '', + querySelector(sel) { + if (sel === '.ch-sidebar' || sel === '.ch-sidebar-resize' || sel === '.ch-main') return makeEl(sel); + if (sel === '.ch-layout') return { classList: { add() {}, remove() {}, contains() { return false; } } }; + return makeEl(sel); + }, + addEventListener() {}, + }; + let region = 'SJC'; + let resolver = null; + ctx.document.getElementById = makeEl; + ctx.document.querySelector = (sel) => { + if (sel === '.ch-layout') return { classList: { add() {}, remove() {}, contains() { return false; } } }; + return null; + }; + ctx.document.querySelectorAll = () => []; + ctx.document.addEventListener = () => {}; + ctx.document.removeEventListener = () => {}; + ctx.document.documentElement = { getAttribute: () => null, setAttribute: () => {} }; + ctx.document.body = { appendChild() {}, removeChild() {}, contains() { return false; } }; + ctx.history = { replaceState() {} }; + ctx.matchMedia = () => ({ matches: false }); + ctx.window.matchMedia = ctx.matchMedia; + ctx.MutationObserver = function () { this.observe = () => {}; this.disconnect = () => {}; }; + ctx.RegionFilter = { init() {}, onChange() { return () => {}; }, offChange() {}, getRegionParam() { return region; } }; + ctx.debouncedOnWS = (fn) => fn; + ctx.onWS = () => {}; + ctx.offWS = () => {}; + ctx.api = (path) => { + if (path.indexOf('/observers') === 0) return Promise.resolve({ observers: [] }); + if (path.indexOf('/channels?') === 0 || path === '/channels') return Promise.resolve({ channels: [{ hash: 'general', name: 'general', messageCount: 2, lastActivity: null }] }); + if (path.indexOf('/channels/general/messages') === 0) { + return new Promise((resolve) => { resolver = resolve; }); + } + return Promise.resolve({ messages: [] }); + }; + ctx.CLIENT_TTL = { observers: 120000, channels: 15000, channelMessages: 10000, nodeDetail: 10000 }; + ctx.ROLE_EMOJI = {}; + ctx.ROLE_LABELS = {}; + ctx.timeAgo = () => '1m ago'; + ctx.registerPage = (name, handlers) => { ctx._pageHandlers = handlers; }; + ctx.btoa = (s) => Buffer.from(String(s), 'utf8').toString('base64'); + ctx.atob = (s) => Buffer.from(String(s), 'base64').toString('utf8'); + + loadInCtx(ctx, 'public/channels.js'); + ctx._pageHandlers.init(appEl); + await Promise.resolve(); + const selectPromise = ctx.window._channelsSelectChannelForTest('general'); + region = 'LAX'; + ctx.window._channelsBeginMessageRequestForTest('other', 'LAX'); + resolver({ messages: [{ sender: 'Alice', text: 'stale', timestamp: '2025-01-01T00:00:00Z' }] }); + await selectPromise; + const state = ctx.window._channelsGetStateForTest(); + assert.strictEqual(state.selectedHash, 'general', 'stale select response must not clear or overwrite selection'); + assert.strictEqual(state.messages.length, 0, 'stale response must be discarded'); + }); + + test('loadChannels clears selected hash when channel no longer exists in region', async () => { + const ctx = makeSandbox(); + const dom = {}; + function makeEl(id) { + if (dom[id]) return dom[id]; + dom[id] = { + id, + innerHTML: '', + textContent: '', + value: '', + scrollTop: 0, + scrollHeight: 100, + clientHeight: 80, + style: {}, + dataset: {}, + classList: { add() {}, remove() {}, toggle() {}, contains() { return false; } }, + addEventListener() {}, + removeEventListener() {}, + querySelector() { return null; }, + querySelectorAll() { return []; }, + getBoundingClientRect() { return { left: 0, bottom: 0, width: 0 }; }, + setAttribute() {}, + removeAttribute() {}, + focus() {}, + }; + return dom[id]; + } + const headerText = { textContent: '' }; + makeEl('chHeader').querySelector = (sel) => (sel === '.ch-header-text' ? headerText : null); + makeEl('chMessages'); + makeEl('chList'); + makeEl('chScrollBtn'); + makeEl('chAriaLive'); + makeEl('chBackBtn'); + makeEl('chRegionFilter'); + const appEl = { + innerHTML: '', + querySelector(sel) { + if (sel === '.ch-sidebar' || sel === '.ch-sidebar-resize' || sel === '.ch-main') return makeEl(sel); + if (sel === '.ch-layout') return { classList: { add() {}, remove() {}, contains() { return false; } } }; + return makeEl(sel); + }, + addEventListener() {}, + }; + const historyCalls = []; + let channelCall = 0; + ctx.document.getElementById = makeEl; + ctx.document.querySelector = (sel) => { + if (sel === '.ch-layout') return { classList: { add() {}, remove() {}, contains() { return false; } } }; + return null; + }; + ctx.document.querySelectorAll = () => []; + ctx.document.addEventListener = () => {}; + ctx.document.removeEventListener = () => {}; + ctx.document.documentElement = { getAttribute: () => null, setAttribute: () => {} }; + ctx.document.body = { appendChild() {}, removeChild() {}, contains() { return false; } }; + ctx.history = { replaceState(_a, _b, url) { historyCalls.push(url); } }; + ctx.matchMedia = () => ({ matches: false }); + ctx.window.matchMedia = ctx.matchMedia; + ctx.MutationObserver = function () { this.observe = () => {}; this.disconnect = () => {}; }; + ctx.RegionFilter = { init() {}, onChange() { return () => {}; }, offChange() {}, getRegionParam() { return 'SJC'; } }; + ctx.debouncedOnWS = (fn) => fn; + ctx.onWS = () => {}; + ctx.offWS = () => {}; + ctx.api = (path) => { + if (path.indexOf('/observers') === 0) return Promise.resolve({ observers: [] }); + if (path.indexOf('/channels') === 0) { + channelCall++; + if (channelCall === 1) return Promise.resolve({ channels: [{ hash: 'general', name: 'general', messageCount: 1, lastActivity: null }] }); + return Promise.resolve({ channels: [{ hash: 'newchan', name: 'newchan', messageCount: 1, lastActivity: null }] }); + } + if (path.indexOf('/channels/general/messages') === 0) return Promise.resolve({ messages: [{ sender: 'Alice', text: 'hi', timestamp: '2025-01-01T00:00:00Z' }] }); + return Promise.resolve({ messages: [] }); + }; + ctx.CLIENT_TTL = { observers: 120000, channels: 15000, channelMessages: 10000, nodeDetail: 10000 }; + ctx.ROLE_EMOJI = {}; + ctx.ROLE_LABELS = {}; + ctx.timeAgo = () => '1m ago'; + ctx.registerPage = (name, handlers) => { ctx._pageHandlers = handlers; }; + ctx.btoa = (s) => Buffer.from(String(s), 'utf8').toString('base64'); + ctx.atob = (s) => Buffer.from(String(s), 'base64').toString('utf8'); + + loadInCtx(ctx, 'public/channels.js'); + ctx._pageHandlers.init(appEl); + await Promise.resolve(); + await ctx.window._channelsSelectChannelForTest('general'); + await ctx.window._channelsLoadChannelsForTest(true); + ctx.window._channelsReconcileSelectionForTest(); + const state = ctx.window._channelsGetStateForTest(); + assert.strictEqual(state.selectedHash, null, 'selection should clear when channel disappears after region update'); + assert.ok(historyCalls.includes('#/channels'), 'should route back to channels root'); + }); +} // ===== SUMMARY ===== console.log(`\n${'═'.repeat(40)}`); console.log(` Frontend helpers: ${passed} passed, ${failed} failed`);