419 lines
16 KiB
Dart
419 lines
16 KiB
Dart
import 'package:flutter/material.dart';
|
||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||
import 'package:flutter_test/flutter_test.dart';
|
||
|
||
import 'package:miler/views/Dashboard/home/home_header.dart';
|
||
|
||
/// ─────────────────────────────────────────────────────────────────────────
|
||
/// THE HOME HEADER
|
||
///
|
||
/// Two things this file is for, and the second is the one that will save
|
||
/// somebody later.
|
||
///
|
||
/// **The budget.** The header's whole justification is that the rider's first
|
||
/// stop was below the fold. That is an arithmetic claim, so it is measured
|
||
/// here: expanded content stays inside 150–175dp, and the collapsed state is
|
||
/// 52. A header that creeps back up a line at a time is exactly how the
|
||
/// original one got to 160.
|
||
///
|
||
/// **The extent contract.** A `SliverPersistentHeader` declares a fixed height
|
||
/// and anything longer than it is *silently clipped* — no exception, no
|
||
/// overflow stripe, just a trip selector with its bottom cut off. Two of the
|
||
/// lines in here are type that grows with the system font, so the extent is
|
||
/// derived rather than hardcoded, and these tests walk it from 1.0× to 2.0× on
|
||
/// four screen widths to prove it is never short.
|
||
/// ─────────────────────────────────────────────────────────────────────────
|
||
void main() {
|
||
/// Pumps the header as the page does: a pinned sliver at the head of a
|
||
/// `CustomScrollView`, with something long under it to scroll.
|
||
Future<ScrollController> pump(
|
||
WidgetTester tester, {
|
||
String greeting = 'Good afternoon, Rajan',
|
||
String subtitle = '17 stops remaining',
|
||
String collapsed = 'Trip 1 · 17 left · 33/50',
|
||
bool onDuty = true,
|
||
bool locked = false,
|
||
bool busy = false,
|
||
VoidCallback? onToggle,
|
||
double width = 390,
|
||
double textScale = 1.0,
|
||
double topInset = 47,
|
||
}) async {
|
||
final controller = ScrollController();
|
||
addTearDown(controller.dispose);
|
||
|
||
tester.view.physicalSize = Size(width * 3, 844 * 3);
|
||
tester.view.devicePixelRatio = 3.0;
|
||
addTearDown(tester.view.reset);
|
||
|
||
await tester.pumpWidget(
|
||
ScreenUtilInit(
|
||
designSize: const Size(390, 844),
|
||
builder: (_, _) => MaterialApp(
|
||
debugShowCheckedModeBanner: false,
|
||
home: MediaQuery(
|
||
data: MediaQueryData(
|
||
size: Size(width, 844),
|
||
padding: EdgeInsets.only(top: topInset),
|
||
textScaler: TextScaler.linear(textScale),
|
||
),
|
||
child: Builder(
|
||
builder: (context) => Scaffold(
|
||
body: CustomScrollView(
|
||
controller: controller,
|
||
slivers: [
|
||
SliverPersistentHeader(
|
||
pinned: true,
|
||
delegate: HomeHeaderDelegate(
|
||
topInset: topInset,
|
||
expandedContent: HomeHeaderDelegate.expandedContentFor(
|
||
context,
|
||
),
|
||
greeting: greeting,
|
||
subtitle: subtitle,
|
||
onDuty: onDuty,
|
||
dutyLocked: locked,
|
||
dutyBusy: busy,
|
||
onToggleDuty: onToggle ?? () {},
|
||
collapsedSummary: collapsed,
|
||
),
|
||
),
|
||
SliverList.list(
|
||
children: [
|
||
for (var i = 0; i < 40; i++)
|
||
SizedBox(height: 60, child: Text('row $i')),
|
||
],
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
);
|
||
await tester.pump();
|
||
return controller;
|
||
}
|
||
|
||
group('1 · the height budget', () {
|
||
testWidgets('expanded content is inside 95–120dp', (tester) async {
|
||
late double content;
|
||
tester.view.physicalSize = const Size(390 * 3, 844 * 3);
|
||
tester.view.devicePixelRatio = 3.0;
|
||
addTearDown(tester.view.reset);
|
||
|
||
await tester.pumpWidget(
|
||
ScreenUtilInit(
|
||
designSize: const Size(390, 844),
|
||
builder: (_, _) => MaterialApp(
|
||
home: Builder(
|
||
builder: (context) {
|
||
content = HomeHeaderDelegate.expandedContentFor(context);
|
||
return const SizedBox();
|
||
},
|
||
),
|
||
),
|
||
),
|
||
);
|
||
|
||
// ── The budget moved when the tabs did ──
|
||
//
|
||
// It was 150–175, for a header that carried the trip selector. The
|
||
// selector sits on the page under it now, so what is left up here is a
|
||
// wordmark row, a greeting and one operational line — and the budget is
|
||
// the sum of those rather than a number inherited from the old shape.
|
||
//
|
||
// The tabs are still charged, just not to this. Header + tabs together
|
||
// are about 124 + 46 + their padding, against the 160 the fixed block
|
||
// cost before — and unlike that block, 72 of it scrolls away.
|
||
// ── The budget moved again when the duplicate line went ──
|
||
//
|
||
// It was 110–135 with `N stops remaining` under the greeting. That line
|
||
// was the brief card's headline said twice, forty points apart, smaller
|
||
// and greyer the first time — so it is gone and the header is ~20 points
|
||
// cheaper. Every one of those points is the rider's first stop moving up
|
||
// the screen, which is the whole reason this test exists.
|
||
expect(
|
||
content,
|
||
inInclusiveRange(95, 120),
|
||
reason:
|
||
'measured $content — the header exists to get the first stop above '
|
||
'the fold, and every point it grows takes one back',
|
||
);
|
||
});
|
||
|
||
testWidgets('collapsed content is 52dp', (tester) async {
|
||
tester.view.physicalSize = const Size(390 * 3, 844 * 3);
|
||
tester.view.devicePixelRatio = 3.0;
|
||
addTearDown(tester.view.reset);
|
||
await tester.pumpWidget(
|
||
ScreenUtilInit(
|
||
designSize: const Size(390, 844),
|
||
builder: (_, _) => const MaterialApp(home: SizedBox()),
|
||
),
|
||
);
|
||
expect(HomeHeaderDelegate.collapsedContent, closeTo(52, 0.5));
|
||
});
|
||
});
|
||
|
||
group('2 · the extent is never short', () {
|
||
for (final width in const [320.0, 360.0, 390.0, 430.0]) {
|
||
for (final scale in const [1.0, 1.3, 1.5, 2.0]) {
|
||
testWidgets('${width.toInt()}dp at ${scale}x', (tester) async {
|
||
await pump(tester, width: width, textScale: scale);
|
||
// A clipped sliver throws nothing, so the assertion that matters is
|
||
// that the layout itself did not complain — an overflowing Column
|
||
// inside the header WOULD throw, and that is the failure mode these
|
||
// widths and scales are hunting.
|
||
expect(tester.takeException(), isNull);
|
||
expect(find.text('ON'), findsOneWidget);
|
||
});
|
||
}
|
||
}
|
||
|
||
testWidgets('a long rider name does not push anything out', (tester) async {
|
||
await pump(
|
||
tester,
|
||
width: 320,
|
||
greeting: 'Good afternoon, Venkateswara Subramaniam',
|
||
);
|
||
expect(tester.takeException(), isNull);
|
||
// The greeting ellipsises to one line rather than wrapping and pushing
|
||
// the header past its declared extent.
|
||
expect(find.text('Good afternoon, Venkateswara Subramaniam'),
|
||
findsOneWidget);
|
||
});
|
||
});
|
||
|
||
group('3 · what each state says', () {
|
||
testWidgets('off duty reads OFF and keeps the same shape', (tester) async {
|
||
await pump(tester, onDuty: false);
|
||
expect(find.text('OFF'), findsOneWidget);
|
||
expect(find.text('ON'), findsNothing);
|
||
});
|
||
|
||
testWidgets('the stop count is NOT repeated up here', (tester) async {
|
||
// ── It was said twice, forty points apart ──
|
||
//
|
||
// `17 stops remaining` sat under the greeting in grey 13pt, and the
|
||
// brief card immediately below leads with `17 stops left` as its
|
||
// headline at 17pt against a progress bar. Two sizes, two wordings, one
|
||
// figure — and the smaller, greyer, vaguer one came first.
|
||
//
|
||
// The header carries identity and duty. The count belongs to the card
|
||
// that also shows the duration, distance and parcels behind it.
|
||
await pump(tester);
|
||
expect(find.text('Good afternoon, Rajan'), findsOneWidget);
|
||
expect(find.text('17 stops remaining'), findsNothing);
|
||
expect(find.textContaining('stops remaining'), findsNothing);
|
||
});
|
||
|
||
testWidgets('but the collapsed line still carries it', (tester) async {
|
||
// The one place the figure is still the header's job: collapsed, the
|
||
// brief card has scrolled away and this is all that is left on screen.
|
||
final c = await pump(tester);
|
||
c.jumpTo(400);
|
||
await tester.pump();
|
||
await tester.pump(const Duration(milliseconds: 300));
|
||
expect(find.text('Trip 1 · 17 left · 33/50'), findsOneWidget);
|
||
});
|
||
});
|
||
|
||
group('4 · duty', () {
|
||
testWidgets('the tap target clears 48dp even though the pill is smaller', (
|
||
tester,
|
||
) async {
|
||
await pump(tester);
|
||
final target = tester.getSize(find.byType(DutyPill));
|
||
expect(target.height, greaterThanOrEqualTo(DutyPill.tapTarget));
|
||
});
|
||
|
||
testWidgets('tapping asks the page, it does not decide', (tester) async {
|
||
var asked = 0;
|
||
await pump(tester, onToggle: () => asked++);
|
||
await tester.tap(find.byType(DutyPill));
|
||
expect(asked, 1);
|
||
});
|
||
|
||
testWidgets('a write in flight refuses a second press', (tester) async {
|
||
var asked = 0;
|
||
await pump(tester, busy: true, onToggle: () => asked++);
|
||
await tester.tap(find.byType(DutyPill), warnIfMissed: false);
|
||
expect(asked, 0, reason: 'two duty writes for one intent');
|
||
expect(find.byType(CircularProgressIndicator), findsOneWidget);
|
||
});
|
||
|
||
testWidgets('locked mid-pickup refuses, and says so', (tester) async {
|
||
var asked = 0;
|
||
await pump(tester, locked: true, onToggle: () => asked++);
|
||
await tester.tap(find.byType(DutyPill), warnIfMissed: false);
|
||
expect(asked, 0);
|
||
// The state is still readable — the control dims rather than vanishing.
|
||
expect(find.text('ON'), findsOneWidget);
|
||
});
|
||
});
|
||
|
||
group('5 · collapsing', () {
|
||
testWidgets('the greeting goes and the operational line arrives', (
|
||
tester,
|
||
) async {
|
||
final c = await pump(tester);
|
||
|
||
expect(find.text('Good afternoon, Rajan'), findsOneWidget);
|
||
expect(find.text('Trip 1 · 17 left · 33/50'), findsNothing);
|
||
|
||
c.jumpTo(400);
|
||
await tester.pump();
|
||
await tester.pump(const Duration(milliseconds: 300));
|
||
|
||
// Each layout is dropped entirely once its side of the cross-fade
|
||
// reaches zero, rather than kept at `Opacity(0)`. A fully transparent
|
||
// subtree still lays out and still paints into a saved layer on every
|
||
// frame of a scroll, and this one holds a wordmark, a greeting and a
|
||
// duty control — which is a lot of nothing to composite forty times a
|
||
// second.
|
||
expect(find.text('Good afternoon, Rajan'), findsNothing);
|
||
expect(find.text('Trip 1 · 17 left · 33/50'), findsOneWidget);
|
||
});
|
||
|
||
testWidgets('duty stays reachable in the collapsed state', (tester) async {
|
||
var asked = 0;
|
||
final c = await pump(tester, onToggle: () => asked++);
|
||
c.jumpTo(400);
|
||
await tester.pump();
|
||
await tester.pump(const Duration(milliseconds: 300));
|
||
|
||
// The expanded copy is gone by now, so this can only be the collapsed
|
||
// one — which is the point: duty is the one control that must never
|
||
// scroll away.
|
||
expect(find.byType(DutyPill), findsOneWidget);
|
||
await tester.tap(find.byType(DutyPill));
|
||
expect(asked, 1);
|
||
});
|
||
|
||
testWidgets('the header holds the top of the screen', (tester) async {
|
||
final c = await pump(tester);
|
||
c.jumpTo(600);
|
||
await tester.pump();
|
||
|
||
// The pinned extent is exactly the collapsed one plus the inset.
|
||
final material = tester.getSize(
|
||
find.byKey(HomeHeaderDelegate.surfaceKey),
|
||
);
|
||
expect(
|
||
material.height,
|
||
closeTo(47 + HomeHeaderDelegate.collapsedContent, 1),
|
||
);
|
||
});
|
||
});
|
||
|
||
group('6 · rebuild scope', () {
|
||
test('an unrelated change does not rebuild the header', () {
|
||
HomeHeaderDelegate make({String subtitle = '17 stops remaining'}) =>
|
||
HomeHeaderDelegate(
|
||
topInset: 47,
|
||
expandedContent: 170,
|
||
greeting: 'Good afternoon, Rajan',
|
||
subtitle: subtitle,
|
||
onDuty: true,
|
||
dutyLocked: false,
|
||
dutyBusy: false,
|
||
onToggleDuty: () {},
|
||
collapsedSummary: 'Trip 1 · 17 left · 33/50',
|
||
);
|
||
|
||
// Same inputs — a row further down the list changing touches none of
|
||
// them, so the sliver does not repaint for it.
|
||
expect(make().shouldRebuild(make()), isFalse);
|
||
// A figure it actually draws does.
|
||
expect(
|
||
make().shouldRebuild(make(subtitle: '16 stops remaining')),
|
||
isTrue,
|
||
);
|
||
});
|
||
});
|
||
|
||
// ══════════════════════════════════════════════════════════════════════
|
||
// THE COLLAPSE ITSELF
|
||
//
|
||
// The budget tests above measure the header at its two REST states. The bug
|
||
// this group exists for lived entirely in between them.
|
||
//
|
||
// `_ExpandedBody` was a plain child of a `StackFit.expand` stack, so it was
|
||
// handed the header's *current* height — which, mid-collapse, is less than
|
||
// the content. Its Column cannot compress, so from the first pixel of scroll
|
||
// Flutter drew `BOTTOM OVERFLOWED BY 28 PIXELS` in yellow and black across
|
||
// the top of Home, over the greeting, on a rider's phone.
|
||
//
|
||
// Neither rest state shows it: fully expanded there is room, fully collapsed
|
||
// the body has faded to nothing. Only the travel between them does — which
|
||
// is why every assertion below walks the scroll rather than sampling its
|
||
// ends.
|
||
// ══════════════════════════════════════════════════════════════════════
|
||
group('7 · nothing overflows while it collapses', () {
|
||
/// Every offset from rest to fully pinned, finely enough that no fade
|
||
/// threshold (0.55 / 0.6) can be stepped over.
|
||
Future<void> walk(
|
||
WidgetTester tester,
|
||
ScrollController controller,
|
||
double range,
|
||
) async {
|
||
for (var i = 0; i <= 40; i++) {
|
||
controller.jumpTo(range * i / 40);
|
||
await tester.pump();
|
||
expect(
|
||
tester.takeException(),
|
||
isNull,
|
||
reason:
|
||
'overflowed at scroll offset ${range * i / 40} of $range — the '
|
||
'header must clip its content, never squeeze it',
|
||
);
|
||
}
|
||
}
|
||
|
||
testWidgets('at 1.0x on a 390pt phone', (tester) async {
|
||
final c = await pump(tester);
|
||
await walk(tester, c, 200);
|
||
});
|
||
|
||
testWidgets('at every text scale a rider can set', (tester) async {
|
||
for (final scale in const [1.0, 1.3, 1.5, 2.0]) {
|
||
final c = await pump(tester, textScale: scale);
|
||
await walk(tester, c, 200);
|
||
}
|
||
});
|
||
|
||
testWidgets('on a narrow phone, where the greeting wraps', (tester) async {
|
||
// 320pt with a long name is where the greeting is most likely to take a
|
||
// second line and push the Column past the extent.
|
||
final c = await pump(
|
||
tester,
|
||
width: 320,
|
||
greeting: 'Good afternoon, Rajanikanth Subramaniam',
|
||
subtitle: '20 stops remaining',
|
||
);
|
||
await walk(tester, c, 200);
|
||
});
|
||
|
||
testWidgets('the expanded body keeps its full height as it goes', (
|
||
tester,
|
||
) async {
|
||
// The fix, stated as behaviour rather than as a widget choice: the
|
||
// greeting does not shrink on the way out, it slides out. If it ever
|
||
// reflows again, this catches it before the overflow banner does.
|
||
final c = await pump(tester);
|
||
final full = tester.getSize(find.text('Good afternoon, Rajan')).height;
|
||
|
||
c.jumpTo(30);
|
||
await tester.pump();
|
||
expect(tester.takeException(), isNull);
|
||
expect(
|
||
tester.getSize(find.text('Good afternoon, Rajan')).height,
|
||
full,
|
||
reason: 'the body is measured at its own height, not the header\'s',
|
||
);
|
||
});
|
||
});
|
||
}
|