11package io.rebble.libpebblecommon.services
22
33import io.rebble.libpebblecommon.database.entity.HealthDataEntity
4+ import kotlinx.datetime.DatePeriod
5+ import kotlinx.datetime.DayOfWeek
6+ import kotlinx.datetime.LocalDate
47import kotlinx.datetime.TimeZone
8+ import kotlinx.datetime.atStartOfDayIn
9+ import kotlinx.datetime.plus
510import kotlin.test.Test
11+ import kotlin.test.assertEquals
12+ import kotlin.test.assertFalse
13+ import kotlin.test.assertNotNull
614import kotlin.test.assertTrue
715
816class HealthStatsSyncTest {
@@ -11,6 +19,95 @@ class HealthStatsSyncTest {
1119 val result = buildWeekdayTypicalsFromData(emptyList(), TimeZone .UTC )
1220 assertTrue(result.isEmpty(), " empty input should produce empty map, got keys=${result.keys} " )
1321 }
22+
23+ @Test
24+ fun buildWeekdayTypicalsFromData_singleMatchingDay_skipsWeekday () {
25+ // 2026-01-05 is a Monday. Provide rows for only that one Monday;
26+ // MIN_DAYS_FOR_TYPICAL = 2, so Monday must be absent from the result.
27+ val mondayStart = LocalDate (2026 , 1 , 5 ).atStartOfDayIn(TimeZone .UTC ).epochSeconds
28+ val rows = (0 .. 23 ).map { hour ->
29+ row(timestamp = mondayStart + hour * 3600L , steps = 100 )
30+ }
31+
32+ val result = buildWeekdayTypicalsFromData(rows, TimeZone .UTC )
33+
34+ assertFalse(
35+ result.containsKey(DayOfWeek .MONDAY ),
36+ " Monday should be absent with only 1 matching day, got keys=${result.keys} " ,
37+ )
38+ }
39+
40+ @Test
41+ fun buildWeekdayTypicalsFromData_twoMondays_producesPayload () {
42+ // Two Mondays meet MIN_DAYS_FOR_TYPICAL=2. Each Monday has one row in slot 32
43+ // (08:00-08:15) with 200 steps. Expected: result has MONDAY, slot 32 = 200.
44+ val mon1 = LocalDate (2026 , 1 , 5 ).atStartOfDayIn(TimeZone .UTC ).epochSeconds
45+ val mon2 = LocalDate (2026 , 1 , 12 ).atStartOfDayIn(TimeZone .UTC ).epochSeconds
46+ val slot32Offset = 32 * 900L
47+
48+ val rows = listOf (
49+ row(timestamp = mon1 + slot32Offset, steps = 200 ),
50+ row(timestamp = mon2 + slot32Offset, steps = 200 ),
51+ )
52+
53+ val result = buildWeekdayTypicalsFromData(rows, TimeZone .UTC )
54+
55+ val payload = result[DayOfWeek .MONDAY ]
56+ assertNotNull(payload, " Monday payload missing; got keys=${result.keys} " )
57+ assertEquals(192 , payload.size, " payload should be 96 * 2 = 192 bytes" )
58+ assertEquals(200 , readUShortLE(payload, 32 ), " slot 32 should be 200" )
59+ }
60+
61+ @Test
62+ fun buildWeekdayTypicalsFromData_slotWithNoData_writesSentinel () {
63+ // Two Mondays each with a row in slot 32. Slot 0 has no data on either Monday.
64+ // Expected: slot 0 = 0xFFFF (sentinel).
65+ val mon1 = LocalDate (2026 , 1 , 5 ).atStartOfDayIn(TimeZone .UTC ).epochSeconds
66+ val mon2 = LocalDate (2026 , 1 , 12 ).atStartOfDayIn(TimeZone .UTC ).epochSeconds
67+ val slot32Offset = 32 * 900L
68+
69+ val rows = listOf (
70+ row(timestamp = mon1 + slot32Offset, steps = 200 ),
71+ row(timestamp = mon2 + slot32Offset, steps = 200 ),
72+ )
73+
74+ val payload = buildWeekdayTypicalsFromData(rows, TimeZone .UTC )[DayOfWeek .MONDAY ]
75+ assertNotNull(payload)
76+ assertEquals(0xFFFF , readUShortLE(payload, 0 ), " slot 0 must be UNKNOWN sentinel" )
77+ assertEquals(0xFFFF , readUShortLE(payload, 95 ), " slot 95 must be UNKNOWN sentinel" )
78+ }
79+
80+ @Test
81+ fun buildWeekdayTypicalsFromData_partialSlotCoverage_avgIsPerSlotNotPerDay () {
82+ // Five Mondays: each contributes 100 steps in slot 60. Two more Mondays exist
83+ // (with rows in OTHER slots) so they count toward matchingDays but NOT toward
84+ // slot 60's per-slot day count. Expected slot 60 = 100 (sum=500, slot-day-count=5),
85+ // NOT 71 (sum=500, total-matching-days=7).
86+ val mondays = (0 .. 6 ).map { week ->
87+ LocalDate (2026 , 1 , 5 ).plus(DatePeriod (days = 7 * week)).atStartOfDayIn(TimeZone .UTC ).epochSeconds
88+ }
89+ val slot60Offset = 60 * 900L
90+ val slot10Offset = 10 * 900L
91+
92+ val rows = buildList {
93+ // First 5 Mondays: rows in slot 60 with 100 steps
94+ for (i in 0 .. 4 ) add(row(timestamp = mondays[i] + slot60Offset, steps = 100 ))
95+ // Last 2 Mondays: rows in slot 10 only (count toward matchingDays, not slot-60-day-count)
96+ for (i in 5 .. 6 ) add(row(timestamp = mondays[i] + slot10Offset, steps = 50 ))
97+ }
98+
99+ val payload = buildWeekdayTypicalsFromData(rows, TimeZone .UTC )[DayOfWeek .MONDAY ]
100+ assertNotNull(payload)
101+ assertEquals(100 , readUShortLE(payload, 60 ), " slot 60 must average over per-slot days (5), not total matching days (7)" )
102+ }
103+
104+ /* * Reads a single little-endian UShort at byte offset slotIndex * 2 from payload. Returns Int 0..65535. */
105+ private fun readUShortLE (payload : ByteArray , slotIndex : Int ): Int {
106+ val byteOffset = slotIndex * 2
107+ val lo = payload[byteOffset].toInt() and 0xFF
108+ val hi = payload[byteOffset + 1 ].toInt() and 0xFF
109+ return (hi shl 8 ) or lo
110+ }
14111}
15112
16113private fun row (timestamp : Long , steps : Int ) = HealthDataEntity (
0 commit comments