Refresh the Flutter snapshot: UI-01 through UI-09, plus a fresh installable APK
Full UI redesign pass complete: persistent tab shell with an always-visible background map, Modern Professional Dark theme, monochrome dark map tiles, offline skeleton map, a shared GlassPanel/FloatingPill component kit, customizable HUD telemetry widgets, and the Map HUD / Plan & Route Planning / Rides History screen rebuilds. 374 tests passing, up from 316. The APK is a fresh release build (debug-signed, no release signing config exists yet). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012Xki7YAcc2TiN2PRZJ2tXr
This commit is contained in:
@@ -1,6 +1,8 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:rippr/src/config/config.dart';
|
||||
import 'package:rippr/src/domain/models.dart';
|
||||
import 'package:rippr/src/hud/hud_metric.dart';
|
||||
import 'package:rippr/src/hud/hud_widget_layout.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
/// `Config` had no dedicated tests before V3-03 — every existing widget test leaves
|
||||
@@ -81,6 +83,57 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
group('hudLayout (UI-04)', () {
|
||||
test('a fresh install gets a default layout for every metric', () async {
|
||||
final config = await freshConfig();
|
||||
final layout = config.hudLayout;
|
||||
expect(layout.keys.toSet(), HudMetric.values.toSet());
|
||||
});
|
||||
|
||||
test('round-trips an edited layout through set/get', () async {
|
||||
final config = await freshConfig();
|
||||
final edited = {
|
||||
...config.hudLayout,
|
||||
HudMetric.speed: const HudWidgetLayout(
|
||||
metric: HudMetric.speed,
|
||||
x: 0.33,
|
||||
y: 0.44,
|
||||
width: 0.25,
|
||||
height: 0.1,
|
||||
visible: true,
|
||||
),
|
||||
};
|
||||
|
||||
await config.setHudLayout(edited);
|
||||
final reloaded = config.hudLayout;
|
||||
|
||||
expect(reloaded[HudMetric.speed]!.x, 0.33);
|
||||
expect(reloaded[HudMetric.speed]!.y, 0.44);
|
||||
});
|
||||
|
||||
test('a metric missing from a saved layout (e.g. one added in a later app '
|
||||
'version) falls back to its default rather than being omitted', () async {
|
||||
SharedPreferences.setMockInitialValues({
|
||||
'hud_layout': '{"speed": {"x": 0.1, "y": 0.1, "width": 0.2, "height": 0.1, '
|
||||
'"visible": true}}',
|
||||
});
|
||||
final config = await Config.load();
|
||||
final layout = config.hudLayout;
|
||||
|
||||
expect(layout.keys.toSet(), HudMetric.values.toSet());
|
||||
final expectedDefault = HudWidgetLayout.defaultFor(HudMetric.distance);
|
||||
expect(layout[HudMetric.distance]!.x, expectedDefault.x);
|
||||
expect(layout[HudMetric.distance]!.visible, expectedDefault.visible);
|
||||
});
|
||||
|
||||
test('corrupted stored JSON falls back to defaults rather than crashing',
|
||||
() async {
|
||||
SharedPreferences.setMockInitialValues({'hud_layout': 'not json at all'});
|
||||
final config = await Config.load();
|
||||
expect(config.hudLayout.keys.toSet(), HudMetric.values.toSet());
|
||||
});
|
||||
});
|
||||
|
||||
group('deviceId', () {
|
||||
test('is generated once and then stable across reads', () async {
|
||||
final config = await freshConfig();
|
||||
|
||||
152
rippr-flutter-src/test/glass_component_kit_test.dart
Normal file
152
rippr-flutter-src/test/glass_component_kit_test.dart
Normal file
@@ -0,0 +1,152 @@
|
||||
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<BackdropFilter>(find.byType(BackdropFilter));
|
||||
expect(backdrop.filter, isA<ImageFilter>());
|
||||
|
||||
final box = tester.widget<DecoratedBox>(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<void> 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<RenderParagraph>(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<Container>(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);
|
||||
});
|
||||
});
|
||||
}
|
||||
113
rippr-flutter-src/test/hud_edit_overlay_test.dart
Normal file
113
rippr-flutter-src/test/hud_edit_overlay_test.dart
Normal file
@@ -0,0 +1,113 @@
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:rippr/src/app/providers.dart';
|
||||
import 'package:rippr/src/config/config.dart';
|
||||
import 'package:rippr/src/hud/hud_metric.dart';
|
||||
import 'package:rippr/src/ui/components/hud_edit_overlay.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
/// UI-04: drag/toggle behaviour of the customizable HUD, simulated via `TestGesture`
|
||||
/// the same way other drag interactions in this codebase are tested (see
|
||||
/// `route_planner_screen_test.dart`'s waypoint-drag coverage).
|
||||
void main() {
|
||||
late Config config;
|
||||
|
||||
setUp(() async {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
config = await Config.load();
|
||||
});
|
||||
|
||||
Widget host() => ProviderScope(
|
||||
overrides: [configProvider.overrideWith((ref) => config)],
|
||||
child: MaterialApp(
|
||||
home: Scaffold(
|
||||
body: SizedBox.expand(
|
||||
child: HudEditOverlay(
|
||||
metricBuilder: (context, metric) => Text(metric.label),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
testWidgets('a tap outside edit mode does not move a widget', (tester) async {
|
||||
await tester.pumpWidget(host());
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byKey(const Key('hud-edit-done')), findsNothing,
|
||||
reason: 'not editing yet');
|
||||
|
||||
await tester.tap(find.text('Speed'));
|
||||
await tester.pump();
|
||||
|
||||
expect(find.byKey(const Key('hud-edit-done')), findsNothing,
|
||||
reason: 'a plain tap on a widget must never enter edit mode or move it');
|
||||
});
|
||||
|
||||
testWidgets('a long-press on empty space enters edit mode, showing resize handles '
|
||||
'and Done', (tester) async {
|
||||
await tester.pumpWidget(host());
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.longPress(find.byKey(const Key('hud-edit-background')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byKey(const Key('hud-edit-done')), findsOneWidget);
|
||||
expect(find.byKey(const Key('hud-resize-handle')), findsWidgets);
|
||||
});
|
||||
|
||||
testWidgets('a long-press-and-drag on a widget moves it, and Done persists the new '
|
||||
'position', (tester) async {
|
||||
await tester.pumpWidget(host());
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.longPress(find.byKey(const Key('hud-edit-background')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final before = config.hudLayout[HudMetric.speed]!;
|
||||
|
||||
final speedFinder = find.text('Speed');
|
||||
final gesture = await tester.startGesture(tester.getCenter(speedFinder));
|
||||
await tester.pump(kLongPressTimeout + kPressTimeout);
|
||||
await gesture.moveBy(const Offset(40, 60));
|
||||
await tester.pump();
|
||||
await gesture.up();
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.byKey(const Key('hud-edit-done')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final after = config.hudLayout[HudMetric.speed]!;
|
||||
expect(after.x, isNot(before.x));
|
||||
expect(after.y, isNot(before.y));
|
||||
});
|
||||
|
||||
testWidgets('a drag while not editing does not move the widget', (tester) async {
|
||||
await tester.pumpWidget(host());
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final before = config.hudLayout[HudMetric.speed]!;
|
||||
final speedFinder = find.text('Speed');
|
||||
|
||||
final gesture = await tester.startGesture(tester.getCenter(speedFinder));
|
||||
await tester.pump(kLongPressTimeout + kPressTimeout);
|
||||
await gesture.moveBy(const Offset(40, 60));
|
||||
await gesture.up();
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byKey(const Key('hud-edit-done')), findsNothing);
|
||||
final after = config.hudLayout[HudMetric.speed]!;
|
||||
expect(after.x, before.x);
|
||||
expect(after.y, before.y);
|
||||
});
|
||||
|
||||
testWidgets('only visible metrics render a widget', (tester) async {
|
||||
await tester.pumpWidget(host());
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Speed'), findsOneWidget, reason: 'visible by default');
|
||||
expect(find.text('Max speed'), findsNothing, reason: 'hidden by default');
|
||||
});
|
||||
}
|
||||
71
rippr-flutter-src/test/hud_layout_controller_test.dart
Normal file
71
rippr-flutter-src/test/hud_layout_controller_test.dart
Normal file
@@ -0,0 +1,71 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:rippr/src/config/config.dart';
|
||||
import 'package:rippr/src/hud/hud_layout_controller.dart';
|
||||
import 'package:rippr/src/hud/hud_metric.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
void main() {
|
||||
Future<Config> freshConfig() async {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
return Config.load();
|
||||
}
|
||||
|
||||
group('HudLayoutController', () {
|
||||
test('updatePosition updates only the given metric, clamped', () async {
|
||||
final controller = HudLayoutController(await freshConfig());
|
||||
final before = controller.state[HudMetric.distance]!;
|
||||
|
||||
controller.updatePosition(HudMetric.speed, 5.0, 5.0);
|
||||
|
||||
expect(controller.state[HudMetric.speed]!.x, lessThanOrEqualTo(1.0));
|
||||
expect(controller.state[HudMetric.distance]!.x, before.x,
|
||||
reason: 'moving one metric must not disturb another');
|
||||
});
|
||||
|
||||
test('updateSize clamps to the legibility floor/ceiling', () async {
|
||||
final controller = HudLayoutController(await freshConfig());
|
||||
|
||||
controller.updateSize(HudMetric.speed, 0.0, 0.0);
|
||||
expect(controller.state[HudMetric.speed]!.width, greaterThan(0.0));
|
||||
expect(controller.state[HudMetric.speed]!.height, greaterThan(0.0));
|
||||
});
|
||||
|
||||
test('setVisible(false) then setVisible(true) restores the last saved position, '
|
||||
'not a fresh default', () async {
|
||||
final controller = HudLayoutController(await freshConfig());
|
||||
controller.updatePosition(HudMetric.avgSpeed, 0.6, 0.6);
|
||||
final movedX = controller.state[HudMetric.avgSpeed]!.x;
|
||||
|
||||
controller.setVisible(HudMetric.avgSpeed, false);
|
||||
expect(controller.state[HudMetric.avgSpeed]!.visible, isFalse);
|
||||
|
||||
controller.setVisible(HudMetric.avgSpeed, true);
|
||||
expect(controller.state[HudMetric.avgSpeed]!.visible, isTrue);
|
||||
expect(controller.state[HudMetric.avgSpeed]!.x, movedX,
|
||||
reason: 'a metric with a real saved position keeps it when re-enabled');
|
||||
});
|
||||
|
||||
test('persist writes the current state to Config', () async {
|
||||
final config = await freshConfig();
|
||||
final controller = HudLayoutController(config);
|
||||
controller.updatePosition(HudMetric.speed, 0.4, 0.4);
|
||||
|
||||
await controller.persist();
|
||||
|
||||
final reloaded = config.hudLayout;
|
||||
expect(reloaded[HudMetric.speed]!.x, 0.4);
|
||||
});
|
||||
|
||||
test('edits before persist are not visible to a fresh read of Config', () async {
|
||||
final config = await freshConfig();
|
||||
final controller = HudLayoutController(config);
|
||||
controller.updatePosition(HudMetric.speed, 0.4, 0.4);
|
||||
|
||||
// No persist() call yet.
|
||||
final reloaded = config.hudLayout;
|
||||
expect(reloaded[HudMetric.speed]!.x, isNot(0.4),
|
||||
reason: 'a drag in progress must only touch in-memory state -- '
|
||||
'persisting every frame is the ticket\'s own named risk');
|
||||
});
|
||||
});
|
||||
}
|
||||
128
rippr-flutter-src/test/hud_widget_layout_test.dart
Normal file
128
rippr-flutter-src/test/hud_widget_layout_test.dart
Normal file
@@ -0,0 +1,128 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:rippr/src/hud/hud_metric.dart';
|
||||
import 'package:rippr/src/hud/hud_widget_layout.dart';
|
||||
|
||||
/// UI-04: the JSON round-trip and clamp math are exercised directly with fixed inputs
|
||||
/// -- no real gestures needed to test either, per the ticket's own Tests section.
|
||||
void main() {
|
||||
group('JSON round trip', () {
|
||||
test('encodes and decodes exactly', () {
|
||||
const original = HudWidgetLayout(
|
||||
metric: HudMetric.speed,
|
||||
x: 0.1,
|
||||
y: 0.2,
|
||||
width: 0.3,
|
||||
height: 0.15,
|
||||
visible: true,
|
||||
);
|
||||
|
||||
final decoded = HudWidgetLayout.fromJson(HudMetric.speed, original.toJson());
|
||||
|
||||
expect(decoded.x, original.x);
|
||||
expect(decoded.y, original.y);
|
||||
expect(decoded.width, original.width);
|
||||
expect(decoded.height, original.height);
|
||||
expect(decoded.visible, original.visible);
|
||||
});
|
||||
|
||||
test('a malformed entry falls back to the default rather than crashing', () {
|
||||
final decoded = HudWidgetLayout.fromJson(HudMetric.distance, {'x': 'not a number'});
|
||||
final default_ = HudWidgetLayout.defaultFor(HudMetric.distance);
|
||||
|
||||
expect(decoded.x, default_.x);
|
||||
expect(decoded.width, default_.width);
|
||||
});
|
||||
|
||||
test('a missing field falls back to the default rather than crashing', () {
|
||||
final decoded = HudWidgetLayout.fromJson(HudMetric.maxSpeed, {'x': 0.1, 'y': 0.1});
|
||||
final default_ = HudWidgetLayout.defaultFor(HudMetric.maxSpeed);
|
||||
|
||||
expect(decoded.x, default_.x);
|
||||
});
|
||||
});
|
||||
|
||||
group('defaultFor', () {
|
||||
test('every metric gets a distinct default position', () {
|
||||
final positions = HudMetric.values
|
||||
.map(HudWidgetLayout.defaultFor)
|
||||
.map((l) => '${l.x},${l.y}')
|
||||
.toSet();
|
||||
expect(positions.length, HudMetric.values.length,
|
||||
reason: 'no two metrics should default to overlapping positions');
|
||||
});
|
||||
|
||||
test('the first four metrics (the Map HUD mockup\'s fixed row: Speed/Avg Speed/'
|
||||
'Dist/Time) start visible', () {
|
||||
expect(HudWidgetLayout.defaultFor(HudMetric.speed).visible, isTrue);
|
||||
expect(HudWidgetLayout.defaultFor(HudMetric.avgSpeed).visible, isTrue);
|
||||
expect(HudWidgetLayout.defaultFor(HudMetric.distance).visible, isTrue);
|
||||
expect(HudWidgetLayout.defaultFor(HudMetric.elapsedTime).visible, isTrue);
|
||||
});
|
||||
|
||||
test('metrics beyond the fixed row start hidden', () {
|
||||
expect(HudWidgetLayout.defaultFor(HudMetric.maxSpeed).visible, isFalse);
|
||||
expect(HudWidgetLayout.defaultFor(HudMetric.elevationGain).visible, isFalse);
|
||||
});
|
||||
|
||||
test('every default is already within the valid clamp bounds', () {
|
||||
for (final metric in HudMetric.values) {
|
||||
final layout = HudWidgetLayout.defaultFor(metric);
|
||||
expect(layout.clamped().x, layout.x);
|
||||
expect(layout.clamped().y, layout.y);
|
||||
expect(layout.clamped().width, layout.width);
|
||||
expect(layout.clamped().height, layout.height);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
group('clamped', () {
|
||||
const base = HudWidgetLayout(
|
||||
metric: HudMetric.speed,
|
||||
x: 0.5,
|
||||
y: 0.5,
|
||||
width: 0.3,
|
||||
height: 0.15,
|
||||
visible: true,
|
||||
);
|
||||
|
||||
test('a position dragged past the right/bottom edge is pulled back on-screen', () {
|
||||
final result = base.copyWith(x: 1.5, y: 1.5).clamped();
|
||||
expect(result.x, 1.0 - base.width);
|
||||
expect(result.y, 1.0 - base.height);
|
||||
});
|
||||
|
||||
test('a position dragged past the left/top edge is pulled back on-screen', () {
|
||||
final result = base.copyWith(x: -0.5, y: -0.5).clamped();
|
||||
expect(result.x, 0.0);
|
||||
expect(result.y, 0.0);
|
||||
});
|
||||
|
||||
test('a resize below the legibility floor is corrected up to the minimum', () {
|
||||
final result = base.copyWith(width: 0.01, height: 0.01).clamped();
|
||||
expect(result.width, hudMinWidthFraction);
|
||||
expect(result.height, hudMinHeightFraction);
|
||||
});
|
||||
|
||||
test('a resize above the sane ceiling is corrected down to the maximum', () {
|
||||
final result = base.copyWith(width: 5.0, height: 5.0).clamped();
|
||||
expect(result.width, hudMaxWidthFraction);
|
||||
expect(result.height, hudMaxHeightFraction);
|
||||
});
|
||||
|
||||
test('shrinking to fit happens before repositioning, so a widget resized past '
|
||||
'the edge shrinks rather than silently relocates', () {
|
||||
final result = base.copyWith(x: 0.9, width: 0.7).clamped();
|
||||
expect(result.width, hudMaxWidthFraction);
|
||||
// x itself is still clamped against the (now-bounded) width so the widget
|
||||
// never sits partially off-screen either.
|
||||
expect(result.x + result.width, lessThanOrEqualTo(1.0));
|
||||
});
|
||||
|
||||
test('an already-valid layout is unchanged', () {
|
||||
expect(base.clamped().x, base.x);
|
||||
expect(base.clamped().y, base.y);
|
||||
expect(base.clamped().width, base.width);
|
||||
expect(base.clamped().height, base.height);
|
||||
});
|
||||
});
|
||||
}
|
||||
116
rippr-flutter-src/test/map_connectivity_test.dart
Normal file
116
rippr-flutter-src/test/map_connectivity_test.dart
Normal file
@@ -0,0 +1,116 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:rippr/src/tiles/map_connectivity.dart';
|
||||
|
||||
/// UI-02: exercises the failure-count/threshold/probe logic with a fake probe -- no
|
||||
/// real network -- mirroring V3-11's `tile_downloader_test.dart` pattern of injecting
|
||||
/// fake fetch outcomes rather than hitting a real tile host from a test.
|
||||
void main() {
|
||||
late bool probeResult;
|
||||
late int probeCalls;
|
||||
late MapConnectivityState state;
|
||||
|
||||
setUp(() {
|
||||
probeResult = false;
|
||||
probeCalls = 0;
|
||||
state = MapConnectivityState(
|
||||
probe: () async {
|
||||
probeCalls++;
|
||||
return probeResult;
|
||||
},
|
||||
probeInterval: const Duration(milliseconds: 20),
|
||||
);
|
||||
});
|
||||
|
||||
tearDown(() {
|
||||
state.dispose();
|
||||
});
|
||||
|
||||
test('starts in live mode', () {
|
||||
expect(state.skeletonMode, isFalse);
|
||||
});
|
||||
|
||||
test('fewer than the threshold of failures stays live', () {
|
||||
state.reportFailure();
|
||||
state.reportFailure();
|
||||
expect(state.skeletonMode, isFalse);
|
||||
});
|
||||
|
||||
test('reaching the threshold of consecutive failures switches to skeleton mode', () {
|
||||
for (var i = 0; i < skeletonFailureThreshold; i++) {
|
||||
state.reportFailure();
|
||||
}
|
||||
expect(state.skeletonMode, isTrue);
|
||||
});
|
||||
|
||||
test('a success before the threshold resets the failure count', () {
|
||||
state.reportFailure();
|
||||
state.reportFailure();
|
||||
state.reportSuccess();
|
||||
state.reportFailure();
|
||||
state.reportFailure();
|
||||
expect(state.skeletonMode, isFalse,
|
||||
reason: 'the success should have reset the run -- two more failures is not '
|
||||
'the same as four in a row');
|
||||
});
|
||||
|
||||
test('notifies listeners exactly when skeletonMode actually changes', () {
|
||||
var notifications = 0;
|
||||
state.addListener(() => notifications++);
|
||||
|
||||
state.reportFailure();
|
||||
state.reportFailure();
|
||||
expect(notifications, 0, reason: 'still below threshold -- no state change yet');
|
||||
|
||||
state.reportFailure();
|
||||
expect(notifications, 1);
|
||||
expect(state.skeletonMode, isTrue);
|
||||
});
|
||||
|
||||
test('a single success while already in skeleton mode recovers immediately', () {
|
||||
for (var i = 0; i < skeletonFailureThreshold; i++) {
|
||||
state.reportFailure();
|
||||
}
|
||||
expect(state.skeletonMode, isTrue);
|
||||
|
||||
state.reportSuccess();
|
||||
expect(state.skeletonMode, isFalse);
|
||||
});
|
||||
|
||||
test('further failures are ignored once already in skeleton mode', () {
|
||||
for (var i = 0; i < skeletonFailureThreshold; i++) {
|
||||
state.reportFailure();
|
||||
}
|
||||
var notifications = 0;
|
||||
state.addListener(() => notifications++);
|
||||
|
||||
// Recovery is the probe loop's job from here -- more failure reports (e.g. from a
|
||||
// TileLayer that hadn't yet unmounted) must not do anything further.
|
||||
state.reportFailure();
|
||||
state.reportFailure();
|
||||
|
||||
expect(notifications, 0);
|
||||
expect(state.skeletonMode, isTrue);
|
||||
});
|
||||
|
||||
test('the probe loop recovers automatically once it starts succeeding', () async {
|
||||
for (var i = 0; i < skeletonFailureThreshold; i++) {
|
||||
state.reportFailure();
|
||||
}
|
||||
expect(state.skeletonMode, isTrue);
|
||||
|
||||
probeResult = true;
|
||||
// Give the periodic probe timer a couple of intervals to fire.
|
||||
await Future<void>.delayed(const Duration(milliseconds: 60));
|
||||
|
||||
expect(state.skeletonMode, isFalse);
|
||||
expect(probeCalls, greaterThan(0));
|
||||
});
|
||||
|
||||
test('the probe loop does not run while already live', () async {
|
||||
await Future<void>.delayed(const Duration(milliseconds: 60));
|
||||
expect(probeCalls, 0,
|
||||
reason: 'a TileLayer that is live and working generates its own fetch '
|
||||
'reports -- a periodic probe on top of that would be exactly the '
|
||||
'excess-request behaviour this ticket exists to avoid');
|
||||
});
|
||||
}
|
||||
43
rippr-flutter-src/test/ride_history_summary_test.dart
Normal file
43
rippr-flutter-src/test/ride_history_summary_test.dart
Normal file
@@ -0,0 +1,43 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:rippr/src/domain/models.dart';
|
||||
import 'package:rippr/src/ui/trips/trips_screen.dart';
|
||||
|
||||
/// UI-07: `RideHistorySummary.compute` is a pure function specifically so it can be
|
||||
/// tested directly against a hand-computed total, rather than through a screen.
|
||||
void main() {
|
||||
test('compute matches a hand-computed total over a fixed set of trips', () {
|
||||
final trips = [
|
||||
const Trip(
|
||||
id: 1,
|
||||
startedAt: 0,
|
||||
endedAt: 3600000,
|
||||
distanceM: 20000,
|
||||
movingMillis: 3600000,
|
||||
),
|
||||
const Trip(
|
||||
id: 2,
|
||||
startedAt: 0,
|
||||
endedAt: 1800000,
|
||||
distanceM: 5000,
|
||||
movingMillis: 1800000,
|
||||
),
|
||||
];
|
||||
|
||||
final summary = RideHistorySummary.compute(trips);
|
||||
|
||||
expect(summary.rideCount, 2);
|
||||
expect(summary.totalDistanceM, 25000);
|
||||
expect(summary.totalMovingMillis, 5400000);
|
||||
// Distance-weighted, not an average of each ride's own average: 25 km over 1.5 h.
|
||||
expect(summary.avgSpeedKmh, closeTo(25 / 1.5, 0.0001));
|
||||
});
|
||||
|
||||
test('an empty history has zero average speed, not a division error', () {
|
||||
final summary = RideHistorySummary.compute(const []);
|
||||
|
||||
expect(summary.rideCount, 0);
|
||||
expect(summary.totalDistanceM, 0);
|
||||
expect(summary.totalMovingMillis, 0);
|
||||
expect(summary.avgSpeedKmh, 0);
|
||||
});
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import 'package:flutter_map/flutter_map.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:rippr/src/domain/models.dart';
|
||||
import 'package:rippr/src/ui/components/ride_map.dart';
|
||||
import 'package:rippr/src/ui/components/skeleton_map_layer.dart';
|
||||
import 'package:rippr/src/ui/theme.dart';
|
||||
|
||||
/// Tiles are never fetched here — a widget test cannot serve them — but the polyline
|
||||
@@ -117,4 +118,67 @@ void main() {
|
||||
expect(map.options.maxZoom, maxTileZoom,
|
||||
reason: 'exceeding OSM max tile zoom renders an empty grid');
|
||||
});
|
||||
|
||||
group('skeleton mode (UI-02)', () {
|
||||
testWidgets('shows the skeleton and no TileLayer when skeletonMode is true',
|
||||
(tester) async {
|
||||
await tester.pumpWidget(MaterialApp(
|
||||
theme: ripprTheme(),
|
||||
home: const Scaffold(
|
||||
body: RideMap(points: [], segments: [], showEmptyLabel: false, skeletonMode: true),
|
||||
),
|
||||
));
|
||||
await tester.pump();
|
||||
|
||||
expect(find.byType(SkeletonMapLayer), findsOneWidget);
|
||||
expect(find.byType(TileLayer), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('shows the TileLayer and no skeleton when skeletonMode is false',
|
||||
(tester) async {
|
||||
await tester.pumpWidget(MaterialApp(
|
||||
theme: ripprTheme(),
|
||||
home: const Scaffold(
|
||||
body: RideMap(points: [], segments: [], showEmptyLabel: false),
|
||||
),
|
||||
));
|
||||
await tester.pump();
|
||||
|
||||
expect(find.byType(TileLayer), findsOneWidget);
|
||||
expect(find.byType(SkeletonMapLayer), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('the recorded path still renders on top of the skeleton',
|
||||
(tester) async {
|
||||
final points = [
|
||||
for (var i = 0; i < 4; i++)
|
||||
TrackPoint(
|
||||
id: i + 1,
|
||||
tripId: 1,
|
||||
segmentId: 1,
|
||||
timestamp: 1000 + i * 1000,
|
||||
latitude: 51.0 + i * 0.0005,
|
||||
longitude: -114.0,
|
||||
speedKmh: 40,
|
||||
altitudeM: 1000,
|
||||
),
|
||||
];
|
||||
const segments = [Segment(id: 1, tripId: 1, startedAt: 0, endedAt: 1)];
|
||||
|
||||
await tester.pumpWidget(MaterialApp(
|
||||
theme: ripprTheme(),
|
||||
home: Scaffold(
|
||||
body: RideMap(points: points, segments: segments, skeletonMode: true),
|
||||
),
|
||||
));
|
||||
await tester.pump();
|
||||
|
||||
expect(find.byType(SkeletonMapLayer), findsOneWidget);
|
||||
expect(find.byType(TileLayer), findsNothing);
|
||||
final layer = tester.widget<PolylineLayer>(find.byType(PolylineLayer));
|
||||
expect(layer.polylines, isNotEmpty,
|
||||
reason: 'the path comes from local data, not tiles -- it must not vanish '
|
||||
'just because the tile fetch is failing');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -7,6 +7,9 @@ import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:rippr/src/app/providers.dart';
|
||||
import 'package:rippr/src/data/database.dart';
|
||||
import 'package:rippr/src/data/route_plan_repository.dart';
|
||||
import 'package:rippr/src/ui/app_shell.dart';
|
||||
import 'package:rippr/src/ui/components/floating_pill.dart';
|
||||
import 'package:rippr/src/ui/format.dart';
|
||||
import 'package:rippr/src/ui/routes/route_planner_screen.dart';
|
||||
import 'package:rippr/src/ui/routes/routes_list_screen.dart';
|
||||
import 'package:rippr/src/ui/theme.dart';
|
||||
@@ -107,8 +110,10 @@ void main() {
|
||||
final id = await repo.createRoutePlan(1000);
|
||||
await pumpMap(tester, host(RoutePlannerScreen(routeId: id)));
|
||||
|
||||
expect(find.text('0 m'), findsOneWidget);
|
||||
expect(find.textContaining('0 pins'), findsOneWidget);
|
||||
// UI-06: no pins yet -- the "tap to drop a pin" tooltip shows, not a stat pill
|
||||
// (there is nothing to summarise yet).
|
||||
expect(find.textContaining('TAP TO DROP A PIN'), findsOneWidget);
|
||||
expect(find.byType(FloatingPill), findsNothing);
|
||||
|
||||
await tester.tapAt(tester.getCenter(find.byType(FlutterMap)));
|
||||
await tester.pump(const Duration(milliseconds: 50));
|
||||
@@ -119,35 +124,54 @@ void main() {
|
||||
|
||||
final waypoints = await repo.waypointsFor(id);
|
||||
expect(waypoints, hasLength(2));
|
||||
expect(find.textContaining('2 pins'), findsOneWidget);
|
||||
|
||||
final label = tester.widget<Text>(find.byKey(const Key('route-distance')));
|
||||
expect(label.data, isNot('0 m'));
|
||||
// The tooltip is gone and the stat pill has taken its place, showing the real
|
||||
// pin count and a non-zero distance. Read the pill's own `stats` directly
|
||||
// rather than `find.text('2')` -- the second waypoint pin's own marker label
|
||||
// is also "2", so that text is no longer unique on screen.
|
||||
expect(find.textContaining('TAP TO DROP A PIN'), findsNothing);
|
||||
final pill = tester.widget<FloatingPill>(find.byType(FloatingPill));
|
||||
final pinsStat = pill.stats.firstWhere((s) => s.label == 'Pins');
|
||||
final distanceStat = pill.stats.firstWhere((s) => s.label == 'Distance');
|
||||
expect(pinsStat.value, '2');
|
||||
expect(distanceStat.value, isNot(formatDistance(0)),
|
||||
reason: 'the distance stat must reflect the two real pins, not stay zeroed');
|
||||
});
|
||||
|
||||
screenTest('tapping a pin deletes it', (tester) async {
|
||||
// UI-06: a single waypoint (rather than two) -- with only one pin, the camera
|
||||
// centres exactly on it, keeping it clear of the floating "Start Route" button
|
||||
// now anchored at the bottom of the screen; two close pins previously landed
|
||||
// one of them directly underneath it in this test's fixed viewport.
|
||||
final id = await repo.createRoutePlan(1000);
|
||||
await repo.addWaypoint(id, 51.0, -114.0);
|
||||
await repo.addWaypoint(id, 51.01, -114.0);
|
||||
|
||||
await pumpMap(tester, host(RoutePlannerScreen(routeId: id)));
|
||||
|
||||
// flutter_map's `Marker` is a plain data class, not a Widget -- it never appears
|
||||
// in the tree itself. `CircleAvatar` is what `_WaypointPin` actually renders.
|
||||
expect(find.byType(CircleAvatar), findsNWidgets(2));
|
||||
expect(find.byType(CircleAvatar), findsOneWidget);
|
||||
|
||||
await tester.tap(find.text('1')); // the first pin's label
|
||||
// Not `find.text('1')`: the FloatingPill's "Pins" stat also reads "1" with a
|
||||
// single waypoint, so the pin's own label text is no longer a unique match.
|
||||
await tester.tap(find.byType(CircleAvatar));
|
||||
await tester.pump();
|
||||
|
||||
expect(await repo.waypointsFor(id), hasLength(1));
|
||||
expect(await repo.waypointsFor(id), isEmpty);
|
||||
});
|
||||
|
||||
screenTest('renaming updates the app bar title', (tester) async {
|
||||
screenTest('renaming writes through to the repository (UI-06: no app bar title '
|
||||
'to display it on any more)', (tester) async {
|
||||
final id = await repo.createRoutePlan(1000, name: 'Old name');
|
||||
await pumpMap(tester, host(RoutePlannerScreen(routeId: id)));
|
||||
|
||||
await tester.tap(find.byKey(const Key('rename-route')));
|
||||
// Rename now lives behind the floating overflow menu -- open it first.
|
||||
await tester.tap(find.byKey(const Key('route-overflow-menu')));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 300));
|
||||
await tester.pump();
|
||||
await tester.tap(find.byKey(const Key('rename-route')));
|
||||
await tester.pump(const Duration(milliseconds: 300));
|
||||
await tester.enterText(
|
||||
find.byKey(const Key('route-name-field')),
|
||||
'Sunday coast run',
|
||||
@@ -155,7 +179,8 @@ void main() {
|
||||
await tester.tap(find.text('Save'));
|
||||
await tester.pump();
|
||||
|
||||
expect(find.text('Sunday coast run'), findsOneWidget);
|
||||
final renamed = await repo.routePlanById(id);
|
||||
expect(renamed?.name, 'Sunday coast run');
|
||||
});
|
||||
|
||||
screenTest('a missing route says so instead of a blank map', (tester) async {
|
||||
@@ -165,16 +190,21 @@ void main() {
|
||||
expect(find.textContaining('no longer exists'), findsOneWidget);
|
||||
});
|
||||
|
||||
screenTest('the offline-tiles download button is disabled with no pins '
|
||||
'(V3-11)', (tester) async {
|
||||
screenTest('the offline-tiles download menu item is disabled with no pins '
|
||||
'(V3-11, UI-06: now a PopupMenuItem behind the overflow menu)',
|
||||
(tester) async {
|
||||
final id = await repo.createRoutePlan(1000);
|
||||
await pumpMap(tester, host(RoutePlannerScreen(routeId: id)));
|
||||
|
||||
final button = tester.widget<IconButton>(
|
||||
await tester.tap(find.byKey(const Key('route-overflow-menu')));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 300));
|
||||
await tester.pump();
|
||||
|
||||
final item = tester.widget<PopupMenuItem<String>>(
|
||||
find.byKey(const Key('download-tiles')),
|
||||
);
|
||||
expect(button.onPressed, isNull);
|
||||
expect(button.tooltip, contains('Add pins'));
|
||||
expect(item.enabled, isFalse);
|
||||
});
|
||||
|
||||
screenTest('downloading shows a tile count and size estimate before any '
|
||||
@@ -184,10 +214,15 @@ void main() {
|
||||
await repo.addWaypoint(id, 51.01, -114.0);
|
||||
await pumpMap(tester, host(RoutePlannerScreen(routeId: id)));
|
||||
|
||||
final button = tester.widget<IconButton>(
|
||||
await tester.tap(find.byKey(const Key('route-overflow-menu')));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 300));
|
||||
await tester.pump();
|
||||
|
||||
final item = tester.widget<PopupMenuItem<String>>(
|
||||
find.byKey(const Key('download-tiles')),
|
||||
);
|
||||
expect(button.onPressed, isNotNull);
|
||||
expect(item.enabled, isTrue);
|
||||
|
||||
await tester.tap(find.byKey(const Key('download-tiles')));
|
||||
// Bounded, not pumpAndSettle: the confirmation dialog is safe to settle (no
|
||||
@@ -201,5 +236,25 @@ void main() {
|
||||
expect(find.textContaining('tiles?'), findsOneWidget);
|
||||
expect(find.textContaining('MB'), findsOneWidget);
|
||||
});
|
||||
|
||||
screenTest('the shell nav bar correctly shows Plan active on this screen '
|
||||
'(UI-06)', (tester) async {
|
||||
final id = await repo.createRoutePlan(1000);
|
||||
await pumpMap(
|
||||
tester,
|
||||
host(
|
||||
ShellScaffold(
|
||||
currentIndex: 2, // Map, Rides, Plan, Settings -- Plan is index 2.
|
||||
onDestinationSelected: (_) {},
|
||||
child: RoutePlannerScreen(routeId: id),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
final navBar = tester.widget<NavigationBar>(find.byKey(const Key('shell-nav-bar')));
|
||||
expect(navBar.selectedIndex, 2,
|
||||
reason: 'not the mockup\'s own generation error (it showed Rides active '
|
||||
'while viewing Plan) -- the shipped nav bar must reflect the real tab');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:rippr/src/app/providers.dart';
|
||||
import 'package:rippr/src/config/config.dart';
|
||||
import 'package:rippr/src/domain/models.dart';
|
||||
import 'package:rippr/src/hud/hud_metric.dart';
|
||||
import 'package:rippr/src/ui/settings/settings_screen.dart';
|
||||
import 'package:rippr/src/ui/theme.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
@@ -196,6 +197,46 @@ void main() {
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
group('Live HUD stats (UI-04)', () {
|
||||
// The section sits at the bottom of a long ListView -- these rows are not built
|
||||
// until scrolled into view (a plain `ListView(children:)` still lazily materialises
|
||||
// via a sliver, same as `.builder`), so every test here scrolls first.
|
||||
Future<void> scrollTo(WidgetTester tester, Key key) => tester.scrollUntilVisible(
|
||||
find.byKey(key),
|
||||
200,
|
||||
scrollable: find.byType(Scrollable).first,
|
||||
);
|
||||
|
||||
testWidgets('a metric switch reflects and writes through to Config', (tester) async {
|
||||
await tester.pumpWidget(host());
|
||||
await tester.pumpAndSettle();
|
||||
await scrollTo(tester, const Key('hud-visible-maxSpeed'));
|
||||
|
||||
final initial = tester
|
||||
.widget<SwitchListTile>(find.byKey(const Key('hud-visible-maxSpeed')))
|
||||
.value;
|
||||
expect(initial, isFalse, reason: 'metrics beyond the fixed row start hidden');
|
||||
|
||||
await tester.tap(find.byKey(const Key('hud-visible-maxSpeed')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(config.hudLayout[HudMetric.maxSpeed]!.visible, isTrue,
|
||||
reason: 'the switch must write through to Config immediately, not wait '
|
||||
'for a HUD edit-mode session to end');
|
||||
});
|
||||
|
||||
testWidgets('a metric already visible by default shows as on', (tester) async {
|
||||
await tester.pumpWidget(host());
|
||||
await tester.pumpAndSettle();
|
||||
await scrollTo(tester, const Key('hud-visible-speed'));
|
||||
|
||||
final speedSwitch = tester
|
||||
.widget<SwitchListTile>(find.byKey(const Key('hud-visible-speed')))
|
||||
.value;
|
||||
expect(speedSwitch, isTrue);
|
||||
});
|
||||
});
|
||||
|
||||
testWidgets('shows a spinner rather than crashing while Config is still loading',
|
||||
(tester) async {
|
||||
await tester.pumpWidget(
|
||||
|
||||
@@ -89,4 +89,26 @@ void main() {
|
||||
expect(await reopened.get(const TileKey(5, 10, 10)), isNotNull);
|
||||
expect(await reopened.sizeBytes(), 64);
|
||||
});
|
||||
|
||||
test('a tile cached under one provider directory is not served from another '
|
||||
'(UI-09)', () async {
|
||||
// `TileKey` carries no provider identity -- (z, x, y) alone can't tell an OSM tan
|
||||
// tile from a CARTO dark one at the same coordinates. `tileCacheProvider` (see
|
||||
// app/providers.dart) relies entirely on giving each tile source its own
|
||||
// subdirectory to prevent a switch from silently serving stale, visually-mismatched
|
||||
// tiles. This proves that isolation actually holds at the `FileTileCache` level.
|
||||
final osm = FileTileCache(
|
||||
directory: Directory('${tempDir.path}/osm'),
|
||||
maxBytes: 1024 * 1024,
|
||||
);
|
||||
final cartoDark = FileTileCache(
|
||||
directory: Directory('${tempDir.path}/carto_dark_v1'),
|
||||
maxBytes: 1024 * 1024,
|
||||
);
|
||||
|
||||
await osm.put(const TileKey(5, 10, 10), bytesOfSize(64));
|
||||
|
||||
expect(await cartoDark.get(const TileKey(5, 10, 10)), isNull,
|
||||
reason: 'a tile cached under the old provider must not leak into the new one');
|
||||
});
|
||||
}
|
||||
|
||||
@@ -12,6 +12,9 @@ import 'package:rippr/src/domain/models.dart';
|
||||
import 'package:rippr/src/ui/activity_display.dart';
|
||||
import 'package:rippr/src/recording/location_source.dart';
|
||||
import 'package:rippr/src/recording/wakelock_controller.dart';
|
||||
import 'package:rippr/src/ui/app_shell.dart';
|
||||
import 'package:rippr/src/ui/components/glass_panel.dart';
|
||||
import 'package:rippr/src/ui/components/ride_map.dart';
|
||||
import 'package:rippr/src/ui/detail/trip_detail_screen.dart';
|
||||
import 'package:rippr/src/ui/format.dart';
|
||||
import 'package:rippr/src/ui/record/record_screen.dart';
|
||||
@@ -184,6 +187,51 @@ void main() {
|
||||
reason: 'cancelling must not delete the ride');
|
||||
});
|
||||
|
||||
screenTest('tapping START actually starts a trip (UI-05)', (tester) async {
|
||||
await tester.pumpWidget(host(const RecordScreen(), map: false));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(await repo.activeTrip(), isNull);
|
||||
await tester.tap(find.byKey(const Key('start')));
|
||||
await tester.pump();
|
||||
|
||||
expect(await repo.activeTrip(), isNotNull,
|
||||
reason: 'the new icon-only control bar must still drive the same engine '
|
||||
'call the old text button did');
|
||||
});
|
||||
|
||||
screenTest('tapping PAUSE and then STOP actually pauses and completes the trip '
|
||||
'(UI-05)', (tester) async {
|
||||
await repo.startTrip(1000);
|
||||
await pumpLive(tester, host(const RecordScreen(), map: false));
|
||||
|
||||
await tester.tap(find.byKey(const Key('pause')));
|
||||
await tester.pump();
|
||||
final paused = await repo.activeTrip();
|
||||
expect(paused?.state, TripState.paused);
|
||||
|
||||
await tester.tap(find.byKey(const Key('stop')));
|
||||
await tester.pump();
|
||||
expect(await repo.activeTrip(), isNull,
|
||||
reason: 'stopping completes the trip -- it is no longer the active one');
|
||||
});
|
||||
|
||||
screenTest('HUD widgets render over the map, not replacing it (UI-05)',
|
||||
(tester) async {
|
||||
await repo.startTrip(1000);
|
||||
await pumpLive(
|
||||
tester,
|
||||
host(
|
||||
ShellScaffold(currentIndex: 0, onDestinationSelected: (_) {}, child: const RecordScreen()),
|
||||
),
|
||||
);
|
||||
|
||||
expect(find.byKey(const Key('shell-background-map')), findsOneWidget,
|
||||
reason: 'the map is still there, underneath the HUD');
|
||||
expect(find.text('SPEED'), findsOneWidget);
|
||||
expect(find.text('DISTANCE'), findsOneWidget);
|
||||
});
|
||||
|
||||
screenTest('text is legible against the dark ground', (tester) async {
|
||||
// The regression this exists for: removing Compose's Surface left LocalContentColor
|
||||
// black, and a 64sp figure rendered invisibly on a near-black background. No logic
|
||||
@@ -203,58 +251,37 @@ void main() {
|
||||
expect(colour, isNot(Colors.black));
|
||||
});
|
||||
|
||||
screenTest('a settings entry point exists and is wired (V3-02)', (tester) async {
|
||||
var opened = false;
|
||||
await tester.pumpWidget(
|
||||
host(RecordScreen(onOpenSettings: () => opened = true)),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
// UI-01: Settings/Routes/Rides entry points are no longer per-screen callback
|
||||
// buttons on RecordScreen -- they're destinations on the shell's persistent bottom
|
||||
// nav bar. See the `AppShell`/`ShellScaffold` group below for their coverage.
|
||||
|
||||
expect(find.byKey(const Key('open-settings')), findsOneWidget);
|
||||
await tester.tap(find.byKey(const Key('open-settings')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(opened, isTrue,
|
||||
reason: 'the button must actually invoke the callback that navigates');
|
||||
});
|
||||
|
||||
screenTest('a routes entry point exists and is wired (V3-07)', (tester) async {
|
||||
var opened = false;
|
||||
await tester.pumpWidget(
|
||||
host(RecordScreen(onOpenRoutes: () => opened = true)),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byKey(const Key('open-routes')), findsOneWidget);
|
||||
await tester.tap(find.byKey(const Key('open-routes')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(opened, isTrue);
|
||||
});
|
||||
|
||||
screenTest('the live map appears only while recording and the toggle is on '
|
||||
'(V3-04)', (tester) async {
|
||||
// Idle, toggle on: no trip to draw, so no map at all.
|
||||
await tester.pumpWidget(host(const RecordScreen()));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byKey(const Key('live-map')), findsNothing);
|
||||
|
||||
// Recording, toggle off: RecordingEngine has produced a trip, but the map must
|
||||
// not be constructed at all -- not just hidden.
|
||||
screenTest('the background map appears only while the map toggle is on '
|
||||
'(V3-04, moved to the shell by UI-01)', (tester) async {
|
||||
// Toggle off: the shell must not construct the map at all -- not just hidden.
|
||||
await repo.startTrip(1000);
|
||||
await tester.pumpWidget(const SizedBox.shrink());
|
||||
await pumpLive(tester, host(const RecordScreen(), map: false));
|
||||
expect(find.byKey(const Key('live-map')), findsNothing);
|
||||
await pumpLive(
|
||||
tester,
|
||||
host(
|
||||
ShellScaffold(currentIndex: 0, onDestinationSelected: (_) {}, child: const RecordScreen()),
|
||||
map: false,
|
||||
),
|
||||
);
|
||||
expect(find.byKey(const Key('shell-background-map')), findsNothing);
|
||||
expect(find.byType(FlutterMap), findsNothing);
|
||||
|
||||
// Recording, toggle on: the map is drawn.
|
||||
// Toggle on: the map is drawn.
|
||||
await tester.pumpWidget(const SizedBox.shrink());
|
||||
await pumpLive(tester, host(const RecordScreen()));
|
||||
expect(find.byKey(const Key('live-map')), findsOneWidget);
|
||||
await pumpLive(
|
||||
tester,
|
||||
host(
|
||||
ShellScaffold(currentIndex: 0, onDestinationSelected: (_) {}, child: const RecordScreen()),
|
||||
),
|
||||
);
|
||||
expect(find.byKey(const Key('shell-background-map')), findsOneWidget);
|
||||
});
|
||||
|
||||
screenTest('backgrounding the app drops the tile layer (V3-04)',
|
||||
(tester) async {
|
||||
screenTest('backgrounding the app drops the tile layer (V3-04, moved to the '
|
||||
'shell by UI-01)', (tester) async {
|
||||
final h = await repo.startTrip(1000);
|
||||
await repo.appendPoints([
|
||||
TrackPoint(
|
||||
@@ -267,7 +294,12 @@ void main() {
|
||||
altitudeM: 1000.0,
|
||||
),
|
||||
]);
|
||||
await pumpLive(tester, host(const RecordScreen()));
|
||||
await pumpLive(
|
||||
tester,
|
||||
host(
|
||||
ShellScaffold(currentIndex: 0, onDestinationSelected: (_) {}, child: const RecordScreen()),
|
||||
),
|
||||
);
|
||||
|
||||
expect(find.byType(TileLayer), findsOneWidget,
|
||||
reason: 'foregrounded: tiles render normally');
|
||||
@@ -278,6 +310,7 @@ void main() {
|
||||
await tester.binding.defaultBinaryMessenger
|
||||
.handlePlatformMessage('flutter/lifecycle', message, (_) {});
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
|
||||
expect(find.byType(TileLayer), findsNothing,
|
||||
reason: 'backgrounded: no tile layer means no tile request can fire');
|
||||
@@ -288,6 +321,7 @@ void main() {
|
||||
await tester.binding.defaultBinaryMessenger
|
||||
.handlePlatformMessage('flutter/lifecycle', resumed, (_) {});
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
expect(find.byType(TileLayer), findsOneWidget,
|
||||
reason: 'foregrounding again must resume tiles');
|
||||
});
|
||||
@@ -362,6 +396,69 @@ void main() {
|
||||
expect(start.height, 96,
|
||||
reason: 'V3-05: 72dp is not enough at speed, with gloves');
|
||||
});
|
||||
|
||||
screenTest('the mounted-mode speed digit is legible against GlassPanel\'s own '
|
||||
'translucent surface, not just different from the dark ground (UI-05)',
|
||||
(tester) async {
|
||||
// UI-05's own named risk: mounted mode was built and tested against an opaque
|
||||
// Card, not a blurred, translucent GlassPanel -- a light theme through blurred
|
||||
// content behaves differently than the dark-on-dark case V3-05 originally
|
||||
// guarded against. This asserts the real contrast ratio, not just "differs from
|
||||
// the wrong ground" the way the test above does.
|
||||
await tester.pumpWidget(host(const RecordScreen(), mountedMode: true));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final speed = tester.widget<Text>(
|
||||
find.descendant(of: find.byKey(const Key('speed')), matching: find.text('0')),
|
||||
);
|
||||
final panel = tester.widget<GlassPanel>(find.byType(GlassPanel).first);
|
||||
final mountedColors = ripprMountedTheme().colorScheme;
|
||||
// GlassPanel fills with `colors.surface` at `GlassPanel.fillOpacity` -- since it
|
||||
// is a solid, near-opaque fill (not a transparency composited over unknown
|
||||
// content), the panel's own surface colour is what the text is actually read
|
||||
// against in practice.
|
||||
expect(panel.child, isNotNull);
|
||||
expect(
|
||||
contrastRatio(speed.style!.color!, mountedColors.surface),
|
||||
greaterThanOrEqualTo(4.5),
|
||||
reason: 'AA normal text against GlassPanel\'s mounted-theme surface',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('shell nav bar', () {
|
||||
// UI-01: Settings/Routes/Rides are no longer per-screen callback buttons on
|
||||
// RecordScreen -- they're destinations on the shell's persistent bottom nav bar.
|
||||
// This replaces the old "a settings/routes entry point exists and is wired"
|
||||
// per-screen tests.
|
||||
screenTest('all four destinations are present and switching tabs calls back '
|
||||
'with the tapped index', (tester) async {
|
||||
var lastIndex = -1;
|
||||
await tester.pumpWidget(host(
|
||||
ShellScaffold(
|
||||
currentIndex: 0,
|
||||
onDestinationSelected: (i) => lastIndex = i,
|
||||
child: const RecordScreen(),
|
||||
),
|
||||
map: false,
|
||||
));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byKey(const Key('shell-nav-bar')), findsOneWidget);
|
||||
expect(find.byKey(const Key('nav-map')), findsOneWidget);
|
||||
expect(find.byKey(const Key('nav-rides')), findsOneWidget);
|
||||
expect(find.byKey(const Key('nav-plan')), findsOneWidget);
|
||||
expect(find.byKey(const Key('nav-settings')), findsOneWidget);
|
||||
|
||||
await tester.tap(find.byKey(const Key('nav-settings')));
|
||||
expect(lastIndex, 3);
|
||||
|
||||
await tester.tap(find.byKey(const Key('nav-rides')));
|
||||
expect(lastIndex, 1);
|
||||
|
||||
await tester.tap(find.byKey(const Key('nav-plan')));
|
||||
expect(lastIndex, 2);
|
||||
});
|
||||
});
|
||||
|
||||
group('trips list', () {
|
||||
@@ -397,8 +494,86 @@ void main() {
|
||||
|
||||
expect(find.byKey(const Key('trip-1')), findsOneWidget);
|
||||
expect(find.text('Finished'), findsOneWidget);
|
||||
// Only the completed one.
|
||||
expect(find.byType(Card), findsOneWidget);
|
||||
// Only the completed one. UI-07: trip cards are GlassPanel-based, not Card --
|
||||
// RideMap's thumbnail is unique to a trip card (unlike GlassPanel/Material, which
|
||||
// other chrome on this screen also uses).
|
||||
expect(find.byType(RideMap), findsOneWidget);
|
||||
});
|
||||
|
||||
screenTest('search narrows the visible list to matching rides (UI-07)',
|
||||
(tester) async {
|
||||
await seedCompletedTrip(
|
||||
startedAt: 1000, endedAt: 5000, name: 'Coast loop');
|
||||
await seedCompletedTrip(
|
||||
startedAt: 100000, endedAt: 200000, name: 'Mountain climb');
|
||||
|
||||
await tester.pumpWidget(host(const TripsScreen()));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Coast loop'), findsOneWidget);
|
||||
expect(find.text('Mountain climb'), findsOneWidget);
|
||||
|
||||
await tester.enterText(find.byKey(const Key('ride-search')), 'coast');
|
||||
await tester.pump();
|
||||
|
||||
expect(find.text('Coast loop'), findsOneWidget);
|
||||
expect(find.text('Mountain climb'), findsNothing);
|
||||
});
|
||||
|
||||
screenTest('filtering by activity narrows the visible list (UI-07)',
|
||||
(tester) async {
|
||||
await repo.startTrip(1000, activity: Activity.bicycle).then(
|
||||
(h) async {
|
||||
await repo.appendPoints([
|
||||
TrackPoint(
|
||||
tripId: h.tripId,
|
||||
segmentId: h.segmentId,
|
||||
timestamp: 1000,
|
||||
latitude: 51.0,
|
||||
longitude: -114.0,
|
||||
speedKmh: 20,
|
||||
altitudeM: 1000,
|
||||
),
|
||||
]);
|
||||
await repo.renameTrip(h.tripId, 'Bike ride');
|
||||
await repo.completeTrip(5000);
|
||||
await repo.recomputeAggregates(h.tripId);
|
||||
});
|
||||
await repo.startTrip(100000, activity: Activity.motorcycle).then(
|
||||
(h) async {
|
||||
await repo.appendPoints([
|
||||
TrackPoint(
|
||||
tripId: h.tripId,
|
||||
segmentId: h.segmentId,
|
||||
timestamp: 100000,
|
||||
latitude: 51.0,
|
||||
longitude: -114.0,
|
||||
speedKmh: 60,
|
||||
altitudeM: 1000,
|
||||
),
|
||||
]);
|
||||
await repo.renameTrip(h.tripId, 'Moto ride');
|
||||
await repo.completeTrip(200000);
|
||||
await repo.recomputeAggregates(h.tripId);
|
||||
});
|
||||
|
||||
await tester.pumpWidget(host(const TripsScreen()));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Bike ride'), findsOneWidget);
|
||||
expect(find.text('Moto ride'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.byKey(const Key('activity-filter')));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 300));
|
||||
await tester.pump();
|
||||
await tester.tap(find.byKey(const Key('activity-filter-bicycle')));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 300));
|
||||
await tester.pump();
|
||||
|
||||
expect(find.text('Bike ride'), findsOneWidget);
|
||||
expect(find.text('Moto ride'), findsNothing);
|
||||
});
|
||||
|
||||
screenTest('Merge enables at exactly two selections', (tester) async {
|
||||
|
||||
Reference in New Issue
Block a user