From 7aee4007b3da24bc3e0a84fb9b26fd1a21c293fd Mon Sep 17 00:00:00 2001 From: Strycher Date: Sat, 1 Aug 2026 17:38:32 -0400 Subject: [PATCH] fix(#497): apply Gemini review, one honest label and one equivalence rule MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- lib/connector/meshcore_connector.dart | 7 +++++++ lib/screens/channel_chat_screen.dart | 6 ++++-- lib/widgets/mention_autocomplete.dart | 10 ++++++++-- test/connector/mention_contract_test.dart | 23 +++++++++++++++++++++++ 4 files changed, 42 insertions(+), 4 deletions(-) diff --git a/lib/connector/meshcore_connector.dart b/lib/connector/meshcore_connector.dart index da62d3e..a463f25 100644 --- a/lib/connector/meshcore_connector.dart +++ b/lib/connector/meshcore_connector.dart @@ -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.filled(units.length, 0); diff --git a/lib/screens/channel_chat_screen.dart b/lib/screens/channel_chat_screen.dart index 1d7aa58..9e2bcd2 100644 --- a/lib/screens/channel_chat_screen.dart +++ b/lib/screens/channel_chat_screen.dart @@ -1315,7 +1315,9 @@ class _ChannelChatScreenState extends State { 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 { 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; diff --git a/lib/widgets/mention_autocomplete.dart b/lib/widgets/mention_autocomplete.dart index 4fc03d2..95af33f 100644 --- a/lib/widgets/mention_autocomplete.dart +++ b/lib/widgets/mention_autocomplete.dart @@ -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, diff --git a/test/connector/mention_contract_test.dart b/test/connector/mention_contract_test.dart index dc6ba00..8857298 100644 --- a/test/connector/mention_contract_test.dart +++ b/test/connector/mention_contract_test.dart @@ -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')); + }); + }); }