diff --git a/lib/health/health_export.dart b/lib/health/health_export.dart index 8d5365fc..0e7a41ca 100644 --- a/lib/health/health_export.dart +++ b/lib/health/health_export.dart @@ -1199,6 +1199,7 @@ class HealthExporter { start: DateTime.fromMillisecondsSinceEpoch(st * 1000), end: DateTime.fromMillisecondsSinceEpoch(en * 1000), totalEnergyBurned: (r['calories'] as num?)?.round(), + title: healthWorkoutTitleForType(r['type']?.toString()), ); } catch (e) { debugPrint('[health] write workout @$st: $e'); @@ -1289,8 +1290,48 @@ class HealthExporter { } } -/// The app's workout-type key -> platform health activity type. +/// The Health Connect record title for a stored `sessions.type`. /// +/// Health Connect titles the record with the ACTIVITY TYPE NAME when the write +/// carries no title of its own — `HealthPlugin.kt` does +/// `call.argument("title") ?: type` — so every session landing on `OTHER` +/// appeared in the user's health app named "OTHER". That is most of the +/// catalogue, and NOT because the store lacks the types: Health Connect +/// accepts `TABLE_TENNIS`, `CRICKET`, `VOLLEYBALL` and more that +/// [healthActivityForType] below simply does not map yet. `OTHER` is this +/// app's fallback, not a platform limit, and widening the map is its own +/// audit — the title is what stops the gap from being user-visible meanwhile. +/// +/// iOS ignores the field: `HKWorkout` has no title, and the activity type IS +/// the label there. One unconditional argument rather than a platform branch, +/// because a value the other store discards is not a platform difference. +/// +/// FUTURE WRITES ONLY. `exportAll` skips dates at or before +/// `health_export_through`, so sessions already finalized on Android keep the +/// label they were written with. Relabelling them would need a bounded replay +/// of the finalized prefix; a wrong name on old rows is not worth that. +/// +/// ponytail: this de-slugs the type key instead of reading the catalogue's +/// display name, so the three acronym-cased entries come back title-cased — +/// "Crossfit", "Hiit", "Diy". The upgrade is one import, and it is not worth +/// taking: `lib/health` reaching into `lib/ui2` to spell three words is the +/// wrong dependency, and every one of them already beats "OTHER". +@visibleForTesting +String? healthWorkoutTitleForType(String? type) { + // Underscores BEFORE the trim, or a type of `_` survives as a one-space + // title — non-null, so it suppresses the platform default and writes a + // blank name where "OTHER" at least said something. + final t = (type ?? '') + .replaceAll('_', ' ') + .replaceAll(RegExp(r'\s+'), ' ') + .trim(); + if (t.isEmpty) return null; + return t + .split(' ') + .map((w) => w[0].toUpperCase() + w.substring(1)) + .join(' '); +} + /// Parameterised by [ios] rather than reading `Platform` directly so a unit /// test can exercise BOTH platform branches on a host VM (where `Platform.isIOS` /// and `Platform.isAndroid` are both false) — see @@ -1396,6 +1437,16 @@ HealthWorkoutActivityType healthActivityForType( : HealthWorkoutActivityType.OTHER; case 'golf': return HealthWorkoutActivityType.GOLF; + case 'bowling': + // Android has no bowling: Health Connect's exercise types stop at the + // sports it knows, and `BOWLING` is absent from the plugin's Android + // set, so the call throws `HealthException` before the channel and the + // session never lands. iOS maps it to a real `HKWorkoutActivityType + // .bowling`. Same #184 shape as strength and swim, caught before the + // bug rather than after it. + return ios + ? HealthWorkoutActivityType.BOWLING + : HealthWorkoutActivityType.OTHER; default: return HealthWorkoutActivityType.OTHER; } diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 4ea74915..9db06886 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -9123,6 +9123,14 @@ "@activitySetupCaloriesNeedWeight": { "description": "Shown instead of a calorie estimate when the user has no weight on file" }, + "activitySetupNoMetEstimate": "No estimate up front: no published MET applies to a session that names no activity. Calories come from your heart rate instead — when your age, weight and sex are set, and your resting and maximum rates are measured rather than assumed.", + "@activitySetupNoMetEstimate": { + "description": "Shown instead of a calorie estimate when the activity has no published MET (e.g. General workout)" + }, + "activitySummaryCalorieNoMet": "Estimated from your heart rate and your weight. No MET is in this figure: the session named no activity for one to apply to.", + "@activitySummaryCalorieNoMet": { + "description": "Calorie basis line on the summary screen for a session whose activity has no published MET (e.g. General workout)" + }, "activitySetupCalorieEstimate": "About {est} kcal per {minutes} min, from {met} MET and your weight.", "@activitySetupCalorieEstimate": { "description": "Calorie estimate line on the activity setup screen, e.g. 'About 250 kcal per 30 min, from 8.0 MET and your weight.'", diff --git a/lib/ui2/activity/catalogue.dart b/lib/ui2/activity/catalogue.dart index 81bff577..59743b9d 100644 --- a/lib/ui2/activity/catalogue.dart +++ b/lib/ui2/activity/catalogue.dart @@ -1,5 +1,6 @@ // The activity vocabulary — ~70 things a person actually does, in eight -// groups, each carrying a published MET value. +// groups, each carrying a published MET value except the one row that names +// no activity at all. // // Why MET and not a made-up "intensity": a calorie figure has to come from // somewhere, and "somewhere" is Ainsworth et al., Compendium of Physical @@ -46,7 +47,17 @@ class Activity { /// Metabolic equivalent of task — the honest basis for a calorie estimate. /// Compendium of Physical Activities, Ainsworth et al. - final double met; + /// + /// NULL for a row the compendium cannot price, which is exactly one: + /// 'General workout' means the user did not say what they did, and the + /// compendium prices named activities. Every number that could go here + /// would be a stand-in — which is the 'Custom activity' mistake below, + /// whose MET of 4.0 was invented. So [kcal] returns null, the picker and + /// the setup screen show no estimate, and the session still gets a REAL + /// calorie figure afterwards: the post-session estimator in + /// `compute/manual_session.dart` works from heart rate and never reads a + /// MET. The guess is what is missing, not the calories. + final double? met; /// Whether a route is worth recording. GPS activities get the map. final bool gps; @@ -72,10 +83,12 @@ class Activity { /// kcal = MET × 3.5 × kg / 200 × minutes. /// - /// Null when body weight is unknown. There is no default body weight: the - /// number would be indistinguishable from a real one on screen. - int? kcal(double? kg, int minutes) => - kg == null || kg <= 0 ? null : (met * 3.5 * kg / 200 * minutes).round(); + /// Null when body weight is unknown, and null when the activity carries no + /// MET. There is no default for either: both numbers would be + /// indistinguishable from a real one on screen. + int? kcal(double? kg, int minutes) => kg == null || kg <= 0 || met == null + ? null + : (met! * 3.5 * kg / 200 * minutes).round(); /// The stored `sessions.type` for this activity. String get typeKey => name.toLowerCase().replaceAll(' ', '_'); @@ -141,6 +154,11 @@ const activityLibrary = [ Activity('Baseball', LucideIcons.target, C.red, Track.duration, 5.0), Activity('Rugby', LucideIcons.volleyball, C.green, Track.duration, 8.3), Activity('Golf', LucideIcons.flag, C.green, Track.duration, 4.8, gps: true), + // Compendium 15092, "bowling, indoor, bowling alley". This row IS the + // alley — that assumption is the choice being made here, not a fact about + // what users do — because the alternative is two near-identical rows. The + // bare 15090 "bowling" is 3.0, and neither number is the measured one. + Activity('Bowling', LucideIcons.circleDot, C.indigo, Track.duration, 3.8), Activity('Boxing', LucideIcons.hand, C.red, Track.interval, 12.8), Activity('Martial arts', LucideIcons.hand, C.red, Track.duration, 10.3), Activity('Wrestling', LucideIcons.users, C.orange, Track.duration, 6.0), @@ -197,6 +215,14 @@ const activityLibrary = [ Activity('Stairs', LucideIcons.trendingUp, C.orange, Track.duration, 8.0), ]), ActGroup('Other', LucideIcons.ellipsis, [ + // The catch-all: nothing in the catalogue matched, or hunting for the + // match was not worth it. NO MET, and that is the whole design of the row + // — see [Activity.met]. 02060 "health club exercise, general" was the + // near miss, and it prices a gym session, which is not what this row + // means. Tracked by duration because the clock and the heart-rate trace + // are everything the app knows about a workout nobody named. + Activity('General workout', LucideIcons.activity, C.purple, + Track.duration, null), Activity('Dancing', LucideIcons.music, C.pink, Track.duration, 7.8), // A normal entry with a real MET and a privacy default, exactly as Apple // Health carries it. Coyness here would be its own kind of judgement. @@ -218,7 +244,8 @@ const activityLibrary = [ /// What the app has to say about a calorie figure, in one place because it was /// said in four and drifted: one site kept quoting a "±15%" error bar that no /// estimator computes, long after the others dropped it. -const kCalorieWhy = 'MET value × your weight, refined by heart rate.'; +const kCalorieWhy = 'MET value × your weight, refined by heart rate — or ' + 'heart rate alone, for an activity that carries no MET.'; /// TS-03 — what every zone chart in the app has to admit about its own edges. /// diff --git a/lib/ui2/activity/picker.dart b/lib/ui2/activity/picker.dart index 19b4e380..81150992 100644 --- a/lib/ui2/activity/picker.dart +++ b/lib/ui2/activity/picker.dart @@ -260,6 +260,16 @@ class ActivityRow extends StatelessWidget { final p = P.of(c); final l = AppLocalizations.of(c); final kcal = a.kcal(weightKg, 30); + final met = a.met; + final metStr = met?.toStringAsFixed(1); + // The catch-all row has neither: no weight means no kcal, and no named + // activity means no MET to fall back to. It gets no trailing number + // rather than a placeholder standing in for one. + final trailing = kcal != null + ? '$kcal kcal / 30 min' + : metStr == null + ? null + : '$metStr MET'; return Pressable( onTap: onTap, child: Padding( @@ -289,12 +299,11 @@ class ActivityRow extends StatelessWidget { // estimate drops off the row rather than overflowing it — the name // and the tap are what the row is for, and the same number is on // the setup screen the tap opens. - if (!bigText(c)) ...[ + if (!bigText(c) && trailing != null) ...[ const SizedBox(width: S.x2), Text( kcal == null - ? (l?.activityPickerMetValue(a.met.toStringAsFixed(1)) ?? - '${a.met.toStringAsFixed(1)} MET') + ? (l?.activityPickerMetValue(metStr!) ?? '$metStr MET') : (l?.activityPickerKcalPer30(kcal) ?? '$kcal kcal / 30 min'), style: F.over.copyWith(color: p.ink3)), diff --git a/lib/ui2/activity/setup.dart b/lib/ui2/activity/setup.dart index c2ed75c2..c6812245 100644 --- a/lib/ui2/activity/setup.dart +++ b/lib/ui2/activity/setup.dart @@ -174,14 +174,25 @@ class _ActivitySetupState extends State { child: Row(children: [ Expanded( child: Text( - est == null - ? (l?.activitySetupCaloriesNeedWeight ?? - 'Calories need your weight.') - : (l?.activitySetupCalorieEstimate(est, - _estimateMin, a.met.toStringAsFixed(1)) ?? - 'About $est kcal per $_estimateMin min, ' - 'from ${a.met.toStringAsFixed(1)} MET ' - 'and your weight.'), + a.met == null + ? (l?.activitySetupNoMetEstimate ?? + 'No estimate up front: no published MET ' + 'applies to a session that names no ' + 'activity. Calories come from your ' + 'heart rate instead — when your age, ' + 'weight and sex are set, and your ' + 'resting and maximum rates are ' + 'measured rather than assumed.') + : est == null + ? (l?.activitySetupCaloriesNeedWeight ?? + 'Calories need your weight.') + : (l?.activitySetupCalorieEstimate( + est, + _estimateMin, + a.met!.toStringAsFixed(1)) ?? + 'About $est kcal per $_estimateMin min, ' + 'from ${a.met!.toStringAsFixed(1)} ' + 'MET and your weight.'), style: F.cap.copyWith(color: p.ink3, height: 1.5)), ), ]), diff --git a/lib/ui2/activity/summary.dart b/lib/ui2/activity/summary.dart index 5686c996..89451829 100644 --- a/lib/ui2/activity/summary.dart +++ b/lib/ui2/activity/summary.dart @@ -1089,7 +1089,7 @@ class _ActivitySummaryState extends State { return l?.activitySummaryCaloriesNeedWeight ?? 'Calories need your weight.'; } - final met = a.met.toStringAsFixed(1); + final met = a.met?.toStringAsFixed(1); if (r.calories == null) { return r.strain == null ? l?.activitySummaryNoCalorieNoStrain ?? @@ -1102,6 +1102,15 @@ class _ActivitySummaryState extends State { 'one of them is not set. Strain above is the effort that ' 'was measured, on its own 0–21 scale.'; } + // No MET is the catch-all activity, whose figure is therefore entirely + // the heart-rate estimate — saying "from MET" over it would name a basis + // this session does not have. + if (met == null) { + return l?.activitySummaryCalorieNoMet ?? + 'Estimated from your heart rate and your weight. No MET is in ' + 'this figure: the session named no activity for one to apply ' + 'to.'; + } return r.avgHr == null ? l?.activitySummaryCalorieNoHr(met) ?? 'Estimated from $met MET and your weight. No heart rate reached ' diff --git a/lib/ui2/profile/gallery.dart b/lib/ui2/profile/gallery.dart index a87d982d..029a19aa 100644 --- a/lib/ui2/profile/gallery.dart +++ b/lib/ui2/profile/gallery.dart @@ -1988,7 +1988,9 @@ const _fixtureWeightKg = 72.0; /// Every number is derived from the activity's MET, track and name, so a /// 1.3-MET meditation and a 23-MET sprint cannot end up sharing a heart-rate /// curve — which is what a single shared fixture would have done, and is -/// exactly the class of bug the gallery exists to catch. +/// exactly the class of bug the gallery exists to catch. The one activity +/// with NO MET falls back to a preview-only 5.0 for the shape, and keeps its +/// real null calories — see below. /// /// This is a PREVIEW, not a measurement, and nothing outside the gallery may /// read it. What it is faithful about is SHAPE: which fields an archetype @@ -1997,7 +1999,12 @@ const _fixtureWeightKg = 72.0; ActivityResult _placeholder(Activity a) { final arch = archOf(a); final mins = 18 + (_n(a.name, 1) * 57).round(); - final avg = (58 + a.met * 7).clamp(50, 172).round(); + // The catch-all activity has no MET (it names no activity), and a fixture + // still has to draw a heart rate and a zone spread. 5.0 is invented HERE, + // in the preview, where inventing is the whole job — `calories` below stays + // `a.kcal(...)` and so previews the absent figure the real screen shows. + final met = a.met ?? 5.0; + final avg = (58 + met * 7).clamp(50, 172).round(); final peak = (avg + 9 + _n(a.name, 3) * 24).clamp(avg + 4, 198).round(); // One slot per minute, and a dropout in roughly a quarter of them, because @@ -2013,7 +2020,7 @@ ActivityResult _placeholder(Activity a) { // Mass moves up the zones with the MET. Meditation sits in Z1; a sprint // session spends its minutes at the top. - final hard = (a.met / 23).clamp(0.0, 1.0); + final hard = (met / 23).clamp(0.0, 1.0); final w = [1.6 - hard, 1.4 - hard * .5, .9 + hard, .4 + hard * 1.4, .1 + hard]; final sum = w.reduce((x, y) => x + y); final zones = [for (final x in w) (mins * x / sum)]; @@ -2026,7 +2033,7 @@ ActivityResult _placeholder(Activity a) { // Speed from the MET, which is the only thing the catalogue knows about how // fast this activity moves. Good enough for a picture, and wrong enough // that nobody could mistake it for a recording. - final km = double.parse((a.met * 1.02 * mins / 60).toStringAsFixed(2)); + final km = double.parse((met * 1.02 * mins / 60).toStringAsFixed(2)); final paceSec = (mins * 60 / km).round(); final onRoute = arch == Arch.route || arch == Arch.journey; // Laps are TAPPED, never measured — so the lap times are the invention here @@ -2046,7 +2053,7 @@ ActivityResult _placeholder(Activity a) { maxHr: peak, calories: a.kcal(_fixtureWeightKg, mins), strain: - double.parse((a.met * mins / 60 * 1.15).clamp(0, 21).toStringAsFixed(1)), + double.parse((met * mins / 60 * 1.15).clamp(0, 21).toStringAsFixed(1)), hr: hr, zoneMinutes: zones, // Only a GPS activity gets a line. An indoor row or a treadmill leaves @@ -2217,8 +2224,10 @@ class _FlowScreen extends StatelessWidget { Padding( padding: const EdgeInsets.symmetric(horizontal: S.x4), child: NavBar(a.name, - sub: '${archLabel(r.arch).toUpperCase()} · ' - '${a.met.toStringAsFixed(1)} MET'), + sub: a.met == null + ? archLabel(r.arch).toUpperCase() + : '${archLabel(r.arch).toUpperCase()} · ' + '${a.met!.toStringAsFixed(1)} MET'), ), Expanded( child: ListView( @@ -2242,9 +2251,10 @@ class _FlowScreen extends StatelessWidget { const StatusCard( 'These numbers are invented', 'Derived from this activity\'s MET and name so the ' - 'screens have something to draw. The SHAPE is real: ' - 'the fields this archetype fills, and the ones it ' - 'leaves empty.', + 'screens have something to draw — or from a ' + 'preview-only stand-in, where the activity has no ' + 'MET. The SHAPE is real: the fields this archetype ' + 'fills, and the ones it leaves empty.', icon: LucideIcons.flaskConical, ) else diff --git a/test/ui2_activity_test.dart b/test/ui2_activity_test.dart index 09894707..be4898af 100644 --- a/test/ui2_activity_test.dart +++ b/test/ui2_activity_test.dart @@ -3,9 +3,10 @@ // // Three things this file is actually protecting: // -// 1. Every activity has a published MET and every archetype has a screen — -// so the "different visual centre of gravity" claim is checkable, not a -// design-doc assertion. +// 1. Every activity has a published MET — bar the one catch-all row that +// names no activity for one to apply to — and every archetype has a +// screen, so the "different visual centre of gravity" claim is +// checkable, not a design-doc assertion. // 2. Volume never counts a bodyweight set as zero kilos. That single null // is why `load_kg` is nullable in the schema. // 3. No screen renders a bare em-dash. Absence is a StatusCard, and the @@ -175,11 +176,19 @@ void main() { } }); - test('every activity carries a physiologically plausible MET', () { + test('every activity carries a physiologically plausible MET, or none', + () { for (final a in allActivities) { - expect(a.met, greaterThanOrEqualTo(1.0), reason: a.name); - expect(a.met, lessThanOrEqualTo(25.0), reason: a.name); + final met = a.met; + if (met == null) continue; + expect(met, greaterThanOrEqualTo(1.0), reason: a.name); + expect(met, lessThanOrEqualTo(25.0), reason: a.name); } + // A null MET is not a gap to be filled later — it is the catch-all row + // saying it was told nothing. Exactly one row may say that, or the + // exception has quietly become a habit. + final unpriced = allActivities.where((a) => a.met == null).toList(); + expect(unpriced.map((a) => a.name), ['General workout']); }); test('Intimacy is a normal entry with a privacy default', () { @@ -190,6 +199,25 @@ void main() { expect(activityByName('intimacy')?.name, 'Intimacy'); }); + test('Bowling cites a compendium row, General workout cites none', () { + // 15092 "bowling, indoor, bowling alley", not 15090's bare 3.0 — and it + // stays out of `_sports`, so a bowling night reports as a session + // rather than as a match's worth of effort. `Arch.match` opens a + // two-sided scorer, and bowling has no opponent to score against. + final bowling = activityByName('bowling')!; + expect(bowling.met, 3.8); + expect(archOf(bowling), Arch.basic); + // The catch-all names no activity, so no MET can be honest for it, so + // no calorie estimate is offered up front. 02060 "health club exercise, + // general" was the near miss and it prices a gym session — which is not + // what this row means. + final general = activityByName('general_workout')!; + expect(general.met, isNull); + expect(general.kcal(80, 45), isNull, + reason: 'a weight is not enough to price an unnamed session'); + expect(general.track, Track.duration); + }); + test('quick start entries all exist in the library', () { for (final q in quickStart) { expect(activityByName(q.typeKey)?.name, q.name); @@ -1476,6 +1504,87 @@ void main() { expect(tester.takeException(), isNull); }); + testWidgets('the unpriced activity offers no number it cannot source', + (tester) async { + tester.view.physicalSize = const Size(390 * 3, 1400 * 3); + tester.view.devicePixelRatio = 3; + addTearDown(tester.view.reset); + + final general = activityByName('general_workout')!; + + // THE PICKER ROW. Every other row ends in "N kcal / 30 min", or in its + // MET when there is no body weight. This one has neither to end in, and + // the point is that it ends in nothing rather than in a placeholder. + await tester.pumpWidget( + _frame(const ActivityPicker(weightKg: 72.4), Brightness.light, 1.0)); + await tester.pumpAndSettle(); + await tester.enterText(find.byType(TextField), 'general'); + await tester.pumpAndSettle(); + expect(find.text(general.name), findsOneWidget); + expect(find.textContaining('kcal / 30 min'), findsNothing); + expect(find.textContaining('MET'), findsNothing, + reason: 'a MET this row does not have must not be printed as one'); + + // THE SETUP SCREEN. A weight IS set here, so "Calories need your + // weight" would be a lie about which input is missing. + await tester.pumpWidget( + _frame(ActivitySetup(general, weightKg: 72.4), Brightness.light, 1.0)); + await tester.pumpAndSettle(); + expect(find.textContaining('per 30 min'), findsNothing); + expect(find.textContaining('Calories need your weight'), findsNothing); + expect(find.textContaining('No estimate up front'), findsOneWidget); + expect(tester.takeException(), isNull); + }); + + testWidgets('the unpriced session still explains a measured figure', + (tester) async { + tester.view.physicalSize = const Size(390 * 3, 2400 * 3); + tester.view.devicePixelRatio = 3; + addTearDown(tester.view.reset); + + final general = activityByName('general_workout')!; + final base = _result(Arch.basic); + + // WITH calories: the heart-rate estimator ran, so there IS a figure — + // and the basis line must not attribute any of it to a MET. + await tester.pumpWidget(_frame( + ActivitySummary( + ActivityResult(general, + start: base.start, + duration: base.duration, + avgHr: 131, + maxHr: 158, + calories: 402, + hr: base.hr, + zoneMinutes: base.zoneMinutes), + weightKg: 72.4), + Brightness.light, + 1.0)); + await tester.pumpAndSettle(); + expect(find.textContaining('MET,'), findsNothing); + expect(find.textContaining('MET and your weight'), findsNothing); + expect(find.textContaining('No MET is in this figure'), findsOneWidget); + + // WITHOUT calories: the missing-anchors explanation is the one that + // applies, and it is about heart rate, not about a MET either. + await tester.pumpWidget(_frame( + ActivitySummary( + ActivityResult(general, + start: base.start, + duration: base.duration, + avgHr: 131, + maxHr: 158, + hr: base.hr, + zoneMinutes: base.zoneMinutes), + weightKg: 72.4), + Brightness.light, + 1.0)); + await tester.pumpAndSettle(); + expect(find.textContaining('No calorie figure for this session'), + findsOneWidget); + expect(tester.takeException(), isNull); + }); + testWidgets('the strength logger accumulates volume, sets and reps', (tester) async { tester.view.physicalSize = const Size(390 * 3, 2400 * 3); diff --git a/test/workout_health_mapping_test.dart b/test/workout_health_mapping_test.dart new file mode 100644 index 00000000..331cfe59 --- /dev/null +++ b/test/workout_health_mapping_test.dart @@ -0,0 +1,109 @@ +// `healthActivityForType` and `healthWorkoutTitleForType` — the app's +// `sessions.type` to HealthKit / Health Connect activity map and record title, +// which the mapper's own doc comment has named as tested here since #184 +// without the file existing. +// +// WHAT THIS CAN AND CANNOT CATCH. It pins the app's own switch, on both +// platform branches, which a host VM is the only place to do: `Platform.isIOS` +// and `Platform.isAndroid` are both false in a unit test, which is why the +// function takes `ios` rather than reading `Platform`. It does NOT call +// `writeWorkoutData`, and it cannot see the plugin's per-platform allow-lists +// or its native maps — so the platform facts asserted below were read out of +// the installed `health` 12.2.1 by hand, and a package upgrade that moved them +// would leave this suite green. Re-read them on a version bump. +// +// The failure being defended against is silent: `writeWorkoutData` throws +// `HealthException` for any type absent from THAT platform's set, before the +// platform channel, so a spelling that exists on one store and not the other +// does not degrade — it drops every workout of that type on the other one. + +import 'package:flutter_test/flutter_test.dart'; +import 'package:health/health.dart'; +import 'package:openstrap_edge/health/health_export.dart'; + +void main() { + group('healthActivityForType', () { + test('the families the two stores spell differently split by platform', () { + // #184 itself: iOS has no bare STRENGTH_TRAINING, and every strength + // workout was rejected for two releases. + expect(healthActivityForType('strength', ios: true), + HealthWorkoutActivityType.TRADITIONAL_STRENGTH_TRAINING); + expect(healthActivityForType('strength', ios: false), + HealthWorkoutActivityType.STRENGTH_TRAINING); + // The same latent bug for swims, caught before it shipped. + expect(healthActivityForType('swimming', ios: true), + HealthWorkoutActivityType.SWIMMING); + expect(healthActivityForType('swimming', ios: false), + HealthWorkoutActivityType.SWIMMING_POOL); + // Bowling exists on iOS and nowhere in Health Connect. + expect(healthActivityForType('bowling', ios: true), + HealthWorkoutActivityType.BOWLING); + expect(healthActivityForType('bowling', ios: false), + HealthWorkoutActivityType.OTHER); + }); + + test('an OTHER workout still reaches Android under its own name', () { + // Health Connect titles the record with the enum name when the write + // carries no title, so bowling landed there as "OTHER". + expect(healthWorkoutTitleForType('bowling'), 'Bowling'); + expect(healthWorkoutTitleForType('general_workout'), 'General Workout'); + expect(healthWorkoutTitleForType('table_tennis'), 'Table Tennis'); + // The ponytail ceiling, pinned so it is a known shape and not a + // surprise: acronyms come back title-cased. + expect(healthWorkoutTitleForType('hiit'), 'Hiit'); + // Nothing to title is not a title of nothing — null lets the platform + // keep its own default rather than writing an empty string. + expect(healthWorkoutTitleForType(null), isNull); + expect(healthWorkoutTitleForType(' '), isNull); + // Null, not a blank string: Android falls back to its own default only + // when the title is absent, so ' ' would write an EMPTY name — worse + // than the "OTHER" it replaced. `sessions.type` is free-form, and the + // coach can write one, so a punctuation-only type is reachable. + expect(healthWorkoutTitleForType('_'), isNull); + expect(healthWorkoutTitleForType('___'), isNull); + expect(healthWorkoutTitleForType('cold__plunge'), 'Cold Plunge'); + }); + + test('soccer is OTHER on Android — a write that reports false', () { + // Not a supported-set problem: SOCCER passes the Dart guard and is + // commented out of Health Connect's own write map, so the call returns + // false, which this file counts toward the day's give-up budget. One + // football would pause the whole day's export, sleep included. + expect(healthActivityForType('football', ios: true), + HealthWorkoutActivityType.SOCCER); + expect(healthActivityForType('football', ios: false), + HealthWorkoutActivityType.OTHER); + }); + + test('one spelling is used where only one is accepted by both', () { + for (final ios in [true, false]) { + // Bare CLIMBING and STAIRS are iOS-only; SKIING is Android-only. + expect(healthActivityForType('climbing', ios: ios), + HealthWorkoutActivityType.ROCK_CLIMBING); + expect(healthActivityForType('stairs', ios: ios), + HealthWorkoutActivityType.STAIR_CLIMBING); + expect(healthActivityForType('skiing', ios: ios), + HealthWorkoutActivityType.DOWNHILL_SKIING); + } + }); + + test('an unknown, unnamed or unmapped type lands as OTHER, not nowhere', + () { + for (final ios in [true, false]) { + // The catch-all row is deliberately here: "a general workout" is the + // user declining to say what it was, and MIXED_CARDIO or + // CROSS_TRAINING would be the app saying it for them. + expect(healthActivityForType('general_workout', ios: ios), + HealthWorkoutActivityType.OTHER); + // 'other' is what an accepted auto-detected bout is stored as. + expect(healthActivityForType('other', ios: ios), + HealthWorkoutActivityType.OTHER); + expect(healthActivityForType(null, ios: ios), + HealthWorkoutActivityType.OTHER); + expect(healthActivityForType('underwater basket weaving', ios: ios), + HealthWorkoutActivityType.OTHER); + } + }); + + }); +}