diff --git a/Strand/Resources/Localizable.xcstrings b/Strand/Resources/Localizable.xcstrings index 86b8f4b9d3..7f227851a7 100644 --- a/Strand/Resources/Localizable.xcstrings +++ b/Strand/Resources/Localizable.xcstrings @@ -571,6 +571,122 @@ } } }, + "No heart rate stored for today yet": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Für heute sind noch keine Herzfrequenzdaten gespeichert" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Aún no hay frecuencia cardíaca guardada para hoy" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Aucune fréquence cardiaque enregistrée pour aujourd’hui" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Per oggi non è ancora salvata alcuna frequenza cardiaca" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Na dziś nie zapisano jeszcze tętna" + } + }, + "pt-PT": { + "stringUnit": { + "state": "translated", + "value": "Ainda sem frequência cardíaca guardada para hoje" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "За сегодня пульс ещё не сохранён" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "今天尚未保存心率数据" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "今天尚未儲存心率資料" + } + } + } + }, + "One five-minute block of heart rate today": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Ein Fünf-Minuten-Block der Herzfrequenz heute" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Un bloque de cinco minutos de frecuencia cardíaca hoy" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Un bloc de cinq minutes de fréquence cardiaque aujourd’hui" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Un blocco di cinque minuti di frequenza cardiaca oggi" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Jeden pięciominutowy blok tętna dzisiaj" + } + }, + "pt-PT": { + "stringUnit": { + "state": "translated", + "value": "Um bloco de cinco minutos de frequência cardíaca hoje" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Один пятиминутный блок пульса за сегодня" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "今天有一个五分钟的心率数据块" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "今天有一個五分鐘的心率資料塊" + } + } + } + }, "Progress while NOOP pulls history from the strap.": { "localizations": { "de": { @@ -1939,6 +2055,122 @@ "Working sets": { "localizations": { "de": {"stringUnit": {"state": "translated", "value": "Arbeitssätze"}}, "es": {"stringUnit": {"state": "translated", "value": "Series efectivas"}}, "fr": {"stringUnit": {"state": "translated", "value": "Séries de travail"}}, "it": {"stringUnit": {"state": "translated", "value": "Serie di lavoro"}}, "pl": {"stringUnit": {"state": "translated", "value": "Serie robocze"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Séries de trabalho"}}, "ru": {"stringUnit": {"state": "translated", "value": "Рабочие подходы"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "正式组"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "正式組"}} } }, + "The curve draws once the strap offloads, or once an import brings one in.": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Die Kurve wird gezeichnet, sobald der Strap überträgt oder ein Import welche mitbringt." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "La curva se dibujará cuando la pulsera transfiera los datos o cuando una importación los traiga." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "La courbe se trace dès que le bracelet transfère ses données ou qu’un import en apporte." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "La curva viene tracciata quando la fascia scarica i dati o quando li porta un’importazione." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Wykres pojawi się, gdy opaska prześle dane lub przyniesie je import." + } + }, + "pt-PT": { + "stringUnit": { + "state": "translated", + "value": "A curva é desenhada quando a pulseira transferir os dados ou quando uma importação os trouxer." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Кривая появится, когда браслет выгрузит данные или их принесёт импорт." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "手环传输数据或导入带入数据后,曲线就会绘制。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "手環傳輸資料或匯入帶入資料後,曲線就會繪製。" + } + } + } + }, + "The curve needs two of them to draw a line.": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Für eine Linie braucht die Kurve zwei davon." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "La curva necesita dos para trazar una línea." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Il en faut deux pour tracer une ligne." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Per tracciare una linea ne servono due." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Do narysowania linii potrzebne są dwa." + } + }, + "pt-PT": { + "stringUnit": { + "state": "translated", + "value": "A curva precisa de dois para traçar uma linha." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Чтобы провести линию, нужно два." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "绘制曲线需要两个。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "繪製曲線需要兩個。" + } + } + } + }, "While the strap is connected.": { "localizations": { "de": { diff --git a/Strand/Screens/TodayView.swift b/Strand/Screens/TodayView.swift index 4e91a53946..4935abfd9c 100644 --- a/Strand/Screens/TodayView.swift +++ b/Strand/Screens/TodayView.swift @@ -3570,6 +3570,27 @@ struct TodayView: View { // MARK: HEART RATE, today's continuous HR, off the strap's own ~1Hz history. + /// The Today HR card's empty-state heading, which states what the bucket read returned and nothing + /// else. It used to open "Calibrating" and assert that nothing had been banked today, neither of which + /// is established here: the gate is simply that the active-strap-plus-imports union came back with + /// under two buckets. Nothing on that branch knows whether a strap is calibrating, whether it has + /// offloaded, or whether rows sit under a source this union cannot see, and a card naming a cause it + /// has not checked sends a reader looking in the wrong place. One stored block is not "no heart rate" + /// either, so it gets its own line rather than being rounded down to zero. Android twin: the `when` in + /// `HeartRateTrendCard`'s empty branch. + private var hrEmptyTitle: String { + if selectedDayOffset != 0 { return String(localized: "No heart rate for this day") } + if hrPoints.count == 1 { return String(localized: "One five-minute block of heart rate today") } + return String(localized: "No heart rate stored for today yet") + } + + /// The line under `hrEmptyTitle`: what would put a curve here, without claiming why there is not one. + private var hrEmptyDetail: String { + if selectedDayOffset != 0 { return String(localized: "Step back to a day the strap was worn.") } + if hrPoints.count == 1 { return String(localized: "The curve needs two of them to draw a line.") } + return String(localized: "The curve draws once the strap offloads, or once an import brings one in.") + } + /// A full-width 24-hour heart-rate trend, plotted from 5-minute bucket means of the strap's /// `hrSample` history (offloaded even while the app was closed, so the day reads continuously). /// When there are fewer than two buckets it shows an explicit calibrating/empty card rather than @@ -3632,23 +3653,20 @@ struct TodayView: View { hrZoomHint } } else { - // #863: an empty / single-bucket day. A calibrating 4.0 banks HR slowly, so an empty curve early - // on isn't a fault , say so explicitly instead of leaving a blank where the chart was (which read - // as the graph freezing). We don't silently swap in another day's curve here; the honest empty - // state is the parity-matched fix. Mirrors the Android HeartRateTrendCard empty branch. + // #863: an empty / single-bucket day. An empty curve early on isn't a fault, so say what the + // read came back with instead of leaving a blank where the chart was (which read as the graph + // freezing). We don't silently swap in another day's curve here; the honest empty state is the + // parity-matched fix. The wording lives in `hrEmptyTitle` / `hrEmptyDetail`, which say what was + // found rather than why. Mirrors the Android HeartRateTrendCard empty branch. VStack(alignment: .leading, spacing: NoopMetrics.gap) { SectionHeader("Heart Rate", overline: "\(selectedDayOverline)") ChartCard( title: "Beats per minute", - subtitle: selectedDayOffset == 0 - ? String(localized: "Calibrating, no heart rate banked yet today") - : String(localized: "No heart rate for this day"), + subtitle: hrEmptyTitle, trailing: nil, tint: StrandPalette.metricRose ) { - Text(selectedDayOffset == 0 - ? String(localized: "Your curve fills in as the strap offloads its history.") - : String(localized: "Step back to a day the strap was worn.")) + Text(hrEmptyDetail) .font(StrandFont.footnote) .foregroundStyle(StrandPalette.textTertiary) .frame(maxWidth: .infinity, alignment: .center) diff --git a/android/app/src/main/java/com/noop/ui/TodayScreen.kt b/android/app/src/main/java/com/noop/ui/TodayScreen.kt index c6ba971de6..b07fa40a42 100644 --- a/android/app/src/main/java/com/noop/ui/TodayScreen.kt +++ b/android/app/src/main/java/com/noop/ui/TodayScreen.kt @@ -1,5 +1,6 @@ package com.noop.ui +import androidx.annotation.StringRes import androidx.compose.foundation.Canvas import androidx.compose.foundation.background import androidx.compose.foundation.border @@ -6455,6 +6456,35 @@ private fun HrWindowPills(selection: HrWindow, onSelect: (HrWindow) -> Unit) { ) } +/** + * Which line the Today HR card shows when it cannot draw a curve, chosen from what the read returned + * and from nothing else (#863). + * + * The card's gate is that the active-strap-plus-imports union came back with under two 5-minute + * buckets. It does NOT know whether a strap is calibrating, whether it has offloaded, or whether rows + * sit under a source the union cannot reach, which is why no branch here names a cause: the old single + * line opened "Calibrating" and asserted "no heart rate banked yet today", and a card naming an + * unchecked cause sends a reader looking in the wrong place. + * + * [dayBucketCount] is the DAY's count, never the windowed subset, because every branch below speaks + * about the day: a narrow rolling window that happens to exclude the one stored block must not make the + * card claim the day holds nothing. The window branch is the one exception and says so explicitly, and + * it only applies while the day itself has a drawable curve to go back to. + * + * Pure so the selection is pinned without a Compose host. Swift twin: `TodayView.hrEmptyTitle`. + */ +@StringRes +internal fun hrEmptyMessageRes( + isToday: Boolean, + windowIsWholeDay: Boolean, + dayBucketCount: Int, +): Int = when { + !isToday -> R.string.today_hr_empty_selected_day + !windowIsWholeDay && dayBucketCount >= 2 -> R.string.today_hr_empty_window + dayBucketCount == 1 -> R.string.today_hr_one_block + else -> R.string.today_hr_none_today +} + /** The width of the Today HR card's buckets. * * ONE literal, read by the load below and by the gap test in [OverviewHRChart]. They have to agree: @@ -6583,6 +6613,14 @@ private fun HeartRateTrendCard( // #985: the check reads the WINDOWED subset, and the pills stay visible in the empty state, so a // too-narrow rolling window (say 1h with no recent offload) is never a dead end — the user widens it // or steps back to Today, and the message says which window came up empty. + // + // The copy states what the READ returned and nothing else. It used to open "Calibrating" and assert + // "no heart rate banked yet today", neither of which is checked here: the gate is simply that the + // active-strap-plus-imports union came back with under two buckets. Nothing on this branch knows + // whether a strap is calibrating, whether it has offloaded, or whether rows are banked somewhere this + // union cannot see, and a card that names a cause it has not established sends a reader looking in the + // wrong place. One stored block is also not "no heart rate", so it gets its own line rather than being + // rounded down to zero. if (winBuckets.size < 2) { SectionHeader(uiString(R.string.today_section_heart_rate), overline = selectedLabel) NoopCard { @@ -6591,14 +6629,17 @@ private fun HeartRateTrendCard( if (selectedDay == today) { HrWindowPills(hrWindow) { hrWindowOrdinal = it.ordinal } } + val emptyRes = hrEmptyMessageRes( + isToday = selectedDay == today, + windowIsWholeDay = hrWindow == HrWindow.TODAY, + dayBucketCount = buckets.size, + ) Text( - when { - selectedDay != today -> - uiString(R.string.today_hr_empty_selected_day) - hrWindow != HrWindow.TODAY && buckets.size >= 2 -> - uiString(R.string.today_hr_empty_window, uiString(hrWindow.labelRes)) - else -> - uiString(R.string.today_hr_calibrating) + // Only the window line takes an argument; the rest are plain. + if (emptyRes == R.string.today_hr_empty_window) { + uiString(emptyRes, uiString(hrWindow.labelRes)) + } else { + uiString(emptyRes) }, style = NoopType.footnote, color = Palette.textTertiary, diff --git a/android/app/src/main/res/values-de/strings.xml b/android/app/src/main/res/values-de/strings.xml index 544488b881..8399bc377c 100644 --- a/android/app/src/main/res/values-de/strings.xml +++ b/android/app/src/main/res/values-de/strings.xml @@ -2257,7 +2257,8 @@ Alle Messwerte anzeigen (%1$d) Keine Herzfrequenzdaten für diesen Tag. Gehe zu einem Tag zurück, an dem du den Strap getragen hast. Keine Herzfrequenzdaten in den letzten %1$s. Wähle einen größeren Zeitraum oder „Heute“. - Kalibrierung läuft. Heute wurden noch keine Herzfrequenzdaten gespeichert. Die Kurve füllt sich, sobald der Strap Daten überträgt. + Für heute sind noch keine Herzfrequenzdaten gespeichert. Die Kurve wird gezeichnet, sobald der Strap überträgt oder ein Import welche mitbringt. + Für heute ist nur ein Fünf-Minuten-Block der Herzfrequenz gespeichert. Für eine Linie braucht die Kurve zwei davon. 5-Minuten-Durchschnitt | letzte %1$s Vergrößert · zum Verschieben ziehen Nicht verbunden diff --git a/android/app/src/main/res/values-es/strings.xml b/android/app/src/main/res/values-es/strings.xml index 43b2fbbb59..20aed28131 100644 --- a/android/app/src/main/res/values-es/strings.xml +++ b/android/app/src/main/res/values-es/strings.xml @@ -2244,7 +2244,8 @@ Mostrar todas las métricas (%1$d) No hay datos de frecuencia cardíaca para este día. Vuelve a un día en que llevaras la pulsera. No hay datos de frecuencia cardíaca en las últimas %1$s. Prueba un intervalo mayor o «Hoy». - Calibrando. Aún no se han guardado datos de frecuencia cardíaca hoy. La curva se completará cuando la pulsera transfiera los datos. + Aún no hay datos de frecuencia cardíaca guardados para hoy. La curva se dibujará cuando la pulsera los transfiera o cuando una importación los traiga. + Solo hay un bloque de cinco minutos de frecuencia cardíaca guardado para hoy. La curva necesita dos para trazar una línea. Media de 5 minutos | últimas %1$s Ampliado · arrastra para desplazarte Sin conexión diff --git a/android/app/src/main/res/values-fr/strings.xml b/android/app/src/main/res/values-fr/strings.xml index c9290f5722..bd1c4cc3b1 100644 --- a/android/app/src/main/res/values-fr/strings.xml +++ b/android/app/src/main/res/values-fr/strings.xml @@ -2243,7 +2243,8 @@ Afficher toutes les mesures (%1$d) Aucune donnée de fréquence cardiaque pour ce jour. Revenez à un jour où vous portiez le bracelet. Aucune donnée de fréquence cardiaque au cours des dernières %1$s. Essayez une période plus large ou « Aujourd’hui ». - Étalonnage en cours. Aucune fréquence cardiaque enregistrée aujourd’hui. La courbe se complétera lorsque le bracelet transférera ses données. + Aucune fréquence cardiaque n’est encore enregistrée pour aujourd’hui. La courbe se trace dès que le bracelet transfère ses données ou qu’un import en apporte. + Un seul bloc de cinq minutes de fréquence cardiaque est enregistré pour aujourd’hui. Il en faut deux pour tracer une ligne. Moyenne sur 5 minutes | dernières %1$s Vue agrandie · faites glisser pour déplacer Non connecté diff --git a/android/app/src/main/res/values-it/strings.xml b/android/app/src/main/res/values-it/strings.xml index 8b358bba52..1a47561f99 100644 --- a/android/app/src/main/res/values-it/strings.xml +++ b/android/app/src/main/res/values-it/strings.xml @@ -2309,7 +2309,8 @@ Mostra tutte le metriche (%1$d) Nessuna frequenza cardiaca per questo giorno. Torna a un giorno in cui la fascia era indossata. Nessuna frequenza cardiaca nelle ultime %1$s. Prova una finestra più ampia o Oggi. - Calibrazione, ancora nessuna frequenza cardiaca salvata oggi. La curva si riempie man mano che la fascia scarica i dati. + Per oggi non è ancora salvata alcuna frequenza cardiaca. La curva viene tracciata quando la fascia scarica i dati o quando li porta un’importazione. + Per oggi è salvato un solo blocco di cinque minuti di frequenza cardiaca. Per tracciare una linea ne servono due. Media su 5 minuti | ultime %1$s Zoom attivo · trascina per spostarti Non connesso diff --git a/android/app/src/main/res/values-pl/strings.xml b/android/app/src/main/res/values-pl/strings.xml index 0985959e0e..b5e9c52de8 100644 --- a/android/app/src/main/res/values-pl/strings.xml +++ b/android/app/src/main/res/values-pl/strings.xml @@ -2242,7 +2242,8 @@ Pokaż wszystkie wskaźniki (%1$d) Brak danych tętna dla tego dnia. Wróć do dnia, w którym opaska była noszona. Brak danych tętna z ostatnich %1$s. Wybierz dłuższy okres lub „Dzisiaj”. - Trwa kalibracja. Dzisiaj nie zapisano jeszcze tętna. Wykres wypełni się po przesłaniu danych z opaski. + Na dziś nie zapisano jeszcze żadnych danych tętna. Wykres pojawi się, gdy opaska prześle dane lub przyniesie je import. + Na dziś zapisano tylko jeden pięciominutowy blok tętna. Do narysowania linii potrzebne są dwa. Średnia 5-minutowa | ostatnie %1$s Powiększono · przeciągnij, aby przesunąć Brak połączenia diff --git a/android/app/src/main/res/values-pt-rPT/strings.xml b/android/app/src/main/res/values-pt-rPT/strings.xml index 91dca8a306..b64877aeb3 100644 --- a/android/app/src/main/res/values-pt-rPT/strings.xml +++ b/android/app/src/main/res/values-pt-rPT/strings.xml @@ -2236,7 +2236,8 @@ Mostrar todas as métricas (%1$d) Não há dados de frequência cardíaca para este dia. Recua até um dia em que tenhas usado a pulseira. Não há dados de frequência cardíaca nas últimas %1$s. Experimenta um período maior ou «Hoje». - A calibrar. Ainda não foram guardados dados de frequência cardíaca hoje. A curva será preenchida quando a pulseira transferir os dados. + Ainda não há dados de frequência cardíaca guardados para hoje. A curva é desenhada quando a pulseira transferir os dados ou quando uma importação os trouxer. + Para hoje está guardado apenas um bloco de cinco minutos de frequência cardíaca. A curva precisa de dois para traçar uma linha. Média de 5 minutos | últimas %1$s Vista ampliada · arrasta para deslocar Não ligado diff --git a/android/app/src/main/res/values-ru/strings.xml b/android/app/src/main/res/values-ru/strings.xml index 3965dafee7..6fbec7268f 100644 --- a/android/app/src/main/res/values-ru/strings.xml +++ b/android/app/src/main/res/values-ru/strings.xml @@ -2199,7 +2199,8 @@ Показать все метрики (%1$d) Нет пульса за этот день. Вернитесь к дню, когда браслет был надет. Нет пульса за последние %1$s. Попробуйте более широкое окно или «Сегодня». - Калибровка, пульс за сегодня ещё не накоплен. Ваша кривая заполнится по мере выгрузки с браслета. + За сегодня пульс ещё не сохранён. Кривая появится, когда браслет выгрузит данные или их принесёт импорт. + За сегодня сохранён только один пятиминутный блок пульса. Чтобы провести линию, нужно два. 5-минутное среднее | последние %1$s Увеличено · перетащите для панорамирования Не подключено diff --git a/android/app/src/main/res/values-zh/strings.xml b/android/app/src/main/res/values-zh/strings.xml index 4818f600d0..75a9e8a04f 100644 --- a/android/app/src/main/res/values-zh/strings.xml +++ b/android/app/src/main/res/values-zh/strings.xml @@ -2164,7 +2164,8 @@ 显示全部指标(%1$d) 当天没有心率数据。请返回到佩戴手环的日期。 过去 %1$s 内没有心率数据。请尝试更长的时间范围或选择“今天”。 - 正在校准。今天尚未保存心率数据,手环传输数据后曲线会逐步显示。 + 今天尚未保存心率数据。手环传输数据或导入带入数据后,曲线就会绘制。 + 今天只保存了一个五分钟的心率数据块。绘制曲线需要两个。 5 分钟平均值 | 过去 %1$s 已放大 · 拖动平移 未连接 diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 2f8ec2d0d0..5ae979343a 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -2365,7 +2365,8 @@ Show all metrics (%1$d) No heart rate for this day. Step back to a day the strap was worn. No heart rate in the last %1$s. Try a wider window or Today. - Calibrating, no heart rate banked yet today. Your curve fills in as the strap offloads. + No heart rate stored for today yet. The curve draws once the strap offloads, or once an import brings one in. + Only one five-minute block of heart rate is stored for today. The curve needs two of them to draw a line. 5-minute average | last %1$s Zoomed in · drag to pan Not connected diff --git a/android/app/src/test/java/com/noop/ui/HrEmptyMessageTest.kt b/android/app/src/test/java/com/noop/ui/HrEmptyMessageTest.kt new file mode 100644 index 0000000000..fabd833f49 --- /dev/null +++ b/android/app/src/test/java/com/noop/ui/HrEmptyMessageTest.kt @@ -0,0 +1,113 @@ +package com.noop.ui + +import com.noop.R +import java.io.File +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * The Today HR card's empty state (#863): WHICH line it shows, and that none of them names a cause the + * card has not established. + * + * The old single line opened "Calibrating" and asserted "no heart rate banked yet today", neither of + * which the branch checks. All it knows is that the active-strap-plus-imports union returned under two + * 5-minute buckets, so the lines now describe the stored rows and stop there. The one-bucket case used + * to take that same "no heart rate" line, where it was plainly false. + */ +class HrEmptyMessageTest { + + @Test fun aPastDayAlwaysGetsTheStepBackLine() { + for (count in listOf(0, 1, 2, 500)) { + assertEquals( + "a past day's line never depends on the bucket count", + R.string.today_hr_empty_selected_day, + hrEmptyMessageRes(isToday = false, windowIsWholeDay = true, dayBucketCount = count), + ) + } + } + + @Test fun anEmptyDaySaysNothingIsStoredRatherThanWhy() { + assertEquals( + R.string.today_hr_none_today, + hrEmptyMessageRes(isToday = true, windowIsWholeDay = true, dayBucketCount = 0), + ) + } + + @Test fun oneStoredBlockIsNotReportedAsNoHeartRate() { + assertEquals( + R.string.today_hr_one_block, + hrEmptyMessageRes(isToday = true, windowIsWholeDay = true, dayBucketCount = 1), + ) + } + + @Test fun aNarrowWindowOverADrawableDaySaysSo() { + assertEquals( + R.string.today_hr_empty_window, + hrEmptyMessageRes(isToday = true, windowIsWholeDay = false, dayBucketCount = 2), + ) + } + + /** + * The regression the DAY-count rule exists for: a rolling 1h window that excludes the day's single + * stored block. Judged on the windowed subset this would read zero and claim the day holds nothing, + * which is the same over-claim in a new place. + */ + @Test fun aNarrowWindowCannotDowngradeTheDaysOneBlockToNothing() { + assertEquals( + R.string.today_hr_one_block, + hrEmptyMessageRes(isToday = true, windowIsWholeDay = false, dayBucketCount = 1), + ) + assertEquals( + R.string.today_hr_none_today, + hrEmptyMessageRes(isToday = true, windowIsWholeDay = false, dayBucketCount = 0), + ) + } + + /** + * Source tripwires. The Apple twin's selection sits in a SwiftUI computed property with no JVM + * reach, and the superseded copy lives in resource files no Kotlin test touches, so both are + * asserted from here, the suite that runs on every push. + */ + @Test fun neitherPlatformStillNamesAnUncheckedCause() { + val userDir = File(System.getProperty("user.dir") ?: ".") + val root = listOf(userDir, File(userDir, ".."), File(userDir, "../..")) + .firstOrNull { File(it, "Strand/Screens/TodayView.swift").isFile } + ?: error("could not locate the repo root from ${userDir.absolutePath}") + fun source(path: String) = File(root, path).readText() + + val apple = source("Strand/Screens/TodayView.swift") + assertTrue( + "the Apple card must choose its empty-state heading through hrEmptyTitle", + apple.contains("subtitle: hrEmptyTitle,"), + ) + assertTrue( + "the Apple heading must split the one-block case out of the empty case", + apple.contains("if hrPoints.count == 1 { return String(localized: " + + "\"One five-minute block of heart rate today\") }"), + ) + assertFalse( + "the Apple card must not claim the strap is calibrating", + apple.contains("Calibrating, no heart rate banked yet today"), + ) + + // The call site is what makes the day-count rule real: the function cannot see which list it + // was handed, so a switch to the windowed subset would pass every case above while putting the + // over-claim straight back on screen. + val todayScreen = source("android/app/src/main/java/com/noop/ui/TodayScreen.kt") + assertTrue( + "the card must judge the DAY's buckets, not the windowed subset", + todayScreen.contains("dayBucketCount = buckets.size,"), + ) + + val defaults = source("android/app/src/main/res/values/strings.xml") + assertFalse( + "the superseded Android line must not come back", + defaults.contains("today_hr_calibrating"), + ) + for (key in listOf("today_hr_none_today", "today_hr_one_block")) { + assertTrue("$key must exist in the default resources", defaults.contains("\"$key\"")) + } + } +}