fix(#497): apply Gemini review, one honest label and one equivalence rule

Two defects the adversarial review found, both verified before accepting.

1. `label` trimmed the name for display while the token stayed raw, so
   two contacts differing only by surrounding whitespace rendered as
   identical rows with no way to tell which one was about to be
   addressed. That hides the exact identity #497 exists to preserve.
   Display now defaults to the raw name.

2. Candidate deduplication used String.toLowerCase() while matching uses
   the ASCII fold. Verified empirically: 'É' and 'é' compare equal under
   toLowerCase and unequal under foldAscii, so of two real contacts one
   silently vanished from the mention list while remaining matchable.
   foldAscii is now public and is the single equivalence rule used for
   both dedup and matching.

Regression tests added for both.

Full suite 719 pass, analyze clean, format clean.

Agent: CalmBay (session d14220d9)
feat/483-button-buzzer-ui
Strycher 2 months ago
parent e52cb2a43f
commit 7aee4007b3

@ -6181,6 +6181,13 @@ class MeshCoreConnector extends ChangeNotifier {
/// the opposite on the device for the SAME message: a silent wrong answer,
/// not a visible failure. Owner decision 2026-07-31 (#475): both sides fold
/// ASCII only, so non-ASCII names compare case-sensitively. (#486)
/// Public so that anything deciding "is this the same name?" uses the SAME
/// equivalence as [mentionsName]. Deduplicating with `String.toLowerCase()`
/// instead silently drops candidates: `'É'` and `'é'` collapse under
/// `toLowerCase()` but stay distinct under this fold, so one of two real
/// contacts would disappear from the mention list while remaining matchable.
static String foldAscii(String s) => _foldAscii(s);
static String _foldAscii(String s) {
final units = s.codeUnits;
final folded = List<int>.filled(units.length, 0);

@ -1315,7 +1315,9 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
candidates.add(
MentionCandidate(name: name, recent: true, lastSeen: time),
);
seen.add(name.toLowerCase());
// Same equivalence as mentionsName, or a real contact silently
// vanishes from the list while still being matchable. (#497)
seen.add(MeshCoreConnector.foldAscii(name));
});
// Known contacts not already present as a recent sender. Same rule: the
@ -1323,7 +1325,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
for (final contact in connector.allContacts) {
final name = contact.name;
if (name.trim().isEmpty) continue;
if (!seen.add(name.toLowerCase())) continue;
if (!seen.add(MeshCoreConnector.foldAscii(name))) continue;
candidates.add(MentionCandidate(name: name, recent: false));
}
return candidates;

@ -26,8 +26,14 @@ class MentionCandidate {
final String? _label;
/// Display-only form. Safe to trim, because it never reaches the wire.
String get label => _label ?? name.trim();
/// Display form. Defaults to the raw name, NOT a trimmed copy.
///
/// Trimming here would render two contacts whose names differ only by
/// surrounding whitespace as identical rows, giving the user no way to tell
/// which one they are about to address, while the inserted tokens differ.
/// That hides exactly the identity #497 exists to preserve, so the display
/// stays honest to what will be sent.
String get label => _label ?? name;
const MentionCandidate({
required this.name,

@ -97,4 +97,27 @@ void main() {
},
);
});
group('foldAscii is the single equivalence rule (Gemini review, #497)', () {
test('ASCII case collapses, non-ASCII case does not', () {
// Dedup used String.toLowerCase(), which collapses these; matching uses
// foldAscii, which keeps them distinct. One of two real contacts would
// have silently vanished from the mention list while staying matchable.
expect('É'.toLowerCase() == 'é'.toLowerCase(), isTrue);
expect(
MeshCoreConnector.foldAscii('É') == MeshCoreConnector.foldAscii('é'),
isFalse,
);
expect(
MeshCoreConnector.foldAscii('Ben') ==
MeshCoreConnector.foldAscii('ben'),
isTrue,
);
});
test('folding leaves non-ASCII bytes untouched', () {
expect(MeshCoreConnector.foldAscii('Érik'), equals('Érik'));
expect(MeshCoreConnector.foldAscii('BEN'), equals('ben'));
});
});
}

Loading…
Cancel
Save

Powered by TurnKey Linux.