feat: import Chinese-localized Buzz source snapshot
Docker image / Build (linux/amd64) (push) Has been cancelled
Docker image / Build (linux/arm64) (push) Has been cancelled
Docker image / Merge release multi-arch manifest (push) Has been cancelled
Docker image / Merge debug multi-arch manifest (push) Has been cancelled
Docker image / Build public push gateway (linux/amd64) (push) Has been cancelled
Docker image / Build public push gateway (linux/arm64) (push) Has been cancelled
Docker image / Publish public push gateway image (push) Has been cancelled
Sprig image / Build (linux/amd64) (push) Has been cancelled
Sprig image / Build (linux/arm64) (push) Has been cancelled
Sprig image / Merge multi-arch manifest (push) Has been cancelled
Harbor Buzz Orchestra / Python tests and lint (push) Has been cancelled
CI / Detect Changed Paths (push) Has been cancelled
CI / Rust Lint (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Desktop Core (push) Has been cancelled
CI / Desktop Smoke E2E (1) (push) Has been cancelled
CI / Desktop Smoke E2E (2) (push) Has been cancelled
CI / Desktop Smoke E2E (3) (push) Has been cancelled
CI / Desktop Smoke E2E (4) (push) Has been cancelled
CI / Desktop (push) Has been cancelled
CI / Desktop E2E Relay (push) Has been cancelled
CI / Desktop E2E Integration (1/2) (push) Has been cancelled
CI / Desktop E2E Integration (2/2) (push) Has been cancelled
CI / Desktop E2E Integration (push) Has been cancelled
CI / Backend Integration (relay e2e) (push) Has been cancelled
CI / Relay E2E (push) Has been cancelled
CI / Web (push) Has been cancelled
CI / Mobile (push) Has been cancelled
CI / Security (push) Has been cancelled
CI / Dead Token Reference Guard (push) Has been cancelled
CI / Server Cross-Compile (aarch64-unknown-linux-musl) (push) Has been cancelled
CI / Server Cross-Compile (x86_64-unknown-linux-musl) (push) Has been cancelled
CI / Windows Rust (x86_64-pc-windows-msvc) (push) Has been cancelled
CI / Desktop Build (macOS) (push) Has been cancelled
helm chart / lint + unittest + render matrix (push) Has been cancelled
helm chart / install on kind (gated) (push) Has been cancelled
helm chart / publish chart to GHCR (push) Has been cancelled
Mesh Lifecycle / Relay-Driven Mesh Lifecycle Smoke (push) Has been cancelled
Sprig / Build (aarch64-unknown-linux-musl) (push) Has been cancelled
Sprig / Build (x86_64-unknown-linux-musl) (push) Has been cancelled
Sprig / Publish rolling release (push) Has been cancelled
Sprig / Publish tagged release (push) Has been cancelled

Signed-off-by: cls_宁波本机 <908705107@qq.com>
This commit is contained in:
2026-08-13 18:34:25 +08:00
parent 61c3fa1df9
commit 9dfa06ffee
3785 changed files with 1085458 additions and 2 deletions
@@ -0,0 +1,124 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:buzz/shared/theme/theme.dart';
import 'package:buzz/shared/widgets/app_list.dart';
import 'package:buzz/shared/widgets/app_list_card.dart';
Widget _host(Widget child) => MaterialApp(
theme: AppTheme.light(),
home: Scaffold(body: child),
);
void main() {
group('AppListRow', () {
testWidgets('centres the leading icon against a two-line row', (
tester,
) async {
await tester.pumpWidget(
_host(
const AppListRow(
icon: Icons.dns,
title: 'Connected to',
subtitle: 'https://relay.example.com',
),
),
);
final row = tester.getRect(find.byType(AppListRow));
final icon = tester.getRect(find.byType(Icon));
expect(icon.center.dy, closeTo(row.center.dy, 0.5));
});
testWidgets('centres the trailing control against a two-line row', (
tester,
) async {
await tester.pumpWidget(
_host(
const AppListRow(
icon: Icons.key,
title: 'Identity',
subtitle: 'deadbeef',
trailing: Icon(Icons.copy, key: Key('trailing')),
),
),
);
final row = tester.getRect(find.byType(AppListRow));
final trailing = tester.getRect(find.byKey(const Key('trailing')));
expect(trailing.center.dy, closeTo(row.center.dy, 0.5));
});
testWidgets('keeps the value flush against the trailing edge', (
tester,
) async {
await tester.pumpWidget(
_host(
const AppListRow(
icon: Icons.palette,
title: 'Theme',
value: 'Buzz',
trailing: Icon(Icons.chevron_right, key: Key('chevron')),
),
),
);
final value = tester.getRect(find.text('Buzz'));
final chevron = tester.getRect(find.byKey(const Key('chevron')));
// Only the row's own inter-widget gap sits between them — no flex slack.
expect(chevron.left - value.right, closeTo(Grid.xxs, 0.5));
});
});
group('AppListCard', () {
testWidgets('separates rows with dividers that clear the icon column', (
tester,
) async {
await tester.pumpWidget(
_host(
const AppListCard(
label: 'Style',
children: [
AppListRow(icon: Icons.sunny, title: 'Appearance'),
AppListRow(icon: Icons.palette, title: 'Theme'),
AppListRow(icon: Icons.water_drop, title: 'Accent color'),
],
),
),
);
final dividers = find.byType(Divider);
expect(dividers, findsNWidgets(2));
final divider = tester.widget<Divider>(dividers.first);
final scheme = AppTheme.light().colorScheme;
// Derived from the text color, not the scheme's border tokens — those sit
// within a few levels of the card fill and vanish.
expect(divider.color, scheme.onSurface.withValues(alpha: 0.12));
// The indent is painted inside the divider's box, so the line starts at
// the box's left edge plus the indent — level with the row titles.
final titleLeft = tester.getRect(find.text('Appearance')).left;
final lineLeft = tester.getRect(dividers.first).left + divider.indent!;
expect(lineLeft, closeTo(titleLeft, 0.5));
});
testWidgets('fills with the softest container step', (tester) async {
await tester.pumpWidget(
_host(
const AppListCard(children: [AppListRow(title: 'Remove community')]),
),
);
final material = tester.widget<Material>(
find.descendant(
of: find.byType(AppListCard),
matching: find.byType(Material),
),
);
expect(
material.color,
AppTheme.light().colorScheme.surfaceContainerHighest,
);
});
});
}
@@ -0,0 +1,74 @@
import 'dart:convert';
import 'package:buzz/shared/widgets/avatar_image.dart';
import 'package:flutter/material.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
const svg =
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">'
'<text x="16" y="24" text-anchor="middle">🦝</text></svg>';
Widget subject(String? imageUrl) => MaterialApp(
home: AvatarImage(
imageUrl: imageUrl,
radius: 16,
fallback: const Text('R'),
),
);
testWidgets('renders raccoon percent-encoded SVG data avatar', (
tester,
) async {
const raccoonAvatar =
'data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%22512%22%20height%3D%22512%22%20viewBox%3D%220%200%20512%20512%22%3E%3Crect%20width%3D%22512%22%20height%3D%22512%22%20rx%3D%22256%22%20fill%3D%22%233399FF%22%2F%3E%3Ctext%20x%3D%2250%25%22%20y%3D%2256%25%22%20dominant-baseline%3D%22middle%22%20text-anchor%3D%22middle%22%20font-size%3D%22258%22%3E%F0%9F%A6%9D%3C%2Ftext%3E%3C%2Fsvg%3E';
await tester.pumpWidget(subject(raccoonAvatar));
await tester.pumpAndSettle();
final emoji = tester.widget<Text>(find.text('🦝'));
expect(emoji.style?.height, 1);
expect(find.byType(SvgPicture), findsNothing);
expect(find.text('R'), findsNothing);
expect(tester.takeException(), isNull);
});
testWidgets('renders base64 SVG data avatar', (tester) async {
await tester.pumpWidget(
subject('data:image/svg+xml;base64,${base64Encode(utf8.encode(svg))}'),
);
expect(find.byType(SvgPicture), findsOneWidget);
});
testWidgets('centers fallback when no avatar is configured', (tester) async {
await tester.pumpWidget(subject(null));
final avatarCenter = tester.getCenter(find.byType(CircleAvatar));
final fallbackCenter = tester.getCenter(find.text('R'));
expect(fallbackCenter, avatarCenter);
});
testWidgets('uses fallback for malformed image data', (tester) async {
await tester.pumpWidget(subject('data:image/svg+xml;base64,%%%'));
expect(find.text('R'), findsOneWidget);
});
testWidgets('reuses parsed raster bytes across parent rebuilds', (
tester,
) async {
const png =
'data:image/png;base64,'
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=';
await tester.pumpWidget(subject(png));
final firstBytes = tester.widget<Image>(find.byType(Image)).image;
await tester.pumpWidget(subject(png));
final rebuiltBytes = tester.widget<Image>(find.byType(Image)).image;
final firstMemory = firstBytes as MemoryImage;
final rebuiltMemory = rebuiltBytes as MemoryImage;
expect(rebuiltMemory.bytes, same(firstMemory.bytes));
});
}
@@ -0,0 +1,379 @@
import 'dart:async';
import 'package:buzz/shared/widgets/bee_refresh_indicator.dart';
import 'package:buzz/shared/widgets/flapping_bee.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import '../../helpers/widget_helpers.dart';
void main() {
testWidgets('shows the bee while pulling to refresh', (tester) async {
const contentKey = ValueKey('loading-content');
var refreshes = 0;
final refreshCompleter = Completer<void>();
await tester.pumpWidget(
WidgetHelpers.testable(
child: BeeRefreshIndicator(
onRefresh: () {
refreshes++;
return refreshCompleter.future;
},
child: ListView(
children: const [SizedBox(key: contentKey, height: 800)],
),
),
),
);
final listFinder = find.byType(ListView);
final restingTop = tester.getTopLeft(listFinder).dy;
final restingContentTop = tester.getTopLeft(find.byKey(contentKey)).dy;
await tester.timedDrag(
listFinder,
const Offset(0, 320),
const Duration(milliseconds: 500),
);
await tester.pump(const Duration(milliseconds: 16));
await tester.pump(const Duration(milliseconds: 300));
final beeFinder = find.byType(FlappingBee);
final loadingTop = tester.getTopLeft(listFinder).dy;
final loadingContentTop = tester.getTopLeft(find.byKey(contentKey)).dy;
final gapTransform = tester.widget<Transform>(
find.byKey(const ValueKey('bee-refresh-retained-gap')),
);
expect(beeFinder, findsOneWidget);
expect(refreshes, 1);
expect(gapTransform.transform.getTranslation().y, closeTo(72, 1));
expect(loadingTop - restingTop, closeTo(72, 1));
final loadingBeeRect = tester.getRect(beeFinder);
final loadingGap = loadingContentTop - restingContentTop;
expect(
loadingBeeRect.center.dy,
closeTo(
restingContentTop +
(loadingGap - loadingBeeRect.height) * 0.75 +
loadingBeeRect.height / 2,
1,
),
);
refreshCompleter.complete();
await tester.pump();
await tester.pump(const Duration(milliseconds: 90));
final closingTop = tester.getTopLeft(listFinder).dy;
expect(closingTop, greaterThan(restingTop));
expect(closingTop, lessThan(loadingTop));
await tester.pumpAndSettle();
expect(tester.getTopLeft(listFinder).dy, closeTo(restingTop, 1));
expect(beeFinder, findsNothing);
});
testWidgets('tracks each stage of an active pull', (tester) async {
tester.view.physicalSize = const Size(420, 912);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
final hapticCalls = <MethodCall>[];
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(SystemChannels.platform, (call) async {
if (call.method == 'HapticFeedback.vibrate') hapticCalls.add(call);
return null;
});
addTearDown(
() => TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(SystemChannels.platform, null),
);
const contentKey = ValueKey('pull-content');
final refreshCompleter = Completer<void>();
await tester.pumpWidget(
WidgetHelpers.testable(
child: BeeRefreshIndicator(
onRefresh: () => refreshCompleter.future,
child: ListView(
children: const [SizedBox(key: contentKey, height: 800)],
),
),
),
);
final restingContentTop = tester.getTopLeft(find.byKey(contentKey)).dy;
final gesture = await tester.startGesture(
tester.getCenter(find.byType(ListView)),
pointer: 1,
);
await gesture.moveBy(const Offset(0, 12));
await tester.pump();
final beeFinder = find.byType(FlappingBee);
expect(beeFinder, findsNothing);
expect(
tester.getTopLeft(find.byKey(contentKey)).dy,
greaterThan(restingContentTop),
);
await gesture.moveBy(const Offset(0, 44));
await tester.pump();
final earlyTop = tester.getTopLeft(beeFinder).dy;
final partialOpacity = tester.widget<Opacity>(
find.byKey(const ValueKey('bee-refresh-opacity')),
);
expect(partialOpacity.opacity, greaterThan(0));
expect(partialOpacity.opacity, lessThan(1));
final partialScale = tester
.widget<Transform>(find.byKey(const ValueKey('bee-refresh-scale')))
.transform
.storage[0];
expect(partialScale, greaterThan(0.6));
expect(partialScale, lessThan(1));
expect(tester.widget<FlappingBee>(beeFinder).eyeProgress, isNull);
expect(hapticCalls, isEmpty);
await gesture.moveBy(const Offset(0, 120));
await tester.pump();
final pulledContentTop = tester.getTopLeft(find.byKey(contentKey)).dy;
expect(tester.getTopLeft(beeFinder).dy, greaterThan(earlyTop));
expect(tester.widget<FlappingBee>(beeFinder).eyeProgress, isNull);
expect(find.byKey(const ValueKey('bee-refresh-eyes-emoji')), findsNothing);
final pulledBeeRect = tester.getRect(beeFinder);
final pulledGap = pulledContentTop - restingContentTop;
expect(
pulledBeeRect.center.dy,
closeTo(
restingContentTop +
(pulledGap - pulledBeeRect.height) * 0.75 +
pulledBeeRect.height / 2,
1,
),
);
await gesture.moveBy(const Offset(0, 120));
await tester.pump();
expect(
tester
.widget<Transform>(find.byKey(const ValueKey('bee-refresh-scale')))
.transform
.storage[0],
closeTo(1, 0.001),
);
expect(hapticCalls, hasLength(1));
expect(hapticCalls.single.arguments, 'HapticFeedbackType.mediumImpact');
expect(tester.widget<FlappingBee>(beeFinder).eyeProgress, isNull);
expect(find.byKey(const ValueKey('bee-refresh-eyes-emoji')), findsNothing);
await tester.pump(const Duration(milliseconds: 350));
await gesture.moveBy(
const Offset(0, 120),
timeStamp: const Duration(milliseconds: 350),
);
await tester.pump();
final pupilProgress = tester.widget<FlappingBee>(beeFinder).eyeProgress;
expect(pupilProgress, greaterThan(0));
expect(pupilProgress, lessThan(0.5));
expect(find.byKey(const ValueKey('bee-refresh-eyes-emoji')), findsNothing);
expect(hapticCalls, hasLength(1));
await tester.pump(const Duration(milliseconds: 400));
await gesture.moveBy(
const Offset(0, 240),
timeStamp: const Duration(milliseconds: 750),
);
await tester.pump();
expect(tester.widget<FlappingBee>(beeFinder).eyeProgress, isNull);
expect(
find.byKey(const ValueKey('bee-refresh-eyes-emoji')),
findsOneWidget,
);
expect(hapticCalls, hasLength(2));
expect(hapticCalls.last.arguments, 'HapticFeedbackType.heavyImpact');
expect(
tester
.widget<Transform>(
find.byKey(const ValueKey('bee-refresh-eyes-emoji-offset')),
)
.transform
.storage[12],
2,
);
final initialShake = tester
.widget<Transform>(
find.byKey(const ValueKey('bee-refresh-eyes-emoji-shake')),
)
.transform
.storage[12];
await tester.pump(const Duration(milliseconds: 35));
final movedShake = tester
.widget<Transform>(
find.byKey(const ValueKey('bee-refresh-eyes-emoji-shake')),
)
.transform
.storage[12];
expect(movedShake, isNot(closeTo(initialShake, 0.01)));
expect(movedShake.abs(), lessThanOrEqualTo(0.75));
await tester.pump(const Duration(milliseconds: 105));
expect(hapticCalls, hasLength(3));
expect(hapticCalls.last.arguments, 'HapticFeedbackType.selectionClick');
final secondGesture = await tester.startGesture(
tester.getCenter(find.byType(ListView)),
pointer: 2,
);
await secondGesture.moveBy(
const Offset(0, 40),
timeStamp: const Duration(milliseconds: 800),
);
await gesture.up();
await tester.pump();
expect(
find.byKey(const ValueKey('bee-refresh-eyes-emoji')),
findsOneWidget,
);
expect(hapticCalls, hasLength(3));
await secondGesture.moveBy(
const Offset(0, 80),
timeStamp: const Duration(milliseconds: 900),
);
await tester.pump();
expect(
find.byKey(const ValueKey('bee-refresh-eyes-emoji')),
findsOneWidget,
);
expect(hapticCalls, hasLength(3));
await secondGesture.up();
await tester.pump();
final hapticsAfterRelease = hapticCalls.length;
await tester.pump(const Duration(milliseconds: 300));
expect(tester.widget<FlappingBee>(beeFinder).eyeProgress, isNull);
expect(find.byKey(const ValueKey('bee-refresh-eyes-emoji')), findsNothing);
expect(hapticCalls, hasLength(hapticsAfterRelease));
refreshCompleter.complete();
await tester.pumpAndSettle();
});
testWidgets('keeps expressive eyes hidden for a quick refresh flick', (
tester,
) async {
final hapticCalls = <MethodCall>[];
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(SystemChannels.platform, (call) async {
if (call.method == 'HapticFeedback.vibrate') hapticCalls.add(call);
return null;
});
addTearDown(
() => TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(SystemChannels.platform, null),
);
final refreshCompleter = Completer<void>();
var refreshes = 0;
await tester.pumpWidget(
WidgetHelpers.testable(
child: BeeRefreshIndicator(
onRefresh: () {
refreshes++;
return refreshCompleter.future;
},
child: ListView(children: const [SizedBox(height: 800)]),
),
),
);
final gesture = await tester.startGesture(
tester.getCenter(find.byType(ListView)),
);
final beeFinder = find.byType(FlappingBee);
for (var step = 0; step < 10; step++) {
await gesture.moveBy(
const Offset(0, 50),
timeStamp: Duration(milliseconds: (step + 1) * 10),
);
await tester.pump(const Duration(milliseconds: 10));
if (beeFinder.evaluate().isNotEmpty) {
expect(tester.widget<FlappingBee>(beeFinder).eyeProgress, isNull);
expect(
find.byKey(const ValueKey('bee-refresh-eyes-emoji')),
findsNothing,
);
}
}
await gesture.up();
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
expect(refreshes, 1);
expect(hapticCalls, hasLength(1));
expect(hapticCalls.single.arguments, 'HapticFeedbackType.mediumImpact');
expect(tester.widget<FlappingBee>(beeFinder).eyeProgress, isNull);
expect(find.byKey(const ValueKey('bee-refresh-eyes-emoji')), findsNothing);
refreshCompleter.complete();
await tester.pumpAndSettle();
});
testWidgets('keeps the bee static when motion is disabled', (tester) async {
await tester.pumpWidget(
MediaQuery(
data: const MediaQueryData(disableAnimations: true),
child: WidgetHelpers.testable(
child: Builder(
builder: (context) => MediaQuery(
data: MediaQuery.of(context).copyWith(disableAnimations: true),
child: BeeRefreshIndicator(
onRefresh: () async {},
child: ListView(children: const [SizedBox(height: 800)]),
),
),
),
),
),
);
await tester.timedDrag(
find.byType(ListView),
const Offset(0, 160),
const Duration(milliseconds: 400),
);
await tester.pump();
final bee = tester.widget<FlappingBee>(find.byType(FlappingBee));
expect(bee.flapAmount, 0);
});
testWidgets('provides elastic always-scrollable physics', (tester) async {
late ScrollPhysics physics;
await tester.pumpWidget(
WidgetHelpers.testable(
child: BeeRefreshIndicator(
onRefresh: () async {},
child: Builder(
builder: (context) {
physics = ScrollConfiguration.of(
context,
).getScrollPhysics(context);
return ListView(children: const [SizedBox(height: 20)]);
},
),
),
),
);
expect(physics, isA<BouncingScrollPhysics>());
expect(physics.parent, isA<AlwaysScrollableScrollPhysics>());
});
}
@@ -0,0 +1,50 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:buzz/shared/theme/theme.dart';
import 'package:buzz/shared/widgets/buzz_loading_indicator.dart';
Widget _testable({required bool disableAnimations}) {
return ProviderScope(
child: MaterialApp(
theme: AppTheme.light(),
home: MediaQuery(
data: const MediaQueryData().copyWith(
disableAnimations: disableAnimations,
),
child: const Scaffold(
body: BuzzLoadingIndicator(semanticLabel: 'Loading photos'),
),
),
),
);
}
void main() {
testWidgets('animates the shared arc spinner', (tester) async {
final semantics = tester.ensureSemantics();
await tester.pumpWidget(_testable(disableAnimations: false));
await tester.pump(const Duration(milliseconds: 70));
final spinner = tester.widget<RotationTransition>(
find.byKey(const ValueKey('buzz-loading-indicator-spinner')),
);
expect(spinner.turns.value, greaterThan(0));
expect(find.bySemanticsLabel('Loading photos'), findsOneWidget);
semantics.dispose();
});
testWidgets('holds a static pose when reduced motion is enabled', (
tester,
) async {
await tester.pumpWidget(_testable(disableAnimations: true));
await tester.pump(const Duration(milliseconds: 70));
final spinner = tester.widget<RotationTransition>(
find.byKey(const ValueKey('buzz-loading-indicator-spinner')),
);
expect(spinner.turns.value, 0);
expect(tester.binding.hasScheduledFrame, isFalse);
});
}
@@ -0,0 +1,137 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:buzz/shared/theme/theme.dart';
import 'package:buzz/shared/widgets/filter_chip_bar.dart';
void main() {
testWidgets('selected chip resolves the accent (scheme.primary) as fill', (
tester,
) async {
final accented = applyAccent(darkColorScheme, 0); // Blue accent
final accent = accented.primary;
await tester.pumpWidget(
MaterialApp(
theme: AppTheme.dark(colorScheme: accented),
home: Scaffold(
body: FilterChipBar<int>(
selected: 0,
onSelected: (_) {},
items: const [
FilterChipItem(id: 0, label: 'Everyone'),
FilterChipItem(id: 1, label: 'Following'),
],
),
),
),
);
await tester.pumpAndSettle();
// The chip's container color comes from ChipThemeData.color resolved for
// the selected state. Resolve it the same way the chip does and assert
// it's the accent.
final chipTheme = ChipTheme.of(
tester.element(find.widgetWithText(RawChip, 'Everyone')),
);
final resolved = chipTheme.color?.resolve({WidgetState.selected});
expect(resolved, accent);
final selectedLabel = tester.widget<Text>(find.text('Everyone'));
final unselectedLabel = tester.widget<Text>(find.text('Following'));
expect(selectedLabel.style?.fontSize, filterChipTextStyle.fontSize);
expect(selectedLabel.style?.height, filterChipTextStyle.height);
expect(selectedLabel.style?.fontFamily, 'Inter');
expect(selectedLabel.style?.fontWeight, FontWeight.w500);
expect(unselectedLabel.style?.fontSize, filterChipTextStyle.fontSize);
expect(unselectedLabel.style?.height, filterChipTextStyle.height);
expect(unselectedLabel.style?.fontWeight, FontWeight.w400);
final chip = tester.widget<FilterChip>(
find.widgetWithText(FilterChip, 'Everyone'),
);
final shape = chip.shape! as RoundedRectangleBorder;
expect(shape.borderRadius, BorderRadius.circular(Radii.lg));
expect(chip.labelPadding, EdgeInsets.zero);
});
testWidgets('search labels are vertically centered in their chips', (
tester,
) async {
await tester.pumpWidget(
MaterialApp(
theme: AppTheme.light(),
home: Scaffold(
body: SizedBox(
width: 390,
child: FilterChipBar<int>(
expandItems: true,
visualDensity: const VisualDensity(horizontal: -2),
chipVerticalPadding: Grid.xxs,
barVerticalPadding: Grid.twelve,
selected: 0,
onSelected: (_) {},
items: const [
FilterChipItem(id: 0, label: 'All'),
FilterChipItem(id: 1, label: 'Messages'),
FilterChipItem(id: 2, label: 'Channels'),
FilterChipItem(id: 3, label: 'People'),
],
),
),
),
),
);
await tester.pumpAndSettle();
for (final label in ['All', 'Messages', 'Channels', 'People']) {
final text = find.text(label);
final chip = find.ancestor(of: text, matching: find.byType(RawChip));
expect(chip, findsOneWidget);
expect(
tester.getCenter(text).dy,
closeTo(tester.getCenter(chip).dy, 0.01),
);
}
});
testWidgets('expanded chips preserve large accessible text scaling', (
tester,
) async {
await tester.pumpWidget(
MaterialApp(
theme: AppTheme.light(),
home: MediaQuery(
data: const MediaQueryData(textScaler: TextScaler.linear(2)),
child: Scaffold(
body: SizedBox(
width: 240,
child: FilterChipBar<int>(
expandItems: true,
selected: 0,
onSelected: (_) {},
items: const [
FilterChipItem(id: 0, label: 'All'),
FilterChipItem(id: 1, label: 'Messages'),
FilterChipItem(id: 2, label: 'Channels'),
FilterChipItem(id: 3, label: 'People'),
],
),
),
),
),
),
);
await tester.pumpAndSettle();
expect(
find.descendant(
of: find.byType(FilterChipBar<int>),
matching: find.byType(FittedBox),
),
findsNothing,
);
expect(
tester.getSize(find.text('Messages')).height,
greaterThanOrEqualTo(30),
);
expect(tester.takeException(), isNull);
});
}
@@ -0,0 +1,86 @@
import 'package:buzz/shared/theme/theme.dart';
import 'package:buzz/shared/widgets/frosted_app_bar.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
testWidgets('title row and reported height grow with accessible text', (
tester,
) async {
const titleStyle = TextStyle(fontSize: 22, height: 1.3);
late double reportedHeight;
await tester.pumpWidget(
MaterialApp(
theme: AppTheme.light(),
home: MediaQuery(
data: const MediaQueryData(textScaler: TextScaler.linear(2)),
child: Builder(
builder: (context) {
reportedHeight = frostedAppBarHeight(
context,
titleStyle: titleStyle,
);
return const Stack(
children: [
FrostedAppBar(title: Text('Search'), titleStyle: titleStyle),
],
);
},
),
),
),
);
await tester.pumpAndSettle();
final clip = find.descendant(
of: find.byType(FrostedAppBar),
matching: find.byType(ClipRect),
);
expect(reportedHeight, greaterThan(48));
expect(tester.getSize(clip).height, closeTo(reportedHeight, 0.01));
expect(tester.getSize(find.text('Search')).height, greaterThan(48));
expect(tester.takeException(), isNull);
});
testWidgets('custom multi-line title height matches reported height', (
tester,
) async {
const titleContentHeight = 64.0;
late double reportedHeight;
await tester.pumpWidget(
MaterialApp(
theme: AppTheme.light(),
home: Builder(
builder: (context) {
reportedHeight = frostedAppBarHeight(
context,
titleContentHeight: titleContentHeight,
);
return const Stack(
children: [
FrostedAppBar(
titleContentHeight: titleContentHeight,
title: Column(
mainAxisSize: MainAxisSize.min,
children: [Text('Title'), Text('Subtitle')],
),
),
],
);
},
),
),
);
await tester.pumpAndSettle();
final clip = find.descendant(
of: find.byType(FrostedAppBar),
matching: find.byType(ClipRect),
);
expect(tester.getSize(clip).height, closeTo(reportedHeight, 0.01));
expect(reportedHeight, closeTo(titleContentHeight + Grid.xs + 1, 0.01));
expect(tester.takeException(), isNull);
});
}
@@ -0,0 +1,159 @@
import 'package:buzz/shared/widgets/keyboard_dismiss_on_drag.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
/// A focused field inside a `Scaffold` body, which is the only arrangement
/// either message list ever runs in.
Widget _testable({
required FocusNode focusNode,
VoidCallback? onUserScrollStart,
}) {
return MaterialApp(
home: Scaffold(
body: Column(
children: [
TextField(focusNode: focusNode),
Expanded(
child: KeyboardDismissOnDrag(
onUserScrollStart: onUserScrollStart,
child: ListView(
children: [
for (var i = 0; i < 40; i++)
SizedBox(height: 60, child: Text('row $i')),
],
),
),
),
],
),
),
);
}
/// Raise the keyboard the way the platform does — on the view, not via a
/// synthetic `MediaQuery` wrapper. It has to travel the real path, because
/// `Scaffold` strips the bottom view inset from its body once it has resized to
/// account for it, and that stripping is the whole reason this widget broke.
void _raiseKeyboard(WidgetTester tester) {
tester.view.viewInsets = const FakeViewPadding(bottom: 300);
addTearDown(tester.view.reset);
}
Future<void> _dragDown(
WidgetTester tester,
double distance, {
String on = 'row 3',
}) async {
final gesture = await tester.startGesture(tester.getCenter(find.text(on)));
// Several small moves, the way a real finger reports — the accumulator has to
// add them up rather than needing one big delta.
final step = distance / 6;
for (var i = 0; i < 6; i++) {
await gesture.moveBy(Offset(0, step));
await tester.pump();
}
await gesture.up();
await tester.pump();
}
void main() {
group('KeyboardDismissOnDrag', () {
testWidgets('a deliberate downward drag drops focus', (tester) async {
final focusNode = FocusNode();
addTearDown(focusNode.dispose);
_raiseKeyboard(tester);
await tester.pumpWidget(_testable(focusNode: focusNode));
focusNode.requestFocus();
await tester.pump();
expect(focusNode.hasFocus, isTrue);
await _dragDown(tester, keyboardDismissDragThreshold * 2);
// At rest the list can't scroll down, so this gesture is pure overscroll
// — the case that reports `OverscrollNotification` rather than
// `ScrollUpdateNotification`, and the one a focused composer is usually
// sitting in.
expect(focusNode.hasFocus, isFalse);
});
testWidgets('an ordinary downward scroll dismisses too', (tester) async {
final focusNode = FocusNode();
addTearDown(focusNode.dispose);
_raiseKeyboard(tester);
await tester.pumpWidget(_testable(focusNode: focusNode));
focusNode.requestFocus();
await tester.pump();
// Move off the resting edge so dragging down actually moves the position,
// covering the `ScrollUpdateNotification` path rather than overscroll.
await tester.drag(find.text('row 3'), const Offset(0, -400));
await tester.pumpAndSettle();
expect(focusNode.hasFocus, isTrue);
await _dragDown(tester, keyboardDismissDragThreshold * 2, on: 'row 10');
expect(focusNode.hasFocus, isFalse);
});
testWidgets('a short scroll leaves the keyboard alone', (tester) async {
final focusNode = FocusNode();
addTearDown(focusNode.dispose);
_raiseKeyboard(tester);
await tester.pumpWidget(_testable(focusNode: focusNode));
focusNode.requestFocus();
await tester.pump();
await _dragDown(tester, keyboardDismissDragThreshold / 2);
expect(focusNode.hasFocus, isTrue);
});
testWidgets('reports a user-started scroll', (tester) async {
final focusNode = FocusNode();
addTearDown(focusNode.dispose);
var userScrollStarts = 0;
await tester.pumpWidget(
_testable(
focusNode: focusNode,
onUserScrollStart: () => userScrollStarts += 1,
),
);
await tester.drag(find.text('row 3'), const Offset(0, -100));
await tester.pumpAndSettle();
expect(userScrollStarts, 1);
});
testWidgets('an upward drag never dismisses, however far it goes', (
tester,
) async {
final focusNode = FocusNode();
addTearDown(focusNode.dispose);
_raiseKeyboard(tester);
await tester.pumpWidget(_testable(focusNode: focusNode));
focusNode.requestFocus();
await tester.pump();
await _dragDown(tester, -keyboardDismissDragThreshold * 4);
expect(focusNode.hasFocus, isTrue);
});
testWidgets('with the keyboard already down, focus is left as it is', (
tester,
) async {
final focusNode = FocusNode();
addTearDown(focusNode.dispose);
// No _raiseKeyboard: the keyboard is down.
await tester.pumpWidget(_testable(focusNode: focusNode));
focusNode.requestFocus();
await tester.pump();
await _dragDown(tester, keyboardDismissDragThreshold * 2);
// Nothing to dismiss, so don't yank focus out of whatever holds it.
expect(focusNode.hasFocus, isTrue);
});
});
}
@@ -0,0 +1,147 @@
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:buzz/shared/theme/theme.dart';
import 'package:buzz/shared/widgets/masked_avatar_badge.dart';
void main() {
group('avatarBadgeMaskPath', () {
const size = 128.0;
const geometry = AvatarBadgeMaskGeometry.badge;
test('keeps the avatar but cuts the notch out of it', () {
final path = avatarBadgeMaskPath(size: size, geometry: geometry);
expect(path.contains(const Offset(size / 2, size / 2)), isTrue);
expect(path.contains(geometry.cutoutCenter(size)), isFalse);
// The far edge is untouched — the notch only bites the bottom-right.
expect(path.contains(const Offset(4, size / 2)), isTrue);
});
test('stays inside the avatar circle on the unnotched sides', () {
// Checked with contains() rather than getBounds(), which reports
// control-point bounds and so overshoots the near-full-circle arc.
final path = avatarBadgeMaskPath(size: size, geometry: geometry);
expect(path.contains(const Offset(-1, size / 2)), isFalse);
expect(path.contains(const Offset(size / 2, -1)), isFalse);
expect(path.contains(const Offset(2, 2)), isFalse);
});
test('fillets the notch rather than subtracting a bare circle', () {
// A plain difference meets the cutout at two cusps. The fillets round
// those off, which necessarily removes extra material just outside the
// cutout circle — so the masked path must be a strict subset there.
final masked = avatarBadgeMaskPath(size: size, geometry: geometry);
final cutoutCenter = geometry.cutoutCenter(size);
final cutoutRadius = geometry.cutoutRadius(size);
final bare = Path.combine(
PathOperation.difference,
Path()..addOval(
Rect.fromCircle(
center: const Offset(size / 2, size / 2),
radius: size / 2,
),
),
Path()..addOval(
Rect.fromCircle(center: cutoutCenter, radius: cutoutRadius),
),
);
// Sample the ring just outside the cutout, where the fillets live.
var filleted = 0;
for (var step = 0; step < 360; step++) {
final angle = step * math.pi / 180;
final point =
cutoutCenter +
Offset(math.cos(angle), math.sin(angle)) * (cutoutRadius + 2);
if (bare.contains(point) && !masked.contains(point)) filleted++;
// The fillet only adds material to the cut, never puts it back.
expect(
masked.contains(point) && !bare.contains(point),
isFalse,
reason: 'mask should not extend beyond the plain difference',
);
}
expect(filleted, greaterThan(0));
});
test('falls back to a plain circle when the notch misses the avatar', () {
// A notch tucked at the centre never crosses the avatar's edge, so there
// is no crossing to fillet and nothing should be cut.
const detached = AvatarBadgeMaskGeometry(
cutoutCenterRatio: 0.5,
cutoutRadiusRatio: 0.05,
badgeSizeRatio: 0.08,
curve: AvatarBadgeCurve.standard,
);
final path = avatarBadgeMaskPath(size: size, geometry: detached);
expect(path.contains(detached.cutoutCenter(size)), isTrue);
expect(path.getBounds().size, const Size(size, size));
});
test(
'scales geometry with the avatar, matching desktop at its own size',
() {
// Desktop's sidebar avatar is 32px with the cutout at (28, 28) r 7.5.
const presence = AvatarBadgeMaskGeometry.presenceDot;
expect(presence.cutoutCenter(32), const Offset(28, 28));
expect(presence.cutoutRadius(32), 7.5);
expect(presence.badgeSize(32), 14);
// Twice the avatar, twice the notch.
expect(presence.cutoutCenter(64), const Offset(56, 56));
expect(presence.cutoutRadius(64), 15);
},
);
});
group('MaskedAvatarBadge', () {
Widget harness({Widget? badge}) => MaterialApp(
theme: AppTheme.light(),
home: Center(
child: MaskedAvatarBadge(
size: 128,
geometry: AvatarBadgeMaskGeometry.badge,
avatar: const ColoredBox(color: Color(0xFF123456)),
badge: badge,
),
),
);
testWidgets('masks the avatar and centres the badge in the notch', (
tester,
) async {
await tester.pumpWidget(
harness(badge: const ColoredBox(color: Color(0xFFABCDEF))),
);
final clip = tester.widget<ClipPath>(find.byType(ClipPath));
expect(clip.clipper, isA<AvatarBadgeMaskClipper>());
const geometry = AvatarBadgeMaskGeometry.badge;
final frame = tester.getRect(find.byType(MaskedAvatarBadge));
final badgeRect = tester.getRect(find.byType(ColoredBox).last);
expect(badgeRect.width, closeTo(geometry.badgeSize(128), 0.01));
expect(
badgeRect.center - frame.topLeft,
within(distance: 0.01, from: geometry.cutoutCenter(128)),
);
});
testWidgets('leaves the avatar whole when there is no badge', (
tester,
) async {
await tester.pumpWidget(harness());
expect(find.byType(ClipPath), findsNothing);
expect(
tester.getSize(find.byType(MaskedAvatarBadge)),
const Size(128, 128),
);
});
});
}
@@ -0,0 +1,117 @@
import 'package:buzz/shared/theme/theme.dart';
import 'package:buzz/shared/widgets/message_author_meta.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
testWidgets('keeps the separator and timestamp next to a short name', (
tester,
) async {
const displayNameKey = Key('author-display-name');
const timestampKey = Key('author-timestamp');
await tester.pumpWidget(
MaterialApp(
theme: AppTheme.light(),
home: const Scaffold(
body: SizedBox(
width: 300,
child: MessageAuthorMeta(
displayName: 'Alice',
timestamp: '2m',
displayNameKey: displayNameKey,
timestampKey: timestampKey,
nameColor: Colors.black,
metadataColor: Colors.grey,
),
),
),
),
);
await tester.pumpAndSettle();
final displayNameRect = tester.getRect(find.byKey(displayNameKey));
final separatorRect = tester.getRect(find.text('·'));
final timestampRect = tester.getRect(find.byKey(timestampKey));
expect(separatorRect.left - displayNameRect.right, Grid.half);
expect(timestampRect.left - separatorRect.right, Grid.half);
expect(tester.takeException(), isNull);
});
testWidgets('reallocates unused metadata width to the display name', (
tester,
) async {
const displayNameKey = Key('author-display-name');
const timestampKey = Key('author-timestamp');
await tester.pumpWidget(
MaterialApp(
theme: AppTheme.light(),
home: const Scaffold(
body: SizedBox(
width: 300,
child: MessageAuthorMeta(
displayName: 'A display name that needs the available width',
username: 'al',
timestamp: '2m',
displayNameKey: displayNameKey,
timestampKey: timestampKey,
nameColor: Colors.black,
metadataColor: Colors.grey,
),
),
),
),
);
await tester.pumpAndSettle();
final row = find.byType(MessageAuthorMeta);
final displayName = find.byKey(displayNameKey);
final timestamp = find.byKey(timestampKey);
expect(
tester.getSize(displayName).width,
greaterThan(tester.getSize(row).width / 2),
);
expect(
tester.getTopRight(timestamp).dx,
closeTo(tester.getTopRight(row).dx, 0.01),
);
expect(tester.takeException(), isNull);
});
testWidgets('constrains long metadata at large accessible text sizes', (
tester,
) async {
const timestampKey = Key('author-timestamp');
await tester.pumpWidget(
MaterialApp(
theme: AppTheme.light(),
home: const MediaQuery(
data: MediaQueryData(textScaler: TextScaler.linear(2)),
child: Scaffold(
body: SizedBox(
width: 220,
child: MessageAuthorMeta(
displayName: 'A very long display name',
username: 'a-very-long-username',
timestamp: 'Mar 15, 2025',
timestampKey: timestampKey,
nameColor: Colors.black,
metadataColor: Colors.grey,
),
),
),
),
),
);
await tester.pumpAndSettle();
final timestamp = tester.widget<Text>(find.byKey(timestampKey));
expect(timestamp.maxLines, 1);
expect(timestamp.overflow, TextOverflow.ellipsis);
expect(tester.takeException(), isNull);
});
}
@@ -0,0 +1,43 @@
import 'package:buzz/shared/widgets/mobile_tab_footer_backdrop.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
testWidgets('shared gradient fades from transparent to the page surface', (
tester,
) async {
LinearGradient? gradient;
await tester.pumpWidget(
MaterialApp(
home: Builder(
builder: (context) {
gradient = mobileTabFooterBackdropGradient(context);
return const SizedBox();
},
),
),
);
expect(gradient?.stops, [0, 0.18, 0.38, 0.6, 0.8, 1]);
expect(gradient?.colors.first.a, 0);
expect(gradient?.colors[1].a, closeTo(0.03, 0.01));
expect(gradient?.colors[3].a, closeTo(0.34, 0.01));
expect(gradient?.colors.last.a, 1);
});
testWidgets('uses a fixed 180px footer backdrop height', (tester) async {
double? height;
await tester.pumpWidget(
Builder(
builder: (context) {
height = mobileTabFooterBackdropHeight(context);
return const SizedBox();
},
),
);
expect(height, 180);
});
}
@@ -0,0 +1,226 @@
import 'package:buzz/shared/widgets/concentric_sheet_surface.dart';
import 'package:buzz/shared/theme/theme.dart';
import 'package:buzz/shared/widgets/modal_presentation.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
testWidgets(
'keeps an opaque Flutter surface when iOS native support is unavailable',
(tester) async {
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
try {
await tester.pumpWidget(
MaterialApp(
theme: AppTheme.light(),
home: const ConcentricSheetSurface(
enabled: true,
color: Colors.red,
child: SizedBox(height: 80, child: Text('Sheet body')),
),
),
);
await tester.pumpAndSettle();
expect(
find.byWidgetPredicate(
(widget) => widget is Material && widget.color == Colors.red,
),
findsOneWidget,
);
} finally {
debugDefaultTargetPlatformOverride = null;
}
},
);
testWidgets(
'replaces the Flutter fallback when native support is available',
(tester) async {
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
const surfaceChannel = MethodChannel('buzz/concentric_sheet_surface');
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
surfaceChannel,
(call) async => call.method == 'isSupported' ? true : null,
);
try {
await tester.pumpWidget(
MaterialApp(
theme: AppTheme.light(),
home: const ConcentricSheetSurface(
enabled: true,
color: Colors.red,
child: SizedBox(height: 80, child: Text('Sheet body')),
),
),
);
await tester.pump();
expect(find.byType(UiKitView), findsOneWidget);
expect(
find.byWidgetPredicate(
(widget) => widget is Material && widget.color == Colors.red,
),
findsNothing,
);
} finally {
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
surfaceChannel,
null,
);
debugDefaultTargetPlatformOverride = null;
}
},
);
testWidgets(
'non-iOS sheets use Flutter drag handle and shared close control',
(tester) async {
debugDefaultTargetPlatformOverride = TargetPlatform.android;
try {
await tester.pumpWidget(
MaterialApp(
theme: AppTheme.light(),
home: Scaffold(
body: Builder(
builder: (context) => FilledButton(
onPressed: () => showBuzzModalBottomSheet<void>(
context: context,
showDragHandle: true,
builder: (_) => const Text('Sheet body'),
),
child: const Text('Open sheet'),
),
),
),
),
);
await tester.tap(find.text('Open sheet'));
await tester.pumpAndSettle();
final closeButton = find.byTooltip('Close sheet');
expect(closeButton, findsOneWidget);
expect(tester.getSize(closeButton), const Size.square(44));
final closeGutter = find.ancestor(
of: closeButton,
matching: find.byWidgetPredicate(
(widget) =>
widget is Padding &&
widget.padding ==
const EdgeInsets.only(
top: Grid.xxs,
left: Grid.gutter,
right: Grid.gutter,
bottom: Grid.xs,
),
),
);
expect(closeGutter, findsOneWidget);
final gutterRect = tester.getRect(closeGutter);
final closeRect = tester.getRect(closeButton);
expect(closeRect.top - gutterRect.top, Grid.gutter);
expect(gutterRect.right - closeRect.right, Grid.gutter);
expect(
tester.widget<BottomSheet>(find.byType(BottomSheet)).showDragHandle,
isTrue,
);
expect(
find.byKey(const ValueKey('buzz-sheet-drag-handle')),
findsNothing,
);
expect(find.text('Sheet body'), findsOneWidget);
await tester.tap(closeButton);
await tester.pumpAndSettle();
expect(find.text('Sheet body'), findsNothing);
} finally {
debugDefaultTargetPlatformOverride = null;
}
},
);
testWidgets('iOS paints the drag handle inside the concentric surface', (
tester,
) async {
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
try {
await tester.pumpWidget(
MaterialApp(
theme: AppTheme.light(),
home: Scaffold(
body: Builder(
builder: (context) => FilledButton(
onPressed: () => showBuzzModalBottomSheet<void>(
context: context,
showDragHandle: true,
builder: (_) => const Text('Sheet body'),
),
child: const Text('Open sheet'),
),
),
),
),
);
await tester.tap(find.text('Open sheet'));
await tester.pumpAndSettle();
expect(
tester.widget<BottomSheet>(find.byType(BottomSheet)).showDragHandle,
isFalse,
);
final internalHandle = find.byKey(
const ValueKey('buzz-sheet-drag-handle'),
);
expect(internalHandle, findsOneWidget);
expect(tester.getSize(internalHandle), const Size(32, 4));
expect(find.bySemanticsLabel('Drag handle'), findsOneWidget);
expect(find.byTooltip('Close sheet'), findsOneWidget);
expect(find.text('Sheet body'), findsOneWidget);
} finally {
debugDefaultTargetPlatformOverride = null;
}
});
testWidgets('iOS compact sheets can omit X but retain the inside handle', (
tester,
) async {
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
try {
await tester.pumpWidget(
MaterialApp(
theme: AppTheme.light(),
home: Scaffold(
body: Builder(
builder: (context) => FilledButton(
onPressed: () => showBuzzModalBottomSheet<void>(
context: context,
showDragHandle: true,
showCloseButton: false,
builder: (_) => const Text('Compact sheet body'),
),
child: const Text('Open compact sheet'),
),
),
),
),
);
await tester.tap(find.text('Open compact sheet'));
await tester.pumpAndSettle();
expect(
find.byKey(const ValueKey('buzz-sheet-drag-handle')),
findsOneWidget,
);
expect(find.byTooltip('Close sheet'), findsNothing);
expect(find.text('Compact sheet body'), findsOneWidget);
} finally {
debugDefaultTargetPlatformOverride = null;
}
});
}
@@ -0,0 +1,186 @@
import 'package:buzz/shared/theme/theme.dart';
import 'package:buzz/shared/widgets/skeleton.dart';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
Widget buildShimmerTestable({
bool disableAnimations = false,
bool enabled = true,
}) {
return MaterialApp(
theme: AppTheme.light(),
builder: (context, child) => MediaQuery(
data: MediaQuery.of(
context,
).copyWith(disableAnimations: disableAnimations),
child: child!,
),
home: Scaffold(
body: SkeletonShimmer(
enabled: enabled,
child: const SkeletonBar(width: 120, height: 16),
),
),
);
}
Widget buildRevealTestable({
required ValueNotifier<bool> loading,
bool disableAnimations = false,
}) {
return MaterialApp(
theme: AppTheme.light(),
builder: (context, child) => MediaQuery(
data: MediaQuery.of(
context,
).copyWith(disableAnimations: disableAnimations),
child: child!,
),
home: Scaffold(
body: ValueListenableBuilder(
valueListenable: loading,
builder: (context, isLoading, _) => SkeletonReveal(
loading: isLoading,
skeleton: const SkeletonBar(width: 120, height: 16),
content: const Text('Loaded content'),
),
),
),
);
}
double layerOpacity(WidgetTester tester, String key) =>
tester.widget<Opacity>(find.byKey(Key(key))).opacity;
bool contentFocusExcluded(WidgetTester tester) => tester
.widget<ExcludeFocus>(
find.descendant(
of: find.byKey(const Key('skeleton-reveal-content')),
matching: find.byType(ExcludeFocus),
),
)
.excluding;
testWidgets('sweeps a highlight across skeleton elements while loading', (
tester,
) async {
await tester.pumpWidget(buildShimmerTestable());
final shimmer = find.byType(SkeletonShimmer);
expect(find.byType(SkeletonBar), findsOneWidget);
expect(
find.descendant(of: shimmer, matching: find.byType(ShaderMask)),
findsOneWidget,
);
final boundary = tester.renderObject<RenderRepaintBoundary>(
find
.descendant(of: shimmer, matching: find.byType(RepaintBoundary))
.first,
);
final beforeBytes = await tester.runAsync(() async {
final image = await boundary.toImage();
return image.toByteData();
});
await tester.pump(const Duration(milliseconds: 1000));
final afterBytes = await tester.runAsync(() async {
final image = await boundary.toImage();
return image.toByteData();
});
expect(
beforeBytes!.buffer.asUint8List(),
isNot(equals(afterBytes!.buffer.asUint8List())),
);
});
testWidgets('keeps skeleton elements static for reduced motion', (
tester,
) async {
await tester.pumpWidget(buildShimmerTestable(disableAnimations: true));
final shimmer = find.byType(SkeletonShimmer);
expect(find.byType(SkeletonBar), findsOneWidget);
expect(
find.descendant(of: shimmer, matching: find.byType(ShaderMask)),
findsNothing,
);
});
testWidgets('keeps skeleton elements static when shimmer is disabled', (
tester,
) async {
await tester.pumpWidget(buildShimmerTestable(enabled: false));
final shimmer = find.byType(SkeletonShimmer);
expect(find.byType(SkeletonBar), findsOneWidget);
expect(
find.descendant(of: shimmer, matching: find.byType(ShaderMask)),
findsNothing,
);
});
testWidgets('reveals content in the same slot with a 400ms cross-fade', (
tester,
) async {
final loading = ValueNotifier(true);
await tester.pumpWidget(buildRevealTestable(loading: loading));
expect(layerOpacity(tester, 'skeleton-reveal-placeholder'), 1);
expect(layerOpacity(tester, 'skeleton-reveal-content'), 0);
expect(contentFocusExcluded(tester), isTrue);
loading.value = false;
await tester.pump();
await tester.pump(const Duration(milliseconds: 200));
expect(
layerOpacity(tester, 'skeleton-reveal-placeholder'),
closeTo(0.5, 0.01),
);
expect(layerOpacity(tester, 'skeleton-reveal-content'), closeTo(0.5, 0.01));
expect(contentFocusExcluded(tester), isTrue);
await tester.pump(const Duration(milliseconds: 200));
expect(layerOpacity(tester, 'skeleton-reveal-placeholder'), 0);
expect(layerOpacity(tester, 'skeleton-reveal-content'), 1);
expect(contentFocusExcluded(tester), isFalse);
expect(find.text('Loaded content'), findsOneWidget);
});
testWidgets('resets to the skeleton without animating backwards', (
tester,
) async {
final loading = ValueNotifier(false);
await tester.pumpWidget(buildRevealTestable(loading: loading));
expect(layerOpacity(tester, 'skeleton-reveal-content'), 1);
loading.value = true;
await tester.pump();
expect(layerOpacity(tester, 'skeleton-reveal-placeholder'), 1);
expect(layerOpacity(tester, 'skeleton-reveal-content'), 0);
});
testWidgets('reveals immediately for reduced motion', (tester) async {
final loading = ValueNotifier(true);
await tester.pumpWidget(
buildRevealTestable(loading: loading, disableAnimations: true),
);
final reveal = find.byType(SkeletonReveal);
expect(
find.descendant(of: reveal, matching: find.byType(ShaderMask)),
findsNothing,
);
loading.value = false;
await tester.pump();
expect(layerOpacity(tester, 'skeleton-reveal-placeholder'), 0);
expect(layerOpacity(tester, 'skeleton-reveal-content'), 1);
});
}