Shuffle/app/test/features/auth/presentation/auth_ui_support.dart
cschlaefke cda9417353 fix(auth): keep heading, text and button as separate semantics nodes in the wide card
Card is a semantics container without explicitChildNodes, so on >= 600 dp
(web/tablet) title, text and button folded into one node. AppFormScaffold now
wraps the card content in Semantics(explicitChildNodes: true).

Refs #41

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-03 13:07:01 +01:00

164 lines
5.4 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_riverpod/misc.dart' show Override;
import 'package:flutter_riverpod/misc.dart' show ProviderListenable;
import 'package:flutter_test/flutter_test.dart';
import 'package:go_router/go_router.dart';
import 'package:mocktail/mocktail.dart';
import 'package:shuffle/core/l10n/app_locale.dart';
import 'package:shuffle/core/platform/platform_info.dart';
import 'package:shuffle/core/platform/url_opener.dart';
import 'package:shuffle/core/result/result.dart';
import 'package:shuffle/features/auth/domain/auth_failure.dart';
import 'package:shuffle/features/auth/domain/auth_repository.dart';
import 'package:shuffle/features/auth/domain/email_link_type.dart';
import 'package:shuffle/features/auth/domain/registration_input.dart';
import 'package:shuffle/features/auth/domain/validation/common_passwords.dart';
import 'package:shuffle/features/auth/presentation/auth_routes.dart';
import 'package:shuffle/features/auth/presentation/providers/auth_providers.dart';
import '../../../helpers/fakes.dart';
import '../../../helpers/pump_app.dart';
class MockAuthRepository extends Mock implements AuthRepository {}
class FakeRegistrationInput extends Fake implements RegistrationInput {}
/// [AppLocale] with a fixed language (tests do not depend on the device).
class FixedAppLocale extends AppLocale {
FixedAppLocale(this.locale);
final Locale locale;
@override
Locale build() => locale;
}
const commonTestPasswords = SetCommonPasswordList({'passwort1234'});
void registerAuthFallbacks() {
registerFallbackValue(FakeRegistrationInput());
registerFallbackValue(EmailLinkType.confirmation);
}
Result<void, AuthFailure> ok() => const Result.success(null);
Result<void, AuthFailure> failed(AuthFailure value) => Result.failure(value);
/// Overrides for auth UI tests: repository mock, fixed language, common
/// password list, URL opener and platform.
List<Override> authUiOverrides(
MockAuthRepository repository, {
Locale locale = const Locale('de'),
UrlOpener? urlOpener,
bool mobileWeb = false,
Future<CommonPasswordList> Function()? commonPasswords,
List<Override> extra = const [],
}) => testOverrides(
overrides: [
authRepositoryProvider.overrideWithValue(repository),
appLocaleProvider.overrideWith(() => FixedAppLocale(locale)),
commonPasswordListProvider.overrideWith(
(ref) => commonPasswords?.call() ?? Future.value(commonTestPasswords),
),
urlOpenerProvider.overrideWithValue(urlOpener ?? (_) async => true),
isMobileWebProvider.overrideWithValue(mobileWeb),
...extra,
],
);
/// The text field inside an `AppTextField`/`AppPasswordField` with [key].
Finder fieldOf(String key) =>
find.descendant(of: find.byKey(Key(key)), matching: find.byType(TextField));
/// Keeps an auto-dispose provider alive during a controller test.
void keepAlive(ProviderContainer container, ProviderListenable<Object?> p) {
container.listen(p, (_, _) {});
}
typedef AuthUiHandle = ({ProviderContainer container, GoRouter router});
/// Pumps the real auth routes (`authRoutes`, without router redirects) at
/// [location] on a phone-sized screen.
Future<AuthUiHandle> pumpAuthRoute(
WidgetTester tester,
MockAuthRepository repository, {
String location = '/register',
Object? extra,
Locale locale = const Locale('de'),
ThemeMode themeMode = ThemeMode.light,
double? textScale,
Size size = const Size(400, 800),
UrlOpener? urlOpener,
bool mobileWeb = false,
bool settle = true,
}) async {
tester.view.physicalSize = size;
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
final router = GoRouter(
initialLocation: location,
initialExtra: extra,
routes: authRoutes,
);
addTearDown(router.dispose);
final container = await pumpApp(
tester,
router: router,
locale: locale,
themeMode: themeMode,
textScale: textScale,
settle: settle,
overrides: authUiOverrides(
repository,
locale: locale,
urlOpener: urlOpener,
mobileWeb: mobileWeb,
),
);
return (container: container, router: router);
}
/// All texts currently visible (for comparing two screens exactly).
List<String> visibleTexts(WidgetTester tester) => [
for (final text in tester.widgetList<Text>(find.byType(Text)))
if (text.data != null) text.data!,
];
/// Wide layout (>= 600 dp): the screen sits in a card (issue #41).
const wideScreenSize = Size(1280, 720);
/// Asserts that title, body text and button are separate semantics nodes
/// (heading / text / button with label), not folded into one node (issue #41).
void expectSeparateSemantics(
WidgetTester tester, {
required String title,
required Key buttonKey,
required String buttonLabel,
Key? bodyKey,
String? bodyLabel,
}) {
expect(
tester.getSemantics(find.text(title).first),
matchesSemantics(label: title, isHeader: true),
reason: 'title must be a heading of its own',
);
if (bodyKey != null) {
expect(
tester.getSemantics(find.byKey(bodyKey)),
matchesSemantics(label: bodyLabel),
reason: 'body text must be a text node of its own',
);
}
expect(
tester.getSemantics(find.byKey(buttonKey)),
matchesSemantics(
label: buttonLabel,
isButton: true,
isEnabled: true,
hasEnabledState: true,
hasTapAction: true,
),
reason: 'button must be a button node of its own',
);
}