Halkyon's shared packages
A pub workspace of the Dart packages Halkyon's apps share: halkyon_ui, Halkyon's look (the theme, fonts, layout and shared widgets, with texts in English and German), halkyon_core's low-level primitives and halkyon_interop's cross-app contracts. Dart 3.13 with LeanCode's lints, configured once at the root; apps take a package by git, pinned to a signed commit. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
commit
80fa7b9628
20 changed files with 6762 additions and 0 deletions
70
packages/halkyon_ui/test/l10n/arb_files_test.dart
Normal file
70
packages/halkyon_ui/test/l10n/arb_files_test.dart
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
/// The messages of the `.arb` file [name] in `lib/l10n/`, without metadata.
|
||||
Map<String, String> _messages(String name) => {
|
||||
for (final MapEntry(:key, :value) in (jsonDecode(
|
||||
File('lib/l10n/$name').readAsStringSync(),
|
||||
) as Map<String, Object?>).entries)
|
||||
if (!key.startsWith('@')) key: value! as String,
|
||||
};
|
||||
|
||||
/// The names of the placeholders in [message], with plural and select
|
||||
/// arguments; the words of their branches don't count. (gen-l10n reads
|
||||
/// quotes as text here: `use-escaping` is off.)
|
||||
Set<String> _placeholders(String message) {
|
||||
final names = <String>{};
|
||||
// Even depths hold text and placeholders, odd ones a plural's branches.
|
||||
var depth = 0;
|
||||
for (var i = 0; i < message.length; i++) {
|
||||
switch (message[i]) {
|
||||
case '{':
|
||||
final name = RegExp(r'\{\s*(\w+)\s*[,}]').matchAsPrefix(message, i);
|
||||
if (name != null && depth.isEven) {
|
||||
names.add(name.group(1)!);
|
||||
}
|
||||
depth++;
|
||||
case '}':
|
||||
depth--;
|
||||
}
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
void main() {
|
||||
final template = _messages('halkyon_ui_en.arb');
|
||||
|
||||
test('German has every message, with the same placeholders', () {
|
||||
final german = _messages('halkyon_ui_de.arb');
|
||||
|
||||
expect(
|
||||
template.keys.where((key) => !german.containsKey(key)),
|
||||
isEmpty,
|
||||
reason: 'untranslated',
|
||||
);
|
||||
expect(
|
||||
german.keys.where((key) => !template.containsKey(key)),
|
||||
isEmpty,
|
||||
reason: 'not in the template',
|
||||
);
|
||||
for (final MapEntry(:key, :value) in german.entries) {
|
||||
expect(_placeholders(value), _placeholders(template[key]!), reason: key);
|
||||
}
|
||||
});
|
||||
|
||||
test("the English variants override the template's messages only, with "
|
||||
'the same placeholders', () {
|
||||
for (final variant in ['halkyon_ui_en_GB.arb', 'halkyon_ui_en_US.arb']) {
|
||||
for (final MapEntry(:key, :value) in _messages(variant).entries) {
|
||||
expect(template, contains(key), reason: '$variant: $key');
|
||||
expect(
|
||||
_placeholders(value),
|
||||
_placeholders(template[key]!),
|
||||
reason: '$variant: $key',
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:halkyon_ui/halkyon_ui.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
import '../support/test_app.dart';
|
||||
|
||||
void main() {
|
||||
test('speaks British English, American English and German', () {
|
||||
expect(
|
||||
lookupHalkyonUiLocalizations(const Locale('en')).colorPickerLabel('X'),
|
||||
'Colour: X',
|
||||
);
|
||||
expect(
|
||||
lookupHalkyonUiLocalizations(
|
||||
const Locale('en', 'GB'),
|
||||
).colorPickerLabel('X'),
|
||||
'Colour: X',
|
||||
);
|
||||
expect(
|
||||
lookupHalkyonUiLocalizations(
|
||||
const Locale('en', 'US'),
|
||||
).colorPickerLabel('X'),
|
||||
'Color: X',
|
||||
);
|
||||
expect(
|
||||
lookupHalkyonUiLocalizations(const Locale('de')).colorPickerLabel('X'),
|
||||
'Farbe: X',
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets("an app adding the package's delegate gets its texts in the "
|
||||
"app's language", (tester) async {
|
||||
await tester.pumpWidget(
|
||||
testApp(
|
||||
locale: const Locale('de'),
|
||||
ReorderToggle(
|
||||
reordering: false,
|
||||
labelled: true,
|
||||
tooltip: 'Pfade umsortieren',
|
||||
onToggle: () {},
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
expect(find.text('Umsortieren'), findsOneWidget);
|
||||
});
|
||||
}
|
||||
24
packages/halkyon_ui/test/layout/window_size_test.dart
Normal file
24
packages/halkyon_ui/test/layout/window_size_test.dart
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:halkyon_ui/halkyon_ui.dart';
|
||||
|
||||
void main() {
|
||||
test("Material 3's classes, by the window's width", () {
|
||||
expect(WindowSize.fromWidth(0), WindowSize.compact);
|
||||
expect(WindowSize.fromWidth(599.9), WindowSize.compact);
|
||||
expect(WindowSize.fromWidth(600), WindowSize.medium);
|
||||
expect(WindowSize.fromWidth(839.9), WindowSize.medium);
|
||||
expect(WindowSize.fromWidth(840), WindowSize.expanded);
|
||||
expect(WindowSize.fromWidth(1199.9), WindowSize.expanded);
|
||||
expect(WindowSize.fromWidth(1200), WindowSize.large);
|
||||
expect(WindowSize.fromWidth(1599.9), WindowSize.large);
|
||||
expect(WindowSize.fromWidth(1600), WindowSize.extraLarge);
|
||||
});
|
||||
|
||||
test('a rail from medium on, desktop layouts from expanded on', () {
|
||||
expect(WindowSize.compact.isAtLeastMedium, isFalse);
|
||||
expect(WindowSize.medium.isAtLeastMedium, isTrue);
|
||||
expect(WindowSize.medium.isAtLeastExpanded, isFalse);
|
||||
expect(WindowSize.expanded.isAtLeastExpanded, isTrue);
|
||||
expect(WindowSize.extraLarge.isAtLeastExpanded, isTrue);
|
||||
});
|
||||
}
|
||||
19
packages/halkyon_ui/test/support/test_app.dart
Normal file
19
packages/halkyon_ui/test/support/test_app.dart
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import 'package:halkyon_ui/halkyon_ui.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
/// [home] in an app as a Halkyon app builds one: the theme, and the
|
||||
/// package's texts beside Material's, in [locale].
|
||||
Widget testApp(
|
||||
Widget home, {
|
||||
Locale locale = const Locale('en'),
|
||||
Brightness brightness = Brightness.light,
|
||||
}) => MaterialApp(
|
||||
theme: appTheme(brightness),
|
||||
locale: locale,
|
||||
localizationsDelegates: const [
|
||||
HalkyonUiLocalizations.delegate,
|
||||
...GlobalMaterialLocalizations.delegates,
|
||||
],
|
||||
supportedLocales: HalkyonUiLocalizations.supportedLocales,
|
||||
home: home,
|
||||
);
|
||||
89
packages/halkyon_ui/test/theme/app_fonts_test.dart
Normal file
89
packages/halkyon_ui/test/theme/app_fonts_test.dart
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
import 'dart:ui' as ui;
|
||||
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:halkyon_ui/halkyon_ui.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
/// How many pixels [text] inks in [family] at [weight]: heavier strokes ink
|
||||
/// more.
|
||||
Future<int> ink(String text, String family, FontWeight weight) async {
|
||||
final painter = TextPainter(
|
||||
text: TextSpan(
|
||||
text: text,
|
||||
style: TextStyle(
|
||||
fontFamily: family,
|
||||
fontSize: 40,
|
||||
fontWeight: weight,
|
||||
color: const Color(0xFF000000),
|
||||
),
|
||||
),
|
||||
textDirection: TextDirection.ltr,
|
||||
)..layout();
|
||||
final recorder = ui.PictureRecorder();
|
||||
painter.paint(Canvas(recorder), Offset.zero);
|
||||
final image = await recorder.endRecording().toImage(
|
||||
painter.width.ceil(),
|
||||
painter.height.ceil(),
|
||||
);
|
||||
final pixels = (await image.toByteData())!;
|
||||
var inked = 0;
|
||||
for (var alpha = 3; alpha < pixels.lengthInBytes; alpha += 4) {
|
||||
if (pixels.getUint8(alpha) > 128) {
|
||||
inked++;
|
||||
}
|
||||
}
|
||||
return inked;
|
||||
}
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
test("the font is the package's own, as an app finds a package's font", () {
|
||||
expect(appFontFamily, 'packages/halkyon_ui/Noto Sans');
|
||||
});
|
||||
|
||||
test("the theme sets text in Noto Sans, with the apps' CJK fonts for what "
|
||||
'it lacks, named as an app declares them', () {
|
||||
for (final brightness in Brightness.values) {
|
||||
final style = appTheme(brightness).textTheme.bodyLarge!;
|
||||
expect(style.fontFamily, appFontFamily);
|
||||
expect(style.fontFamilyFallback, [
|
||||
'Noto Sans JP',
|
||||
'Noto Sans SC',
|
||||
'Noto Sans TC',
|
||||
'Noto Sans KR',
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
test("a language's own CJK font comes first, so shared Han characters "
|
||||
'take its shapes', () {
|
||||
expect(appFontFallbackFor(null).first, 'Noto Sans JP');
|
||||
expect(appFontFallbackFor('en'), appFontFallback);
|
||||
expect(appFontFallbackFor('ja').first, 'Noto Sans JP');
|
||||
expect(appFontFallbackFor('zh-Hans').first, 'Noto Sans SC');
|
||||
expect(appFontFallbackFor('zh').first, 'Noto Sans SC');
|
||||
expect(appFontFallbackFor('zh-Hant').first, 'Noto Sans TC');
|
||||
expect(appFontFallbackFor('zh-TW').first, 'Noto Sans TC');
|
||||
expect(appFontFallbackFor('ko').first, 'Noto Sans KR');
|
||||
expect(hasOwnFont('ko'), isTrue);
|
||||
expect(hasOwnFont('de'), isFalse);
|
||||
// Every font stays, once.
|
||||
expect(appFontFallbackFor('ko').toSet(), appFontFallback.toSet());
|
||||
expect(appFontFallbackFor('ko'), hasLength(4));
|
||||
});
|
||||
|
||||
test('Noto Sans draws every weight from its one file', () async {
|
||||
// In this package's own tests it is the root project, whose assets have
|
||||
// no `packages/` prefix; an app's test of the name is in example/.
|
||||
final loader = FontLoader(appFontFamily)
|
||||
..addFont(rootBundle.load('assets/fonts/NotoSans-Variable.ttf'));
|
||||
await loader.load();
|
||||
|
||||
const text = 'Ἀθῆναι Halkyon';
|
||||
final regular = await ink(text, appFontFamily, FontWeight.w400);
|
||||
final bold = await ink(text, appFontFamily, FontWeight.w700);
|
||||
expect(bold, greaterThan(regular * 1.2));
|
||||
});
|
||||
}
|
||||
99
packages/halkyon_ui/test/theme/theme_test.dart
Normal file
99
packages/halkyon_ui/test/theme/theme_test.dart
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
import 'dart:math';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:halkyon_ui/halkyon_ui.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
/// WCAG's contrast ratio of two colours, from 1 (none) to 21.
|
||||
double _contrast(Color a, Color b) {
|
||||
final (lighter, darker) = (
|
||||
max(a.computeLuminance(), b.computeLuminance()),
|
||||
min(a.computeLuminance(), b.computeLuminance()),
|
||||
);
|
||||
return (lighter + 0.05) / (darker + 0.05);
|
||||
}
|
||||
|
||||
/// An app's own colours, beside the shared ones.
|
||||
final class const _AppColors(final Color special)
|
||||
extends ThemeExtension<_AppColors> {
|
||||
@override
|
||||
_AppColors copyWith({Color? special}) => _AppColors(special ?? this.special);
|
||||
|
||||
@override
|
||||
_AppColors lerp(_AppColors? other, double t) =>
|
||||
other == null ? this : _AppColors(Color.lerp(special, other.special, t)!);
|
||||
}
|
||||
|
||||
void main() {
|
||||
group('the picker palette', () {
|
||||
test('has the 20 colours content can be given', () {
|
||||
expect(PaletteColor.values, hasLength(20));
|
||||
});
|
||||
|
||||
for (final color in PaletteColor.values) {
|
||||
test(
|
||||
'${color.name}: its text colour is readable on its fill (WCAG AA)',
|
||||
() {
|
||||
expect(
|
||||
_contrast(color.foreground, color.background),
|
||||
greaterThanOrEqualTo(4.5),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
test(
|
||||
'finds a colour by its stored name, and nothing for an unknown one',
|
||||
() {
|
||||
expect(PaletteColor.byName('cobalt'), PaletteColor.cobalt);
|
||||
expect(PaletteColor.byName('ultraviolet'), isNull);
|
||||
expect(PaletteColor.byName(null), isNull);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
group('the theme', () {
|
||||
for (final brightness in Brightness.values) {
|
||||
test('$brightness: Material 3, with the status colours', () {
|
||||
final theme = appTheme(brightness);
|
||||
|
||||
expect(theme.useMaterial3, isTrue);
|
||||
expect(theme.colorScheme.brightness, brightness);
|
||||
expect(theme.extras, isA<ThemeExtras>());
|
||||
});
|
||||
}
|
||||
|
||||
test("takes an app's own extensions beside the shared ones", () {
|
||||
const colors = _AppColors(Color(0xFF123456));
|
||||
|
||||
final theme = appTheme(Brightness.light, extensions: [colors]);
|
||||
|
||||
expect(theme.extension<_AppColors>(), colors);
|
||||
expect(theme.extras, ThemeExtras.light);
|
||||
});
|
||||
|
||||
test('light: cobalt is the primary colour, copper the accent', () {
|
||||
final scheme = appTheme(Brightness.light).colorScheme;
|
||||
|
||||
expect(scheme.primary, Brand.primary);
|
||||
expect(scheme.tertiary, Brand.accent);
|
||||
});
|
||||
|
||||
test("dark: the primary colour's text stays readable", () {
|
||||
final scheme = appTheme(Brightness.dark).colorScheme;
|
||||
|
||||
expect(
|
||||
_contrast(scheme.onPrimary, scheme.primary),
|
||||
greaterThanOrEqualTo(4.5),
|
||||
);
|
||||
expect(
|
||||
_contrast(scheme.onSurface, scheme.surface),
|
||||
greaterThanOrEqualTo(4.5),
|
||||
);
|
||||
});
|
||||
|
||||
test("the navigation bar is Material 3 Expressive's shorter one", () {
|
||||
expect(appTheme(Brightness.light).navigationBarTheme.height, 64);
|
||||
});
|
||||
});
|
||||
}
|
||||
216
packages/halkyon_ui/test/widget/adaptive_sheet_test.dart
Normal file
216
packages/halkyon_ui/test/widget/adaptive_sheet_test.dart
Normal file
|
|
@ -0,0 +1,216 @@
|
|||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:halkyon_ui/halkyon_ui.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
void main() {
|
||||
/// Opens a sheet titled "New studyset" with [body] and a Create button, in
|
||||
/// a window [width] wide, with a status bar [statusBar] high on top.
|
||||
Future<void> open(
|
||||
WidgetTester tester, {
|
||||
required double width,
|
||||
Widget body = const Text('Body'),
|
||||
double statusBar = 0,
|
||||
bool busy = false,
|
||||
}) async {
|
||||
tester.view.physicalSize = Size(width, 900);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
tester.view.padding = FakeViewPadding(top: statusBar);
|
||||
tester.view.viewPadding = FakeViewPadding(top: statusBar);
|
||||
addTearDown(tester.view.reset);
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
localizationsDelegates: HalkyonUiLocalizations.localizationsDelegates,
|
||||
supportedLocales: HalkyonUiLocalizations.supportedLocales,
|
||||
home: Scaffold(
|
||||
body: Builder(
|
||||
builder: (context) => TextButton(
|
||||
onPressed: () => showAdaptiveSheet<void>(
|
||||
context,
|
||||
swipeable: !busy,
|
||||
builder: (context) => SheetScaffold(
|
||||
title: 'New studyset',
|
||||
busy: busy,
|
||||
body: body,
|
||||
footer: FilledButton(
|
||||
onPressed: () {},
|
||||
child: const Text('Create'),
|
||||
),
|
||||
),
|
||||
),
|
||||
child: const Text('Open'),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.tap(find.text('Open'));
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
testWidgets('is a bottom sheet below expanded windows', (tester) async {
|
||||
await open(tester, width: 839);
|
||||
|
||||
expect(find.byType(BottomSheet), findsOneWidget);
|
||||
expect(find.byType(Dialog), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('is a dialog from expanded windows on', (tester) async {
|
||||
await open(tester, width: 840);
|
||||
|
||||
expect(find.byType(Dialog), findsOneWidget);
|
||||
expect(find.byType(BottomSheet), findsNothing);
|
||||
});
|
||||
|
||||
for (final width in [400.0, 1200.0]) {
|
||||
testWidgets('keeps its close button and footer in view above a long body '
|
||||
'(${width.round()} wide)', (tester) async {
|
||||
await open(
|
||||
tester,
|
||||
width: width,
|
||||
body: Column(
|
||||
children: [for (var i = 0; i < 80; i++) Text('Line $i')],
|
||||
),
|
||||
);
|
||||
|
||||
expect(find.byTooltip('Close').hitTestable(), findsOneWidget);
|
||||
expect(find.text('Create').hitTestable(), findsOneWidget);
|
||||
});
|
||||
}
|
||||
|
||||
testWidgets('stays below the status bar with the keyboard open', (
|
||||
tester,
|
||||
) async {
|
||||
await open(
|
||||
tester,
|
||||
width: 400,
|
||||
statusBar: 48,
|
||||
body: Column(
|
||||
children: [for (var i = 0; i < 80; i++) Text('Line $i')],
|
||||
),
|
||||
);
|
||||
|
||||
tester.view.viewInsets = const FakeViewPadding(bottom: 400);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(tester.getTopLeft(find.byType(BottomSheet)).dy, 48);
|
||||
expect(find.byTooltip('Close').hitTestable(), findsOneWidget);
|
||||
expect(find.text('Create').hitTestable(), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('closes with its close button', (tester) async {
|
||||
await open(tester, width: 400);
|
||||
|
||||
await tester.tap(find.byTooltip('Close'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('New studyset'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('while busy, its close button is off and back keeps it open', (
|
||||
tester,
|
||||
) async {
|
||||
await open(tester, width: 400, busy: true);
|
||||
|
||||
expect(
|
||||
tester
|
||||
.widget<IconButton>(
|
||||
find.ancestor(
|
||||
of: find.byTooltip('Close'),
|
||||
matching: find.byType(IconButton),
|
||||
),
|
||||
)
|
||||
.onPressed,
|
||||
isNull,
|
||||
);
|
||||
await tester.binding.handlePopRoute();
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('New studyset'), findsOneWidget);
|
||||
});
|
||||
|
||||
group('a body longer than the sheet', () {
|
||||
final tall = Column(
|
||||
children: [for (var i = 0; i < 40; i++) const Text('Line')],
|
||||
);
|
||||
Finder below() => find.byKey(SheetScaffold.moreBelowKey);
|
||||
Finder above() => find.byKey(SheetScaffold.moreAboveKey);
|
||||
|
||||
testWidgets('shows a line above the buttons while more lies below, and '
|
||||
'one under the title while more lies above', (tester) async {
|
||||
// As a dialog: a modal bottom sheet takes a test's drag itself.
|
||||
await open(tester, width: 1000, body: tall);
|
||||
|
||||
expect(below(), findsOneWidget);
|
||||
expect(above(), findsNothing);
|
||||
|
||||
await tester.drag(
|
||||
find.byType(SingleChildScrollView),
|
||||
const Offset(0, -300),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
expect(below(), findsOneWidget);
|
||||
expect(above(), findsOneWidget);
|
||||
expect(
|
||||
tester
|
||||
.state<ScrollableState>(
|
||||
find.descendant(
|
||||
of: find.byType(SingleChildScrollView),
|
||||
matching: find.byType(Scrollable),
|
||||
),
|
||||
)
|
||||
.position
|
||||
.pixels,
|
||||
greaterThan(0),
|
||||
reason: 'the line coming keeps where the body was scrolled to',
|
||||
);
|
||||
|
||||
await tester.drag(
|
||||
find.byType(SingleChildScrollView),
|
||||
const Offset(0, -5000),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
expect(below(), findsNothing);
|
||||
expect(above(), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('has a scrollbar, always shown on the desktop', (tester) async {
|
||||
debugDefaultTargetPlatformOverride = TargetPlatform.linux;
|
||||
addTearDown(() => debugDefaultTargetPlatformOverride = null);
|
||||
await open(tester, width: 1000, body: tall);
|
||||
|
||||
final scrollbar = tester.widget<Scrollbar>(find.byType(Scrollbar));
|
||||
expect(scrollbar.thumbVisibility, isTrue);
|
||||
debugDefaultTargetPlatformOverride = null;
|
||||
});
|
||||
});
|
||||
|
||||
testWidgets('a body that fits shows no lines', (tester) async {
|
||||
await open(tester, width: 400);
|
||||
|
||||
expect(find.byKey(SheetScaffold.moreBelowKey), findsNothing);
|
||||
expect(find.byKey(SheetScaffold.moreAboveKey), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets("the body keeps clear of the scroll view's edges, where it is "
|
||||
'cut and the scrollbar runs', (tester) async {
|
||||
for (final width in [400.0, 1000.0]) {
|
||||
await open(
|
||||
tester,
|
||||
width: width,
|
||||
body: const TextField(
|
||||
decoration: InputDecoration(border: OutlineInputBorder()),
|
||||
),
|
||||
);
|
||||
|
||||
final scrolling = tester.getRect(find.byType(SingleChildScrollView));
|
||||
final field = tester.getRect(find.byType(TextField));
|
||||
expect(field.left - scrolling.left, greaterThanOrEqualTo(16));
|
||||
expect(scrolling.right - field.right, greaterThanOrEqualTo(16));
|
||||
// The title and the button stay in line with the body.
|
||||
expect(tester.getTopLeft(find.text('New studyset')).dx, field.left);
|
||||
expect(tester.getTopRight(find.byType(FilledButton)).dx, field.right);
|
||||
await tester.tap(find.byTooltip('Close'));
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:expressive_loading_indicator/expressive_loading_indicator.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:halkyon_ui/halkyon_ui.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
void main() {
|
||||
testWidgets("pulled down, it refreshes with the app's spinner, Material 3 "
|
||||
"Expressive's, not Flutter's older one", (tester) async {
|
||||
final refreshing = Completer<void>();
|
||||
var refreshes = 0;
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
theme: appTheme(Brightness.light),
|
||||
home: Scaffold(
|
||||
body: AppRefreshIndicator(
|
||||
onRefresh: () {
|
||||
refreshes++;
|
||||
return refreshing.future;
|
||||
},
|
||||
child: ListView(
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
children: const [Text('Japanese')],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
expect(find.byType(ExpressiveLoadingIndicator), findsNothing);
|
||||
|
||||
await tester.fling(find.text('Japanese'), const Offset(0, 400), 1000);
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 300));
|
||||
expect(refreshes, 1);
|
||||
expect(find.byType(ExpressiveLoadingIndicator), findsOneWidget);
|
||||
expect(find.byType(CircularProgressIndicator), findsNothing);
|
||||
|
||||
refreshing.complete();
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byType(ExpressiveLoadingIndicator), findsNothing);
|
||||
});
|
||||
}
|
||||
17
packages/halkyon_ui/test/widget/count_stepper_test.dart
Normal file
17
packages/halkyon_ui/test/widget/count_stepper_test.dart
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:halkyon_ui/halkyon_ui.dart';
|
||||
|
||||
void main() {
|
||||
test('steps go to the next multiple of the step, never below the first', () {
|
||||
expect(stepUp(12, 5, max: 100), 15);
|
||||
expect(stepUp(15, 5, max: 100), 20);
|
||||
expect(stepUp(98, 5, max: 100), 100);
|
||||
expect(stepDown(12, 5), 10);
|
||||
expect(stepDown(15, 5), 10);
|
||||
expect(stepDown(7, 5), 5);
|
||||
expect(canStepDown(5, 5), isFalse);
|
||||
expect(canStepDown(6, 5), isTrue);
|
||||
expect(stepUp(1, 25, max: 1000), 25);
|
||||
expect(stepDown(230, 25), 225);
|
||||
});
|
||||
}
|
||||
121
packages/halkyon_ui/test/widget/found_by_id_test.dart
Normal file
121
packages/halkyon_ui/test/widget/found_by_id_test.dart
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:halkyon_ui/halkyon_ui.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
import '../support/test_app.dart';
|
||||
|
||||
void main() {
|
||||
late StreamController<List<String>> items;
|
||||
|
||||
setUp(() => items = StreamController());
|
||||
tearDown(() => items.close());
|
||||
|
||||
/// Opens the screen of the item [id] over a first screen.
|
||||
Future<void> open(
|
||||
WidgetTester tester,
|
||||
String id, {
|
||||
({String label, VoidCallback onPressed})? wayBack,
|
||||
}) async {
|
||||
await tester.pumpWidget(
|
||||
testApp(
|
||||
Builder(
|
||||
builder: (context) => TextButton(
|
||||
onPressed: () => Navigator.of(context).push(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (context) => FoundById<String>(
|
||||
id: id,
|
||||
items: () => items.stream,
|
||||
idOf: (item) => item,
|
||||
builder: (item, items) =>
|
||||
Scaffold(body: Text('Item $item of ${items.length}')),
|
||||
wayBack: wayBack,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: const Text('Open'),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.tap(find.text('Open'));
|
||||
// Through the opening transition; the spinner never settles.
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(seconds: 1));
|
||||
}
|
||||
|
||||
testWidgets('shows a spinner until the list arrives, then the item', (
|
||||
tester,
|
||||
) async {
|
||||
await open(tester, 'a');
|
||||
expect(find.byType(AppLoadingIndicator), findsOneWidget);
|
||||
|
||||
items.add(['a', 'b']);
|
||||
await tester.pump();
|
||||
|
||||
expect(find.text('Item a of 2'), findsOneWidget);
|
||||
expect(find.byType(AppLoadingIndicator), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('says that an item missing from the list no longer exists', (
|
||||
tester,
|
||||
) async {
|
||||
await open(tester, 'c');
|
||||
|
||||
items.add(['a', 'b']);
|
||||
await tester.pump();
|
||||
|
||||
expect(find.textContaining('no longer exists'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('leads the way back from there, as the app says', (
|
||||
tester,
|
||||
) async {
|
||||
var ledBack = 0;
|
||||
await open(
|
||||
tester,
|
||||
'c',
|
||||
wayBack: (label: 'To the library', onPressed: () => ledBack++),
|
||||
);
|
||||
items.add(['a']);
|
||||
await tester.pump();
|
||||
|
||||
await tester.tap(find.text('To the library'));
|
||||
|
||||
expect(ledBack, 1);
|
||||
});
|
||||
|
||||
testWidgets('says so once the item goes while its screen is open', (
|
||||
tester,
|
||||
) async {
|
||||
await open(tester, 'a');
|
||||
items.add(['a']);
|
||||
await tester.pump();
|
||||
|
||||
// Deleted on another device, say.
|
||||
items.add([]);
|
||||
await tester.pump();
|
||||
|
||||
expect(find.text('Item a of 1'), findsNothing);
|
||||
expect(find.textContaining('no longer exists'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('a screen that closes as its item goes keeps showing the item '
|
||||
'on its way out', (tester) async {
|
||||
await open(tester, 'a');
|
||||
items.add(['a']);
|
||||
await tester.pump();
|
||||
|
||||
// As when the screen deleted its item and closes.
|
||||
Navigator.of(tester.element(find.text('Item a of 1'))).pop();
|
||||
items.add([]);
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
|
||||
expect(find.text('Item a of 1'), findsOneWidget);
|
||||
expect(find.textContaining('no longer exists'), findsNothing);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Open'), findsOneWidget);
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:halkyon_ui/halkyon_ui.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
void main() {
|
||||
Future<void> leaveAndComeBack(WidgetTester tester) async {
|
||||
[
|
||||
AppLifecycleState.inactive,
|
||||
AppLifecycleState.hidden,
|
||||
AppLifecycleState.paused,
|
||||
AppLifecycleState.hidden,
|
||||
AppLifecycleState.inactive,
|
||||
AppLifecycleState.resumed,
|
||||
].forEach(tester.binding.handleAppLifecycleStateChanged);
|
||||
await tester.pump();
|
||||
}
|
||||
|
||||
bool keyboardAskedFor(WidgetTester tester) =>
|
||||
tester.testTextInput.log.any((call) => call.method == 'TextInput.show');
|
||||
|
||||
testWidgets('a field focused when the app was left has its keyboard back '
|
||||
'on return', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
const MaterialApp(
|
||||
home: KeyboardBackOnResume(
|
||||
child: Scaffold(body: TextField(autofocus: true)),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
tester.testTextInput.log.clear();
|
||||
|
||||
await leaveAndComeBack(tester);
|
||||
|
||||
expect(keyboardAskedFor(tester), isTrue);
|
||||
});
|
||||
|
||||
testWidgets('without a field focused, no keyboard', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
const MaterialApp(
|
||||
home: KeyboardBackOnResume(child: Scaffold(body: TextField())),
|
||||
),
|
||||
);
|
||||
tester.testTextInput.log.clear();
|
||||
|
||||
await leaveAndComeBack(tester);
|
||||
|
||||
expect(keyboardAskedFor(tester), isFalse);
|
||||
});
|
||||
}
|
||||
53
packages/halkyon_ui/test/widget/launch_screen_test.dart
Normal file
53
packages/halkyon_ui/test/widget/launch_screen_test.dart
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:halkyon_ui/halkyon_ui.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
import '../support/test_app.dart';
|
||||
|
||||
/// A transparent PNG of one pixel, standing in for an app's icon.
|
||||
final _icon = MemoryImage(
|
||||
Uint8List.fromList(const [
|
||||
0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D, //
|
||||
0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01,
|
||||
0x08, 0x06, 0x00, 0x00, 0x00, 0x1F, 0x15, 0xC4, 0x89, 0x00, 0x00, 0x00,
|
||||
0x0D, 0x49, 0x44, 0x41, 0x54, 0x78, 0x9C, 0x63, 0x00, 0x01, 0x00, 0x00,
|
||||
0x05, 0x00, 0x01, 0x0D, 0x0A, 0x2D, 0xB4, 0x00, 0x00, 0x00, 0x00, 0x49,
|
||||
0x45, 0x4E, 0x44, 0xAE, 0x42, 0x60, 0x82,
|
||||
]),
|
||||
);
|
||||
|
||||
void main() {
|
||||
testWidgets("shows the app's icon, and \"Halkyon\" over the app's word, "
|
||||
"read out as the app's name", (tester) async {
|
||||
await tester.pumpWidget(
|
||||
testApp(LaunchScreen(icon: _icon, product: 'Todo', name: 'Halkyon Todo')),
|
||||
);
|
||||
|
||||
expect(find.byType(Image), findsOneWidget);
|
||||
final brand = tester.getRect(find.text('Halkyon'));
|
||||
final product = tester.getRect(find.text('Todo'));
|
||||
expect(product.top, greaterThanOrEqualTo(brand.bottom - 1));
|
||||
expect(product.center.dx, moreOrLessEquals(brand.center.dx));
|
||||
expect(find.bySemanticsLabel('Halkyon Todo'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('the wordmark is cobalt over copper, and silent to screen '
|
||||
'readers', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
testApp(const Scaffold(body: BrandWordmark(product: 'Train'))),
|
||||
);
|
||||
|
||||
final scheme = appTheme(Brightness.light).colorScheme;
|
||||
expect(
|
||||
tester.widget<Text>(find.text('Halkyon')).style?.color,
|
||||
scheme.primary,
|
||||
);
|
||||
expect(
|
||||
tester.widget<Text>(find.text('Train')).style?.color,
|
||||
scheme.tertiary,
|
||||
);
|
||||
expect(find.bySemanticsLabel('Train'), findsNothing);
|
||||
});
|
||||
}
|
||||
58
packages/halkyon_ui/test/widget/scroll_hiding_fab_test.dart
Normal file
58
packages/halkyon_ui/test/widget/scroll_hiding_fab_test.dart
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:halkyon_ui/halkyon_ui.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
void main() {
|
||||
/// A list of [lines] with a button that hides while reading onwards.
|
||||
Future<void> show(WidgetTester tester, {int lines = 100}) async {
|
||||
tester.view.physicalSize = const Size(400, 800);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.reset);
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: ScrollHidingFab(
|
||||
builder: (context, fab) => Scaffold(
|
||||
floatingActionButton: fab,
|
||||
body: ListView(
|
||||
children: [
|
||||
for (var i = 0; i < lines; i++)
|
||||
SizedBox(height: 48, child: Text('Line $i')),
|
||||
],
|
||||
),
|
||||
),
|
||||
fab: FloatingActionButton(
|
||||
tooltip: 'New studyset',
|
||||
onPressed: () {},
|
||||
child: const Icon(Icons.add),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Finder fab() => find.byTooltip('New studyset').hitTestable();
|
||||
|
||||
testWidgets('hides while scrolling down and returns when scrolling up', (
|
||||
tester,
|
||||
) async {
|
||||
await show(tester);
|
||||
expect(fab(), findsOneWidget);
|
||||
|
||||
await tester.drag(find.byType(ListView), const Offset(0, -300));
|
||||
await tester.pumpAndSettle();
|
||||
expect(fab(), findsNothing);
|
||||
|
||||
await tester.drag(find.byType(ListView), const Offset(0, 100));
|
||||
await tester.pumpAndSettle();
|
||||
expect(fab(), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('stays when the list fits without scrolling', (tester) async {
|
||||
await show(tester, lines: 3);
|
||||
|
||||
await tester.drag(find.byType(ListView), const Offset(0, -300));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(fab(), findsOneWidget);
|
||||
});
|
||||
}
|
||||
64
packages/halkyon_ui/test/widget/tab_visibility_test.dart
Normal file
64
packages/halkyon_ui/test/widget/tab_visibility_test.dart
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:halkyon_ui/halkyon_ui.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
import '../support/test_app.dart';
|
||||
|
||||
void main() {
|
||||
/// Pumps [child] under a screen tint, in a medium window, so a gradient
|
||||
/// in it claims its colour while in view.
|
||||
Future<ScreenTint> show(WidgetTester tester, {required bool visible}) async {
|
||||
tester.view.physicalSize = const Size(800, 600);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.reset);
|
||||
final tint = ScreenTint();
|
||||
addTearDown(tint.dispose);
|
||||
await tester.pumpWidget(
|
||||
testApp(
|
||||
ScreenTintScope(
|
||||
notifier: tint,
|
||||
child: TabVisibility(
|
||||
visible: visible,
|
||||
child: const TopGradient(
|
||||
color: Color(0xFFBA1E55),
|
||||
background: Color(0xFFFFFFFF),
|
||||
child: SizedBox.expand(),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
return tint;
|
||||
}
|
||||
|
||||
testWidgets('a screen whose tab shows gives the frame its colour', (
|
||||
tester,
|
||||
) async {
|
||||
final tint = await show(tester, visible: true);
|
||||
|
||||
expect(tint.color, const Color(0xFFBA1E55));
|
||||
});
|
||||
|
||||
testWidgets("a hidden tab's screen leaves the frame's colour alone", (
|
||||
tester,
|
||||
) async {
|
||||
final tint = await show(tester, visible: false);
|
||||
|
||||
expect(tint.color, Brand.primary);
|
||||
});
|
||||
|
||||
testWidgets('outside any tabs, a screen counts as shown', (tester) async {
|
||||
late bool visible;
|
||||
await tester.pumpWidget(
|
||||
Builder(
|
||||
builder: (context) {
|
||||
visible = TabVisibility.of(context);
|
||||
return const SizedBox();
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
expect(visible, isTrue);
|
||||
});
|
||||
}
|
||||
44
packages/halkyon_ui/test/widget/text_limits_test.dart
Normal file
44
packages/halkyon_ui/test/widget/text_limits_test.dart
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:halkyon_ui/halkyon_ui.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
void main() {
|
||||
Future<TextEditingController> show(WidgetTester tester) async {
|
||||
final controller = TextEditingController();
|
||||
addTearDown(controller.dispose);
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Scaffold(
|
||||
body: TextField(
|
||||
controller: controller,
|
||||
maxLength: TextLimits.name,
|
||||
buildCounter: nearLimitCounter,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
return controller;
|
||||
}
|
||||
|
||||
testWidgets('a field counts only near its limit, and stops there', (
|
||||
tester,
|
||||
) async {
|
||||
final controller = await show(tester);
|
||||
|
||||
await tester.enterText(find.byType(TextField), 'a' * 10);
|
||||
await tester.pump();
|
||||
expect(find.textContaining(' / ${TextLimits.name}'), findsNothing);
|
||||
|
||||
await tester.enterText(find.byType(TextField), 'a' * 35);
|
||||
await tester.pump();
|
||||
expect(find.text('35 / ${TextLimits.name}'), findsOneWidget);
|
||||
|
||||
await tester.enterText(find.byType(TextField), 'a' * 100);
|
||||
await tester.pump();
|
||||
expect(controller.text, hasLength(TextLimits.name));
|
||||
expect(
|
||||
find.text('${TextLimits.name} / ${TextLimits.name}'),
|
||||
findsOneWidget,
|
||||
);
|
||||
});
|
||||
}
|
||||
48
packages/halkyon_ui/test/widget/tinted_frame_test.dart
Normal file
48
packages/halkyon_ui/test/widget/tinted_frame_test.dart
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:halkyon_ui/halkyon_ui.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
void main() {
|
||||
/// A screen with something typed into it, as a study session or a card
|
||||
/// being written holds what was done.
|
||||
Future<void> typeIn(
|
||||
WidgetTester tester,
|
||||
Widget Function(Widget) around,
|
||||
) async {
|
||||
tester.view.physicalSize = const Size(1200, 800);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.reset);
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
theme: appTheme(Brightness.light),
|
||||
home: around(const Scaffold(body: TextField())),
|
||||
),
|
||||
);
|
||||
await tester.enterText(find.byType(TextField), 'kept');
|
||||
}
|
||||
|
||||
/// Crosses every line where the frame or the snack bars change: medium,
|
||||
/// compact, medium again.
|
||||
Future<void> resize(WidgetTester tester) async {
|
||||
for (final width in const [700.0, 400.0, 1200.0]) {
|
||||
tester.view.physicalSize = Size(width, 800);
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
}
|
||||
|
||||
testWidgets('a framed screen keeps what it holds when the window crosses '
|
||||
'from medium to compact and back', (tester) async {
|
||||
await typeIn(tester, (screen) => FramedScreen(child: screen));
|
||||
await resize(tester);
|
||||
|
||||
expect(find.text('kept'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('so does every screen under the snack bars fitted to the '
|
||||
'window', (tester) async {
|
||||
await typeIn(tester, (screen) => FittedSnackBars(child: screen));
|
||||
await resize(tester);
|
||||
|
||||
expect(find.text('kept'), findsOneWidget);
|
||||
});
|
||||
}
|
||||
85
packages/halkyon_ui/test/widget/title_with_kicker_test.dart
Normal file
85
packages/halkyon_ui/test/widget/title_with_kicker_test.dart
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:halkyon_ui/halkyon_ui.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
void main() {
|
||||
Future<void> show(WidgetTester tester, Widget child) => tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Scaffold(
|
||||
body: Align(
|
||||
alignment: Alignment.topLeft,
|
||||
child: SizedBox(width: 400, child: child),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
double top(WidgetTester tester, Finder finder) =>
|
||||
tester.getTopLeft(finder).dy;
|
||||
|
||||
testWidgets('shows the kicker uppercased above the title', (tester) async {
|
||||
await show(
|
||||
tester,
|
||||
const TitleWithKicker(title: 'Vocabulary', kicker: 'Japanese'),
|
||||
);
|
||||
|
||||
expect(find.text('JAPANESE'), findsOneWidget);
|
||||
expect(
|
||||
top(tester, find.text('JAPANESE')),
|
||||
lessThan(top(tester, find.text('Vocabulary'))),
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('shows the title alone without a kicker', (tester) async {
|
||||
await show(tester, const TitleWithKicker(title: 'Vocabulary', kicker: ''));
|
||||
|
||||
expect(top(tester, find.text('Vocabulary')), 0);
|
||||
});
|
||||
|
||||
testWidgets('can keep the kicker line free, so titles side by side line '
|
||||
'up', (tester) async {
|
||||
await show(
|
||||
tester,
|
||||
const Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: TitleWithKicker(title: 'Vocabulary', kicker: 'JLPT'),
|
||||
),
|
||||
Expanded(
|
||||
child: TitleWithKicker(title: 'Grammar', reserveKickerSpace: true),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
expect(
|
||||
top(tester, find.text('Grammar')),
|
||||
top(tester, find.text('Vocabulary')),
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('puts the trailing widget beside the title, not the kicker', (
|
||||
tester,
|
||||
) async {
|
||||
const action = Key('action');
|
||||
await show(
|
||||
tester,
|
||||
const TitleWithKicker(
|
||||
title: 'Vocabulary',
|
||||
kicker: 'Japanese',
|
||||
trailing: SizedBox(key: action, width: 48, height: 48),
|
||||
),
|
||||
);
|
||||
|
||||
expect(
|
||||
top(tester, find.byKey(action)),
|
||||
top(tester, find.text('Vocabulary')),
|
||||
);
|
||||
expect(
|
||||
tester.getTopRight(find.byKey(action)).dx,
|
||||
400,
|
||||
reason: 'the title takes the rest of the width',
|
||||
);
|
||||
});
|
||||
}
|
||||
139
packages/halkyon_ui/test/widget/top_gradient_test.dart
Normal file
139
packages/halkyon_ui/test/widget/top_gradient_test.dart
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:halkyon_ui/halkyon_ui.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
void main() {
|
||||
group('The tone at the top', () {
|
||||
for (final brightness in Brightness.values) {
|
||||
final scheme = appTheme(brightness).colorScheme;
|
||||
for (final background in [scheme.surface, scheme.surfaceContainerLow]) {
|
||||
test('keeps the text readable on every palette colour, '
|
||||
'${brightness.name}, $background', () {
|
||||
for (final color in PaletteColor.values) {
|
||||
final top = TopGradient.topTone(
|
||||
scheme,
|
||||
color.background,
|
||||
background,
|
||||
);
|
||||
expect(
|
||||
TopGradient.contrast(top, scheme.onSurface),
|
||||
greaterThanOrEqualTo(TopGradient.minContrast),
|
||||
reason: color.name,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
test("the frame around the tabs keeps the rail's labels readable on "
|
||||
'every palette colour, top and foot', () {
|
||||
for (final brightness in Brightness.values) {
|
||||
final scheme = appTheme(brightness).colorScheme;
|
||||
for (final color in PaletteColor.values) {
|
||||
for (final tone in TopGradient.frame(
|
||||
scheme,
|
||||
color.background,
|
||||
).colors) {
|
||||
expect(
|
||||
TopGradient.contrast(tone, scheme.onSurfaceVariant),
|
||||
greaterThanOrEqualTo(TopGradient.minContrast),
|
||||
reason: '${brightness.name} ${color.name}',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('is a deep shade of cobalt in dark mode, a tint in light mode', () {
|
||||
final dark = appTheme(Brightness.dark).colorScheme;
|
||||
final light = appTheme(Brightness.light).colorScheme;
|
||||
|
||||
expect(
|
||||
TopGradient.topTone(dark, Brand.primary, dark.surface),
|
||||
Color.lerp(
|
||||
const Color(0xFF000000),
|
||||
Brand.primary,
|
||||
TopGradient.darkShade,
|
||||
),
|
||||
);
|
||||
expect(
|
||||
TopGradient.topTone(light, Brand.primary, light.surface),
|
||||
Color.lerp(light.surface, Brand.primary, TopGradient.lightMix),
|
||||
);
|
||||
});
|
||||
|
||||
test('half as strong inside the panel', () {
|
||||
final dark = appTheme(Brightness.dark).colorScheme;
|
||||
final full = Color.lerp(
|
||||
const Color(0xFF000000),
|
||||
Brand.primary,
|
||||
TopGradient.darkShade,
|
||||
)!;
|
||||
|
||||
expect(
|
||||
TopGradient.topTone(
|
||||
dark,
|
||||
Brand.primary,
|
||||
dark.surface,
|
||||
share: TopGradient.panelShare,
|
||||
),
|
||||
Color.lerp(dark.surface, full, TopGradient.panelShare),
|
||||
);
|
||||
});
|
||||
|
||||
test('mixes in less where the full share would be unreadable', () {
|
||||
final dark = appTheme(Brightness.dark).colorScheme;
|
||||
// Light text can't sit on a shade of white at 42 %.
|
||||
const white = Color(0xFFFFFFFF);
|
||||
|
||||
final top = TopGradient.topTone(dark, white, dark.surface);
|
||||
|
||||
expect(
|
||||
top,
|
||||
isNot(
|
||||
Color.lerp(const Color(0xFF000000), white, TopGradient.darkShade),
|
||||
),
|
||||
);
|
||||
expect(
|
||||
TopGradient.contrast(top, dark.onSurface),
|
||||
greaterThanOrEqualTo(TopGradient.minContrast),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
testWidgets('fades from the tone into the background below the status bar', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
theme: appTheme(Brightness.dark),
|
||||
home: MediaQuery(
|
||||
data: const MediaQueryData(padding: EdgeInsets.only(top: 24)),
|
||||
child: Builder(
|
||||
builder: (context) => TopGradient(
|
||||
color: Brand.primary,
|
||||
background: Theme.of(context).colorScheme.surface,
|
||||
extent: 100,
|
||||
child: const SizedBox.expand(),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
final box = tester.widget<DecoratedBox>(
|
||||
find.descendant(
|
||||
of: find.byType(TopGradient),
|
||||
matching: find.byType(DecoratedBox),
|
||||
),
|
||||
);
|
||||
final gradient =
|
||||
(box.decoration as BoxDecoration).gradient! as LinearGradient;
|
||||
final scheme = appTheme(Brightness.dark).colorScheme;
|
||||
expect(gradient.colors, [
|
||||
TopGradient.topTone(scheme, Brand.primary, scheme.surface),
|
||||
scheme.surface,
|
||||
]);
|
||||
expect(tester.getSize(find.byWidget(box)).height, 124);
|
||||
});
|
||||
}
|
||||
54
packages/halkyon_ui/test/widget/undo_snack_bar_test.dart
Normal file
54
packages/halkyon_ui/test/widget/undo_snack_bar_test.dart
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:halkyon_ui/halkyon_ui.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
void main() {
|
||||
late int undos;
|
||||
|
||||
setUp(() => undos = 0);
|
||||
|
||||
/// Shows "Studyset archived" with a way to undo it.
|
||||
Future<void> archive(WidgetTester tester) async {
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
localizationsDelegates: HalkyonUiLocalizations.localizationsDelegates,
|
||||
supportedLocales: HalkyonUiLocalizations.supportedLocales,
|
||||
home: Scaffold(
|
||||
body: Builder(
|
||||
builder: (context) => TextButton(
|
||||
onPressed: () => showUndoSnackBar(
|
||||
context,
|
||||
message: 'Studyset archived',
|
||||
onUndo: () => undos++,
|
||||
),
|
||||
child: const Text('Archive'),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.tap(find.text('Archive'));
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
testWidgets('offers to undo', (tester) async {
|
||||
await archive(tester);
|
||||
expect(find.text('Studyset archived'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.text('Undo'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(undos, 1);
|
||||
expect(find.text('Studyset archived'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('goes by itself after three seconds', (tester) async {
|
||||
await archive(tester);
|
||||
|
||||
await tester.pump(const Duration(seconds: 3));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Studyset archived'), findsNothing);
|
||||
expect(undos, 0);
|
||||
});
|
||||
}
|
||||
99
packages/halkyon_ui/test/widget/widget_texts_test.dart
Normal file
99
packages/halkyon_ui/test/widget/widget_texts_test.dart
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:halkyon_ui/halkyon_ui.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
import '../support/test_app.dart';
|
||||
|
||||
void main() {
|
||||
/// Shows [child] on a screen, in German.
|
||||
Future<void> show(WidgetTester tester, Widget child) => tester.pumpWidget(
|
||||
testApp(locale: const Locale('de'), Scaffold(body: child)),
|
||||
);
|
||||
|
||||
testWidgets("a required field says so in the app's language", (
|
||||
tester,
|
||||
) async {
|
||||
await show(
|
||||
tester,
|
||||
Builder(
|
||||
builder: (context) => TextField(
|
||||
decoration: requiredDecoration(
|
||||
context,
|
||||
const InputDecoration(labelText: 'Name'),
|
||||
missing: true,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
expect(find.text('Pflichtfeld'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('a count stepper names its buttons', (tester) async {
|
||||
await show(
|
||||
tester,
|
||||
CountStepper(value: '5', onFewer: () {}, onMore: () {}, onTyped: (_) {}),
|
||||
);
|
||||
|
||||
expect(find.byTooltip('Verringern'), findsOneWidget);
|
||||
expect(find.byTooltip('Erhöhen'), findsOneWidget);
|
||||
expect(find.byTooltip('Zahl eingeben'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('a reorderable row is dragged by its grip', (tester) async {
|
||||
await show(
|
||||
tester,
|
||||
CustomScrollView(
|
||||
slivers: [
|
||||
SliverReorderableList(
|
||||
itemCount: 1,
|
||||
onReorderItem: (_, _) {},
|
||||
itemBuilder: (context, index) => ListTile(
|
||||
key: const ValueKey('row'),
|
||||
title: const Text('Row'),
|
||||
trailing: ReorderGrip(index: index),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
expect(find.byTooltip('Zum Umsortieren ziehen'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('More info names itself, and copies a value with a tap', (
|
||||
tester,
|
||||
) async {
|
||||
await show(
|
||||
tester,
|
||||
Builder(
|
||||
builder: (context) => TextButton(
|
||||
onPressed: () => showMoreInfoSheet(context, [
|
||||
(
|
||||
title: null,
|
||||
rows: [(label: 'ID', value: '0199a', copyable: true)],
|
||||
),
|
||||
]),
|
||||
child: const Text('Open'),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.tap(find.text('Open'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Weitere Informationen'), findsOneWidget);
|
||||
await tester.tap(find.byTooltip('Kopieren'));
|
||||
await tester.pump();
|
||||
expect(find.text('In die Zwischenablage kopiert'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('the palette picker names its colour', (tester) async {
|
||||
await show(
|
||||
tester,
|
||||
PaletteColorPicker(selected: PaletteColor.copper, onChanged: (_) {}),
|
||||
);
|
||||
|
||||
expect(find.text('Kupfer'), findsOneWidget);
|
||||
expect(find.bySemanticsLabel('Farbe: Kupfer'), findsOneWidget);
|
||||
});
|
||||
}
|
||||
Reference in a new issue