Files
buzz/mobile/test/shared/widgets/skeleton_test.dart
klopez4212 46e60206a4 fix(mobile): fill and stabilize content-aware loading placeholders (#8083)
Fill mobile loading views across the screen and use known image, video,
voice-note, and file shapes in message placeholders. Freeze each loading
cycle’s layout so arriving metadata cannot reshuffle the shimmer just
before content appears; preserve cached Home and forum content during
reconnects.

Validation: mobile analysis and full test suite passed (2,752 passed, 4
skipped), plus 3 unconfigured-push tests and file-size checks. Installed
and launched the loading changes on Pixel 10 before the latest main
merge. Full `just ci` was stopped during desktop checks due to low disk
space.

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
2026-10-05 11:26:48 -04:00

273 lines
8.8 KiB
Dart

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() {
for (final reducedMotion in [false, true]) {
testWidgets(
'holds the placeholder through metadata and reveal updates (reduced motion: $reducedMotion)',
(tester) async {
final state = ValueNotifier((loading: true, media: false));
addTearDown(state.dispose);
await tester.pumpWidget(
MaterialApp(
theme: AppTheme.light(),
home: MediaQuery(
data: MediaQueryData(disableAnimations: reducedMotion),
child: Scaffold(
body: ValueListenableBuilder(
valueListenable: state,
builder: (context, value, _) => SkeletonReveal(
loading: value.loading,
skeleton: Align(
alignment: Alignment.topLeft,
child: SkeletonBar(
key: ValueKey(
value.media ? 'media-shape' : 'text-shape',
),
width: 200,
height: value.media ? 240 : 16,
),
),
content: const Text('Actual content'),
),
),
),
),
),
);
state.value = (loading: true, media: true);
await tester.pump();
expect(find.byKey(const ValueKey('text-shape')), findsOneWidget);
expect(find.byKey(const ValueKey('media-shape')), findsNothing);
state.value = (loading: false, media: true);
await tester.pump();
await tester.pump(const Duration(milliseconds: 200));
expect(find.byKey(const ValueKey('text-shape')), findsOneWidget);
expect(find.byKey(const ValueKey('media-shape')), findsNothing);
// A new loading cycle may use the now-known media layout, even if it
// interrupts an in-progress reveal.
state.value = (loading: true, media: true);
await tester.pump();
expect(find.byKey(const ValueKey('media-shape')), findsOneWidget);
expect(find.byKey(const ValueKey('text-shape')), findsNothing);
state.value = (loading: false, media: false);
await tester.pump();
await tester.pump(const Duration(milliseconds: 450));
expect(find.byKey(const ValueKey('media-shape')), findsOneWidget);
expect(
tester
.widget<Opacity>(find.byKey(const Key('skeleton-reveal-content')))
.opacity,
1,
);
},
);
}
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,
ValueChanged<bool>? onReadyChanged,
}) {
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,
onReadyChanged: onReadyChanged,
skeleton: const SkeletonBar(width: 120, height: 16),
content: const Text('Loaded content'),
),
),
),
);
}
testWidgets('reports ready only after the loaded reveal finishes', (
tester,
) async {
final loading = ValueNotifier(true);
addTearDown(loading.dispose);
final readiness = <bool>[];
await tester.pumpWidget(
buildRevealTestable(loading: loading, onReadyChanged: readiness.add),
);
expect(readiness.last, false);
loading.value = false;
await tester.pump();
await tester.pump();
await tester.pump(const Duration(milliseconds: 200));
expect(readiness.last, false);
await tester.pump(const Duration(milliseconds: 220));
expect(readiness.last, true);
loading.value = true;
await tester.pump();
expect(readiness.last, false);
});
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);
});
}