diff --git a/pubspec.lock b/pubspec.lock index 599ea649..9d4a2ad6 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -608,6 +608,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.6.0" + http_mock_adapter: + dependency: "direct dev" + description: + name: http_mock_adapter + sha256: "46399c78bd4a0af071978edd8c502d7aeeed73b5fb9860bca86b5ed647a63c1b" + url: "https://pub.dev" + source: hosted + version: "0.6.1" http_multi_server: dependency: transitive description: @@ -720,6 +728,14 @@ packages: url: "https://pub.dev" source: hosted version: "6.0.0" + logger: + dependency: transitive + description: + name: logger + sha256: a7967e31b703831a893bbc3c3dd11db08126fe5f369b5c648a36f821979f5be3 + url: "https://pub.dev" + source: hosted + version: "2.6.2" logging: dependency: transitive description: @@ -784,6 +800,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.7.4" + mocktail: + dependency: "direct dev" + description: + name: mocktail + sha256: "890df3f9688106f25755f26b1c60589a92b3ab91a22b8b224947ad041bf172d8" + url: "https://pub.dev" + source: hosted + version: "1.0.4" native_device_orientation: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index 51ec65a3..3be38265 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -87,6 +87,8 @@ dev_dependencies: mobx_codegen: ^2.0.6 flutter_native_splash: ^2.0.5 flutter_launcher_icons: ^0.14.1 + mocktail: ^1.0.4 + http_mock_adapter: ^0.6.1 # For information on the generic Dart part of this file, see the # following page: https://dart.dev/tools/pub/pubspec diff --git a/test/apis/base_api_client_test.dart b/test/apis/base_api_client_test.dart new file mode 100644 index 00000000..258d68dd --- /dev/null +++ b/test/apis/base_api_client_test.dart @@ -0,0 +1,403 @@ +import 'package:dio/dio.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:frosty/apis/base_api_client.dart'; +import 'package:http_mock_adapter/http_mock_adapter.dart'; + +/// Concrete test subclass to test BaseApiClient through its public interface. +class TestApiClient extends BaseApiClient { + TestApiClient(Dio dio) : super(dio, 'https://api.test.com'); + + /// Test wrapper that exercises the error handling path through get(). + Future testGet(String endpoint) => get(endpoint); +} + +void main() { + late Dio dio; + late DioAdapter dioAdapter; + late TestApiClient client; + + setUp(() { + dio = Dio(BaseOptions()); + dioAdapter = DioAdapter(dio: dio); + client = TestApiClient(dio); + }); + + group('isSuccessResponse', () { + test('returns true for 200', () { + final response = Response( + requestOptions: RequestOptions(), + statusCode: 200, + ); + expect(client.isSuccessResponse(response), isTrue); + }); + + test('returns true for 201', () { + final response = Response( + requestOptions: RequestOptions(), + statusCode: 201, + ); + expect(client.isSuccessResponse(response), isTrue); + }); + + test('returns true for 204', () { + final response = Response( + requestOptions: RequestOptions(), + statusCode: 204, + ); + expect(client.isSuccessResponse(response), isTrue); + }); + + test('returns false for 300', () { + final response = Response( + requestOptions: RequestOptions(), + statusCode: 300, + ); + expect(client.isSuccessResponse(response), isFalse); + }); + + test('returns false for 400', () { + final response = Response( + requestOptions: RequestOptions(), + statusCode: 400, + ); + expect(client.isSuccessResponse(response), isFalse); + }); + + test('returns false for 500', () { + final response = Response( + requestOptions: RequestOptions(), + statusCode: 500, + ); + expect(client.isSuccessResponse(response), isFalse); + }); + + test('returns false for null statusCode', () { + final response = Response(requestOptions: RequestOptions()); + expect(client.isSuccessResponse(response), isFalse); + }); + }); + + group('error handling through get()', () { + test('400 throws ApiException', () async { + dioAdapter.onGet( + 'https://api.test.com/test', + (server) => server.reply(400, {'message': 'Bad field'}), + ); + + expect( + () => client.testGet('/test'), + throwsA( + isA().having((e) => e.message, 'message', 'Bad field'), + ), + ); + }); + + test('401 throws UnauthorizedException', () async { + dioAdapter.onGet( + 'https://api.test.com/auth', + (server) => server.reply(401, {'message': 'Unauthorized'}), + ); + + expect( + () => client.testGet('/auth'), + throwsA(isA()), + ); + }); + + test('404 throws NotFoundException', () async { + dioAdapter.onGet( + 'https://api.test.com/missing', + (server) => server.reply(404, {'message': 'User not found'}), + ); + + expect( + () => client.testGet('/missing'), + throwsA( + isA().having( + (e) => e.message, + 'message', + 'User not found', + ), + ), + ); + }); + + test('404 with no server message uses default', () async { + dioAdapter.onGet( + 'https://api.test.com/empty404', + (server) => server.reply(404, 'not json'), + ); + + expect( + () => client.testGet('/empty404'), + throwsA( + isA().having( + (e) => e.message, + 'message', + 'Resource not found', + ), + ), + ); + }); + + test('409 throws ApiException about conflict', () async { + dioAdapter.onGet( + 'https://api.test.com/conflict', + (server) => server.reply(409, null), + ); + + expect( + () => client.testGet('/conflict'), + throwsA( + isA().having( + (e) => e.message, + 'message', + contains('Conflict'), + ), + ), + ); + }); + + test('429 throws ApiException about rate limit', () async { + dioAdapter.onGet( + 'https://api.test.com/ratelimit', + (server) => server.reply(429, null), + ); + + expect( + () => client.testGet('/ratelimit'), + throwsA( + isA().having( + (e) => e.message, + 'message', + contains('Rate limit'), + ), + ), + ); + }); + + test('500 throws ServerException', () async { + dioAdapter.onGet( + 'https://api.test.com/server-error', + (server) => server.reply(500, null), + ); + + expect( + () => client.testGet('/server-error'), + throwsA( + isA().having( + (e) => e.statusCode, + 'statusCode', + 500, + ), + ), + ); + }); + + test('502 throws ServerException', () async { + dioAdapter.onGet( + 'https://api.test.com/bad-gateway', + (server) => server.reply(502, null), + ); + + expect( + () => client.testGet('/bad-gateway'), + throwsA( + isA().having( + (e) => e.statusCode, + 'statusCode', + 502, + ), + ), + ); + }); + + test('503 throws ServerException', () async { + dioAdapter.onGet( + 'https://api.test.com/maintenance', + (server) => server.reply(503, null), + ); + + expect( + () => client.testGet('/maintenance'), + throwsA(isA()), + ); + }); + + test('504 throws ServerException', () async { + dioAdapter.onGet( + 'https://api.test.com/timeout', + (server) => server.reply(504, null), + ); + + expect( + () => client.testGet('/timeout'), + throwsA(isA()), + ); + }); + + test('422 uses server message from error field', () async { + dioAdapter.onGet( + 'https://api.test.com/unprocessable', + (server) => server.reply(422, {'error': 'Invalid email'}), + ); + + expect( + () => client.testGet('/unprocessable'), + throwsA( + isA().having( + (e) => e.message, + 'message', + 'Invalid email', + ), + ), + ); + }); + + test('400 uses error_description field', () async { + dioAdapter.onGet( + 'https://api.test.com/oauth-error', + (server) => server.reply( + 400, + {'error_description': 'OAuth error description'}, + ), + ); + + expect( + () => client.testGet('/oauth-error'), + throwsA( + isA().having( + (e) => e.message, + 'message', + 'OAuth error description', + ), + ), + ); + }); + + test('400 prefers message field over error field', () async { + dioAdapter.onGet( + 'https://api.test.com/priority', + (server) => server.reply(400, { + 'message': 'From message', + 'error': 'From error', + }), + ); + + expect( + () => client.testGet('/priority'), + throwsA( + isA().having( + (e) => e.message, + 'message', + 'From message', + ), + ), + ); + }); + + test('403 returns permission error', () async { + dioAdapter.onGet( + 'https://api.test.com/forbidden', + (server) => server.reply(403, null), + ); + + expect( + () => client.testGet('/forbidden'), + throwsA( + isA().having( + (e) => e.message, + 'message', + contains('permissions'), + ), + ), + ); + }); + + test('unknown status code throws ServerException', () async { + dioAdapter.onGet( + 'https://api.test.com/teapot', + (server) => server.reply(418, null), + ); + + expect( + () => client.testGet('/teapot'), + throwsA( + isA().having( + (e) => e.statusCode, + 'statusCode', + 418, + ), + ), + ); + }); + }); + + group('successful get()', () { + test('returns data on success', () async { + dioAdapter.onGet( + 'https://api.test.com/users', + (server) => server.reply(200, {'data': 'test'}), + ); + + final result = await client.testGet('/users'); + expect(result, {'data': 'test'}); + }); + + test('handles absolute URL', () async { + dioAdapter.onGet( + 'https://other.com/endpoint', + (server) => server.reply(200, {'result': 'ok'}), + ); + + final result = await client.testGet('https://other.com/endpoint'); + expect(result, {'result': 'ok'}); + }); + }); + + group('ApiException', () { + test('toString includes message', () { + const exception = ApiException('Test error'); + expect(exception.toString(), 'ApiException: Test error'); + }); + + test('stores status code and type', () { + const exception = ApiException( + 'Error', + 404, + DioExceptionType.badResponse, + ); + expect(exception.statusCode, 404); + expect(exception.type, DioExceptionType.badResponse); + }); + }); + + group('Exception subtypes', () { + test('NetworkException has connectionError type', () { + const exception = NetworkException('No network'); + expect(exception.type, DioExceptionType.connectionError); + expect(exception.statusCode, isNull); + }); + + test('TimeoutException has connectionTimeout type', () { + const exception = TimeoutException('Timed out'); + expect(exception.type, DioExceptionType.connectionTimeout); + }); + + test('ServerException has badResponse type and status code', () { + const exception = ServerException('Server error', 500); + expect(exception.type, DioExceptionType.badResponse); + expect(exception.statusCode, 500); + }); + + test('NotFoundException has 404 status code', () { + const exception = NotFoundException('Not found'); + expect(exception.statusCode, 404); + }); + + test('UnauthorizedException has 401 status code', () { + const exception = UnauthorizedException('Unauthorized'); + expect(exception.statusCode, 401); + }); + }); +} diff --git a/test/apis/bttv_api_test.dart b/test/apis/bttv_api_test.dart new file mode 100644 index 00000000..e78004be --- /dev/null +++ b/test/apis/bttv_api_test.dart @@ -0,0 +1,106 @@ +import 'package:dio/dio.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:frosty/apis/base_api_client.dart'; +import 'package:frosty/apis/bttv_api.dart'; +import 'package:frosty/models/emotes.dart'; +import 'package:http_mock_adapter/http_mock_adapter.dart'; + +import '../fixtures/api_responses.dart'; + +void main() { + late Dio dio; + late DioAdapter dioAdapter; + late BTTVApi api; + + setUp(() { + dio = Dio(BaseOptions()); + dioAdapter = DioAdapter(dio: dio); + api = BTTVApi(dio); + }); + + group('getEmotesGlobal', () { + test('returns list of global BTTV emotes', () async { + dioAdapter.onGet( + 'https://api.betterttv.net/3/cached/emotes/global', + (server) => server.reply(200, bttvEmotesGlobalResponse), + ); + + final emotes = await api.getEmotesGlobal(); + + expect(emotes, hasLength(2)); + expect(emotes[0].name, 'SourPls'); + expect(emotes[0].type, EmoteType.bttvGlobal); + expect(emotes[0].url, contains('bttv1')); + expect(emotes[1].name, 'catJAM'); + }); + + test('returns empty list for empty response', () async { + dioAdapter.onGet( + 'https://api.betterttv.net/3/cached/emotes/global', + (server) => server.reply(200, []), + ); + + final emotes = await api.getEmotesGlobal(); + + expect(emotes, isEmpty); + }); + }); + + group('getEmotesChannel', () { + test('combines channel and shared emotes', () async { + dioAdapter.onGet( + 'https://api.betterttv.net/3/cached/users/twitch/12345', + (server) => server.reply(200, bttvEmotesChannelResponse), + ); + + final emotes = await api.getEmotesChannel(id: '12345'); + + expect(emotes, hasLength(3)); + expect(emotes[0].name, 'ChannelEmote'); + expect(emotes[0].type, EmoteType.bttvChannel); + expect(emotes[1].name, 'SharedEmote'); + expect(emotes[1].type, EmoteType.bttvShared); + expect(emotes[2].name, 'SharedEmote2'); + expect(emotes[2].type, EmoteType.bttvShared); + }); + + test('throws on 404 for unknown channel', () { + dioAdapter.onGet( + 'https://api.betterttv.net/3/cached/users/twitch/99999', + (server) => server.reply(404, {'message': 'user not found'}), + ); + + expect( + () => api.getEmotesChannel(id: '99999'), + throwsA(isA()), + ); + }); + }); + + group('getBadges', () { + test('returns map of userId to ChatBadge', () async { + dioAdapter.onGet( + 'https://api.betterttv.net/3/cached/badges', + (server) => server.reply(200, bttvBadgesResponse), + ); + + final badges = await api.getBadges(); + + expect(badges, hasLength(2)); + expect(badges['user123']!.name, 'BTTV Developer'); + expect(badges['user123']!.url, contains('dev.svg')); + expect(badges['user456']!.name, 'BTTV Supporter'); + }); + + test('returns empty map for empty response', () async { + dioAdapter.onGet( + 'https://api.betterttv.net/3/cached/badges', + (server) => server.reply(200, []), + ); + + final badges = await api.getBadges(); + + expect(badges, isEmpty); + }); + }); +} diff --git a/test/apis/ffz_api_test.dart b/test/apis/ffz_api_test.dart new file mode 100644 index 00000000..c33ca470 --- /dev/null +++ b/test/apis/ffz_api_test.dart @@ -0,0 +1,192 @@ +import 'package:dio/dio.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:frosty/apis/base_api_client.dart'; +import 'package:frosty/apis/ffz_api.dart'; +import 'package:frosty/models/badges.dart'; +import 'package:frosty/models/emotes.dart'; +import 'package:http_mock_adapter/http_mock_adapter.dart'; + +import '../fixtures/api_responses.dart'; + +void main() { + late Dio dio; + late DioAdapter dioAdapter; + late FFZApi api; + + setUp(() { + dio = Dio(BaseOptions()); + dioAdapter = DioAdapter(dio: dio); + api = FFZApi(dio); + }); + + group('getEmotesGlobal', () { + test('returns global FFZ emotes from default sets', () async { + dioAdapter.onGet( + 'https://api.frankerfacez.com/v1/set/global', + (server) => server.reply(200, ffzEmotesGlobalResponse), + ); + + final emotes = await api.getEmotesGlobal(); + + expect(emotes, hasLength(1)); + expect(emotes.first.name, 'LULW'); + expect(emotes.first.type, EmoteType.ffzGlobal); + expect(emotes.first.ownerDisplayName, 'FFZOwner'); + expect(emotes.first.ownerUsername, 'ffzowner'); + }); + + test('returns emotes from multiple default sets', () async { + dioAdapter.onGet( + 'https://api.frankerfacez.com/v1/set/global', + (server) => server.reply(200, { + 'default_sets': [1, 2], + 'sets': { + '1': { + 'emoticons': [ + { + 'name': 'LULW', + 'height': 28, + 'width': 28, + 'owner': {'display_name': 'A', 'name': 'a'}, + 'urls': {'1': 'https://cdn/1x.png'}, + 'animated': null, + }, + ], + }, + '2': { + 'emoticons': [ + { + 'name': 'KEKW', + 'height': 32, + 'width': 32, + 'owner': {'display_name': 'B', 'name': 'b'}, + 'urls': {'1': 'https://cdn/kekw.png'}, + 'animated': null, + }, + ], + }, + }, + }), + ); + + final emotes = await api.getEmotesGlobal(); + + expect(emotes, hasLength(2)); + expect(emotes[0].name, 'LULW'); + expect(emotes[1].name, 'KEKW'); + }); + }); + + group('getRoomInfo', () { + test('returns room info and channel emotes', () async { + dioAdapter.onGet( + 'https://api.frankerfacez.com/v1/room/id/12345', + (server) => server.reply(200, ffzRoomInfoResponse), + ); + + final (roomInfo, emotes) = await api.getRoomInfo(id: '12345'); + + expect(roomInfo.set, 123); + expect(roomInfo.vipBadge, isNull); + expect(roomInfo.modUrls, isNull); + expect(emotes, hasLength(1)); + expect(emotes.first.name, 'ChannelFFZ'); + expect(emotes.first.type, EmoteType.ffzChannel); + }); + + test('returns room info with VIP badge', () async { + dioAdapter.onGet( + 'https://api.frankerfacez.com/v1/room/id/54321', + (server) => server.reply(200, { + 'room': { + 'set': 456, + 'vip_badge': { + '1': 'https://cdn/vip/1x.png', + '2': 'https://cdn/vip/2x.png', + '4': 'https://cdn/vip/4x.png', + }, + 'mod_urls': null, + }, + 'sets': { + '456': { + 'emoticons': [], + }, + }, + }), + ); + + final (roomInfo, emotes) = await api.getRoomInfo(id: '54321'); + + expect(roomInfo.vipBadge, isNotNull); + expect(roomInfo.vipBadge!.url1x, 'https://cdn/vip/1x.png'); + expect(emotes, isEmpty); + }); + + test('throws NotFoundException for unknown channel', () { + dioAdapter.onGet( + 'https://api.frankerfacez.com/v1/room/id/99999', + (server) => server.reply(404, {'error': 'No such room'}), + ); + + expect( + () => api.getRoomInfo(id: '99999'), + throwsA(isA()), + ); + }); + }); + + group('getBadges', () { + test('builds user-to-badge map with reversed badge order', () async { + dioAdapter.onGet( + 'https://api.frankerfacez.com/v1/badges/ids', + (server) => server.reply(200, ffzBadgesResponse), + ); + + final badges = await api.getBadges(); + + // User 22222 appears in both badge lists, so should have 2 badges. + // Reversed iteration means badge 2 (Supporter) is processed first, + // then badge 1 (Developer). + expect(badges['22222'], hasLength(2)); + expect(badges['22222']![0].name, 'FFZ Supporter'); + expect(badges['22222']![1].name, 'FFZ Developer'); + + // User 11111 only appears in badge 1 + expect(badges['11111'], hasLength(1)); + expect(badges['11111']![0].name, 'FFZ Developer'); + + // User 33333 only appears in badge 2 + expect(badges['33333'], hasLength(1)); + expect(badges['33333']![0].name, 'FFZ Supporter'); + }); + + test('badge entries have correct type', () async { + dioAdapter.onGet( + 'https://api.frankerfacez.com/v1/badges/ids', + (server) => server.reply(200, ffzBadgesResponse), + ); + + final badges = await api.getBadges(); + + for (final badgeList in badges.values) { + for (final badge in badgeList) { + expect(badge.type, BadgeType.ffz); + } + } + }); + + test('returns empty map when no badges exist', () async { + dioAdapter.onGet( + 'https://api.frankerfacez.com/v1/badges/ids', + (server) => server.reply(200, { + 'badges': [], + 'users': {}, + }), + ); + + final badges = await api.getBadges(); + + expect(badges, isEmpty); + }); + }); +} diff --git a/test/apis/seventv_api_test.dart b/test/apis/seventv_api_test.dart new file mode 100644 index 00000000..a9d90057 --- /dev/null +++ b/test/apis/seventv_api_test.dart @@ -0,0 +1,142 @@ +import 'package:dio/dio.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:frosty/apis/base_api_client.dart'; +import 'package:frosty/apis/seventv_api.dart'; +import 'package:frosty/models/emotes.dart'; +import 'package:http_mock_adapter/http_mock_adapter.dart'; + +import '../fixtures/api_responses.dart'; + +void main() { + late Dio dio; + late DioAdapter dioAdapter; + late SevenTVApi api; + + setUp(() { + dio = Dio(BaseOptions()); + dioAdapter = DioAdapter(dio: dio); + api = SevenTVApi(dio); + }); + + group('getEmotesGlobal', () { + test('returns list of global 7TV emotes', () async { + dioAdapter.onGet( + 'https://7tv.io/v3/emote-sets/global', + (server) => server.reply(200, sevenTVEmotesGlobalResponse), + ); + + final emotes = await api.getEmotesGlobal(); + + expect(emotes, hasLength(1)); + expect(emotes.first.name, 'EZ'); + expect(emotes.first.type, EmoteType.sevenTVGlobal); + expect(emotes.first.url, contains('7tv.app')); + expect(emotes.first.url, contains('4x.webp')); + expect(emotes.first.ownerDisplayName, 'Creator'); + expect(emotes.first.ownerUsername, 'creator'); + }); + + test('returns empty list for empty emotes', () async { + dioAdapter.onGet( + 'https://7tv.io/v3/emote-sets/global', + (server) => server.reply(200, { + 'emotes': [], + }), + ); + + final emotes = await api.getEmotesGlobal(); + + expect(emotes, isEmpty); + }); + }); + + group('getEmotesChannel', () { + test('returns emote set ID and channel emotes', () async { + dioAdapter.onGet( + 'https://7tv.io/v3/users/twitch/12345', + (server) => server.reply(200, sevenTVEmotesChannelResponse), + ); + + final (emoteSetId, emotes) = await api.getEmotesChannel(id: '12345'); + + expect(emoteSetId, 'set_abc123'); + expect(emotes, hasLength(1)); + expect(emotes.first.name, 'Clap'); + expect(emotes.first.type, EmoteType.sevenTVChannel); + }); + + test('filters out emotes with empty URLs', () async { + dioAdapter.onGet( + 'https://7tv.io/v3/users/twitch/12345', + (server) => server.reply(200, { + 'emote_set': { + 'id': 'set_filter', + 'emotes': [ + { + 'id': 'valid', + 'name': 'ValidEmote', + 'data': { + 'id': 'valid', + 'name': 'ValidEmote', + 'flags': 0, + 'owner': null, + 'host': { + 'url': '//cdn.7tv.app/emote/valid', + 'files': [ + { + 'name': '1x.webp', + 'width': 32, + 'height': 32, + 'format': 'WEBP', + }, + ], + }, + }, + }, + { + 'id': 'empty_url', + 'name': 'NoUrlEmote', + 'data': { + 'id': 'empty_url', + 'name': 'NoUrlEmote', + 'flags': 0, + 'owner': null, + 'host': { + 'url': '//cdn.7tv.app/emote/empty_url', + // Only AVIF files — these get filtered by lastWhereOrNull, + // resulting in empty URL + 'files': [ + { + 'name': '1x.avif', + 'width': 32, + 'height': 32, + 'format': 'AVIF', + }, + ], + }, + }, + }, + ], + }, + }), + ); + + final (_, emotes) = await api.getEmotesChannel(id: '12345'); + + expect(emotes, hasLength(1)); + expect(emotes.first.name, 'ValidEmote'); + }); + + test('throws on 404 for unknown user', () { + dioAdapter.onGet( + 'https://7tv.io/v3/users/twitch/99999', + (server) => server.reply(404, {'message': 'Unknown User'}), + ); + + expect( + () => api.getEmotesChannel(id: '99999'), + throwsA(isA()), + ); + }); + }); +} diff --git a/test/apis/twitch_api_test.dart b/test/apis/twitch_api_test.dart new file mode 100644 index 00000000..5b5df81a --- /dev/null +++ b/test/apis/twitch_api_test.dart @@ -0,0 +1,392 @@ +import 'package:dio/dio.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:frosty/apis/base_api_client.dart'; +import 'package:frosty/apis/twitch_api.dart'; +import 'package:frosty/models/emotes.dart'; +import 'package:http_mock_adapter/http_mock_adapter.dart'; + +import '../fixtures/api_responses.dart'; + +void main() { + late Dio dio; + late DioAdapter dioAdapter; + late TwitchApi api; + + setUp(() { + dio = Dio(BaseOptions()); + dioAdapter = DioAdapter(dio: dio); + api = TwitchApi(dio); + }); + + group('getEmotesGlobal', () { + test('returns list of global Twitch emotes', () async { + dioAdapter.onGet( + 'https://api.twitch.tv/helix/chat/emotes/global', + (server) => server.reply(200, twitchEmotesGlobalResponse), + ); + + final emotes = await api.getEmotesGlobal(); + + expect(emotes, hasLength(2)); + expect(emotes[0].name, 'Kappa'); + expect(emotes[0].type, EmoteType.twitchGlobal); + expect(emotes[0].url, contains('25')); + expect(emotes[1].name, 'Keepo'); + }); + }); + + group('getBadgesGlobal', () { + test('returns map of badge set/version to ChatBadge', () async { + dioAdapter.onGet( + 'https://api.twitch.tv/helix/chat/badges/global', + (server) => server.reply(200, twitchBadgesGlobalResponse), + ); + + final badges = await api.getBadgesGlobal(); + + expect(badges, hasLength(1)); + expect(badges['subscriber/0'], isNotNull); + expect(badges['subscriber/0']!.name, 'Subscriber'); + expect(badges['subscriber/0']!.url, contains('4x.png')); + }); + + test('handles multiple versions per badge set', () async { + dioAdapter.onGet( + 'https://api.twitch.tv/helix/chat/badges/global', + (server) => server.reply(200, { + 'data': [ + { + 'set_id': 'subscriber', + 'versions': [ + { + 'id': '0', + 'image_url_1x': 'https://cdn/1x.png', + 'image_url_2x': 'https://cdn/2x.png', + 'image_url_4x': 'https://cdn/4x.png', + 'title': 'Subscriber', + 'description': '0 months', + }, + { + 'id': '3', + 'image_url_1x': 'https://cdn/3mo/1x.png', + 'image_url_2x': 'https://cdn/3mo/2x.png', + 'image_url_4x': 'https://cdn/3mo/4x.png', + 'title': '3-Month Subscriber', + 'description': '3 months', + }, + ], + }, + ], + }), + ); + + final badges = await api.getBadgesGlobal(); + + expect(badges, hasLength(2)); + expect(badges['subscriber/0']!.name, 'Subscriber'); + expect(badges['subscriber/3']!.name, '3-Month Subscriber'); + }); + }); + + group('getUser', () { + test('returns user by login', () async { + dioAdapter.onGet( + 'https://api.twitch.tv/helix/users', + (server) => server.reply(200, twitchUserResponse), + queryParameters: {'login': 'testuser'}, + ); + + final user = await api.getUser(userLogin: 'testuser'); + + expect(user.id, '12345'); + expect(user.login, 'testuser'); + expect(user.displayName, 'TestUser'); + expect(user.profileImageUrl, 'https://cdn/profile.png'); + }); + + test('returns user by id', () async { + dioAdapter.onGet( + 'https://api.twitch.tv/helix/users', + (server) => server.reply(200, twitchUserResponse), + queryParameters: {'id': '12345'}, + ); + + final user = await api.getUser(id: '12345'); + + expect(user.id, '12345'); + }); + + test('throws NotFoundException for unknown user', () { + dioAdapter.onGet( + 'https://api.twitch.tv/helix/users', + (server) => server.reply(200, { + 'data': [], + }), + queryParameters: {'login': 'doesnotexist'}, + ); + + expect( + () => api.getUser(userLogin: 'doesnotexist'), + throwsA(isA()), + ); + }); + }); + + group('getStream', () { + test('returns stream for online user', () async { + dioAdapter.onGet( + 'https://api.twitch.tv/helix/streams', + (server) => server.reply(200, twitchStreamResponse), + queryParameters: {'user_login': 'testuser'}, + ); + + final stream = await api.getStream(userLogin: 'testuser'); + + expect(stream.userName, 'TestUser'); + expect(stream.gameName, 'Just Chatting'); + expect(stream.viewerCount, 1000); + }); + + test('throws ApiException for offline user', () { + dioAdapter.onGet( + 'https://api.twitch.tv/helix/streams', + (server) => server.reply(200, { + 'data': [], + 'pagination': {}, + }), + queryParameters: {'user_login': 'offlineuser'}, + ); + + expect( + () => api.getStream(userLogin: 'offlineuser'), + throwsA( + isA().having( + (e) => e.message, + 'message', + contains('offlineuser'), + ), + ), + ); + }); + }); + + group('getStreamsByIds', () { + test('builds query string for multiple user IDs', () async { + // getStreamsByIds manually builds URL: /streams?user_id=1&user_id=2&first=100 + dioAdapter.onGet( + 'https://api.twitch.tv/helix/streams?user_id=111&user_id=222&first=100', + (server) => server.reply(200, { + 'data': [ + { + 'user_id': '111', + 'user_login': 'user1', + 'user_name': 'User1', + 'game_id': '1', + 'game_name': 'Game', + 'title': 'Stream 1', + 'viewer_count': 100, + 'started_at': '2024-01-01T00:00:00Z', + 'thumbnail_url': 'https://cdn/1.jpg', + }, + { + 'user_id': '222', + 'user_login': 'user2', + 'user_name': 'User2', + 'game_id': '2', + 'game_name': 'Game 2', + 'title': 'Stream 2', + 'viewer_count': 200, + 'started_at': '2024-01-01T00:00:00Z', + 'thumbnail_url': 'https://cdn/2.jpg', + }, + ], + 'pagination': {}, + }), + ); + + final result = await api.getStreamsByIds( + userIds: ['111', '222'], + ); + + expect(result.data, hasLength(2)); + expect(result.data[0].userName, 'User1'); + expect(result.data[1].userName, 'User2'); + }); + }); + + group('validateToken', () { + test('returns true for valid token', () async { + dioAdapter.onGet( + 'https://id.twitch.tv/oauth2/validate', + (server) => server.reply(200, twitchValidateTokenResponse), + ); + + final isValid = await api.validateToken(token: 'valid_token'); + + expect(isValid, isTrue); + }); + + test('returns false for expired token (401)', () async { + dioAdapter.onGet( + 'https://id.twitch.tv/oauth2/validate', + (server) => server.reply(401, {'message': 'invalid access token'}), + ); + + final isValid = await api.validateToken(token: 'expired_token'); + + expect(isValid, isFalse); + }); + + test('rethrows on server error', () { + dioAdapter.onGet( + 'https://id.twitch.tv/oauth2/validate', + (server) => server.reply(500, null), + ); + + expect( + () => api.validateToken(token: 'some_token'), + throwsA(isA()), + ); + }); + }); + + group('getUserBlockedList', () { + test('returns blocked users from single page', () async { + dioAdapter.onGet( + 'https://api.twitch.tv/helix/users/blocks', + (server) => server.reply(200, { + 'data': [ + { + 'user_id': '99999', + 'user_login': 'blockeduser', + 'display_name': 'BlockedUser', + }, + ], + 'pagination': {'cursor': null}, + }), + queryParameters: {'first': '100', 'broadcaster_id': '12345'}, + ); + + final blocked = await api.getUserBlockedList(id: '12345'); + + expect(blocked, hasLength(1)); + expect(blocked.first.userId, '99999'); + expect(blocked.first.userLogin, 'blockeduser'); + }); + + test('returns empty list when no users blocked', () async { + dioAdapter.onGet( + 'https://api.twitch.tv/helix/users/blocks', + (server) => server.reply(200, { + 'data': [], + 'pagination': {'cursor': null}, + }), + queryParameters: {'first': '100', 'broadcaster_id': '12345'}, + ); + + final blocked = await api.getUserBlockedList(id: '12345'); + + expect(blocked, isEmpty); + }); + }); + + group('getTopStreams', () { + test('returns streams with pagination', () async { + dioAdapter.onGet( + 'https://api.twitch.tv/helix/streams', + (server) => server.reply(200, twitchStreamResponse), + ); + + final streams = await api.getTopStreams(); + + expect(streams.data, hasLength(1)); + expect(streams.data.first.userName, 'TestUser'); + expect(streams.pagination['cursor'], 'next_cursor'); + }); + + test('passes cursor for pagination', () async { + dioAdapter.onGet( + 'https://api.twitch.tv/helix/streams', + (server) => server.reply(200, { + 'data': [], + 'pagination': {}, + }), + queryParameters: {'after': 'page2_cursor'}, + ); + + final streams = await api.getTopStreams(cursor: 'page2_cursor'); + + expect(streams.data, isEmpty); + }); + }); + + group('getChannel', () { + test('returns channel info', () async { + dioAdapter.onGet( + 'https://api.twitch.tv/helix/channels', + (server) => server.reply(200, twitchChannelResponse), + queryParameters: {'broadcaster_id': '12345'}, + ); + + final channel = await api.getChannel(userId: '12345'); + + expect(channel.broadcasterId, '12345'); + expect(channel.broadcasterName, 'TestUser'); + expect(channel.title, 'Test Stream'); + expect(channel.gameName, 'Just Chatting'); + }); + + test('throws ApiException for nonexistent channel', () { + dioAdapter.onGet( + 'https://api.twitch.tv/helix/channels', + (server) => server.reply(200, { + 'data': [], + }), + queryParameters: {'broadcaster_id': '99999'}, + ); + + expect( + () => api.getChannel(userId: '99999'), + throwsA(isA()), + ); + }); + }); + + group('getEmotesChannel', () { + test('maps emote types correctly', () async { + dioAdapter.onGet( + 'https://api.twitch.tv/helix/chat/emotes', + (server) => server.reply(200, { + 'data': [ + { + 'id': '1', + 'name': 'BitsEmote', + 'emote_type': 'bitstier', + 'owner_id': null, + }, + { + 'id': '2', + 'name': 'FollowerEmote', + 'emote_type': 'follower', + 'owner_id': null, + }, + { + 'id': '3', + 'name': 'SubEmote', + 'emote_type': 'subscriptions', + 'owner_id': '12345', + }, + ], + }), + queryParameters: {'broadcaster_id': '12345'}, + ); + + final emotes = await api.getEmotesChannel(id: '12345'); + + expect(emotes, hasLength(3)); + expect(emotes[0].type, EmoteType.twitchBits); + expect(emotes[1].type, EmoteType.twitchFollower); + expect(emotes[2].type, EmoteType.twitchChannel); + }); + }); +} diff --git a/test/apis/twitch_auth_interceptor_test.dart b/test/apis/twitch_auth_interceptor_test.dart new file mode 100644 index 00000000..fa4316a0 --- /dev/null +++ b/test/apis/twitch_auth_interceptor_test.dart @@ -0,0 +1,95 @@ +import 'package:dio/dio.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:frosty/apis/twitch_auth_interceptor.dart'; +import 'package:frosty/screens/settings/stores/auth_store.dart'; +import 'package:mocktail/mocktail.dart'; + +class MockAuthStore extends Mock implements AuthStore {} + +void main() { + late MockAuthStore mockAuthStore; + late TwitchAuthInterceptor interceptor; + + setUp(() { + mockAuthStore = MockAuthStore(); + interceptor = TwitchAuthInterceptor(mockAuthStore); + + when(() => mockAuthStore.headersTwitch).thenReturn({ + 'Authorization': 'Bearer test_token', + 'Client-Id': 'test_client_id', + }); + }); + + group('TwitchAuthInterceptor', () { + test('adds headers for Twitch Helix API requests', () { + final options = RequestOptions( + path: 'https://api.twitch.tv/helix/users', + ); + + final handler = RequestInterceptorHandler(); + + // We test by calling onRequest and checking that headers were added + interceptor.onRequest(options, handler); + + expect(options.headers['Authorization'], 'Bearer test_token'); + expect(options.headers['Client-Id'], 'test_client_id'); + }); + + test('adds headers for Twitch OAuth requests', () { + final options = RequestOptions( + path: 'https://id.twitch.tv/oauth2/validate', + ); + + final handler = RequestInterceptorHandler(); + interceptor.onRequest(options, handler); + + expect(options.headers['Authorization'], 'Bearer test_token'); + expect(options.headers['Client-Id'], 'test_client_id'); + }); + + test('does not add headers for BTTV API requests', () { + final options = RequestOptions( + path: 'https://api.betterttv.net/3/cached/emotes/global', + ); + + final handler = RequestInterceptorHandler(); + interceptor.onRequest(options, handler); + + expect(options.headers.containsKey('Authorization'), isFalse); + expect(options.headers.containsKey('Client-Id'), isFalse); + }); + + test('does not add headers for FFZ API requests', () { + final options = RequestOptions( + path: 'https://api.frankerfacez.com/v1/set/global', + ); + + final handler = RequestInterceptorHandler(); + interceptor.onRequest(options, handler); + + expect(options.headers.containsKey('Authorization'), isFalse); + }); + + test('does not add headers for 7TV API requests', () { + final options = RequestOptions( + path: 'https://7tv.io/v3/emote-sets/global', + ); + + final handler = RequestInterceptorHandler(); + interceptor.onRequest(options, handler); + + expect(options.headers.containsKey('Authorization'), isFalse); + }); + + test('does not add headers for other URLs', () { + final options = RequestOptions( + path: 'https://example.com/api/data', + ); + + final handler = RequestInterceptorHandler(); + interceptor.onRequest(options, handler); + + expect(options.headers.containsKey('Authorization'), isFalse); + }); + }); +} diff --git a/test/fixtures/api_responses.dart b/test/fixtures/api_responses.dart new file mode 100644 index 00000000..8aefa1e5 --- /dev/null +++ b/test/fixtures/api_responses.dart @@ -0,0 +1,350 @@ +/// Test fixtures containing sample API response data. +/// Reused across model serialization and API service tests. +library; + +// ── Twitch API Responses ── + +const twitchEmotesGlobalResponse = { + 'data': [ + { + 'id': '25', + 'name': 'Kappa', + 'emote_type': 'bitstier', + 'owner_id': null, + }, + { + 'id': '1902', + 'name': 'Keepo', + 'emote_type': 'globals', + 'owner_id': null, + }, + ], +}; + +const twitchBadgesGlobalResponse = { + 'data': [ + { + 'set_id': 'subscriber', + 'versions': [ + { + 'id': '0', + 'image_url_1x': 'https://cdn/sub/1x.png', + 'image_url_2x': 'https://cdn/sub/2x.png', + 'image_url_4x': 'https://cdn/sub/4x.png', + 'title': 'Subscriber', + 'description': '0 months', + }, + ], + }, + ], +}; + +const twitchUserResponse = { + 'data': [ + { + 'id': '12345', + 'login': 'testuser', + 'display_name': 'TestUser', + 'profile_image_url': 'https://cdn/profile.png', + }, + ], +}; + +const twitchStreamResponse = { + 'data': [ + { + 'user_id': '12345', + 'user_login': 'testuser', + 'user_name': 'TestUser', + 'game_id': '509658', + 'game_name': 'Just Chatting', + 'title': 'Test Stream', + 'viewer_count': 1000, + 'started_at': '2024-01-01T00:00:00Z', + 'thumbnail_url': 'https://cdn/thumb.jpg', + }, + ], + 'pagination': {'cursor': 'next_cursor'}, +}; + +const twitchCategoryResponse = { + 'data': [ + { + 'box_art_url': 'https://cdn/boxart.jpg', + 'id': '509658', + 'name': 'Just Chatting', + }, + ], + 'pagination': {'cursor': 'cat_cursor'}, +}; + +const twitchChannelResponse = { + 'data': [ + { + 'broadcaster_id': '12345', + 'broadcaster_login': 'testuser', + 'broadcaster_name': 'TestUser', + 'broadcaster_language': 'en', + 'title': 'Test Stream', + 'game_id': '509658', + 'game_name': 'Just Chatting', + }, + ], +}; + +const twitchChannelQueryResponse = { + 'data': [ + { + 'broadcaster_login': 'testuser', + 'display_name': 'TestUser', + 'id': '12345', + 'is_live': true, + 'started_at': '2024-01-01T00:00:00Z', + }, + ], +}; + +const twitchBlockedUsersResponse = { + 'data': [ + { + 'user_id': '99999', + 'user_login': 'blockeduser', + 'display_name': 'BlockedUser', + }, + ], + 'pagination': {'cursor': null}, +}; + +const twitchSharedChatSessionResponse = { + 'data': [ + { + 'session_id': 'session_123', + 'host_broadcaster_id': '12345', + 'participants': [ + {'broadcaster_id': '12345'}, + {'broadcaster_id': '67890'}, + ], + 'created_at': '2024-01-01T00:00:00Z', + 'updated_at': '2024-01-01T01:00:00Z', + }, + ], +}; + +const twitchTokenResponse = { + 'access_token': 'test_token_abc123', + 'token_type': 'bearer', + 'expires_in': 5000000, +}; + +const twitchValidateTokenResponse = { + 'client_id': 'test_client_id', + 'login': 'testuser', + 'scopes': ['chat:read', 'chat:edit'], + 'user_id': '12345', + 'expires_in': 5000000, +}; + +// ── BTTV API Responses ── + +const bttvEmotesGlobalResponse = [ + {'id': 'bttv1', 'code': 'SourPls'}, + {'id': 'bttv2', 'code': 'catJAM'}, +]; + +const bttvEmotesChannelResponse = { + 'channelEmotes': [ + {'id': 'ch1', 'code': 'ChannelEmote'}, + ], + 'sharedEmotes': [ + {'id': 'sh1', 'code': 'SharedEmote'}, + {'id': 'sh2', 'code': 'SharedEmote2'}, + ], +}; + +const bttvBadgesResponse = [ + { + 'providerId': 'user123', + 'badge': { + 'description': 'BTTV Developer', + 'svg': 'https://cdn.bttv.net/badge/dev.svg', + }, + }, + { + 'providerId': 'user456', + 'badge': { + 'description': 'BTTV Supporter', + 'svg': 'https://cdn.bttv.net/badge/supporter.svg', + }, + }, +]; + +// ── FFZ API Responses ── + +const ffzEmotesGlobalResponse = { + 'default_sets': [1], + 'sets': { + '1': { + 'emoticons': [ + { + 'name': 'LULW', + 'height': 28, + 'width': 28, + 'owner': {'display_name': 'FFZOwner', 'name': 'ffzowner'}, + 'urls': { + '1': 'https://cdn.ffz.net/1x.png', + '2': 'https://cdn.ffz.net/2x.png', + '4': 'https://cdn.ffz.net/4x.png', + }, + 'animated': null, + }, + ], + }, + }, +}; + +const ffzRoomInfoResponse = { + 'room': { + 'set': 123, + 'vip_badge': null, + 'mod_urls': null, + }, + 'sets': { + '123': { + 'emoticons': [ + { + 'name': 'ChannelFFZ', + 'height': 32, + 'width': 32, + 'owner': {'display_name': 'Streamer', 'name': 'streamer'}, + 'urls': { + '1': 'https://cdn.ffz.net/ch/1x.png', + '2': 'https://cdn.ffz.net/ch/2x.png', + '4': 'https://cdn.ffz.net/ch/4x.png', + }, + 'animated': null, + }, + ], + }, + }, +}; + +const ffzBadgesResponse = { + 'badges': [ + { + 'id': 1, + 'title': 'FFZ Developer', + 'color': '#FF0000', + 'urls': { + '1': 'https://cdn.ffz.net/badge/1x.png', + '2': 'https://cdn.ffz.net/badge/2x.png', + '4': 'https://cdn.ffz.net/badge/4x.png', + }, + }, + { + 'id': 2, + 'title': 'FFZ Supporter', + 'color': '#00FF00', + 'urls': { + '1': 'https://cdn.ffz.net/badge2/1x.png', + '2': 'https://cdn.ffz.net/badge2/2x.png', + '4': 'https://cdn.ffz.net/badge2/4x.png', + }, + }, + ], + 'users': { + '1': [11111, 22222], + '2': [22222, 33333], + }, +}; + +// ── 7TV API Responses ── + +const sevenTVEmotesGlobalResponse = { + 'emotes': [ + { + 'id': '7tv1', + 'name': 'EZ', + 'data': { + 'id': '7tv1', + 'name': 'EZ', + 'flags': 0, + 'owner': {'username': 'creator', 'display_name': 'Creator'}, + 'host': { + 'url': '//cdn.7tv.app/emote/7tv1', + 'files': [ + {'name': '1x.webp', 'width': 32, 'height': 32, 'format': 'WEBP'}, + {'name': '2x.webp', 'width': 64, 'height': 64, 'format': 'WEBP'}, + {'name': '3x.webp', 'width': 96, 'height': 96, 'format': 'WEBP'}, + { + 'name': '4x.webp', + 'width': 128, + 'height': 128, + 'format': 'WEBP', + }, + ], + }, + }, + }, + ], +}; + +const sevenTVEmotesChannelResponse = { + 'emote_set': { + 'id': 'set_abc123', + 'emotes': [ + { + 'id': '7tv_ch1', + 'name': 'Clap', + 'data': { + 'id': '7tv_ch1', + 'name': 'Clap', + 'flags': 0, + 'owner': null, + 'host': { + 'url': '//cdn.7tv.app/emote/7tv_ch1', + 'files': [ + { + 'name': '1x.webp', + 'width': 32, + 'height': 32, + 'format': 'WEBP', + }, + { + 'name': '4x.webp', + 'width': 128, + 'height': 128, + 'format': 'WEBP', + }, + ], + }, + }, + }, + ], + }, +}; + +// ── Chatters Response ── + +const chattersResponse = { + 'chatter_count': 5, + 'chatters': { + 'broadcaster': ['streamer'], + 'vips': ['vip1'], + 'moderators': ['mod1'], + 'staff': [], + 'admins': [], + 'global_mods': [], + 'viewers': ['viewer1', 'viewer2'], + }, +}; + +// ── 7TV Events ── + +const sevenTVEventJson = { + 'op': 0, + 't': 1, + 'd': { + 'type': 'emote_set.update', + 'condition': {'object_id': 'set_abc123'}, + }, +}; diff --git a/test/fixtures/irc_messages.dart b/test/fixtures/irc_messages.dart index 8d16f5dd..28624337 100644 --- a/test/fixtures/irc_messages.dart +++ b/test/fixtures/irc_messages.dart @@ -152,6 +152,20 @@ const messageWithEmoji = 'tmi-sent-ts=1234567890123;turbo=0;user-id=14141;user-type= ' ':emojiuser!emojiuser@emojiuser.tmi.twitch.tv PRIVMSG #channel :Hello 😀🎉 World'; +/// A message with empty body (edge case) +const messageEmptyBody = + '@badge-info=;badges=;color=#FF0000;display-name=EmptyMsg;emotes=;' + 'id=empty-123;mod=0;room-id=12345;subscriber=0;' + 'tmi-sent-ts=1234567890123;turbo=0;user-id=16161;user-type= ' + ':emptymsg!emptymsg@emptymsg.tmi.twitch.tv PRIVMSG #channel :'; + +/// A message containing only emotes (no text words) +const messageOnlyEmotes = + '@badge-info=;badges=;color=#FF0000;display-name=EmoteOnly;' + 'emotes=25:0-4,6-10;id=emoteonly-123;mod=0;room-id=12345;subscriber=0;' + 'tmi-sent-ts=1234567890123;turbo=0;user-id=17171;user-type= ' + ':emoteonly!emoteonly@emoteonly.tmi.twitch.tv PRIVMSG #channel :Kappa Kappa'; + /// A message with a URL const messageWithUrl = '@badge-info=;badges=;color=#9400D3;display-name=LinkUser;emotes=;' diff --git a/test/models/category_test.dart b/test/models/category_test.dart new file mode 100644 index 00000000..6623b7cf --- /dev/null +++ b/test/models/category_test.dart @@ -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': [], + 'pagination': {}, + }); + + expect(categories.data, isEmpty); + }); + + test('fromJson handles null pagination', () { + final categories = CategoriesTwitch.fromJson(const { + 'data': [], + 'pagination': null, + }); + + expect(categories.pagination, isNull); + }); + }); +} diff --git a/test/models/channel_test.dart b/test/models/channel_test.dart new file mode 100644 index 00000000..6a08ec86 --- /dev/null +++ b/test/models/channel_test.dart @@ -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, + ); + + 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, + ); + + 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); + }); + }); +} diff --git a/test/models/chatters_test.dart b/test/models/chatters_test.dart new file mode 100644 index 00000000..95eca790 --- /dev/null +++ b/test/models/chatters_test.dart @@ -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, + ); + + 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, + ); + + 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)); + }); + }); +} diff --git a/test/models/emotes_test.dart b/test/models/emotes_test.dart index 4b049a77..08ec7ca5 100644 --- a/test/models/emotes_test.dart +++ b/test/models/emotes_test.dart @@ -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( diff --git a/test/models/events_test.dart b/test/models/events_test.dart new file mode 100644 index 00000000..168dd39c --- /dev/null +++ b/test/models/events_test.dart @@ -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, + ); + + 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, + ); + + // 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; + 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)['d'] + as Map, + ); + + 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': [], + 'pulled': [], + }); + + 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); + }); + }); +} diff --git a/test/models/irc_test.dart b/test/models/irc_test.dart index 5f76cd3f..0d57776d 100644 --- a/test/models/irc_test.dart +++ b/test/models/irc_test.dart @@ -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 = []; + 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); + }); + }); + group('USERSTATE', () { test('creates default USERSTATE', () { const userstate = USERSTATE(); diff --git a/test/models/shared_chat_session_test.dart b/test/models/shared_chat_session_test.dart new file mode 100644 index 00000000..d3d5c0a0 --- /dev/null +++ b/test/models/shared_chat_session_test.dart @@ -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, + ); + + 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, + ); + + expect(session.participants[0].broadcasterId, '12345'); + expect(session.participants[1].broadcasterId, '67890'); + }); + }); +} diff --git a/test/models/stream_test.dart b/test/models/stream_test.dart new file mode 100644 index 00000000..76ef1105 --- /dev/null +++ b/test/models/stream_test.dart @@ -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, + ); + + 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, + ); + + 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': [], + 'pagination': {}, + }); + + expect(streams.data, isEmpty); + }); + }); +} diff --git a/test/models/user_test.dart b/test/models/user_test.dart new file mode 100644 index 00000000..d8de1787 --- /dev/null +++ b/test/models/user_test.dart @@ -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, + ); + + 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, + ); + + 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'); + }); + }); +} diff --git a/test/regex_test.dart b/test/regex_test.dart index b47decb7..d97486ce 100644 --- a/test/regex_test.dart +++ b/test/regex_test.dart @@ -106,8 +106,10 @@ void main() { expect(regexLink.hasMatch('localhost'), isFalse); }); - test('does not match email addresses as URLs', () { - expect(regexLink.hasMatch('example.com'), isTrue); + test('matches domain part of email addresses', () { + // The regex matches the domain portion (example.com) within an email + // This is expected behavior — the regex is for URLs, not email filtering + expect(regexLink.hasMatch('user@example.com'), isTrue); }); }); @@ -224,8 +226,11 @@ void main() { test('handles consecutive emojis', () { const text = '😀😃😄'; final matches = regexEmoji.allMatches(text); - // May match as one or multiple depending on regex - expect(matches.isNotEmpty, isTrue); + // Consecutive emojis may be grouped into a single match by the regex + expect(matches.length, greaterThanOrEqualTo(1)); + // Verify all emoji characters are captured + final matched = matches.map((m) => m.group(0)).join(); + expect(matched, text); }); }); }); diff --git a/test/screens/settings/stores/settings_store_test.dart b/test/screens/settings/stores/settings_store_test.dart new file mode 100644 index 00000000..8dc7dcc8 --- /dev/null +++ b/test/screens/settings/stores/settings_store_test.dart @@ -0,0 +1,278 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:frosty/screens/settings/stores/settings_store.dart'; + +void main() { + group('SettingsStore JSON serialization', () { + test('fromJson with empty map returns all defaults', () { + final store = SettingsStore.fromJson({}); + + expect(store.themeType, ThemeType.system); + expect(store.accentColor, 0xff9146ff); + expect(store.showThumbnails, isTrue); + expect(store.largeStreamCard, isFalse); + expect(store.launchUrlExternal, isFalse); + expect(store.showVideo, isTrue); + expect(store.defaultToHighestQuality, isFalse); + expect(store.useTextureRendering, isTrue); + expect(store.showOverlay, isTrue); + expect(store.toggleableOverlay, isFalse); + expect(store.showLatency, isFalse); + expect(store.badgeScale, 1.0); + expect(store.emoteScale, 1.0); + expect(store.messageScale, 1.0); + expect(store.messageSpacing, 8.0); + expect(store.fontSize, 12.0); + expect(store.showDeletedMessages, isFalse); + expect(store.showChatMessageDividers, isFalse); + expect(store.timestampType, TimestampType.disabled); + expect(store.autoSyncChatDelay, isFalse); + expect(store.chatDelay, 0.0); + expect(store.highlightFirstTimeChatter, isTrue); + expect(store.showUserNotices, isTrue); + expect(store.emoteMenuButtonOnLeft, isFalse); + expect(store.landscapeChatLeftSide, isFalse); + expect(store.landscapeForceVerticalChat, isFalse); + expect(store.landscapeCutout, LandscapeCutoutType.none); + expect(store.chatWidth, 0.2); + expect(store.fullScreenChatOverlayOpacity, 0.5); + expect(store.autocomplete, isTrue); + expect(store.showTwitchEmotes, isTrue); + expect(store.showTwitchBadges, isTrue); + expect(store.show7TVEmotes, isTrue); + expect(store.showBTTVEmotes, isTrue); + expect(store.showBTTVBadges, isTrue); + expect(store.showFFZEmotes, isTrue); + expect(store.showFFZBadges, isTrue); + expect(store.showRecentMessages, isFalse); + expect(store.persistChatTabs, isTrue); + expect(store.secondaryTabs, isEmpty); + expect(store.mutedWords, isEmpty); + expect(store.matchWholeWord, isTrue); + expect(store.shareCrashLogsAndAnalytics, isTrue); + expect(store.fullScreen, isFalse); + expect(store.fullScreenChatOverlay, isFalse); + expect(store.pinnedChannelIds, isEmpty); + }); + + test('roundtrip serialization preserves non-default values', () { + final store = SettingsStore.fromJson({}); + + // Set non-default values + store.themeType = ThemeType.dark; + store.accentColor = 0xFF00FF00; + store.showThumbnails = false; + store.fontSize = 16.0; + store.chatDelay = 5.0; + store.showVideo = false; + store.timestampType = TimestampType.twelve; + store.landscapeCutout = LandscapeCutoutType.both; + store.mutedWords = ['spam', 'bad']; + store.pinnedChannelIds = ['ch1', 'ch2']; + + final json = store.toJson(); + final restored = SettingsStore.fromJson(json); + + expect(restored.themeType, ThemeType.dark); + expect(restored.accentColor, 0xFF00FF00); + expect(restored.showThumbnails, isFalse); + expect(restored.fontSize, 16.0); + expect(restored.chatDelay, 5.0); + expect(restored.showVideo, isFalse); + expect(restored.timestampType, TimestampType.twelve); + expect(restored.landscapeCutout, LandscapeCutoutType.both); + expect(restored.mutedWords, ['spam', 'bad']); + expect(restored.pinnedChannelIds, ['ch1', 'ch2']); + }); + + test('unknown ThemeType enum value falls back to system', () { + final store = SettingsStore.fromJson({ + 'themeType': 'nonexistent_theme', + }); + expect(store.themeType, ThemeType.system); + }); + + test('unknown TimestampType enum value falls back to disabled', () { + final store = SettingsStore.fromJson({ + 'timestampType': 'nonexistent_timestamp', + }); + expect(store.timestampType, TimestampType.disabled); + }); + }); + + group('SettingsStore reset actions', () { + test('resetGeneralSettings restores general defaults', () { + final store = SettingsStore.fromJson({}); + + store.themeType = ThemeType.dark; + store.accentColor = 0xFF00FF00; + store.showThumbnails = false; + store.largeStreamCard = true; + store.launchUrlExternal = true; + + store.resetGeneralSettings(); + + expect(store.themeType, ThemeType.system); + expect(store.accentColor, 0xff9146ff); + expect(store.showThumbnails, isTrue); + expect(store.largeStreamCard, isFalse); + expect(store.launchUrlExternal, isFalse); + }); + + test('resetVideoSettings restores video defaults', () { + final store = SettingsStore.fromJson({}); + + store.showVideo = false; + store.defaultToHighestQuality = true; + store.useTextureRendering = false; + store.showOverlay = false; + store.toggleableOverlay = true; + store.showLatency = true; + + store.resetVideoSettings(); + + expect(store.showVideo, isTrue); + expect(store.defaultToHighestQuality, isFalse); + expect(store.useTextureRendering, isTrue); + expect(store.showOverlay, isTrue); + expect(store.toggleableOverlay, isFalse); + expect(store.showLatency, isFalse); + }); + + test('resetChatSettings restores chat defaults', () { + final store = SettingsStore.fromJson({}); + + store.badgeScale = 2.0; + store.emoteScale = 2.0; + store.fontSize = 20.0; + store.showDeletedMessages = true; + store.timestampType = TimestampType.twentyFour; + store.chatDelay = 10.0; + store.emoteMenuButtonOnLeft = true; + store.landscapeCutout = LandscapeCutoutType.right; + store.chatWidth = 0.5; + store.mutedWords = ['word1']; + store.showBTTVEmotes = false; + store.showFFZBadges = false; + + store.resetChatSettings(); + + expect(store.badgeScale, 1.0); + expect(store.emoteScale, 1.0); + expect(store.fontSize, 12.0); + expect(store.showDeletedMessages, isFalse); + expect(store.timestampType, TimestampType.disabled); + expect(store.chatDelay, 0.0); + expect(store.emoteMenuButtonOnLeft, isFalse); + expect(store.landscapeCutout, LandscapeCutoutType.none); + expect(store.chatWidth, 0.2); + expect(store.mutedWords, isEmpty); + expect(store.showBTTVEmotes, isTrue); + expect(store.showFFZBadges, isTrue); + }); + + test('resetOtherSettings restores other defaults', () { + final store = SettingsStore.fromJson({}); + + store.shareCrashLogsAndAnalytics = false; + + store.resetOtherSettings(); + + expect(store.shareCrashLogsAndAnalytics, isTrue); + }); + + test('resetGlobalConfigs restores global config defaults', () { + final store = SettingsStore.fromJson({}); + + store.fullScreen = true; + store.fullScreenChatOverlay = true; + store.pinnedChannelIds = ['ch1']; + + store.resetGlobalConfigs(); + + expect(store.fullScreen, isFalse); + expect(store.fullScreenChatOverlay, isFalse); + expect(store.pinnedChannelIds, isEmpty); + }); + + test('resetAllSettings restores everything to defaults', () { + final store = SettingsStore.fromJson({}); + + // Modify settings across all categories + store.themeType = ThemeType.light; + store.showVideo = false; + store.fontSize = 20.0; + store.shareCrashLogsAndAnalytics = false; + store.fullScreen = true; + + store.resetAllSettings(); + + expect(store.themeType, ThemeType.system); + expect(store.showVideo, isTrue); + expect(store.fontSize, 12.0); + expect(store.shareCrashLogsAndAnalytics, isTrue); + expect(store.fullScreen, isFalse); + }); + + test('resetGeneralSettings does not affect video settings', () { + final store = SettingsStore.fromJson({}); + + store.showVideo = false; + store.themeType = ThemeType.dark; + + store.resetGeneralSettings(); + + expect(store.themeType, ThemeType.system); + expect(store.showVideo, isFalse); // Should remain changed + }); + + test('resetVideoSettings does not affect chat settings', () { + final store = SettingsStore.fromJson({}); + + store.fontSize = 20.0; + store.showLatency = true; + + store.resetVideoSettings(); + + expect(store.showLatency, isFalse); + expect(store.fontSize, 20.0); // Should remain changed + }); + }); + + group('Enums', () { + test('ThemeType has expected values', () { + expect(ThemeType.values.length, 3); + expect(ThemeType.values, contains(ThemeType.system)); + expect(ThemeType.values, contains(ThemeType.light)); + expect(ThemeType.values, contains(ThemeType.dark)); + }); + + test('TimestampType has expected values', () { + expect(TimestampType.values.length, 3); + expect(TimestampType.values, contains(TimestampType.disabled)); + expect(TimestampType.values, contains(TimestampType.twelve)); + expect(TimestampType.values, contains(TimestampType.twentyFour)); + }); + + test('LandscapeCutoutType has expected values', () { + expect(LandscapeCutoutType.values.length, 4); + expect(LandscapeCutoutType.values, contains(LandscapeCutoutType.none)); + expect(LandscapeCutoutType.values, contains(LandscapeCutoutType.left)); + expect(LandscapeCutoutType.values, contains(LandscapeCutoutType.right)); + expect(LandscapeCutoutType.values, contains(LandscapeCutoutType.both)); + }); + + test('themeNames matches ThemeType order', () { + expect(themeNames.length, ThemeType.values.length); + expect(themeNames[0], 'System'); + expect(themeNames[1], 'Light'); + expect(themeNames[2], 'Dark'); + }); + + test('timestampNames matches TimestampType order', () { + expect(timestampNames.length, TimestampType.values.length); + expect(timestampNames[0], 'Disabled'); + expect(timestampNames[1], '12-hour'); + expect(timestampNames[2], '24-hour'); + }); + }); +} diff --git a/test/utils_test.dart b/test/utils_test.dart index 9409fa5a..846540f6 100644 --- a/test/utils_test.dart +++ b/test/utils_test.dart @@ -1,3 +1,5 @@ +import 'dart:math' as math; + import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:frosty/utils.dart'; @@ -290,5 +292,48 @@ void main() { ); } }); + + testWidgets('adjusted color meets WCAG AA 4.5:1 contrast ratio', + (tester) async { + late Color adjustedColor; + const darkBackground = Color(0xFF121212); + const darkBlue = Color(0xFF0000AA); + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData.dark().copyWith( + scaffoldBackgroundColor: darkBackground, + ), + home: Scaffold( + body: Builder( + builder: (context) { + adjustedColor = adjustChatNameColor(context, darkBlue); + return const SizedBox(); + }, + ), + ), + ), + ); + + // Compute WCAG contrast ratio manually + double linearize(double c) => c <= 0.03928 + ? c / 12.92 + : math.pow((c + 0.055) / 1.055, 2.4).toDouble(); + + double luminance(Color c) { + return 0.2126 * linearize(c.r) + + 0.7152 * linearize(c.g) + + 0.0722 * linearize(c.b); + } + + final l1 = luminance(adjustedColor); + final l2 = luminance(darkBackground); + final hi = l1 > l2 ? l1 : l2; + final lo = l1 > l2 ? l2 : l1; + final ratio = (hi + 0.05) / (lo + 0.05); + + expect(ratio, greaterThanOrEqualTo(4.5), + reason: 'Adjusted color should meet WCAG AA 4.5:1 contrast ratio'); + }); }); }