] compacted message rows safe for API responses; a
+ # row whose sender was matched by name carries +sender_verified: false+
+ # (see {DataProcessing#meshcore_sender_name_attributed?}).
def query_messages(limit, node_ref: nil, include_encrypted: false, since: 0, before: nil, protocol: nil)
limit = coerce_query_limit(limit)
now = Time.now.to_i
@@ -119,6 +121,10 @@ module PotatoMesh
end
r["node_id"] = node_id if node_id
+ # A MeshCore channel sender is matched by name, never by an id the
+ # packet carries: flag it, and leave the key off every other row as
+ # the node API does with +synthetic+ (SPEC SV2).
+ r["sender_verified"] = false if meshcore_sender_name_attributed?(r)
if PotatoMesh::Config.debug? && (r["from_id"].nil? || r["from_id"].to_s.strip.empty?)
debug_log(
diff --git a/web/public/assets/js/app/__tests__/chat-sender-marker.test.js b/web/public/assets/js/app/__tests__/chat-sender-marker.test.js
new file mode 100644
index 00000000..63435c63
--- /dev/null
+++ b/web/public/assets/js/app/__tests__/chat-sender-marker.test.js
@@ -0,0 +1,173 @@
+/*
+ * Copyright © 2025-26 l5yth & contributors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { readFileSync } from 'node:fs';
+import { fileURLToPath } from 'node:url';
+
+import {
+ SENDER_UNVERIFIED_LABEL,
+ SENDER_UNVERIFIED_TEXT,
+ formatChatSenderMarker,
+ isSenderUnverified,
+} from '../chat-sender-marker.js';
+import { renderMessages } from '../node-page/messages.js';
+import { withApp, innerHtml } from './main-app-test-helpers.js';
+
+/** A MeshCore channel line as ``GET /api/messages`` serves it (SPEC SV2). */
+const NAMED = Object.freeze({
+ id: 401,
+ text: 'Alice: see you at the hut',
+ rx_time: 1_791_280_000,
+ protocol: 'meshcore',
+ to_id: '^all',
+ from_id: '!a11ce001',
+ node_id: '!a11ce001',
+ hops: 2,
+ sender_verified: false,
+});
+
+/** A Meshtastic line: its sender comes from the packet, so no flag. */
+const MESHTASTIC = Object.freeze({
+ id: 402,
+ text: 'Alice: hello',
+ rx_time: 1_791_280_000,
+ protocol: 'meshtastic',
+ to_id: '^all',
+ from_id: '!0badc0de',
+ node_id: '!0badc0de',
+ hops: 1,
+});
+
+/** The hydrated sender node the dashboard attaches to a line. */
+const ALICE_NODE = Object.freeze({ short_name: 'ALCE', role: 'COMPANION', protocol: 'meshcore' });
+
+const MARKER = ' unverified';
+
+/**
+ * Badge renderer standing in for the dashboard's ``renderShortHtml``.
+ *
+ * @param {string} short Short name.
+ * @returns {string} Badge HTML.
+ */
+function badge(short) {
+ return `${short}`;
+}
+
+// --- the module ---
+
+test('the marker shows "unverified", titled "Sender not verified"', () => {
+ assert.equal(SENDER_UNVERIFIED_TEXT, 'unverified');
+ assert.equal(SENDER_UNVERIFIED_LABEL, 'Sender not verified');
+});
+
+test('isSenderUnverified is true only for sender_verified false or a badge named from the text', () => {
+ assert.equal(isSenderUnverified(NAMED), true);
+ assert.equal(isSenderUnverified(MESHTASTIC), false);
+ for (const value of [undefined, null, true, 0, '', 'false']) {
+ assert.equal(isSenderUnverified({ ...NAMED, sender_verified: value }), false, `${String(value)} is not the flag`);
+ }
+ for (const value of [null, undefined, 'x', 42]) {
+ assert.equal(isSenderUnverified(value), false, `${String(value)} is no message`);
+ }
+ assert.equal(isSenderUnverified({ ...MESHTASTIC }, { senderFromText: true }), true);
+ assert.equal(isSenderUnverified(MESHTASTIC, { senderFromText: false }), false);
+ assert.equal(isSenderUnverified(MESHTASTIC, {}), false);
+});
+
+test('formatChatSenderMarker renders a titled "unverified" chip with a leading space', () => {
+ assert.equal(formatChatSenderMarker(NAMED), MARKER);
+ assert.equal(formatChatSenderMarker(MESHTASTIC, { senderFromText: true }), MARKER);
+});
+
+test('formatChatSenderMarker renders nothing for a sender from an id the packet carries', () => {
+ assert.equal(formatChatSenderMarker(MESHTASTIC), '');
+ assert.equal(formatChatSenderMarker({ ...NAMED, sender_verified: undefined }), '');
+ assert.equal(formatChatSenderMarker(null), '');
+});
+
+// --- the dashboard chat line ---
+
+test('the dashboard chat line marks the sender after the badge and before the route chip, outside the prefix', () => {
+ withApp((t) => {
+ const html = innerHtml(t.createMessageChatEntry({ ...NAMED, node: ALICE_NODE }));
+ const marker = html.indexOf(MARKER);
+ assert.ok(marker > html.indexOf('short-name'), 'marker follows the sender badge');
+ assert.ok(marker < html.indexOf('class="chat-route-chip"'), 'marker precedes the route chip');
+ assert.ok(marker < html.indexOf('see you at the hut'), 'marker precedes the text');
+ assert.match(html, /^\[[^\]]*\]\[[^\]]*\]\[[^\]]*\] /, 'the bracketed 19ch prefix is unchanged');
+ });
+});
+
+test('the dashboard chat line has no marker for a Meshtastic sender or an unflagged MeshCore line', () => {
+ withApp((t) => {
+ const meshtastic = innerHtml(t.createMessageChatEntry({ ...MESHTASTIC, node: { short_name: 'BOB' } }));
+ assert.ok(!meshtastic.includes('chat-sender-unverified'), meshtastic);
+ const unflagged = innerHtml(
+ t.createMessageChatEntry({ ...NAMED, sender_verified: undefined, node: ALICE_NODE }),
+ );
+ assert.ok(!unflagged.includes('chat-sender-unverified'), unflagged);
+ });
+});
+
+test('the dashboard marks a MeshCore channel line whose badge it named from the text itself', () => {
+ withApp((t) => {
+ // An ingestor before 0.6.0 posts a channel line with no sender id; the
+ // renderer then names the badge from the "Alice:" prefix on its own.
+ const html = innerHtml(t.createMessageChatEntry({
+ id: 403,
+ text: 'Alice: no sender id',
+ rx_time: 1_791_280_000,
+ protocol: 'meshcore',
+ to_id: '^all',
+ node: null,
+ }));
+ assert.ok(html.indexOf(MARKER) > html.indexOf('short-name'), html);
+ });
+});
+
+// --- the node page ---
+
+test('the node page marks the sender after the badge and before the route chip', () => {
+ const node = { shortName: 'ALCE', longName: 'Alice', role: 'COMPANION', nodeId: '!a11ce001', protocol: 'meshcore' };
+ const html = renderMessages([{ ...NAMED, node: ALICE_NODE }], badge, node);
+ const marker = html.indexOf(MARKER);
+ assert.ok(marker > html.indexOf('short-name'), 'marker follows the sender badge');
+ assert.ok(marker < html.indexOf('class="chat-route-chip"'), 'marker precedes the route chip');
+ assert.ok(marker < html.indexOf('see you at the hut'), 'marker precedes the text');
+ // The node page names a MeshCore channel badge from the text when the line
+ // carries no hydrated node; that badge is marked too.
+ const fromText = renderMessages([{ ...NAMED, sender_verified: undefined }], badge, node);
+ assert.ok(fromText.indexOf(MARKER) > fromText.indexOf('short-name'), fromText);
+});
+
+test('the node page has no marker for a Meshtastic sender', () => {
+ const node = { shortName: 'BOB', longName: 'Bob', role: 'CLIENT', nodeId: '!0badc0de', protocol: 'meshtastic' };
+ const html = renderMessages([{ ...MESHTASTIC, node: { short_name: 'BOB' } }], badge, node);
+ assert.ok(html.includes('short-name') && !html.includes('chat-sender-unverified'), html);
+});
+
+// --- the stylesheet ---
+
+test('base.css keeps the marker out of the hanging indent and lets the line wrap', () => {
+ const cssPath = fileURLToPath(new URL('../../../styles/base.css', import.meta.url));
+ const rule = readFileSync(cssPath, 'utf8').match(/\.chat-sender-unverified \{[^}]*\}/);
+ assert.ok(rule, '.chat-sender-unverified rule exists');
+ assert.match(rule[0], /text-indent: 0;/);
+ assert.match(rule[0], /display: inline-block;/);
+ assert.doesNotMatch(rule[0], /white-space: nowrap/, 'the line may wrap (LC3)');
+});
diff --git a/web/public/assets/js/app/chat-sender-marker.js b/web/public/assets/js/app/chat-sender-marker.js
new file mode 100644
index 00000000..7b649922
--- /dev/null
+++ b/web/public/assets/js/app/chat-sender-marker.js
@@ -0,0 +1,75 @@
+/*
+ * Copyright © 2025-26 l5yth & contributors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/**
+ * Unverified-sender marker for chat lines (SPEC SV3).
+ *
+ * A MeshCore channel message names its sender only in the ``Name:`` prefix of
+ * its text, which no key backs. The web app serves such a row with
+ * ``sender_verified: false`` (SPEC SV2), and the dashboard chat and the node
+ * page mark the line with an "unverified" chip after the sender badge, titled
+ * "Sender not verified". Visible text, not a glyph: a tooltip never shows on a
+ * touch screen, and ``?`` already marks a badge whose node is unknown. A line
+ * whose sender comes from an id the packet carries gets no marker.
+ *
+ * @module chat-sender-marker
+ */
+
+/**
+ * Tooltip and accessible description of the marker.
+ *
+ * @type {string}
+ */
+export const SENDER_UNVERIFIED_LABEL = 'Sender not verified';
+
+/**
+ * Visible text of the marker.
+ *
+ * @type {string}
+ */
+export const SENDER_UNVERIFIED_TEXT = 'unverified';
+
+/**
+ * Whether a chat line's sender was attributed by name rather than by an id
+ * the packet carries.
+ *
+ * @param {?Object} message Message payload.
+ * @param {{ senderFromText?: boolean }} [options] ``senderFromText``: the
+ * renderer named the sender badge from the line's ``Name:`` prefix itself,
+ * which it does for a MeshCore channel line that came with no sender id
+ * (posted by an ingestor before 0.6.0).
+ * @returns {boolean} ``true`` when the row carries ``sender_verified: false``
+ * or the badge was named from the text.
+ */
+export function isSenderUnverified(message, { senderFromText = false } = {}) {
+ if (senderFromText === true) return true;
+ return message != null && typeof message === 'object' && message.sender_verified === false;
+}
+
+/**
+ * Render the unverified-sender marker of a chat line, placed right after the
+ * sender badge and outside the 19ch prefix (FU9).
+ *
+ * @param {?Object} message Message payload.
+ * @param {{ senderFromText?: boolean }} [options] See
+ * {@link isSenderUnverified}.
+ * @returns {string} HTML for the marker with a leading space, or ``''`` for a
+ * sender that came from an id the packet carries.
+ */
+export function formatChatSenderMarker(message, options = {}) {
+ if (!isSenderUnverified(message, options)) return '';
+ return ` ${SENDER_UNVERIFIED_TEXT}`;
+}
diff --git a/web/public/assets/js/app/main.js b/web/public/assets/js/app/main.js
index 56babbcc..b6e852ac 100644
--- a/web/public/assets/js/app/main.js
+++ b/web/public/assets/js/app/main.js
@@ -92,6 +92,7 @@ import {
formatChatPresetTag
} from './chat-format.js';
import { formatChatRouteChip } from './chat-route-chip.js';
+import { formatChatSenderMarker } from './chat-sender-marker.js';
import { initializeInstanceSelector } from './instance-selector.js';
import { initializeMobileMenu } from './mobile-menu.js';
import { MESSAGE_LIMIT, normaliseMessageLimit } from './message-limit.js';
@@ -3619,8 +3620,9 @@ export function initializeApp(config) {
// Sender badge: prefer the ingestor-hydrated node; fall back to the
// MeshCore name-based lookup performed inside the shared renderer for
// channel messages whose sender wasn't yet known to the contacts roster.
+ const senderFromText = !m.node && Boolean(meshcoreSenderNode);
let short;
- if (!m.node && meshcoreSenderNode) {
+ if (senderFromText) {
short = renderShortHtml(
meshcoreSenderNode.short_name ?? meshcoreSenderNode.shortName,
meshcoreSenderNode.role,
@@ -3636,11 +3638,13 @@ export function initializeApp(config) {
frequency: metadata.frequency ? escapeHtml(metadata.frequency) : ''
});
const presetTag = formatChatPresetTag({ presetCode: metadata.presetCode });
- // Route chip (hops + flood scope) after the sender badge (SPEC SC7).
+ // A sender named only by the text gets a marker right after its badge
+ // (SPEC SV3), then the route chip (hops + flood scope, SPEC SC7).
+ const senderMarker = formatChatSenderMarker(m, { senderFromText });
const routeChip = formatChatRouteChip(m);
return {
className: 'chat-entry-msg',
- html: `${prefix}${presetTag} ${nodeProtocolPrefix}${short}${routeChip} ${text}`
+ html: `${prefix}${presetTag} ${nodeProtocolPrefix}${short}${senderMarker}${routeChip} ${text}`
};
}
diff --git a/web/public/assets/js/app/node-page/messages.js b/web/public/assets/js/app/node-page/messages.js
index 94fa0ea9..8c5be82a 100644
--- a/web/public/assets/js/app/node-page/messages.js
+++ b/web/public/assets/js/app/node-page/messages.js
@@ -29,6 +29,7 @@ import {
formatChatPresetTag,
} from '../chat-format.js';
import { formatChatRouteChip } from '../chat-route-chip.js';
+import { formatChatSenderMarker } from '../chat-sender-marker.js';
import { buildMessageIndex } from '../message-replies.js';
import { renderChatEntryContent } from '../chat-entry-renderer.js';
import { formatMessageTimestamp } from '../node-page-charts.js';
@@ -209,8 +210,13 @@ export function renderMessages(messages, renderShortHtml, node, globalNodesById
?? null,
source: senderNode ?? fallbackNode?.rawSources?.node ?? fallbackNode,
});
+ // A sender named only by the text gets a marker right after its badge
+ // (SPEC SV3).
+ const senderMarker = formatChatSenderMarker(message, {
+ senderFromText: senderNode !== null && senderNode === meshcoreSenderNode,
+ });
- return `${prefix}${presetTag}${channelTag} ${protocolIconHtml}${badgeHtml}${routeChip} ${bodyHtml}
`;
+ return `${prefix}${presetTag}${channelTag} ${protocolIconHtml}${badgeHtml}${senderMarker}${routeChip} ${bodyHtml}
`;
})
.filter(item => item != null);
if (items.length === 0) return '';
diff --git a/web/public/assets/styles/base.css b/web/public/assets/styles/base.css
index 5a3e429c..8040d8de 100644
--- a/web/public/assets/styles/base.css
+++ b/web/public/assets/styles/base.css
@@ -3254,6 +3254,22 @@ button.legend-item[aria-pressed="true"] {
font-size: 0.85em;
}
+/* Unverified-sender marker (SPEC SV3): an "unverified" chip after the sender
+ badge of a line whose sender is named only by its text, outside the 19ch
+ prefix (FU9). It resets the inherited hanging indent like the route chip,
+ and has no nowrap, so the line still wraps on a narrow panel (LC3). The
+ dashed border sets it apart from the route chip. */
+.chat-sender-unverified {
+ display: inline-block;
+ text-indent: 0;
+ padding: 0 4px;
+ border: 1px dashed var(--muted);
+ border-radius: 4px;
+ color: var(--muted);
+ font-size: 0.85em;
+ cursor: help;
+}
+
/* Chat date dividers separate days spatially (UX11, D-040). */
.chat-entry-date {
border-top: 1px solid var(--line);
diff --git a/web/spec/meshcore_sender_verified_spec.rb b/web/spec/meshcore_sender_verified_spec.rb
new file mode 100644
index 00000000..0ae749ed
--- /dev/null
+++ b/web/spec/meshcore_sender_verified_spec.rb
@@ -0,0 +1,188 @@
+# Copyright © 2025-26 l5yth & contributors
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+# frozen_string_literal: true
+
+require "spec_helper"
+require "json"
+require_relative "support/data_processing_harness"
+
+# MeshCore sender-verified flag (SPEC SV1/SV2; ACCEPTANCE SV-A1). A MeshCore
+# channel message carries no sender key, only the typed "Name:" prefix of its
+# text: the ingestor matches that name against its roster or derives an id
+# from it, and the web app may re-map the id by name again (GN3, the
+# placeholder merge). GET /api/messages and GET /api/messages/:id serve such a
+# row with `sender_verified: false` and omit the key on every other row.
+RSpec.describe "MeshCore sender-verified flag" do
+ include_context "with isolated db"
+ include MeshcoreNodeSeeds
+
+ let(:app) { Sinatra::Application }
+ let(:harness_class) { DataProcessingHarness.build }
+
+ subject(:dp) { harness_class.new }
+
+ let(:day) { 86_400 }
+ # Alice: a keyed MeshCore contact, live (heard three days ago).
+ let(:alice) { "!a11ce001" }
+ # The id the ingestor derives from the name "Alice" when its roster lacks
+ # her (the SHA-256 prefix of +_derive_synthetic_node_id+).
+ let(:alice_derived_id) { "!3bc51062" }
+ # The MeshCore host whose ingestor posts the lines.
+ let(:host) { "!634069bc" }
+ let(:api_token) { "sender-verified-spec-token" }
+ let(:auth_headers) do
+ { "CONTENT_TYPE" => "application/json", "HTTP_AUTHORIZATION" => "Bearer #{api_token}" }
+ end
+
+ before do
+ @original_token = ENV["API_TOKEN"]
+ ENV["API_TOKEN"] = api_token
+ PotatoMesh::App::ApiCache.invalidate_all
+ end
+
+ after do
+ @original_token.nil? ? ENV.delete("API_TOKEN") : ENV["API_TOKEN"] = @original_token
+ PotatoMesh::App::ApiCache.invalidate_all
+ end
+
+ # A MeshCore channel message as the ingestor posts it.
+ #
+ # @param id [Integer] message id.
+ # @param from_id [String, nil] sender id; nil leaves the key out.
+ # @param text [String] message text.
+ # @return [Hash] POST /api/messages payload.
+ def channel_message(id, from_id, text)
+ {
+ "id" => id, "rx_time" => now - 60, "from_id" => from_id, "to_id" => "^all",
+ "channel" => 0, "channel_name" => "Public", "text" => text,
+ "portnum" => "TEXT_MESSAGE_APP", "protocol" => "meshcore", "ingestor" => host,
+ }.compact
+ end
+
+ # POST one message through the authenticated ingest route.
+ #
+ # @param payload [Hash] message payload.
+ # @return [void]
+ def post_message(payload)
+ post "/api/messages", payload.to_json, auth_headers
+ expect(last_response.status).to eq(201)
+ end
+
+ # Fetch +path+ and return the served row with +id+.
+ #
+ # @param path [String] GET route.
+ # @param id [Integer] message id.
+ # @return [Hash, nil] the served row.
+ def served_row(path, id)
+ get path
+ expect(last_response).to be_ok
+ JSON.parse(last_response.body).find { |row| row["id"] == id }
+ end
+
+ # Store a keyed MeshCore contact named "Alice".
+ #
+ # @param node_id [String] canonical node id.
+ # @param key_byte [String] two hex digits repeated into the public key.
+ # @param heard [Integer] unix seconds of the contact record.
+ # @return [void]
+ def seed_alice(node_id, key_byte, heard)
+ db = open_db
+ seed_keyed_node(db, node_id, "Alice", key_byte, heard)
+ ensure
+ db&.close
+ end
+
+ describe "a MeshCore channel line whose sender is named only in its text" do
+ before { seed_alice(alice, "a1", now - 3 * day) }
+
+ # Both routes serve the line as Alice's, flagged.
+ #
+ # @param id [Integer] message id.
+ # @return [void]
+ def expect_flagged_as_alice(id)
+ [served_row("/api/messages", id), served_row("/api/messages/#{alice}", id)].each do |row|
+ expect(row).to include("node_id" => alice, "sender_verified" => false)
+ end
+ end
+
+ it "is served with sender_verified false when posted with the key id the roster matched" do
+ post_message(channel_message(101, alice, "Alice: see you at the hut"))
+ expect_flagged_as_alice(101)
+ end
+
+ it "is served with sender_verified false when posted with the name-derived id" do
+ post_message(channel_message(102, alice_derived_id, "Alice: unrostered"))
+ expect_flagged_as_alice(102)
+ end
+
+ it "is served with sender_verified false when posted with a positively stale same-name key" do
+ seed_alice("!a11ce0ff", "af", now - 40 * day)
+ post_message(channel_message(103, "!a11ce0ff", "Alice: via a stale roster"))
+ expect_flagged_as_alice(103)
+ end
+ end
+
+ describe "a line whose sender comes from an id the packet carries" do
+ it "a Meshtastic message has no sender_verified key on either route" do
+ post_message(
+ "id" => 201, "rx_time" => now - 60, "from_id" => "!0badc0de", "to_id" => "^all",
+ "channel" => 0, "text" => "Alice: hello", "portnum" => "TEXT_MESSAGE_APP", "protocol" => "meshtastic",
+ )
+ [served_row("/api/messages", 201), served_row("/api/messages/!0badc0de", 201)].each do |row|
+ expect(row).to include("node_id" => "!0badc0de")
+ expect(row).not_to have_key("sender_verified")
+ end
+ end
+
+ it "a MeshCore direct message has no sender_verified key on either route" do
+ seed_alice(alice, "a1", now - 3 * day)
+ post_message(channel_message(202, alice, "hello from my own key").merge("to_id" => host).except("channel_name"))
+ [served_row("/api/messages", 202), served_row("/api/messages/#{alice}", 202)].each do |row|
+ expect(row).to include("node_id" => alice, "to_id" => host)
+ expect(row).not_to have_key("sender_verified")
+ end
+ end
+ end
+
+ describe "a MeshCore channel line without a sender" do
+ it "has no sender_verified key" do
+ post_message(channel_message(301, nil, "no sender prefix"))
+ row = served_row("/api/messages", 301)
+ expect(row).not_to have_key("from_id")
+ expect(row).not_to have_key("sender_verified")
+ end
+ end
+
+ describe "#meshcore_sender_name_attributed?" do
+ let(:channel_row) { { "protocol" => "meshcore", "to_id" => "^all", "from_id" => alice } }
+
+ it "is true for a MeshCore channel row with a sender" do
+ expect(dp.meshcore_sender_name_attributed?(channel_row)).to be(true)
+ end
+
+ {
+ "another protocol" => { "protocol" => "meshtastic" },
+ "no protocol" => { "protocol" => nil },
+ "a direct message" => { "to_id" => "!634069bc" },
+ "no recipient" => { "to_id" => nil },
+ "no sender" => { "from_id" => nil },
+ "a blank sender" => { "from_id" => " " },
+ }.each do |label, overrides|
+ it "is false for #{label}" do
+ expect(dp.meshcore_sender_name_attributed?(channel_row.merge(overrides))).to be(false)
+ end
+ end
+ end
+end