diff --git a/.github/workflows/test-flutter.yaml b/.github/workflows/test-flutter.yaml index 363c041..23120cd 100644 --- a/.github/workflows/test-flutter.yaml +++ b/.github/workflows/test-flutter.yaml @@ -18,6 +18,8 @@ jobs: name: multi_packages_app_app - dir: example/multi_packages_app/theme name: multi_packages_app_theme + - dir: . + name: adaptive_test defaults: run: working-directory: ${{ matrix.dir }} diff --git a/README.md b/README.md index 091716d..e1cef1a 100644 --- a/README.md +++ b/README.md @@ -75,6 +75,23 @@ Future testExecutable(FutureOr Function() testMain) async { > ℹ️ `loadFonts()` loads fonts from `pubspec.yaml` and from every separate package dependency as well. +A font bundled by a dependency is registered under both its manifest name +(`packages/my_theme/Roboto`) and its bare family name (`Roboto`), since that is +the name a `TextStyle` or a `ThemeData` asks for. + +`loadFonts()` also registers the families the framework itself falls back to on +each platform — `Roboto` on Android, `CupertinoSystemText` on iOS, `Segoe UI` on +Windows, ... They are declared in no `pubspec.yaml`: they either ship with the +Flutter SDK or belong to the host OS. Without them, any widget that does not +specify a font — a `DatePickerDialog`, a `CupertinoButton` — renders as +placeholder blocks in your goldens. + +Opt out to register nothing but what the manifest declares: + +```dart +AdaptiveTestConfiguration.instance.setLoadPlatformFallbackFonts(false); +``` + ### Setting Up Test Devices 1. Define a set of device variants: diff --git a/example/multi_packages_app/app/test/src/fonts_test.dart b/example/multi_packages_app/app/test/src/fonts_test.dart new file mode 100644 index 0000000..4d1b065 --- /dev/null +++ b/example/multi_packages_app/app/test/src/fonts_test.dart @@ -0,0 +1,68 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// Whether text laid out with [fontFamily] is rendered with a real font. +/// +/// The placeholder font `flutter_test` uses for unknown families gives every +/// glyph the same advance, so a narrow and a wide string of the same length +/// measure the same. Any real proportional font measures them differently. +bool _rendersWithARealFont(String fontFamily) { + double widthOf(String text) { + final painter = TextPainter( + text: TextSpan( + text: text, + style: TextStyle(fontFamily: fontFamily, fontSize: 40), + ), + textDirection: TextDirection.ltr, + )..layout(); + final width = painter.width; + painter.dispose(); + + return width; + } + + return widthOf('iiii') != widthOf('WWWW'); +} + +void main() { + group('loadFonts in an app whose fonts live in a dependency', () { + test('renders a font bundled by a dependency under its bare name', () { + // ThemeSans is bundled by the theme package this app depends on, so the + // manifest only exposes it as + // `packages/multi_packages_example_theme/ThemeSans` — but a widget or a + // theme defaulting to that family asks for the bare name. + expect(_rendersWithARealFont('ThemeSans'), isTrue); + }); + + test('keeps the family under its manifest name as well', () { + expect( + _rendersWithARealFont( + 'packages/multi_packages_example_theme/ThemeSans', + ), + isTrue, + ); + }); + + test('renders the Material default typeface of the host platform', () { + final defaultFamily = Typography.material2021( + platform: TargetPlatform.iOS, + ).black.bodyMedium?.fontFamily; + + expect(defaultFamily, isNotNull); + // ignore: avoid-non-null-assertion, asserted right above + expect(_rendersWithARealFont(defaultFamily!), isTrue); + }); + + test('renders text with a family no font could ever provide', () { + // Apple's system typeface: it ships with iOS and cannot be bundled, so it + // is stood in for by the typeface the SDK ships. + const cupertinoTextTheme = CupertinoTextThemeData(); + final cupertinoFamily = cupertinoTextTheme.textStyle.fontFamily; + + expect(cupertinoFamily, isNotNull); + // ignore: avoid-non-null-assertion, asserted right above + expect(_rendersWithARealFont(cupertinoFamily!), isTrue); + }); + }); +} diff --git a/example/multi_packages_app/theme/pubspec.yaml b/example/multi_packages_app/theme/pubspec.yaml index 5c9c32b..4bae3c2 100644 --- a/example/multi_packages_app/theme/pubspec.yaml +++ b/example/multi_packages_app/theme/pubspec.yaml @@ -41,4 +41,9 @@ flutter: - asset: fonts/Roboto-MediumItalic.ttf - asset: fonts/Roboto-Regular.ttf - asset: fonts/Roboto-Thin.ttf - - asset: fonts/Roboto-ThinItalic.ttf \ No newline at end of file + - asset: fonts/Roboto-ThinItalic.ttf + # Declared from an existing asset, to cover a font family bundled by a + # dependency that does not collide with a platform default one. + - family: ThemeSans + fonts: + - asset: fonts/Roboto-Regular.ttf diff --git a/lib/adaptive_test.dart b/lib/adaptive_test.dart index 33a304e..9175e6e 100644 --- a/lib/adaptive_test.dart +++ b/lib/adaptive_test.dart @@ -1,15 +1,16 @@ export 'src/adaptive/adaptive_test.dart'; export 'src/adaptive/devices_data.dart'; export 'src/adaptive/widgets/adaptive_wrapper.dart'; -export 'src/adaptive/window_configuration_tester.dart'; export 'src/adaptive/window_config.dart'; +export 'src/adaptive/window_config_data/dynamic_island_data.dart'; +export 'src/adaptive/window_config_data/punch_hole_data.dart'; +export 'src/adaptive/window_config_data/system_nav_bar_data.dart' + show SystemNavBarData; +export 'src/adaptive/window_config_data/window_config_data.dart'; +export 'src/adaptive/window_configuration_tester.dart'; export 'src/configuration.dart'; export 'src/helpers/await_images.dart'; +export 'src/helpers/font_registration.dart'; export 'src/helpers/fonts_loader.dart'; export 'src/helpers/goldens_difference.dart'; export 'src/helpers/skip_test_extension.dart'; -export 'src/adaptive/window_config_data/window_config_data.dart'; -export 'src/adaptive/window_config_data/system_nav_bar_data.dart' - show SystemNavBarData; -export 'src/adaptive/window_config_data/punch_hole_data.dart'; -export 'src/adaptive/window_config_data/dynamic_island_data.dart'; diff --git a/lib/src/adaptive/devices_data.dart b/lib/src/adaptive/devices_data.dart index 74b3b44..559b819 100644 --- a/lib/src/adaptive/devices_data.dart +++ b/lib/src/adaptive/devices_data.dart @@ -1,3 +1,5 @@ +// ignore_for_file: constant_identifier_names + import 'package:adaptive_test/src/adaptive/window_config_data/dynamic_island_data.dart'; import 'package:adaptive_test/src/adaptive/window_config_data/punch_hole_data.dart'; import 'package:adaptive_test/src/adaptive/window_config_data/system_nav_bar_data.dart'; diff --git a/lib/src/adaptive/widgets/adaptive_wrapper.dart b/lib/src/adaptive/widgets/adaptive_wrapper.dart index a259f81..22c6658 100644 --- a/lib/src/adaptive/widgets/adaptive_wrapper.dart +++ b/lib/src/adaptive/widgets/adaptive_wrapper.dart @@ -1,8 +1,8 @@ import 'package:adaptive_test/src/adaptive/widgets/layers/hardware_layer.dart'; import 'package:adaptive_test/src/adaptive/widgets/layers/keyboard_layer.dart'; import 'package:adaptive_test/src/adaptive/widgets/layers/system_nav_bar_layer.dart'; -import 'package:adaptive_test/src/adaptive/window_config_data/window_config_data.dart'; import 'package:adaptive_test/src/adaptive/window_config.dart'; +import 'package:adaptive_test/src/adaptive/window_config_data/window_config_data.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; diff --git a/lib/src/adaptive/widgets/layers/keyboard_layer.dart b/lib/src/adaptive/widgets/layers/keyboard_layer.dart index b5d832e..71c9305 100644 --- a/lib/src/adaptive/widgets/layers/keyboard_layer.dart +++ b/lib/src/adaptive/widgets/layers/keyboard_layer.dart @@ -1,5 +1,5 @@ -import 'package:adaptive_test/src/adaptive/window_configuration_tester.dart'; import 'package:adaptive_test/src/adaptive/window_config.dart'; +import 'package:adaptive_test/src/adaptive/window_configuration_tester.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; diff --git a/lib/src/adaptive/widgets/layers/three_button_system_nav_bar_layer.dart b/lib/src/adaptive/widgets/layers/three_button_system_nav_bar_layer.dart index 53c2134..cbb7bae 100644 --- a/lib/src/adaptive/widgets/layers/three_button_system_nav_bar_layer.dart +++ b/lib/src/adaptive/widgets/layers/three_button_system_nav_bar_layer.dart @@ -32,7 +32,7 @@ class ThreeButtonSystemNavBarLayer extends StatelessWidget { children: const [ Icons.arrow_back_ios_rounded, Icons.circle, - Icons.square_rounded + Icons.square_rounded, ] .map( (iconData) => Icon( diff --git a/lib/src/adaptive/window_config_data/window_config_data.dart b/lib/src/adaptive/window_config_data/window_config_data.dart index 02253c1..7e94153 100644 --- a/lib/src/adaptive/window_config_data/window_config_data.dart +++ b/lib/src/adaptive/window_config_data/window_config_data.dart @@ -53,7 +53,8 @@ class WindowConfigData extends Equatable { /// This is null when the device has no notch. final Size? notchSize; - /// Describe the size of the device physical screen top dynamic island in `dp`. + /// Describe the size of the device physical screen top dynamic island in + /// `dp`. /// /// This is null when the device has no dynamic island. final DynamicIslandData? dynamicIsland; diff --git a/lib/src/adaptive/window_configuration_tester.dart b/lib/src/adaptive/window_configuration_tester.dart index eaba821..5267e83 100644 --- a/lib/src/adaptive/window_configuration_tester.dart +++ b/lib/src/adaptive/window_configuration_tester.dart @@ -19,13 +19,15 @@ extension WidgetTesterWithConfigurableWindow on WidgetTester { addTearDown(view.resetViewInsets); } - /// Configure the tester window to represent an opened keyboard on the given device variant. + /// Configure the tester window to represent an opened keyboard on the given + /// device variant. void configureOpenedKeyboardWindow(WindowConfigData windowConfig) { view.viewInsets = windowConfig.viewInsets; view.padding = windowConfig.padding.copyWith(bottom: 0); } - /// Configure the tester window to represent a closed keyboard on the given device variant. + /// Configure the tester window to represent a closed keyboard on the given + /// device variant. void configureClosedKeyboardWindow(WindowConfigData windowConfig) { view.resetViewInsets(); view.padding = windowConfig.padding; diff --git a/lib/src/configuration.dart b/lib/src/configuration.dart index 11b7f4d..685d5eb 100644 --- a/lib/src/configuration.dart +++ b/lib/src/configuration.dart @@ -1,3 +1,5 @@ +// ignore_for_file: use_setters_to_change_properties + import 'package:adaptive_test/src/adaptive/window_config.dart'; import 'package:adaptive_test/src/adaptive/window_config_data/window_config_data.dart'; import 'package:flutter/material.dart'; @@ -46,6 +48,21 @@ class AdaptiveTestConfiguration { _failTestOnWrongPlatform = failTestOnWrongPlatform; } + bool _loadPlatformFallbackFonts = true; + + bool get loadPlatformFallbackFonts => _loadPlatformFallbackFonts; + + /// Whether [loadFonts] also registers the font families the framework falls + /// back to on each platform, e.g. `Roboto` or `CupertinoSystemText`. They are + /// not declared in any `pubspec.yaml`, so without this the text using them + /// renders as placeholder blocks in goldens. + /// + /// Defaults to true. Set it to false to keep the previous behavior, for + /// instance if you would rather register those families yourself. + void setLoadPlatformFallbackFonts(bool loadPlatformFallbackFonts) { + _loadPlatformFallbackFonts = loadPlatformFallbackFonts; + } + WindowVariant? _deviceVariant; WindowVariant get deviceVariant { @@ -65,7 +82,8 @@ See: https://api.flutter.dev/flutter/flutter_test/flutter_test-library.html /// Set the devices variant on which you want your test to run. /// - /// Eg [iPhone8], [iPhone13], [iPhone16],[iPhone16Dark], [iPadPro], [desktop], [pixel5], [pixel9]. + /// Eg [iPhone8], [iPhone13], [iPhone16],[iPhone16Dark], [iPadPro], + /// [desktop], [pixel5], [pixel9]. void setDeviceVariants(Set deviceConfigs) { _deviceVariant = WindowVariant(deviceConfigs); } diff --git a/lib/src/helpers/font_loading_policy.dart b/lib/src/helpers/font_loading_policy.dart new file mode 100644 index 0000000..ddbb073 --- /dev/null +++ b/lib/src/helpers/font_loading_policy.dart @@ -0,0 +1,47 @@ +import 'package:adaptive_test/src/helpers/platform_fonts.dart'; +import 'package:meta/meta.dart'; + +/// Which font families [loadFonts] provides beyond the ones the font manifest +/// declares, and under which names. +/// +/// [platformFamilies] are the families the framework falls back to when a +/// widget does not specify a font, [sdkFamilies] the ones the Flutter SDK ships +/// font files for. +@immutable +@internal +class FontLoadingPolicy { + const FontLoadingPolicy({ + required this.platformFamilies, + required this.sdkFamilies, + }); + + /// Provides the platform default families, gathered from the SDK. + factory FontLoadingPolicy.platformAware() => FontLoadingPolicy( + platformFamilies: platformDefaultFontFamilies(), + sdkFamilies: sdkFontFamilies(), + ); + + /// Registers nothing but what the font manifest declares. + const FontLoadingPolicy.manifestOnly() + : platformFamilies = const {}, + sdkFamilies = const {}; + + final Set platformFamilies; + final Set sdkFamilies; + + /// Whether a font bundled by a dependency, exposed by the manifest as + /// `packages/my_theme/Roboto`, is also registered under its bare [family] + /// name — the name a `TextStyle` or a `ThemeData` asks for. + /// + /// It is, unless it would shadow a platform default the SDK ships a font for. + /// A dependency usually bundles a single weight of such a family while the + /// SDK ships all of them, and on a device that bundled font would not shadow + /// the platform one either: it is only reachable through its `packages/` + /// name. + bool registersBareFamilyName(String family) => + !platformFamilies.contains(family) || !sdkFamilies.contains(family); + + /// The families that must be provided on top of the manifest, so that text + /// laid out with a platform default does not render as placeholder blocks. + Set get familiesToProvide => platformFamilies; +} diff --git a/lib/src/helpers/font_registration.dart b/lib/src/helpers/font_registration.dart new file mode 100644 index 0000000..2af7325 --- /dev/null +++ b/lib/src/helpers/font_registration.dart @@ -0,0 +1,14 @@ +import 'package:flutter/services.dart'; + +/// Registers [assets] under [family] and returns that family name, so that +/// callers can collect what they loaded without going through shared state. +Future registerFontFamily( + String family, + Iterable> assets, +) async { + final loader = FontLoader(family); + assets.forEach(loader.addFont); + await loader.load(); + + return family; +} diff --git a/lib/src/helpers/fonts_loader.dart b/lib/src/helpers/fonts_loader.dart index a743bce..2918948 100644 --- a/lib/src/helpers/fonts_loader.dart +++ b/lib/src/helpers/fonts_loader.dart @@ -3,22 +3,43 @@ import 'dart:convert'; import 'dart:io'; +import 'package:adaptive_test/src/configuration.dart'; +import 'package:adaptive_test/src/helpers/font_loading_policy.dart'; +import 'package:adaptive_test/src/helpers/font_registration.dart'; +import 'package:adaptive_test/src/helpers/platform_fonts.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:package_config/package_config.dart'; -/// Loads fonts and icons to ensure they appear in golden tests. +/// Loads the fonts and icons the goldens need, and returns the font families +/// that were registered. /// /// Usage: /// 1. Create a flutter_test_config.dart file. /// 2. Add `await loadFonts();` in the `testExecutable` function. /// /// Note: Your package must include all used fonts as assets for this to work. -Future loadFonts() async { +/// +/// The families the framework falls back to on each platform are loaded too. +/// Opt out with +/// `AdaptiveTestConfiguration.instance.setLoadPlatformFallbackFonts(false)`. +Future> loadFonts() async { TestWidgetsFlutterBinding.ensureInitialized(); - final fontManifest = await _loadFontManifest(); - final packageName = await _getCurrentPackageName(); - await _loadFontsFromManifest(fontManifest, packageName); + final policy = AdaptiveTestConfiguration.instance.loadPlatformFallbackFonts + ? FontLoadingPolicy.platformAware() + : const FontLoadingPolicy.manifestOnly(); + + final manifestFamilies = await _loadFontsFromManifest( + await _loadFontManifest(), + packageName: await _getCurrentPackageName(), + policy: policy, + ); + final providedFamilies = await loadPlatformFallbackFonts( + familiesToProvide: policy.familiesToProvide, + alreadyLoaded: manifestFamilies, + ); + + return {...manifestFamilies, ...providedFamilies}; } Future<_FontManifest> _loadFontManifest() async { @@ -30,33 +51,43 @@ Future<_FontManifest> _loadFontManifest() async { return fontManifest.map((font) => _FontData.fromJson(font)).toList(); } -Future _loadFontsFromManifest( - _FontManifest fontManifest, - String? packageName, -) async { - final fontLoaders = fontManifest.expand((font) { - final regularFontLoader = _createFontLoader(font.family, font.fonts); +Future> _loadFontsFromManifest( + _FontManifest fontManifest, { + required String? packageName, + required FontLoadingPolicy policy, +}) async { + final loadings = fontManifest.expand((font) { final fontFamilyStartsWithPackages = font.family.startsWith('packages/'); + // A font bundled by a dependency shows up as `packages//` in + // the manifest, but a widget styled with + // `TextStyle(fontFamily: 'Roboto', package: 'my_theme')` — or a theme + // defaulting to that family — asks for the bare name. + final bareFamily = + fontFamilyStartsWithPackages ? _bareFamilyName(font.family) : null; return [ - regularFontLoader, + _loadFontFamily(font.family, font.fonts), if (!fontFamilyStartsWithPackages && packageName != null) - _createFontLoader('packages/$packageName/${font.family}', font.fonts), + _loadFontFamily('packages/$packageName/${font.family}', font.fonts), + if (bareFamily != null && policy.registersBareFamilyName(bareFamily)) + _loadFontFamily(bareFamily, font.fonts), ]; }).toList(); - await Future.wait(fontLoaders.map((loader) => loader.load())); + return (await Future.wait(loadings)).toSet(); } -FontLoader _createFontLoader(String fontFamily, List<_FontType> fontTypes) { - final fontLoader = FontLoader(fontFamily); - fontTypes.forEach( - (fontType) => fontLoader.addFont(rootBundle.load(fontType.asset)), +Future _loadFontFamily(String fontFamily, List<_FontType> fontTypes) { + return registerFontFamily( + fontFamily, + fontTypes.map((fontType) => rootBundle.load(fontType.asset)), ); - - return fontLoader; } +/// `packages/my_theme/Roboto` -> `Roboto`. +String _bareFamilyName(String manifestFamily) => + manifestFamily.split('/').skip(2).join('/'); + Future _getCurrentPackageName() async { final current = Directory.current; final packageConfig = await findPackageConfig(current); diff --git a/lib/src/helpers/goldens_difference.dart b/lib/src/helpers/goldens_difference.dart index 8b5f500..3fa72f6 100644 --- a/lib/src/helpers/goldens_difference.dart +++ b/lib/src/helpers/goldens_difference.dart @@ -36,7 +36,10 @@ void setupFileComparatorWithThreshold([ /// exceeded, marks the test as a failure. class LocalFileComparatorWithThreshold extends LocalFileComparator { LocalFileComparatorWithThreshold(super.testFile, this.threshold) - : assert(threshold >= 0 && threshold <= 1); + : assert( + threshold >= 0 && threshold <= 1, + 'The threshold must be between 0 and 1 inclusive', + ); /// Threshold above which tests will be marked as failing. /// Ranges from 0 to 1, both inclusive. diff --git a/lib/src/helpers/platform_fonts.dart b/lib/src/helpers/platform_fonts.dart new file mode 100644 index 0000000..cb6405d --- /dev/null +++ b/lib/src/helpers/platform_fonts.dart @@ -0,0 +1,203 @@ +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:adaptive_test/src/helpers/font_registration.dart'; +import 'package:collection/collection.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:meta/meta.dart'; +import 'package:path/path.dart' as path; + +/// The font families the framework itself falls back to when a widget does not +/// specify one, gathered from the SDK rather than hardcoded so that the list +/// keeps up with Flutter (`Roboto`, `CupertinoSystemText`, `Segoe UI`, ...). +/// +/// None of those families are declared in a `pubspec.yaml`: they are provided +/// by the host OS or bundled with the engine, so [loadFonts] cannot find them +/// in the font manifest. +@internal +Set platformDefaultFontFamilies() { + const cupertinoTextTheme = CupertinoTextThemeData(); + + return { + ...TargetPlatform.values + .map( + (targetPlatform) => Typography.material2021(platform: targetPlatform), + ) + .expand(_textThemesOf) + .expand(_representativeStylesOf) + .expand(_familiesOf), + ...[ + cupertinoTextTheme.textStyle, + cupertinoTextTheme.navTitleTextStyle, + cupertinoTextTheme.navLargeTitleTextStyle, + cupertinoTextTheme.actionTextStyle, + cupertinoTextTheme.tabLabelTextStyle, + cupertinoTextTheme.pickerTextStyle, + ].expand(_familiesOf), + }; +} + +Iterable _textThemesOf(Typography typography) => [ + typography.black, + typography.white, + typography.englishLike, + typography.dense, + typography.tall, + ]; + +/// The families do not vary with the size within a text theme, so a few styles +/// are enough to collect them all. +Iterable _representativeStylesOf(TextTheme textTheme) => [ + textTheme.displayLarge, + textTheme.headlineMedium, + textTheme.titleMedium, + textTheme.bodyMedium, + textTheme.labelLarge, + ]; + +Iterable _familiesOf(TextStyle? style) => [ + if (style?.fontFamily case final family?) family, + ...?style?.fontFamilyFallback, + ]; + +/// Locates the fonts shipped with the Flutter SDK +/// (`$FLUTTER_ROOT/bin/cache/artifacts/material_fonts`). +/// +/// `FLUTTER_ROOT` is exported by `flutter test`, but the directory is also +/// resolved by walking up from the test executable, which lives inside the +/// same `bin/cache` tree, so this keeps working in environments where the +/// variable is not set. +Directory? _findMaterialFontsDirectory() { + const dirName = 'material_fonts'; + final flutterRoot = Platform.environment['FLUTTER_ROOT']; + + final candidatePaths = [ + if (flutterRoot != null) + path.join(flutterRoot, 'bin', 'cache', 'artifacts', dirName), + // e.g. $FLUTTER_ROOT/bin/cache/artifacts/engine/darwin-x64/flutter_tester + ..._ancestorsOf(File(Platform.resolvedExecutable).parent.path) + .map((ancestor) => path.join(ancestor, dirName)), + ]; + + return candidatePaths + .map(Directory.new) + .firstWhereOrNull((directory) => directory.existsSync()); +} + +/// The given directory and all its parents, closest first. +Iterable _ancestorsOf(String directoryPath) { + final segments = path.split(directoryPath); + + return List.generate( + segments.length, + (index) => path.joinAll(segments.take(segments.length - index)), + ); +} + +/// The font files the Flutter SDK ships, grouped by the family they belong to. +/// +/// Empty when the SDK font cache cannot be located. +@visibleForTesting +Map> sdkFontFilesByFamily() { + const fontExtensions = ['.ttf', '.otf']; + final fontsDirectory = _findMaterialFontsDirectory(); + if (fontsDirectory == null || !fontsDirectory.existsSync()) return const {}; + + final fontFiles = fontsDirectory + .listSync() + .whereType() + .where((file) => fontExtensions.contains(path.extension(file.path))) + // A directory listing comes back in file system order, which differs + // between APFS, ext4 and NTFS, and the fonts must be registered in the + // same order on every machine for the goldens to match. + .sortedBy((file) => file.path); + + return Map.fromEntries( + fontFiles.groupListsBy(_familyNameOf).entries.expand( + (entry) => [ + if (entry.key case final family?) MapEntry(family, entry.value), + ], + ), + ); +} + +/// The family a font file belongs to, e.g. `Roboto-BoldItalic.ttf` belongs to +/// `Roboto`. +/// +/// Null for a file name that carries no family at all, which the fonts shipped +/// by the SDK always do: such a file is left out rather than grouped under a +/// made up name. +String? _familyNameOf(File file) => + path.basenameWithoutExtension(file.path).split('-').firstOrNull; + +/// The font families the Flutter SDK ships font files for. +/// +/// Empty when the SDK font cache cannot be located. +@internal +Set sdkFontFamilies() => sdkFontFilesByFamily().keys.toSet(); + +/// Registers the [familiesToProvide] that [alreadyLoaded] does not cover, so +/// that text laid out with a platform default renders real glyphs instead of +/// placeholder blocks, and returns the families it registered. +/// +/// Families that the SDK actually ships (`Roboto`) are loaded from their own +/// files. The remaining ones are provided by the host OS and cannot be loaded +/// at all — Apple's `CupertinoSystemText` for instance — so they are aliased +/// to the face the SDK ships. Glyphs then differ from a real device, but the +/// goldens stay readable and stable. +@internal +Future> loadPlatformFallbackFonts({ + required Set familiesToProvide, + required Set alreadyLoaded, +}) async { + final missingFamilies = familiesToProvide.difference(alreadyLoaded); + if (missingFamilies.isEmpty) return const {}; + + final availableFamilies = sdkFontFilesByFamily(); + final frameworkFallbackFamily = _frameworkFallbackFamily( + availableFamilies.keys, + familiesToProvide, + ); + if (frameworkFallbackFamily == null) return const {}; + + // Only the files that are about to be registered are read, and each one only + // once: the same bytes are reused by every family aliased to them. + final ownAssets = Map>.fromEntries( + availableFamilies.entries + .where((entry) => missingFamilies.contains(entry.key)) + .map((entry) => MapEntry(entry.key, _readAll(entry.value))), + ); + final frameworkFallbackAssets = ownAssets[frameworkFallbackFamily] ?? + _readAll(availableFamilies[frameworkFallbackFamily] ?? const []); + + final registeredFamilies = await Future.wait( + missingFamilies.map( + (family) => registerFontFamily( + family, + (ownAssets[family] ?? frameworkFallbackAssets) + .map(Future.value), + ), + ), + ); + + return registeredFamilies.toSet(); +} + +/// The family the framework falls back to that the SDK ships a font for, used +/// to stand in for the families only the host OS can provide. +/// +/// Derived from what the framework defaults to, so no typeface name has to be +/// hardcoded here. +String? _frameworkFallbackFamily( + Iterable availableFamilies, + Set platformFamilies, +) => + availableFamilies + .toSet() + .intersection(platformFamilies) + .sorted((a, b) => a.compareTo(b)) + .firstOrNull; + +List _readAll(Iterable files) => + files.map((file) => ByteData.view(file.readAsBytesSync().buffer)).toList(); diff --git a/lib/src/helpers/skip_test_extension.dart b/lib/src/helpers/skip_test_extension.dart index 6deb121..e12deb9 100644 --- a/lib/src/helpers/skip_test_extension.dart +++ b/lib/src/helpers/skip_test_extension.dart @@ -9,8 +9,8 @@ extension ShouldSkipAdaptiveTest on AdaptiveTestConfiguration { /// runtime platform does not match the enforced platform, the test will be /// skipped if [AdaptiveTestConfiguration.failTestOnWrongPlatform] is false. /// - /// This extension is used to determine if a test should be skipped based on the - /// [AdaptiveTestConfiguration.enforcedTestPlatform] and + /// This extension is used to determine if a test should be skipped based on + /// the [AdaptiveTestConfiguration.enforcedTestPlatform] and /// [AdaptiveTestConfiguration.failTestOnWrongPlatform] values. bool get shouldSkipTest { final configuration = AdaptiveTestConfiguration.instance; diff --git a/lib/src/helpers/target_platform_extension.dart b/lib/src/helpers/target_platform_extension.dart index fbc72d9..236bcf5 100644 --- a/lib/src/helpers/target_platform_extension.dart +++ b/lib/src/helpers/target_platform_extension.dart @@ -2,7 +2,6 @@ import 'dart:developer'; import 'dart:io'; import 'package:flutter/foundation.dart'; -import 'package:meta/meta.dart'; @internal extension IsRuntimePlatform on TargetPlatform { @@ -13,7 +12,9 @@ extension IsRuntimePlatform on TargetPlatform { TargetPlatform.windows: return; - default: + case TargetPlatform.android || + TargetPlatform.iOS || + TargetPlatform.fuchsia: log('Tests are intended to be runned on linux, macOS or windows' ' platform. But you are running them on $name'); } diff --git a/pubspec.yaml b/pubspec.yaml index 6fca261..88c0a1f 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -10,6 +10,7 @@ environment: flutter: '>=1.17.0' dependencies: + collection: ^1.18.0 cupertino_icons: ^1.0.5 equatable: ^2.0.5 file: ^7.0.0 diff --git a/test/helpers/font_loading_policy_test.dart b/test/helpers/font_loading_policy_test.dart new file mode 100644 index 0000000..fa93499 --- /dev/null +++ b/test/helpers/font_loading_policy_test.dart @@ -0,0 +1,47 @@ +import 'package:adaptive_test/src/helpers/font_loading_policy.dart'; +import 'package:adaptive_test/src/helpers/platform_fonts.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + setUpAll(TestWidgetsFlutterBinding.ensureInitialized); + + group('registersBareFamilyName', () { + const policy = FontLoadingPolicy( + platformFamilies: {'Roboto', 'CupertinoSystemText'}, + sdkFamilies: {'Roboto'}, + ); + + test('registers a family that is not a platform default', () { + expect(policy.registersBareFamilyName('ThemeSans'), isTrue); + }); + + test('registers a platform default the SDK ships no font for', () { + // Nothing else can provide it, a stand-in beats placeholder blocks. + expect(policy.registersBareFamilyName('CupertinoSystemText'), isTrue); + }); + + test('leaves a platform default the SDK ships a font for alone', () { + // A dependency usually bundles a single weight of it, the SDK ships all + // of them, so the SDK font stays closer to a real device. + expect(policy.registersBareFamilyName('Roboto'), isFalse); + }); + }); + + group('FontLoadingPolicy.platformAware', () { + test('provides the platform default families', () { + final policy = FontLoadingPolicy.platformAware(); + + expect(policy.familiesToProvide, equals(platformDefaultFontFamilies())); + expect(policy.sdkFamilies, equals(sdkFontFamilies())); + }); + }); + + group('FontLoadingPolicy.manifestOnly', () { + test('provides nothing on top of the manifest', () { + const policy = FontLoadingPolicy.manifestOnly(); + + expect(policy.familiesToProvide, isEmpty); + expect(policy.registersBareFamilyName('Roboto'), isTrue); + }); + }); +} diff --git a/test/helpers/font_registration_test.dart b/test/helpers/font_registration_test.dart new file mode 100644 index 0000000..8c7ea92 --- /dev/null +++ b/test/helpers/font_registration_test.dart @@ -0,0 +1,32 @@ +import 'package:adaptive_test/adaptive_test.dart'; +import 'package:adaptive_test/src/helpers/platform_fonts.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'font_test_helpers.dart'; + +void main() { + setUpAll(TestWidgetsFlutterBinding.ensureInitialized); + + group('registerFontFamily', () { + test('returns the family it registered', () async { + final family = await registerFontFamily('AFamily', const []); + + expect(family, 'AFamily'); + }); + + test('registers the assets it is given', () async { + // A proportional face, so that the width heuristic can tell it apart + // from the placeholder font — an icon font would not do. + final proportionalFace = sdkFontFilesByFamily().entries.firstWhere( + (entry) => platformDefaultFontFamilies().contains(entry.key), + ); + + await registerFontFamily( + 'ARealFamily', + proportionalFace.value.toByteData(), + ); + + expect(rendersWithARealFont('ARealFamily'), isTrue); + }); + }); +} diff --git a/test/helpers/font_test_helpers.dart b/test/helpers/font_test_helpers.dart new file mode 100644 index 0000000..7d2aa46 --- /dev/null +++ b/test/helpers/font_test_helpers.dart @@ -0,0 +1,36 @@ +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:flutter/material.dart'; + +/// Whether text laid out with [fontFamily] is rendered with a real font. +/// +/// The placeholder font used by `flutter_test` when a family is unknown gives +/// every glyph the same advance, so a narrow and a wide string of the same +/// length measure exactly the same. Any real proportional font measures them +/// differently, which makes this a reliable way to assert a font was loaded +/// without comparing golden files. +bool rendersWithARealFont(String fontFamily) { + double widthOf(String text) { + final painter = TextPainter( + text: TextSpan( + text: text, + style: TextStyle(fontFamily: fontFamily, fontSize: 40), + ), + textDirection: TextDirection.ltr, + )..layout(); + final width = painter.width; + painter.dispose(); + + return width; + } + + return widthOf('iiii') != widthOf('WWWW'); +} + +extension FontFilesToAssets on Iterable { + /// The font files as the byte data a font loader expects. + Iterable> toByteData() => map( + (file) async => ByteData.view((await file.readAsBytes()).buffer), + ); +} diff --git a/test/helpers/fonts_loader_test.dart b/test/helpers/fonts_loader_test.dart new file mode 100644 index 0000000..1f9fa1a --- /dev/null +++ b/test/helpers/fonts_loader_test.dart @@ -0,0 +1,57 @@ +import 'package:adaptive_test/adaptive_test.dart'; +import 'package:adaptive_test/src/helpers/platform_fonts.dart'; +import 'package:collection/collection.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'font_test_helpers.dart'; + +void main() { + late Set loadedFamilies; + + setUpAll(() async => loadedFamilies = await loadFonts()); + + group('loadFonts', () { + test('registers the families declared in the font manifest', () { + // Bundled by this package through `uses-material-design: true`. + expect(loadedFamilies, contains('MaterialIcons')); + }); + + test('registers a dependency font under its manifest name', () { + // Bundled by the cupertino_icons dependency. + expect( + loadedFamilies, + contains('packages/cupertino_icons/CupertinoIcons'), + ); + }); + + test('registers a dependency font under its bare family name too', () { + // A widget styled with + // `TextStyle(fontFamily: 'CupertinoIcons', package: 'cupertino_icons')` + // asks for the bare name, not the manifest one. + expect(loadedFamilies, contains('CupertinoIcons')); + }); + + test('registers every font family the framework falls back to', () { + expect( + platformDefaultFontFamilies().difference(loadedFamilies), + isEmpty, + ); + }); + + test('makes the platform default families render real glyphs', () { + final blockRendering = platformDefaultFontFamilies().whereNot( + rendersWithARealFont, + ); + + expect( + blockRendering, + isEmpty, + reason: 'those families render with the placeholder test font', + ); + }); + + test('leaves unknown families to the placeholder font', () { + expect(rendersWithARealFont('NotARegisteredFontFamily'), isFalse); + }); + }); +} diff --git a/test/helpers/platform_fonts_test.dart b/test/helpers/platform_fonts_test.dart new file mode 100644 index 0000000..a3c1f6d --- /dev/null +++ b/test/helpers/platform_fonts_test.dart @@ -0,0 +1,142 @@ +import 'package:adaptive_test/src/helpers/platform_fonts.dart'; +import 'package:collection/collection.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + setUpAll(TestWidgetsFlutterBinding.ensureInitialized); + + group('platformDefaultFontFamilies', () { + test('collects the families of every target platform typography', () { + final materialFamilies = TargetPlatform.values + .map( + (targetPlatform) => Typography.material2021( + platform: targetPlatform, + ).black.bodyMedium?.fontFamily, + ) + .nonNulls + .toSet(); + + expect(materialFamilies, isNotEmpty); + expect(platformDefaultFontFamilies(), containsAll(materialFamilies)); + }); + + test('collects the Cupertino default family', () { + const cupertinoTextTheme = CupertinoTextThemeData(); + final cupertinoFamily = cupertinoTextTheme.textStyle.fontFamily; + + expect(cupertinoFamily, isNotNull); + expect(platformDefaultFontFamilies(), contains(cupertinoFamily)); + }); + + test('collects the fallbacks declared by the styles', () { + final fallbacks = TargetPlatform.values + .expand( + (targetPlatform) => + Typography.material2021(platform: targetPlatform) + .black + .bodyMedium + ?.fontFamilyFallback ?? + const [], + ) + .toSet(); + + expect(platformDefaultFontFamilies(), containsAll(fallbacks)); + }); + }); + + group('determinism across machines', () { + test('registers the SDK font files in a file system agnostic order', () { + // A directory listing comes back in file system order, which differs + // between APFS, ext4 and NTFS. + final filesByFamily = sdkFontFilesByFamily(); + + final unsortedFamilies = filesByFamily.entries.where((entry) { + final paths = entry.value.map((file) => file.path).toList(); + + return !paths.equals(paths.sorted((a, b) => a.compareTo(b))); + }).map((entry) => entry.key); + + expect(filesByFamily, isNotEmpty); + expect(unsortedFamilies, isEmpty); + }); + + test('does not depend on the host platform', () { + // The families are enumerated for every TargetPlatform, not for the one + // the test happens to run on. + final families = platformDefaultFontFamilies(); + + final perHostPlatform = TargetPlatform.values.map((targetPlatform) { + debugDefaultTargetPlatformOverride = targetPlatform; + + return platformDefaultFontFamilies(); + }).toList(); + debugDefaultTargetPlatformOverride = null; + + expect( + perHostPlatform.where((collected) => !setEquals(collected, families)), + isEmpty, + ); + }); + }); + + group('sdkFontFamilies', () { + test('lists the families the SDK ships', () { + // Also covers locating the SDK font cache, which callers cannot do + // themselves anymore. + final families = sdkFontFamilies(); + + expect(families, isNotEmpty); + expect( + families.intersection(platformDefaultFontFamilies()), + isNotEmpty, + reason: 'the SDK ships at least one of the platform default families', + ); + }); + }); + + group('loadPlatformFallbackFonts', () { + test('registers every family it is asked to provide', () async { + final registeredFamilies = await loadPlatformFallbackFonts( + familiesToProvide: platformDefaultFontFamilies(), + alreadyLoaded: const {}, + ); + + expect(registeredFamilies, containsAll(platformDefaultFontFamilies())); + }); + + test('aliases the families the SDK does not ship any file for', () async { + final osProvidedFamilies = + platformDefaultFontFamilies().difference(sdkFontFamilies()); + + final registeredFamilies = await loadPlatformFallbackFonts( + familiesToProvide: platformDefaultFontFamilies(), + alreadyLoaded: const {}, + ); + + // e.g. Apple's CupertinoSystemText, which no font file can provide. + expect(osProvidedFamilies, isNotEmpty); + expect(registeredFamilies, containsAll(osProvidedFamilies)); + }); + + test('skips the families already loaded', () async { + final registeredFamilies = await loadPlatformFallbackFonts( + familiesToProvide: platformDefaultFontFamilies(), + alreadyLoaded: platformDefaultFontFamilies(), + ); + + expect(registeredFamilies, isEmpty); + }); + + test('registers nothing when there is nothing to provide', () async { + final registeredFamilies = await loadPlatformFallbackFonts( + familiesToProvide: const {}, + alreadyLoaded: const {}, + ); + + expect(registeredFamilies, isEmpty); + }); + }); +}