diff --git a/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/models/ScoreList.kt b/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/models/ScoreList.kt index 824d8a4a3..5827670a6 100644 --- a/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/models/ScoreList.kt +++ b/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/models/ScoreList.kt @@ -61,6 +61,32 @@ class ScoreList { return result } + /** + * Returns the number of skips after the offset in the interval used to calculate + * the percentage of completed days. + * + * If skips are found in the interval, it expands the interval by the number of skips found + * and repeats this process for the expanded part until no skips are found in an expanded part. + */ + @Synchronized + tailrec fun getNumberOfSkipsByInterval( + values: IntArray, + firstIndexCurrentInterval: Int, + lastIndexCurrentInterval: Int, + nbSkipsIntermedSol: Int = 0 + ): Int { + if (lastIndexCurrentInterval < firstIndexCurrentInterval) return nbSkipsIntermedSol + var nbOfSkips = 0 + var nextLastIndex = lastIndexCurrentInterval + for (i in firstIndexCurrentInterval..lastIndexCurrentInterval) { + if (values[i] == Entry.SKIP) { + nbOfSkips++ + if (lastIndexCurrentInterval + nbOfSkips < values.size) nextLastIndex++ + } + } + return getNumberOfSkipsByInterval(values, lastIndexCurrentInterval + 1, nextLastIndex, nbSkipsIntermedSol + nbOfSkips) + } + /** * Recomputes all scores between the provided [from] and [to] timestamps. */ @@ -125,8 +151,13 @@ class ScoreList { rollingSum += 1.0 } if (offset + denominator < values.size) { - if (values[offset + denominator] == Entry.YES_MANUAL) { - rollingSum -= 1.0 + val nbOfSkips = + getNumberOfSkipsByInterval(values, offset, offset + denominator) + val lastIndexForRollingSum = offset + denominator + nbOfSkips + if (lastIndexForRollingSum < values.size) { + if (values[lastIndexForRollingSum] == Entry.YES_MANUAL) { + if (values[offset] != Entry.SKIP) rollingSum -= 1.0 + } } } if (values[offset] != Entry.SKIP) { diff --git a/uhabits-core/src/jvmTest/java/org/isoron/uhabits/core/models/ScoreListTest.kt b/uhabits-core/src/jvmTest/java/org/isoron/uhabits/core/models/ScoreListTest.kt index 723a3c232..054175df6 100644 --- a/uhabits-core/src/jvmTest/java/org/isoron/uhabits/core/models/ScoreListTest.kt +++ b/uhabits-core/src/jvmTest/java/org/isoron/uhabits/core/models/ScoreListTest.kt @@ -18,15 +18,19 @@ */ package org.isoron.uhabits.core.models +import junit.framework.Assert.assertTrue import org.hamcrest.MatcherAssert.assertThat import org.hamcrest.number.IsCloseTo import org.hamcrest.number.OrderingComparison import org.isoron.uhabits.core.BaseUnitTest import org.isoron.uhabits.core.models.Entry.Companion.SKIP +import org.isoron.uhabits.core.models.Entry.Companion.UNKNOWN +import org.isoron.uhabits.core.models.Entry.Companion.YES_MANUAL import org.isoron.uhabits.core.utils.DateUtils.Companion.getToday import org.junit.Before import org.junit.Test import java.util.ArrayList +import kotlin.test.assertEquals import kotlin.test.assertTrue open class BaseScoreListTest : BaseUnitTest() { @@ -145,6 +149,96 @@ class YesNoScoreListTest : BaseScoreListTest() { checkScoreValues(expectedValues) } + @Test + fun test_getNumberOfSkipsByInterval_NoSkips() { + val vars = intArrayOf(UNKNOWN, UNKNOWN, UNKNOWN, UNKNOWN, SKIP, YES_MANUAL, YES_MANUAL, + UNKNOWN, YES_MANUAL, YES_MANUAL, UNKNOWN, YES_MANUAL, YES_MANUAL, UNKNOWN, SKIP, YES_MANUAL) + val nbOfSkips = habit.scores.getNumberOfSkipsByInterval(vars, 5, 13) + assertEquals(0, nbOfSkips) + } + + @Test + fun test_getNumberOfSkipsByInterval_SkipsOnlyInInitialInterval() { + val vars = intArrayOf(UNKNOWN, UNKNOWN, UNKNOWN, UNKNOWN, SKIP, YES_MANUAL, YES_MANUAL, + UNKNOWN, SKIP, SKIP, UNKNOWN, YES_MANUAL, YES_MANUAL, UNKNOWN, SKIP, YES_MANUAL) + val nbOfSkips = habit.scores.getNumberOfSkipsByInterval(vars, 4, 9) + assertEquals(3, nbOfSkips) + } + + @Test + fun test_getNumberOfSkipsByInterval_SkipsInSubsequentIntervals() { + val vars = intArrayOf(UNKNOWN, UNKNOWN, UNKNOWN, UNKNOWN, SKIP, YES_MANUAL, YES_MANUAL, + UNKNOWN, SKIP, SKIP, UNKNOWN, YES_MANUAL, YES_MANUAL, UNKNOWN, SKIP, YES_MANUAL) + val nbOfSkips = habit.scores.getNumberOfSkipsByInterval(vars, 4, 11) + assertEquals(4, nbOfSkips) + } + + @Test + fun test_getValueNonDailyWithSkip() { + habit.frequency = Frequency(6, 7) + check(0, 18) + addSkip(10) + addSkip(11) + addSkip(12) + habit.recompute() + val expectedValues = doubleArrayOf( + 0.365222, + 0.333100, + 0.299354, + 0.263899, + 0.226651, + 0.191734, + 0.159268, + 0.129375, + 0.102187, + 0.077839, + 0.056477, + 0.056477, + 0.056477, + 0.056477, + 0.038251, + 0.023319, + 0.011848, + 0.004014, + 0.000000, + 0.000000, + 0.000000 + ) + checkScoreValues(expectedValues) + } + + @Test + fun test_perfectDailyWithSkips() { + // If the habit is performed daily and the user always either completes or + // skips the habit, score should converge to 100%. + habit.frequency = Frequency(1, 1) + val values = ArrayList() + check(0, 500) + for (k in 0..99) { + addSkip(7 * k + 5) + addSkip(7 * k + 6) + } + habit.recompute() + check(values) + assertThat(habit.scores[today].value, IsCloseTo.closeTo(1.0, E)) + } + + @Test + fun test_perfectNonDailyWithSkips() { + // If the habit is performed six times per week and the user always either completes or + // skips the habit, score should converge to 100%. + habit.frequency = Frequency(6, 7) + val values = ArrayList() + check(0, 500) + for (k in 0..99) { + addSkip(7 * k + 5) + addSkip(7 * k + 6) + } + habit.recompute() + check(values) + assertThat(habit.scores[today].value, IsCloseTo.closeTo(1.0, E)) + } + @Test fun test_imperfectNonDaily() { // If the habit should be performed 3 times per week and the user misses 1 repetition