frosty/test/models/irc_test.dart
Tommy Chow 89f8859ba3 add merged chat mode for multi-tab view
Merge only already-activated tabs; lazy tabs join when tapped. Dedup
messages by IRC id for Twitch shared chat overlap. Preserve draft and
reply state when switching tabs in merged mode. Merge button appears
only when 2+ tabs are loaded.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 17:00:03 -08:00

635 lines
18 KiB
Dart

import 'package:flutter_test/flutter_test.dart';
import 'package:frosty/constants.dart';
import 'package:frosty/models/irc.dart';
import '../fixtures/irc_messages.dart';
void main() {
group('IRCMessage.fromString', () {
group('basic parsing', () {
test('parses basic chat message correctly', () {
final msg = IRCMessage.fromString(basicPrivmsg);
expect(msg.user, 'testuser');
expect(msg.message, 'Hello World');
expect(msg.command, Command.privateMessage);
expect(msg.tags['display-name'], 'TestUser');
expect(msg.tags['color'], '#FF0000');
expect(msg.tags['user-id'], '67890');
expect(msg.tags['room-id'], '12345');
});
test('parses message with escaped spaces in tags', () {
final msg = IRCMessage.fromString(messageWithEscapedSpaces);
// \\s should be converted to actual space
expect(msg.tags['badge-info'], 'subscriber/12 months');
expect(msg.message, 'test message');
});
test('handles message with no color tag', () {
final msg = IRCMessage.fromString(messageNoColor);
// Tags with empty values are skipped during parsing
expect(msg.tags['color'], isNull);
expect(msg.user, 'nocolor');
});
test('splits message into words correctly', () {
final msg = IRCMessage.fromString(basicPrivmsg);
expect(msg.split, ['Hello', 'World']);
});
});
group('command detection', () {
test('identifies PRIVMSG command', () {
final msg = IRCMessage.fromString(basicPrivmsg);
expect(msg.command, Command.privateMessage);
});
test('identifies CLEARCHAT command for user ban', () {
final msg = IRCMessage.fromString(clearChatUser);
expect(msg.command, Command.clearChat);
expect(msg.message, 'banneduser');
expect(msg.tags['ban-duration'], '600');
});
test('identifies CLEARCHAT command for full chat clear', () {
final msg = IRCMessage.fromString(clearChatAll);
expect(msg.command, Command.clearChat);
expect(msg.message, isNull);
});
test('identifies CLEARMSG command', () {
final msg = IRCMessage.fromString(clearMsg);
expect(msg.command, Command.clearMessage);
expect(msg.tags['target-msg-id'], 'target-msg-abc123');
});
test('identifies NOTICE command', () {
final msg = IRCMessage.fromString(noticeMessage);
expect(msg.command, Command.notice);
expect(msg.tags['msg-id'], 'slow_on');
});
test('identifies USERNOTICE command', () {
final msg = IRCMessage.fromString(userNoticeSubscription);
expect(msg.command, Command.userNotice);
expect(msg.tags['msg-id'], 'sub');
});
test('identifies ROOMSTATE command', () {
final msg = IRCMessage.fromString(roomState);
expect(msg.command, Command.roomState);
expect(msg.tags['slow'], '0');
expect(msg.tags['emote-only'], '0');
});
test('identifies USERSTATE command', () {
final msg = IRCMessage.fromString(userState);
expect(msg.command, Command.userState);
expect(msg.tags['mod'], '0');
});
test('identifies GLOBALUSERSTATE command', () {
final msg = IRCMessage.fromString(globalUserState);
expect(msg.command, Command.globalUserState);
});
});
group('emote parsing', () {
test('extracts local emotes from emote tag', () {
final msg = IRCMessage.fromString(privmsgWithEmotes);
expect(msg.localEmotes, isNotNull);
expect(msg.localEmotes!.containsKey('Kappa'), isTrue);
expect(msg.localEmotes!.containsKey('LUL'), isTrue);
// Verify emote URLs are constructed correctly
expect(
msg.localEmotes!['Kappa']!.url,
contains('static-cdn.jtvnw.net/emoticons/v2/25'),
);
});
test('handles multiple instances of same emote', () {
final msg = IRCMessage.fromString(privmsgWithMultipleEmotes);
// Should still only have one entry in localEmotes map
expect(msg.localEmotes!.length, 1);
expect(msg.localEmotes!['Kappa'], isNotNull);
});
test('handles message with no emotes', () {
final msg = IRCMessage.fromString(basicPrivmsg);
expect(msg.localEmotes, isEmpty);
});
});
group('IRC action (/me) detection', () {
test('detects /me action messages', () {
final msg = IRCMessage.fromString(actionMessage);
expect(msg.action, isTrue);
expect(msg.message, 'is testing');
// Action prefix and suffix should be stripped
expect(msg.message, isNot(contains('\x01')));
});
test('non-action messages have action=false', () {
final msg = IRCMessage.fromString(basicPrivmsg);
expect(msg.action, isFalse);
});
});
group('mention detection', () {
test('detects when message mentions logged-in user', () {
final msg = IRCMessage.fromString(
mentionMessage,
userLogin: 'targetuser',
);
expect(msg.mention, isTrue);
});
test('mention detection lowercases the message', () {
// Note: The code lowercases the message but compares against userLogin as-is
// So userLogin should be provided in lowercase for proper matching
final msg = IRCMessage.fromString(
mentionMessage,
userLogin: 'targetuser', // lowercase to match the lowercased message
);
expect(msg.mention, isTrue);
});
test('no mention when user is not mentioned', () {
final msg = IRCMessage.fromString(
basicPrivmsg,
userLogin: 'someotheruser',
);
expect(msg.mention, isFalse);
});
test('mention is false when userLogin not provided', () {
final msg = IRCMessage.fromString(basicPrivmsg);
expect(msg.mention, isFalse);
});
});
group('Unicode handling', () {
test('removes invalid Unicode characters', () {
final msg = IRCMessage.fromString(messageWithInvalidUnicode);
// The invalid Unicode character should be stripped
expect(msg.message, 'HelloWorld');
expect(msg.message, isNot(contains('\u{E0000}')));
});
test('handles emoji characters correctly', () {
final msg = IRCMessage.fromString(messageWithEmoji);
expect(msg.message, contains('😀'));
expect(msg.message, contains('🎉'));
// Emojis should be separated into their own entries in split
expect(msg.split!.any((s) => s.contains('😀')), isTrue);
});
});
group('reply message handling', () {
test('parses reply message tags', () {
final msg = IRCMessage.fromString(replyMessage);
expect(msg.tags['reply-parent-display-name'], 'OriginalUser');
expect(msg.tags['reply-parent-msg-id'], 'original-123');
expect(msg.tags['reply-parent-user-login'], 'originaluser');
});
});
group('special message types', () {
test('parses moderator message', () {
final msg = IRCMessage.fromString(moderatorMessage);
expect(msg.tags['mod'], '1');
expect(msg.tags['badges'], contains('moderator'));
});
test('parses historical message', () {
final msg = IRCMessage.fromString(historicalMessage);
expect(msg.tags['historical'], '1');
});
test('parses shared chat message with source room', () {
final msg = IRCMessage.fromString(sharedChatMessage);
expect(msg.tags['source-room-id'], '99999');
expect(msg.tags['room-id'], '12345');
});
test('parses message with URL in split', () {
final msg = IRCMessage.fromString(messageWithUrl);
expect(msg.message, contains('https://twitch.tv/channel'));
expect(msg.split, isNotNull);
expect(
msg.split!.any((word) => regexLink.hasMatch(word)),
isTrue,
);
});
});
});
group('IRCMessage.clearChat', () {
late List<IRCMessage> messages;
late List<IRCMessage> bufferedMessages;
setUp(() {
// Create test messages from different users
messages = [
IRCMessage.fromString(basicPrivmsg), // from testuser
IRCMessage.fromString(moderatorMessage), // from moduser
IRCMessage.fromString(
basicPrivmsg.replaceAll('testuser', 'user2').replaceAll('TestUser', 'User2'),
), // from user2
];
bufferedMessages = [];
});
test('marks all messages from banned user', () {
// Create a CLEARCHAT message targeting testuser
final clearChatMsg = IRCMessage(
raw: '',
command: Command.clearChat,
tags: {'ban-duration': '600'},
message: 'testuser',
);
IRCMessage.clearChat(
messages: messages,
bufferedMessages: bufferedMessages,
ircMessage: clearChatMsg,
);
// testuser's message should be marked as clearChat
expect(
messages.where((m) => m.user == 'testuser').first.command,
Command.clearChat,
);
// Other users' messages should be unchanged
expect(
messages.where((m) => m.user == 'moduser').first.command,
Command.privateMessage,
);
});
test('adds ban-duration tag to affected messages', () {
final clearChatMsg = IRCMessage(
raw: '',
command: Command.clearChat,
tags: {'ban-duration': '300'},
message: 'testuser',
);
IRCMessage.clearChat(
messages: messages,
bufferedMessages: bufferedMessages,
ircMessage: clearChatMsg,
);
final affectedMessage = messages.where((m) => m.user == 'testuser').first;
expect(affectedMessage.tags['ban-duration'], '300');
});
test('clears entire chat when no target user', () {
final clearAllMsg = IRCMessage(
raw: '',
command: Command.clearChat,
tags: {},
);
IRCMessage.clearChat(
messages: messages,
bufferedMessages: bufferedMessages,
ircMessage: clearAllMsg,
);
// Messages list should be cleared and contain only the notice
expect(messages.length, 1);
expect(messages.first.command, Command.notice);
expect(messages.first.message, 'Chat was cleared by a moderator');
});
});
group('IRCMessage.clearMessage', () {
late List<IRCMessage> messages;
late List<IRCMessage> bufferedMessages;
setUp(() {
messages = [
IRCMessage(
raw: '',
command: Command.privateMessage,
tags: {'id': 'msg-1'},
user: 'user1',
message: 'Message 1',
),
IRCMessage(
raw: '',
command: Command.privateMessage,
tags: {'id': 'msg-2'},
user: 'user2',
message: 'Message 2',
),
IRCMessage(
raw: '',
command: Command.privateMessage,
tags: {'id': 'msg-3'},
user: 'user1',
message: 'Message 3',
),
];
bufferedMessages = [];
});
test('marks specific message by ID', () {
final clearMsg = IRCMessage(
raw: '',
command: Command.clearMessage,
tags: {'target-msg-id': 'msg-2'},
);
IRCMessage.clearMessage(
messages: messages,
bufferedMessages: bufferedMessages,
ircMessage: clearMsg,
);
// Only msg-2 should be marked
expect(
messages.firstWhere((m) => m.tags['id'] == 'msg-2').command,
Command.clearMessage,
);
// Other messages unchanged
expect(
messages.firstWhere((m) => m.tags['id'] == 'msg-1').command,
Command.privateMessage,
);
expect(
messages.firstWhere((m) => m.tags['id'] == 'msg-3').command,
Command.privateMessage,
);
});
test('handles non-existent message ID gracefully', () {
final clearMsg = IRCMessage(
raw: '',
command: Command.clearMessage,
tags: {'target-msg-id': 'non-existent'},
);
// Should not throw
expect(
() => IRCMessage.clearMessage(
messages: messages,
bufferedMessages: bufferedMessages,
ircMessage: clearMsg,
),
returnsNormally,
);
// All messages should be unchanged
expect(
messages.every((m) => m.command == Command.privateMessage),
isTrue,
);
});
});
group('IRCMessage.createNotice', () {
test('creates notice message with correct properties', () {
final notice = IRCMessage.createNotice(message: 'Test notice');
expect(notice.command, Command.notice);
expect(notice.message, 'Test notice');
expect(notice.raw, isEmpty);
expect(notice.tags, contains('tmi-sent-ts'));
expect(notice.tags.length, 1);
});
test('creates notice with action callback', () {
var called = false;
final notice = IRCMessage.createNotice(
message: 'Test',
actionCallback: () => called = true,
actionLabel: 'Retry',
);
expect(notice.actionCallback, isNotNull);
expect(notice.actionLabel, 'Retry');
notice.actionCallback!();
expect(called, isTrue);
});
});
group('ROOMSTATE', () {
test('creates default ROOMSTATE', () {
const roomstate = ROOMSTATE();
expect(roomstate.emoteOnly, '0');
expect(roomstate.followersOnly, '-1');
expect(roomstate.r9k, '0');
expect(roomstate.slowMode, '0');
expect(roomstate.subMode, '0');
});
test('updates from IRC message', () {
const original = ROOMSTATE();
final ircMsg = IRCMessage(
raw: '',
command: Command.roomState,
tags: {
'emote-only': '1',
'followers-only': '10',
'slow': '30',
},
);
final updated = original.fromIRCMessage(ircMsg);
expect(updated.emoteOnly, '1');
expect(updated.followersOnly, '10');
expect(updated.slowMode, '30');
// Unchanged values should keep original
expect(updated.r9k, '0');
expect(updated.subMode, '0');
});
});
group('IRCMessage.clearChat — buffered messages', () {
test('marks buffered messages from banned user', () {
final messages = <IRCMessage>[];
final bufferedMessages = [
IRCMessage(
raw: '',
command: Command.privateMessage,
tags: {'id': 'buf-1'},
user: 'banneduser',
message: 'Buffered msg',
),
IRCMessage(
raw: '',
command: Command.privateMessage,
tags: {'id': 'buf-2'},
user: 'innocentuser',
message: 'Other msg',
),
];
final clearChatMsg = IRCMessage(
raw: '',
command: Command.clearChat,
tags: {'ban-duration': '600'},
message: 'banneduser',
);
IRCMessage.clearChat(
messages: messages,
bufferedMessages: bufferedMessages,
ircMessage: clearChatMsg,
);
expect(bufferedMessages[0].command, Command.clearChat);
expect(bufferedMessages[0].tags['ban-duration'], '600');
expect(bufferedMessages[1].command, Command.privateMessage);
});
test('full chat clear also clears buffered messages', () {
final messages = [
IRCMessage(
raw: '',
command: Command.privateMessage,
tags: {},
message: 'msg',
),
];
final bufferedMessages = [
IRCMessage(
raw: '',
command: Command.privateMessage,
tags: {},
message: 'buffered',
),
];
final clearAllMsg = IRCMessage(
raw: '',
command: Command.clearChat,
tags: {},
);
IRCMessage.clearChat(
messages: messages,
bufferedMessages: bufferedMessages,
ircMessage: clearAllMsg,
);
expect(bufferedMessages, isEmpty);
});
});
group('IRCMessage.clearMessage — buffered messages', () {
test('marks buffered message by ID', () {
final messages = <IRCMessage>[];
final bufferedMessages = [
IRCMessage(
raw: '',
command: Command.privateMessage,
tags: {'id': 'buf-target'},
user: 'someuser',
message: 'To be deleted',
),
];
final clearMsg = IRCMessage(
raw: '',
command: Command.clearMessage,
tags: {'target-msg-id': 'buf-target'},
);
IRCMessage.clearMessage(
messages: messages,
bufferedMessages: bufferedMessages,
ircMessage: clearMsg,
);
expect(bufferedMessages.first.command, Command.clearMessage);
});
});
group('IRCMessage edge cases', () {
test('parses message with empty body', () {
final msg = IRCMessage.fromString(messageEmptyBody);
expect(msg.command, Command.privateMessage);
// Empty trailing content after the colon
expect(msg.message, isEmpty);
});
test('parses emote-only message', () {
final msg = IRCMessage.fromString(messageOnlyEmotes);
expect(msg.command, Command.privateMessage);
expect(msg.message, 'Kappa Kappa');
expect(msg.localEmotes, isNotNull);
expect(msg.localEmotes!.containsKey('Kappa'), isTrue);
});
});
group('USERSTATE', () {
test('creates default USERSTATE', () {
const userstate = USERSTATE();
expect(userstate.color, '');
expect(userstate.displayName, '');
expect(userstate.mod, isFalse);
expect(userstate.subscriber, isFalse);
});
test('updates from IRC message', () {
const original = USERSTATE();
final ircMsg = IRCMessage(
raw: '@color=#FF0000;display-name=TestUser;mod=1;subscriber=1',
command: Command.userState,
tags: {
'color': '#FF0000',
'display-name': 'TestUser',
'mod': '1',
'subscriber': '1',
},
);
final updated = original.fromIRCMessage(ircMsg);
expect(updated.color, '#FF0000');
expect(updated.displayName, 'TestUser');
expect(updated.mod, isTrue);
expect(updated.subscriber, isTrue);
});
test('mod=0 is false', () {
const original = USERSTATE();
final ircMsg = IRCMessage(
raw: '',
command: Command.userState,
tags: {'mod': '0'},
);
final updated = original.fromIRCMessage(ircMsg);
expect(updated.mod, isFalse);
});
});
}