overhaul tests
This commit is contained in:
parent
e58c9875e0
commit
baebafea59
22 changed files with 2637 additions and 52 deletions
24
pubspec.lock
24
pubspec.lock
|
|
@ -608,6 +608,14 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.6.0"
|
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:
|
http_multi_server:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -720,6 +728,14 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "6.0.0"
|
version: "6.0.0"
|
||||||
|
logger:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: logger
|
||||||
|
sha256: a7967e31b703831a893bbc3c3dd11db08126fe5f369b5c648a36f821979f5be3
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.6.2"
|
||||||
logging:
|
logging:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -784,6 +800,14 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.7.4"
|
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:
|
native_device_orientation:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
|
|
|
||||||
|
|
@ -87,6 +87,8 @@ dev_dependencies:
|
||||||
mobx_codegen: ^2.0.6
|
mobx_codegen: ^2.0.6
|
||||||
flutter_native_splash: ^2.0.5
|
flutter_native_splash: ^2.0.5
|
||||||
flutter_launcher_icons: ^0.14.1
|
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
|
# For information on the generic Dart part of this file, see the
|
||||||
# following page: https://dart.dev/tools/pub/pubspec
|
# following page: https://dart.dev/tools/pub/pubspec
|
||||||
|
|
|
||||||
403
test/apis/base_api_client_test.dart
Normal file
403
test/apis/base_api_client_test.dart
Normal file
|
|
@ -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<dynamic> 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<ApiException>().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<UnauthorizedException>()),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
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<NotFoundException>().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<NotFoundException>().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<ApiException>().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<ApiException>().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<ServerException>().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<ServerException>().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<ServerException>()),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('504 throws ServerException', () async {
|
||||||
|
dioAdapter.onGet(
|
||||||
|
'https://api.test.com/timeout',
|
||||||
|
(server) => server.reply(504, null),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
() => client.testGet('/timeout'),
|
||||||
|
throwsA(isA<ServerException>()),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
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<ApiException>().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<ApiException>().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<ApiException>().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<ApiException>().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<ServerException>().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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
106
test/apis/bttv_api_test.dart
Normal file
106
test/apis/bttv_api_test.dart
Normal file
|
|
@ -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, <dynamic>[]),
|
||||||
|
);
|
||||||
|
|
||||||
|
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<NotFoundException>()),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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, <dynamic>[]),
|
||||||
|
);
|
||||||
|
|
||||||
|
final badges = await api.getBadges();
|
||||||
|
|
||||||
|
expect(badges, isEmpty);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
192
test/apis/ffz_api_test.dart
Normal file
192
test/apis/ffz_api_test.dart
Normal file
|
|
@ -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': <dynamic>[],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
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<NotFoundException>()),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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': <dynamic>[],
|
||||||
|
'users': <String, dynamic>{},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
final badges = await api.getBadges();
|
||||||
|
|
||||||
|
expect(badges, isEmpty);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
142
test/apis/seventv_api_test.dart
Normal file
142
test/apis/seventv_api_test.dart
Normal file
|
|
@ -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': <dynamic>[],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
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<NotFoundException>()),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
392
test/apis/twitch_api_test.dart
Normal file
392
test/apis/twitch_api_test.dart
Normal file
|
|
@ -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': <dynamic>[],
|
||||||
|
}),
|
||||||
|
queryParameters: {'login': 'doesnotexist'},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
() => api.getUser(userLogin: 'doesnotexist'),
|
||||||
|
throwsA(isA<NotFoundException>()),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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': <dynamic>[],
|
||||||
|
'pagination': <String, String>{},
|
||||||
|
}),
|
||||||
|
queryParameters: {'user_login': 'offlineuser'},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
() => api.getStream(userLogin: 'offlineuser'),
|
||||||
|
throwsA(
|
||||||
|
isA<ApiException>().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': <String, String>{},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
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<ServerException>()),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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': <dynamic>[],
|
||||||
|
'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': <dynamic>[],
|
||||||
|
'pagination': <String, String>{},
|
||||||
|
}),
|
||||||
|
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': <dynamic>[],
|
||||||
|
}),
|
||||||
|
queryParameters: {'broadcaster_id': '99999'},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
() => api.getChannel(userId: '99999'),
|
||||||
|
throwsA(isA<ApiException>()),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
95
test/apis/twitch_auth_interceptor_test.dart
Normal file
95
test/apis/twitch_auth_interceptor_test.dart
Normal file
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
350
test/fixtures/api_responses.dart
vendored
Normal file
350
test/fixtures/api_responses.dart
vendored
Normal file
|
|
@ -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': <String>[],
|
||||||
|
'admins': <String>[],
|
||||||
|
'global_mods': <String>[],
|
||||||
|
'viewers': ['viewer1', 'viewer2'],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── 7TV Events ──
|
||||||
|
|
||||||
|
const sevenTVEventJson = {
|
||||||
|
'op': 0,
|
||||||
|
't': 1,
|
||||||
|
'd': {
|
||||||
|
'type': 'emote_set.update',
|
||||||
|
'condition': {'object_id': 'set_abc123'},
|
||||||
|
},
|
||||||
|
};
|
||||||
14
test/fixtures/irc_messages.dart
vendored
14
test/fixtures/irc_messages.dart
vendored
|
|
@ -152,6 +152,20 @@ const messageWithEmoji =
|
||||||
'tmi-sent-ts=1234567890123;turbo=0;user-id=14141;user-type= '
|
'tmi-sent-ts=1234567890123;turbo=0;user-id=14141;user-type= '
|
||||||
':emojiuser!emojiuser@emojiuser.tmi.twitch.tv PRIVMSG #channel :Hello 😀🎉 World';
|
':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
|
/// A message with a URL
|
||||||
const messageWithUrl =
|
const messageWithUrl =
|
||||||
'@badge-info=;badges=;color=#9400D3;display-name=LinkUser;emotes=;'
|
'@badge-info=;badges=;color=#9400D3;display-name=LinkUser;emotes=;'
|
||||||
|
|
|
||||||
55
test/models/category_test.dart
Normal file
55
test/models/category_test.dart
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
49
test/models/channel_test.dart
Normal file
49
test/models/channel_test.dart
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
36
test/models/chatters_test.dart
Normal file
36
test/models/chatters_test.dart
Normal 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));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -34,6 +34,19 @@ void main() {
|
||||||
|
|
||||||
expect(emote.ownerId, '67890');
|
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', () {
|
group('Emote.fromBTTV', () {
|
||||||
|
|
@ -47,53 +60,21 @@ void main() {
|
||||||
expect(emote.type, EmoteType.bttvGlobal);
|
expect(emote.type, EmoteType.bttvGlobal);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('marks SoSnowy as zero-width', () {
|
for (final name in [
|
||||||
final bttvEmote = EmoteBTTV('snowyid', 'SoSnowy');
|
'SoSnowy',
|
||||||
|
'IceCold',
|
||||||
final emote = Emote.fromBTTV(bttvEmote, EmoteType.bttvGlobal);
|
'SantaHat',
|
||||||
|
'TopHat',
|
||||||
expect(emote.zeroWidth, isTrue);
|
'ReinDeer',
|
||||||
});
|
'CandyCane',
|
||||||
|
'cvMask',
|
||||||
test('marks IceCold as zero-width', () {
|
'cvHazmat',
|
||||||
final bttvEmote = EmoteBTTV('iceid', 'IceCold');
|
]) {
|
||||||
|
test('marks $name as zero-width', () {
|
||||||
final emote = Emote.fromBTTV(bttvEmote, EmoteType.bttvChannel);
|
final emote = Emote.fromBTTV(EmoteBTTV('id', name), EmoteType.bttvGlobal);
|
||||||
|
expect(emote.zeroWidth, isTrue);
|
||||||
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);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('regular emotes are not zero-width', () {
|
test('regular emotes are not zero-width', () {
|
||||||
final bttvEmote = EmoteBTTV('regularid', 'RegularEmote');
|
final bttvEmote = EmoteBTTV('regularid', 'RegularEmote');
|
||||||
|
|
@ -214,6 +195,29 @@ void main() {
|
||||||
// Should use animated 2x since animated 4x is not available
|
// Should use animated 2x since animated 4x is not available
|
||||||
expect(emote.url, 'https://cdn.ffz.net/anim/2x.gif');
|
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', () {
|
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', () {
|
group('Emote JSON serialization', () {
|
||||||
test('roundtrip serialization works', () {
|
test('roundtrip serialization works', () {
|
||||||
const original = Emote(
|
const original = Emote(
|
||||||
|
|
|
||||||
110
test/models/events_test.dart
Normal file
110
test/models/events_test.dart
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:frosty/constants.dart';
|
||||||
import 'package:frosty/models/irc.dart';
|
import 'package:frosty/models/irc.dart';
|
||||||
|
|
||||||
import '../fixtures/irc_messages.dart';
|
import '../fixtures/irc_messages.dart';
|
||||||
|
|
@ -173,7 +174,7 @@ void main() {
|
||||||
expect(msg.mention, isFalse);
|
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);
|
final msg = IRCMessage.fromString(basicPrivmsg);
|
||||||
expect(msg.mention, isFalse);
|
expect(msg.mention, isFalse);
|
||||||
});
|
});
|
||||||
|
|
@ -228,6 +229,17 @@ void main() {
|
||||||
expect(msg.tags['source-room-id'], '99999');
|
expect(msg.tags['source-room-id'], '99999');
|
||||||
expect(msg.tags['room-id'], '12345');
|
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', () {
|
group('USERSTATE', () {
|
||||||
test('creates default USERSTATE', () {
|
test('creates default USERSTATE', () {
|
||||||
const userstate = USERSTATE();
|
const userstate = USERSTATE();
|
||||||
|
|
|
||||||
41
test/models/shared_chat_session_test.dart
Normal file
41
test/models/shared_chat_session_test.dart
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
45
test/models/stream_test.dart
Normal file
45
test/models/stream_test.dart
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
44
test/models/user_test.dart
Normal file
44
test/models/user_test.dart
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -106,8 +106,10 @@ void main() {
|
||||||
expect(regexLink.hasMatch('localhost'), isFalse);
|
expect(regexLink.hasMatch('localhost'), isFalse);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('does not match email addresses as URLs', () {
|
test('matches domain part of email addresses', () {
|
||||||
expect(regexLink.hasMatch('example.com'), isTrue);
|
// 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', () {
|
test('handles consecutive emojis', () {
|
||||||
const text = '😀😃😄';
|
const text = '😀😃😄';
|
||||||
final matches = regexEmoji.allMatches(text);
|
final matches = regexEmoji.allMatches(text);
|
||||||
// May match as one or multiple depending on regex
|
// Consecutive emojis may be grouped into a single match by the regex
|
||||||
expect(matches.isNotEmpty, isTrue);
|
expect(matches.length, greaterThanOrEqualTo(1));
|
||||||
|
// Verify all emoji characters are captured
|
||||||
|
final matched = matches.map((m) => m.group(0)).join();
|
||||||
|
expect(matched, text);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
278
test/screens/settings/stores/settings_store_test.dart
Normal file
278
test/screens/settings/stores/settings_store_test.dart
Normal file
|
|
@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
import 'dart:math' as math;
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:frosty/utils.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');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue