diff --git a/README.md b/README.md index 091716d..0a17873 100644 --- a/README.md +++ b/README.md @@ -75,6 +75,24 @@ Future testExecutable(FutureOr Function() testMain) async { > â„šī¸ `loadFonts()` loads fonts from `pubspec.yaml` and from every separate package dependency as well. +#### Catching a missing font + +When a golden is taken, `expectGolden` checks the font families the widget tree +asks for against the ones that were loaded, and warns about the text that will +render as blocks: + +``` +adaptive_test: no font is registered for 'Poppins'. +The text using that family renders as placeholder blocks in the golden. +``` + +Make it a failure — recommended on CI once your suite is clean — or silence it: + +```dart +AdaptiveTestConfiguration.instance + .setMissingFontsBehavior(MissingFontsBehavior.fail); +``` + ### Setting Up Test Devices 1. Define a set of device variants: diff --git a/lib/adaptive_test.dart b/lib/adaptive_test.dart index fcd1c65..f80c79e 100644 --- a/lib/adaptive_test.dart +++ b/lib/adaptive_test.dart @@ -8,8 +8,9 @@ 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/configuration.dart' show AdaptiveTestConfiguration; export 'src/helpers/await_images.dart'; export 'src/helpers/fonts_loader.dart'; export 'src/helpers/goldens_difference.dart'; +export 'src/helpers/missing_fonts.dart' show MissingFontsBehavior; export 'src/helpers/skip_test_extension.dart'; diff --git a/lib/src/adaptive/adaptive_test.dart b/lib/src/adaptive/adaptive_test.dart index 47f6f38..1dca035 100644 --- a/lib/src/adaptive/adaptive_test.dart +++ b/lib/src/adaptive/adaptive_test.dart @@ -5,6 +5,7 @@ import 'package:adaptive_test/src/adaptive/window_config_data/window_config_data import 'package:adaptive_test/src/adaptive/window_configuration_tester.dart'; import 'package:adaptive_test/src/configuration.dart'; import 'package:adaptive_test/src/helpers/await_images.dart'; +import 'package:adaptive_test/src/helpers/missing_fonts.dart'; import 'package:adaptive_test/src/helpers/skip_test_extension.dart'; import 'package:adaptive_test/src/helpers/target_platform_extension.dart'; import 'package:flutter/foundation.dart'; @@ -120,12 +121,36 @@ extension Adaptive on WidgetTester { await awaitImages(); } + final finder = + byKey != null ? find.byKey(byKey) : find.byType(AdaptiveWrapper); + + _reportMissingFonts(finder); + final key = path ?? 'preview/${windowConfig.name}-${name.snakeCase}$localSuffix.png'; await expectLater( - // Find by its type except if the widget's unique key was given. - byKey != null ? find.byKey(byKey) : find.byType(AdaptiveWrapper), + finder, matchesGoldenFile(key, version: version), ); } + + void _reportMissingFonts(Finder finder) { + final configuration = AdaptiveTestConfiguration.instance; + if (configuration.missingFontsBehavior == MissingFontsBehavior.ignore) { + return; + } + + final renderObject = finder.evaluate().firstOrNull?.renderObject; + if (renderObject == null) return; + + final warnedFamilies = reportUnregisteredFontFamilies( + findUnregisteredFontFamilies( + renderObject, + loadedFamilies: FontLoadingRegistry.loadedFontFamilies, + ), + configuration.missingFontsBehavior, + alreadyWarned: FontLoadingRegistry.warnedFontFamilies, + ); + FontLoadingRegistry.addWarnedFontFamilies(warnedFamilies); + } } diff --git a/lib/src/configuration.dart b/lib/src/configuration.dart index 59aa9c0..e767cb1 100644 --- a/lib/src/configuration.dart +++ b/lib/src/configuration.dart @@ -2,6 +2,7 @@ import 'package:adaptive_test/src/adaptive/window_config.dart'; import 'package:adaptive_test/src/adaptive/window_config_data/window_config_data.dart'; +import 'package:adaptive_test/src/helpers/missing_fonts.dart'; import 'package:flutter/material.dart'; /// Singleton class that configures global variables for the test. @@ -48,6 +49,22 @@ class AdaptiveTestConfiguration { _failTestOnWrongPlatform = failTestOnWrongPlatform; } + final Set _loadedFontFamilies = {}; + + final Set _warnedFontFamilies = {}; + + MissingFontsBehavior _missingFontsBehavior = MissingFontsBehavior.warn; + + MissingFontsBehavior get missingFontsBehavior => _missingFontsBehavior; + + /// What [expectGolden] does when the widget tree asks for a font family that + /// no font has been registered for, see [MissingFontsBehavior]. + /// + /// Defaults to [MissingFontsBehavior.warn]. + void setMissingFontsBehavior(MissingFontsBehavior missingFontsBehavior) { + _missingFontsBehavior = missingFontsBehavior; + } + WindowVariant? _deviceVariant; WindowVariant get deviceVariant { @@ -73,3 +90,19 @@ See: https://api.flutter.dev/flutter/flutter_test/flutter_test-library.html _deviceVariant = WindowVariant(deviceConfigs); } } + +abstract final class FontLoadingRegistry { + static Set get loadedFontFamilies => + Set.unmodifiable(AdaptiveTestConfiguration.instance._loadedFontFamilies); + + static void addLoadedFontFamilies(Iterable families) { + AdaptiveTestConfiguration.instance._loadedFontFamilies.addAll(families); + } + + static Set get warnedFontFamilies => + Set.unmodifiable(AdaptiveTestConfiguration.instance._warnedFontFamilies); + + static void addWarnedFontFamilies(Iterable families) { + AdaptiveTestConfiguration.instance._warnedFontFamilies.addAll(families); + } +} diff --git a/lib/src/helpers/fonts_loader.dart b/lib/src/helpers/fonts_loader.dart index 03545a0..e1dfc35 100644 --- a/lib/src/helpers/fonts_loader.dart +++ b/lib/src/helpers/fonts_loader.dart @@ -3,22 +3,28 @@ import 'dart:convert'; import 'dart:io'; +import 'package:adaptive_test/src/configuration.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 fonts and icons to ensure they appear in golden tests and returns a +/// [Set] containing all the loaded fonts /// /// 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); + final loadedFamilies = await _loadFontsFromManifest( + await _loadFontManifest(), + await _getCurrentPackageName(), + ); + FontLoadingRegistry.addLoadedFontFamilies(loadedFamilies); + + return loadedFamilies; } Future<_FontManifest> _loadFontManifest() async { @@ -30,7 +36,7 @@ Future<_FontManifest> _loadFontManifest() async { return fontManifest.map((font) => _FontData.fromJson(font)).toList(); } -Future _loadFontsFromManifest( +Future> _loadFontsFromManifest( _FontManifest fontManifest, String? packageName, ) async { @@ -39,13 +45,18 @@ Future _loadFontsFromManifest( final fontFamilyStartsWithPackages = font.family.startsWith('packages/'); return [ - regularFontLoader, + MapEntry(font.family, regularFontLoader), if (!fontFamilyStartsWithPackages && packageName != null) - _createFontLoader('packages/$packageName/${font.family}', font.fonts), + MapEntry( + 'packages/$packageName/${font.family}', + _createFontLoader('packages/$packageName/${font.family}', font.fonts), + ), ]; }).toList(); - await Future.wait(fontLoaders.map((loader) => loader.load())); + await Future.wait(fontLoaders.map((entry) => entry.value.load())); + + return fontLoaders.map((entry) => entry.key).toSet(); } FontLoader _createFontLoader(String fontFamily, List<_FontType> fontTypes) { diff --git a/lib/src/helpers/missing_fonts.dart b/lib/src/helpers/missing_fonts.dart new file mode 100644 index 0000000..07b3704 --- /dev/null +++ b/lib/src/helpers/missing_fonts.dart @@ -0,0 +1,99 @@ +import 'package:flutter/rendering.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// What to do when a widget tree asks for a font family that no font has been +/// registered for. Such text renders as placeholder blocks in goldens, which is +/// easy to miss when reviewing them. +enum MissingFontsBehavior { + /// Do not check for missing font families. + ignore, + + /// Print a warning listing the missing families. This is the default. + warn, + + /// Fail the test. Recommended on CI once the suite is clean. + fail, +} + +/// The font families the rendered text asks for while [loadedFamilies], as +/// returned by [loadFonts], does not cover them. +/// +/// A family is only reported when none of the fallbacks of the style is covered +/// either, since the engine would then have a real font to use. +Set findUnregisteredFontFamilies( + RenderObject root, { + required Set loadedFamilies, +}) { + final unregisteredFamilies = {}; + + void visitStyle(TextStyle? style) { + final family = style?.fontFamily; + if (style == null || family == null) return; + + final candidates = [family, ...?style.fontFamilyFallback]; + if (candidates.any(loadedFamilies.contains)) return; + + unregisteredFamilies.add(family); + } + + void visitSpan(InlineSpan span) { + if (span is TextSpan) visitStyle(span.style); + span.visitChildren((child) { + if (child != span) visitSpan(child); + + return true; + }); + } + + void visitRenderObject(RenderObject renderObject) { + switch (renderObject) { + case final RenderParagraph paragraph: + visitSpan(paragraph.text); + case final RenderEditable editable: + if (editable.text case final text?) visitSpan(text); + default: + break; + } + renderObject.visitChildren(visitRenderObject); + } + + visitRenderObject(root); + + return unregisteredFamilies; +} + +/// Reports the [families] no font is registered for, according to [behavior], +/// and returns the ones it warned about. +/// +/// The families of [alreadyWarned] are skipped, so that the output stays +/// readable when many goldens share the same gap. +Set reportUnregisteredFontFamilies( + Set families, + MissingFontsBehavior behavior, { + Set alreadyWarned = const {}, +}) { + if (behavior == MissingFontsBehavior.ignore || families.isEmpty) { + return const {}; + } + + if (behavior == MissingFontsBehavior.fail) { + throw TestFailure(_message(families)); + } + + final familiesToWarnAbout = families.difference(alreadyWarned); + if (familiesToWarnAbout.isEmpty) return const {}; + + debugPrint(_message(familiesToWarnAbout)); + + return familiesToWarnAbout; +} + +String _message(Set families) { + final sortedFamilies = families.toList()..sort(); + + return ''' +adaptive_test: no font is registered for ${sortedFamilies.map((family) => "'$family'").join(', ')}. +The text using ${sortedFamilies.length > 1 ? 'those families' : 'that family'} renders as placeholder blocks in the golden. +Declare the font in your pubspec.yaml so that loadFonts() picks it up, or, if it +is provided by the host OS, register a stand-in with FontLoader before the test.'''; +} diff --git a/test/helpers/fonts_loader_test.dart b/test/helpers/fonts_loader_test.dart new file mode 100644 index 0000000..8b4bdb0 --- /dev/null +++ b/test/helpers/fonts_loader_test.dart @@ -0,0 +1,22 @@ +import 'package:adaptive_test/adaptive_test.dart'; +import 'package:adaptive_test/src/configuration.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + late Set loadedFamilies; + + setUpAll(() async => loadedFamilies = await loadFonts()); + + group('loadFonts', () { + test('returns the families it registered', () { + expect(loadedFamilies, isNotEmpty); + }); + + test('records the loaded families in the configuration', () { + expect( + FontLoadingRegistry.loadedFontFamilies, + containsAll(loadedFamilies), + ); + }); + }); +} diff --git a/test/helpers/missing_fonts_test.dart b/test/helpers/missing_fonts_test.dart new file mode 100644 index 0000000..4a5fefd --- /dev/null +++ b/test/helpers/missing_fonts_test.dart @@ -0,0 +1,205 @@ +import 'package:adaptive_test/adaptive_test.dart'; +import 'package:adaptive_test/src/helpers/missing_fonts.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +const _registeredFamily = 'ARegisteredFamily'; +const _unregisteredFamily = 'AnUnregisteredFamily'; + +/// Registers [family] with no asset behind it and returns its name: these +/// tests only care about whether a family is known, not about its glyphs. +Future _registerEmptyFamily(String family) async { + await FontLoader(family).load(); + + return family; +} + +void main() { + late Set loadedFamilies; + + setUpAll(() async { + // The app fonts and the platform defaults are loaded, so that only the + // families these tests introduce are reported as missing. + loadedFamilies = { + ...await loadFonts(), + await _registerEmptyFamily(_registeredFamily), + }; + }); + + group('findUnregisteredFontFamilies', () { + Future> familiesOf(WidgetTester tester, Widget child) async { + // Wrapped in a Scaffold: a bare MaterialApp home inherits the debug + // error text style, which brings its own font family along. The default + // text style is pinned to a registered family so that what the ambient + // theme happens to ask for on this host does not leak into the result. + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: DefaultTextStyle( + style: const TextStyle(fontFamily: _registeredFamily), + child: child, + ), + ), + ), + ); + + // ignore: avoid-non-null-assertion, the tree has just been pumped + return findUnregisteredFontFamilies( + tester.element(find.byWidget(child)).renderObject!, + loadedFamilies: loadedFamilies, + ); + } + + testWidgets('reports a family no font is registered for', (tester) async { + final families = await familiesOf( + tester, + const Text( + 'Hello', + style: TextStyle(fontFamily: _unregisteredFamily), + ), + ); + + expect(families, {_unregisteredFamily}); + }); + + testWidgets('ignores a registered family', (tester) async { + final families = await familiesOf( + tester, + const Text('Hello', style: TextStyle(fontFamily: _registeredFamily)), + ); + + expect(families, isEmpty); + }); + + testWidgets('ignores a family with a registered fallback', (tester) async { + final families = await familiesOf( + tester, + const Text( + 'Hello', + style: TextStyle( + fontFamily: _unregisteredFamily, + fontFamilyFallback: [_registeredFamily], + ), + ), + ); + + expect(families, isEmpty); + }); + + testWidgets('reports the families of nested spans', (tester) async { + final families = await familiesOf( + tester, + const Text.rich( + TextSpan( + children: [ + TextSpan( + text: 'Hello', + style: TextStyle(fontFamily: _registeredFamily), + ), + TextSpan( + text: 'World', + style: TextStyle(fontFamily: _unregisteredFamily), + ), + ], + ), + ), + ); + + expect(families, {_unregisteredFamily}); + }); + + testWidgets('reports the family of an editable text', (tester) async { + final families = await familiesOf( + tester, + TextField( + controller: TextEditingController(text: 'Hello'), + style: const TextStyle(fontFamily: _unregisteredFamily), + ), + ); + + expect(families, contains(_unregisteredFamily)); + }); + }); + + group('reportUnregisteredFontFamilies', () { + List capturePrints(void Function() body) { + final logs = []; + final previous = debugPrint; + debugPrint = (message, {wrapWidth}) => logs.add(message ?? ''); + try { + body(); + } finally { + debugPrint = previous; + } + + return logs; + } + + test('warns about the families it is given', () { + late Set warnedFamilies; + final logs = capturePrints(() { + warnedFamilies = reportUnregisteredFontFamilies( + {_unregisteredFamily}, + MissingFontsBehavior.warn, + ); + }); + + expect(warnedFamilies, {_unregisteredFamily}); + expect(logs, hasLength(1)); + expect(logs.single, contains(_unregisteredFamily)); + }); + + test('stays silent about a family already warned about', () { + late Set warnedFamilies; + final logs = capturePrints(() { + warnedFamilies = reportUnregisteredFontFamilies( + {_unregisteredFamily}, + MissingFontsBehavior.warn, + alreadyWarned: {_unregisteredFamily}, + ); + }); + + expect(warnedFamilies, isEmpty); + expect(logs, isEmpty); + }); + + test('stays silent when there is nothing to report', () { + final logs = capturePrints( + () => reportUnregisteredFontFamilies( + const {}, + MissingFontsBehavior.warn, + ), + ); + + expect(logs, isEmpty); + }); + + test('stays silent when ignoring', () { + final logs = capturePrints( + () => reportUnregisteredFontFamilies( + {_unregisteredFamily}, + MissingFontsBehavior.ignore, + ), + ); + + expect(logs, isEmpty); + }); + + test('fails the test when configured to', () { + expect( + () => reportUnregisteredFontFamilies( + {_unregisteredFamily}, + MissingFontsBehavior.fail, + ), + throwsA( + isA().having( + (failure) => failure.message, + 'message', + contains(_unregisteredFamily), + ), + ), + ); + }); + }); +}