import 'dart:ui'; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:rippr/src/ui/components/floating_pill.dart'; import 'package:rippr/src/ui/components/glass_panel.dart'; import 'package:rippr/src/ui/components/pulsing_location_marker.dart'; import 'package:rippr/src/ui/theme.dart'; /// UI-03: structure/colour assertions, not pixels -- no golden files, per V3-16's own /// precedent (see that ticket's Tests section for the reasoning). void main() { group('GlassPanel', () { testWidgets('renders a blurred, bordered, translucent container', (tester) async { await tester.pumpWidget(MaterialApp( theme: ripprTheme(), home: const Scaffold(body: GlassPanel(child: Text('content'))), )); expect(find.byType(BackdropFilter), findsOneWidget); expect(find.text('content'), findsOneWidget); final backdrop = tester.widget(find.byType(BackdropFilter)); expect(backdrop.filter, isA()); final box = tester.widget(find.byType(DecoratedBox).first); final decoration = box.decoration as BoxDecoration; expect(decoration.color, isNotNull); expect(decoration.color!.a, closeTo(GlassPanel.fillOpacity, 0.01), reason: 'the fill must be translucent, not a solid card'); expect(decoration.border, isNotNull); }); Future expectLegible(WidgetTester tester, ThemeData theme) async { await tester.pumpWidget(MaterialApp( theme: theme, home: const Scaffold(body: GlassPanel(child: Text('Distance'))), )); final colors = theme.colorScheme; // The actually-resolved paint colour, not the (null, since the Text above sets // no style of its own) `Text.style` -- this is what the black-on-black bug the // rest of the theme tests guard against would have shown up as. final paragraph = tester.renderObject(find.text('Distance')); final resolvedColor = paragraph.text.style?.color; expect(resolvedColor, isNotNull, reason: 'text inside a GlassPanel must not fall back to an undefined ' 'colour -- the same failure mode the ambient bodyColor fix in ' 'ripprTheme() exists to prevent'); expect(contrastRatio(resolvedColor!, colors.surface), greaterThanOrEqualTo(4.5)); } // Two separate tests, not one test looping over both themes and re-pumping into // the same tester -- pumping a second MaterialApp with an identically-shaped tree // reuses the first pump's elements rather than rebuilding from the new theme, // which silently re-asserted the first theme's already-resolved colour twice. testWidgets('is legible against the dark ground in the pocketed theme', (tester) { return expectLegible(tester, ripprTheme()); }); testWidgets('is legible against the dark ground in the mounted theme', (tester) { return expectLegible(tester, ripprMountedTheme()); }); }); group('FloatingPill', () { testWidgets('renders an arbitrary number of stat columns with dividers between ' 'them', (tester) async { await tester.pumpWidget(MaterialApp( theme: ripprTheme(), home: const Scaffold( body: FloatingPill( stats: [ PillStat(label: 'Distance', value: '12.4', unit: 'km'), PillStat(label: 'Est. Time', value: '45', unit: 'm'), PillStat(label: 'Pins', value: '5'), PillStat(label: 'Elevation', value: '210', unit: 'm'), ], ), ), )); expect(find.text('DISTANCE'), findsOneWidget); expect(find.text('EST. TIME'), findsOneWidget); expect(find.text('PINS'), findsOneWidget); expect(find.text('ELEVATION'), findsOneWidget); expect(find.text('12.4'), findsOneWidget); expect(find.text('km'), findsOneWidget); expect(find.text('5'), findsOneWidget); // n stats -> n-1 dividers, whatever n is -- not hardcoded to three. final dividers = tester .widgetList(find.byType(Container)) .where((c) => c.constraints?.maxWidth == 1) .toList(); expect(dividers.length, 3); }); testWidgets('renders correctly with a single stat and no dividers', (tester) async { await tester.pumpWidget(MaterialApp( theme: ripprTheme(), home: const Scaffold( body: FloatingPill(stats: [PillStat(label: 'Pins', value: '0')]), ), )); expect(find.text('PINS'), findsOneWidget); expect(find.text('0'), findsOneWidget); }); }); group('PulsingLocationMarker', () { testWidgets('animates continuously', (tester) async { await tester.pumpWidget(const MaterialApp( home: Scaffold(body: PulsingLocationMarker()), )); await tester.pump(const Duration(milliseconds: 500)); await tester.pump(const Duration(milliseconds: 500)); // Still ticking after a full second -- a one-shot animation would have // completed and stopped producing new frames by now. expect(tester.hasRunningAnimations, isTrue); }); testWidgets('disposes its AnimationController when removed from the tree', (tester) async { await tester.pumpWidget(const MaterialApp( home: Scaffold(body: PulsingLocationMarker()), )); await tester.pump(const Duration(milliseconds: 100)); await tester.pumpWidget(const MaterialApp(home: Scaffold(body: SizedBox()))); await tester.pumpAndSettle(); // No lingering ticker/timer -- flutter_test's own binding fails the test at // tearDown if a ticker from the removed widget is still registered. expect(tester.hasRunningAnimations, isFalse); }); testWidgets('falls back to a static dot when reduced motion is enabled', (tester) async { await tester.pumpWidget(const MediaQuery( data: MediaQueryData(disableAnimations: true), child: MaterialApp(home: Scaffold(body: PulsingLocationMarker())), )); await tester.pump(const Duration(milliseconds: 500)); expect(tester.hasRunningAnimations, isFalse); }); }); }