Files
doormile_milderapp/test/home_header_test.dart
2026-08-28 11:13:15 +05:30

419 lines
16 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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',
);
});
});
}