feat: работа с текстом
This commit is contained in:
@@ -0,0 +1,187 @@
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:komet/core/utils/text_format.dart';
|
||||
import 'package:komet/models/contact_info.dart';
|
||||
import 'package:komet/frontend/screens/chats/chat/mention_panel_controller.dart';
|
||||
import 'package:komet/frontend/widgets/rich_message_controller.dart';
|
||||
|
||||
void main() {
|
||||
group('mentionQueryAt', () {
|
||||
test('detects a bare @ at the start', () {
|
||||
final q = mentionQueryAt('@', 1)!;
|
||||
expect(q.start, 0);
|
||||
expect(q.end, 1);
|
||||
expect(q.text, '');
|
||||
});
|
||||
|
||||
test('detects a query after a space', () {
|
||||
final q = mentionQueryAt('hi @ал', 6)!;
|
||||
expect(q.start, 3);
|
||||
expect(q.end, 6);
|
||||
expect(q.text, 'ал');
|
||||
});
|
||||
|
||||
test('ignores an @ glued to a preceding word', () {
|
||||
expect(mentionQueryAt('mail@ya', 7), isNull);
|
||||
});
|
||||
|
||||
test('ignores a token that already contains a space', () {
|
||||
expect(mentionQueryAt('@ал ексей', 9), isNull);
|
||||
});
|
||||
|
||||
test('ignores text without an @ before the caret', () {
|
||||
expect(mentionQueryAt('привет', 6), isNull);
|
||||
});
|
||||
});
|
||||
|
||||
group('RichMessageController mentions', () {
|
||||
test('insertMention replaces the token and emits USER_MENTION', () {
|
||||
final c = RichMessageController();
|
||||
c.value = const TextEditingValue(
|
||||
text: '@ал',
|
||||
selection: TextSelection.collapsed(offset: 3),
|
||||
);
|
||||
final query = mentionQueryAt(c.text, 3)!;
|
||||
c.insertMention(
|
||||
userId: 3079465,
|
||||
name: 'Алексей Поляков',
|
||||
start: query.start,
|
||||
end: query.end,
|
||||
);
|
||||
c.value = TextEditingValue(
|
||||
text: '${c.text}test',
|
||||
selection: TextSelection.collapsed(offset: c.text.length + 4),
|
||||
);
|
||||
|
||||
final content = c.buildContent();
|
||||
expect(content.text, 'Алексей Поляков test');
|
||||
expect(content.elements, [
|
||||
{'type': 'USER_MENTION', 'from': 0, 'length': 15, 'entityId': 3079465},
|
||||
]);
|
||||
});
|
||||
|
||||
test('editing inside a mention drops it', () {
|
||||
final c = RichMessageController();
|
||||
c.value = const TextEditingValue(
|
||||
text: '@a',
|
||||
selection: TextSelection.collapsed(offset: 2),
|
||||
);
|
||||
c.insertMention(userId: 42, name: 'Иван', start: 0, end: 2);
|
||||
expect(c.buildContent().elements, hasLength(1));
|
||||
|
||||
c.value = const TextEditingValue(
|
||||
text: 'Ив ',
|
||||
selection: TextSelection.collapsed(offset: 2),
|
||||
);
|
||||
expect(c.buildContent().elements, isEmpty);
|
||||
});
|
||||
|
||||
test('text typed before a mention shifts its offset', () {
|
||||
final c = RichMessageController();
|
||||
c.value = const TextEditingValue(
|
||||
text: '@a',
|
||||
selection: TextSelection.collapsed(offset: 2),
|
||||
);
|
||||
c.insertMention(userId: 42, name: 'Иван', start: 0, end: 2);
|
||||
c.value = const TextEditingValue(
|
||||
text: 'эй, Иван ',
|
||||
selection: TextSelection.collapsed(offset: 4),
|
||||
);
|
||||
|
||||
final element = c.buildContent().elements.single;
|
||||
expect(element['from'], 4);
|
||||
expect(element['length'], 4);
|
||||
expect(element['entityId'], 42);
|
||||
});
|
||||
|
||||
test('setFormatRanges restores mentions for editing', () {
|
||||
final c = RichMessageController(text: 'Иван привет');
|
||||
c.setFormatRanges(const [
|
||||
FormatRange(
|
||||
format: TextFormat.userMention,
|
||||
start: 0,
|
||||
length: 4,
|
||||
entityId: 42,
|
||||
),
|
||||
]);
|
||||
|
||||
expect(c.buildContent().elements, [
|
||||
{'type': 'USER_MENTION', 'from': 0, 'length': 4, 'entityId': 42},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
group('ContactInfo names', () {
|
||||
ContactInfo info(List<Map<String, dynamic>> names) =>
|
||||
ContactInfo.fromMap({'id': 1, 'names': names});
|
||||
|
||||
test('full name joins first and last, not the short name field', () {
|
||||
final contact = info([
|
||||
{
|
||||
'name': 'Светлана',
|
||||
'firstName': 'Светлана',
|
||||
'lastName': 'Михайловна',
|
||||
'type': 'CUSTOM',
|
||||
},
|
||||
{
|
||||
'name': 'Светлана',
|
||||
'firstName': 'Светлана',
|
||||
'lastName': '',
|
||||
'type': 'ONEME',
|
||||
},
|
||||
]);
|
||||
|
||||
expect(contact.fullName, 'Светлана Михайловна');
|
||||
expect(contact.isSavedContact, isTrue);
|
||||
});
|
||||
|
||||
test('a non-contact falls back to the ONEME name', () {
|
||||
final contact = info([
|
||||
{
|
||||
'name': 'Алексей',
|
||||
'firstName': 'Алексей',
|
||||
'lastName': 'Поляков',
|
||||
'type': 'ONEME',
|
||||
},
|
||||
]);
|
||||
|
||||
expect(contact.fullName, 'Алексей Поляков');
|
||||
expect(contact.isSavedContact, isFalse);
|
||||
});
|
||||
|
||||
test('a custom name wins over the oneme one', () {
|
||||
final contact = info([
|
||||
{'firstName': 'Лёша', 'lastName': 'сосед', 'type': 'CUSTOM'},
|
||||
{'firstName': 'Алексей', 'lastName': 'Поляков', 'type': 'ONEME'},
|
||||
]);
|
||||
|
||||
expect(contact.fullName, 'Лёша сосед');
|
||||
});
|
||||
});
|
||||
|
||||
group('parseFormatElements', () {
|
||||
test('reads a server USER_MENTION without an explicit from', () {
|
||||
final ranges = parseFormatElements([
|
||||
{'entityId': 3079465, 'type': 'USER_MENTION', 'length': 15},
|
||||
]);
|
||||
expect(ranges.single.format, TextFormat.userMention);
|
||||
expect(ranges.single.start, 0);
|
||||
expect(ranges.single.length, 15);
|
||||
expect(ranges.single.entityId, 3079465);
|
||||
});
|
||||
|
||||
test('segmentizeFormats carries the mention id onto its segment', () {
|
||||
final segments = segmentizeFormats('Алексей Поляков test', const [
|
||||
FormatRange(
|
||||
format: TextFormat.userMention,
|
||||
start: 0,
|
||||
length: 15,
|
||||
entityId: 3079465,
|
||||
),
|
||||
]);
|
||||
expect(segments.first.mentionId, 3079465);
|
||||
expect(segments.last.mentionId, isNull);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:komet/backend/modules/messages.dart';
|
||||
import 'package:komet/frontend/widgets/message_bubble.dart';
|
||||
import 'package:komet/l10n/app_localizations.dart';
|
||||
|
||||
const int _me = 1;
|
||||
const int _peer = 7;
|
||||
|
||||
CachedMessage _message({
|
||||
required String text,
|
||||
bool withReply = false,
|
||||
int senderId = _peer,
|
||||
}) => CachedMessage(
|
||||
id: '1',
|
||||
accountId: _me,
|
||||
chatId: 2,
|
||||
senderId: senderId,
|
||||
text: text,
|
||||
time: DateTime(2026, 1, 1, 5, 46).millisecondsSinceEpoch,
|
||||
status: 'sent',
|
||||
payload: withReply
|
||||
? {
|
||||
'link': {
|
||||
'type': 'REPLY',
|
||||
'message': {
|
||||
'id': '9',
|
||||
'sender': _me,
|
||||
'text': 'Алексей Поляков написал очень длинный ответ',
|
||||
'time': 0,
|
||||
'attaches': [],
|
||||
},
|
||||
},
|
||||
}
|
||||
: null,
|
||||
);
|
||||
|
||||
Future<void> _pumpBubble(WidgetTester tester, CachedMessage message) async {
|
||||
tester.view.physicalSize = const Size(1080, 2400);
|
||||
tester.view.devicePixelRatio = 2.5;
|
||||
addTearDown(tester.view.reset);
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
locale: const Locale('ru'),
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
home: Scaffold(
|
||||
body: Align(
|
||||
alignment: Alignment.topLeft,
|
||||
child: MessageBubble(
|
||||
message: message,
|
||||
isMe: false,
|
||||
myId: _me,
|
||||
chatType: 'CHAT',
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
}
|
||||
|
||||
Rect _rectOf(WidgetTester tester, Finder finder) {
|
||||
final size = tester.getSize(finder);
|
||||
final topLeft = tester.getTopLeft(finder);
|
||||
return topLeft & size;
|
||||
}
|
||||
|
||||
void main() {
|
||||
setUp(() => ContactCache.put(_peer, 'Алексей Поляков123'));
|
||||
|
||||
testWidgets('a long sender name pushes the clock to the bubble edge', (
|
||||
tester,
|
||||
) async {
|
||||
await _pumpBubble(tester, _message(text: 'нет'));
|
||||
|
||||
final header = _rectOf(tester, find.text('Алексей Поляков123'));
|
||||
final clock = _rectOf(tester, find.textContaining('05:46'));
|
||||
final body = _rectOf(tester, find.text('нет'));
|
||||
|
||||
expect(header.width, greaterThan(body.width + clock.width));
|
||||
expect(clock.right, closeTo(header.right, 1));
|
||||
});
|
||||
|
||||
testWidgets('the reply quote fills the width the sender name opened up', (
|
||||
tester,
|
||||
) async {
|
||||
await _pumpBubble(tester, _message(text: 'нет', withReply: true));
|
||||
|
||||
final header = _rectOf(tester, find.text('Алексей Поляков123'));
|
||||
final label = _rectOf(tester, find.text('Вы'));
|
||||
final quote = _rectOf(
|
||||
tester,
|
||||
find
|
||||
.ancestor(of: find.text('Вы'), matching: find.byType(Container))
|
||||
.first,
|
||||
);
|
||||
final clock = _rectOf(tester, find.textContaining('05:46'));
|
||||
|
||||
expect(quote.right, greaterThan(label.right));
|
||||
expect(quote.right, closeTo(header.right, 1));
|
||||
expect(clock.right, closeTo(header.right, 1));
|
||||
});
|
||||
|
||||
testWidgets('a bubble without a header or reply still hugs its text', (
|
||||
tester,
|
||||
) async {
|
||||
await _pumpBubble(tester, _message(text: 'нет', senderId: 404));
|
||||
|
||||
final clock = _rectOf(tester, find.textContaining('05:46'));
|
||||
final body = _rectOf(tester, find.text('нет'));
|
||||
|
||||
expect(clock.left, closeTo(body.right + 8, 1));
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:komet/core/utils/text_entities.dart';
|
||||
|
||||
void main() {
|
||||
group('detectTextEntities', () {
|
||||
test('finds a phone and a card in one message', () {
|
||||
final found = detectTextEntities('+79231234567 тест 2200123456789019');
|
||||
|
||||
expect(found, hasLength(2));
|
||||
expect(found.first.kind, TextEntityKind.phone);
|
||||
expect(found.first.value, '+79231234567');
|
||||
expect(found.last.kind, TextEntityKind.card);
|
||||
expect(found.last.value, '2200123456789019');
|
||||
});
|
||||
|
||||
test('finds a bare russian phone and a spaced card', () {
|
||||
final found = detectTextEntities('89231234567 и 2200 1234 5678 9019');
|
||||
expect(found.map((e) => e.kind), [
|
||||
TextEntityKind.phone,
|
||||
TextEntityKind.card,
|
||||
]);
|
||||
expect(found.first.value, '+89231234567');
|
||||
expect(found.last.value, '2200123456789019');
|
||||
});
|
||||
|
||||
test('ignores digits that are not a valid card', () {
|
||||
expect(detectTextEntities('116984447620359334'), isEmpty);
|
||||
expect(detectTextEntities('2200123456789018'), isEmpty);
|
||||
expect(detectTextEntities('1234567890123456'), isEmpty);
|
||||
});
|
||||
|
||||
test('ignores timestamps and short numbers', () {
|
||||
expect(detectTextEntities('05:46:16 1785041009832'), isEmpty);
|
||||
});
|
||||
|
||||
test('finds a nickname but not an email', () {
|
||||
final found = detectTextEntities('привет @GroupGuardBot и mail@ya.ru');
|
||||
expect(found, hasLength(1));
|
||||
expect(found.single.kind, TextEntityKind.mention);
|
||||
expect(found.single.value, 'GroupGuardBot');
|
||||
expect(found.single.start, 7);
|
||||
expect(found.single.end, 21);
|
||||
});
|
||||
|
||||
test('finds a formatted profile phone', () {
|
||||
final found = detectTextEntities('+7 (923) 123-45-67');
|
||||
expect(found, hasLength(1));
|
||||
expect(found.single.kind, TextEntityKind.phone);
|
||||
expect(found.single.value, '+79231234567');
|
||||
});
|
||||
|
||||
test('skips ranges that are already claimed', () {
|
||||
const text = 'https://max.ru/GroupGuardBot';
|
||||
expect(
|
||||
detectTextEntities(text, skip: [(start: 0, end: text.length)]),
|
||||
isEmpty,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('card metadata', () {
|
||||
test('recognises payment systems by BIN', () {
|
||||
expect(cardBrand('2200123456789019'), 'MIR');
|
||||
expect(cardBrand('4111111111111111'), 'VISA');
|
||||
expect(cardBrand('5500000000000004'), 'MASTERCARD');
|
||||
expect(cardBrand('340000000000009'), 'AMEX');
|
||||
expect(cardBrand('6200000000000005'), 'UNIONPAY');
|
||||
expect(cardBrand('1234567890123456'), isNull);
|
||||
});
|
||||
|
||||
test('builds the mask shown in the action menu', () {
|
||||
expect(cardMask('2200123456789019'), 'MIR*9019');
|
||||
expect(cardBrandTitle('2200123456789019'), 'МИР');
|
||||
});
|
||||
|
||||
test('formats a card number in groups of four', () {
|
||||
expect(formatCardNumber('2200123456789019'), '2200 1234 5678 9019');
|
||||
});
|
||||
|
||||
test('luhn rejects a corrupted number', () {
|
||||
expect(isLuhnValid('2200123456789019'), isTrue);
|
||||
expect(isLuhnValid('2200123456789018'), isFalse);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:komet/backend/modules/messages.dart';
|
||||
import 'package:komet/frontend/widgets/formatted_message_text.dart';
|
||||
import 'package:komet/frontend/widgets/message_bubble.dart';
|
||||
import 'package:komet/frontend/widgets/text_entity_actions.dart';
|
||||
import 'package:komet/l10n/app_localizations.dart';
|
||||
|
||||
const String _sample = '+79231234567 тест 2200123456789019 @GroupGuardBot';
|
||||
|
||||
CachedMessage _message(String text) => CachedMessage(
|
||||
id: '1',
|
||||
accountId: 1,
|
||||
chatId: 2,
|
||||
senderId: 1,
|
||||
text: text,
|
||||
time: DateTime(2026, 1, 1, 5, 46).millisecondsSinceEpoch,
|
||||
status: 'sent',
|
||||
);
|
||||
|
||||
Future<void> _pump(WidgetTester tester, Widget child) async {
|
||||
tester.view.physicalSize = const Size(1080, 2400);
|
||||
tester.view.devicePixelRatio = 2.5;
|
||||
addTearDown(tester.view.reset);
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
locale: const Locale('ru'),
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
home: Scaffold(
|
||||
body: Align(alignment: Alignment.topLeft, child: child),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
}
|
||||
|
||||
TextSpan? _spanWithText(WidgetTester tester, String text) {
|
||||
TextSpan? found;
|
||||
for (final widget in tester.widgetList<RichText>(find.byType(RichText))) {
|
||||
widget.text.visitChildren((span) {
|
||||
if (span is TextSpan && span.text == text) {
|
||||
found = span;
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
if (found != null) break;
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
void main() {
|
||||
testWidgets('a bubble highlights the phone, the card and the nickname', (
|
||||
tester,
|
||||
) async {
|
||||
await _pump(
|
||||
tester,
|
||||
MessageBubble(
|
||||
message: _message(_sample),
|
||||
isMe: false,
|
||||
myId: 1,
|
||||
chatType: 'DIALOG',
|
||||
),
|
||||
);
|
||||
|
||||
final accent = ThemeData().colorScheme.primary;
|
||||
final phone = _spanWithText(tester, '+79231234567');
|
||||
final card = _spanWithText(tester, '2200123456789019');
|
||||
final mention = _spanWithText(tester, '@GroupGuardBot');
|
||||
final plain = _spanWithText(tester, ' тест ');
|
||||
|
||||
expect(phone?.style?.color, accent);
|
||||
expect(card?.style?.color, accent);
|
||||
expect(mention?.style?.color, accent);
|
||||
expect(plain?.style?.color, isNot(accent));
|
||||
|
||||
expect(phone?.recognizer, isA<LongPressGestureRecognizer>());
|
||||
expect(card?.recognizer, isA<LongPressGestureRecognizer>());
|
||||
expect(mention?.recognizer, isA<TapGestureRecognizer>());
|
||||
});
|
||||
|
||||
testWidgets('a server USER_MENTION by name opens the profile on tap', (
|
||||
tester,
|
||||
) async {
|
||||
final message = CachedMessage(
|
||||
id: '2',
|
||||
accountId: 1,
|
||||
chatId: 2,
|
||||
senderId: 1,
|
||||
text: '@GroupGuardBot test',
|
||||
time: DateTime(2026, 1, 1, 5, 46).millisecondsSinceEpoch,
|
||||
status: 'sent',
|
||||
payload: const {
|
||||
'elements': [
|
||||
{'entityName': 'GroupGuardBot', 'type': 'USER_MENTION', 'length': 14},
|
||||
],
|
||||
},
|
||||
);
|
||||
|
||||
await _pump(
|
||||
tester,
|
||||
MessageBubble(message: message, isMe: false, myId: 1, chatType: 'DIALOG'),
|
||||
);
|
||||
|
||||
final mention = _spanWithText(tester, '@GroupGuardBot');
|
||||
expect(mention?.style?.color, ThemeData().colorScheme.primary);
|
||||
expect(mention?.recognizer, isA<TapGestureRecognizer>());
|
||||
});
|
||||
|
||||
testWidgets('copy mode taps instead of opening a menu', (tester) async {
|
||||
await _pump(
|
||||
tester,
|
||||
FormattedMessageText(
|
||||
text: _sample,
|
||||
ranges: const [],
|
||||
entityMode: TextEntityMode.copy,
|
||||
style: const TextStyle(fontSize: 16),
|
||||
),
|
||||
);
|
||||
|
||||
expect(
|
||||
_spanWithText(tester, '+79231234567')?.recognizer,
|
||||
isA<TapGestureRecognizer>(),
|
||||
);
|
||||
expect(
|
||||
_spanWithText(tester, '2200123456789019')?.recognizer,
|
||||
isA<TapGestureRecognizer>(),
|
||||
);
|
||||
});
|
||||
|
||||
Future<void> openMenuAt(WidgetTester tester, Offset at) async {
|
||||
await _pump(
|
||||
tester,
|
||||
Builder(
|
||||
builder: (context) => TextButton(
|
||||
onPressed: () =>
|
||||
showCardEntityMenu(context, '2200123456789019', at: at),
|
||||
child: const Text('open'),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.tap(find.text('open'));
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
Rect menuRect(WidgetTester tester) => tester.getRect(
|
||||
find
|
||||
.ancestor(
|
||||
of: find.text('Скопировать номер карты'),
|
||||
matching: find.byType(SingleChildScrollView),
|
||||
)
|
||||
.first,
|
||||
);
|
||||
|
||||
testWidgets('a menu opened near the bottom flips above the anchor', (
|
||||
tester,
|
||||
) async {
|
||||
await openMenuAt(tester, const Offset(200, 940));
|
||||
|
||||
final screen = tester.view.physicalSize / tester.view.devicePixelRatio;
|
||||
final rect = menuRect(tester);
|
||||
|
||||
expect(rect.bottom, lessThanOrEqualTo(screen.height - 8));
|
||||
expect(rect.bottom, lessThan(940));
|
||||
expect(rect.top, greaterThanOrEqualTo(8));
|
||||
});
|
||||
|
||||
testWidgets('a menu opened near the top stays below the anchor', (
|
||||
tester,
|
||||
) async {
|
||||
await openMenuAt(tester, const Offset(200, 100));
|
||||
|
||||
final rect = menuRect(tester);
|
||||
expect(rect.top, greaterThanOrEqualTo(100));
|
||||
});
|
||||
|
||||
testWidgets('the card menu shows the copy action and the card mask', (
|
||||
tester,
|
||||
) async {
|
||||
await _pump(
|
||||
tester,
|
||||
Builder(
|
||||
builder: (context) => TextButton(
|
||||
onPressed: () => showCardEntityMenu(
|
||||
context,
|
||||
'2200123456789019',
|
||||
at: const Offset(200, 300),
|
||||
),
|
||||
child: const Text('open'),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.tap(find.text('open'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Скопировать номер карты'), findsOneWidget);
|
||||
expect(find.text('MIR*9019'), findsOneWidget);
|
||||
expect(find.text('МИР'), findsOneWidget);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user