Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,24 @@ Future<void> testExecutable(FutureOr<void> 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:
Expand Down
3 changes: 2 additions & 1 deletion lib/adaptive_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
29 changes: 27 additions & 2 deletions lib/src/adaptive/adaptive_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);
}
}
33 changes: 33 additions & 0 deletions lib/src/configuration.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -48,6 +49,22 @@ class AdaptiveTestConfiguration {
_failTestOnWrongPlatform = failTestOnWrongPlatform;
}

final Set<String> _loadedFontFamilies = {};

final Set<String> _warnedFontFamilies = {};

MissingFontsBehavior _missingFontsBehavior = MissingFontsBehavior.warn;

MissingFontsBehavior get missingFontsBehavior => _missingFontsBehavior;
Comment on lines +52 to +58

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All how this should stay private. or at least not being exposed to the public API of adaptive_test

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed I think I missed this part when reviewing, sorry i'll see what i can change


/// 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 {
Expand All @@ -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<String> get loadedFontFamilies =>
Set.unmodifiable(AdaptiveTestConfiguration.instance._loadedFontFamilies);

static void addLoadedFontFamilies(Iterable<String> families) {
AdaptiveTestConfiguration.instance._loadedFontFamilies.addAll(families);
}

static Set<String> get warnedFontFamilies =>
Set.unmodifiable(AdaptiveTestConfiguration.instance._warnedFontFamilies);

static void addWarnedFontFamilies(Iterable<String> families) {
AdaptiveTestConfiguration.instance._warnedFontFamilies.addAll(families);
}
}
29 changes: 20 additions & 9 deletions lib/src/helpers/fonts_loader.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> loadFonts() async {
Future<Set<String>> 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 {
Expand All @@ -30,7 +36,7 @@ Future<_FontManifest> _loadFontManifest() async {
return fontManifest.map((font) => _FontData.fromJson(font)).toList();
}

Future<void> _loadFontsFromManifest(
Future<Set<String>> _loadFontsFromManifest(
_FontManifest fontManifest,
String? packageName,
) async {
Expand All @@ -39,13 +45,18 @@ Future<void> _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) {
Expand Down
99 changes: 99 additions & 0 deletions lib/src/helpers/missing_fonts.dart
Original file line number Diff line number Diff line change
@@ -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<String> findUnregisteredFontFamilies(
RenderObject root, {
required Set<String> loadedFamilies,
}) {
final unregisteredFamilies = <String>{};

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<String> reportUnregisteredFontFamilies(
Set<String> families,
MissingFontsBehavior behavior, {
Set<String> 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<String> 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.''';
}
22 changes: 22 additions & 0 deletions test/helpers/fonts_loader_test.dart
Original file line number Diff line number Diff line change
@@ -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<String> 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),
);
});
});
}
Loading
Loading