633 lines
21 KiB
Dart
633 lines
21 KiB
Dart
import 'dart:async';
|
|
import 'dart:ui' show Tristate;
|
|
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
import 'package:go_router/go_router.dart';
|
|
import 'package:mocktail/mocktail.dart';
|
|
import 'package:shuffle/core/result/result.dart';
|
|
import 'package:shuffle/features/auth/domain/auth_failure.dart';
|
|
import 'package:shuffle/features/auth/presentation/auth_routes.dart';
|
|
import 'package:shuffle/l10n/app_localizations_de.dart';
|
|
import 'package:shuffle/l10n/app_localizations_en.dart';
|
|
|
|
import '../../../../helpers/pump_app.dart';
|
|
import '../../../../helpers/router_helpers.dart';
|
|
import '../auth_ui_support.dart';
|
|
|
|
final _de = AppLocalizationsDe();
|
|
final _en = AppLocalizationsEn();
|
|
|
|
void main() {
|
|
late MockAuthRepository repository;
|
|
|
|
setUpAll(registerAuthFallbacks);
|
|
|
|
setUp(() {
|
|
repository = MockAuthRepository();
|
|
when(() => repository.signIn(any(), any())).thenAnswer((_) async => ok());
|
|
when(() => repository.resendConfirmation(any()))
|
|
.thenAnswer((_) async => ok());
|
|
});
|
|
|
|
Future<AuthUiHandle> open(
|
|
WidgetTester tester, {
|
|
String location = '/login',
|
|
Locale locale = const Locale('de'),
|
|
ThemeMode themeMode = ThemeMode.light,
|
|
double? textScale,
|
|
Size size = const Size(400, 800),
|
|
}) async {
|
|
tester.view.physicalSize = size;
|
|
tester.view.devicePixelRatio = 1;
|
|
addTearDown(tester.view.reset);
|
|
final router = GoRouter(
|
|
initialLocation: location,
|
|
routes: [
|
|
...authRoutes,
|
|
GoRoute(path: '/', builder: (_, _) => const Text('home-page')),
|
|
GoRoute(
|
|
path: '/private',
|
|
builder: (_, _) => const Text('private-page'),
|
|
),
|
|
],
|
|
);
|
|
addTearDown(router.dispose);
|
|
final container = await pumpApp(
|
|
tester,
|
|
router: router,
|
|
locale: locale,
|
|
themeMode: themeMode,
|
|
textScale: textScale,
|
|
overrides: authUiOverrides(repository, locale: locale),
|
|
);
|
|
return (container: container, router: router);
|
|
}
|
|
|
|
Future<void> type(WidgetTester tester, String key, String text) async {
|
|
await tester.ensureVisible(fieldOf(key));
|
|
await tester.enterText(fieldOf(key), text);
|
|
await tester.pump();
|
|
}
|
|
|
|
Future<void> tapKey(WidgetTester tester, String key) async {
|
|
final finder = find.byKey(Key(key));
|
|
await tester.ensureVisible(finder);
|
|
await tester.tap(finder);
|
|
await tester.pump();
|
|
}
|
|
|
|
Future<void> fill(WidgetTester tester) async {
|
|
await type(tester, 'login_email', 'anna@example.com');
|
|
await type(tester, 'login_password', 'geheim-und-lang');
|
|
}
|
|
|
|
Future<void> submit(WidgetTester tester) async {
|
|
await tapKey(tester, 'login_submit');
|
|
await tester.pumpAndSettle();
|
|
}
|
|
|
|
void stubSignInFailure(AuthFailure failure) =>
|
|
when(() => repository.signIn(any(), any()))
|
|
.thenAnswer((_) async => failed(failure));
|
|
|
|
group('KTO-01 Kriterium 5: sign in', () {
|
|
testWidgets('signs in and leaves the page', (tester) async {
|
|
final (:container, :router) = await open(tester);
|
|
await fill(tester);
|
|
|
|
await submit(tester);
|
|
|
|
verify(() => repository.signIn('anna@example.com', 'geheim-und-lang'))
|
|
.called(1);
|
|
expect(currentPath(router), '/');
|
|
expect(find.text('home-page'), findsOneWidget);
|
|
});
|
|
|
|
testWidgets('the enter key in the password field submits', (tester) async {
|
|
final (:container, :router) = await open(tester);
|
|
await fill(tester);
|
|
|
|
await tester.showKeyboard(fieldOf('login_password'));
|
|
await tester.testTextInput.receiveAction(TextInputAction.done);
|
|
await tester.pumpAndSettle();
|
|
|
|
verify(() => repository.signIn(any(), any())).called(1);
|
|
expect(currentPath(router), '/');
|
|
});
|
|
|
|
testWidgets('returns to a valid from path', (tester) async {
|
|
final (:container, :router) = await open(
|
|
tester,
|
|
location: '/login?from=%2Fprivate',
|
|
);
|
|
await fill(tester);
|
|
|
|
await submit(tester);
|
|
|
|
expect(currentPath(router), '/private');
|
|
});
|
|
|
|
for (final from in [
|
|
'https://evil.example',
|
|
'//evil.example',
|
|
'/register',
|
|
]) {
|
|
testWidgets('ignores the unsafe from "$from"', (tester) async {
|
|
final (:container, :router) = await open(
|
|
tester,
|
|
location: Uri(
|
|
path: '/login',
|
|
queryParameters: {'from': from},
|
|
).toString(),
|
|
);
|
|
await fill(tester);
|
|
|
|
await submit(tester);
|
|
|
|
expect(currentPath(router), '/');
|
|
});
|
|
}
|
|
|
|
testWidgets('fields carry autofill hints and keyboard types', (
|
|
tester,
|
|
) async {
|
|
await open(tester);
|
|
|
|
final email = tester.widget<TextField>(fieldOf('login_email'));
|
|
expect(email.autofillHints, contains(AutofillHints.username));
|
|
expect(email.autofillHints, contains(AutofillHints.email));
|
|
expect(email.keyboardType, TextInputType.emailAddress);
|
|
expect(email.textInputAction, TextInputAction.next);
|
|
final password = tester.widget<TextField>(fieldOf('login_password'));
|
|
expect(password.autofillHints, contains(AutofillHints.password));
|
|
expect(
|
|
password.autofillHints,
|
|
isNot(contains(AutofillHints.newPassword)),
|
|
);
|
|
expect(password.obscureText, isTrue);
|
|
expect(password.textInputAction, TextInputAction.done);
|
|
});
|
|
|
|
testWidgets('empty fields show errors at the fields and send nothing', (
|
|
tester,
|
|
) async {
|
|
await open(tester);
|
|
|
|
await submit(tester);
|
|
|
|
expect(find.text(_de.authErrorEmailInvalid), findsOneWidget);
|
|
expect(find.text(_de.authPasswordRequired), findsOneWidget);
|
|
verifyNever(() => repository.signIn(any(), any()));
|
|
});
|
|
|
|
testWidgets('double tap sends once and locks the form', (tester) async {
|
|
final completer = Completer<Result<void, AuthFailure>>();
|
|
when(() => repository.signIn(any(), any()))
|
|
.thenAnswer((_) => completer.future);
|
|
await open(tester);
|
|
await fill(tester);
|
|
|
|
await tapKey(tester, 'login_submit');
|
|
await tester.tap(find.byKey(const Key('login_submit')));
|
|
await tester.pump();
|
|
|
|
expect(tester.widget<TextField>(fieldOf('login_email')).enabled, isFalse);
|
|
expect(
|
|
tester.widget<TextField>(fieldOf('login_password')).enabled,
|
|
isFalse,
|
|
);
|
|
completer.complete(ok());
|
|
await tester.pumpAndSettle();
|
|
verify(() => repository.signIn(any(), any())).called(1);
|
|
});
|
|
|
|
testWidgets('links lead to register and forgot password', (tester) async {
|
|
final (:container, :router) = await open(tester);
|
|
|
|
await tapKey(tester, 'login_register_link');
|
|
await tester.pumpAndSettle();
|
|
expect(currentPath(router), '/register');
|
|
|
|
router.go('/login');
|
|
await tester.pumpAndSettle();
|
|
await tapKey(tester, 'login_forgot_link');
|
|
await tester.pumpAndSettle();
|
|
expect(currentPath(router), '/forgot-password');
|
|
});
|
|
});
|
|
|
|
group('from is carried to register and forgot password', () {
|
|
testWidgets('a safe from goes along and comes back', (tester) async {
|
|
final (:container, :router) = await open(
|
|
tester,
|
|
location: '/login?from=%2Fprivate',
|
|
);
|
|
|
|
await tapKey(tester, 'login_register_link');
|
|
await tester.pumpAndSettle();
|
|
expect(currentUri(router).toString(), '/register?from=%2Fprivate');
|
|
await tapKey(tester, 'register_login_link');
|
|
await tester.pumpAndSettle();
|
|
expect(currentUri(router).toString(), '/login?from=%2Fprivate');
|
|
|
|
await tapKey(tester, 'login_forgot_link');
|
|
await tester.pumpAndSettle();
|
|
expect(currentUri(router).toString(), '/forgot-password?from=%2Fprivate');
|
|
await tapKey(tester, 'forgot_password_back');
|
|
await tester.pumpAndSettle();
|
|
expect(currentUri(router).toString(), '/login?from=%2Fprivate');
|
|
});
|
|
|
|
testWidgets('an unsafe from is not forwarded', (tester) async {
|
|
final (:container, :router) = await open(
|
|
tester,
|
|
location: '/login?from=%2F%2Fevil.example',
|
|
);
|
|
|
|
await tapKey(tester, 'login_register_link');
|
|
await tester.pumpAndSettle();
|
|
expect(currentUri(router).toString(), '/register');
|
|
});
|
|
|
|
testWidgets('without from the links stay plain', (tester) async {
|
|
final (:container, :router) = await open(tester);
|
|
|
|
await tapKey(tester, 'login_forgot_link');
|
|
await tester.pumpAndSettle();
|
|
expect(currentUri(router).toString(), '/forgot-password');
|
|
});
|
|
});
|
|
|
|
group('KTO-01 Kriterium 7: generic failure', () {
|
|
testWidgets('invalid credentials: one banner, no field error, input kept', (
|
|
tester,
|
|
) async {
|
|
stubSignInFailure(const AuthFailure.invalidCredentials());
|
|
await open(tester);
|
|
await fill(tester);
|
|
|
|
await submit(tester);
|
|
|
|
expect(find.text(_de.authErrorInvalidCredentials), findsOneWidget);
|
|
expect(find.byKey(const Key('login_banner')), findsOneWidget);
|
|
expect(find.text(_de.authErrorEmailInvalid), findsNothing);
|
|
expect(find.text(_de.authPasswordRequired), findsNothing);
|
|
expect(
|
|
tester.widget<TextField>(fieldOf('login_email')).controller!.text,
|
|
'anna@example.com',
|
|
);
|
|
expect(
|
|
tester.widget<TextField>(fieldOf('login_password')).controller!.text,
|
|
'geheim-und-lang',
|
|
);
|
|
});
|
|
|
|
testWidgets(
|
|
'wrong email and wrong password show exactly the same screen text',
|
|
(tester) async {
|
|
// Screen level only: the repository mock returns invalidCredentials
|
|
// for both cases, so this proves the screen adds nothing that tells
|
|
// them apart. The real proof that an unknown address and a wrong
|
|
// password yield the same `invalid_credentials` answer is the API
|
|
// E2E test (T3).
|
|
stubSignInFailure(const AuthFailure.invalidCredentials());
|
|
final handle = tester.ensureSemantics();
|
|
await open(tester);
|
|
await type(tester, 'login_email', 'unknown@example.com');
|
|
await type(tester, 'login_password', 'irgendein-passwort');
|
|
await submit(tester);
|
|
final wrongEmail = visibleTexts(tester);
|
|
|
|
await type(tester, 'login_email', 'anna@example.com');
|
|
await type(tester, 'login_password', 'falsches-passwort');
|
|
await submit(tester);
|
|
final wrongPassword = visibleTexts(tester);
|
|
|
|
expect(wrongEmail, wrongPassword);
|
|
// Fixed wording, independent of the l10n getter.
|
|
const fixed = 'E-Mail-Adresse oder Passwort ist nicht korrekt.';
|
|
expect(wrongEmail, contains(fixed));
|
|
expect(_de.authErrorInvalidCredentials, fixed);
|
|
expect(find.text(fixed), findsOneWidget);
|
|
expect(find.bySemanticsLabel('Fehler: $fixed'), findsOneWidget);
|
|
handle.dispose();
|
|
},
|
|
);
|
|
|
|
testWidgets('throttling (429) shows the throttle hint', (tester) async {
|
|
stubSignInFailure(const AuthFailure.rateLimited());
|
|
await open(tester);
|
|
await fill(tester);
|
|
|
|
await submit(tester);
|
|
|
|
expect(find.text(_de.authErrorRateLimited), findsOneWidget);
|
|
expect(find.text(_de.authActionRetry), findsNothing);
|
|
});
|
|
|
|
testWidgets('offline: shows the hint, keeps the input, can retry', (
|
|
tester,
|
|
) async {
|
|
var calls = 0;
|
|
when(() => repository.signIn(any(), any())).thenAnswer((_) async {
|
|
calls++;
|
|
return calls == 1 ? failed(const AuthFailure.network()) : ok();
|
|
});
|
|
final (:container, :router) = await open(tester);
|
|
await fill(tester);
|
|
|
|
await submit(tester);
|
|
expect(find.text(_de.authErrorNetwork), findsOneWidget);
|
|
expect(
|
|
tester.widget<TextField>(fieldOf('login_email')).controller!.text,
|
|
'anna@example.com',
|
|
);
|
|
|
|
await tester.tap(find.text(_de.authActionRetry));
|
|
await tester.pumpAndSettle();
|
|
|
|
verify(() => repository.signIn(any(), any())).called(2);
|
|
expect(currentPath(router), '/');
|
|
});
|
|
});
|
|
|
|
group('KTO-01 Kriterium 6: unconfirmed account', () {
|
|
setUp(() => stubSignInFailure(const AuthFailure.emailNotConfirmed()));
|
|
|
|
testWidgets('shows the hint and resends the confirmation', (tester) async {
|
|
await open(tester);
|
|
await fill(tester);
|
|
|
|
await submit(tester);
|
|
expect(find.text(_de.authErrorEmailNotConfirmed), findsOneWidget);
|
|
expect(find.text(_de.authErrorInvalidCredentials), findsNothing);
|
|
|
|
await tester.tap(find.text(_de.authActionResendConfirmation));
|
|
await tester.pumpAndSettle();
|
|
|
|
verify(() => repository.resendConfirmation('anna@example.com')).called(1);
|
|
expect(find.byKey(const Key('login_resent_banner')), findsOneWidget);
|
|
expect(find.text(_de.authCheckEmailResent), findsOneWidget);
|
|
});
|
|
|
|
testWidgets('emailSendLimited shows the throttle hint, not success', (
|
|
tester,
|
|
) async {
|
|
when(
|
|
() => repository.resendConfirmation(any()),
|
|
).thenAnswer((_) async => failed(const AuthFailure.emailSendLimited()));
|
|
await open(tester);
|
|
await fill(tester);
|
|
await submit(tester);
|
|
|
|
await tester.tap(find.text(_de.authActionResendConfirmation));
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(find.text(_de.authErrorRateLimited), findsOneWidget);
|
|
expect(find.byKey(const Key('login_resent_banner')), findsNothing);
|
|
});
|
|
|
|
testWidgets('S4: a server failure on resend stays visible at login', (
|
|
tester,
|
|
) async {
|
|
when(() => repository.resendConfirmation(any()))
|
|
.thenAnswer((_) async => failed(const AuthFailure.unexpected()));
|
|
await open(tester);
|
|
await fill(tester);
|
|
await submit(tester);
|
|
|
|
await tester.tap(find.text(_de.authActionResendConfirmation));
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(find.text(_de.authErrorUnexpected), findsOneWidget);
|
|
expect(find.byKey(const Key('login_resent_banner')), findsNothing);
|
|
});
|
|
|
|
testWidgets('the resend action is locked while sending', (tester) async {
|
|
final semanticsHandle = tester.ensureSemantics();
|
|
final completer = Completer<Result<void, AuthFailure>>();
|
|
when(() => repository.resendConfirmation(any()))
|
|
.thenAnswer((_) => completer.future);
|
|
await open(tester);
|
|
await fill(tester);
|
|
await submit(tester);
|
|
|
|
await tester.tap(find.text(_de.authActionResendConfirmation));
|
|
await tester.pump();
|
|
|
|
// Still there for screen readers, but disabled: no second send.
|
|
final action = find.widgetWithText(
|
|
TextButton,
|
|
_de.authActionResendConfirmation,
|
|
);
|
|
expect(action, findsOneWidget);
|
|
expect(tester.widget<TextButton>(action).onPressed, isNull);
|
|
expect(
|
|
tester
|
|
.getSemantics(action)
|
|
.getSemanticsData()
|
|
.flagsCollection
|
|
.isEnabled,
|
|
Tristate.isFalse,
|
|
);
|
|
await tester.tap(action, warnIfMissed: false);
|
|
semanticsHandle.dispose();
|
|
completer.complete(ok());
|
|
await tester.pumpAndSettle();
|
|
verify(() => repository.resendConfirmation(any())).called(1);
|
|
});
|
|
|
|
testWidgets('offline resend shows the offline hint', (tester) async {
|
|
when(() => repository.resendConfirmation(any()))
|
|
.thenAnswer((_) async => failed(const AuthFailure.network()));
|
|
await open(tester);
|
|
await fill(tester);
|
|
await submit(tester);
|
|
|
|
await tester.tap(find.text(_de.authActionResendConfirmation));
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(find.text(_de.authErrorNetwork), findsOneWidget);
|
|
});
|
|
});
|
|
|
|
group('KTO-01 Kriterium 5: autofill context', () {
|
|
// Only calls that save the credentials (`shouldSave: true`).
|
|
int finishCalls(WidgetTester tester) => tester.testTextInput.log
|
|
.where(
|
|
(call) =>
|
|
call.method == 'TextInput.finishAutofillContext' &&
|
|
call.arguments == true,
|
|
)
|
|
.length;
|
|
|
|
testWidgets('is finished after a successful sign-in', (tester) async {
|
|
await open(tester);
|
|
await fill(tester);
|
|
tester.testTextInput.log.clear();
|
|
|
|
await submit(tester);
|
|
|
|
// Our explicit call, plus the AutofillGroup committing on disposal
|
|
// when the screen is replaced.
|
|
expect(finishCalls(tester), greaterThanOrEqualTo(1));
|
|
});
|
|
|
|
testWidgets('is finished even if the router already removed the screen', (
|
|
tester,
|
|
) async {
|
|
final completer = Completer<Result<void, AuthFailure>>();
|
|
when(() => repository.signIn(any(), any()))
|
|
.thenAnswer((_) => completer.future);
|
|
final (:container, :router) = await open(tester);
|
|
await fill(tester);
|
|
await tapKey(tester, 'login_submit');
|
|
// The status change makes the router redirect away before the
|
|
// sign-in future completes.
|
|
router.go('/private');
|
|
await tester.pumpAndSettle();
|
|
expect(find.byKey(const Key('login_screen')), findsNothing);
|
|
tester.testTextInput.log.clear();
|
|
|
|
completer.complete(ok());
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(finishCalls(tester), 1);
|
|
expect(currentPath(router), '/private');
|
|
});
|
|
|
|
testWidgets('is not finished after a failed sign-in', (tester) async {
|
|
stubSignInFailure(const AuthFailure.invalidCredentials());
|
|
await open(tester);
|
|
await fill(tester);
|
|
tester.testTextInput.log.clear();
|
|
|
|
await submit(tester);
|
|
|
|
expect(finishCalls(tester), 0);
|
|
});
|
|
});
|
|
|
|
group('Zustände Kriterium 3: banners and focus', () {
|
|
testWidgets('unexpected error shows the banner with retry', (tester) async {
|
|
stubSignInFailure(const AuthFailure.unexpected());
|
|
await open(tester);
|
|
await fill(tester);
|
|
|
|
await submit(tester);
|
|
|
|
expect(find.text(_de.authErrorUnexpected), findsOneWidget);
|
|
expect(find.text(_de.authActionRetry), findsOneWidget);
|
|
stubSignInFailure(const AuthFailure.unexpected());
|
|
await tester.tap(find.text(_de.authActionRetry));
|
|
await tester.pumpAndSettle();
|
|
verify(() => repository.signIn(any(), any())).called(2);
|
|
});
|
|
|
|
testWidgets('a failed submit focuses the first invalid field', (
|
|
tester,
|
|
) async {
|
|
await open(tester);
|
|
|
|
await submit(tester);
|
|
expect(
|
|
tester.widget<TextField>(fieldOf('login_email')).focusNode!.hasFocus,
|
|
isTrue,
|
|
);
|
|
|
|
await type(tester, 'login_email', 'anna@example.com');
|
|
await submit(tester);
|
|
expect(
|
|
tester.widget<TextField>(fieldOf('login_password')).focusNode!.hasFocus,
|
|
isTrue,
|
|
);
|
|
});
|
|
});
|
|
|
|
group('QUE-05 Kriterium 2: de and en', () {
|
|
testWidgets('english texts', (tester) async {
|
|
await open(tester, locale: const Locale('en'));
|
|
|
|
expect(find.text(_en.authLoginTitle), findsWidgets);
|
|
expect(find.text(_en.authLoginForgotPassword), findsOneWidget);
|
|
expect(find.text(_en.authLoginCreateAccount), findsOneWidget);
|
|
expect(find.text(_de.authLoginForgotPassword), findsNothing);
|
|
});
|
|
});
|
|
|
|
group('QUE-06 Kriterien 1-4: accessibility', () {
|
|
for (final mode in [ThemeMode.light, ThemeMode.dark]) {
|
|
testWidgets('meets the guidelines ($mode)', (tester) async {
|
|
final handle = tester.ensureSemantics();
|
|
stubSignInFailure(const AuthFailure.emailNotConfirmed());
|
|
await open(tester, themeMode: mode);
|
|
await fill(tester);
|
|
await submit(tester);
|
|
|
|
await expectLater(tester, meetsGuideline(androidTapTargetGuideline));
|
|
await expectLater(tester, meetsGuideline(labeledTapTargetGuideline));
|
|
await expectLater(tester, meetsGuideline(textContrastGuideline));
|
|
handle.dispose();
|
|
});
|
|
}
|
|
|
|
for (final mode in [ThemeMode.light, ThemeMode.dark]) {
|
|
testWidgets('error banner meets the contrast guideline ($mode)', (
|
|
tester,
|
|
) async {
|
|
final handle = tester.ensureSemantics();
|
|
stubSignInFailure(const AuthFailure.invalidCredentials());
|
|
await open(tester, themeMode: mode);
|
|
await fill(tester);
|
|
await submit(tester);
|
|
expect(find.byKey(const Key('login_banner')), findsOneWidget);
|
|
|
|
await expectLater(tester, meetsGuideline(textContrastGuideline));
|
|
await expectLater(tester, meetsGuideline(androidTapTargetGuideline));
|
|
handle.dispose();
|
|
});
|
|
}
|
|
|
|
testWidgets('the title is a heading and the banner a live region', (
|
|
tester,
|
|
) async {
|
|
final handle = tester.ensureSemantics();
|
|
stubSignInFailure(const AuthFailure.invalidCredentials());
|
|
await open(tester);
|
|
await fill(tester);
|
|
await submit(tester);
|
|
|
|
expect(
|
|
find.bySemanticsLabel(
|
|
'Fehler: E-Mail-Adresse oder Passwort ist nicht korrekt.',
|
|
),
|
|
findsOneWidget,
|
|
);
|
|
expect(
|
|
tester.getSemantics(find.text(_de.authLoginTitle).first),
|
|
matchesSemantics(label: _de.authLoginTitle, isHeader: true),
|
|
);
|
|
handle.dispose();
|
|
});
|
|
|
|
testWidgets('text scale 2.0 on a small phone does not overflow', (
|
|
tester,
|
|
) async {
|
|
stubSignInFailure(const AuthFailure.emailNotConfirmed());
|
|
await open(tester, textScale: 2, size: const Size(320, 640));
|
|
await fill(tester);
|
|
await submit(tester);
|
|
|
|
expect(tester.takeException(), isNull);
|
|
});
|
|
|
|
testWidgets('wide screens show a centered card', (tester) async {
|
|
await open(tester, size: const Size(1200, 900));
|
|
|
|
final card = tester.getRect(find.byType(Card).first);
|
|
expect(card.width, lessThanOrEqualTo(480));
|
|
expect(card.center.dx, closeTo(600, 1));
|
|
});
|
|
});
|
|
}
|