overhaul tests

This commit is contained in:
Tommy Chow 2026-02-07 00:51:22 -08:00
parent e58c9875e0
commit baebafea59
22 changed files with 2637 additions and 52 deletions

View file

@ -0,0 +1,55 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:frosty/models/category.dart';
void main() {
group('CategoryTwitch', () {
test('fromJson deserializes snake_case fields', () {
final category = CategoryTwitch.fromJson(const {
'box_art_url': 'https://cdn/boxart.jpg',
'id': '509658',
'name': 'Just Chatting',
});
expect(category.boxArtUrl, 'https://cdn/boxart.jpg');
expect(category.id, '509658');
expect(category.name, 'Just Chatting');
});
});
group('CategoriesTwitch', () {
test('fromJson deserializes data list and pagination', () {
final categories = CategoriesTwitch.fromJson(const {
'data': [
{
'box_art_url': 'https://cdn/boxart.jpg',
'id': '509658',
'name': 'Just Chatting',
},
],
'pagination': {'cursor': 'cat_cursor'},
});
expect(categories.data.length, 1);
expect(categories.data.first.name, 'Just Chatting');
expect(categories.pagination?['cursor'], 'cat_cursor');
});
test('fromJson handles empty data list', () {
final categories = CategoriesTwitch.fromJson(const {
'data': <dynamic>[],
'pagination': <String, String>{},
});
expect(categories.data, isEmpty);
});
test('fromJson handles null pagination', () {
final categories = CategoriesTwitch.fromJson(const {
'data': <dynamic>[],
'pagination': null,
});
expect(categories.pagination, isNull);
});
});
}

View file

@ -0,0 +1,49 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:frosty/models/channel.dart';
import '../fixtures/api_responses.dart';
void main() {
group('Channel', () {
test('fromJson deserializes snake_case fields', () {
final channel = Channel.fromJson(
(twitchChannelResponse['data']! as List).first as Map<String, dynamic>,
);
expect(channel.broadcasterId, '12345');
expect(channel.broadcasterLogin, 'testuser');
expect(channel.broadcasterName, 'TestUser');
expect(channel.broadcasterLanguage, 'en');
expect(channel.title, 'Test Stream');
expect(channel.gameId, '509658');
expect(channel.gameName, 'Just Chatting');
});
});
group('ChannelQuery', () {
test('fromJson deserializes snake_case fields', () {
final query = ChannelQuery.fromJson(
(twitchChannelQueryResponse['data']! as List).first
as Map<String, dynamic>,
);
expect(query.broadcasterLogin, 'testuser');
expect(query.displayName, 'TestUser');
expect(query.id, '12345');
expect(query.isLive, isTrue);
expect(query.startedAt, '2024-01-01T00:00:00Z');
});
test('fromJson handles isLive false', () {
final query = ChannelQuery.fromJson(const {
'broadcaster_login': 'offline',
'display_name': 'Offline',
'id': '99999',
'is_live': false,
'started_at': '',
});
expect(query.isLive, isFalse);
});
});
}

View file

@ -0,0 +1,36 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:frosty/models/chatters.dart';
import '../fixtures/api_responses.dart';
void main() {
group('ChatUsers', () {
test('fromJson deserializes chatter count and chatters', () {
final chatUsers = ChatUsers.fromJson(
chattersResponse as Map<String, dynamic>,
);
expect(chatUsers.chatterCount, 5);
expect(chatUsers.chatters.broadcaster, ['streamer']);
expect(chatUsers.chatters.vips, ['vip1']);
expect(chatUsers.chatters.moderators, ['mod1']);
expect(chatUsers.chatters.viewers, ['viewer1', 'viewer2']);
});
});
group('Chatters', () {
test('fromJson handles all role lists', () {
final chatters = Chatters.fromJson(
chattersResponse['chatters'] as Map<String, dynamic>,
);
expect(chatters.broadcaster, hasLength(1));
expect(chatters.vips, hasLength(1));
expect(chatters.moderators, hasLength(1));
expect(chatters.staff, isEmpty);
expect(chatters.admins, isEmpty);
expect(chatters.globalMods, isEmpty);
expect(chatters.viewers, hasLength(2));
});
});
}

