frosty/test/models/irc_test.dart
Tommy Chow ef1b1ded5d fix: reclassify transient errors as non-fatal and fix top crashes
- main: record transient I/O, plugin-teardown, and non-crashing UI errors
  (network/websocket/timeout, MissingPluginException, image-load, layout
  overflow) as non-fatal, and add a grouping reason to async errors so
  distinct errors stop collapsing into one catch-all Crashlytics issue
- irc: guard IRCMessage.fromString against malformed/truncated lines that
  threw RangeError, with regression tests
- chat: wrap the emote menu in OverflowBox so it no longer overflows
  (RenderFlex) while the panel collapses
- chat: fix onBadgeError returning a List instead of a Map, which threw a
  Future.catchError type error during channel asset loads
- video: skip setAutoPipMode on Android < 12 where it isn't implemented

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 15:04:11 -04:00

688 lines
21 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('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<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);
});
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);
});
});
}