merge secondary channels by name (#468)

* merge secondary channels by name

* cover missing unit test vectors
This commit is contained in:
l5y
2025-11-18 18:33:02 +01:00
committed by GitHub
parent 8f7adba65a
commit be46963744
2 changed files with 138 additions and 6 deletions
@@ -108,7 +108,7 @@ test('buildChatTabModel returns sorted nodes and channel buckets', () => {
const secondaryChannel = channelByLabel.BerlinMesh;
assert.equal(secondaryChannel.index, 1);
assert.equal(secondaryChannel.id, 'channel-1');
assert.equal(secondaryChannel.id, 'channel-secondary-berlinmesh');
assert.equal(secondaryChannel.entries.length, 2);
assert.deepEqual(secondaryChannel.entries.map(entry => entry.message.id), ['iso-ts', 'recent-alt']);
});
@@ -249,3 +249,80 @@ test('buildChatTabModel ignores plaintext log-only entries', () => {
assert.equal(encryptedEntries.length, 1);
assert.equal(encryptedEntries[0]?.message?.id, 'enc');
});
test('buildChatTabModel merges secondary channels with matching labels regardless of index', () => {
const primaryId = 'primary';
const secondaryFirstId = 'secondary-one';
const secondarySecondId = 'secondary-two';
const label = 'MeshTown';
const model = buildChatTabModel({
nodes: [],
messages: [
{ id: secondaryFirstId, rx_time: NOW - 12, channel: 7, channel_name: label },
{ id: primaryId, rx_time: NOW - 10, channel: 0, channel_name: label },
{ id: secondarySecondId, rx_time: NOW - 8, channel: 3, channel_name: ` ${label} ` }
],
nowSeconds: NOW,
windowSeconds: WINDOW
});
const meshChannels = model.channels.filter(channel => channel.label === label);
assert.equal(meshChannels.length, 2);
const primaryChannel = meshChannels.find(channel => channel.index === 0);
assert.ok(primaryChannel);
assert.equal(primaryChannel.entries.length, 1);
assert.equal(primaryChannel.entries[0]?.message?.id, primaryId);
const secondaryChannel = meshChannels.find(channel => channel.index > 0);
assert.ok(secondaryChannel);
assert.equal(secondaryChannel.id, 'channel-secondary-meshtown');
assert.equal(secondaryChannel.index, 3);
assert.deepEqual(secondaryChannel.entries.map(entry => entry.message.id), [secondaryFirstId, secondarySecondId]);
});
test('buildChatTabModel rekeys unnamed secondary buckets when a label later arrives', () => {
const unnamedId = 'unnamed';
const namedId = 'named';
const label = 'SideMesh';
const index = 4;
const model = buildChatTabModel({
nodes: [],
messages: [
{ id: unnamedId, rx_time: NOW - 15, channel: index },
{ id: namedId, rx_time: NOW - 10, channel: index, channel_name: label }
],
nowSeconds: NOW,
windowSeconds: WINDOW
});
const secondaryChannels = model.channels.filter(channel => channel.index === index);
assert.equal(secondaryChannels.length, 1);
const [secondaryChannel] = secondaryChannels;
assert.equal(secondaryChannel.id, 'channel-secondary-sidemesh');
assert.equal(secondaryChannel.label, label);
assert.deepEqual(secondaryChannel.entries.map(entry => entry.message.id), [unnamedId, namedId]);
});
test('buildChatTabModel merges unlabeled secondary messages into existing named buckets by index', () => {
const namedId = 'named';
const unlabeledId = 'unlabeled';
const label = 'MeshNorth';
const index = 5;
const model = buildChatTabModel({
nodes: [],
messages: [
{ id: namedId, rx_time: NOW - 12, channel: index, channel_name: label },
{ id: unlabeledId, rx_time: NOW - 8, channel: index }
],
nowSeconds: NOW,
windowSeconds: WINDOW
});
const secondaryChannels = model.channels.filter(channel => channel.index === index);
assert.equal(secondaryChannels.length, 1);
const [secondaryChannel] = secondaryChannels;
assert.equal(secondaryChannel.id, 'channel-secondary-meshnorth');
assert.equal(secondaryChannel.label, label);
assert.deepEqual(secondaryChannel.entries.map(entry => entry.message.id), [namedId, unlabeledId]);
});
+60 -5
View File
@@ -189,8 +189,28 @@ export function buildChatTabModel({
modemPreset,
envFallbackLabel: primaryChannelEnvLabel
});
const bucketKey = buildChannelBucketKey(safeIndex, safeIndex === 0 && labelInfo.label !== '0' ? labelInfo.label : null);
const nameBucketKey = safeIndex > 0 ? buildSecondaryNameBucketKey(labelInfo) : null;
const primaryBucketKey = safeIndex === 0 && labelInfo.label !== '0' ? buildPrimaryBucketKey(labelInfo.label) : '0';
let bucketKey = safeIndex === 0 ? primaryBucketKey : nameBucketKey ?? String(safeIndex);
let bucket = channelBuckets.get(bucketKey);
if (!bucket && safeIndex > 0) {
const existingBucketKey = findExistingBucketKeyByIndex(channelBuckets, safeIndex);
if (existingBucketKey) {
bucketKey = existingBucketKey;
bucket = channelBuckets.get(existingBucketKey);
}
}
if (bucket && nameBucketKey && bucket.key !== nameBucketKey) {
channelBuckets.delete(bucket.key);
bucket.key = nameBucketKey;
bucket.id = buildChannelTabId(nameBucketKey);
channelBuckets.set(nameBucketKey, bucket);
bucketKey = nameBucketKey;
}
if (!bucket) {
bucket = {
key: bucketKey,
@@ -208,6 +228,9 @@ export function buildChatTabModel({
bucket.label = labelInfo.label;
bucket.labelPriority = labelInfo.priority;
}
if (Number.isFinite(safeIndex)) {
bucket.index = Math.min(bucket.index ?? safeIndex, safeIndex);
}
}
bucket.entries.push({ ts, message });
@@ -425,15 +448,47 @@ export function normaliseChannelName(value) {
return null;
}
function buildChannelBucketKey(index, primaryChannelLabel) {
const safeIndex = Number.isFinite(index) ? Math.max(0, Math.trunc(index)) : 0;
if (safeIndex === 0 && primaryChannelLabel) {
function buildPrimaryBucketKey(primaryChannelLabel) {
if (primaryChannelLabel) {
const trimmed = primaryChannelLabel.trim();
if (trimmed.length > 0 && trimmed !== '0') {
return `0::${trimmed.toLowerCase()}`;
}
}
return String(safeIndex);
return '0';
}
function buildSecondaryNameBucketKey(labelInfo) {
const label = labelInfo?.label ?? null;
const priority = labelInfo?.priority ?? CHANNEL_LABEL_PRIORITY.INDEX;
if (priority !== CHANNEL_LABEL_PRIORITY.NAME || !label) {
return null;
}
const trimmedLabel = label.trim().toLowerCase();
if (!trimmedLabel.length) {
return null;
}
return `secondary::${trimmedLabel}`;
}
function findExistingBucketKeyByIndex(channelBuckets, targetIndex) {
if (!channelBuckets || !Number.isFinite(targetIndex) || targetIndex <= 0) {
return null;
}
const normalizedTarget = Math.trunc(targetIndex);
for (const [key, bucket] of channelBuckets.entries()) {
if (!bucket || !Number.isFinite(bucket.index)) {
continue;
}
if (Math.trunc(bucket.index) !== normalizedTarget) {
continue;
}
if (bucket.index === 0) {
continue;
}
return key;
}
return null;
}
function buildChannelTabId(bucketKey) {