View file

@ -34,6 +34,19 @@ void main() {
expect(emote.ownerId, '67890');
});
test('handles null owner ID', () {
final twitchEmote = EmoteTwitch(
'25',
'Kappa',
'bitstier',
null,
);
final emote = Emote.fromTwitch(twitchEmote, EmoteType.twitchGlobal);
expect(emote.ownerId, isNull);
});
});
group('Emote.fromBTTV', () {
@ -47,53 +60,21 @@ void main() {
expect(emote.type, EmoteType.bttvGlobal);
});
test('marks SoSnowy as zero-width', () {
final bttvEmote = EmoteBTTV('snowyid', 'SoSnowy');
final emote = Emote.fromBTTV(bttvEmote, EmoteType.bttvGlobal);
expect(emote.zeroWidth, isTrue);
});
test('marks IceCold as zero-width', () {
final bttvEmote = EmoteBTTV('iceid', 'IceCold');
final emote = Emote.fromBTTV(bttvEmote, EmoteType.bttvChannel);
expect(emote.zeroWidth, isTrue);
});
test('marks SantaHat as zero-width', () {
final bttvEmote = EmoteBTTV('santaid', 'SantaHat');
final emote = Emote.fromBTTV(bttvEmote, EmoteType.bttvShared);
expect(emote.zeroWidth, isTrue);
});
test('marks TopHat as zero-width', () {
final bttvEmote = EmoteBTTV('topid', 'TopHat');
final emote = Emote.fromBTTV(bttvEmote, EmoteType.bttvGlobal);
expect(emote.zeroWidth, isTrue);
});
test('marks cvMask as zero-width', () {
final bttvEmote = EmoteBTTV('maskid', 'cvMask');
final emote = Emote.fromBTTV(bttvEmote, EmoteType.bttvGlobal);
expect(emote.zeroWidth, isTrue);
});
test('marks cvHazmat as zero-width', () {
final bttvEmote = EmoteBTTV('hazmatid', 'cvHazmat');
final emote = Emote.fromBTTV(bttvEmote, EmoteType.bttvGlobal);
expect(emote.zeroWidth, isTrue);
});
for (final name in [
'SoSnowy',
'IceCold',
'SantaHat',
'TopHat',
'ReinDeer',
'CandyCane',
'cvMask',
'cvHazmat',
]) {
test('marks $name as zero-width', () {
final emote = Emote.fromBTTV(EmoteBTTV('id', name), EmoteType.bttvGlobal);
expect(emote.zeroWidth, isTrue);
});
}
test('regular emotes are not zero-width', () {
final bttvEmote = EmoteBTTV('regularid', 'RegularEmote');
@ -214,6 +195,29 @@ void main() {
// Should use animated 2x since animated 4x is not available
expect(emote.url, 'https://cdn.ffz.net/anim/2x.gif');
});
test('animated with only 1x falls back to animated 1x', () {
final ffzEmote = EmoteFFZ(
'AnimOnly1x',
24,
24,
const OwnerFFZ(displayName: 'Owner', name: 'owner'),
const ImagesFFZ(
'https://cdn.ffz.net/static/1x.png',
'https://cdn.ffz.net/static/2x.png',
'https://cdn.ffz.net/static/4x.png',
),
const ImagesFFZ(
'https://cdn.ffz.net/anim/1x.gif',
null, // No animated 2x
null, // No animated 4x
),
);
final emote = Emote.fromFFZ(ffzEmote, EmoteType.ffzGlobal);
expect(emote.url, 'https://cdn.ffz.net/anim/1x.gif');
});
});
group('Emote.from7TV', () {
@ -473,6 +477,27 @@ void main() {
});
});
group('EmoteBTTVChannel', () {
test('combines channel and shared emotes', () {
final json = {
'channelEmotes': [
{'id': 'ch1', 'code': 'ChannelEmote'},
],
'sharedEmotes': [
{'id': 'sh1', 'code': 'SharedEmote1'},
{'id': 'sh2', 'code': 'SharedEmote2'},
],
};
final result = EmoteBTTVChannel.fromJson(json);
expect(result.channelEmotes.length, 1);
expect(result.channelEmotes.first.code, 'ChannelEmote');
expect(result.sharedEmotes.length, 2);
expect(result.sharedEmotes.first.code, 'SharedEmote1');
});
});
group('Emote JSON serialization', () {
test('roundtrip serialization works', () {
const original = Emote(

View file

@ -0,0 +1,110 @@
import 'dart:convert';
import 'package:flutter_test/flutter_test.dart';
import 'package:frosty/models/events.dart';
import '../fixtures/api_responses.dart';
void main() {
group('SevenTVEvent', () {
test('fromJson deserializes op, t, and d fields', () {
final event = SevenTVEvent.fromJson(
sevenTVEventJson as Map<String, dynamic>,
);
expect(event.op, 0);
expect(event.t, 1);
expect(event.d.type, 'emote_set.update');
});
test('toJson roundtrip preserves data', () {
final event = SevenTVEvent.fromJson(
sevenTVEventJson as Map<String, dynamic>,
);
// Use jsonEncode/jsonDecode for a true roundtrip, since toJson()
// doesn't recursively serialize nested objects without explicitToJson.
final json = jsonDecode(jsonEncode(event.toJson()))
as Map<String, dynamic>;
final restored = SevenTVEvent.fromJson(json);
expect(restored.op, event.op);
expect(restored.t, event.t);
expect(restored.d.type, event.d.type);
});
test('fromJson handles null t field', () {
final event = SevenTVEvent.fromJson(const {
'op': 2,
'd': {'type': null},
});
expect(event.op, 2);
expect(event.t, isNull);
});
});
group('SevenTVEventData', () {
test('fromJson deserializes type and condition', () {
final data = SevenTVEventData.fromJson(
(sevenTVEventJson as Map<String, dynamic>)['d']
as Map<String, dynamic>,
);
expect(data.type, 'emote_set.update');
expect(data.condition?['object_id'], 'set_abc123');
});
test('fromJson with null type', () {
final data = SevenTVEventData.fromJson(const {
'type': null,
'condition': null,
});
expect(data.type, isNull);
expect(data.condition, isNull);
});
test('toJson produces valid output', () {
const data = SevenTVEventData(
type: 'emote_set.update',
condition: {'object_id': 'test'},
);
final json = data.toJson();
expect(json['type'], 'emote_set.update');
expect(json['condition'], {'object_id': 'test'});
});
});
group('SevenTVEventEmoteSetBody', () {
test('fromJson deserializes actor and pushed/pulled lists', () {
final body = SevenTVEventEmoteSetBody.fromJson(const {
'actor': {
'username': 'moderator',
'display_name': 'Moderator',
},
'pushed': <dynamic>[],
'pulled': <dynamic>[],
});
expect(body.actor.username, 'moderator');
expect(body.actor.displayName, 'Moderator');
expect(body.pushed, isEmpty);
expect(body.pulled, isEmpty);
});
test('fromJson handles null pushed/pulled', () {
final body = SevenTVEventEmoteSetBody.fromJson(const {
'actor': {
'username': 'mod',
'display_name': 'Mod',
},
});
expect(body.pushed, isNull);
expect(body.pulled, isNull);
});
});
}

View file

@ -1,4 +1,5 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:frosty/constants.dart';
import 'package:frosty/models/irc.dart';
import '../fixtures/irc_messages.dart';
@ -173,7 +174,7 @@ void main() {
expect(msg.mention, isFalse);
});
test('mention is null when userLogin not provided', () {
test('mention is false when userLogin not provided', () {
final msg = IRCMessage.fromString(basicPrivmsg);
expect(msg.mention, isFalse);
});
@ -228,6 +229,17 @@ void main() {
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,
);
});
});
});
@ -456,6 +468,126 @@ void main() {
});
});
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();

