Repository navigation
Flutter Kotlin test parity matrix features section
Scope: test/features/ minus manualentry/, hydration/, mindfulness/, caffeine/, nutrition/ (owned by another agent) and minus dashboard/, devicesync/, imports/applehealth/, body/, cycle/, recovery/ (persisted separately). Covered here: activity (incl. maps/ and export/), settings, imports/csv + route imports, sleep, heart, vitals, bodyenergy, readiness, achievements, homewidgets, onboarding.
Statuses: PORTED (same logic + equivalent assertions), DIVERGED (covered but weaker/different assertions), MISSING (no Kotlin coverage and portable to a JVM unit test), N/A-WIDGET (widget/golden case with no JVM-portable core), N/A-FRAMEWORK (Flutter-only plumbing, or an Android surface only instrumentation can drive), N/A-BEHAVIOR (the Kotlin behavior itself differs, so a 1:1 test cannot pass without changing production).
Kotlin counterpart: app/src/test/kotlin/tech/mmarca/openvitals/features/activity/ActivitiesOverviewSectionsTest.kt (over activityOverviewTotals / activityOverviewBuckets / limitActivityOverviewBuckets and the extracted workoutStatisticsValues / activityOverviewStripBuckets / activityTypeFilterOptions seams), plus ActivitiesViewModelTest.kt, ui/charts/PeriodChartTest.kt, domain/insights/MetricInterpretationsTest.kt
| Flutter case | Status | Kotlin test | Note |
|---|---|---|---|
| key-metric totals > folds steps, distance, energy, cardio load and averages HRV | PORTED | ActivitiesOverviewSectionsTest.kt: key metric totals fold steps, distance, energy, cardio load and average HRV
|
activityOverviewTotals/aggregateCardioLoadConfidence exist untested |
| key-metric totals > a day with no cardio-load reading is left out of the sum | PORTED | ActivitiesOverviewSectionsTest.kt: a day with no cardio-load reading is left out of the sum
|
also asserts the skipped day still charts as 0.0 |
| buckets > a week is one bucket per day, in date order | PORTED | ActivitiesOverviewSectionsTest.kt: a week is one bucket per day, in date order
|
activityOverviewBuckets untested |
| buckets > a year rolls its days up into one bucket per month | PORTED | ActivitiesOverviewSectionsTest.kt: a year rolls its days up into one bucket per month
|
HRV-average half now asserted at the overview series layer |
| buckets > more days than buckets chunk down to the cap (7) | PORTED | ActivitiesOverviewSectionsTest.kt: more days than buckets chunk down to the cap
|
limitActivityOverviewBuckets untested |
| buckets > the week strip marks the days that carry a workout — week only | PORTED | ActivitiesOverviewSectionsTest.kt: the week strip marks the days that carry a workout - week only
|
seam activityOverviewStripBuckets |
| statistics > folds the period total, average, longest and previous total | PORTED | ActivitiesOverviewSectionsTest.kt: statistics fold the period total, average, longest and previous total
|
seam workoutStatisticsValues; workoutsByDay has no Kotlin analog |
| statistics > the HHS guideline averages by week on a month or a year | PORTED | ActivitiesOverviewSectionsTest.kt: the HHS guideline averages by week on a month or a year
|
weekCount() 1.0 vs 4.0 and 140 to 35 min |
| statistics > the filter options are the union with the selection, by label | PORTED | ActivitiesOverviewSectionsTest.kt: the filter options are the union with the selection, by label
|
seam activityTypeFilterOptions |
| an empty period derives zeroes and nulls, not a crash | PORTED | ActivitiesOverviewSectionsTest.kt: an empty period derives zeroes and nulls, not a crash
|
no aggregate empty-period derivation test |
Kotlin counterpart: app/src/test/kotlin/tech/mmarca/openvitals/features/activity/ActivitiesViewModelTest.kt, ActivityMetricsTest.kt, core/presentation/MetricDetailSectionOrderViewModelTest.kt, ActivitiesOverviewSectionsTest.kt
| Flutter case | Status | Kotlin test | Note |
|---|---|---|---|
| renders the Kotlin activities sections from a period | PORTED | ActivitiesOrderedContentTest: aPeriodRendersItsWorkoutsAndTheKeyMetricCards
|
Compose instrumentation; runs on a device, not in CI |
| the activity-type filter narrows the workout list | PORTED | ActivitiesViewModelTest.kt: selectActivityType filters loaded activities without changing selected period, selectActivityType all restores loaded activities
|
logic covered at view-model level |
| key-metric cards navigate to their metric targets | PORTED | ActivitiesOrderedContentTest: theStepsKeyMetricCardOpensTheStepsMetric
|
Compose instrumentation; runs on a device, not in CI |
| the cardio-load card opens the cardio-load detail | PORTED | ActivitiesOrderedContentTest: theCardioLoadCardOpensTheCardioLoadDetail
|
Compose instrumentation; runs on a device, not in CI |
| the goal steppers move and persist the workout goal | PORTED | ActivitiesViewModelTest.kt: moving the daily goal re-derives the goal progress + decreasing the daily goal stops at the floor
|
increaseDailyGoal/step-5/persist logic untested in Kotlin |
| a pause segment shortens moving duration and speeds moving pace | DIVERGED | ActivityMetricsTest.kt: moving duration excludes pause segments; ActivitiesViewModelTest.kt: loaded activities expose aggregate stats by activity type
|
per-type aggregate moving-speed comparison not asserted |
| best speed takes the max of avg speed and distance/moving duration | DIVERGED | ActivitiesViewModelTest.kt: loaded activities expose aggregate stats by activity type
|
only the recorded-average branch; derived-wins, either-alone, null and group-max branches unasserted |
| only the current window is loaded with the per-session route metrics | PORTED | ActivitiesViewModelTest.kt: only the current window pays for the per-session route metrics
|
expensive read exactly once; previous/baseline take the cheap loadWorkouts |
| the key-metric sparkline renders weekday label rows for a week | PORTED | ActivitiesOrderedContentTest: theKeyMetricSparklineLabelsEveryWeekdayBucket
|
Compose instrumentation; runs on a device, not in CI |
| the section order persists across notifier instances | DIVERGED | MetricDetailSectionOrderViewModelTest.kt: moveSectionToTarget_reordersAndPersists, initialOrder_usesDefaultWhenPreferencesMissing
|
persist call verified; re-read by a fresh instance not asserted |
| the week strip marks the day you trained and rings the rest | PORTED | ActivitiesOverviewSectionsTest.kt: the week strip marks the days that carry a workout - week only + marker is empty when day has movement metrics but no workout
|
only the rest-ring half of the marker rule |
Kotlin counterpart: app/src/test/kotlin/tech/mmarca/openvitals/features/activity/ActivitiesViewModelTest.kt (partial)
| Flutter case | Status | Kotlin test | Note |
|---|---|---|---|
| a loaded period lands with its display precomputed | DIVERGED | ActivitiesViewModelTest.kt: loaded activities expose aggregate stats by activity type
|
Kotlin has no precomputed display; state-population asserted, totals/chart values not |
| a permission failure becomes ScreenErrorPermissionDenied | PORTED | ActivitiesViewModelTest.kt: a permission failure becomes ScreenError PermissionDenied
|
toScreenError() now maps Health Connect's SecurityException to ScreenError.PermissionDenied; error type, cleared loading and empty workouts asserted |
| an unexpected failure carries its message to the screen | PORTED | ActivitiesViewModelTest.kt: an unexpected failure carries its message to the screen
|
asserted on sibling ActivityViewModel, not ActivitiesViewModel |
| the type filter re-slices the display without reloading | PORTED | ActivitiesViewModelTest.kt: the type filter re-slices the loaded result without reloading
|
load count asserted exactly 1 |
| moving the daily goal re-derives the goal progress | PORTED | ActivitiesViewModelTest.kt: moving the daily goal re-derives the goal progress
|
30 to 60 in six steps, all persisted, 0 reloads, goalMetDays 1 to 0 |
| a stale load cannot overwrite the newer one it lost to | PORTED | ActivitiesViewModelTest.kt: a stale load cannot overwrite the newer one it lost to
|
gated repo; the superseded week answer is dropped |
Kotlin counterpart: app/src/test/kotlin/tech/mmarca/openvitals/features/activity/ActivityDetailDisplayTest.kt (elevation profile, pace scale, split speed trace), plus ActivityMetricsTest.kt (cadence kinds, paused/moving) and ActivityDetailViewModelTest.kt
| Flutter case | Status | Kotlin test | Note |
|---|---|---|---|
| a pause segment splits the session into paused and moving time | PORTED | ActivityMetricsTest.kt: moving duration excludes pause segments
|
pausedDurationMs now asserted alongside movingDurationMs |
| an unpaused session is all moving time | PORTED | ActivityMetricsTest.kt: an unpaused session is all moving time
|
full-duration-moving asserted only via aggregates |
| only the cadence kinds that recorded something get a card | PORTED | ActivityMetricsTest.kt: only the cadence kinds that recorded something get a card
|
seam activityCadenceKinds |
| the pace scale is the slowest and fastest split, per kilometre | PORTED | ActivityDetailDisplayTest.kt: the pace scale is the slowest and fastest split, per kilometre
|
slowest/fastestSplitPaceSeconds untested except estimated-equality |
| a split with no distance leaves the scale unset, not zeroed | PORTED | ActivityDetailDisplayTest.kt: a split with no distance leaves the scale unset, not zeroed
|
|
| a workout with no route has no route distance | N/A-FRAMEWORK | none | no routeDistanceMeters on the Kotlin detail display; the route card reads workout.totalDistanceMeters or the split sum |
| the elevation profile > comes from the route altitudes, oldest first | PORTED | ActivityDetailDisplayTest.kt: the elevation profile comes from the route altitudes, oldest first
|
elevationProfile() untested |
| the elevation profile > skips the points the device gave no height for | PORTED | ActivityDetailDisplayTest.kt: the elevation profile skips the points the device gave no height for
|
RouteElevationTest.kt covers null-skip for GAIN, not the profile samples |
| the elevation profile > one height is not a profile | PORTED | ActivityDetailDisplayTest.kt: one height is not an elevation profile
|
|
| the elevation profile > a route with no altitude at all has no profile | PORTED | ActivityDetailDisplayTest.kt: a route with no altitude at all has no elevation profile
|
|
| the elevation profile > an activity with no route has no profile | PORTED | ActivityDetailDisplayTest.kt: an activity with no route has no elevation profile
|
|
| speed rebuilt from the splits > a split holds its speed across its window: the trace is a step | PORTED | ActivityDetailDisplayTest.kt: a split holds its speed across its window - the trace is a step
|
splitSpeedTrace() exists in Kotlin, untested |
| speed rebuilt from the splits > the average is distance over time — NOT the mean of the plotted points | PORTED | ActivityDetailDisplayTest.kt: the average is distance over time, NOT the mean of the plotted points
|
harmonic-vs-arithmetic assertion included |
| speed rebuilt from the splits > a recorded trace wins — a measurement beats a reconstruction | PORTED | ActivityDetailDisplayTest.kt: a recorded trace wins - a measurement beats a reconstruction
|
|
| speed rebuilt from the splits > estimated splits draw NOTHING: they are flat by construction | PORTED | ActivityDetailViewModelTest.kt: load cuts splits at the preferred distance
|
asserts splitSpeedTrace == null for estimated source |
| speed rebuilt from the splits > one split is an average, not a trace | PORTED | ActivityDetailDisplayTest.kt: one split is an average, not a trace
|
|
| speed rebuilt from the splits > device laps get a trace too | PORTED | ActivityDetailDisplayTest.kt: device laps get a trace too
|
|
| speed rebuilt from the splits > a lap with no distance or no time is skipped, not drawn at zero | PORTED | ActivityDetailDisplayTest.kt: a lap with no distance or no time is skipped, not drawn at zero
|
800/185 average asserted |
Kotlin counterpart: app/src/test/kotlin/tech/mmarca/openvitals/features/activity/ActivityDetailViewModelTest.kt, ActivityMetricRelevanceTest.kt, domain/model/ActivityBackfillTest.kt
| Flutter case | Status | Kotlin test | Note |
|---|---|---|---|
| a cycling activity > renders the recorded speed and cadence it used to drop | PORTED | ActivityDetailViewModelTest.kt: initial load backfills missing averages from samples
|
sample-backfilled averages asserted; chart presence itself is widget-only |
| a cycling activity > does not offer steps, floors or wheelchair pushes | PORTED | ActivityMetricRelevanceTest.kt: bike ride does not advertise steps floors or wheelchair pushes, hardware bound metrics are never relevant when absent
|
|
| a cycling activity > surfaces cadence and power averages on the metric rows | DIVERGED | UnitFormatterTest.kt: cadence uses rpm
|
row wiring and power/recorded-speed formatting unasserted |
| a cycling activity > a failing cadence read costs the card, not the screen | PORTED | ActivityDetailViewModelTest.kt: a failing cadence read costs the card, not the screen
|
fixed: the cadence read now degrades to empty inside its own runCatching |
| a running activity > gets step cadence in steps per minute, not revolutions | N/A-BEHAVIOR | blocked on behavior decision - Kotlin formats all cadence as rpm, so there is no steps-per-minute unit to assert | |
| a running activity > shows pace and steps, and no crank | PORTED | ActivityMetricRelevanceTest.kt: run prefers pace and walks the step based sets
|
wheelchair-pushes and floors negatives added |
| a recorded value is shown even when the type says it is irrelevant | PORTED | ActivityMetricRelevanceTest.kt: a recorded value is shown even when the type says it is irrelevant
|
seam showsMetricRow |
| a walking activity recorded by a watch > shows the totals its session record never carried | N/A-BEHAVIOR | none | blocked on behavior decision - Kotlin has no window-aggregate session-metrics backfill at all (same gap as the activity_backfill rows in domain-data-core.md) |
| a walking activity recorded by a watch > derives a distance from speed when no distance was written | PORTED | ActivityDetailViewModelTest.kt: initial load backfills missing averages from samples; ActivityBackfillTest.kt: a recorded distance beats one integrated from speed
|
|
| a walking activity recorded by a watch > a failing metrics read costs the numbers, not the screen | PORTED | ActivityDetailViewModelTest.kt: a failing marker read costs the marks, not the screen
|
Kotlin folds the session metrics into loadWorkout, so the marker read is the degrading read that stands in for Flutter's separate metrics read; it too is guarded now |
| a strength session shows no distance metrics at all | PORTED | ActivityMetricRelevanceTest.kt: a strength session reports none of the distance metrics
|
trait covered; per-metric relevance for strength unasserted |
Kotlin counterpart: app/src/test/kotlin/tech/mmarca/openvitals/domain/insights/ActivitySplitsTest.kt, features/activity/ActivityDetailViewModelTest.kt, domain/preferences/ActivitySplitDistanceTest.kt
| Flutter case | Status | Kotlin test | Note |
|---|---|---|---|
| device laps render as "Laps", credited to the recording device | PORTED | ActivitySplitsTest.kt: device laps win over a route, and are NOT re-cut to the split distance, uneven device laps keep their own lengths
|
header copy widget-only |
| a GPS route renders as auto-derived splits, with the split distance in the header | PORTED | ActivitySplitsTest.kt: cut at exactly the right distance, with the crossing time INTERPOLATED between fixes, average heart rate covers only the samples inside the split window
|
|
| the split-distance preference drives the header and the cuts | PORTED | ActivityDetailViewModelTest.kt: load cuts splits at the preferred distance, split distance preference change re-cuts splits without reloading; ActivitySplitsTest.kt: a custom split distance (5 km, the cyclist case) is honoured
|
|
| speed samples (no route) still derive real splits | PORTED | ActivitySplitsTest.kt: integrate v dt to cut at the right times, with no route at all
|
|
| with no route and no speed samples the card says ESTIMATED and explains why | PORTED | ActivitySplitsTest.kt: every estimated split shares the activity average pace, and the source says so
|
explanatory copy widget-only |
| a failing speed read degrades to estimated splits instead of blowing up the screen | PORTED | ActivityDetailViewModelTest.kt: a failing speed read degrades to estimated splits instead of blowing up the screen
|
fixed: loadSpeedSamples degrades to empty, so the splits fall back to ESTIMATED |
| an activity with no distance hides the card entirely | PORTED | ActivitySplitsTest.kt: a session with no distance, no route and no speed has no splits
|
|
| imperial units re-express the derived header in miles | PORTED | ActivitySplitDistanceLabelTest.kt: imperial units re-express the derived header in miles + metric presets print as the round numbers the user picked + every imperial preset labels as a round mile fraction
|
splitDistanceLabel composition now covered |
Kotlin counterpart: app/src/test/kotlin/tech/mmarca/openvitals/features/activity/ActivityDetailViewModelTest.kt
| Flutter case | Status | Kotlin test | Note |
|---|---|---|---|
| deletes an owned activity and invokes the pop callback | PORTED | ActivityDetailViewModelTest.kt: deleteActivity deletes OpenVitals activity and reports completion
|
|
| does not delete a foreign activity | PORTED | ActivityDetailViewModelTest.kt: deleteActivity ignores workout not created by OpenVitals
|
|
| keeps the screen and records the error when the delete fails | PORTED | ActivityDetailViewModelTest.kt: keeps the screen and records the error when the delete fails
|
delete-failure path untested |
Kotlin counterpart: app/src/test/kotlin/tech/mmarca/openvitals/domain/insights/HeartRateRecoveryTest.kt, features/activity/ActivityDetailViewModelTest.kt
| Flutter case | Status | Kotlin test | Note |
|---|---|---|---|
| LoadActivityDetailUseCase heart-rate recovery > an ordinary workout with no cessation mark is not measured | PORTED | ActivityDetailViewModelTest.kt: no recovery window issues no extra heart rate read; HeartRateRecoveryTest.kt: a session with no rest segment has no recovery window
|
|
| LoadActivityDetailUseCase heart-rate recovery > a guided test reads the recovery on its own window | PORTED | ActivityDetailViewModelTest.kt: recovery window issues its own heart rate read and exposes the reading
|
exact window bounds asserted |
| LoadActivityDetailUseCase heart-rate recovery > measures the fall for a strap that kept recording | PORTED | HeartRateRecoveryTest.kt: a chest strap at 1Hz measures every mark and reads clean
|
|
| LoadActivityDetailUseCase heart-rate recovery > a failed recovery read costs the card, not the screen | PORTED | ActivityDetailViewModelTest.kt: a failed recovery read costs the card, not the screen
|
fixed: loadHeartRateRecovery is guarded, leaving a null reading and the rest of the detail intact |
| LoadActivityDetailUseCase heart-rate recovery > the observed maximum is used, and is not an estimate | PORTED | HeartRateRecoveryTest.kt: the same peak against a KNOWN max is submaximal (tighter band) (asserts maxUsed + estimated=false), an observed max below the trust bar is not used as a maximum
|
Kotlin additionally keeps an explicit-max rung Flutter removed |
| LoadActivityDetailUseCase heart-rate recovery > with no observed maximum it falls back to the age estimate | PORTED | HeartRateRecoveryTest.kt: the age formula is Tanaka (208 - 0,7 x age), flagged estimated
|
|
| ActivityHeartRateRecoveryCard > shows the fall after a guided test | PORTED | HeartRateRecoveryCardTest: showsTheFallAfterAGuidedTest
|
Compose instrumentation; runs on a device, not in CI |
| ActivityHeartRateRecoveryCard > a watch that stopped recording says so, and shows dashes rather than numbers | PORTED | HeartRateRecoveryCardTest: aWatchThatStoppedRecordingSaysSoRatherThanShowingNumbers
|
Compose instrumentation; runs on a device, not in CI |
| ActivityHeartRateRecoveryCard > an ordinary workout shows no card at all | PORTED | ActivityDetailRecoveryCardGateTest: anOrdinaryWorkoutShowsNoCardAtAll
|
Compose instrumentation; runs on a device, not in CI |
| ActivityHeartRateRecoveryCard > a submaximal guided test still shows the card, flagged | PORTED | HeartRateRecoveryCardTest: aSubmaximalGuidedTestStillShowsTheCardFlagged
|
Compose instrumentation; runs on a device, not in CI |
| ActivityHeartRateRecoveryCard > with no maximum knowable at all, the card appears and asks for one | PORTED | HeartRateRecoveryCardTest: withNoMaximumKnowableTheCardAppearsAndAsksForOne
|
Compose instrumentation; runs on a device, not in CI |
Kotlin counterpart: app/src/test/kotlin/tech/mmarca/openvitals/features/activity/ActivityPresentationMapperTest.kt, ActivityViewModelTest.kt, domain/insights/DailyGoalsTest.kt
| Flutter case | Status | Kotlin test | Note |
|---|---|---|---|
| steps > sums values and counts only the days with movement | PORTED | ActivityPresentationMapperTest.kt: steps display sums values and counts only the days with movement
|
total, best, trackedDates, sampleCount and activeDays all asserted |
| steps > the daily average divides by active days, not calendar days | PORTED | ActivityPresentationMapperTest.kt: the daily average divides by active days, not calendar days
|
asserted only for the calories path (CaloriesDerivationsTest), not the metric display |
| steps > compares against the previous period total | PORTED | ActivityPresentationMapperTest.kt: steps display compares against the previous period total
|
comparison direction covered at domain level; mapper previousTotal wiring untested |
| steps > goal progress counts the days that reached the target | PORTED | ActivityPresentationMapperTest.kt: steps display computes goal progress; DailyGoalsTest.kt: at least goals count tracked days and streaks
|
|
| steps > a week with no rows has no data; a day always does | PORTED | ActivityPresentationMapperTest.kt: a week with no rows has no data, a day always does
|
"a day always has data" half unasserted |
| sample count > a day is described by its intraday samples | PORTED | ActivityPresentationMapperTest.kt: a day is described by its intraday samples
|
sampleCount/dayTotal derivation untested |
| sample count > a longer period is described by its active days | PORTED | ActivityPresentationMapperTest.kt: steps display sums values and counts only the days with movement
|
zero-day-excluded sampleCount now asserted |
| sample count > intraday points are dropped for a metric the device never sampled | PORTED | ActivityPresentationMapperTest.kt: intraday points are dropped for a metric the device never sampled
|
|
| per-metric slices > calories burned reads the nutrition slice, not daily steps | PORTED | ActivityPresentationMapperTest.kt: calories burned reads the nutrition slice, not daily steps
|
contrasting dailySteps row and previousTotal asserted |
| per-metric slices > a nullable metric has no data until a row actually carries it | PORTED | ActivityPresentationMapperTest.kt: a nullable metric has no data until a row actually carries it
|
essence at state level; mapper hasData/activeDays unasserted |
| per-metric slices > steps and distance always have data when rows exist | DIVERGED | ActivityPresentationMapperTest.kt: steps has data whenever rows exist, distance needs a positive one
|
deliberate contract difference - Kotlin distance hasData requires a positive row; asserted explicitly rather than changing behavior |
| per-metric slices > each metric reads its own column | PORTED | ActivityPresentationMapperTest.kt: each metric reads its own column
|
all six columns |
| every metric maps to its own goal key | PORTED | ActivityPresentationMapperTest.kt: every metric maps to its own goal key
|
goal-key uniqueness untested |
Kotlin counterpart: app/src/test/kotlin/tech/mmarca/openvitals/features/activity/ActivityMetricRelevanceTest.kt
| Flutter case | Status | Kotlin test | Note |
|---|---|---|---|
| cycling > reports the absence of the metrics a bike ride can produce | PORTED | ActivityMetricRelevanceTest.kt: bike ride does not advertise steps floors or wheelchair pushes + universal metrics are relevant for every exercise type
|
|
| cycling > stays silent about metrics a bike ride can never produce | PORTED | ActivityMetricRelevanceTest.kt: bike ride does not advertise steps floors or wheelchair pushes, hardware bound metrics are never relevant when absent
|
|
| sensor-only metrics earn a row by being recorded, never by being missing | PORTED | ActivityMetricRelevanceTest.kt: hardware bound metrics are never relevant when absent
|
3 metrics x 4 exercise types |
| running > reports steps and pace | PORTED | ActivityMetricRelevanceTest.kt: run prefers pace and walks the step based sets
|
distance and elevation added |
| running > stays silent about the crank | PORTED | ActivityMetricRelevanceTest.kt: run prefers pace and walks the step based sets
|
wheelchair-pushes and floors negatives unasserted |
| indoor activities do not report a missing elevation gain | PORTED | ActivityMetricRelevanceTest.kt: indoor activities do not report missing elevation
|
stationary bike, rowing machine, pool and the outdoor contrast added |
| floors climbed belongs to stair climbing and nothing else | PORTED | ActivityMetricRelevanceTest.kt: single purpose metrics belong to their single exercise
|
negatives for running, biking and wheelchair added |
| wheelchair pushes belong to wheelchair and nothing else | PORTED | ActivityMetricRelevanceTest.kt: single purpose metrics belong to their single exercise
|
steps-irrelevant-for-wheelchair added |
| a strength session reports none of the distance metrics | PORTED | ActivityMetricRelevanceTest.kt: a strength session reports none of the distance metrics
|
only the distance-based trait covered (ExerciseTypeTraitsTest); per-metric relevance unasserted |
| an unknown exercise type reports the universal absences and nothing invented | PORTED | ActivityMetricRelevanceTest.kt: an unknown exercise type reports the universal absences and nothing invented
|
universal positives asserted for unknown type; invented-metric negatives unasserted |
Kotlin counterpart: app/src/test/kotlin/tech/mmarca/openvitals/features/activity/ActivityRouteExportTest.kt (writers/filenames only)
| Flutter case | Status | Kotlin test | Note |
|---|---|---|---|
| route card offers open-in-map, GPX/KMZ save and GPX/KMZ share actions | PORTED | ActivityRouteExportButtonsTest: aRouteOffersOpenInMapAndBothSaveAndShareFormats
|
Compose instrumentation; runs on a device, not in CI |
| no route means no export actions | PORTED | ActivityRouteExportButtonsTest: aWorkoutWithoutARouteOffersNoExportAtAll
|
Compose instrumentation; runs on a device, not in CI |
Kotlin counterpart: app/src/test/kotlin/tech/mmarca/openvitals/features/activity/ActivityViewModelTest.kt, ActivityPresentationMapperTest.kt
| Flutter case | Status | Kotlin test | Note |
|---|---|---|---|
| Steps screen renders the Kotlin sections once loaded | PORTED | ActivityMetricSectionsTest: stepsPeriodRendersTheChartGoalStatisticsConfidenceAndEntries
|
Compose instrumentation; runs on a device, not in CI |
| the goal steppers move and persist the daily goal | PORTED | ActivityViewModelTest.kt: the goal steppers move and persist the daily goal + the daily goal stops at its floor and its ceiling + moving the goal re-derives the goal progress without reloading
|
8000 default, 500 step, every move persisted |
| {steps,distance,caloriesOut,activeCalories,floors,elevation,wheelchair} screen renders the shared sections (7 cases) | PORTED | ActivityMetricSectionsTest: one test per metric (steps…, distance…, caloriesBurned…, activeCalories…, floors…, elevation…, wheelchairPushes…RendersTheSharedSections) |
Compose instrumentation; runs on a device, not in CI |
| the day range shows the intraday chart, not the period chart | DIVERGED | ActivityViewModelTest.kt: load for DAY range calls loadActivityProgress, load for WEEK range returns empty activityProgress
|
data-selection logic covered; chart-type swap not asserted |
| Steps screen shows the access gate when permission missing | N/A-WIDGET | permission-gate rendering | |
| Steps screen shows the empty placeholder with no data | PORTED | ActivityMetricSectionsTest: stepsWithNoDataSaysSoInsteadOfDrawingAnEmptyChart
|
Compose instrumentation; runs on a device, not in CI |
Kotlin counterpart: app/src/test/kotlin/tech/mmarca/openvitals/features/activity/CaloriesDerivationsTest.kt, ActivityPresentationMapperTest.kt
| Flutter case | Status | Kotlin test | Note |
|---|---|---|---|
| each series totals its own slice | PORTED | CaloriesDerivationsTest.kt: statistics ignore days whose calories carry no data, statistics average over the days that reported, not the days in the window; ActivityPresentationMapperTest.kt: calories burned reads the nutrition slice, not daily steps
|
|
| all-zero readings are no data — that is what shows the placeholder | DIVERGED | ActivityPresentationMapperTest.kt: calories burned display has no data when nutrition has no burned calories
|
active-calories all-zero half unasserted |
| an empty period derives empty series, not nulls | DIVERGED | CaloriesDerivationsTest.kt: statistics are null for empty input rather than zero
|
Kotlin's emptiness contract is the opposite (null-not-zero vs empty-not-null) |
Kotlin counterpart: app/src/test/kotlin/tech/mmarca/openvitals/features/activity/ActivityRouteExportTest.kt
Note: this Flutter file is itself documented as "Port of the Kotlin ActivityRouteExportTest" but has since grown 5 extra cases the Kotlin test never gained. All the underlying Kotlin functions exist in app/src/main/kotlin/tech/mmarca/openvitals/features/activity/ActivityRouteExport.kt.
| Flutter case | Status | Kotlin test | Note |
|---|---|---|---|
| kmz export writes parseable route with metadata | PORTED | ActivityRouteExportTest.kt: kmz export writes parseable route with metadata
|
Identical round-trip through RouteFileParser, same fixture values and elevation assertion (8.0 ± 0.001) |
| gpx export writes parseable route with metadata | PORTED | ActivityRouteExportTest.kt: gpx export writes parseable route with metadata
|
seam - defaulted XmlSerializer param; round-trips through RouteFileParser |
| kmz escapes markup in title and notes | PORTED | ActivityRouteExportTest.kt: kmz escapes markup in title and notes
|
routeXmlEscaped in ActivityRouteExport.kt untested; KML path is pure string building, JVM-portable |
| route export file names use selected format extension | PORTED | ActivityRouteExportTest.kt: route export file names use selected format extension
|
Same prefix/extension assertions |
| blank title falls back to activity-route | PORTED | ActivityRouteExportTest.kt: blank title falls back to activity-route
|
sanitizeRouteFileName fallback untested, pure JVM |
| sorted points require a non-empty route | PORTED | ActivityRouteExportTest.kt: sorted points require a non-empty route
|
seam private to internal; Kotlin require() throws IllegalArgumentException where Dart throws StateError |
| sorted points order by time | PORTED | ActivityRouteExportTest.kt: sorted points order by time
|
sortedBy { it.time } untested |
Kotlin counterpart: none (sharing implemented as Context.shareActivityRoute Intent/FileProvider plumbing in ActivityRouteExport.kt; no Kotlin test)
| Flutter case | Status | Kotlin test | Note |
|---|---|---|---|
| ActivityRouteSharing > sharing a route hands the sheet a real GPX file | N/A-FRAMEWORK | — | Kotlin path is ACTION_SEND Intent + FileProvider, no JVM seam; export bytes covered by ActivityRouteExportTest |
| ActivityRouteSharing > the shared file carries the format mime type so the target app recognises it | N/A-FRAMEWORK | — |
Intent.type = format.mimeType wiring; mime constants exist on ActivityRouteExportFormat but the wiring needs instrumentation |
| ActivityRouteSharing > the chooser title and the email subject reach the share sheet | N/A-FRAMEWORK | — | Kotlin uses Intent.createChooser + string resource; no subject concept |
| ActivityRouteSharing > a route with no points fails instead of sharing an empty file | PORTED | ActivityRouteExportTest.kt: sorted points require a non-empty route
|
the require() is the whole essence; the Intent half stays N/A-FRAMEWORK |
| ActivityRouteSharing > a share sheet with no target surfaces as a failure to the caller | N/A-FRAMEWORK | — |
runCatching around startActivity; platform plumbing |
| ActivityRouteExportCache > a staged export is named for the activity and its start time | DIVERGED | ActivityRouteExportTest.kt: route export file names use selected format extension
|
Filename covered; staging into cacheDir/route_exports (createActivityRouteExportFile, Context-bound) untested |
| ActivityRouteExportCache > a staged export holds the format's own bytes | DIVERGED | ActivityRouteExportTest.kt: gpx export writes parseable route with metadata + kmz export writes parseable route with metadata
|
both formats' bytes now covered; staging into cacheDir/route_exports remains Context-bound and untested |
Kotlin counterpart: none
The Kotlin app renders Mapsforge maps with the native org.mapsforge TileRendererLayer (MapsforgeRouteMap.kt), which draws labels inside the library; the Flutter label-layer geometry (visibleLabelTileRange / labelPaintTransform) is a Flutter-only re-implementation that has no Kotlin analog.
| Flutter case | Status | Kotlin test | Note |
|---|---|---|---|
| visibleLabelTileRange > a viewport sitting exactly on one tile reads that tile plus a ring | N/A-FRAMEWORK | — | Label layout done natively by mapsforge library in Kotlin |
| visibleLabelTileRange > a viewport spanning several tiles covers all of them | N/A-FRAMEWORK | — | |
| visibleLabelTileRange > a fractional zoom reads the tiles of the rounded zoom level | N/A-FRAMEWORK | — | |
| visibleLabelTileRange > a viewport at the edge of the world clamps to real tiles | N/A-FRAMEWORK | — | |
| visibleLabelTileRange > ranges compare by value, so panning within one tile reads once | N/A-FRAMEWORK | — | |
| labelPaintTransform > the reference is the map pixel at the centre of the screen, not its corner | N/A-FRAMEWORK | — | |
| labelPaintTransform > half a zoom level in, the labels scale with the tiles under them | N/A-FRAMEWORK | — | |
| labelPaintTransform > the map pixel under the top-left corner is drawn at the top-left corner | N/A-FRAMEWORK | — | |
| labelPaintTransform > the map pixel under the bottom-right corner is drawn at the bottom-right corner | N/A-FRAMEWORK | — | |
| labelPaintTransform > panning moves a label by exactly what the map moved under it | N/A-FRAMEWORK | — |
Kotlin counterpart: none
The tile-render coordination (single reader warm-up, render concurrency cap, byte-budget cache, empty-tile memoization) is a Flutter re-implementation; Kotlin gets all of this from native TileRendererLayer + AndroidUtil.createTileCache (MapsforgeRouteMap.kt:188-211).
| Flutter case | Status | Kotlin test | Note |
|---|---|---|---|
| twenty concurrent tile requests warm the renderer exactly once | N/A-FRAMEWORK | — | Fix for a mapsforge_flutter isolate bug; no Kotlin analog |
| a second request for the same tile is served from the cache and never re-renders | N/A-FRAMEWORK | — | Native tile cache in Kotlin |
| the renderer never has more than four renders in flight | N/A-FRAMEWORK | — | |
| concurrent requests for the same tile share one render | N/A-FRAMEWORK | — | |
| evicting a cached tile disposes the master but not an image already handed out | N/A-FRAMEWORK | — | dart:ui image lifecycle |
| a tile the packs do not cover is rendered once and then remembered as empty | N/A-FRAMEWORK | — | |
| a renderer failure surfaces as an empty tile, never as a thrown map | N/A-FRAMEWORK | — | |
| a failed tile is retried rather than remembered as empty | N/A-FRAMEWORK | — | |
| disposing the renderer drops its cached tiles and answers null | N/A-FRAMEWORK | — |
Kotlin counterpart: none
The same one-word policy choice exists in Kotlin: MultiMapDataStore(MultiMapDataStore.DataPolicy.DEDUPLICATE) at MapsforgeRouteMap.kt:195. The mapsforge datastore/reader classes are pure Java, so this contract suite is JVM-portable — classified MISSING, not N/A.
| Flutter case | Status | Kotlin test | Note |
|---|---|---|---|
| a tile at the seam draws data from whichever pack actually holds it | PORTED | MultiMapMergePolicyTest.kt: a tile at the seam draws data from whichever pack actually holds it
|
contract test over MultiMapDataStore(DEDUPLICATE) with a hand-written MapDataStore fake |
| every pack whose bounding box covers a tile is read, not just the first | PORTED | MultiMapMergePolicyTest.kt: every pack whose bounding box covers a tile is read, not just the first
|
contract test over MultiMapDataStore(DEDUPLICATE) with a hand-written MapDataStore fake |
| a tile only one pack covers reads only that pack | PORTED | MultiMapMergePolicyTest.kt: a tile only one pack covers reads only that pack
|
contract test over MultiMapDataStore(DEDUPLICATE) with a hand-written MapDataStore fake |
| a tile no pack covers stays empty rather than drawing a blank | PORTED | MultiMapMergePolicyTest.kt: a tile no pack covers stays empty rather than drawing a blank
|
contract test over MultiMapDataStore(DEDUPLICATE) with a hand-written MapDataStore fake |
Kotlin counterpart: (partial) app/src/test/kotlin/tech/mmarca/openvitals/features/activity/maps/OfflineMapMetadataStoreTest.kt, OfflineMapPackFormatTest.kt — the import flow itself lives untested in app/src/main/kotlin/.../maps/OfflineMapRepository.kt
| Flutter case | Status | Kotlin test | Note |
|---|---|---|---|
| imports a pmtiles pack, copies the file and records metadata | DIVERGED | OfflineMapMetadataStoreTest.kt: write and read preserves imported maps and active format
|
Only metadata persistence covered; the copy/progress/active-format-on-import path (OfflineMapRepository.importMap, ContentResolver-bound) untested |
| rejects unsupported file extensions | DIVERGED | OfflineMapPackFormatTest.kt: detects supported offline map file extensions
|
Format detection (null for .osm.pbf) covered; the import failure path itself untested |
| rejects a mapsforge pack that is not a valid map file | PORTED | OfflineMapRepositoryTest.kt: rejects a mapsforge pack that is not a valid map file
|
drives the real importMap over a mocked ContentResolver; asserts the cleanup too |
| deleteMap removes the file and its metadata entry | PORTED | OfflineMapRepositoryTest.kt: deleteMap removes the file and its metadata entry
|
— |
Kotlin counterpart: app/src/test/kotlin/tech/mmarca/openvitals/features/activity/maps/OfflineMapMetadataStoreTest.kt, OfflineMapPackFormatTest.kt (Flutter file is documented as ported from both)
| Flutter case | Status | Kotlin test | Note |
|---|---|---|---|
| write then read preserves imported maps sorted newest-first | PORTED | OfflineMapMetadataStoreTest.kt: write and read preserves imported maps and active format
|
|
| read drops missing map files and clears active format | PORTED | OfflineMapMetadataStoreTest.kt: read drops missing map files and clears active format
|
|
| preserves mapsforge format and .map extension in the path | PORTED | OfflineMapMetadataStoreTest.kt: write and read preserves mapsforge format and map extension
|
|
| migrates legacy activeMapId to activeFormat | PORTED | OfflineMapMetadataStoreTest.kt: read migrates old active map id to active format
|
Same legacy JSON fixture |
| detects supported offline map file extensions | PORTED | OfflineMapPackFormatTest.kt: detects supported offline map file extensions
|
Identical four assertions |
Kotlin counterpart: app/src/test/kotlin/tech/mmarca/openvitals/features/activity/maps/OfflineMapStyleTest.kt (the expansion was extracted from offlineMapStyleJson into the pure expandPmtilesStyle in OfflineRouteMap.kt; same asset bundled at app/src/main/assets/offline_maps/protomaps_base_style.json)
| Flutter case | Status | Kotlin test | Note |
|---|---|---|---|
| expands one source and one templated-layer copy per pack | PORTED | OfflineMapStyleTest.kt: expands one source and one templated-layer copy per pack
|
seam added: expandPmtilesStyle(root, packFileUrls) takes the file URLs, so no AssetManager or android.net.Uri is needed |
| bundled style asset parses and only references the template source | PORTED | OfflineMapStyleTest.kt: bundled style asset parses and only references the template source
|
resolves the asset from either the module or the repo root working directory |
Kotlin counterpart: app/src/test/kotlin/.../maps/OfflineRouteGeoJsonTest.kt (segments); distance covered piecemeal by RouteFileParserTest / ActivityRecordingSplitsTest; bounds/simplification delegated to MapLibre in Kotlin
| Flutter case | Status | Kotlin test | Note |
|---|---|---|---|
| routeSegments > break indexes split the route into separate segments | PORTED | OfflineRouteGeoJsonTest.kt: break indexes split the route into separate segments, tail included
|
the 1-point tail segment now asserted |
| routeSegments > invalid break indexes are ignored | PORTED | OfflineRouteGeoJsonTest.kt: invalid route break indexes are ignored
|
Identical inputs ([0, 99]) and assertion |
| routeSegments > non-finite coordinates are dropped | PORTED | OfflineRouteGeoJsonTest.kt: non-finite coordinates are dropped
|
routeSegments finite-filter (OfflineRouteGeoJson.kt:38) untested, pure JVM |
| RouteBounds.fromPoints > computes the tightest box over finite points | N/A-FRAMEWORK | — | Kotlin delegates bounds to MapLibre LatLngBounds.Builder inside view-bound fitCamera (OfflineRouteMap.kt:379) |
| RouteBounds.fromPoints > single repeated point is flagged | N/A-FRAMEWORK | — | Single-point branch (SinglePointZoom) lives in MapLibre view code |
| RouteBounds.fromPoints > returns null when there are no finite points | N/A-FRAMEWORK | — |
fitCamera size-0 branch, view-bound |
| routeTotalDistanceMeters > sums haversine distance between consecutive points | PORTED | OfflineRouteGeoJsonTest.kt: route distance sums haversine distance between consecutive points
|
exact haversine sum to 1e-6 |
| routeTotalDistanceMeters > does not bridge across a route break | PORTED | OfflineRouteGeoJsonTest.kt: route distance does not bridge across a route break
|
exact per-segment sum |
| routeTotalDistanceMeters > is zero for a single point | PORTED | OfflineRouteGeoJsonTest.kt: route distance is zero for a single point
|
Trivial JVM assert on routeDistanceMeters/activityRecordingRouteDistanceMeters, uncovered |
| simplifyRoutePoints > a route below the display cap is returned unchanged, and identical | N/A-FRAMEWORK | — | Display-time decimation exists only for flutter_map; Kotlin renders full track via MapLibre/mapsforge (and no import-time cap either: RouteFileParserTest parse keeps every point of a very large route file; Health Connect's record size limit is met at write time by OversizedRouteShrinker) |
| simplifyRoutePoints > a route longer than the display cap keeps its first and last point | N/A-FRAMEWORK | — | Same — no display decimation in Kotlin |
| simplifyRoutePoints > decimating for the map never changes the distance the screen reports | N/A-FRAMEWORK | — | Same |
| buildRouteMapGeometry > segments are split at breaks and projected for drawing | PORTED | OfflineRouteGeoJsonTest.kt: segments are split at breaks and keep their own coordinates
|
coordinate values now checked; projection stays MapLibre-side |
| buildRouteMapGeometry > a route with no finite points yields no bounds and no markers | PORTED | OfflineRouteGeoJsonTest.kt: a route with no finite points yields no line and no markers
|
portable half only; camera bounds stay MapLibre-side |
| buildRouteMapGeometry > the current point widens the camera bounds beyond the route | N/A-FRAMEWORK | — |
fitCamera includes currentPoint in cameraPoints, but that is MapLibre view code |
Kotlin counterpart: none (Compose + MapLibre/Mapsforge views; GeoJSON feature-building separately covered by OfflineRouteGeoJsonTest.kt)
| Flutter case | Status | Kotlin test | Note |
|---|---|---|---|
| renders a polyline route without fetching network tiles | PORTED | RoutePreviewTest: aRecordedRouteDraws
|
Compose instrumentation; runs on a device, not in CI |
| default (offline) render draws no tile layer and fetches no network tiles | PORTED | OfflineRouteMapTest: withNoImportedPackNoMapViewIsBuiltAndNoRecenterControlIsOffered + OfflineMapNetworkPolicyTest (JVM, config layer) |
Compose instrumentation; runs on a device, not in CI |
| shows no recenter control by default | PORTED | OfflineRouteMapTest: anImportedPackRendersAMapViewAndStillHidesTheRecenterControlByDefault
|
Compose instrumentation; runs on a device, not in CI |
| recenter control re-fits the camera to the route bounds | PORTED | OfflineRouteMapTest: recenter case (stub pack installed, getMapAsync awaited) | Compose instrumentation; runs on a device, not in CI |
| recenter control handles a single-point route | PORTED | OfflineRouteMapTest: single-point recenter case | Compose instrumentation; runs on a device, not in CI |
| handles an empty route gracefully | PORTED | RoutePreviewTest: anEmptyRouteIsHandledGracefully
|
Compose instrumentation; runs on a device, not in CI |
| rebuilding with the same points reuses the polyline layer instead of re-projecting | N/A-FRAMEWORK | — | flutter_map didUpdateWidget memoization; irrelevant to MapLibre |
| scrolling the route card out of view keeps the map alive | N/A-FRAMEWORK | — | Flutter ListView keepAlive plumbing |
Kotlin counterpart: none
Both providers are Flutter adapters (flutter_map ImageProvider / vector_map_tiles). Kotlin renders Mapsforge packs with the native library layer and PMTiles directly through MapLibre's pmtiles:// source URLs (OfflineRouteMap.kt:432) — no hand-written tile provider exists.
| Flutter case | Status | Kotlin test | Note |
|---|---|---|---|
| MapsforgeTileProvider > a tile outside pack coverage draws as transparent, not an error | N/A-FRAMEWORK | — | |
| MapsforgeTileProvider > a rendered tile reaches flutter_map as a live image | N/A-FRAMEWORK | — | dart:ui rasterization |
| MapsforgeTileProvider > tiles are keyed by coordinate, so the image cache can work | N/A-FRAMEWORK | — | Flutter ImageProvider equality contract |
| PmtilesVectorTileProvider > zoom bounds come from the archive header | N/A-FRAMEWORK | — | PMTiles reading delegated to MapLibre native in Kotlin |
| PmtilesVectorTileProvider > serves the uncompressed tile bytes | N/A-FRAMEWORK | — | |
| PmtilesVectorTileProvider > a missing tile maps to a 404 the map renders as empty | N/A-FRAMEWORK | — | |
| PmtilesVectorTileProvider > a corrupt read maps to a per-tile 500, never a crash | N/A-FRAMEWORK | — |
Kotlin counterpart: app/src/test/kotlin/tech/mmarca/openvitals/domain/preferences/ActivitySplitDistanceTest.kt, domain/preferences/ActivityRecordingPreferencesTest.kt, features/settings/SettingsViewModelTest.kt
| Flutter case | Status | Kotlin test | Note |
|---|---|---|---|
| ActivityRecordingPreferencesCard > renders the intro and all sub-controls | PORTED | ActivityRecordingAndCaffeineCardsTest: activityRecordingCard_rendersTheIntroAndEverySubControl
|
Compose instrumentation; runs on a device, not in CI |
| ActivityRecordingPreferencesCard > toggling a switch persists to the repository | PORTED | SettingsViewModelTest.kt: updateActivityRecordingPreferences persists preference and updates ui state
|
includes keepScreenOnDuringRecording |
| ActivityRecordingPreferencesCard > selecting a segment persists to the repository | PORTED | SettingsViewModelTest.kt: updateActivityRecordingPreferences persists preference and updates ui state
|
includes autoIdleTimeoutSeconds |
| ActivityRecordingPreferencesCard > idle-timeout choice is disabled when auto-idle is off | PORTED | ActivityRecordingAndCaffeineCardsTest: activityRecordingCard_idleTimeoutIsDeadWhileAutoIdleIsOff
|
Compose instrumentation; runs on a device, not in CI |
| FavoriteActivityCard > selecting a type persists and "latest" clears it | DIVERGED | SettingsViewModelTest.kt: selectFavoriteActivity persists preference and updates ui state
|
set is asserted; clearing back to null is not |
| ActivitySplitDistanceCard > offers metric presets and saves the choice in METERS | DIVERGED | ActivitySplitDistanceTest.kt: nearestPreset highlights the closest chip after a unit-system switch
|
preset/normalize logic covered; persistence via prefs not asserted |
| ActivitySplitDistanceCard > offers mile presets in imperial but still stores meters | PORTED | ActivitySplitDistanceTest.kt: imperial presets are exact mile fractions, not rounded meters
|
same 1609.344 exactness assertion |
Kotlin counterpart: app/src/test/kotlin/tech/mmarca/openvitals/features/imports/applehealth/AppleHealthImportServiceTest.kt, AppleHealthImportProgressTest.kt, features/settings/SettingsViewModelTest.kt
| Flutter case | Status | Kotlin test | Note |
|---|---|---|---|
| renders header, permission line and analyze action | PORTED | AppleHealthImportCardTest: theCardNamesTheImporterItsAccessAndTheWayIn
|
Compose instrumentation; runs on a device, not in CI |
| analyze populates the analysis result and category checklist | DIVERGED | AppleHealthImportServiceTest.kt: service analysis detects import categories without writing
|
category/route-count detection covered; card checklist state not |
| toggling a category then importing calls import with the selected set and shows the result | PORTED | AppleHealthImportServiceTest.kt: service imports only selected categories after analysis
|
|
| save report action invokes the save seam and confirms | PORTED | AppleHealthImportCardTest: theReportActionsHandOverTheExactReportTheImportProduced
|
Compose instrumentation; runs on a device, not in CI |
| share report action hands the sheet the same report the save writes | PORTED | AppleHealthImportCardTest: theReportActionsHandOverTheExactReportTheImportProduced
|
Compose instrumentation; runs on a device, not in CI |
| a share with no target shows the failure in the card | N/A-WIDGET | — | |
| analysis failure shows the error text | DIVERGED | AppleHealthImportServiceTest.kt: failed staged analysis clears the local copy before retry
|
staging-clear-on-failure covered; error-text surfacing not |
| a successful import stages the pick, then clears the staged export and its checkpoint | DIVERGED | AppleHealthImportServiceTest.kt: staged analysis reuses its verified copy for import (+ staging cleanup deletes private export files but preserves selected source) |
checkpoint-clear-on-success not asserted |
| an incomplete workout-route archive shows the warning row | PORTED | AppleHealthImportCardTest: anArchiveThatLostItsWorkoutRoutesSaysSoRatherThanReportingSuccess
|
Compose instrumentation; runs on a device, not in CI |
| the import is handed to the foreground service, not run in the UI isolate | DIVERGED | SettingsViewModelTest.kt: apple import observer uses current import work over older failures
|
WorkManager enqueue carries categories + both denominators, but enqueue is stubbed, not the focus |
| progress and the result from the service isolate drive the card | DIVERGED | AppleHealthImportProgressTest.kt: percent uses raw scan progress when analyzed element total is known
|
progress math ported; worker-to-card wiring untested |
| an error from the service isolate shows in the card | PORTED | SettingsViewModelTest.kt: apple import observer uses current import work over older failures
|
worker error reaches uiState.appleHealthImportError |
| a running activity recording refuses the import instead of crashing on the single foreground service | N/A-FRAMEWORK | — | single-ForegroundService constraint is Flutter-side; WorkManager has no such conflict |
| an import still running on relaunch re-attaches to the card | DIVERGED | SettingsViewModelTest.kt: apple import observer ignores stale finished failures without current work
|
observer attach covered for failures; running-progress re-attach not |
Kotlin counterpart: none
| Flutter case | Status | Kotlin test | Note |
|---|---|---|---|
| leaving the screen with a flow open resets it without notifying the dying element | N/A-FRAMEWORK | — | Riverpod dispose/notify regression; no Compose analogue |
Kotlin counterpart: app/src/test/kotlin/tech/mmarca/openvitals/features/settings/BleDevicesViewModelTest.kt (sensor add/edit flow), plus domain/model/BleDeviceKindTest.kt. The Garmin watch-onboarding half left with the device layer (now OpenWatch); its rows are gone from this table.
| Flutter case | Status | Kotlin test | Note |
|---|---|---|---|
| selecting a device auto-discovers capabilities via the GATT probe | PORTED | BleDevicesViewModelTest.kt: selecting a device auto-discovers capabilities via the GATT probe
|
Kotlin VM has identical selectDiscoveredDevice, untested |
| falls back to advertised capabilities when the probe finds none | PORTED | BleDevicesViewModelTest.kt: falls back to advertised capabilities when the probe finds none
|
fallback branch exists in Kotlin VM, untested |
| flags a capability conflict against an already-paired device | PORTED | BleDevicesViewModelTest.kt: flags a capability conflict against an already-paired device
|
capabilityConflicts untested |
| saving a speed sensor persists the wheel circumference | PORTED | BleDevicesViewModelTest.kt: saving a speed sensor persists the wheel circumference
|
|
| non-speed sensors are saved without a wheel circumference | PORTED | BleDevicesViewModelTest.kt: non-speed sensors are saved without a wheel circumference
|
|
| saving with no capabilities surfaces an error and does not persist | PORTED | BleDevicesViewModelTest.kt: saving with no capabilities surfaces an error and does not persist
|
|
| toggling a capability recomputes conflicts | PORTED | BleDevicesViewModelTest.kt: toggling a capability recomputes conflicts
|
|
| edit flow loads the device and saves changes | PORTED | BleDevicesViewModelTest.kt: edit flow loads the device and saves changes
|
|
| removing the edited device closes the edit flow | PORTED | BleDevicesViewModelTest.kt: removing the edited device closes the edit flow
|
|
| the freezed state keeps its defaults and its derived getter | N/A-FRAMEWORK | — | freezed-conversion pin; Kotlin data-class defaults are language-level (enabledDeviceCount getter exists, untested) |
| enabledDeviceCount survives the conversion | PORTED | BleDevicesViewModelTest.kt: enabledDeviceCount counts only the enabled devices
|
the derived getter is now asserted through the VM state |
| copyWith still clears a nullable field when passed null | N/A-FRAMEWORK | — | Dart copyWith _unset sentinel semantics |
| the freezed state compares by value | N/A-FRAMEWORK | — | Kotlin data class equality is language-guaranteed |
Kotlin counterpart: none (Body Energy diagnostics feature not ported; features/settings/DiagnosticsCards.kt has no such card)
| Flutter case | Status | Kotlin test | Note |
|---|---|---|---|
| does not read anything until it is asked to | N/A-WIDGET | — | lazy-load gating; underlying feature absent in Kotlin |
| renders the report and offers a copy once run | N/A-WIDGET | — | |
| warns when more than one app wrote active calories | MISSING | none | blocked - feature not ported to Kotlin (no buildBodyEnergyDiagnostics equivalent) |
| says so when no watch samples are stored | MISSING | none | blocked - feature not ported to Kotlin (no buildBodyEnergyDiagnostics equivalent) |
Kotlin counterpart: none (buildBodyEnergyDiagnostics / BodyEnergyDiagnosticsReport has no Kotlin equivalent anywhere in main or test)
| Flutter case | Status | Kotlin test | Note |
|---|---|---|---|
| the component decomposition > activity uses the per-point max, never the sum | MISSING | — | blocked - feature not ported to Kotlin (no buildBodyEnergyDiagnostics equivalent anywhere in app/src) |
| the component decomposition > a tie counts as the calorie estimate winning | MISSING | — | blocked - feature not ported to Kotlin (no buildBodyEnergyDiagnostics equivalent anywhere in app/src) |
| the component decomposition > buckets with no activity drain count for neither side | MISSING | — | blocked - feature not ported to Kotlin (no buildBodyEnergyDiagnostics equivalent anywhere in app/src) |
| clipping > a floored day reports its buckets and when it first pinned | MISSING | — | blocked - feature not ported to Kotlin (no buildBodyEnergyDiagnostics equivalent anywhere in app/src) |
| clipping > a ledger that does not balance is flagged | MISSING | — | blocked - feature not ported to Kotlin (no buildBodyEnergyDiagnostics equivalent anywhere in app/src) |
| the watch totals > are delta sums, not start minus end | MISSING | — | blocked - feature not ported to Kotlin (no buildBodyEnergyDiagnostics equivalent anywhere in app/src) |
| the watch totals > are absent when the watch never synced that day | MISSING | — | blocked - feature not ported to Kotlin (no buildBodyEnergyDiagnostics equivalent anywhere in app/src) |
| the watch totals > drainError is signed so an over-draining model is visible | MISSING | — | blocked - feature not ported to Kotlin (no buildBodyEnergyDiagnostics equivalent anywhere in app/src) |
| per-influence errors > are signed, count-weighted, and omit unobserved influences | MISSING | — | blocked - feature not ported to Kotlin (no buildBodyEnergyDiagnostics equivalent anywhere in app/src) |
| source attribution > flags a day two apps both wrote active calories for | MISSING | — | blocked - feature not ported to Kotlin (no buildBodyEnergyDiagnostics equivalent anywhere in app/src) |
| source attribution > stays quiet when one app wrote them | MISSING | — | blocked - feature not ported to Kotlin (no buildBodyEnergyDiagnostics equivalent anywhere in app/src) |
| source attribution > does not confuse two metrics for two calorie sources | MISSING | — | blocked - feature not ported to Kotlin (no buildBodyEnergyDiagnostics equivalent anywhere in app/src) |
| toReportText > is stable, explicit, and carries the decisive figures | MISSING | — | blocked - feature not ported to Kotlin (no buildBodyEnergyDiagnostics equivalent anywhere in app/src) |
| toReportText > names missing permissions rather than showing an empty report | MISSING | — | blocked - feature not ported to Kotlin (no buildBodyEnergyDiagnostics equivalent anywhere in app/src) |
| toReportText > says so when the per-source read was truncated | MISSING | — | blocked - feature not ported to Kotlin (no buildBodyEnergyDiagnostics equivalent anywhere in app/src) |
Kotlin counterpart: app/src/test/kotlin/tech/mmarca/openvitals/features/settings/BodyProfileCardTest.kt, domain/preferences/BodyEnergyCalibrationTest.kt, features/settings/SettingsViewModelTest.kt
| Flutter case | Status | Kotlin test | Note |
|---|---|---|---|
| BodyProfileCard > seeds fields from stored profile | PORTED | BodyProfileCardScreenTest: seedsItsFieldsFromTheStoredProfile
|
Compose instrumentation; needs a device, so not run in CI |
| BodyProfileCard > shows where weight came from | PORTED | BodyProfileCardScreenTest: showsWhereAMeasuredValueCameFrom
|
Compose instrumentation; needs a device, so not run in CI |
| BodyProfileCard > editing a field and saving persists via bodyProfile() | DIVERGED | SettingsViewModelTest.kt: updateBodyProfile writes measurements only on change and with permission
|
persistence covered at VM level; field parse/save-button path not |
| the Body profile screen shows each fact once > the birth year is not asked for twice | PORTED | BodyProfileCardScreenTest: theBirthYearIsNotAskedForTwice
|
Compose instrumentation; needs a device, so not run in CI |
| the Body profile screen shows each fact once > the zones and tuning live inside the Body card, not beside it | N/A-WIDGET | — | layout composition |
| the Body profile screen shows each fact once > but standalone it still carries a birth year and a Save | PORTED | BodyProfileCardScreenTest: standaloneItStillCarriesABirthYearAndASave
|
Compose instrumentation; needs a device, so not run in CI |
| BodyEnergyCalibrationCard > toggling manual zones reveals the five zone fields | PORTED | BodyEnergyCalibrationCardTest: togglingManualZonesRevealsTheFiveZoneFields
|
Compose instrumentation; runs on a device, not in CI |
| BodyEnergyCalibrationCard > editing zones and saving persists and completes setup | DIVERGED | BodyEnergyCalibrationTest.kt: manual zones round trip through preference string + normalization preserves setupCompleted flag
|
model round-trip only; save flow untested |
| BodyEnergyCalibrationCard > switching manual zones off keeps the typed ladder | PORTED | BodyEnergyCalibrationCardTest.kt: switching manual zones off keeps the typed ladder
|
plus an invalid ladder is the one thing that does erase it
|
Kotlin counterpart: app/src/test/kotlin/tech/mmarca/openvitals/features/settings/CaffeinePreferencesCardTest.kt and SettingsViewModelTest.kt
| Flutter case | Status | Kotlin test | Note |
|---|---|---|---|
| renders the caffeine fields seeded from preferences | PORTED | ActivityRecordingAndCaffeineCardsTest: caffeineCard_seedsItsFieldsFromTheStoredPreferences
|
Compose instrumentation; runs on a device, not in CI |
| editing a field and saving persists via the repository | PORTED | SettingsViewModelTest.kt: updateCaffeinePreferences persists preference and updates ui state
|
halfLife=360 + profileCompleted=true persisted, same essence |
| an out-of-range half-life is clamped by the repository on save | PORTED | CaffeinePreferencesCardTest.kt: an out-of-range half-life is clamped on save
|
plus SettingsViewModelTest.kt: saving an out-of-range half-life reseeds from the clamped stored value
|
| LocalTime bedtime default matches the model | PORTED | CaffeinePreferencesCardTest.kt: the bedtime default matches the model
|
DefaultBedtime 22:30 exists in Kotlin, untested |
Kotlin counterpart: app/src/test/kotlin/tech/mmarca/openvitals/features/settings/SettingsViewModelTest.kt (partial; Kotlin has no draft/seedRevision card VM)
| Flutter case | Status | Kotlin test | Note |
|---|---|---|---|
| build seeds the draft and the body profile from preferences | PORTED | SettingsViewModelTest.kt: build seeds the caffeine preferences and the body profile from storage
|
no draft layer in Kotlin; the seeding is the settings ui state |
| save persists the whole draft with profileCompleted | DIVERGED | SettingsViewModelTest.kt: updateCaffeinePreferences persists preference and updates ui state
|
no draft-vs-disk staging in Kotlin; whole-object persist asserted |
| save reseeds the draft from the clamped stored value | PORTED | SettingsViewModelTest.kt: saving an out-of-range half-life reseeds from the clamped stored value
|
clamp-on-write + reseed untested |
Kotlin counterpart: none (CSV import logic itself covered under features/imports/csv/ tests)
| Flutter case | Status | Kotlin test | Note |
|---|---|---|---|
| the card names the importer and what it is for | PORTED | SettingsSmallCardsTest: csvImportCard_namesTheImporterAndWhatItIsFor
|
Compose instrumentation; runs on a device, not in CI |
| tapping the action opens the CSV import route | N/A-FRAMEWORK | — | navigation wiring; route exists in Kotlin AppNavigationSettingsRoutes.kt |
Kotlin counterpart: app/src/test/kotlin/tech/mmarca/openvitals/core/diagnostics/PrivacySafeDebugLogExporterTest.kt
| Flutter case | Status | Kotlin test | Note |
|---|---|---|---|
| renders the title, body and the share + save actions | PORTED | SettingsSmallCardsTest: debugDiagnosticsCard_rendersTheTitleBodyAndBothActions
|
Compose instrumentation; runs on a device, not in CI |
| save flow sanitizes the logcat and reports success | DIVERGED | PrivacySafeDebugLogExporterTest.kt: export masks identifiers, addresses and dates and keeps the tag and the numbers (+ an exception's message is masked whole and its frames stay) |
masking covered, every tag kept; package=/version= header and file name unasserted |
| degrades gracefully when the native channel is unavailable | N/A-FRAMEWORK | — | Flutter platform-channel absence; Kotlin reads logcat natively |
| share flow sanitizes the logcat and reaches the share seam | DIVERGED | PrivacySafeDebugLogExporterTest.kt: export masks identifiers, addresses and dates and keeps the tag and the numbers
|
masking covered; Android chooser seam untested |
| share failure surfaces the failure snackbar | N/A-WIDGET | — | |
| share degrades gracefully when the native channel is missing | N/A-FRAMEWORK | — |
Kotlin counterpart: app/src/test/kotlin/tech/mmarca/openvitals/features/settings/FitFolderImportTest.kt (the SAF folder walk lives in RouteFolderScanner, mocked at the seam; SettingsViewModel.importFitFolder drives it)
| Flutter case | Status | Kotlin test | Note |
|---|---|---|---|
| imports every FIT file the folder held | PORTED | FitFolderImportTest.kt: imports every FIT file the folder held
|
also asserts the run is tagged FIT_FOLDER so the FIT card, not the route card, shows it |
| opens the files one at a time, not the whole folder at once | PORTED | FitFolderImportTest.kt: opens the files one at a time, not the whole folder at once
|
scan returns URIs only; importer opens each in scan order |
| a cancelled pick leaves the card exactly as it was | N/A-FRAMEWORK | — | picker cancellation handled by Android activity-result (OpenDocumentTree returns null), never reaches the VM |
| a folder with no FIT files says so, and is not an error | PORTED | FitFolderImportTest.kt: a folder with no FIT files says so, and is not an error
|
fitFolderHadNoFitFiles, importer never called |
| a folder too big to list says how much of it was taken | PORTED | FitFolderImportTest.kt: a folder too big to list says how much of it was taken
|
fitFolderTruncatedAt = listed count |
| one unreadable file fails that file, not the folder | PORTED | FitFolderImportTest.kt: one unreadable file fails that file, not the folder
|
scan error stays null; failure lands on the bulk-import surface |
| a failed scan surfaces, and imports nothing | PORTED | FitFolderImportTest.kt: a failed scan surfaces, and imports nothing
|
fitFolderScanError, importer never called |
| — | KOTLIN-ONLY | FitFolderImportTest.kt: a second pick while scanning is ignored, a fresh pick clears the outcome of the previous one
|
busy guard shared with the route bulk import; outcome lines reset per pick |
Kotlin counterpart: app/src/test/kotlin/tech/mmarca/openvitals/features/settings/HealthConnectSourcesTest.kt
| Flutter case | Status | Kotlin test | Note |
|---|---|---|---|
| aggregateHealthConnectSources > folds observations per package, counting and dating them | DIVERGED | HealthConnectSourcesTest.kt: aggregates counts and metrics per package + sorts most recent contributor first
|
"OpenVitals (this app)" self-package label (present in Kotlin HealthConnectSources.kt) never asserted |
| aggregateHealthConnectSources > blank sources collapse to a single "unknown" entry | PORTED | HealthConnectSourcesTest.kt: blank sources fold into the unknown bucket
|
|
| aggregateHealthConnectSources > an empty read yields no sources | PORTED | HealthConnectSourcesTest.kt: empty input yields an empty list
|
|
| aggregateHealthConnectSources > an unknown package keeps its raw name | PORTED | HealthConnectSourcesTest.kt: sorts most recent contributor first
|
raw-name fallback asserted there |
Kotlin counterpart: app/src/test/kotlin/tech/mmarca/openvitals/features/settings/SettingsViewModelTest.kt
| Flutter case | Status | Kotlin test | Note |
|---|---|---|---|
| the high threshold cannot be stepped below the low one | PORTED | BodySettingsViewModelTest.kt: threshold steps persist through the repository clamp + threshold gap clamp lands inside the repository bounds
|
latter proves gap (95 = 90 + 5) distinct from repo floor |
| the low threshold cannot be stepped above the high one | PORTED | SettingsViewModelTest.kt: low threshold gap clamp lands inside the repository bounds
|
high=90 then low=95 yields 85, distinct from the repo ceiling of 100 |
| a legitimate change still lands unchanged | PORTED | SettingsViewModelTest.kt: a legitimate threshold change still lands unchanged
|
150/45 land exactly |
Kotlin counterpart: app/src/test/kotlin/tech/mmarca/openvitals/features/settings/SettingsViewModelTest.kt, app/src/test/kotlin/tech/mmarca/openvitals/data/migration/FlutterPrefsKeyTableTest.kt
| Flutter case | Status | Kotlin test | Note |
|---|---|---|---|
| a physiological flag persists through the caffeine store | DIVERGED | SettingsViewModelTest.kt: updateCaffeinePreferences persists preference and updates ui state
|
persists via mocked PreferencesRepository; smoker flag landing under the original caffeine_* keys not asserted |
| reads values written under the original caffeine keys | DIVERGED | FlutterPrefsKeyTableTest.kt: dart enum names transcode to kotlin constants, multi word names included
|
covers key-level migration (incl. caffeine_hormonal_status), not the UI read-back path |
| surfaces pregnancy, which used to be buried under caffeine | PORTED | SettingsSmallCardsTest: metabolismCard_surfacesHormonalStatus_whichUsedToBeBuriedUnderCaffeine
|
Compose instrumentation; runs on a device, not in CI |
Kotlin counterpart: app/src/test/kotlin/tech/mmarca/openvitals/features/activity/maps/OfflineMapMetadataStoreTest.kt, OfflineMapPackFormatTest.kt
| Flutter case | Status | Kotlin test | Note |
|---|---|---|---|
| renders the empty state when no packs are imported | PORTED | OfflineMapsCardTest: withNoPacksTheCardSaysSoAndStillOffersTheImport
|
Compose instrumentation; runs on a device, not in CI |
| renders the pack list and the active render format | PORTED | OfflineMapsCardTest: aPackIsListedWithItsFormatAndOnlyItsOwnRendererCanBeChosen
|
Compose instrumentation; runs on a device, not in CI |
| selecting an enabled format chip calls setActiveFormat | DIVERGED | OfflineMapMetadataStoreTest.kt: write and read preserves imported maps and active format
|
activeFormat persistence covered at store level; OfflineMapRepository.setActiveFormat and its state flow untested |
| the delete affordance deletes the pack | DIVERGED | OfflineMapRepositoryTest.kt: deleteMap removes the file and its metadata entry
|
the deletion itself is covered at repository level; the card's affordance is Compose |
| import shows progress, disables the button, then reports | PORTED | OfflineMapsCardTest: anImportInFlightLocksTheButtonAndReportsItsProgress, aFinishedImportNamesWhatLandedAndGivesTheButtonBack
|
Compose instrumentation; runs on a device, not in CI |
| a rejected file surfaces the import error in error color | DIVERGED | OfflineMapPackFormatTest.kt: detects supported offline map file extensions
|
.osm.pbf rejection covered; import-error message/color surfacing untested |
Kotlin counterpart: none (logic partially in app/src/test/kotlin/tech/mmarca/openvitals/features/settings/SettingsViewModelTest.kt)
| Flutter case | Status | Kotlin test | Note |
|---|---|---|---|
| renders a row per category with granted/optional status | PORTED | PermissionCategoryCardTest: rendersARowPerCategoryWithGrantedOptionalAndUnsupportedStatus
|
Compose instrumentation; runs on a device, not in CI |
| a grant button requests the category permissions | PORTED | PermissionCategoryCardTest: aGrantButtonRequestsThatCategorysPermissions
|
Compose instrumentation; runs on a device, not in CI |
Kotlin counterpart: app/src/test/kotlin/tech/mmarca/openvitals/features/settings/SettingsViewModelTest.kt
| Flutter case | Status | Kotlin test | Note |
|---|---|---|---|
| build exposes the category taxonomy once the gates resolve | DIVERGED | SettingsViewModelTest.kt: refresh includes cycle permissions with visible permissions
|
asserts only the union of category permissions; ordered category taxonomy (SettingsViewModel.permissionCategories) never asserted |
| a granted request succeeds and asks for exactly the missing set | DIVERGED | SettingsViewModelTest.kt: missingVisiblePermissions excludes already granted visible permissions
|
missing-set derivation covered; the request itself is an ActivityResult launch, untested |
| a manual-only category opens the Health Connect settings screen | DIVERGED | SettingsViewModelTest.kt: missingVisiblePermissions excludes already granted visible permissions
|
missingManualVisiblePermissions asserted; opening HC settings is a platform intent, untested |
| a refused request lands as CommandFailure carrying the ScreenError | N/A-FRAMEWORK | Kotlin permission refusal returns via ActivityResult (no throwing path); ScreenError.PermissionDenied exists but this command plumbing is Riverpod-specific |
Kotlin counterpart: none
| Flutter case | Status | Kotlin test | Note |
|---|---|---|---|
| renders the title, body and the show-reminder action | PORTED | SettingsSmallCardsTest: reminderTestCard_rendersTheTitleBodyAndShowReminderAction
|
Compose instrumentation; runs on a device, not in CI |
| a posted reminder confirms with a snackbar | DIVERGED | SettingsViewModelTest.kt: the test reminder is posted through the hydration reminder controller
|
the post is asserted at VM level; the snackbar confirmation is Compose |
| disabled notifications are reported, not silently swallowed | N/A-BEHAVIOR | none | blocked on behavior decision - showTestReminder returns silently when the permission is absent |
| a failed post reports the failure | N/A-BEHAVIOR | none | blocked on behavior decision - the Kotlin controller has no failure-reporting path |
Kotlin counterpart: app/src/test/kotlin/tech/mmarca/openvitals/features/settings/SettingsViewModelTest.kt, app/src/test/kotlin/tech/mmarca/openvitals/features/manualentry/activity/ActivityEntryViewModelTest.kt
| Flutter case | Status | Kotlin test | Note |
|---|---|---|---|
| single route import stores the pending handle and navigates | DIVERGED | ActivityEntryViewModelTest.kt: route import fills distance and elevation fields in current unit system
|
receiving-end import handling covered; pending-handle handoff/navigation is Android nav plumbing, untested |
| FIT import stores the pending handle and navigates | DIVERGED | ActivityEntryViewModelTest.kt: FIT import without route fills manual activity fields
|
same: handoff/navigation untested |
| bulk import writes one activity per file | PORTED | SettingsViewModelTest.kt: bulk route import writes all selected files in one batched call
|
Kotlin additionally asserts single batched HC call |
| bulk import tolerates a bad file (imported/failed counts) | PORTED | SettingsViewModelTest.kt: a failed batch retries file by file so only the guilty file fails
|
equivalent imported=1/failed=1 assertions |
Kotlin counterpart: app/src/test/kotlin/tech/mmarca/openvitals/features/settings/SettingsViewModelTest.kt
| Flutter case | Status | Kotlin test | Note |
|---|---|---|---|
| build derives the permission counts from the activity repository | DIVERGED | SettingsViewModelTest.kt: missingVisiblePermissions excludes already granted visible permissions + missingVisiblePermissions is empty when all visible permissions are granted
|
global missing/granted derivation covered; route-import-card-specific counts/availability flags not modeled as a distinct state |
| grantPermissions requests exactly the missing set | DIVERGED | SettingsViewModelTest.kt: missingVisiblePermissions excludes already granted visible permissions
|
missing-set computation covered; the launch of the request contract untested |
| a failed grant lands as CommandFailure with the error message | N/A-FRAMEWORK | Kotlin grants return via ActivityResult contract, no throwing grant command; generic error mapping covered by core/presentation ScreenErrorTest |
Kotlin counterpart: app/src/test/kotlin/tech/mmarca/openvitals/features/settings/SettingsViewModelTest.kt
| Flutter case | Status | Kotlin test | Note |
|---|---|---|---|
| settings root renders the section cards | PORTED | SettingsRootTest: everySectionRendersACardThatOpensIt
|
Compose instrumentation; runs on a device, not in CI |
| settings root shows the support card and version footer | PORTED | SettingsRootTest: theSupportCardOffersItsThreeRoutesOut, theVersionFooterNamesTheBuildABugReportWouldQuote
|
Compose instrumentation; runs on a device, not in CI |
| selecting a theme mode persists through the repository | PORTED | SettingsViewModelTest.kt: selectAppThemeMode persists preference and updates ui state
|
Kotlin counterpart: app/src/test/kotlin/tech/mmarca/openvitals/features/imports/csv/CsvDateTimeFormatTest.kt
| Flutter case | Status | Kotlin test | Note |
|---|---|---|---|
| resolveCsvInstant > a timezone-less timestamp at a fixed +02:00 resolves two hours earlier in UTC | PORTED | CsvDateTimeFormatTest.kt: a timezone-less timestamp at a fixed plus two resolves two hours earlier in UTC
|
|
| resolveCsvInstant > a timestamp read as UTC keeps its wall clock and a zero offset | PORTED | CsvDateTimeFormatTest.kt: a timestamp read as UTC keeps its wall clock and a zero offset
|
|
| resolveCsvInstant > an ISO timestamp carrying +05:30 overrides the selected UTC mode | PORTED | CsvDateTimeFormatTest.kt: an ISO timestamp carrying an offset overrides the selected UTC mode
|
|
| resolveCsvInstant > an ISO timestamp ending in Z resolves to that instant with no offset | PORTED | CsvDateTimeFormatTest.kt: an ISO timestamp ending in Z resolves to that instant with no offset
|
|
| resolveCsvInstant > the device zone reports an offset that maps the instant back to the wall clock in the file | PORTED | CsvDateTimeFormatTest.kt: the device zone reports an offset that maps the instant back to the wall clock in the file
|
|
| resolveCsvInstant > epoch seconds resolve to the matching UTC instant | PORTED | CsvDateTimeFormatTest.kt: epoch seconds resolve to the matching UTC instant
|
|
| resolveCsvInstant > a date-only cell resolves to midnight of that day | PORTED | CsvDateTimeFormatTest.kt: a date-only cell resolves to midnight of that day
|
|
| resolveCsvInstant > a cell that does not match the chosen format resolves to null | PORTED | CsvDateTimeFormatTest.kt: a cell that does not match the chosen format resolves to null
|
|
| resolveCsvInstant > a custom pattern parses a shape none of the families cover | PORTED | CsvDateTimeFormatTest.kt: a custom pattern parses a shape none of the families cover
|
|
| parseCsvWallClock > a date-only pattern does not silently swallow a trailing time | PORTED | CsvDateTimeFormatTest.kt: a date-only pattern does not silently swallow a trailing time
|
|
| parseCsvWallClock > a ten-digit epoch value is not misread as milliseconds | PORTED | CsvDateTimeFormatTest.kt: a ten-digit epoch value is not misread as milliseconds
|
|
| parseCsvWallClock > a small counting number is not accepted as an epoch timestamp | PORTED | CsvDateTimeFormatTest.kt: a small counting number is not accepted as an epoch timestamp
|
|
| parseCsvWallClock > a real epoch second inside the plausible window still parses | PORTED | CsvDateTimeFormatTest.kt: a real epoch second inside the plausible window still parses
|
|
| csvTimestampHasExplicitOffset > a bare ISO date is not mistaken for carrying an offset | PORTED | CsvDateTimeFormatTest.kt: a bare ISO date is not mistaken for carrying an offset
|
|
| csvTimestampHasExplicitOffset > an offset suffix on a full timestamp is detected | PORTED | CsvDateTimeFormatTest.kt: an offset suffix on a full timestamp is detected
|
|
| csvTimestampHasExplicitOffset > a timestamp with no offset suffix is reported as carrying none | PORTED | CsvDateTimeFormatTest.kt: a timestamp with no offset suffix is reported as carrying none
|
|
| detectCsvDateTimeFormat > a year-first sample is detected as year-first | PORTED | CsvDateTimeFormatTest.kt: a year-first sample is detected as year-first
|
|
| detectCsvDateTimeFormat > a sample where both day-first and month-first parse every row is reported ambiguous rather than guessed | PORTED | CsvDateTimeFormatTest.kt: a sample where both day-first and month-first parse every row is reported ambiguous rather than guessed
|
|
| detectCsvDateTimeFormat > a day above twelve resolves the ordering to day-first | PORTED | CsvDateTimeFormatTest.kt: a day above twelve resolves the ordering to day-first
|
|
| detectCsvDateTimeFormat > an unparsable sample reports that nothing matched | PORTED | CsvDateTimeFormatTest.kt: an unparsable sample reports that nothing matched
|
|
| detectCsvDateTimeFormat > an empty sample reports that nothing matched | PORTED | CsvDateTimeFormatTest.kt: an empty sample reports that nothing matched
|
Kotlin counterpart: none
| Flutter case | Status | Kotlin test | Note |
|---|---|---|---|
| tapping Done leaves the importer and returns to Data Importers | N/A-FRAMEWORK | — | go_router pop plumbing; Kotlin Done is a compose nav callback in CsvImportScreen, untested |
Kotlin counterpart: app/src/test/kotlin/tech/mmarca/openvitals/features/imports/csv/CsvImportReportTest.kt (1:1 port of buildCsvImportReport)
| Flutter case | Status | Kotlin test | Note |
|---|---|---|---|
| buildCsvImportReport > the report names the file and the outcome | DIVERGED | CsvImportReportTest.kt: the report gives the outcome and never the file's name
|
the outcome is kept; a file's name can be a person's, so the report leaves it out |
| buildCsvImportReport > an unnamed file is reported rather than left blank | REMOVED | — | no file name is reported at all |
| buildCsvImportReport > every tally from the run appears | PORTED | CsvImportReportTest.kt: every tally from the run appears
|
|
| buildCsvImportReport > the parsing settings that produced the run are recorded | PORTED | CsvImportReportTest.kt: the parsing settings that produced the run are recorded
|
|
| buildCsvImportReport > a fixed offset is written out in full | PORTED | CsvImportReportTest.kt: a fixed offset is written out in full
|
|
| buildCsvImportReport > a custom date pattern is recorded so a bad one can be spotted | PORTED | CsvImportReportTest.kt: a custom date pattern is recorded so a bad one can be spotted
|
|
| buildCsvImportReport > every column is listed with the role it was given | DIVERGED | CsvImportReportTest.kt: every column is listed with the role it was given, not its header
|
the column's index and role, not the file's header text |
| buildCsvImportReport > a derived body fat says what it was derived from, not just its unit | PORTED | CsvImportReportTest.kt: a derived body fat says what it was derived from, not just its unit
|
|
| buildCsvImportReport > rejection counts are grouped by reason | PORTED | CsvImportReportTest.kt: rejection counts are grouped by reason
|
|
| buildCsvImportReport > individual rejected rows name the row, column and value | DIVERGED | CsvImportReportTest.kt: individual rejected rows name the row, column and the cell's shape
|
the cell's shape (99/99/9999), never its text or value |
| buildCsvImportReport > a capped per-row log says how many were dropped and that the counts are not | PORTED | CsvImportReportTest.kt: a capped per-row log says how many were dropped and that the counts are not
|
|
| buildCsvImportReport > a clean run has no rejection sections at all | PORTED | CsvImportReportTest.kt: a clean run has no rejection sections at all
|
|
| buildCsvImportReport > a failed run carries its error text | PORTED | CsvImportReportTest.kt: a failed run carries its error text
|
|
| buildCsvImportReport > a rate-limited run says so rather than reading as a plain stop | PORTED | CsvImportReportTest.kt: a rate-limited run says so rather than reading as a plain stop
|
|
| the totals partition the rows, qualify the records, and account for every drip day | PORTED | CsvImportReportTest.kt: the totals partition the rows, qualify the records, and account for every drip day
|
Kotlin-only |
| a presence lookup that fails leaves the records written and counted as unchecked | PORTED | CsvImportServiceTest.kt: a presence lookup that fails leaves the records written and counted as unchecked
|
Kotlin-only; a missing read permission must not read as "not present" |
| a malformed row is counted as a rejected row, not as nothing to import | PORTED | CsvImportServiceTest.kt: a malformed row is counted as a rejected row, not as nothing to import
|
Kotlin-only |
Kotlin counterpart: none (compose screen app/src/main/kotlin/.../csv/CsvImportScreen.kt has no androidTest)
| Flutter case | Status | Kotlin test | Note |
|---|---|---|---|
| the importer opens on the pick step with its explainer | PORTED | CsvImportStepsTest: theImporterOpensOnThePickStepWithItsExplainer
|
Compose instrumentation; runs on a device, not in CI |
| picking a file lists one row per column with its header | PORTED | CsvImportFlowTest: pickingAFileListsOneRowPerColumnWithItsHeader
|
Compose instrumentation; runs on a device, not in CI |
| a file with only a header row shows the empty-file message | PORTED | CsvImportStepsTest: aFileWithOnlyAHeaderRowShowsTheEmptyFileMessage
|
Compose instrumentation; runs on a device, not in CI |
| a freshly picked file cannot continue until a metric column is chosen | PORTED | CsvImportFlowTest: aFreshlyPickedFileCannotContinueUntilAMetricColumnIsChosen
|
Compose instrumentation; runs on a device, not in CI |
| mapping body fat as a mass with no weight column shows the needs-weight error and keeps Continue disabled | PORTED | CsvImportFlowTest: bodyFatAsAMassWithNoWeightColumnSaysSoAndKeepsContinueDisabled
|
Compose instrumentation; runs on a device, not in CI |
| the confirm step shows the observed range for each metric | PORTED | CsvImportFlowTest: theConfirmStepShowsTheObservedRangeForEachMetric
|
Compose instrumentation; runs on a device, not in CI |
| the confirm step shows the date span the import will write | PORTED | CsvImportFlowTest: theConfirmStepShowsTheDateSpanTheImportWillWrite
|
Compose instrumentation; runs on a device, not in CI |
| reading an ambiguous file month-first instead of day-first is visible in the date span | PORTED | CsvImportFlowTest: readingAnAmbiguousFileMonthFirstIsVisibleInTheDateSpan
|
Compose instrumentation; runs on a device, not in CI |
| a finished import reports what was written | PORTED | CsvImportResultViewTest: aFinishedImportReportsWhatWasWritten
|
Compose instrumentation; runs on a device, not in CI |
| an import that rejects every row says nothing was imported | PORTED | CsvImportResultViewTest: anImportThatRejectsEveryRowSaysNothingWasImportedAndWhy
|
Compose instrumentation; runs on a device, not in CI |
| the finished import offers to save a report | PORTED | CsvImportResultViewTest: theFinishedImportOffersToTakeTheReportAway
|
Compose instrumentation; runs on a device, not in CI |
| saving the report writes it and confirms once | N/A-WIDGET | — | Kotlin saves via SAF launcher + Toast; report content untested (see report MISSING) |
| sharing the report hands the sheet the report and stays quiet | N/A-WIDGET | — | Kotlin shares via system share sheet |
| a share with no target tells the user instead of failing silently | N/A-WIDGET | — | |
| tapping Choose CSV file invokes the picker | PORTED | CsvImportStepsTest: tappingChooseCsvFileInvokesThePicker
|
Compose instrumentation; runs on a device, not in CI |
Kotlin counterpart: app/src/test/kotlin/tech/mmarca/openvitals/features/imports/csv/CsvImportServiceTest.kt
| Flutter case | Status | Kotlin test | Note |
|---|---|---|---|
| every row of a clean file is written and the run completes | PORTED | CsvImportServiceTest.kt: every row of a clean file is written and the run completes
|
|
| records already in Health Connect are counted as present and still written, so a corrected value upserts | PORTED | CsvImportServiceTest.kt: records already in Health Connect are counted as present and still written so a corrected value upserts
|
|
| a duplicated row inside one file is written once | PORTED | CsvImportServiceTest.kt: a duplicated row inside one file is written once
|
|
| a refused batch is retried record by record and only the bad record is counted as rejected | PORTED | CsvImportServiceTest.kt: a refused batch is retried record by record and only the bad record is counted as rejected
|
|
| a rate-limited batch is written again after a pause and the run completes | PORTED | CsvImportServiceTest.kt: a rate-limited batch is written again after a pause and the run completes
|
Kotlin-only: pauses 15 s then 30 s, counted down per second, tallied into the progress |
| a rate-limited run stops and reports how far it got | PORTED | CsvImportServiceTest.kt: a rate-limited run stops and reports how far it got
|
Gives up only once the 30-minute pause budget is spent |
| cancelling during a quota pause ends the run as cancelled within a second | PORTED | CsvImportServiceTest.kt: cancelling during a quota pause ends the run as cancelled within a second
|
Kotlin-only |
| a drip export writes its records to Health Connect and its journal days to Room | PORTED | CsvImportServiceTest.kt: a drip export writes its records to Health Connect and its journal days to Room
|
Kotlin-only; a second run still counts its journal days but updates none; an empty row is counted, so rows = journal days + empty rows |
| drip export: header detection, bleeding, temperature, mucus, sex, journal, cervix and desire, disabled options, bad dates, stable ids, journal merge, sample summary | PORTED | DripImportTest.kt (12 tests) | Kotlin-only; drip had no Flutter counterpart |
| cancelling mid-run keeps what was written and stops reading | PORTED | CsvImportServiceTest.kt: cancelling mid-run keeps what was written and stops reading
|
|
| a malformed row is skipped with a diagnostic and the rest imports | PORTED | CsvImportServiceTest.kt: a malformed row is skipped with a diagnostic and the rest imports
|
|
| a missing file fails the run instead of throwing | PORTED | CsvImportServiceTest.kt: a missing file fails the run instead of throwing
|
|
| a file with only a header writes nothing and still completes | PORTED | CsvImportServiceTest.kt: a file with only a header writes nothing and still completes
|
|
| the retained diagnostic log is capped while the counts stay complete | PORTED | CsvImportServiceTest.kt: the retained diagnostic log is capped while the counts stay complete
|
|
| progress reports a fraction of the file once bytes are known | PORTED | CsvImportServiceTest.kt: progress reports a fraction of the file once bytes are known
|
Kotlin counterpart: app/src/test/kotlin/tech/mmarca/openvitals/features/imports/csv/CsvImportViewModelTest.kt; further logic coverage in CsvMappingValidationTest.kt / CsvRowConverterTest.kt / CsvImportServiceTest.kt
| Flutter case | Status | Kotlin test | Note |
|---|---|---|---|
| picking a file advances to the mapping step and exposes its columns | PORTED | CsvImportViewModelTest.kt: picking a file advances to the mapping step and exposes its columns
|
pickFile(uri) portable with mocked Context/ContentResolver |
| the app-open permission refresh does not discard a file already picked | N/A-FRAMEWORK | — | Riverpod build/invalidate regression; Kotlin VM has no rebuild-on-invalidate hazard |
| a permission granted after the file was picked still reaches the state | PORTED | CsvImportViewModelTest.kt: a permission granted after the file was picked still reaches the state
|
Kotlin refreshPermissions() is JVM-testable |
| cancelling the file picker leaves the importer on the pick step | N/A-FRAMEWORK | — | Kotlin picker cancel never reaches the VM (ActivityResult) |
| the date column is pre-selected but no metric is guessed | PORTED | CsvImportViewModelTest.kt: the date column is pre-selected but no metric is guessed
|
now asserted through VM pickFile |
| a freshly picked file cannot continue until a metric is mapped | PORTED | CsvImportViewModelTest.kt: a freshly picked file cannot continue until a metric is mapped
|
canContinue state property untested |
| mapping a column defaults its unit from the column's own header | PORTED | CsvImportViewModelTest.kt: mapping a column defaults its unit from the column's own header
|
setColumnRole defaulting now asserted |
| mapping fat mass in kg to body fat without a weight column blocks continuing | PORTED | CsvImportViewModelTest.kt: mapping fat mass in kg to body fat without a weight column blocks continuing
|
canContinue=false now asserted at VM level |
| mapping the weight column too clears the derivation issue | PORTED | CsvImportViewModelTest.kt: mapping the weight column too clears the derivation issue
|
|
| only the mapped metrics permissions are reported missing | PORTED | CsvImportViewModelTest.kt: only the mapped metrics permissions are reported missing
|
VM missingPermissions now asserted |
| a permission the installed provider does not define is never requested | PORTED | CsvImportViewModelTest.kt: a permission the installed provider does not define is never requested
|
supportedWritePermissions filter in CsvImportState untested |
| a supported permission is still requested when another is unsupported | PORTED | CsvImportViewModelTest.kt: a supported permission is still requested when another is unsupported
|
|
| an already-granted permission is not reported missing | PORTED | CsvImportViewModelTest.kt: an already-granted permission is not reported missing
|
|
| a completed import writes one record per mapped metric per row | PORTED | CsvImportViewModelTest.kt: a completed import writes one record per mapped metric per row
|
VM step DONE transition now asserted |
| the Withings fat-mass derivation writes body-fat percentages | PORTED | CsvImportViewModelTest.kt: the Withings fat-mass derivation writes body-fat percentages
|
now asserted via VM import |
| saveReport > the report describes the run that just finished | DIVERGED | CsvImportViewModelTest.kt: the report describes the run that just finished
|
asserts the run's totals and mapping, and that the file and header names stay out |
| saveReport > there is nothing to report before an import has run | PORTED | CsvImportViewModelTest.kt: there is nothing to report before an import has run
|
reportText() null-guard untested |
| saveReport > a saved report ends the command in success carrying true | N/A-FRAMEWORK | — | no CommandState in Kotlin; save is a SAF launcher in the screen |
| saveReport > a cancelled save succeeds carrying false, not a failure | N/A-FRAMEWORK | — | |
| saveReport > a throwing save lands as a command failure rather than escaping | N/A-FRAMEWORK | — | |
| saveReport > clearing returns the command to idle so it cannot replay | N/A-FRAMEWORK | — | |
| saveReport > saving before an import has run does nothing | N/A-FRAMEWORK | — | guard lives in screen (reportText() null → return) |
| saveReport > sharing hands the sheet the same report the save writes | N/A-FRAMEWORK | — | both flows read reportText(); no share command in Kotlin VM |
| saveReport > a share with no target lands as a command failure rather than escaping | N/A-FRAMEWORK | — | |
| saveReport > clearing the share returns the command to idle so it cannot replay | N/A-FRAMEWORK | — | |
| saveReport > sharing before an import has run does nothing | N/A-FRAMEWORK | — | |
| an empty file lands on the mapping step with no mapping to edit | PORTED | CsvImportViewModelTest.kt: an empty file lands on the mapping step with no mapping to edit
|
|
| resetting returns to the pick step and drops the previous run | PORTED | CsvImportViewModelTest.kt: resetting returns to the pick step and drops the previous run
|
Kotlin reset() exists, untested |
| changing the separator re-reads the file under the new dialect | PORTED | CsvImportViewModelTest.kt: changing the separator re-reads the file under the new dialect
|
Kotlin setDialect() exists, untested |
Kotlin counterpart: app/src/test/kotlin/tech/mmarca/openvitals/features/imports/csv/CsvMappingValidationTest.kt
| Flutter case | Status | Kotlin test | Note |
|---|---|---|---|
| validateCsvMapping > a complete mapping reports no issues | PORTED | CsvMappingValidationTest.kt: a complete mapping reports no issues
|
|
| validateCsvMapping > a mapping with no timestamp column reports it | PORTED | CsvMappingValidationTest.kt: a mapping with no timestamp column reports it
|
|
| validateCsvMapping > two timestamp columns report the conflict | PORTED | CsvMappingValidationTest.kt: two timestamp columns report the conflict
|
|
| validateCsvMapping > a mapping with no metric column reports it | PORTED | CsvMappingValidationTest.kt: a mapping with no metric column reports it
|
|
| validateCsvMapping > two columns mapped to the same metric report the duplicate | PORTED | CsvMappingValidationTest.kt: two columns mapped to the same metric report the duplicate
|
|
| validateCsvMapping > body fat as a mass with no weight column reports that it needs one | PORTED | CsvMappingValidationTest.kt: body fat as a mass with no weight column reports that it needs one
|
|
| validateCsvMapping > body fat as a percentage needs no weight column | PORTED | CsvMappingValidationTest.kt: body fat as a percentage needs no weight column
|
|
| validateCsvMapping > a date format matching no sampled row reports it | PORTED | CsvMappingValidationTest.kt: a date format matching no sampled row reports it
|
|
| validateCsvMapping > an undecidable day/month order is reported while the format is still automatic | PORTED | CsvMappingValidationTest.kt: an undecidable day month order is reported while the format is still automatic
|
|
| validateCsvMapping > choosing day-first answers the ambiguity and clears the issue | PORTED | CsvMappingValidationTest.kt: choosing day-first answers the ambiguity and clears the issue
|
|
| initialCsvMapping > the first column that parses as a date is pre-selected | PORTED | CsvMappingValidationTest.kt: the first column that parses as a date is pre-selected
|
|
| initialCsvMapping > no metric is guessed from a header name | PORTED | CsvMappingValidationTest.kt: no metric is guessed from a header name
|
|
| initialCsvMapping > a file with no date-like column selects no timestamp | PORTED | CsvMappingValidationTest.kt: a file with no date-like column selects no timestamp
|
|
| requiredWritePermissions > only the mapped metrics permissions are required | PORTED | CsvMappingValidationTest.kt: only the mapped metrics permissions are required
|
|
| requiredWritePermissions > a body-composition mapping requires one permission per metric | PORTED | CsvMappingValidationTest.kt: a body-composition mapping requires one permission per metric
|
|
| detectCsvUnitInHeader > a parenthesised unit is read off the header | PORTED | CsvMappingValidationTest.kt: a parenthesised unit is read off the header
|
|
| detectCsvUnitInHeader > a unit word inside the label is not read as the unit | PORTED | CsvMappingValidationTest.kt: a unit word inside the label is not read as the unit
|
|
| detectCsvUnitInHeader > a header with no unit reads as none | PORTED | CsvMappingValidationTest.kt: a header with no unit reads as none
|
Kotlin counterpart: app/src/test/kotlin/tech/mmarca/openvitals/features/imports/csv/CsvRowConverterTest.kt
| Flutter case | Status | Kotlin test | Note |
|---|---|---|---|
| (all 43 cases: convertCsvRow x14, buildCsvClientRecordId x5, vitals metrics x12, previewInstantRange x6, parseCsvNumber x7 — omitted here individually; each maps 1:1 by name) | PORTED | CsvRowConverterTest.kt (same test names) | 1:1 port; Kotlin additionally pins a byte-identical cross-build client-record-id |
Kotlin counterpart: app/src/test/kotlin/tech/mmarca/openvitals/features/imports/csv/CsvTableReaderTest.kt
| Flutter case | Status | Kotlin test | Note |
|---|---|---|---|
| sniffDialect > a comma file with quoted headers is sniffed as comma-delimited | PORTED | CsvTableReaderTest.kt: a comma file with quoted headers is sniffed as comma-delimited
|
|
| sniffDialect > a semicolon file whose quoted headers contain commas is sniffed as semicolon-delimited | PORTED | CsvTableReaderTest.kt: a semicolon file whose quoted headers contain commas is sniffed as semicolon-delimited
|
|
| sniffDialect > a CRLF file is sniffed as CRLF | PORTED | CsvTableReaderTest.kt: a CRLF file is sniffed as CRLF
|
|
| sniffDialect > an LF file is sniffed as LF | PORTED | CsvTableReaderTest.kt: an LF file is sniffed as LF
|
|
| sample > a quoted header containing a comma reads as a single column | PORTED | CsvTableReaderTest.kt: a quoted header containing a comma reads as a single column
|
|
| sample > a UTF-8 BOM does not leak into the first header cell | PORTED | CsvTableReaderTest.kt: a UTF-8 BOM does not leak into the first header cell
|
|
| sample > a quoted field containing newlines survives a chunk boundary intact | PORTED | CsvTableReaderTest.kt: a quoted field containing newlines survives a chunk boundary intact
|
|
| sample > sampling a file with thousands of rows stops at the preview limit | PORTED | CsvTableReaderTest.kt: sampling a file with thousands of rows stops at the preview limit
|
|
| sample > a file with no header row gets synthesised column labels | PORTED | CsvTableReaderTest.kt: a file with no header row gets synthesised column labels
|
|
| sample > a file containing only a header row samples as empty | PORTED | CsvTableReaderTest.kt: a file containing only a header row samples as empty
|
|
| sample > columnValues skips blank cells in the requested column | PORTED | CsvTableReaderTest.kt: columnValues skips blank cells in the requested column
|
|
| rows > the header row is not emitted as data | PORTED | CsvTableReaderTest.kt: the header row is not emitted as data
|
|
| rows > bytes read grow as rows are emitted | DIVERGED | CsvTableReaderTest.kt: bytes read grow as rows are emitted
|
strengthened to monotonic growth plus reaching EOF; still no byteLength() API in Kotlin (totalBytes comes from the SAF cursor) |
| rows > a missing file reports a read failure rather than hanging | PORTED | CsvTableReaderTest.kt: a missing file reports a read failure rather than hanging
|
Kotlin adds a sample() variant too |
| CsvRow.cell > a short row reports null rather than throwing | PORTED | CsvTableReaderTest.kt: a short row reports null rather than throwing
|
|
| CsvRow.cell > a blank cell reads as null so a gap is not parsed as zero | PORTED | CsvTableReaderTest.kt: a blank cell reads as null so a gap is not parsed as zero
|
Kotlin counterpart: app/src/test/kotlin/tech/mmarca/openvitals/features/imports/RouteBulkImportTest.kt (bulk route import lives in SettingsViewModel.importRouteFiles in the Kotlin port), plus features/settings/SettingsViewModelTest.kt
| Flutter case | Status | Kotlin test | Note |
|---|---|---|---|
| imports every file and reports the counts | PORTED | SettingsViewModelTest.kt: bulk route import writes all selected files in one batched call
|
|
| progress counts the files as they land | PORTED | RouteBulkImportTest.kt: progress counts the files as they land
|
snapshots read inside the importer (uiState is conflated); Flutter's queued 0-of-N tick is unobservable in Kotlin |
| a refused write permission fails one file, not the batch | PORTED | RouteBulkImportTest.kt: a refused write permission fails one file, not the batch
|
error text plus zero writes reaching the repository |
| a failed write surfaces the failure message | PORTED | RouteBulkImportTest.kt: a failed write surfaces the failure message
|
exact routeImportError text asserted |
| a malformed file is tolerated and its parse error reported | PORTED | RouteBulkImportTest.kt: a malformed file is tolerated and its parse error reported
|
only the wellness-FIT fallback path of a parse exception is tested |
| opens files as it reaches them, never the whole folder up front | PORTED | RouteBulkImportTest.kt: opens files as it reaches them, never the whole folder up front
|
60 files; read/write interleave ordering asserted |
| writes activities in batches, not one Health Connect call per file | PORTED | RouteBulkImportTest.kt: writes activities in batches, not one Health Connect call per file
|
Flutter's [25,25,10] chunking now asserted |
| a rejected batch is retried file by file, so one bad file fails alone | PORTED | SettingsViewModelTest.kt: a failed batch retries file by file so only the guilty file fails
|
|
| a spent Health Connect quota stops the run instead of failing every file | PORTED | RouteBulkImportTest.kt: a spent Health Connect quota stops the run instead of failing every file
|
60 files, one batch attempted, no single-write retry, remaining files never opened |
| a file that cannot be opened fails that file, not the batch | PORTED | RouteBulkImportTest.kt: a file that cannot be opened fails that file, not the batch
|
|
| an activity FIT file is imported as an activity, not skipped as wellness | PORTED | RouteBulkImportTest.kt: an activity file is imported as an activity, not skipped as wellness
|
Kotlin has no skippedFiles field and reaches the wellness fallback only from the failure branch; asserts the fallback is never consulted |
| an empty pick does nothing | PORTED | RouteBulkImportTest.kt: an empty pick does nothing
|
Kotlin counterpart: none (Kotlin handles this natively in MainActivity via ExternalRouteImportRequest; no channel exists and no tests)
| Flutter case | Status | Kotlin test | Note |
|---|---|---|---|
| RouteImportIntentChannel > maps the native payload to an ActivityRouteFileHandle | N/A-FRAMEWORK | — | MethodChannel plumbing; Kotlin receives ACTION_VIEW intents directly (untested) |
| RouteImportIntentChannel > returns null when nothing is pending | N/A-FRAMEWORK | — | |
| RouteImportIntentChannel > a missing plugin (non-Android host) is not an error | N/A-FRAMEWORK | — | Flutter-only host concern |
| RouteImportIntentBootstrap > a pending file is parked in the seam and opens the form | N/A-FRAMEWORK | — | go_router navigation; Kotlin equivalent (MainActivity → ActivityEntryScreen pending-import) untested |
| RouteImportIntentBootstrap > nothing pending leaves the seam empty and does not navigate | N/A-FRAMEWORK | — |
Kotlin counterpart: app/src/test/kotlin/tech/mmarca/openvitals/domain/model/SleepSessionMergingTest.kt, features/sleep/SleepPresentationMapperTest.kt
| Flutter case | Status | Kotlin test | Note |
|---|---|---|---|
| excludes awake time within the session from the sleep duration | PORTED | SleepSessionMergingTest.kt: sleepDurationMsFromStages excludes awake stages when sleep stages are present (also SleepPresentationMapperTest.kt: duration points are asleep hours with awake stages excluded) |
|
| falls back to time in bed when the night has no stage data | PORTED | SleepPresentationMapperTest.kt: duration points fall back to time in bed when the night has no stage data
|
sleepDurationMsFromStages(stages, fallbackDurationMs) empty-stages fallback path never exercised |
| is zero when there is no night for the date | PORTED | SleepPresentationMapperTest.kt: asleep hours are zero when there is no night for the date
|
no Kotlin test asserts zero asleep hours for a date with no sessions |
Kotlin counterpart: app/src/test/kotlin/tech/mmarca/openvitals/features/sleep/SleepStageScrubberTest.kt (over sleepScrubTimeAt / sleepStageTypeAt), plus ui/charts/ChartScrubberTest.kt for the line-chart rule
| Flutter case | Status | Kotlin test | Note |
|---|---|---|---|
| inert until touched — no crosshair, no tooltip | PORTED | SleepStageScrubberTest: theHypnogramStaysSilentUntilAFingerIsOnIt
|
Compose instrumentation; runs on a device, not in CI |
| a horizontal drag reveals the clock time and stage at the finger | PORTED | SleepStageScrubberTest.kt: a horizontal drag reveals the clock time and stage at the finger
|
core only; the gesture itself is widget-level |
| the time tracks the finger across the night | PORTED | SleepStageScrubberTest.kt: the time tracks the finger across the night
|
same uncovered fraction→clock-time mapping |
| a VERTICAL drag starting on the chart still scrolls the page | PORTED | SleepStageScrubberTest: aVerticalDragStartingOnTheHypnogramStillScrollsThePage
|
Compose instrumentation; runs on a device, not in CI |
Kotlin counterpart: app/src/test/kotlin/tech/mmarca/openvitals/features/sleep/SleepDetailViewModelTest.kt
| Flutter case | Status | Kotlin test | Note |
|---|---|---|---|
| renders summary, breakdown, details and stage events | PORTED | SleepDetailScreenTest: theDetailScreenShowsTheNightItsBreakdownItsMetadataAndItsStageEvents
|
Compose instrumentation; runs on a device, not in CI |
| the stage lane chart paints the cross-lane connector without throwing | PORTED | SleepDetailScreenTest: aNightOfBackToBackStagesDrawsWithoutTearingTheChart
|
Compose instrumentation; runs on a device, not in CI |
| shows the no-stages message when a session has no stages | PORTED | SleepDetailScreenTest: aNightWithoutStagesSaysSoRatherThanShowingAnEmptyChart
|
Compose instrumentation; runs on a device, not in CI |
| shows an error when the session is missing | PORTED | SleepDetailViewModelTest.kt: missing sleep session sets not found error
|
VM-level equivalent (ScreenError.NotFound vs error text) |
Kotlin counterpart: app/src/test/kotlin/tech/mmarca/openvitals/features/sleep/SleepPresentationMapperTest.kt and SleepMetricSectionsTest.kt (stage shares, entry ordering, manual-entry confidence, schedule-chart rule, over the extracted sleepPeriodTotals seam), plus domain/insights tests
| Flutter case | Status | Kotlin test | Note |
|---|---|---|---|
| an empty period derives zeroes, not nulls | PORTED | SleepPresentationMapperTest.kt: an empty period derives zeroes not nulls + SleepMetricSectionsTest.kt: an empty period derives zeroes not NaN
|
mapper never tested with an empty period |
| only the nights that recorded sleep count as nights | PORTED | SleepPresentationMapperTest.kt + SleepMetricSectionsTest.kt: only the nights that recorded sleep count as nights
|
totals, average, longest and night-count now asserted |
| the stage shares split the recorded stage time, and only it | PORTED | SleepMetricSectionsTest.kt: the stage shares split the recorded stage time and only it
|
stage-share percents/fractions computed in SleepStageShareCard composable, untested |
| the entry lists come out newest night first | PORTED | SleepMetricSectionsTest.kt: the entry lists come out newest night first
|
ordering of period nights vs raw sessions untested |
| a week whose nights know their bedtimes gets the schedule chart | PORTED | SleepMetricSectionsTest.kt: a week whose nights know their bedtimes gets the schedule chart
|
useScheduleChart + scheduleDays now asserted |
| a night short of the goal reads as below target | DIVERGED | MetricInterpretationsTest.kt: interpretsSleepAgainstUserTarget
|
classification covered at domain level; display wiring and goalMetDays not |
| sleep and HRV only correlate once enough nights pair up | PORTED | CrossMetricInsightsTest.kt: returnsNullWhenThereAreNotEnoughPairs + calculatesPositiveCorrelationForPairedDays
|
at domain crossMetricInsight level |
| manual-entry confidence > an actively-recorded night is not a manual entry | PORTED | SleepMetricSectionsTest.kt: an actively-recorded night is not a manual entry
|
recordingMethod→manualEntryCount derivation lives untested in SleepMetricOrderedSections.kt:516; DataConfidenceTest only covers downstream warning |
| manual-entry confidence > a hand-typed night is | PORTED | SleepMetricSectionsTest.kt: a hand-typed night is a manual entry
|
same |
Kotlin counterpart: none (card itself); underlying data in SleepPresentationMapperTest.kt
| Flutter case | Status | Kotlin test | Note |
|---|---|---|---|
| highlights Sleep (asleep) and demotes Time in bed | PORTED | SleepScreenSectionsTest: theOverviewKeepsAsleepTimeInBedAndAwakeAsThreeDistinctFigures
|
Compose instrumentation; runs on a device, not in CI |
Kotlin counterpart: app/src/test/kotlin/tech/mmarca/openvitals/features/sleep/SleepScheduleAxisTest.kt and SleepScheduleDaysTest.kt
| Flutter case | Status | Kotlin test | Note |
|---|---|---|---|
| anchoredMinutes > the 18:00 anchor is minute zero | PORTED | SleepScheduleAxisTest.kt: the anchor itself is minute zero
|
anchoredMinutes asserted at one point only; anchor→0 identity not pinned |
| anchoredMinutes > an evening bedtime sits early on the axis | PORTED | SleepScheduleAxisTest.kt: normalized end minutes stay monotone across the anchor wrap
|
23:30 → 90 with 22:00 anchor |
| anchoredMinutes > a morning wake-up wraps past midnight, not back to the top | PORTED | SleepScheduleAxisTest.kt: normalized end minutes stay monotone across the anchor wrap
|
07:15 → 555 exercises the wrap |
| anchoredMinutes > is always inside a single day | PORTED | SleepScheduleAxisTest.kt: anchoredMinutes is always inside a single day
|
0 ≤ value < 1440 sweep untested |
| normalizedEndMinutes > a wake-up after the bedtime stays after it | PORTED | SleepScheduleAxisTest.kt: normalized end minutes stay monotone across the anchor wrap
|
|
| normalizedEndMinutes > an afternoon nap that crosses the anchor still moves forward | PORTED | SleepScheduleAxisTest.kt: normalized end minutes stay monotone across the anchor wrap
|
end-before-start wrap-forward branch asserted |
| anchoredMinuteToClock > round-trips the anchor and a wrapped morning | PORTED | SleepScheduleAxisTest.kt: clockTime round-trips the anchor and a wrapped morning
|
Kotlin SleepScheduleAxis.clockTime untested |
| anchoredMinuteToClock > minuteOfDayToAnchored is its inverse | PORTED | SleepScheduleAxisTest.kt: anchoredClockMinute is the inverse of clockTime
|
Kotlin SleepScheduleAxis.anchoredClockMinute untested |
| scheduleAxisRange > is null when no night has a bedtime | PORTED | SleepScheduleAxisTest.kt: no plausible nights yields a null axis
|
|
| scheduleAxisRange > spans every night, padded to whole hours | PORTED | SleepScheduleAxisTest.kt: range pads partial hours out to whole hours
|
|
| scheduleAxisRange > one impossible night does not stretch the axis past a day | PORTED | SleepScheduleAxisTest.kt: a 20h night is excluded from the range while a 7h night sets it
|
|
| scheduleAxisRange > a long but possible lie-in still counts | PORTED | SleepScheduleAxisTest.kt: a long but possible lie-in still counts
|
14 h positive case added |
| scheduleAxisRange > is null when every night is impossible | PORTED | SleepScheduleAxisTest.kt: no plausible nights yields a null axis
|
|
| scheduleAxisRange > label ticks are hourly, thinning to two-hourly over eight hours | PORTED | SleepScheduleAxisTest.kt: tick step is hourly at an 8h span and 2-hourly above it
|
|
| toSleepScheduleDays > maps a merged night to its span and stages | PORTED | SleepScheduleDaysTest.kt: maps a merged night to its span and stages
|
Kotlin List<SleepOverviewDay>.toSleepScheduleDays() (SleepPresentationMapper.kt:185) untested |
| toSleepScheduleDays > a night with no stages carries an empty stage list | PORTED | SleepScheduleDaysTest.kt: a night with no stages carries an empty stage list
|
|
| toSleepScheduleDays > a date with no night has no bedtime | PORTED | SleepScheduleDaysTest.kt: a date with no night has no bedtime
|
|
| toSleepScheduleDays > days come out in date order | PORTED | SleepScheduleDaysTest.kt: days come out in date order
|
|
| widget > renders nothing when no night has a bedtime | PORTED | SleepChartsTest: theScheduleChartRendersNothingWhenNoNightHasABedtime
|
Compose instrumentation; runs on a device, not in CI |
| widget > draws the chart and its summary | PORTED | SleepChartsTest: theScheduleChartDrawsItsTitleAndSummary
|
Compose instrumentation; runs on a device, not in CI |
| widget > an impossible night still renders, it just does not scale | PORTED | SleepChartsTest: anImpossibleNightStillRenders_itJustDoesNotScale
|
Compose instrumentation; runs on a device, not in CI |
| widget > tapping a night reports that night, not its neighbour | PORTED | SleepChartsTest: tappingANightReportsThatNightAndNotItsNeighbour
|
Compose instrumentation; runs on a device, not in CI |
Kotlin counterpart: app/src/androidTest/kotlin/tech/mmarca/openvitals/features/sleep/SleepScreenWeekTest.kt (partial)
| Flutter case | Status | Kotlin test | Note |
|---|---|---|---|
| Sleep screen renders the Kotlin ordered sections | DIVERGED | SleepScreenWeekTest.kt: sleepWeekView_showsPeriodNavigatorAndWeekContent
|
instrumentation test asserts only navigator + week-content tag, not goal/confidence/sessions/target sections |
| the goal steppers move and persist the sleep target | PORTED | SleepViewModelTest.kt: the goal steppers move and persist the sleep target
|
quarter-hour step 8.0 -> 8.25 -> 7.75, and every step persisted |
| the sleep-vs-HRV card needs enough paired nights | PORTED | SleepScreenSectionsTest: aCoupleOfNightsIsNotEnoughToClaimASleepHrvCorrelation
|
Compose instrumentation; runs on a device, not in CI |
| a week of nights paired with HRV shows the correlation card | PORTED | SleepScreenSectionsTest: aWeekOfNightsPairedWithHrvShowsTheCorrelationCard
|
Compose instrumentation; runs on a device, not in CI |
| the day view closes with the data-source education link | PORTED | SleepScreenSectionsTest: theDayViewClosesWithTheDataSourceEducationLink
|
Compose instrumentation; runs on a device, not in CI |
| a period view renders when the selected day has no sleep but the period does | PORTED | SleepScreenSectionsTest: aPeriodViewStillRendersWhenTheSelectedDayHasNoSleep
|
Compose instrumentation; runs on a device, not in CI |
| the day view still says so when the selected day has no sleep | PORTED | SleepScreenSectionsTest: theDayEmptyStateNamesTheSelectedDayAndNotThePeriod
|
Compose instrumentation; runs on a device, not in CI |
| Sleep screen shows the access gate when permission missing | PORTED | HealthConnectAccessGateTest: insufficientAccess_replacesTheContentAndOffersToGrant
|
Kotlin routes every screen gate through HealthConnectScreenShell, so it is pinned once rather than per screen |
Kotlin counterpart: none (rendering); merged-id logic in domain/model/SleepSessionMergingTest.kt
| Flutter case | Status | Kotlin test | Note |
|---|---|---|---|
| the stage fills are actually painted — non-zero HEIGHT | N/A-WIDGET | Flutter Stack/loose-constraints layout regression | |
| a bigger stage gets a wider bar than a smaller one | N/A-WIDGET | pixel-width assertion | |
| no stage data hides the card rather than drawing empty bars | PORTED | SleepChartsTest: noStageDataHidesTheShareCardRatherThanDrawingEmptyBars
|
Compose instrumentation; runs on a device, not in CI |
| the night is drawn as a lane chart, not a flat bar | PORTED | SleepSessionTimelineCardTest: theNightIsDrawnAsALaneChartAndNotAFlatBar
|
Compose instrumentation; runs on a device, not in CI |
| tapping the card opens that night, and it says so | PORTED | SleepSessionTimelineCardTest: tappingTheCardOpensThatNightAndSaysSoFirst
|
Compose instrumentation; runs on a device, not in CI |
| a merged night offers no detail to open | PORTED | SleepSessionTimelineCardTest: aMergedNightOffersNoDetailToOpen
|
Compose instrumentation; runs on a device, not in CI |
Kotlin counterpart: app/src/test/kotlin/tech/mmarca/openvitals/features/sleep/SleepViewModelTest.kt
| Flutter case | Status | Kotlin test | Note |
|---|---|---|---|
| a loaded period lands with its display precomputed | PORTED | SleepViewModelTest.kt: a loaded period lands with its display precomputed
|
7 points, night count, average and goal target asserted |
| a permission failure becomes ScreenErrorPermissionDenied | PORTED | SleepViewModelTest.kt: a permission failure becomes ScreenError PermissionDenied
|
error type, cleared loading and empty sessions asserted |
| an unexpected failure carries its message to the screen | PORTED | SleepViewModelTest.kt: load failure sets error message
|
|
| refresh reloads the current selection in force mode | PORTED | SleepViewModelTest.kt: refresh reloads the current selection in force mode
|
resumeCurrentPeriod(refreshCurrent = true) / RefreshMode.FORCE untested |
| moving the goal rebuilds the display without reloading | PORTED | SleepViewModelTest.kt: moving the goal rebuilds the display without reloading
|
goal-step-without-repo-reload untested (Kotlin computes goal progress in composable) |
| navigating to a new range clears the stale display mid-load | N/A-BEHAVIOR | none | blocked on behavior decision - Kotlin keeps the previous display through a load and only flips isLoading; there is no nullable display to clear |
| a same-range refresh keeps the display (no loading flash) | PORTED | SleepViewModelTest.kt: a same-range refresh keeps the display
|
no coverage |
| a stale load cannot overwrite the newer one it lost to | PORTED | SleepViewModelTest.kt: a stale load cannot overwrite the newer one it lost to
|
single-flight/staleness guard untested |
Kotlin counterpart: app/src/test/kotlin/tech/mmarca/openvitals/ui/charts/ChartViewportTest.kt (zoom math only)
Note: Kotlin bodyenergy belongs to a separate workstream; classified against what exists today.
| Flutter case | Status | Kotlin test | Note |
|---|---|---|---|
| pinching the Body Energy chart zooms the line, strip and hours | PORTED | BodyEnergyChartZoomTest: pinchingTheChartZoomsTheLineStripAndHoursTogether
|
Compose instrumentation; runs on a device, not in CI |
Kotlin counterpart: none (BodyEnergyScreen.kt has no Compose/androidTest; mapper covered separately by BodyEnergyPresentationMapperTest.kt)
| Flutter case | Status | Kotlin test | Note |
|---|---|---|---|
| Body Energy renders the timeline chart once loaded | PORTED | BodyEnergyCardsTest: theDayCardRendersTheTimelineOnceLoaded
|
Compose instrumentation; runs on a device, not in CI |
| the Daily Readiness card rides along on the same day | N/A-WIDGET | — | day-follow guard in DailyReadinessCard.kt/DailyReadinessViewModel.kt is untested in Kotlin |
| Body Energy renders the "how it is estimated" card | PORTED | BodyEnergyCardsTest: theExplainerCardsSayWhatMovedItAndHowItIsEstimated
|
Compose instrumentation; runs on a device, not in CI |
| Body Energy shows only the calibration card until setup completes | N/A-WIDGET | — | setupCompleted flag round-trip covered by BodyEnergyCalibrationTest (automatic calibration defaults to setup not completed, normalization preserves setupCompleted flag); the screen gating itself untested |
| Body Energy reveals the timeline after calibration is saved | N/A-WIDGET | — | BodyEnergyViewModel.completeSetup untested (no BodyEnergyViewModelTest exists) |
| Body Energy setup refuses to complete without a birth year | PORTED | BodyEnergyCalibrationCardTest: setupRefusesToCompleteWithoutABirthYear
|
Compose instrumentation; runs on a device, not in CI |
| Body Energy shows the access gate when permission missing | N/A-WIDGET | — | generic error mapping covered by core/presentation ScreenErrorTest; bodyenergy permission path untested |
| Body Energy shows the empty state with no timeline | PORTED | BodyEnergyCardsTest: withNoTimelineAtAllTheCardShowsNoScoreRatherThanAZero
|
Compose instrumentation; runs on a device, not in CI |
Kotlin counterpart: app/src/test/kotlin/tech/mmarca/openvitals/features/bodyenergy/BodyEnergyPresentationMapperTest.kt
| Flutter case | Status | Kotlin test | Note |
|---|---|---|---|
| no timeline at all is an empty display | PORTED | BodyEnergyPresentationMapperTest.kt: no timeline at all is an empty display
|
nullable-receiver extension |
| a timeline with no points still explains its inputs | PORTED | BodyEnergyPresentationMapperTest.kt: missing input rows expose sparse body energy inputs
|
Kotlin asserts more row statuses (sleep/workouts/calibration) though not the isEmpty flag |
| points become day fractions, and the strip scales to its tallest bar | PORTED | BodyEnergyPresentationMapperTest.kt: influence bars preserve bucket x fractions + max influence magnitude floors at one so an empty day divides by something
|
split across two tests; same xFraction and tallest-bar assertions |
| the reasons rank charge and drain together, and drop the trivial ones | PORTED | BodyEnergyPresentationMapperTest.kt: top reasons summarize largest charge and drain contributors + reasons below the minimum amount are dropped
|
full rank order now pinned as a list |
| the legend lists only the influences that actually moved the score | PORTED | BodyEnergyPresentationMapperTest.kt: the legend lists only the influences that actually moved the score
|
legendInfluences is computed in BodyEnergyPresentationMapper.kt:124 but never asserted |
Kotlin counterpart: app/src/test/kotlin/tech/mmarca/openvitals/features/bodyenergy/BodyEnergyViewModelTest.kt
| Flutter case | Status | Kotlin test | Note |
|---|---|---|---|
| a loaded day lands with its display precomputed | PORTED | BodyEnergyViewModelTest.kt: a loaded day lands with its display precomputed
|
standard coroutine VM test, portable |
| a day with no timeline at all still gives the screen a display | PORTED | BodyEnergyViewModelTest.kt: a day with no timeline at all still gives the screen a display
|
|
| a permission failure becomes ScreenErrorPermissionDenied | PORTED | BodyEnergyViewModelTest.kt: a permission failure becomes ScreenError PermissionDenied
|
the former message-path test now asserts the typed error, as in the Dart original |
| an unexpected failure carries its message to the screen | PORTED | BodyEnergyViewModelTest.kt: an unexpected failure carries its message to the screen
|
|
| a future day is clamped to today | PORTED | BodyEnergyViewModelTest.kt: a future day is clamped to today
|
|
| a stale load cannot overwrite the newer one it lost to | PORTED | BodyEnergyViewModelTest.kt: a stale load cannot overwrite the newer one it lost to
|
Kotlin counterpart: none (DailyReadinessCard.kt untested)
| Flutter case | Status | Kotlin test | Note |
|---|---|---|---|
| renders the readiness verdict for the host day | PORTED | DailyReadinessPanelTest: rendersTheReadinessVerdictForTheHostDay
|
Compose instrumentation; unblocked by the readinessInsight() fixture |
| no self-link: the card offers Training but not Body energy | PORTED | DailyReadinessPanelTest: noSelfLink_theCardOffersTrainingButNotBodyEnergy
|
Compose instrumentation; unblocked by the readinessInsight() fixture |
| a day the provider has not reached yet shows a placeholder | N/A-WIDGET | — | day-guard placeholder logic in the Kotlin card is untested |
Kotlin counterpart: none for the display compositions (panel lines live as private functions in DailyReadinessPanel.kt, detail spec in ReadinessScoreDetailsScreen.kt); partial neighbors: features/readiness/StressL10nTest.kt (stress strings), domain/insights/DailyReadinessTest.kt (insight computation)
| Flutter case | Status | Kotlin test | Note |
|---|---|---|---|
| daily readiness panel > composes every line the panel used to build inline | MISSING | none | blocked - needs a production seam in features/readiness (owned by another workstream) |
| daily readiness panel > an empty insight still composes: no strain, no score, no factors | MISSING | none | blocked - needs a production seam in features/readiness (owned by another workstream) |
| daily readiness panel > an unrecognised confidence reason falls back to partial data | PORTED | ReadinessConfidenceTextTest: an unrecognised confidence reason falls back to partial data
|
The mapping moved out of the two screens into ReadinessConfidenceText; each had its own copy, both built from hardcoded English that never reached strings.xml |
| daily readiness panel > the factor list is capped at five | MISSING | none | blocked - needs a production seam in features/readiness (owned by another workstream) |
| training readiness detail > score, verdict, confidence, signals and guidance | MISSING | none | blocked - readinessDetailSpec is private in ReadinessScoreDetailsScreen.kt (other workstream) |
| training readiness detail > no training-side factors falls back to the no-signals message | MISSING | none | blocked - needs a production seam in features/readiness (owned by another workstream) |
| training readiness detail > an unknown state reads as needs-more-data, whatever the score | MISSING | none | blocked - needs a production seam in features/readiness (owned by another workstream) |
| training readiness detail > the verdict bands | MISSING | none | blocked - scoreBandLabel is private in ReadinessScoreDetailsScreen.kt (other workstream) |
| training readiness detail > the strain bullet always renders, from the state | MISSING | none | blocked - needs a production seam in features/readiness (owned by another workstream) |
Kotlin counterpart: none (ReadinessScoreDetailsScreen.kt has no test); date-arg parsing covered by navigation/SelectedDayArgTest.kt
| Flutter case | Status | Kotlin test | Note |
|---|---|---|---|
| renders score, verdict, and training factor group | PORTED | TrainingReadinessDetailsTest: rendersTheScoreVerdictAndTheTrainingSignalsBehindIt
|
Compose instrumentation; runs on a device, not in CI |
| falls back to the no-signals message when factors are empty | PORTED | TrainingReadinessDetailsTest: withNoTrainingSideSignalsItSaysSoRatherThanShowingAnEmptyCard
|
Compose instrumentation; runs on a device, not in CI |
| shows the needs-more-data verdict for an unknown state | PORTED | TrainingReadinessDetailsTest: anUnknownStateReadsAsNeedsMoreDataHoweverHighTheScore
|
Compose instrumentation; runs on a device, not in CI |
| an invalid date argument falls back to today and still renders | N/A-WIDGET | — | arg-fallback core covered by SelectedDayArgTest.kt: absent or malformed values read as no pin
|
Kotlin counterpart: app/src/test/kotlin/tech/mmarca/openvitals/features/readiness/DailyReadinessViewModelTest.kt (the detail display spec still lives inside ReadinessScoreDetailsScreen.kt and is untestable without a seam there)
| Flutter case | Status | Kotlin test | Note |
|---|---|---|---|
| load publishes the insight and its precomputed display | DIVERGED | DailyReadinessViewModelTest.kt: load publishes the insight for the loaded day
|
insight covered; the detail display spec is built inside ReadinessScoreDetailsScreen.kt (other workstream) |
| a future day is clamped to today | PORTED | DailyReadinessViewModelTest.kt: a future day is clamped to today
|
|
| day navigation loads the day it moves to, and stops at today | PORTED | DailyReadinessViewModelTest.kt: day navigation loads the day it moves to, and stops at today
|
|
| refresh reloads the selected day, forcing it | PORTED | DailyReadinessViewModelTest.kt: refresh reloads the selected day, forcing it
|
|
| a stale day cannot overwrite the day that overtook it | PORTED | DailyReadinessViewModelTest.kt: a stale day cannot overwrite the day that overtook it
|
|
| a failed load becomes a ScreenError, not an exception | MISSING | — | blocked - the seam lives in features/readiness (owned by another workstream) |
Kotlin counterpart: app/src/test/kotlin/tech/mmarca/openvitals/features/heart/HeartMetricStatsTest.kt (BP / SpO2 / skin-temp / axis-floor stats), HeartPresentationMapperTest.kt, HeartViewModelTest.kt, features/vitals/HeartVitalsSummariesTest.kt
| Flutter case | Status | Kotlin test | Note |
|---|---|---|---|
| heart rate > a day of samples sorts oldest first and takes its extremes | PORTED | HeartMetricStatsTest.kt: a day of samples sorts oldest first and takes its extremes
|
flags/counts and sorting asserted; min/max/avg and axis padding values not |
| heart rate > a period keeps both orders: oldest first, and newest first | PORTED | HeartViewModelTest.kt: load success populates display metric for average heart rate
|
both orders and extremes now asserted |
| heart rate > the threshold checks count the days that crossed the line | PORTED | HeartViewModelTest.kt: heart rate checks count days in multi-day ranges (+ heart rate checks count samples in day range using configured thresholds) |
|
| resting heart rate > the day falls back to the mean of its samples with no aggregate | DIVERGED | data/repository/HeartRepositoryTest.kt: DAY resting heart rate uses raw full samples for selected day graph
|
Kotlin derives dayRestingBpm = sample mean in the repository; low/high-from-samples display values unasserted |
| resting heart rate > an aggregate with no samples still yields one reading | DIVERGED | HeartPresentationMapperTest.kt: resting heart rate display populates day value
|
day value + sampleCount 1 asserted; low/high collapse onto the aggregate not |
| vitals > blood pressure keeps the latest reading and the highest one | PORTED | HeartMetricStatsTest.kt: blood pressure keeps the latest reading and the highest one
|
latest asserted at VM level; highest/averageSystolic/sort (HeartMetricSharedSections.kt:487) untested |
| vitals > SpO2 averages every reading and sorts the series | PORTED | HeartMetricStatsTest.kt: spO2 averages every reading and keeps its extremes
|
SpO2 average/sort/stats computed in composables, untested |
| vitals > skin temperature excludes deltaless entries from the arithmetic | PORTED | HeartMetricStatsTest.kt: skin temperature excludes deltaless entries from the arithmetic
|
the filter (skinTemperatureChartEntries, SkinTemperatureSections.kt) is asserted through the chart entries |
| vitals > skin temperature with no delta anywhere has no statistics at all | PORTED | HeartMetricStatsTest.kt: skin temperature with no delta anywhere has no statistics at all
|
|
| — | KOTLIN-ONLY | SkinTemperatureSectionsTest.kt: a row says the variation and the record's own baseline, a delta-less record never prints its baseline as a reading, the period baseline is the one the records share, records that disagree fall back to the newest baseline, flagged, no record with a baseline means no period baseline
|
skin temperature as a variation from a per-record baseline (issue #2) |
| — | KOTLIN-ONLY | SkinTemperatureDayChartTest.kt: samples across records are flattened and sorted, the day's only, the summary is zero-centred and spans the recorded times, one sample is a dot, not a line
|
the day chart draws the records' samples, not one dot per record |
| an empty period leaves every section null | DIVERGED | HeartPresentationMapperTest.kt: average heart rate display has no data for empty week
|
only the HR metric's hasData=false; other sections and the empty threshold checks unasserted |
| a day average never sits outside its own range > resting heart rate averages the samples it also ranges | PORTED | HeartMetricStatsTest.kt: resting heart rate averages the samples it also ranges
|
avg-inside-own-range invariant not asserted anywhere in Kotlin |
| a day average never sits outside its own range > with no samples the provider aggregate is all there is | PORTED | HeartMetricStatsTest.kt: with no samples the provider aggregate is all there is
|
aggregate-only day covered; low/high collapse not |
| respiratory rate reports ONE average, the one under its chart | PORTED | HeartVitalsSummariesTest.kt: week respiratory summary averages daily buckets (+ day respiratory summary uses latest reading) |
same mean-of-daily-means rule |
Kotlin counterpart: app/src/test/kotlin/tech/mmarca/openvitals/features/heart/HeartViewModelTest.kt, core/presentation/MetricDetailSectionOrderViewModelTest.kt (screen itself has no Compose test; androidTest MetricDetailScaffoldTest covers the shared scaffold)
| Flutter case | Status | Kotlin test | Note |
|---|---|---|---|
| Heart rate screen renders the ordered period sections | PORTED | HeartMetricContentTest: aLoadedPeriodRendersTheOrderedSections
|
Compose instrumentation; runs on a device, not in CI |
| Average heart rate period view renders the data-source education item | PORTED | HeartMetricContentTest: aLoadedPeriodCarriesTheDataSourceEducationLink
|
Compose instrumentation; runs on a device, not in CI |
| Heart rate screen shows the access gate when permission missing | PORTED | HealthConnectAccessGateTest: insufficientAccess_replacesTheContentAndOffersToGrant
|
Kotlin routes every screen gate through HealthConnectScreenShell, so it is pinned once rather than per screen |
| Heart rate screen shows the empty placeholder with no data | PORTED | HeartMetricContentTest: showsTheEmptyPlaceholderWithNoData
|
Compose instrumentation; runs on a device, not in CI |
| Threshold steppers persist to SharedPreferences | PORTED | HeartViewModelTest.kt: updating high heart rate threshold persists and recalculates checks
|
persist + recalculation covered; stepper tap itself is widget-only |
| Section reorder persists the new order | DIVERGED | MetricDetailSectionOrderViewModelTest.kt: moveSectionToTarget_reordersAndPersists
|
persist call verified; re-read by a fresh instance not asserted |
Kotlin counterpart: app/src/test/kotlin/tech/mmarca/openvitals/features/heart/HeartViewModelTest.kt
| Flutter case | Status | Kotlin test | Note |
|---|---|---|---|
| a loaded period lands with its display precomputed | PORTED | HeartViewModelTest.kt: load success populates display metric for average heart rate
|
display populated asserted; order/avg/low/high values not |
| a permission failure becomes ScreenErrorPermissionDenied | PORTED | HeartViewModelTest.kt: a permission failure becomes ScreenError PermissionDenied
|
a refused heart read maps to ScreenError.PermissionDenied; the missing-permission state stays a separate signal |
| an unexpected failure carries its message to the screen | PORTED | HeartViewModelTest.kt: load failure sets error and clears loading
|
ScreenError.Message("error") asserted |
| refresh reloads the current selection in force mode | PORTED | HeartViewModelTest.kt: refresh reloads the current selection in force mode
|
no refresh()/RefreshMode.FORCE reload test (FORCE only asserted on the delete-reload path) |
| a moved threshold rebuilds the checks against the loaded samples | PORTED | HeartViewModelTest.kt: updating high heart rate threshold persists and recalculates checks
|
|
| a stale load cannot overwrite the newer one it lost to | PORTED | HeartViewModelTest.kt: a stale load cannot overwrite the newer one it lost to
|
no staleness/single-flight guard test |
Kotlin counterpart: app/src/test/kotlin/tech/mmarca/openvitals/features/recovery/HeartRateRecoveryViewModelTest.kt
| Flutter case | Status | Kotlin test | Note |
|---|---|---|---|
| a strap gives a chartable point per workout | DIVERGED | HeartRateRecoveryViewModelTest.kt: only comparable readings feed the chart and the rest are counted as unmeasured
|
chartable/comparable covered; ordering contract is reversed by design (Kotlin newest-first vs Flutter oldest-first) |
| a watch that stops recording gives workouts with NO chartable point — and they are still counted | PORTED | HeartRateRecoveryViewModelTest.kt: only comparable readings feed the chart and the rest are counted as unmeasured
|
|
| an ordinary workout with no cessation mark is never even read | PORTED | HeartRateRecoveryViewModelTest.kt: only sessions of five minutes or more with a stop mark cost a heart rate read (+ a period with no candidates issues no heart rate reads at all) |
|
| a period bigger than the cap says so rather than quietly showing less | PORTED | HeartRateRecoveryViewModelTest.kt: sessions come back newest first and a period over the cap is truncated and says so
|
Kotlin counterpart: none (screen); VM covered by features/recovery/HeartRateRecoveryViewModelTest.kt
| Flutter case | Status | Kotlin test | Note |
|---|---|---|---|
| renders the period view without an error for a normal week | PORTED | HeartRateRecoveryEmptyCardTest: theEmptyPeriodCardSaysBothThatThereIsNothingAndWhy
|
Compose instrumentation; runs on a device, not in CI |
Kotlin counterpart: app/src/test/kotlin/tech/mmarca/openvitals/features/vitals/HeartVitalsOverviewCardsTest.kt (per-card stats), HeartVitalsSummariesTest.kt, HeartVitalsRangeSummaryTest.kt, features/heart/HeartMetricStatsTest.kt, HeartPresentationMapperTest.kt, HeartViewModelTest.kt
| Flutter case | Status | Kotlin test | Note |
|---|---|---|---|
| heart rate > a day of samples sorts oldest first and averages them | PORTED | HeartVitalsOverviewCardsTest.kt: a day of samples sorts oldest first and averages them
|
sorting covered; day average/min/max values unasserted |
| heart rate > the axis floors at 30, never at min-5 below it | PORTED | HeartMetricStatsTest.kt: the intraday axis floors at 30 never at min minus five below it
|
generic padding floor covered; the HR-specific 30 bpm floor (HeartCards.kt:48) untested |
| heart rate > a lone day sample still fills the card but draws no timeline | PORTED | HeartPresentationMapperTest.kt: average heart rate display hides timeline for single day sample
|
|
| heart rate > the day card names its source only when the samples agree | PORTED | HeartVitalsOverviewCardsTest.kt: the day card names its source only when the samples agree
|
source-agreement (distinct().singleOrNull()) asserted only for respiratory, not heart rate |
| heart rate > a period takes its extremes across every daily summary | PORTED | HeartVitalsOverviewCardsTest.kt: a period takes its extremes across every daily summary
|
rangeSummary non-null only; extremes/mean values unasserted |
| resting heart rate and HRV > a day reads the provider aggregate, not the daily series | PORTED | HeartVitalsOverviewCardsTest.kt: a day reads the provider aggregate not the daily series
|
aggregate path covered; aggregate-beats-series contrast not asserted |
| resting heart rate and HRV > a period averages the daily series and keeps its extremes | PORTED | HeartVitalsRangeSummaryTest.kt: resting heart rate summary reports the real average and range + hrv summary reports the real average and range (+ mapper week-trend average) |
|
| resting heart rate and HRV > a day with no resting aggregate has no resting card | PORTED | HeartVitalsOverviewCardsTest.kt: a day with no resting aggregate has no resting card
|
null aggregate covered; no-card rule despite a present daily series not |
| cardiovascular > blood pressure sorts, counts and takes the latest reading | PORTED | HeartVitalsOverviewCardsTest.kt: blood pressure sorts counts and takes the latest reading
|
latest (122) asserted; sort/readings/hasChart untested |
| cardiovascular > SpO2 and blood glucose average every reading | DIVERGED | HeartViewModelTest.kt: load success populates selected vitals and latest values
|
latest covered per selected metric; every-reading averages computed in composables, unasserted |
| cardiovascular > a long-range overview reads native daily aggregates | PORTED | HeartVitalsOverviewCardsTest.kt: a long-range overview reads native daily aggregates
|
count-weighted mean and raw-count total now asserted |
| cardiovascular > within a day one timestamp draws no chart, two do | PORTED | HeartVitalsOverviewCardsTest.kt: within a day one timestamp draws no chart and two do
|
hasChart threshold for vitals untested (HR analog covered by the mapper single-sample test) |
| respiratory > a period card and chart both print the mean of the daily means | PORTED | HeartVitalsSummariesTest.kt: week respiratory summary averages daily buckets
|
|
| respiratory > a day card prints the latest reading, the chart the daily mean | PORTED | HeartVitalsSummariesTest.kt: day respiratory summary uses latest reading + respiratoryRateDaySummaries groups readings by date
|
|
| respiratory > a long-range card names the latest reading source | DIVERGED | HeartVitalsSummariesTest.kt: day respiratory summary uses latest reading (source asserted) |
week variant reports a "3 readings" count as source instead; long-range latest-source not asserted |
| respiratory > skin temperature charts a daily delta point per day, weighted mean | PORTED | HeartVitalsOverviewCardsTest.kt: skin temperature charts a daily delta point per day with a daily mean
|
skin-temp daily aggregation in composable, untested |
| respiratory > day view charts only the raw entries that carry a delta | PORTED | HeartVitalsOverviewCardsTest.kt: day view charts only the raw entries that carry a delta
|
delta filter: skinTemperatureChartEntries (SkinTemperatureSections.kt) |
| respiratory > a delta-less newest entry does not blank the card (day) | PORTED | HeartVitalsOverviewCardsTest.kt: a delta-less newest entry does not blank the card (day)
|
fixed: the card reads skinTemperatureCardDeltaCelsius() - the newest entry that carries a delta, the same population the chart draws |
| respiratory > a day with no delta anywhere shows nothing, card or chart | PORTED | HeartVitalsOverviewCardsTest.kt: a day with no delta anywhere shows nothing card or chart
|
|
| respiratory > body temperature counts its readings and takes the latest (day) | PORTED | HeartVitalsOverviewCardsTest.kt: body temperature counts its readings and takes the latest
|
latest pattern covered; readings count untested |
| respiratory > body temperature over a long range totals its daily reading counts | PORTED | HeartVitalsOverviewCardsTest.kt: body temperature over a long range totals its daily reading counts
|
|
| an empty period derives an empty display, section by section | PORTED | HeartVitalsOverviewCardsTest.kt: an empty period derives an empty display section by section
|
no all-sections-empty derivation test |
| vo2 max sorts, counts and takes the latest reading (day) | PORTED | HeartVitalsOverviewCardsTest.kt: vo2 max sorts counts and takes the latest reading
|
latest pattern covered; sort/count untested |
| vo2 max over a long range totals its daily reading counts | PORTED | HeartVitalsOverviewCardsTest.kt: vo2 max over a long range totals its daily reading counts
|
Kotlin counterpart: app/src/test/kotlin/tech/mmarca/openvitals/features/vitals/HeartVitalsSummariesTest.kt, features/heart/HeartViewModelTest.kt, core/presentation/MetricDetailSectionOrderViewModelTest.kt
| Flutter case | Status | Kotlin test | Note |
|---|---|---|---|
| respiratory flat mean and daily-bucket mean stay distinct | PORTED | HeartVitalsSummariesTest.kt: week respiratory summary averages daily buckets
|
uneven reading counts pin the equal-weighting rule |
| renders the three reorderable group sections | PORTED | HeartVitalsOverviewContentTest: theThreeGroupSectionsRenderAndTheStoredOrderDecidesWhichComesFirst
|
Compose instrumentation; runs on a device, not in CI |
| renders the data-source education item after the sections | PORTED | HeartVitalsOverviewContentTest: theDataSourceEducationItemComesAfterTheSections
|
Compose instrumentation; runs on a device, not in CI |
| renders the three heart-section MetricLineCharts | PORTED | HeartVitalsOverviewContentTest: theHeartSectionDrawsAChartUnderEachOfItsThreeCards
|
Compose instrumentation; runs on a device, not in CI |
| lays the summary metrics out two per row | N/A-WIDGET | — | pure layout |
| includes the skin temperature card in the respiratory section | N/A-WIDGET | — | |
| changing the range selector reloads the period | PORTED | HeartViewModelTest.kt: selectRange updates selectedRange + WEEK range calls loadDailyHeartRateSummaries / DAY range calls loadHeartRateSamples
|
range-driven reload covered at VM level |
| tapping the skin temperature row opens its metric route | N/A-WIDGET | — | navigation wiring |
| reordering a section persists across a rebuild | DIVERGED | MetricDetailSectionOrderViewModelTest.kt: moveSectionToTarget_reordersAndPersists
|
persist verified; fresh-instance re-read not asserted |
Kotlin counterpart: app/src/test/kotlin/tech/mmarca/openvitals/features/heart/HeartViewModelTest.kt (Kotlin has no separate vitals-overview VM; HeartViewModel serves the overview)
| Flutter case | Status | Kotlin test | Note |
|---|---|---|---|
| a loaded period lands with its display precomputed | PORTED | HeartViewModelTest.kt: load success populates display metric for average heart rate
|
populated-state asserted; display values not |
| the display follows the range the load carried | PORTED | HeartViewModelTest.kt: WEEK range does not call loadHeartRateSamples + DAY range calls loadHeartRateSamples + switching from DAY to WEEK clears HR samples
|
|
| a permission failure becomes ScreenErrorPermissionDenied | PORTED | HeartViewModelTest.kt: a permission failure on the overview becomes ScreenError PermissionDenied
|
the refused vitals half fails the combined load and keeps the permission type |
| an unexpected failure carries its message to the screen | PORTED | HeartViewModelTest.kt: load failure sets error and clears loading
|
|
| either half failing fails the combined load | PORTED | HeartViewModelTest.kt: either half failing fails the combined load
|
no combined heart+vitals failure test |
| refresh reloads the current selection in force mode | PORTED | HeartViewModelTest.kt: refresh reloads the current selection in force mode
|
|
| a stale load cannot overwrite the newer one it lost to | PORTED | HeartViewModelTest.kt: a stale load cannot overwrite the newer one it lost to
|
Kotlin counterpart: app/src/test/kotlin/tech/mmarca/openvitals/features/heart/HeartViewModelTest.kt (delete path); screens untested
| Flutter case | Status | Kotlin test | Note |
|---|---|---|---|
| Blood pressure screen renders the ordered sections once loaded | PORTED | VitalsContentTest: bloodPressureRendersItsSectionsOnceLoaded
|
Compose instrumentation; runs on a device, not in CI |
| Blood pressure screen shows the access gate when missing | PORTED | HealthConnectAccessGateTest: insufficientAccess_replacesTheContentAndOffersToGrant
|
Kotlin routes every screen gate through HealthConnectScreenShell, so it is pinned once rather than per screen |
| Blood pressure screen shows placeholder with no readings | PORTED | VitalsContentTest: bloodPressureShowsThePlaceholderWithNoReadings
|
Compose instrumentation; runs on a device, not in CI |
| Swipe-deleting a manual entry calls the vitals repository | PORTED | HeartViewModelTest.kt: deleteVitalsMeasurementEntry removes OpenVitals blood pressure and reloads (+ ignores blood pressure not created by OpenVitals) |
swipe gesture itself widget-only |
Kotlin counterpart: app/src/test/kotlin/tech/mmarca/openvitals/features/achievements/AchievementsViewModelTest.kt (badge evaluation lives inside AchievementsViewModel; no separate display test)
| Flutter case | Status | Kotlin test | Note |
|---|---|---|---|
| an empty history unlocks nothing and reports no activity | PORTED | AchievementsViewModelTest.kt: an empty history unlocks nothing and reports no activity
|
no empty-history invariants test (unlockedCount 0, ratios, hasActivityHistory, per-badge zeros) |
| the stats aggregate the whole window | PORTED | AchievementsViewModelTest.kt: the stats aggregate the whole window
|
blank-day exclusion, totalDistance, maxDailyFloors, window dates all asserted |
| a badge is earned on the first day that reaches its target | PORTED | AchievementsViewModelTest.kt: a badge is earned on the first day that reaches its target
|
achievedOn/timesEarned/currentValue/completionRatio asserted |
| a locked badge carries its partial progress, clamped | PORTED | AchievementsViewModelTest.kt: a locked badge carries its partial progress, clamped
|
0.5 ratio plus whole-catalog clamping |
| the category filter is precomputed per chip | PORTED | AchievementsViewModelTest.kt: the category filter is precomputed per chip
|
per-category filter untested (catalog category counts covered by catalog includes legacy activity badge set, not the filter) |
| a history with no floor data leaves the floor badges unearned | PORTED | AchievementsViewModelTest.kt: a history with no floor data leaves the floor badges unearned
|
no floorless-history case |
Kotlin counterpart: none (AchievementsScreen.kt untested); VM covered by AchievementsViewModelTest.kt
| Flutter case | Status | Kotlin test | Note |
|---|---|---|---|
| renders summary + earned badge once loaded | PORTED | AchievementsContentTest: theSummaryCardCountsUnlockedAgainstTotal, anEarnedBadgeNamesTheDayItWasEarned
|
Compose instrumentation; runs on a device, not in CI |
| shows the no-activity message with empty history | PORTED | AchievementsEmptyHistoryTest: anEmptyHistorySaysSoRatherThanLeavingEveryBadgeUnexplained
|
Compose instrumentation; runs on a device, not in CI |
Kotlin counterpart: app/src/test/kotlin/tech/mmarca/openvitals/features/homewidgets/HomeQuickBeverageWidgetDrinkOrderingTest.kt (ordering + picker labels), HomeQuickBeverageWidgetStateTest.kt (cached snapshot round trip), HomeQuickBeverageWidgetReceiverTest.kt (receiver resolution); the Activity's persistence path stays instrumentation-only
| Flutter case | Status | Kotlin test | Note |
|---|---|---|---|
| configure route > carries the widget type and the appWidgetId | N/A-FRAMEWORK | — | Flutter deep-link configure route; Kotlin uses a native config Activity receiving appWidgetId in the launch intent |
| configure route > an ordinary launch is not a configure launch | N/A-FRAMEWORK | — | |
| configure route > a route it cannot fully understand is refused | N/A-FRAMEWORK | — | |
| configure route > a status widget routes to the exact-alarm gate | N/A-FRAMEWORK | — | no exact-alarm gate exists in the Kotlin app |
| widget-type resolution > a beverage configure route opens the beverage picker | PORTED | HomeWidgetConfigurationActivityTest: aBeverageWidgetOpensTheBeveragePicker
|
Compose instrumentation; runs on a device, not in CI |
| widget-type resolution > the 1x1 opens the same beverage picker | PORTED | HomeWidgetConfigurationActivityTest: theOneTapWidgetOpensTheSameBeveragePicker
|
Compose instrumentation; runs on a device, not in CI |
| widget-type resolution > a metric configure route NEVER opens the beverage picker | PORTED | HomeWidgetConfigurationActivityTest: aMetricWidgetNeverOpensTheBeveragePicker
|
Compose instrumentation; runs on a device, not in CI |
| widget-type resolution > a beverage route NEVER opens the metric picker, either | PORTED | HomeWidgetConfigurationActivityTest: aBeverageWidgetNeverOpensTheMetricPicker
|
Compose instrumentation; runs on a device, not in CI |
| beverage picker > lists the drinks as "name - amount", in catalog order | PORTED | HomeQuickBeverageWidgetDrinkOrderingTest.kt: the picker lists the drinks as name - amount, in catalog order
|
label extracted to quickBeverageWidgetPickerLabels; amount formatting now asserted |
| beverage picker > an empty catalog says so rather than showing a blank list | N/A-WIDGET | — | empty-state rendering; ordering helper's empty-input behavior also untested |
| beverage picker > picking a drink persists the selection and the payload, pushes, finishes | N/A-FRAMEWORK | none | instrumentation-only - the Glance action/receiver runs on AppWidgetManager + SharedPreferences with no JVM seam; none |
| beverage picker > the 1x1 pick pushes to the 1x1 receiver | PORTED | HomeQuickBeverageWidgetReceiverTest.kt: a one-tap instance is redrawn through the one-tap receiver
|
receiver resolution covered; the setDrink half is instrumentation-only |
| beverage picker > backing out without picking never finishes the configuration | PORTED | HomeWidgetConfigurationActivityTest: backingOutOfTheBeveragePickerNeverFinishesTheConfiguration
|
Compose instrumentation; runs on a device, not in CI |
| refresh > re-pushes a configured instance from its cached payload | PORTED | HomeQuickBeverageWidgetStateTest.kt: re-pushes a configured instance from its cached payload
|
cached-payload round trip covered; refreshHomeQuickBeverageWidget orchestration still untested |
| refresh > leaves an unconfigured instance on its native state | PORTED | HomeQuickBeverageWidgetStateTest.kt: leaves an unconfigured instance on its native state
|
null-snapshot path untested |
Kotlin counterpart: none for the log path (HomeQuickBeverageLogAction + features/manualentry/hydration/HydrationDrinkLogger.kt logCustomHydrationDrinkEntry have no tests); reminder rules partially in features/hydration/reminders/HydrationReminderControllerTest.kt
| Flutter case | Status | Kotlin test | Note |
|---|---|---|---|
| quickBeverageLogAppWidgetId > reads the appWidgetId off the background URI | N/A-FRAMEWORK | — | Flutter background-callback URI; Kotlin ActionCallback receives the GlanceId directly |
| quickBeverageLogAppWidgetId > ignores anything that is not a beverage-log broadcast | N/A-FRAMEWORK | — | |
| resolves Health Connect access before writing | N/A-FRAMEWORK | none | instrumentation-only - the Glance action/receiver runs on AppWidgetManager + SharedPreferences with no JVM seam; — |
| logs the hydration AND the nutrition entry for a drink with caffeine | PORTED | HomeQuickBeverageLogTest.kt | the "no JVM seam" note was a category error — the Glance plumbing needs a device, the decision layer is plain Kotlin over two repository interfaces |
| re-anchors the hydration reminder once water is logged | PORTED | HydrationReminderControllerTest.kt (re-anchor-on-log rules); HomeQuickBeverageWidget.kt | was a real gap, not a testing one: the widget path hid the notification but left the alarm armed from the previous drink. It now calls applyConfig() like the in-app save and the notification quick-add both did |
| leaves the reminder alone for a nutrition-only drink | PORTED | HomeQuickBeverageLogTest.kt | a 0-multiplier drink writes no hydration; wroteHydration=false and effectiveLiters=0.0 are the flags the reminder gate reads |
| confirms with "Saved now", then falls back to "Tap to log" | N/A-FRAMEWORK | none | instrumentation-only - the Glance action/receiver runs on AppWidgetManager + SharedPreferences with no JVM seam; — |
| a nutrition-only drink confirms with "Saved as nutrition" | PORTED | HomeQuickBeverageLogTest.kt | same outcome object selects the string |
| a missing permission writes nothing and is not auto-cleared | PORTED | HomeQuickBeverageLogTest.kt | MISSING_WRITE_PERMISSION, zero writes to either repository |
| a missing nutrition permission blocks the whole drink | PORTED | HomeQuickBeverageLogTest.kt | no hydration write either — the water is not logged with the caffeine lost |
| a failed write reports "Unable to update" and does not throw | PORTED | HomeQuickBeverageLogTest.kt | the write propagates (the contract the action's runCatching depends on) and the nutrition half is never attempted |
| an unconfigured instance is told to pick a beverage, and writes nothing | N/A-FRAMEWORK | none | instrumentation-only - the Glance action/receiver runs on AppWidgetManager + SharedPreferences with no JVM seam; — |
| a stale payload naming another drink is refused | N/A-FRAMEWORK | — | payload staleness is bridge-specific; Kotlin resolves the drink by id from the repository |
| redraws the 2x1 receiver when the 2x1 owns the instance | PORTED | HomeQuickBeverageWidgetReceiverTest.kt: redraws the 2x1 receiver when the 2x1 owns the instance
|
receiver-class selection helper untested |
| an appWidgetId belonging to no beverage widget is ignored | DIVERGED | HomeQuickBeverageWidgetReceiverTest.kt: an appWidgetId belonging to no placed widget falls back to the 2x1
|
the hasAppWidgetInfo guard is covered; drinkIdFor's null early-return is instrumentation-only (SharedPreferences) |
Kotlin counterpart: app/src/test/kotlin/tech/mmarca/openvitals/features/homewidgets/HomeQuickBeverageWidgetDrinkOrderingTest.kt and HomeQuickBeverageAmountLabelTest.kt
| Flutter case | Status | Kotlin test | Note |
|---|---|---|---|
| quickBeverageWidgetDrinkOptions > frequent drinks come first, in their own ranking | PORTED | HomeQuickBeverageWidgetDrinkOrderingTest.kt: orders frequent then custom then catalog drinks by category and name
|
frequent order (cola, still-water) preserved |
| quickBeverageWidgetDrinkOptions > a frequent drink that is no longer in the catalog is dropped | PORTED | HomeQuickBeverageWidgetDrinkOrderingTest.kt: a frequent drink that is no longer in the catalog is dropped
|
not exercised by the Kotlin ordering test |
| quickBeverageWidgetDrinkOptions > user drinks come before the preloaded catalog | PORTED | HomeQuickBeverageWidgetDrinkOrderingTest.kt: user drinks come before the preloaded catalog
|
Dart-faithful isolated case added alongside the combined one |
| quickBeverageWidgetDrinkOptions > sorts each group by category, then name, then id | PORTED | HomeQuickBeverageWidgetDrinkOrderingTest.kt: sorts each group by category, then name, then id
|
all seven category ranks plus the case-insensitive name compare |
| quickBeverageWidgetDrinkOptions > breaks a same-name tie on the id | PORTED | HomeQuickBeverageWidgetDrinkOrderingTest.kt: breaks a same-name tie on the id
|
|
| quickBeverageWidgetDrinkOptions > an empty catalog yields no options | PORTED | HomeQuickBeverageWidgetDrinkOrderingTest.kt: an empty catalog yields no options
|
|
| quickBeverageAmountLabel > metric sub-litre volumes read in millilitres, without a space | PORTED | HomeQuickBeverageAmountLabelTest.kt: metric sub-litre volumes read in millilitres, without a space
|
quickBeverageAmountLabel (HomeQuickBeverageWidget.kt:553) untested |
| quickBeverageAmountLabel > metric volumes of a litre and up read in litres | PORTED | HomeQuickBeverageAmountLabelTest.kt: metric volumes of a litre and up read in litres
|
|
| quickBeverageAmountLabel > imperial always reads through the formatter | PORTED | HomeQuickBeverageAmountLabelTest.kt: imperial always reads through the formatter
|
UnitFormatterTest covers volume units generally, not this label helper |
| drink payload > round-trips the fields the log callback needs | N/A-FRAMEWORK | — | no payload in Kotlin; the action resolves the drink by id |
| drink payload > refuses a malformed, empty or unloggable payload | N/A-FRAMEWORK | — | Kotlin validates via isValidCustomHydrationDrink instead |
| drink payload > drops an unknown nutrient rather than the whole drink | N/A-FRAMEWORK | — | payload nutrient parsing is bridge-specific |
Kotlin counterpart: none (HomeMetricWidgetConfigurationActivity.kt and HomeMetricWidgetSelection untested)
| Flutter case | Status | Kotlin test | Note |
|---|---|---|---|
| renders the prompt and the metric catalog | PORTED | HomeWidgetConfigurationActivityTest: theMetricPickerRendersThePromptAndTheMetricCatalog
|
Compose instrumentation; runs on a device, not in CI |
| excludes what the catalog excludes | N/A-BEHAVIOR | none | blocked on behavior decision - Kotlin drops CARDIO_LOAD + CAFFEINE, Flutter drops caffeine + intensityMinutes |
| picking a metric persists its selection_id and pushes the tile | N/A-FRAMEWORK | none | instrumentation-only - the Glance action/receiver runs on AppWidgetManager + SharedPreferences with no JVM seam; — |
| backing out without picking never finishes the configuration | PORTED | HomeWidgetConfigurationActivityTest: backingOutOfTheMetricPickerNeverFinishesTheConfiguration
|
Compose instrumentation; runs on a device, not in CI |
| a failed load still records the pick and finishes | N/A-FRAMEWORK | none | instrumentation-only - the Glance action/receiver runs on AppWidgetManager + SharedPreferences with no JVM seam; — |
Kotlin counterpart: none — the Kotlin app schedules no exact alarms for widgets (grep finds no ExactAlarm usage); refresh is receiver/system driven
| Flutter case | Status | Kotlin test | Note |
|---|---|---|---|
| a granted permission finishes invisibly | N/A-FRAMEWORK | — | exact-alarm gate does not exist in Kotlin |
| "Not now" keeps the widget: RESULT_OK, never a cancel | N/A-FRAMEWORK | — | |
| "Allow" that grants re-arms the refresh chain and finishes | N/A-FRAMEWORK | — |
Kotlin counterpart: app/src/test/kotlin/tech/mmarca/openvitals/features/homewidgets/HomeWidgetLaunchRouteTest.kt (over the now-internal MainActivity.kt openVitalsRoute / migratedOpenVitalsRoute / isSupportedOpenVitalsRoute)
| Flutter case | Status | Kotlin test | Note |
|---|---|---|---|
| homeWidgetRouteLocation > parses the native openvitals://widget?route=... form | N/A-FRAMEWORK | — | Kotlin passes the route as an intent extra; no URI form |
| homeWidgetRouteLocation > maps every allowed widget route to its go_router location | PORTED | HomeWidgetLaunchRouteTest.kt: maps every allowed widget route to its destination + every route the snapshot builders write is on the allow-list
|
Kotlin additionally allows manual_entry/activity, which Flutter rejects |
| homeWidgetRouteLocation > accepts a path-only uri (no authority) | N/A-FRAMEWORK | — | |
| homeWidgetRouteLocation > rejects a route that is not on the allow-list | PORTED | HomeWidgetLaunchRouteTest.kt: rejects a route that is not on the allow-list
|
|
| homeWidgetRouteLocation > rejects a malformed argument | PORTED | HomeWidgetLaunchRouteTest.kt: rejects a malformed argument
|
fixed: the body_energy segment is now parsed strictly as an ISO date, so yesterday and 2026-13-45 are dropped like Flutter's |
| homeWidgetRouteLocation > rejects nothing to open | PORTED | HomeWidgetLaunchRouteTest.kt: rejects nothing to open
|
null, blank and unsupported extra all yield null |
| homeWidgetRouteLocationOf > maps the raw route string the snapshot carries | PORTED | HomeWidgetLaunchRouteTest.kt: maps the raw route string the snapshot carries
|
snapshot route strings vs allow-list consistency untested |
| homeWidgetRouteLocationOf > a readiness widget placed before the merge lands on Body Energy | PORTED | HomeWidgetLaunchRouteTest.kt: a readiness widget placed before the merge lands on Body Energy
|
migratedOpenVitalsRoute (LegacyDailyReadinessRoute → BodyEnergyDetails) exists verbatim, untested |
Kotlin counterpart: none (refreshHomeMetricWidget / refreshDailyReadinessWidget / refreshBodyEnergyWidget / refreshTodayVitalsWidget and UpdatingHomeWidgetReceiver are untested)
| Flutter case | Status | Kotlin test | Note |
|---|---|---|---|
| refresh resolves Health Connect access before loading | N/A-FRAMEWORK | none | instrumentation-only - the Glance action/receiver runs on AppWidgetManager + SharedPreferences with no JVM seam; — |
| pushes all three shared widgets from one load | N/A-FRAMEWORK | — | Kotlin widgets refresh independently per receiver; no one-load fan-out |
| pushes one snapshot per placed metric instance, keyed by its id | N/A-FRAMEWORK | none | instrumentation-only - the Glance action/receiver runs on AppWidgetManager + SharedPreferences with no JVM seam; — |
| leaves an unconfigured or unknown instance alone | N/A-FRAMEWORK | none | instrumentation-only - the Glance action/receiver runs on AppWidgetManager + SharedPreferences with no JVM seam; — |
| a beverage instance is not touched (a later phase) | N/A-FRAMEWORK | — | phase ordering is a Flutter-refresher concern; Kotlin widgets are independent |
| loads today, forcing a fresh read | N/A-FRAMEWORK | none | instrumentation-only - the Glance action/receiver runs on AppWidgetManager + SharedPreferences with no JVM seam; — |
| refreshIfPlaced skips the dashboard load when no widget is placed | PORTED | HomeWidgetRefreshTriggerTest.kt | the note answered the wrong question — the invariant is that a refresh happens AT ALL after out-of-band data lands, and nothing provided it. refreshPlacedHomeWidgets now runs after watch sync, phone sync and import, and returns early when no widget is placed |
| refreshIfPlaced refreshes when a widget is placed | PORTED | HomeWidgetRefreshTriggerTest.kt (manifest-vs-list completeness) | the broadcast shape needs instrumentation (ComponentName/Intent are throwing stubs on the JVM); what is pinned is that no declared widget is left out of the list |
| a failed load never throws | PORTED | HomeStatusWidgetSnapshotTest.kt; HomeMetricWidget.kt / HomeReadinessWidgets.kt | Kotlin met the never-throws half and broke the second assertion: it WROTE the failure snapshot, which was byte-identical to an empty day, over real numbers. A failed or timed-out read now leaves the last good snapshot alone |
| a failing client cannot stop the other widgets updating | N/A-FRAMEWORK | — | isolation is structural (independent receivers) |
| push reuses the caller's data without loading | N/A-FRAMEWORK | — | no push bridge in Kotlin |
Kotlin counterpart: mostly none — the SharedPreferences key bridge these tests pin does not exist in the Kotlin app (widgets keep per-instance Glance state and read repositories directly); the row cap is covered by HomeMetricWidgetSnapshotTest.kt
| Flutter case | Status | Kotlin test | Note |
|---|---|---|---|
| homeWidgetKeyPrefix > namespaces a shared widget by its storage key | N/A-FRAMEWORK | — | bridge-specific |
| homeWidgetKeyPrefix > namespaces a per-instance widget by appWidgetId too | N/A-FRAMEWORK | — | Glance state is per-instance natively |
| homeWidgetKeyPrefix > the two beverage widgets share one storage namespace | N/A-FRAMEWORK | — | |
| homeWidgetDataMap > a plot series rides as one comma-separated value | N/A-FRAMEWORK | — | comma-encoding is bridge-specific |
| homeWidgetDataMap > a widget with no plot writes an empty series, not a missing key | N/A-FRAMEWORK | — | |
| homeWidgetDataMap > maps the snapshot to the Kotlin key layout, under the prefix | N/A-FRAMEWORK | — | |
| homeWidgetDataMap > omits selection_id when not provided | N/A-FRAMEWORK | — | |
| homeWidgetDataMap > caps rows at maxHomeWidgetRows | PORTED | HomeMetricWidgetSnapshotTest.kt: writing a snapshot caps the rows the widget can draw
|
write path extracted to MutablePreferences.putHomeWidgetSnapshot |
| homeWidgetDataMap > two widgets pushed in turn do not clobber each other | N/A-FRAMEWORK | — | per-widget Glance state by construction |
| HomeWidgetService > persists every key then updates the qualified receiver | N/A-FRAMEWORK | — | |
| HomeWidgetService > a per-instance push is keyed by appWidgetId | N/A-FRAMEWORK | — | |
| HomeWidgetService > instancesOf returns only that widget's placed instances | N/A-FRAMEWORK | — | GlanceAppWidgetManager native |
| release receiver names > instancesOf matches them | N/A-FRAMEWORK | — | |
| release receiver names > widgetOfInstance resolves them | N/A-FRAMEWORK | — | |
| release receiver names > the fully-qualified debug form keeps working | N/A-FRAMEWORK | — |
Kotlin counterpart: app/src/test/kotlin/tech/mmarca/openvitals/features/homewidgets/HomeStatusWidgetSnapshotTest.kt and HomeMetricWidgetSnapshotTest.kt, over the pure builders extracted from HomeReadinessWidgets.kt (buildDailyReadinessSnapshot / buildBodyEnergySnapshot / buildTodayVitalsSnapshot) and HomeMetricWidget.kt (buildMetricWidgetSnapshot / homeMetricWidgetRoute)
| Flutter case | Status | Kotlin test | Note |
|---|---|---|---|
| buildDailyReadinessSnapshot > reports the score, status and recommendation | PORTED | HomeStatusWidgetSnapshotTest.kt: daily readiness reports the score, status and recommendation
|
|
| buildDailyReadinessSnapshot > falls back to "--" when no signal is loaded (UNKNOWN) | PORTED | HomeStatusWidgetSnapshotTest.kt: daily readiness falls back to dashes when no signal is loaded + daily readiness falls back to dashes when nothing loaded at all
|
|
| buildDailyReadinessSnapshot > honours the caller's goals | PORTED | HomeStatusWidgetSnapshotTest.kt: daily readiness honours the caller's goals
|
|
| buildBodyEnergySnapshot > reports the score with start/charged/drained rows | PORTED | HomeStatusWidgetSnapshotTest.kt: body energy reports the score with start, charged and drained rows
|
|
| buildBodyEnergySnapshot > maps every status threshold | PORTED | HomeStatusWidgetSnapshotTest.kt: body energy maps every status threshold
|
all seven Dart boundaries (80/79/60/59/40/39/0) |
| buildBodyEnergySnapshot > carries the day as a series the widget can plot | N/A-WIDGET | none | HomeMetricWidgetSnapshot has no series field; no Kotlin widget plots a line |
| buildBodyEnergySnapshot > a full day is thinned, and still ends where the number says | N/A-WIDGET | none | no series to thin; maxHomeWidgetSeriesPoints has no Kotlin counterpart |
| buildBodyEnergySnapshot > falls back to "--" with no rows when the timeline is absent | PORTED | HomeStatusWidgetSnapshotTest.kt: body energy falls back to dashes with no rows when the timeline is absent
|
|
| buildTodayVitalsSnapshot > lists the rows in the Kotlin order, values joined with their unit | PORTED | HomeStatusWidgetSnapshotTest.kt: today lists the rows in order, values joined with their unit
|
all nine row labels in order plus every Dart value assertion |
| buildTodayVitalsSnapshot > drops the readiness row and shows "No data" rows when empty | PORTED | HomeStatusWidgetSnapshotTest.kt: today drops the readiness row and shows no-data rows when empty
|
|
| buildMetricSnapshot > formats a metric with its unit and routes to the metric screen | PORTED | HomeMetricWidgetSnapshotTest.kt: formats a metric with its unit and routes to the metric screen
|
|
| buildMetricSnapshot > body energy routes to its dated detail screen, not /metric | PORTED | HomeMetricWidgetSnapshotTest.kt: body energy routes to its dated detail screen, not the metric screen
|
route, score and the three rows asserted; Kotlin's tile subtitle is the status word, not "+30 / -12" |
| buildMetricSnapshot > reports "--" / "No data" for an absent reading | PORTED | HomeMetricWidgetSnapshotTest.kt: reports dashes and no data for an absent reading
|
|
| buildMetricSnapshot > a missing permission wins over the reading | PORTED | HomeMetricWidgetSnapshotTest.kt: a missing permission wins over the reading
|
|
| buildMetricSnapshot > weekly cardio load reports progress and percent | N/A-FRAMEWORK | — | CARDIO_LOAD is excluded from the Kotlin widget catalog |
| buildMetricSnapshot > every catalog metric has a title, a route and a no-data snapshot | PORTED | HomeMetricWidgetSnapshotTest.kt: every catalog metric has a title, a route and a no-data snapshot
|
homeMetricTitleRes/route coverage sweep is a pure JVM test |
| homeMetricWidgetCatalog > drops caffeine and intensity minutes (the Kotlin catalog) | N/A-BEHAVIOR | none | blocked on behavior decision - the two catalogs disagree on membership |
Kotlin counterpart: app/src/test/kotlin/tech/mmarca/openvitals/features/onboarding/OnboardingViewModelTest.kt (row derivation lives inside OnboardingViewModel)
| Flutter case | Status | Kotlin test | Note |
|---|---|---|---|
| a fresh install: nothing granted, everything outstanding | PORTED | OnboardingViewModelTest.kt: a fresh install derives every row outstanding
|
per-row counts, missingRequestableFor, opt-in-not-folded rule |
| a partially granted category reports the count, not just the flag | PORTED | OnboardingViewModelTest.kt: partial rows report their counts
|
|
| the required set being granted turns the button into Continue, even with the opt-in rows still outstanding | PORTED | OnboardingViewModelTest.kt: next walks the applicable steps in order (canAdvance with only activity+sleep) + additional access step still applies for the routes walkthrough alone (routesOutstanding) |
|
| a manual-only category cannot be granted by the runtime dialog | PORTED | OnboardingViewModelTest.kt: a manual-only permission cannot be granted by the runtime dialog
|
Kotlin models the manual grant as catalog.routeReadPermission + routesOutstanding |
| an unsupported category is never "granted", whatever is in the set | PORTED | OnboardingViewModelTest.kt: an unsupported category is never granted, whatever is in the set
|
unsupported-but-granted row rule untested (step skipping is covered, the row flag is not) |
| an empty catalog derives an empty display | PORTED | OnboardingViewModelTest.kt: an empty catalog derives an empty display
|
Kotlin counterpart: app/src/test/kotlin/tech/mmarca/openvitals/features/onboarding/OnboardingViewModelTest.kt (screen itself untested)
| Flutter case | Status | Kotlin test | Note |
|---|---|---|---|
| step 1 lists the five Health Connect categories | PORTED | OnboardingViewModelTest.kt: checkState loads catalog and granted permissions
|
5 rows, first ACTIVITY |
| Next is refused until Activity and Sleep are granted | PORTED | OnboardingViewModelTest.kt: step one gates advancing on the required set
|
|
| granting only Activity and Sleep is enough to move on | PORTED | OnboardingViewModelTest.kt: next walks the applicable steps in order
|
|
| a category row requests exactly its own permissions | PORTED | OnboardingViewModelTest.kt: missingRequestableFor subtracts the granted set
|
|
| the mindfulness step is skipped when the device lacks it | PORTED | OnboardingViewModelTest.kt: steps that do not apply on this device are skipped
|
|
| the mindfulness step appears where the device has it | PORTED | OnboardingViewModelTest.kt: next walks the applicable steps in order + mindfulness step stays unsatisfied with the opt-in off
|
|
| the forward button stops saying "Not now" once a step is done | DIVERGED | OnboardingViewModelTest.kt: mindfulness step stays unsatisfied with the opt-in off
|
currentStepSatisfied covered; label mapping is widget-side |
| back walks the steps, and only exits from the first | PORTED | OnboardingViewModelTest.kt: back walks the steps, and only exits from the first
|
isFirstStep + back() is a no-op on step one |
| the last step walks the user to exercise routes by hand | PORTED | OnboardingViewModelTest.kt: additional access step still applies for the routes walkthrough alone
|
|
| finishing persists the prefs and the permission-set version | DIVERGED | OnboardingViewModelTest.kt: completeOnboarding stamps privacy policy and the done flag
|
architecture divergence - Kotlin stamps lastPromptedPermissionSetVersion in healthconnect/HealthConnectScreenUxCoordinator.kt, not in OnboardingViewModel |
| shows the unavailable message when Health Connect is missing | PORTED | OnboardingViewModelTest.kt: unavailable short-circuits without reading the catalog
|
message rendering widget-side |
| needsProviderUpdate offers an install action | PORTED | OnboardingScreenTest: anOutdatedHealthConnectExplainsItselfAndOffersTheInstall
|
Compose instrumentation; runs on a device, not in CI |
| the header renders the wide logo and the language dropdown | PORTED | OnboardingScreenTest: theHeaderIdentifiesTheAppAndLetsTheLanguageBeChangedBeforeAnythingElse
|
Compose instrumentation; runs on a device, not in CI |
| picking a language persists the app-language preference | PORTED | OnboardingViewModelTest.kt: selectAppLanguage persists and updates state
|
|
| the catalog is Health Connect's categories, in wizard order | PORTED | OnboardingViewModelTest.kt: the catalog is Health Connect's categories, in wizard order
|
full 8-category order plus non-empty permission sets |
| the additional-access row counts only what its button can grant | PORTED | OnboardingViewModelTest.kt: the additional-access row counts only what its button can grant
|
route read excluded from the row total |
| the required set is Activity and Sleep, and nothing that cannot be granted | PORTED | OnboardingViewModelTest.kt: a fresh install derives every row outstanding
|
set EQUALITY plus route_read/mindfulness/cycle exclusion |
The device layer (Garmin watch sync, watch screens, notification forwarding) left this repository for OpenWatch, and its sections left this file with it. The counts below predate that and still include them.
Counts are per Flutter test case. The csv_row_converter_test.dart row is collapsed in its table but counted at its true size (43 cases, all PORTED).
| Status | Count |
|---|---|
| PORTED | 497 |
| DIVERGED | 74 |
| MISSING | 27 |
| N/A-WIDGET | 134 |
| N/A-FRAMEWORK | 132 |
| N/A-BEHAVIOR | 7 |
| Total cases | 871 |
Remaining MISSING cases. Every one is blocked on a feature that was never ported (Body Energy diagnostics) or a seam owned by another workstream (features/readiness). Rows that were blocked on a behavior decision or on an instrumentation-only Android surface are no longer listed here — they now carry N/A-BEHAVIOR / N/A-FRAMEWORK with the reason in their Note column.
- test/features/settings/body_energy_diagnostics_card_test.dart: warns when more than one app wrote active calories
- test/features/settings/body_energy_diagnostics_card_test.dart: says so when no watch samples are stored
- test/features/settings/body_energy_diagnostics_test.dart: the component decomposition > activity uses the per-point max, never the sum
- test/features/settings/body_energy_diagnostics_test.dart: the component decomposition > a tie counts as the calorie estimate winning
- test/features/settings/body_energy_diagnostics_test.dart: the component decomposition > buckets with no activity drain count for neither side
- test/features/settings/body_energy_diagnostics_test.dart: clipping > a floored day reports its buckets and when it first pinned
- test/features/settings/body_energy_diagnostics_test.dart: clipping > a ledger that does not balance is flagged
- test/features/settings/body_energy_diagnostics_test.dart: the watch totals > are delta sums, not start minus end
- test/features/settings/body_energy_diagnostics_test.dart: the watch totals > are absent when the watch never synced that day
- test/features/settings/body_energy_diagnostics_test.dart: the watch totals > drainError is signed so an over-draining model is visible
- test/features/settings/body_energy_diagnostics_test.dart: per-influence errors > are signed, count-weighted, and omit unobserved influences
- test/features/settings/body_energy_diagnostics_test.dart: source attribution > flags a day two apps both wrote active calories for
- test/features/settings/body_energy_diagnostics_test.dart: source attribution > stays quiet when one app wrote them
- test/features/settings/body_energy_diagnostics_test.dart: source attribution > does not confuse two metrics for two calorie sources
- test/features/settings/body_energy_diagnostics_test.dart: toReportText > is stable, explicit, and carries the decisive figures
- test/features/settings/body_energy_diagnostics_test.dart: toReportText > names missing permissions rather than showing an empty report
- test/features/settings/body_energy_diagnostics_test.dart: toReportText > says so when the per-source read was truncated
- test/features/readiness/readiness_display_test.dart: daily readiness panel > composes every line the panel used to build inline
- test/features/readiness/readiness_display_test.dart: daily readiness panel > an empty insight still composes: no strain, no score, no factors
- test/features/readiness/readiness_display_test.dart: daily readiness panel > the factor list is capped at five
- test/features/readiness/readiness_display_test.dart: training readiness detail > score, verdict, confidence, signals and guidance
- test/features/readiness/readiness_display_test.dart: training readiness detail > no training-side factors falls back to the no-signals message
- test/features/readiness/readiness_display_test.dart: training readiness detail > an unknown state reads as needs-more-data, whatever the score
- test/features/readiness/readiness_display_test.dart: training readiness detail > the verdict bands
- test/features/readiness/readiness_display_test.dart: training readiness detail > the strain bullet always renders, from the state
- test/features/readiness/training_readiness_details_view_model_test.dart: a failed load becomes a ScreenError, not an exception
Generated from docs/engineering/test-parity/features.md. Edit it there; changes made here are overwritten.
This wiki mirrors the Markdown in Hale-MTU/OpenVitals on every push to main.
Features
- Features
- Achievements
- Activity And Training Plans
- Activity Metrics
- Apple Health Import
- Beverage Logging And Caffeine
- Bluetooth LE Sensors
- Body Energy
- Body Metrics
- CoMaps Navigation Context
- CSV Import
- Cycle Tracking
- Daily Readiness
- Elevation Correction
- Feature Map
- FIT Files Import
- Food Logging
- GPX/KML/KMZ/TCX Route Import
- Health Connect Metrics Dashboard
- Health Report Export
- Heart And Vitals
- Heart Rate Recovery
- Home Screen Widgets
- Hydration
- Manual Entry Of Metrics
- Medical Records
- Metric Detail Customization
- Mindfulness
- Non Health Connect Metrics Dashboard
- Nutrition
- Offline Maps Support
- Onboarding And Permissions
- Preloaded Beverage Nutrition Reference
- Privacy, Support, And Diagnostics
- Recording Of Activity
- Reminders
- Settings And Preferences
- Sleep Score And Recovery
- Sleep Tracking
- Statistics
- Sync With Another Phone
How-to
App
- App Guide
- FAQ
- Getting Started
- Health Connect
- Install
- Local And Connected Editions
- Permissions
- Privacy
- Screenshots
- Support
Reference
- Design Reference
- Reference
- Design Guidelines for Offline & Sync
- OpenVitals UI Notes
- Reference: Data Capture
- Reference: Data Display
- Reference: Health Connect Matchmaking
- Reference: Health Connect Onboarding
- Reference: Health Connect Permissions
- Reference: Health Connect UX
- Reference: Promoting Health Connect
Engineering
- Code Analysis
- Engineering
- Flutter ↔ Kotlin test-parity matrix
- apple-health-import
- Architecture
- body-cycle-recovery
- Design system conformance
- Development
- Feature Playbook
- Flutter -> Kotlin test parity: domain/data/core/contract/state/navigation/di/bootstrap/l10n/widget_test
- Flutter → Kotlin test-parity matrix: features section
- Flutter → Kotlin test-parity matrix: ui / goldens / integration / fixtures
- Flutter-to-Kotlin test-parity matrix — dashboard + devicesync
- manual-entry
- Translations
Proposals
- Proposals
- Activity Start Flow Analysis
- Activity Start Flow Simplification Proposals
- Caffeine Detail And Future Sleep Insights
- Cycle Tracking: Design and Status
- Medical Records Through Health Connect
- Medical Records: What Is Left
Releases
- OpenVitals 2.16.0
- OpenVitals 2.15.2
- OpenVitals 2.15.1
- OpenVitals 2.15.0
- OpenVitals 2.14.5
- OpenVitals 2.14.4
- OpenVitals 2.14.3
- OpenVitals 2.14.2
- OpenVitals 2.14.1
- OpenVitals 2.14.0
- OpenVitals 2.13.0
- OpenVitals 2.12.0
- OpenVitals 2.11.2
- OpenVitals 2.11.1
- OpenVitals 2.11.0
- OpenVitals 2.10.1
- OpenVitals 2.10.0
- OpenVitals 2.9.1
- OpenVitals 2.9.0
- OpenVitals 2.8.0
- OpenVitals 2.7.3
- OpenVitals 2.7.2
- OpenVitals 2.7.1
- OpenVitals 2.7.0
- OpenVitals 2.6.6
- OpenVitals 2.6.5
- OpenVitals 2.6.4
- OpenVitals 2.6.3
- OpenVitals 2.6.2
- OpenVitals 2.6.1
- OpenVitals 2.6.0
- OpenVitals 2.5.0
- OpenVitals 2.4.1
- OpenVitals 2.4.0
- OpenVitals 2.3.2
- OpenVitals 2.3.1
- OpenVitals 2.3.0
- OpenVitals 2.2.8
- OpenVitals 2.2.7
- OpenVitals 2.2.6
- OpenVitals 2.2.5
- OpenVitals 2.2.4
- OpenVitals 2.2.3
- OpenVitals 2.2.2
- OpenVitals 2.2.1
- OpenVitals 2.2.0
- OpenVitals 2.1.0
- OpenVitals 2.0.4
- OpenVitals 2.0.3
- OpenVitals 2.0.2
- OpenVitals 2.0.1
- OpenVitals 1.9.0
- OpenVitals 1.8.0
- OpenVitals 1.7.7
- OpenVitals 1.7.6
- OpenVitals 1.7.5
- OpenVitals 1.7.4
- OpenVitals 1.7.3
- OpenVitals 1.7.2
- OpenVitals 1.7.1
- OpenVitals 1.7.0
- OpenVitals 1.6.3
- OpenVitals 1.6.2
- OpenVitals 1.6.1
- OpenVitals 1.6.0
- OpenVitals 1.5.1
- OpenVitals 1.5.0
Project