mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-08-29 00:58:21 +00:00
Fix channel region crosstalk in frontend (#307)
## 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>
This commit is contained in:
co-authored by
Kpa-clawbot
Copilot
parent
3a1d7263b4
commit
f28a3146da
+152
-7
@@ -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 = '<div class="ch-empty">Choose a channel from the sidebar to view messages</div>';
|
||||
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 @@
|
||||
</div>`;
|
||||
|
||||
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 = '<div class="ch-loading">Loading messages…</div>';
|
||||
|
||||
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 = '<div class="ch-empty">Channel not available in selected region</div>';
|
||||
} else {
|
||||
renderMessages();
|
||||
scrollToBottom();
|
||||
}
|
||||
} catch (e) {
|
||||
if (isStaleMessageRequest(request)) return;
|
||||
msgEl.innerHTML = `<div class="ch-empty">Failed to load messages: ${e.message}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
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 = '<div class="ch-empty">Channel not available in selected region</div>';
|
||||
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 });
|
||||
})();
|
||||
|
||||
+29
-28
@@ -22,9 +22,9 @@
|
||||
<meta name="twitter:title" content="CoreScope">
|
||||
<meta name="twitter:description" content="Real-time MeshCore LoRa mesh network analyzer — live packet visualization, node tracking, channel decryption, and route analysis.">
|
||||
<meta name="twitter:image" content="https://raw.githubusercontent.com/Kpa-clawbot/corescope/master/public/og-image.png">
|
||||
<link rel="stylesheet" href="style.css?v=1774920715">
|
||||
<link rel="stylesheet" href="home.css?v=1774920715">
|
||||
<link rel="stylesheet" href="live.css?v=1774920715">
|
||||
<link rel="stylesheet" href="style.css?v=1774926567">
|
||||
<link rel="stylesheet" href="home.css?v=1774926567">
|
||||
<link rel="stylesheet" href="live.css?v=1774926567">
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
|
||||
integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY="
|
||||
crossorigin="anonymous">
|
||||
@@ -81,30 +81,31 @@
|
||||
<main id="app" role="main"></main>
|
||||
|
||||
<script src="vendor/qrcode.js"></script>
|
||||
<script src="roles.js?v=1774920715"></script>
|
||||
<script src="customize.js?v=1774920715" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="region-filter.js?v=1774920715"></script>
|
||||
<script src="hop-resolver.js?v=1774920715"></script>
|
||||
<script src="hop-display.js?v=1774920715"></script>
|
||||
<script src="app.js?v=1774920715"></script>
|
||||
<script src="home.js?v=1774920715"></script>
|
||||
<script src="packet-filter.js?v=1774920715"></script>
|
||||
<script src="packets.js?v=1774920715"></script>
|
||||
<script src="geo-filter-overlay.js?v=1774920715"></script>
|
||||
<script src="map.js?v=1774920715" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="channels.js?v=1774920715" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="nodes.js?v=1774920715" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="traces.js?v=1774920715" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="analytics.js?v=1774920715" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="audio.js?v=1774920715" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="audio-v1-constellation.js?v=1774920715" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="audio-v2-constellation.js?v=1774920715" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="audio-lab.js?v=1774920715" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="live.js?v=1774920715" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="observers.js?v=1774920715" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="observer-detail.js?v=1774920715" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="compare.js?v=1774920715" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="node-analytics.js?v=1774920715" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="perf.js?v=1774920715" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="roles.js?v=1774926567"></script>
|
||||
<script src="customize.js?v=1774926567" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="region-filter.js?v=1774926567"></script>
|
||||
<script src="hop-resolver.js?v=1774926567"></script>
|
||||
<script src="hop-display.js?v=1774926567"></script>
|
||||
<script src="app.js?v=1774926567"></script>
|
||||
<script src="home.js?v=1774926567"></script>
|
||||
<script src="packet-filter.js?v=1774926567"></script>
|
||||
<script src="packets.js?v=1774926567"></script>
|
||||
<script src="geo-filter-overlay.js?v=1774926567"></script>
|
||||
<script src="map.js?v=1774926567" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="channels.js?v=1774926567" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="nodes.js?v=1774926567" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="traces.js?v=1774926567" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="analytics.js?v=1774926567" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="audio.js?v=1774926567" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="audio-v1-constellation.js?v=1774926567" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="audio-v2-constellation.js?v=1774926567" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="audio-lab.js?v=1774926567" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="live.js?v=1774926567" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="observers.js?v=1774926567" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="observer-detail.js?v=1774926567" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="compare.js?v=1774926567" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="node-analytics.js?v=1774926567" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="perf.js?v=1774926567" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
|
||||
@@ -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`);
|
||||
|
||||
Reference in New Issue
Block a user