View file

@ -0,0 +1,41 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:frosty/models/shared_chat_session.dart';
import '../fixtures/api_responses.dart';
void main() {
group('SharedChatSession', () {
test('fromJson deserializes all fields', () {
final session = SharedChatSession.fromJson(
(twitchSharedChatSessionResponse['data']! as List).first
as Map<String, dynamic>,
);
expect(session.sessionId, 'session_123');
expect(session.hostBroadcasterId, '12345');
expect(session.participants.length, 2);
expect(session.createdAt, '2024-01-01T00:00:00Z');
expect(session.updatedAt, '2024-01-01T01:00:00Z');
});
});
group('Participant', () {
test('fromJson deserializes broadcaster_id', () {
final participant = Participant.fromJson(const {
'broadcaster_id': '67890',
});
expect(participant.broadcasterId, '67890');
});
test('participants from shared chat session are correct', () {
final session = SharedChatSession.fromJson(
(twitchSharedChatSessionResponse['data']! as List).first
as Map<String, dynamic>,
);
expect(session.participants[0].broadcasterId, '12345');
expect(session.participants[1].broadcasterId, '67890');
});
});
}

View file

@ -0,0 +1,45 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:frosty/models/stream.dart';
import '../fixtures/api_responses.dart';
void main() {
group('StreamTwitch', () {
test('fromJson deserializes snake_case fields', () {
final stream = StreamTwitch.fromJson(
(twitchStreamResponse['data']! as List).first as Map<String, dynamic>,
);
expect(stream.userId, '12345');
expect(stream.userLogin, 'testuser');
expect(stream.userName, 'TestUser');
expect(stream.gameId, '509658');
expect(stream.gameName, 'Just Chatting');
expect(stream.title, 'Test Stream');
expect(stream.viewerCount, 1000);
expect(stream.startedAt, '2024-01-01T00:00:00Z');
expect(stream.thumbnailUrl, 'https://cdn/thumb.jpg');
});
});
group('StreamsTwitch', () {
test('fromJson deserializes data and pagination', () {
final streams = StreamsTwitch.fromJson(
twitchStreamResponse as Map<String, dynamic>,
);
expect(streams.data.length, 1);
expect(streams.data.first.userName, 'TestUser');
expect(streams.pagination['cursor'], 'next_cursor');
});
test('fromJson handles empty data list', () {
final streams = StreamsTwitch.fromJson(const {
'data': <dynamic>[],
'pagination': <String, String>{},
});
expect(streams.data, isEmpty);
});
});
}

View file

@ -0,0 +1,44 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:frosty/models/user.dart';
import '../fixtures/api_responses.dart';
void main() {
group('UserTwitch', () {
test('fromJson deserializes snake_case fields', () {
final user = UserTwitch.fromJson(
(twitchUserResponse['data']! as List).first as Map<String, dynamic>,
);
expect(user.id, '12345');
expect(user.login, 'testuser');
expect(user.displayName, 'TestUser');
expect(user.profileImageUrl, 'https://cdn/profile.png');
});
});
group('UserBlockedTwitch', () {
test('fromJson deserializes snake_case fields', () {
final blocked = UserBlockedTwitch.fromJson(
(twitchBlockedUsersResponse['data']! as List).first
as Map<String, dynamic>,
);
expect(blocked.userId, '99999');
expect(blocked.userLogin, 'blockeduser');
expect(blocked.displayName, 'BlockedUser');
});
test('fromJson with different values', () {
final blocked = UserBlockedTwitch.fromJson(const {
'user_id': '11111',
'user_login': 'another_blocked',
'display_name': 'AnotherBlocked',
});
expect(blocked.userId, '11111');
expect(blocked.userLogin, 'another_blocked');
expect(blocked.displayName, 'AnotherBlocked');
});
});
}