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 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 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', ); }); }); }