import 'dart:typed_data'; import 'dart:ui' as ui; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:doormile_cx/ui/screens/splash_screen.dart'; import 'package:doormile_cx/ui/tokens.dart'; /// ───────────────────────────────────────────────────────────────────────── /// THE SPLASH IS CENTRED /// /// ── Why this reads pixels instead of checking the layout ── /// /// The layout was never the problem. Both beats sit in a `Center`, and the /// widget tree was correct the whole time the truck was visibly 30pt right and /// 36pt low: `splash.json` is a 500x500 composition with the truck drawn low /// and to one side inside it, so a perfectly centred widget renders an /// off-centre picture. Nothing about the tree can catch that. /// /// So this renders a frame and looks for the ink. It is the only form of the /// test that would have failed before the fix and passes after it — and the /// only one that will still mean something if somebody drops in a different /// Lottie, which is exactly when `SplashScreen._truckNudge` goes stale. /// ───────────────────────────────────────────────────────────────────────── void main() { const key = ValueKey('splash-frame'); /// How far the drawn content sits from the middle of the screen. /// /// Finds the bounding box of everything that is not the background colour — /// the truck on the first beat, the mark on the last — and returns its /// centre relative to the frame's. Zero is centred. /// /// The frame's own dimensions come from the captured image rather than from /// the view: they are the same number until they are not, and a one-row /// disagreement between them is an index past the end of the buffer. Future inkOffset(WidgetTester tester) async { late Uint8List pixels; late int w; late int h; await tester.runAsync(() async { final boundary = tester.renderObject(find.byKey(key)); // At the phone's own density. The default pixelRatio of 1 rasterizes // the thin speed lines so faintly that the threshold below misses them, // which truncates the bounding box and moves the measured centre — the // first calibration of the nudge was made against exactly that error. final image = await boundary.toImage(pixelRatio: 3); w = image.width; h = image.height; final data = await image.toByteData(format: ui.ImageByteFormat.rawRgba); pixels = data!.buffer.asUint8List(); image.dispose(); }); int at(int x, int y, int c) => pixels[(y * w + x) * 4 + c]; // The ground is whatever the corner is, so this works on either beat // without being told which one it is looking at. final bg = [at(2, 2, 0), at(2, 2, 1), at(2, 2, 2)]; int? minX, maxX, minY, maxY; for (var y = 0; y < h; y++) { for (var x = 0; x < w; x++) { final delta = (at(x, y, 0) - bg[0]).abs() + (at(x, y, 1) - bg[1]).abs() + (at(x, y, 2) - bg[2]).abs(); if (delta < 40) continue; minX = minX == null || x < minX ? x : minX; maxX = maxX == null || x > maxX ? x : maxX; minY = minY == null || y < minY ? y : minY; maxY = maxY == null || y > maxY ? y : maxY; } } if (minX == null) fail('nothing drawn on the splash'); // Back to logical points, so the tolerance means the same thing here as // it does in the widget code. return Offset( ((minX + maxX!) / 2 - w / 2) / 3, ((minY! + maxY!) / 2 - h / 2) / 3, ); } /// Renders at the size the app actually ships at. /// /// The default 800x600 test window is wider and much shorter than a phone, /// and the truck is drawn 704pt tall once it is scaled — so on the default /// surface it overflows top and bottom, the capture clips it, and the /// measured centre is of a truncated shape. Measuring the real thing means /// rendering the real shape. Future pumpSplash(WidgetTester tester) async { tester.view.devicePixelRatio = 3; tester.view.physicalSize = const Size(1170, 2532); addTearDown(tester.view.reset); await tester.pumpWidget( MaterialApp( home: RepaintBoundary( key: key, child: SplashScreen(ready: true, onDone: () {}), ), ), ); // An `Image.asset` never decodes under the test binding unless the decode // is given a real async turn, and an undecoded image draws nothing — which // is indistinguishable from a mark that is missing, and was read as one. await tester.runAsync(() async { await precacheImage( const AssetImage('assets/images/doormile-mark.png'), tester.element(find.byType(MaterialApp)), ); }); } /// Advances the clock in frames rather than in one jump. /// /// The splash does not start its beats until it has walked an async chain — /// the composition parses, the engine reports a rasterized frame, a short /// delay covers the system splash's own exit. Each `await` in that chain /// needs the event loop to turn, and one enormous `pump` turns it once. Two /// of these tests were pumping four seconds in two calls and finding the /// screen exactly where it started. Future pumpFor(WidgetTester tester, int ms) async { const step = Duration(milliseconds: 100); for (var t = 0; t < ms; t += 100) { await tester.pump(step); } } /// Generous, because the truck's own ink is not symmetrical — the speed /// lines run off one side — and this is guarding against 30pt, not 3. const tolerance = 12.0; testWidgets('the truck is in the middle of the screen', (tester) async { await pumpSplash(tester); // Into the first beat: the composition has parsed and the truck is up, // and the invert has not started. await pumpFor(tester, 2500); final off = await inkOffset(tester); expect(off.dx.abs(), lessThan(tolerance), reason: 'truck is $off off'); expect(off.dy.abs(), lessThan(tolerance), reason: 'truck is $off off'); }); testWidgets('and so is the mark', (tester) async { await pumpSplash(tester); // Past the truck beat and the invert, with the entrance settled — and // before `_markHold` runs out and the splash hands over. await pumpFor(tester, 4300); final off = await inkOffset(tester); expect(off.dx.abs(), lessThan(tolerance), reason: 'mark is $off off'); expect(off.dy.abs(), lessThan(tolerance), reason: 'mark is $off off'); }); testWidgets('the ground inverts, the ink inverts with it', (tester) async { await pumpSplash(tester); await pumpFor(tester, 2500); Color ground() => tester.widget(find.byType(Scaffold)).backgroundColor!; expect(ground(), DmColors.splash, reason: 'beat 1 is crimson'); await pumpFor(tester, 1800); expect(ground(), DmColors.surface, reason: 'beat 3 is white'); }); }