From 01e974082f55f37a474461425d9d8d066aa08b1c Mon Sep 17 00:00:00 2001 From: MaximeRougieux Date: Mon, 3 Aug 2026 10:37:08 +0200 Subject: [PATCH 1/5] fix: :rotating_light: linter warnings in adaptive test --- lib/adaptive_test.dart | 12 ++++++------ lib/src/adaptive/devices_data.dart | 2 ++ lib/src/adaptive/widgets/adaptive_wrapper.dart | 2 +- lib/src/adaptive/widgets/layers/keyboard_layer.dart | 2 +- .../layers/three_button_system_nav_bar_layer.dart | 2 +- .../window_config_data/window_config_data.dart | 3 ++- lib/src/adaptive/window_configuration_tester.dart | 6 ++++-- lib/src/configuration.dart | 5 ++++- lib/src/helpers/fonts_loader.dart | 6 +++--- lib/src/helpers/goldens_difference.dart | 5 ++++- lib/src/helpers/skip_test_extension.dart | 4 ++-- lib/src/helpers/target_platform_extension.dart | 5 +++-- 12 files changed, 33 insertions(+), 21 deletions(-) diff --git a/lib/adaptive_test.dart b/lib/adaptive_test.dart index 33a304e..fcd1c65 100644 --- a/lib/adaptive_test.dart +++ b/lib/adaptive_test.dart @@ -1,15 +1,15 @@ 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/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..59aa9c0 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'; @@ -65,7 +67,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/fonts_loader.dart b/lib/src/helpers/fonts_loader.dart index a743bce..03545a0 100644 --- a/lib/src/helpers/fonts_loader.dart +++ b/lib/src/helpers/fonts_loader.dart @@ -50,9 +50,9 @@ Future _loadFontsFromManifest( FontLoader _createFontLoader(String fontFamily, List<_FontType> fontTypes) { final fontLoader = FontLoader(fontFamily); - fontTypes.forEach( - (fontType) => fontLoader.addFont(rootBundle.load(fontType.asset)), - ); + for (final fontType in fontTypes) { + fontLoader.addFont(rootBundle.load(fontType.asset)); + } return fontLoader; } 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/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'); } From 37e68aaa942d9703bfb36b8cabd63ae0e77ac334 Mon Sep 17 00:00:00 2001 From: MaximeRougieux Date: Fri, 31 Jul 2026 15:00:04 +0200 Subject: [PATCH 2/5] ci: :construction_worker: run the package unit tests The matrix only covered the three example apps, so nothing under `test/` ever ran on CI. Format and analyze are left out of this job for now: the package sources still carry pre-existing analyzer infos. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/test-flutter.yaml | 2 ++ 1 file changed, 2 insertions(+) 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 }} From a65f486388934a3225e36143ffb881e45c53ae9a Mon Sep 17 00:00:00 2001 From: MaximeRougieux Date: Fri, 31 Jul 2026 14:57:53 +0200 Subject: [PATCH 3/5] feat: :sparkles: register a dependency font under its bare family name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A font bundled by a dependency is exposed by the font manifest as `packages/my_theme/Roboto`, and `loadFonts()` only registered it under that name. A widget styled with `TextStyle(fontFamily: 'Roboto', package: 'my_theme')` — or a theme defaulting to that family — asks for the bare `Roboto` instead, so no font was found and the text rendered as placeholder blocks in the goldens. Register both names. `loadFonts()` now returns the families it registered, which is also what lets the tests assert on it without shared state. Co-Authored-By: Claude Opus 5 (1M context) --- .../app/test/src/fonts_test.dart | 47 ++++++++++++++++ example/multi_packages_app/theme/pubspec.yaml | 7 ++- lib/adaptive_test.dart | 1 + lib/src/helpers/font_registration.dart | 14 +++++ lib/src/helpers/fonts_loader.dart | 54 +++++++++++-------- test/helpers/font_registration_test.dart | 14 +++++ test/helpers/fonts_loader_test.dart | 30 +++++++++++ 7 files changed, 145 insertions(+), 22 deletions(-) create mode 100644 example/multi_packages_app/app/test/src/fonts_test.dart create mode 100644 lib/src/helpers/font_registration.dart create mode 100644 test/helpers/font_registration_test.dart create mode 100644 test/helpers/fonts_loader_test.dart 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..04057aa --- /dev/null +++ b/example/multi_packages_app/app/test/src/fonts_test.dart @@ -0,0 +1,47 @@ +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, + ); + }); + + }); +} 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 fcd1c65..9175e6e 100644 --- a/lib/adaptive_test.dart +++ b/lib/adaptive_test.dart @@ -10,6 +10,7 @@ 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'; 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 03545a0..c2dd225 100644 --- a/lib/src/helpers/fonts_loader.dart +++ b/lib/src/helpers/fonts_loader.dart @@ -3,22 +3,26 @@ import 'dart:convert'; import 'dart:io'; +import 'package:adaptive_test/src/helpers/font_registration.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 { +Future> loadFonts() async { TestWidgetsFlutterBinding.ensureInitialized(); - final fontManifest = await _loadFontManifest(); - final packageName = await _getCurrentPackageName(); - await _loadFontsFromManifest(fontManifest, packageName); + + return _loadFontsFromManifest( + await _loadFontManifest(), + packageName: await _getCurrentPackageName(), + ); } Future<_FontManifest> _loadFontManifest() async { @@ -30,33 +34,41 @@ 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, +}) 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) _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); - for (final fontType in fontTypes) { - fontLoader.addFont(rootBundle.load(fontType.asset)); - } - - return fontLoader; +Future _loadFontFamily(String fontFamily, List<_FontType> fontTypes) { + return registerFontFamily( + fontFamily, + fontTypes.map((fontType) => rootBundle.load(fontType.asset)), + ); } +/// `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/test/helpers/font_registration_test.dart b/test/helpers/font_registration_test.dart new file mode 100644 index 0000000..0e3c6dc --- /dev/null +++ b/test/helpers/font_registration_test.dart @@ -0,0 +1,14 @@ +import 'package:adaptive_test/adaptive_test.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + setUpAll(TestWidgetsFlutterBinding.ensureInitialized); + + group('registerFontFamily', () { + test('returns the family it registered', () async { + final family = await registerFontFamily('AFamily', const []); + + expect(family, 'AFamily'); + }); + }); +} diff --git a/test/helpers/fonts_loader_test.dart b/test/helpers/fonts_loader_test.dart new file mode 100644 index 0000000..f1d55dd --- /dev/null +++ b/test/helpers/fonts_loader_test.dart @@ -0,0 +1,30 @@ +import 'package:adaptive_test/adaptive_test.dart'; +import 'package:flutter_test/flutter_test.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')); + }); + }); +} From d63be67ac4e1fd72cfd4bf0889d4a2b73a75cf10 Mon Sep 17 00:00:00 2001 From: MaximeRougieux Date: Fri, 31 Jul 2026 14:59:19 +0200 Subject: [PATCH 4/5] feat: :sparkles: load the font families the framework falls back to MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The families a widget gets when it specifies no font — `Roboto` on Android, `CupertinoSystemText` on iOS, `Segoe UI` on Windows — are declared in no `pubspec.yaml`: they either ship with the Flutter SDK or belong to the host OS. `loadFonts()` could not find them in the font manifest, so a `DatePickerDialog` or a `CupertinoButton` rendered as placeholder blocks in the goldens. The list is gathered from `Typography` and `CupertinoTextThemeData` instead of being hardcoded, so it follows the SDK. The families the SDK ships are loaded from its font cache, found through `FLUTTER_ROOT` or by walking up from the test executable. The ones only the OS can provide are aliased to the typeface the SDK ships: the glyphs then differ from a real device, but the goldens stay readable. Opt out with `setLoadPlatformFallbackFonts(false)`. `FontLoadingPolicy` holds what is registered on top of the manifest, and with it the arbitration introduced here: a dependency font does not claim the bare name of a platform default the SDK ships a font for. It usually bundles a single weight of such a family while the SDK ships all of them, and on a device it would not shadow the platform font either. The SDK font files are sorted before being registered: a directory listing comes back in file system order, which differs between APFS, ext4 and NTFS, and goldens must not depend on the machine that generated them. Co-Authored-By: Claude Opus 5 (1M context) --- .../app/test/src/fonts_test.dart | 21 ++ lib/src/configuration.dart | 15 ++ lib/src/helpers/font_loading_policy.dart | 47 ++++ lib/src/helpers/fonts_loader.dart | 23 +- lib/src/helpers/platform_fonts.dart | 203 ++++++++++++++++++ pubspec.yaml | 1 + test/helpers/font_loading_policy_test.dart | 47 ++++ test/helpers/font_registration_test.dart | 18 ++ test/helpers/font_test_helpers.dart | 36 ++++ test/helpers/fonts_loader_test.dart | 27 +++ test/helpers/platform_fonts_test.dart | 142 ++++++++++++ 11 files changed, 578 insertions(+), 2 deletions(-) create mode 100644 lib/src/helpers/font_loading_policy.dart create mode 100644 lib/src/helpers/platform_fonts.dart create mode 100644 test/helpers/font_loading_policy_test.dart create mode 100644 test/helpers/font_test_helpers.dart create mode 100644 test/helpers/platform_fonts_test.dart diff --git a/example/multi_packages_app/app/test/src/fonts_test.dart b/example/multi_packages_app/app/test/src/fonts_test.dart index 04057aa..4d1b065 100644 --- a/example/multi_packages_app/app/test/src/fonts_test.dart +++ b/example/multi_packages_app/app/test/src/fonts_test.dart @@ -1,3 +1,4 @@ +import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -43,5 +44,25 @@ void main() { ); }); + 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/lib/src/configuration.dart b/lib/src/configuration.dart index 59aa9c0..685d5eb 100644 --- a/lib/src/configuration.dart +++ b/lib/src/configuration.dart @@ -48,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 { 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/fonts_loader.dart b/lib/src/helpers/fonts_loader.dart index c2dd225..2918948 100644 --- a/lib/src/helpers/fonts_loader.dart +++ b/lib/src/helpers/fonts_loader.dart @@ -3,7 +3,10 @@ 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'; @@ -16,13 +19,27 @@ import 'package:package_config/package_config.dart'; /// 2. Add `await loadFonts();` in the `testExecutable` function. /// /// Note: Your package must include all used fonts as assets for this to work. +/// +/// 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 policy = AdaptiveTestConfiguration.instance.loadPlatformFallbackFonts + ? FontLoadingPolicy.platformAware() + : const FontLoadingPolicy.manifestOnly(); - return _loadFontsFromManifest( + 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 { @@ -37,6 +54,7 @@ Future<_FontManifest> _loadFontManifest() async { Future> _loadFontsFromManifest( _FontManifest fontManifest, { required String? packageName, + required FontLoadingPolicy policy, }) async { final loadings = fontManifest.expand((font) { final fontFamilyStartsWithPackages = font.family.startsWith('packages/'); @@ -51,7 +69,8 @@ Future> _loadFontsFromManifest( _loadFontFamily(font.family, font.fonts), if (!fontFamilyStartsWithPackages && packageName != null) _loadFontFamily('packages/$packageName/${font.family}', font.fonts), - if (bareFamily != null) _loadFontFamily(bareFamily, font.fonts), + if (bareFamily != null && policy.registersBareFamilyName(bareFamily)) + _loadFontFamily(bareFamily, font.fonts), ]; }).toList(); 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/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 index 0e3c6dc..8c7ea92 100644 --- a/test/helpers/font_registration_test.dart +++ b/test/helpers/font_registration_test.dart @@ -1,6 +1,9 @@ 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); @@ -10,5 +13,20 @@ void main() { 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 index f1d55dd..1f9fa1a 100644 --- a/test/helpers/fonts_loader_test.dart +++ b/test/helpers/fonts_loader_test.dart @@ -1,6 +1,10 @@ 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; @@ -26,5 +30,28 @@ void main() { // 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); + }); + }); +} From 37d18b91f443f5b5b2c3a7dd0a78128d4ad8e117 Mon Sep 17 00:00:00 2001 From: MaximeRougieux Date: Mon, 3 Aug 2026 15:23:15 +0200 Subject: [PATCH 5/5] docs: :memo: document platform fallback font loading Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) 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: