Skip to content

Commit 0bb26fc

Browse files
committed
Add: domain model test coverage for DivePlan, CylinderRole, DivePlanInputModel and others
1 parent 1ddfb28 commit 0bb26fc

8 files changed

Lines changed: 874 additions & 4 deletions

File tree

domain/src/commonTest/kotlin/org/neotech/app/abysner/domain/core/model/CylinderTest.kt

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,9 @@ import org.neotech.app.abysner.domain.core.physics.asBarToPsi
1616
import kotlin.math.roundToInt
1717
import kotlin.test.Test
1818
import kotlin.test.assertEquals
19+
import kotlin.test.assertNotNull
20+
import kotlin.test.assertNull
21+
import kotlin.test.assertTrue
1922

2023
class CylinderTest {
2124

@@ -43,4 +46,50 @@ class CylinderTest {
4346
assertEquals(3300, Cylinder.AL100.workingPressure.asBarToPsi().roundToInt())
4447
assertEquals(3442, Cylinder.HP100.workingPressure.asBarToPsi().roundToInt())
4548
}
49+
50+
@Test
51+
fun pressureAfter_returnsReducedPressure() {
52+
val cylinder = Cylinder(Gas.Air, 200.0, 10.0)
53+
val pressure = cylinder.pressureAfter(volumeUsage = 1500.0)
54+
// Not asserting on exact value, to avoid coupling to the equation of state model.
55+
assertNotNull(pressure)
56+
assertTrue(pressure < 100.0)
57+
assertTrue(pressure > 0.0)
58+
}
59+
60+
@Test
61+
fun pressureAfter_returnsNullWhenVolumeExceedsCapacity() {
62+
val cylinder = Cylinder(Gas.Air, 200.0, 10.0)
63+
val pressure = cylinder.pressureAfter(volumeUsage = 20000.0)
64+
assertNull(pressure)
65+
}
66+
67+
@Test
68+
fun sizeFindMatching_returnsMatchingStandardSize() {
69+
val found = Cylinder.Size.findMatching(waterVolume = 12.0, workingPressure = 232.0)
70+
assertNotNull(found)
71+
assertEquals("12L", found.name)
72+
}
73+
74+
@Test
75+
fun sizeFindMatching_matchesWithinTolerance() {
76+
// Tolerance is 0.05L and 0.5 bar
77+
val found = Cylinder.Size.findMatching(waterVolume = 12.04, workingPressure = 231.6)
78+
assertNotNull(found)
79+
assertEquals("12L", found.name)
80+
}
81+
82+
@Test
83+
fun sizeFindMatching_returnsNullForDeviationBeyondTolerance() {
84+
val found = Cylinder.Size.findMatching(waterVolume = 12.1, workingPressure = 232.0)
85+
assertNull(found)
86+
}
87+
88+
@Test
89+
fun sizeFill_createsCylinderWithWorkingPressure() {
90+
val cylinder = Cylinder.STEEL_12L.fill(Gas.Air)
91+
assertEquals(Cylinder.STEEL_12L.workingPressure, cylinder.pressure)
92+
assertEquals(Cylinder.STEEL_12L.waterVolume, cylinder.waterVolume)
93+
assertEquals(Gas.Air, cylinder.gas)
94+
}
4695
}

domain/src/commonTest/kotlin/org/neotech/app/abysner/domain/core/model/GasTest.kt

Lines changed: 92 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,19 @@ package org.neotech.app.abysner.domain.core.model
1515
import org.neotech.app.abysner.domain.core.physics.ambientPressureToMeters
1616
import kotlin.test.Test
1717
import kotlin.test.assertEquals
18+
import kotlin.test.assertFailsWith
1819
import org.neotech.app.abysner.domain.core.physics.metersToAmbientPressure
1920
import kotlin.math.floor
2021

