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('unescapes the full IRCv3 tag escape set', () { final msg = IRCMessage.fromString(messageWithEscapedTagChars); expect(msg.tags['a-space'], 'one two'); expect(msg.tags['a-semi'], 'left;right'); expect(msg.tags['a-slash'], r'a\b'); // Only the first '=' splits key/value; later '=' stays in the value. expect(msg.tags['a-equals'], 'k=v'); // `\\` consumes both backslashes; the following 's' is a literal letter, // not a `\s` space escape (single-pass unescaping). expect(msg.tags['a-double'], r'x\sy'); }); test('falls back to login tag when prefix has no "!"', () { // An unexpected prefix shape must not throw and drop the message. final msg = IRCMessage.fromString(messageNoBangPrefix); expect(msg.user, 'fallbackuser'); expect(msg.message, 'server style'); }); 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 historical message with bare (valueless) tags', () { // The recent-messages API serializes empty tags as bare keys (e.g. // `emotes`, `flags`) instead of `key=`. This previously threw a // RangeError, silently dropping all recent messages. final msg = IRCMessage.fromString(historicalMessageBareTags); expect(msg.tags['historical'], '1'); expect(msg.tags['display-name'], 'BareUser'); expect(msg.message, 'Bare tag message'); // Bare tags should be absent from the map, not crash parsing. expect(msg.tags.containsKey('emotes'), isFalse); expect(msg.tags.containsKey('flags'), isFalse); }); 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 messages; late List 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 messages; late List 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 = []; 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 = []; 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); }); test('returns Command.none for a truncated line without a command', () { // A malformed/truncated line (e.g. from the recent-messages history API) // whose message section is a single token used to throw a RangeError on // splitMessage[1]. It must now parse safely as a none command. final msg = IRCMessage.fromString('@historical=1 :justaprefix'); expect(msg.command, Command.none); expect(msg.tags['historical'], '1'); }); test('returns Command.none for a line with no separating space', () { // No space means there are no tags to slice and no body to parse; must // not throw on the substring/split calls. final msg = IRCMessage.fromString('@onlytags'); expect(msg.command, Command.none); }); }); 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); }); test('missing mod and subscriber tags are false', () { const original = USERSTATE(); final ircMsg = IRCMessage( raw: '', command: Command.userState, tags: {'display-name': 'TestUser'}, ); final updated = original.fromIRCMessage(ircMsg); expect(updated.mod, isFalse); expect(updated.subscriber, isFalse); }); }); }