frosty/test/models/irc_test.dart
Tommy Chow 57e7f73aa8 perf(chat): cut per-flush rebuild and decode costs in message list
every 200ms flush shifted all sliver indices (reverse list), and the
per-row ObjectKeys without findChildIndexCallback made the framework
deactivate and re-inflate every built row instead of moving it. add the
callback so elements move, and cache ChatMessage widget instances per
message (Expando) so unchanged rows skip build() entirely via the
identical-widget short-circuit. IRCMessage.renderRevision invalidates
rows the CLEARCHAT/CLEARMSG handlers mutate in place.

also decode emotes/badges at display size (memCacheHeight = logical
size x devicePixelRatio) instead of the CDN's 3x-4x intrinsic size:
several-fold less image memory and raster cost, animation preserved
(ResizeImage applies the target size to the whole codec).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 14:45:20 -04:00

726 lines
22 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('bumps renderRevision only on affected messages', () {
final clearChatMsg = IRCMessage(
raw: '',
command: Command.clearChat,
tags: {'ban-duration': '600'},
message: 'testuser',
);
bufferedMessages = [
IRCMessage.fromString(basicPrivmsg), // from testuser
];
IRCMessage.clearChat(
messages: messages,
bufferedMessages: bufferedMessages,
ircMessage: clearChatMsg,
);
// Mutated-in-place messages must signal render caches to invalidate.
expect(
messages.where((m) => m.user == 'testuser').first.renderRevision,
1,
);
expect(bufferedMessages.first.renderRevision, 1);
expect(
messages.where((m) => m.user == 'moduser').first.renderRevision,
0,
);
});
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,
);
// Mutated-in-place message must signal render caches to invalidate.
expect(
messages.firstWhere((m) => m.tags['id'] == 'msg-2').renderRevision,
1,
);
expect(
messages.firstWhere((m) => m.tags['id'] == 'msg-1').renderRevision,
0,
);
// 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);
});
});
}