2122
class GasTest {
2223

24+
@Test
25+
fun init_rejectsGasWhenCombinedOxygenAndHeliumFractionsExceedOneHundredPercent() {
26+
assertFailsWith<IllegalStateException> {
27+
Gas(oxygenFraction = 0.8, heliumFraction = 0.3)
28+
}
29+
}
30+
2331
@Test
2432
fun oxygenMod_returnsCorrectModForCommonGases() {
2533
assertEquals(metersToAmbientPressure(40.653, Environment.SeaLevelFresh).value, Gas.Nitrox28.oxygenModAmbientPressure(1.4).value, DOUBLE_TOLERANCE)
@@ -72,10 +80,10 @@ class GasTest {
7280
}
7381

7482
@Test
75-
fun nitrogenFraction_isComputedFromOxygenAndHelium() {
76-
val newGas = Gas(oxygenFraction = 21f / 100.0, heliumFraction = 0f / 100.0)
77-
val nitrogenPercentage = (newGas.nitrogenFraction * 100.0).toInt()
78-
println(nitrogenPercentage)
83+
fun nitrogenFraction_isRemainderOfOxygenAndHelium() {
84+
assertEquals(0.79, Gas.Air.nitrogenFraction, DOUBLE_TOLERANCE)
85+
assertEquals(0.44, Gas.Trimix2135.nitrogenFraction, DOUBLE_TOLERANCE)
86+
assertEquals(0.0, Gas.Oxygen.nitrogenFraction, DOUBLE_TOLERANCE)
7987
}
8088

8189
@Test
@@ -188,6 +196,86 @@ class GasTest {
188196
assertEquals(0.328, inspired.oxygenFraction, DOUBLE_TOLERANCE)
189197
assertEquals(0.298, inspired.heliumFraction, DOUBLE_TOLERANCE)
190198
}
199+
200+
@Test
201+
fun inspiredGas_pureOxygenDiluentReturnsOxygenOnly() {
202+
val inspired = Gas.Oxygen.inspiredGas(metersToAmbientPressure(6.0, Environment.SeaLevelFresh), 1.3)
203+
assertEquals(1.0, inspired.oxygenFraction, DOUBLE_TOLERANCE)
204+
assertEquals(0.0, inspired.heliumFraction, DOUBLE_TOLERANCE)
205+
}
206+
207+
@Test
208+
fun endAmbientPressure_airEqualsAmbientPressure() {
209+
// Air has no helium, so END equals actual depth
210+
val ambient = metersToAmbientPressure(30.0, Environment.SeaLevelFresh).value
211+
assertEquals(ambient, Gas.Air.endAmbientPressure(ambient), DOUBLE_TOLERANCE)
212+
}
213+
214+
@Test
215+
fun endAmbientPressure_trimixReducesNarcoticDepth() {
216+
val ambient = metersToAmbientPressure(60.0, Environment.SeaLevelFresh).value
217+
// Trimix 21/35 has 35% helium (non-narcotic), so at 60m (6.897 bar ambient) only the
218+
// remaining 65% (21% O2 + 44% N2) counts towards narcotic depth, an END of about 4.483 bar.
219+
assertEquals(4.483, Gas.Trimix2135.endAmbientPressure(ambient), DOUBLE_TOLERANCE)
220+
}
221+
222+
@Test
223+
fun diveIndustryName_returnsOxygenForPureO2() {
224+
assertEquals("Oxygen", Gas.Oxygen.diveIndustryName())
225+
}
226+
227+
@Test
228+
fun diveIndustryName_returnsAirForStandardAir() {
229+
assertEquals("Air", Gas.Air.diveIndustryName())
230+
}
231+
232+
@Test
233+
fun diveIndustryName_returnsNitroxForAnyEnrichedAirAbove21Percent() {
234+
for (percentage in 22..99) {
235+
val gas = Gas(oxygenFraction = percentage / 100.0, heliumFraction = 0.0)
236+
assertEquals("Nitrox", gas.diveIndustryName())
237+
}
238+
}
239+
240+
@Test
241+
fun diveIndustryName_returnsHelioxForAnyZeroNitrogenHeliumBlend() {
242+
// Any gas with He > 0 and N2 = 0 (and not pure O2) is Heliox
243+
for (oxygenPercentage in 1..99) {
244+
val heliumPercentage = 100 - oxygenPercentage
245+
val gas = Gas(oxygenFraction = oxygenPercentage / 100.0, heliumFraction = heliumPercentage / 100.0)
246+
assertEquals("Heliox", gas.diveIndustryName())
247+
}
248+
}
249+
250+
@Test
251+
fun diveIndustryName_returnsTrimixForHypoxicHeliumBlendWithNitrogen() {
252+
// Any gas with O2 <= 21%, He > 0, and N2 > 0 is Trimix
253+
for (oxygenPercentage in 1..21) {
254+
for (heliumPercentage in 1 until (100 - oxygenPercentage)) {
255+
val gas = Gas(oxygenFraction = oxygenPercentage / 100.0, heliumFraction = heliumPercentage / 100.0)
256+
assertEquals("Trimix", gas.diveIndustryName())
257+
}
258+
}
259+
}
260+
261+
@Test
262+
fun diveIndustryName_returnsHelitroxForHyperoxicHeliumBlendWithNitrogen() {
263+
// Any gas with O2 > 21%, He > 0, and N2 > 0 is Helitrox
264+
for (oxygenPercentage in 22..98) {
265+
for (heliumPercentage in 1 until (100 - oxygenPercentage)) {
266+
val gas = Gas(oxygenFraction = oxygenPercentage / 100.0, heliumFraction = heliumPercentage / 100.0)
267+
assertEquals("Helitrox", gas.diveIndustryName(), "Expected Helitrox for $oxygenPercentage/$heliumPercentage")
268+
}
269+
}
270+
}
271+
272+
@Test
273+
fun diveIndustryName_returnsHypoxicForAnySubAirO2WithoutHelium() {
274+
for (percentage in 1..20) {
275+
val gas = Gas(oxygenFraction = percentage / 100.0, heliumFraction = 0.0)
276+
assertEquals("Hypoxic", gas.diveIndustryName(), "Expected Hypoxic for $percentage% O2")
277+
}
278+
}
191279
}
192280

193281
private const val DOUBLE_TOLERANCE = 1e-3

domain/src/commonTest/kotlin/org/neotech/app/abysner/domain/core/physics/PolynomialRealGasModelTest.kt

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import org.neotech.app.abysner.domain.core.model.Cylinder
1616
import org.neotech.app.abysner.domain.core.model.Gas
1717
import kotlin.test.Test
1818
import kotlin.test.assertEquals
19+
import kotlin.test.assertTrue
1920

2021
class PolynomialRealGasModelTest {
2122

@@ -33,6 +34,51 @@ class PolynomialRealGasModelTest {
3334
test(it * 30.0)
3435
}
3536
}
37+
38+
@Test
39+
fun getGasPressure_constantTimeCalculation_producesReasonableResult() {
40+
val model = PolynomialRealGasModel(constantTimeCalculation = true)
41+
val cylinder = Cylinder(Gas.Air, 200.0, 10.0)
42+
val volume = model.getGasVolume(cylinder)
43+
val recoveredPressure = model.getGasPressure(cylinder, volume)
44+
// constantTimeCalculation is an approximation, allow wider tolerance
45+
assertEquals(200.0, recoveredPressure, 5.0)
46+
}
47+
48+
@Test
49+
fun getGasVolume_heliumRichGas_differFromIdealGas() {
50+
val realModel = PolynomialRealGasModel()
51+
val heliox = Gas(oxygenFraction = 0.21, heliumFraction = 0.79)
52+
val volume = realModel.getGasVolume(heliox, 12.0, 200.0)
53+
val idealVolume = 200.0 * 12.0 // 2400L
54+
// Real gas model should produce a different result than ideal gas
55+
assertTrue(volume != idealVolume, "Real gas volume should differ from ideal")
56+
// But should still be in a reasonable range (within 10% of ideal)
57+
assertTrue(volume > idealVolume * 0.9, "Volume $volume too low")
58+
assertTrue(volume < idealVolume * 1.1, "Volume $volume too high")
59+
}
60+
61+
@Test
62+
fun getGasPressure_heliumRichGasHighPressure_convergesWithinTolerance() {
63+
val model = PolynomialRealGasModel()
64+
val heliox = Gas(oxygenFraction = 0.10, heliumFraction = 0.90)
65+
val cylinder = Cylinder(heliox, 300.0, 10.0)
66+
val volume = model.getGasVolume(cylinder)
67+
val recoveredPressure = model.getGasPressure(cylinder, volume)
68+
assertEquals(300.0, recoveredPressure, 1.0)
69+
}
70+
71+
@Test
72+
fun getGasVolume_nitroxAtVariousPressures_increasesMonotonically() {
73+
val model = PolynomialRealGasModel()
74+
var previousVolume = 0.0
75+
for (pressure in 1..300 step 50) {
76+
val cylinder = Cylinder(Gas.Nitrox50, pressure.toDouble(), 10.0)
77+
val volume = model.getGasVolume(cylinder)
78+
assertTrue(volume > previousVolume, "Volume should increase with pressure")
79+
previousVolume = volume
80+
}
81+
}
3682
}
3783

3884
private const val DOUBLE_TOLERANCE = 1e-6
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
/*
2+
* Abysner - Dive planner
3+
* Copyright (C) 2026 Neotech
4+
*
5+
* Abysner is free software: you can redistribute it and/or modify
6+
* it under the terms of the GNU Affero General Public License version 3,
7+
* as published by the Free Software Foundation.
8+
*
9+
* You should have received a copy of the GNU Affero General Public License
10+
* along with this program. If not, see https://www.gnu.org/licenses/.
11+
*/
12+
13+
package org.neotech.app.abysner.domain.diveplanning.model
14+
15+
import kotlin.test.Test
16+
import kotlin.test.assertEquals
17+
import kotlin.test.assertFalse
18+
import kotlin.test.assertNull
19+
import kotlin.test.assertTrue
20+
21+
class CylinderRoleTest {
22+
23+
@Test
24+
fun isCcrOxygen_trueOnlyForCcrOxygenRole() {
25+
assertTrue(CylinderRole.CCR_OXYGEN.isCcrOxygen)
26+
assertFalse(CylinderRole.CCR_DILUENT.isCcrOxygen)
27+
assertFalse(CylinderRole.CCR_DILUENT_AND_BAILOUT.isCcrOxygen)
28+
}
29+
30+
@Test
31+
fun isCcrOxygen_falseForNull() {
32+
val role: CylinderRole? = null
33+
assertFalse(role.isCcrOxygen)
34+
}
35+
36+
@Test
37+
fun isCcrDiluent_trueForDiluentAndDiluentBailout() {
38+
assertTrue(CylinderRole.CCR_DILUENT.isCcrDiluent)
39+
assertTrue(CylinderRole.CCR_DILUENT_AND_BAILOUT.isCcrDiluent)
40+
assertFalse(CylinderRole.CCR_OXYGEN.isCcrDiluent)
41+
}
42+
43+
@Test
44+
fun isCcrDiluent_falseForNull() {
45+
val role: CylinderRole? = null
46+
assertFalse(role.isCcrDiluent)
47+
}
48+
49+
@Test
50+
fun isAvailableForBailout_trueForNullAndDiluentBailout() {
51+
val nullRole: CylinderRole? = null
52+
assertTrue(nullRole.isAvailableForBailout)
53+
assertTrue(CylinderRole.CCR_DILUENT_AND_BAILOUT.isAvailableForBailout)
54+
}
55+
56+
@Test
57+
fun isAvailableForBailout_falseForOxygenAndDiluent() {
58+
assertFalse(CylinderRole.CCR_OXYGEN.isAvailableForBailout)
59+
assertFalse(CylinderRole.CCR_DILUENT.isAvailableForBailout)
60+
}
61+
62+
@Test
63+
fun toggleAvailableForBailout_togglesDiluentToBailout() {
64+
val result = CylinderRole.CCR_DILUENT.toggleAvailableForBailout(true)
65+
assertEquals(CylinderRole.CCR_DILUENT_AND_BAILOUT, result)
66+
}
67+
68+
@Test
69+
fun toggleAvailableForBailout_togglesBailoutToDiluent() {
70+
val result = CylinderRole.CCR_DILUENT_AND_BAILOUT.toggleAvailableForBailout(false)
71+
assertEquals(CylinderRole.CCR_DILUENT, result)
72+
}
73+
74+
@Test
75+
fun toggleAvailableForBailout_noOpForOxygen() {
76+
val result = CylinderRole.CCR_OXYGEN.toggleAvailableForBailout(true)
77+
assertEquals(CylinderRole.CCR_OXYGEN, result)
78+
}
79+
80+
@Test
81+
fun toggleAvailableForBailout_noOpForNull() {
82+
val role: CylinderRole? = null
83+
val result = role.toggleAvailableForBailout(true)
84+
assertNull(result)
85+
}
86+
}

0 commit comments

Comments
 (0)