Skip to content

Commit 2afc28a

Browse files
committed
Add unit and integration tests for core measurement and user use cases
1 parent ad99bc7 commit 2afc28a

8 files changed

Lines changed: 742 additions & 0 deletions

File tree

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
/*
2+
* openScale
3+
* Copyright (C) 2025 olie.xdev <olie.xdeveloper@googlemail.com>
4+
*
5+
* This program is free software: you can redistribute it and/or modify
6+
* it under the terms of the GNU General Public License as published by
7+
* the Free Software Foundation, either version 3 of the License, or
8+
* (at your option) any later version.
9+
*
10+
* This program is distributed in the hope that it will be useful,
11+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
12+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13+
* GNU General Public License for more details.
14+
*
15+
* You should have received a copy of the GNU General Public License
16+
* along with this program. If not, see <https://www.gnu.org/licenses/>.
17+
*/
18+
package com.health.openscale.core.database
19+
20+
import androidx.test.core.app.ApplicationProvider
21+
import com.google.common.truth.Truth.assertThat
22+
import com.health.openscale.core.data.ActivityLevel
23+
import com.health.openscale.core.data.GenderType
24+
import com.health.openscale.core.data.MeasurementType
25+
import com.health.openscale.core.data.MeasurementTypeKey
26+
import com.health.openscale.core.data.User
27+
import com.health.openscale.core.data.UserGoals
28+
import kotlinx.coroutines.flow.first
29+
import kotlinx.coroutines.runBlocking
30+
import org.junit.After
31+
import org.junit.Before
32+
import org.junit.Test
33+
import org.junit.runner.RunWith
34+
import org.robolectric.RobolectricTestRunner
35+
import org.robolectric.annotation.Config
36+
37+
/**
38+
* [UserGoalsDao] behaviour on the JVM (Robolectric): conflict-ignore on the composite primary key,
39+
* delete by (userId, typeId), and foreign-key CASCADE when the owning user is removed.
40+
*/
41+
@RunWith(RobolectricTestRunner::class)
42+
@Config(sdk = [34])
43+
class UserGoalsDaoTest {
44+
45+
private lateinit var db: AppDatabase
46+
private var userId = 0
47+
private var typeId = 0
48+
49+
@Before
50+
fun setUp() = runBlocking {
51+
db = com.health.openscale.testutil.RoomTestSupport.inMemory(ApplicationProvider.getApplicationContext())
52+
userId = db.userDao().insert(
53+
User(
54+
name = "u", birthDate = 0L, gender = GenderType.MALE, heightCm = 175f,
55+
activityLevel = ActivityLevel.MODERATE, useAssistedWeighing = false,
56+
)
57+
).toInt()
58+
typeId = db.measurementTypeDao().insert(MeasurementType(key = MeasurementTypeKey.WEIGHT)).toInt()
59+
}
60+
61+
@After
62+
fun tearDown() = db.close()
63+
64+
@Test
65+
fun insert_duplicateCompositeKey_isIgnoredAndKeepsOriginal() = runBlocking {
66+
val first = db.userGoalsDao().insert(UserGoals(userId, typeId, goalValue = 70f))
67+
val second = db.userGoalsDao().insert(UserGoals(userId, typeId, goalValue = 99f))
68+
69+
assertThat(first).isAtLeast(0L)
70+
assertThat(second).isEqualTo(-1L) // OnConflict.IGNORE
71+
val goals = db.userGoalsDao().getAllForUser(userId).first()
72+
assertThat(goals).hasSize(1)
73+
assertThat(goals.single().goalValue).isWithin(1e-3f).of(70f)
74+
}
75+
76+
@Test
77+
fun delete_removesGoalByCompositeKey() = runBlocking {
78+
db.userGoalsDao().insert(UserGoals(userId, typeId, goalValue = 70f))
79+
db.userGoalsDao().delete(userId, typeId)
80+
assertThat(db.userGoalsDao().getAllForUser(userId).first()).isEmpty()
81+
}
82+
83+
@Test
84+
fun deleteUser_cascadesToGoals() = runBlocking {
85+
db.userGoalsDao().insert(UserGoals(userId, typeId, goalValue = 70f))
86+
db.userDao().delete(db.userDao().getById(userId).first()!!)
87+
assertThat(db.userGoalsDao().getAllForUser(userId).first()).isEmpty()
88+
}
89+
}
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
/*
2+
* openScale
3+
* Copyright (C) 2025 olie.xdev <olie.xdeveloper@googlemail.com>
4+
*
5+
* This program is free software: you can redistribute it and/or modify
6+
* it under the terms of the GNU General Public License as published by
7+
* the Free Software Foundation, either version 3 of the License, or
8+
* (at your option) any later version.
9+
*
10+
* This program is distributed in the hope that it will be useful,
11+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
12+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13+
* GNU General Public License for more details.
14+
*
15+
* You should have received a copy of the GNU General Public License
16+
* along with this program. If not, see <https://www.gnu.org/licenses/>.
17+
*/
18+
package com.health.openscale.core.service
19+
20+
import android.content.Context
21+
import androidx.test.core.app.ApplicationProvider
22+
import com.google.common.truth.Truth.assertThat
23+
import com.health.openscale.core.data.MeasurementTypeKey
24+
import com.health.openscale.core.data.Trend
25+
import com.health.openscale.testutil.Fixtures
26+
import com.health.openscale.testutil.RoomTestSupport
27+
import kotlinx.coroutines.CoroutineScope
28+
import kotlinx.coroutines.Dispatchers
29+
import kotlinx.coroutines.SupervisorJob
30+
import org.junit.Test
31+
import org.junit.runner.RunWith
32+
import org.robolectric.RobolectricTestRunner
33+
import org.robolectric.annotation.Config
34+
import java.io.File
35+
36+
/**
37+
* Tests [MeasurementEnricher.enrichWithDifferences] — the per-type diff/trend logic shown next to
38+
* each value. The method is pure; Robolectric is only used to build the (unused-by-this-method)
39+
* SettingsFacade dependency. Measurements are passed newest-first (the production ordering).
40+
*/
41+
@RunWith(RobolectricTestRunner::class)
42+
@Config(sdk = [34])
43+
class MeasurementEnricherTest {
44+
45+
private val context: Context get() = ApplicationProvider.getApplicationContext()
46+
47+
private fun enricher(): MeasurementEnricher {
48+
val settings = RoomTestSupport.settingsFacadeFor(
49+
CoroutineScope(SupervisorJob() + Dispatchers.IO),
50+
File(context.cacheDir, "enricher-${System.nanoTime()}.preferences_pb"),
51+
)
52+
return MeasurementEnricher(settings, TrendCalculator())
53+
}
54+
55+
@Test
56+
fun enrichWithDifferences_computesDeltaAndTrend_vsPreviousMeasurement() {
57+
val weight = Fixtures.type(id = 1, key = MeasurementTypeKey.WEIGHT)
58+
val newest = Fixtures.mwv(2, Fixtures.ts(2025, 1, 2), listOf(Fixtures.valueWithType(weight, 72f, 2)))
59+
val older = Fixtures.mwv(1, Fixtures.ts(2025, 1, 1), listOf(Fixtures.valueWithType(weight, 70f, 1)))
60+
61+
// production passes measurements newest-first
62+
val result = enricher().enrichWithDifferences(listOf(newest, older), listOf(weight))
63+
64+
assertThat(result).hasSize(2)
65+
// newest (index 0) diffs against the older one
66+
assertThat(result[0].difference).isWithin(1e-3f).of(2f)
67+
assertThat(result[0].trend).isEqualTo(Trend.UP)
68+
// oldest has no predecessor
69+
assertThat(result[1].difference).isNull()
70+
assertThat(result[1].trend).isEqualTo(Trend.NOT_APPLICABLE)
71+
}
72+
73+
@Test
74+
fun enrichWithDifferences_skipsDisabledTypes() {
75+
val disabled = Fixtures.type(id = 1, key = MeasurementTypeKey.WEIGHT, enabled = false)
76+
val mwv = Fixtures.mwv(1, Fixtures.ts(2025, 1, 1), listOf(Fixtures.valueWithType(disabled, 70f, 1)))
77+
78+
val result = enricher().enrichWithDifferences(listOf(mwv), listOf(disabled))
79+
80+
assertThat(result).isEmpty()
81+
}
82+
83+
@Test
84+
fun enrichWithDifferences_sortsByDisplayOrder() {
85+
val late = Fixtures.type(id = 1, key = MeasurementTypeKey.WEIGHT, displayOrder = 5)
86+
val early = Fixtures.type(id = 2, key = MeasurementTypeKey.BODY_FAT, displayOrder = 1)
87+
val mwv = Fixtures.mwv(
88+
1, Fixtures.ts(2025, 1, 1),
89+
listOf(Fixtures.valueWithType(late, 70f, 1), Fixtures.valueWithType(early, 20f, 1)),
90+
)
91+
92+
val result = enricher().enrichWithDifferences(listOf(mwv), listOf(late, early))
93+
94+
assertThat(result.map { it.currentValue.type.id }).containsExactly(2, 1).inOrder()
95+
}
96+
}
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
/*
2+
* openScale
3+
* Copyright (C) 2025 olie.xdev <olie.xdeveloper@googlemail.com>
4+
*
5+
* This program is free software: you can redistribute it and/or modify
6+
* it under the terms of the GNU General Public License as published by
7+
* the Free Software Foundation, either version 3 of the License, or
8+
* (at your option) any later version.
9+
*
10+
* This program is distributed in the hope that it will be useful,
11+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
12+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13+
* GNU General Public License for more details.
14+
*
15+
* You should have received a copy of the GNU General Public License
16+
* along with this program. If not, see <https://www.gnu.org/licenses/>.
17+
*/
18+
package com.health.openscale.core.usecase
19+
20+
import android.app.Application
21+
import androidx.test.core.app.ApplicationProvider
22+
import com.google.common.truth.Truth.assertThat
23+
import com.health.openscale.core.data.ActivityLevel
24+
import com.health.openscale.core.data.GenderType
25+
import com.health.openscale.core.data.Measurement
26+
import com.health.openscale.core.data.MeasurementTypeKey
27+
import com.health.openscale.core.data.MeasurementValue
28+
import com.health.openscale.core.data.User
29+
import com.health.openscale.core.database.AppDatabase
30+
import com.health.openscale.core.database.DatabaseRepository
31+
import com.health.openscale.getDefaultMeasurementTypes
32+
import com.health.openscale.testutil.RoomTestSupport
33+
import kotlinx.coroutines.CoroutineScope
34+
import kotlinx.coroutines.Dispatchers
35+
import kotlinx.coroutines.SupervisorJob
36+
import kotlinx.coroutines.flow.first
37+
import kotlinx.coroutines.runBlocking
38+
import org.junit.After
39+
import org.junit.Before
40+
import org.junit.Test
41+
import org.junit.runner.RunWith
42+
import org.robolectric.RobolectricTestRunner
43+
import org.robolectric.annotation.Config
44+
import java.io.File
45+
46+
/**
47+
* Tests [MeasurementCrudUseCases.saveMeasurement] — insert and the update value-diff
48+
* (delete removed / update existing / insert new) — against in-memory Room (Robolectric).
49+
* Assertions target specific measurement types since recalculation adds derived values.
50+
*/
51+
@RunWith(RobolectricTestRunner::class)
52+
@Config(sdk = [34])
53+
class MeasurementCrudUseCasesTest {
54+
55+
private val app: Application get() = ApplicationProvider.getApplicationContext()
56+
private lateinit var db: AppDatabase
57+
private lateinit var repo: DatabaseRepository
58+
private lateinit var crud: MeasurementCrudUseCases
59+
private var userId = 0
60+
private var weightId = 0
61+
private var waistId = 0
62+
private var neckId = 0
63+
64+
@Before
65+
fun setUp() = runBlocking {
66+
db = RoomTestSupport.inMemory(app)
67+
repo = RoomTestSupport.repositoryFor(db)
68+
repo.insertAllMeasurementTypes(getDefaultMeasurementTypes())
69+
val settings = RoomTestSupport.settingsFacadeFor(
70+
CoroutineScope(SupervisorJob() + Dispatchers.IO),
71+
File(app.cacheDir, "crud-${System.nanoTime()}.preferences_pb"),
72+
)
73+
crud = RoomTestSupport.measurementCrudFor(app, repo, settings)
74+
75+
val types = repo.getAllMeasurementTypes().first()
76+
weightId = types.first { it.key == MeasurementTypeKey.WEIGHT }.id
77+
waistId = types.first { it.key == MeasurementTypeKey.WAIST }.id
78+
neckId = types.first { it.key == MeasurementTypeKey.NECK }.id
79+
80+
userId = repo.insertUser(
81+
User(
82+
name = "u", birthDate = 0L, gender = GenderType.MALE, heightCm = 175f,
83+
activityLevel = ActivityLevel.MODERATE, useAssistedWeighing = false,
84+
)
85+
).toInt()
86+
}
87+
88+
@After
89+
fun tearDown() = db.close()
90+
91+
@Test
92+
fun saveMeasurement_insert_persistsValues() = runBlocking {
93+
val id = crud.saveMeasurement(
94+
Measurement(userId = userId, timestamp = 1_000L),
95+
listOf(MeasurementValue(measurementId = 0, typeId = weightId, floatValue = 70f)),
96+
).getOrThrow()
97+
98+
assertThat(id).isGreaterThan(0)
99+
val values = repo.getValuesForMeasurement(id).first()
100+
assertThat(values.any { it.typeId == weightId && it.floatValue == 70f }).isTrue()
101+
}
102+
103+
@Test
104+
fun saveMeasurement_update_appliesValueDiff() = runBlocking {
105+
// initial: WEIGHT + WAIST
106+
val id = crud.saveMeasurement(
107+
Measurement(userId = userId, timestamp = 2_000L),
108+
listOf(
109+
MeasurementValue(measurementId = 0, typeId = weightId, floatValue = 70f),
110+
MeasurementValue(measurementId = 0, typeId = waistId, floatValue = 90f),
111+
),
112+
).getOrThrow()
113+
val weightValueId = repo.getValuesForMeasurement(id).first().first { it.typeId == weightId }.id
114+
115+
// update: keep+change WEIGHT, drop WAIST, add NECK
116+
crud.saveMeasurement(
117+
Measurement(id = id, userId = userId, timestamp = 2_000L),
118+
listOf(
119+
MeasurementValue(id = weightValueId, measurementId = id, typeId = weightId, floatValue = 72f),
120+
MeasurementValue(measurementId = 0, typeId = neckId, floatValue = 38f),
121+
),
122+
).getOrThrow()
123+
124+
val after = repo.getValuesForMeasurement(id).first()
125+
assertThat(after.none { it.typeId == waistId }).isTrue() // deleted
126+
assertThat(after.first { it.typeId == weightId }.floatValue).isWithin(1e-3f).of(72f) // updated
127+
assertThat(after.any { it.typeId == neckId && it.floatValue == 38f }).isTrue() // inserted
128+
}
129+
}
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
/*
2+
* openScale
3+
* Copyright (C) 2025 olie.xdev <olie.xdeveloper@googlemail.com>
4+
*
5+
* This program is free software: you can redistribute it and/or modify
6+
* it under the terms of the GNU General Public License as published by
7+
* the Free Software Foundation, either version 3 of the License, or
8+
* (at your option) any later version.
9+
*
10+
* This program is distributed in the hope that it will be useful,
11+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
12+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13+
* GNU General Public License for more details.
14+
*
15+
* You should have received a copy of the GNU General Public License
16+
* along with this program. If not, see <https://www.gnu.org/licenses/>.
17+
*/
18+
package com.health.openscale.core.usecase
19+
20+
import com.google.common.truth.Truth.assertThat
21+
import com.health.openscale.core.data.MeasurementTypeKey
22+
import com.health.openscale.core.model.MeasurementWithValues
23+
import com.health.openscale.core.model.TrendDirection
24+
import com.health.openscale.testutil.Fixtures
25+
import org.junit.Test
26+
27+
/**
28+
* Pure JVM tests for [MeasurementInsightsUseCase.compute] — the statistical analysis behind the
29+
* Insights screen. Covers the minimum-data gate and the core measurement analysis (delta, min/max,
30+
* long-term trend) for a clean monotonic series.
31+
*/
32+
class MeasurementInsightsUseCaseTest {
33+
34+
private val useCase = MeasurementInsightsUseCase()
35+
private val weight = Fixtures.type(id = 1, key = MeasurementTypeKey.WEIGHT)
36+
37+
private fun m(day: Int, value: Float): MeasurementWithValues =
38+
Fixtures.mwv(
39+
measurementId = day,
40+
timestamp = Fixtures.ts(2025, 1, day),
41+
values = listOf(Fixtures.valueWithType(weight, value, day)),
42+
)
43+
44+
private fun risingSeries(): List<MeasurementWithValues> =
45+
listOf(m(1, 70f), m(2, 71f), m(3, 72f), m(4, 73f), m(5, 74f), m(6, 75f))
46+
47+
@Test
48+
fun compute_emptyInput_returnsEmptyInsight() {
49+
val insight = useCase.compute(emptyList(), primaryTypeId = weight.id)
50+
assertThat(insight.measurementAnalysis).isNull()
51+
assertThat(insight.anomalies).isEmpty()
52+
assertThat(insight.basedOnCount).isEqualTo(0)
53+
}
54+
55+
@Test
56+
fun compute_belowMinimumMeasurements_returnsEmptyAnalysis() {
57+
val insight = useCase.compute(risingSeries().take(4), primaryTypeId = weight.id)
58+
assertThat(insight.measurementAnalysis).isNull()
59+
assertThat(insight.basedOnCount).isEqualTo(4)
60+
}
61+
62+
@Test
63+
fun compute_nullPrimaryType_returnsEmptyAnalysis() {
64+
val insight = useCase.compute(risingSeries(), primaryTypeId = null)
65+
assertThat(insight.measurementAnalysis).isNull()
66+
}
67+
68+
@Test
69+
fun compute_risingSeries_producesAnalysisWithUpwardTrend() {
70+
val insight = useCase.compute(risingSeries(), primaryTypeId = weight.id)
71+
72+
val analysis = insight.measurementAnalysis
73+
assertThat(analysis).isNotNull()
74+
assertThat(analysis!!.firstValue).isWithin(1e-3f).of(70f)
75+
assertThat(analysis.lastValue).isWithin(1e-3f).of(75f)
76+
assertThat(analysis.deltaAbsolute).isWithin(1e-3f).of(5f)
77+
assertThat(analysis.minValue).isWithin(1e-3f).of(70f)
78+
assertThat(analysis.maxValue).isWithin(1e-3f).of(75f)
79+
assertThat(analysis.longTermTrend).isEqualTo(TrendDirection.UP)
80+
assertThat(insight.basedOnCount).isEqualTo(6)
81+
}
82+
}

0 commit comments

Comments
 (0)