Files
rippr/test/format_test.dart
uhryniuk 37fba4784a V3-02 + V3-03: settings screen, and metric/imperial units
Built together because V3-02's settings screen needed something real for
V3-03's units to control -- executed in reverse of the ticket numbering, but
both tickets are independently complete.

V3-03: UnitSystem (metric/imperial) lives in domain/models.dart alongside
Activity, defaulting from Platform.localeName on first launch. Storage stays
SI everywhere -- conversion happens only in ui/format.dart, at the last
possible moment, which is now documented as the file's central invariant.
Threaded through all three screens plus the speed histogram's bucket labels,
which relabel for display without changing how speedHistogram itself bins.
Export was deliberately left untouched: gpx()/geoJson() take no UnitSystem
parameter at all, a stronger guarantee than validating one would be.

V3-02: SettingsScreen reachable from the record screen. Map render toggle
(nothing previously exposed mapEnabledProvider to the user, despite it
existing since T15 -- there was nothing to "move off trip detail" as drafted),
unit selector, upload endpoint with http(s) validation, a copyable device id,
and a licences page via Flutter's built-in showLicensePage. Skipped
package_info_plus (static version string instead) and a privacy-policy link
(none published yet) as disproportionate to an S-sized ticket.

Neither ticket needed the ConfigNotifier the implementation notes proposed:
unitSystemProvider reuses the exact StateProvider-seeded-from-Config pattern
mapEnabledProvider already established, since Config mutates its own backing
SharedPreferences in place and re-assigning the same instance would never
notify a watcher anyway.

A real locale-dependent flake was caught, not just anticipated: a test
asserting a fresh Config defaults to metric failed, because this machine's own
locale resolves to a region in the imperial set. Fixed by seeding an explicit
value before asserting, and config_test.dart's locale test was written from
the start to only prove the fallback path doesn't throw, not to assert which
value it returns.

221 tests (211 -> 221): 13 format_test, 9 config_test, 9 settings_screen_test,
1 confirming the record screen's settings button is genuinely wired. Analyze
clean.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-17 14:23:43 -05:00

116 lines
4.4 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_test/flutter_test.dart';
import 'package:rippr/src/domain/models.dart';
import 'package:rippr/src/export/ride_export.dart';
import 'package:rippr/src/ui/format.dart';
/// V3-03: distance and speed units. Metric is the default everywhere, so every existing
/// call site that omits `unit:` keeps behaving exactly as before — these tests are about
/// what changes when a caller opts into imperial.
void main() {
group('formatDistance', () {
test('metric switches from metres to kilometres at 1000 m', () {
expect(formatDistance(999), '999 m');
expect(formatDistance(1000), '1.0 km');
expect(formatDistance(12345.6), '12.3 km');
});
test('imperial switches from feet to miles at 1000 ft', () {
// 1000 ft is ~304.8 m.
expect(formatDistance(100, unit: UnitSystem.imperial), '328 ft');
expect(
formatDistance(305, unit: UnitSystem.imperial),
'0.2 mi',
reason: 'just past the 1000 ft threshold (1000 ft is not 1 mile — 5280 ft is)',
);
// 12345.6 m is ~7.67 mi.
expect(formatDistance(12345.6, unit: UnitSystem.imperial), '7.7 mi');
});
test('metric is the default when unit is omitted', () {
expect(formatDistance(1500), formatDistance(1500, unit: UnitSystem.metric));
});
});
group('formatSpeed', () {
test('metric prints km/h to one decimal', () {
expect(formatSpeed(88.5), '88.5 km/h');
});
test('imperial converts to mph', () {
// 100 km/h is ~62.1 mph.
expect(formatSpeed(100, unit: UnitSystem.imperial), '62.1 mph');
});
});
group('formatElevation', () {
test('metric prints whole metres', () {
expect(formatElevation(1045.7), '1045 m');
});
test('imperial converts to whole feet', () {
// 1000 m is ~3280.84 ft.
expect(formatElevation(1000, unit: UnitSystem.imperial), '3280 ft');
});
});
group('parts helpers used by BigStat', () {
test('formatDistanceParts always uses the "big" unit, km or mi', () {
expect(formatDistanceParts(500), ('0.5', 'km'));
expect(formatDistanceParts(1609.344, unit: UnitSystem.imperial), ('1.0', 'mi'));
});
test('formatSpeedParts has zero decimal places, matching the live speedo', () {
expect(formatSpeedParts(88.6), ('89', 'km/h'));
expect(formatSpeedParts(100, unit: UnitSystem.imperial), ('62', 'mph'));
});
});
group('formatSpeedRangeLabel', () {
test('metric passes the km/h boundaries through unchanged', () {
expect(formatSpeedRangeLabel(10, 20), '10–20');
});
test('imperial converts and rounds the boundaries, not the underlying bucket', () {
// 10 km/h ~ 6 mph, 20 km/h ~ 12 mph.
expect(formatSpeedRangeLabel(10, 20, unit: UnitSystem.imperial), '6–12');
});
});
group('unit system never reaches export (the risk this ticket names)', () {
// gpx() and geoJson() take no UnitSystem parameter at all -- there is no argument to
// thread incorrectly. This proves the invariant one level up: formatting the same
// stored value under both units produces different text, but export always reads the
// one stored value, regardless of what a caller displays it as.
const trip = Trip(
id: 1,
startedAt: 1700000000000,
endedAt: 1700000600000,
state: TripState.completed,
distanceM: 12345.6,
maxSpeedKmh: 88.5,
);
test('display formatting differs by unit for the same stored value', () {
expect(
formatDistance(trip.distanceM, unit: UnitSystem.metric),
isNot(formatDistance(trip.distanceM, unit: UnitSystem.imperial)),
);
});
test('export output is identical regardless of what unit the UI last showed', () {
// GPX carries no trip-level distance field at all (it is built from individual
// <trkpt> elements) -- deterministic regardless of unit is the property under
// test, not any particular substring.
final first = gpx(trip, const [], const [], 'Ride');
final second = gpx(trip, const [], const [], 'Ride');
expect(first, second);
// GeoJSON does carry trip.distanceM in its properties, and it is the raw metric
// figure -- 12345.6, never a converted "7.7 mi".
final json = geoJson(trip, const [], const [], 'Ride');
expect(json, contains('"distance_m": 12345.6'));
expect(json, isNot(contains('mi')), reason: 'export is metric by specification');
});
});
}