diff --git a/app/src/test/java/com/almothafar/simplebatterynotifier/model/BatteryDOTest.java b/app/src/test/java/com/almothafar/simplebatterynotifier/model/BatteryDOTest.java index d1aa70c..a27f55b 100644 --- a/app/src/test/java/com/almothafar/simplebatterynotifier/model/BatteryDOTest.java +++ b/app/src/test/java/com/almothafar/simplebatterynotifier/model/BatteryDOTest.java @@ -1,120 +1,93 @@ package com.almothafar.simplebatterynotifier.model; -import org.junit.Before; import org.junit.Test; +import org.junit.experimental.runners.Enclosed; +import org.junit.runner.RunWith; +import org.junit.runners.Parameterized; +import org.junit.runners.Parameterized.Parameter; +import org.junit.runners.Parameterized.Parameters; -import static org.junit.Assert.*; +import java.util.Arrays; +import java.util.Collection; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertSame; /** - * Integration tests for BatteryDO - * Tests actual calculation logic and edge cases + * Tests for {@link BatteryDO} calculation logic and edge cases. */ +@RunWith(Enclosed.class) public class BatteryDOTest { - private BatteryDO battery; - - @Before - public void setUp() { - battery = new BatteryDO(); - } - - // ===== REAL TESTS FOR getBatteryPercentage() CALCULATION LOGIC ===== - - @Test - public void getBatteryPercentage_normalCase_calculatesCorrectly() { - battery.setLevel(50).setScale(100); - assertEquals(50.0f, battery.getBatteryPercentage(), 0.01f); - } - - @Test - public void getBatteryPercentage_differentScale_calculatesCorrectly() { - battery.setLevel(80).setScale(200); - assertEquals(40.0f, battery.getBatteryPercentage(), 0.01f); - } - - @Test - public void getBatteryPercentage_fullBattery_returns100() { - battery.setLevel(100).setScale(100); - assertEquals(100.0f, battery.getBatteryPercentage(), 0.01f); - } - - @Test - public void getBatteryPercentage_emptyBattery_returns0() { - battery.setLevel(0).setScale(100); - assertEquals(0.0f, battery.getBatteryPercentage(), 0.01f); - } - /** - * CRITICAL EDGE CASE: Division by zero / invalid scale - * A zero (or negative) scale is malformed battery data. It must be guarded so it never - * produces Infinity/NaN, which would become Integer.MAX_VALUE when cast to int downstream. + * {@link BatteryDO#getBatteryPercentage()} across normal, boundary and malformed inputs. A zero or + * negative scale must be guarded so it never yields Infinity/NaN, which would become + * Integer.MAX_VALUE when cast to int downstream. */ - @Test - public void getBatteryPercentage_scaleZero_returnsZero() { - battery.setLevel(50).setScale(0); - final float result = battery.getBatteryPercentage(); - assertEquals("Invalid scale must be guarded, not produce Infinity", 0.0f, result, 0.01f); - } - - @Test - public void getBatteryPercentage_negativeScale_returnsZero() { - battery.setLevel(50).setScale(-1); - assertEquals("Negative scale must be guarded", 0.0f, battery.getBatteryPercentage(), 0.01f); + @RunWith(Parameterized.class) + public static class GetBatteryPercentage { + + @Parameter(0) public String label; + @Parameter(1) public int level; + @Parameter(2) public int scale; + @Parameter(3) public float expected; + + @Parameters(name = "{0}") + public static Collection data() { + return Arrays.asList(new Object[][]{ + {"normal 50/100", 50, 100, 50.0f}, + {"different scale 80/200", 80, 200, 40.0f}, + {"full battery", 100, 100, 100.0f}, + {"empty battery", 0, 100, 0.0f}, + {"decimal 33/100", 33, 100, 33.0f}, + // Malformed data must be guarded, not produce Infinity. + {"zero scale guarded", 50, 0, 0.0f}, + {"negative scale guarded", 50, -1, 0.0f}, + // Defensive: out-of-range inputs still compute rather than crash. + {"negative level", -10, 100, -10.0f}, + {"level exceeds scale", 150, 100, 150.0f}, + }); + } + + @Test + public void matchesExpected() { + final float result = new BatteryDO().setLevel(level).setScale(scale).getBatteryPercentage(); + assertEquals(label, expected, result, 0.01f); + } } /** - * EDGE CASE: Negative level (shouldn't happen but test defensive code) + * Builder chaining, decimal precision and the health-status enum are distinct concerns, kept as + * named tests. */ - @Test - public void getBatteryPercentage_negativeLevel_calculatesCorrectly() { - battery.setLevel(-10).setScale(100); - assertEquals(-10.0f, battery.getBatteryPercentage(), 0.01f); - } - - /** - * EDGE CASE: Level exceeds scale (malformed data) - */ - @Test - public void getBatteryPercentage_levelExceedsScale_calculatesOver100() { - battery.setLevel(150).setScale(100); - assertEquals(150.0f, battery.getBatteryPercentage(), 0.01f); - } - - /** - * PRECISION TEST: Decimal percentages - */ - @Test - public void getBatteryPercentage_decimalResult_maintainsPrecision() { - battery.setLevel(33).setScale(100); - assertEquals(33.0f, battery.getBatteryPercentage(), 0.01f); - - battery.setLevel(1).setScale(3); - assertEquals(33.333f, battery.getBatteryPercentage(), 0.001f); - } - - // ===== BUILDER PATTERN TESTS (minimal, only testing method chaining works) ===== - - @Test - public void setters_returnThis_forMethodChaining() { - final BatteryDO result = battery - .setLevel(50) - .setScale(100) - .setStatus(3) - .setPlugged(1); - - assertSame("Builder pattern should return same instance", battery, result); - } - - // ===== HEALTH STATUS ENUM TEST ===== - - @Test - public void healthStatus_defaultsToUnknown() { - assertEquals(BatteryHealthStatus.UNKNOWN, battery.getHealthStatus()); - } - - @Test - public void setHealthStatus_storesCorrectValue() { - battery.setHealthStatus(BatteryHealthStatus.CRITICAL); - assertEquals(BatteryHealthStatus.CRITICAL, battery.getHealthStatus()); + public static class Behaviour { + + @Test + public void getBatteryPercentage_oneThird_maintainsPrecision() { + assertEquals(33.333f, new BatteryDO().setLevel(1).setScale(3).getBatteryPercentage(), 0.001f); + } + + @Test + public void setters_returnThis_forMethodChaining() { + final BatteryDO battery = new BatteryDO(); + final BatteryDO result = battery + .setLevel(50) + .setScale(100) + .setStatus(3) + .setPlugged(1); + assertSame("Builder pattern should return same instance", battery, result); + } + + @Test + public void healthStatus_defaultsToUnknown() { + assertEquals(BatteryHealthStatus.UNKNOWN, new BatteryDO().getHealthStatus()); + } + + @Test + public void setHealthStatus_storesCorrectValue() { + final BatteryDO battery = new BatteryDO(); + battery.setHealthStatus(BatteryHealthStatus.CRITICAL); + assertEquals(BatteryHealthStatus.CRITICAL, battery.getHealthStatus()); + } } } diff --git a/app/src/test/java/com/almothafar/simplebatterynotifier/service/BatteryHealthTrackerTest.java b/app/src/test/java/com/almothafar/simplebatterynotifier/service/BatteryHealthTrackerTest.java index b9dabd1..1e61a05 100644 --- a/app/src/test/java/com/almothafar/simplebatterynotifier/service/BatteryHealthTrackerTest.java +++ b/app/src/test/java/com/almothafar/simplebatterynotifier/service/BatteryHealthTrackerTest.java @@ -3,124 +3,188 @@ import com.almothafar.simplebatterynotifier.model.BatteryHealthGrade; import org.junit.Test; +import org.junit.experimental.runners.Enclosed; +import org.junit.runner.RunWith; +import org.junit.runners.Parameterized; +import org.junit.runners.Parameterized.Parameter; +import org.junit.runners.Parameterized.Parameters; + +import java.util.Arrays; +import java.util.Collection; import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertTrue; /** * Unit tests for the pure, Android-free helpers in {@link BatteryHealthTracker}. */ +@RunWith(Enclosed.class) public class BatteryHealthTrackerTest { - @Test - public void computeMeasuredHealth_typicalReadings() { - // 3800 mAh measured against a 4000 mAh design capacity -> 95% - assertEquals(95, BatteryHealthTracker.computeMeasuredHealth(3800, 4000)); - // A healthy battery measuring at its rated capacity -> 100% - assertEquals(100, BatteryHealthTracker.computeMeasuredHealth(4000, 4000)); - // Worn battery: 3000 of 5000 -> 60% - assertEquals(60, BatteryHealthTracker.computeMeasuredHealth(3000, 5000)); - } - - @Test - public void computeMeasuredHealth_clampsToRange() { - // Measured above rated (fresh cell / rounding) is clamped to 100 - assertEquals(100, BatteryHealthTracker.computeMeasuredHealth(4200, 4000)); - // Never reports 0; the smallest positive result is 1 - assertEquals(1, BatteryHealthTracker.computeMeasuredHealth(1, 15000)); - } - - @Test - public void computeMeasuredHealth_unusableInputs_returnsMinusOne() { - assertEquals(-1, BatteryHealthTracker.computeMeasuredHealth(0, 4000)); - assertEquals(-1, BatteryHealthTracker.computeMeasuredHealth(3800, 0)); - assertEquals(-1, BatteryHealthTracker.computeMeasuredHealth(-5, 4000)); - } - - @Test - public void computeMeasuredHealth_shownRegardlessOfChargeLevel() { - // #103: the measured figure no longer depends on charge level and is never withheld in favour - // of a misleading cycle-based 100%. A ~4400 mAh estimate on a 4700 mAh design reads 94% whether - // the battery is near full or nearly empty, staying consistent with the displayed Capacity. - assertEquals(94, BatteryHealthTracker.computeMeasuredHealth(4400, 4700)); - } - - @Test - public void accruePartialCycles_accumulatesPositiveDeltasWhileCharging() { - // +50 (40 -> 90) while charging, no prior carry -> 0 cycles, carry 50 - final BatteryHealthTracker.CycleAccrual first = - BatteryHealthTracker.accruePartialCycles(40, 90, true, 0); - assertEquals(0, first.completedCycles()); - assertEquals(50, first.carryPercentPoints()); - - // Another +50 on top of carry 50 -> exactly one cycle, carry resets to 0 - final BatteryHealthTracker.CycleAccrual second = - BatteryHealthTracker.accruePartialCycles(40, 90, true, 50); - assertEquals(1, second.completedCycles()); - assertEquals(0, second.carryPercentPoints()); - } - - @Test - public void accruePartialCycles_ignoresDischargeFlatNotChargingAndUnknownPrev() { - // Discharging (current < prev): no accrual, carry unchanged - assertEquals(0, BatteryHealthTracker.accruePartialCycles(90, 40, true, 30).completedCycles()); - assertEquals(30, BatteryHealthTracker.accruePartialCycles(90, 40, true, 30).carryPercentPoints()); - // Flat level - assertEquals(30, BatteryHealthTracker.accruePartialCycles(50, 50, true, 30).carryPercentPoints()); - // Not charging, even though the level rose - assertEquals(30, BatteryHealthTracker.accruePartialCycles(40, 90, false, 30).carryPercentPoints()); - // Unknown previous level (-1): first observation only, nothing accrues - assertEquals(30, BatteryHealthTracker.accruePartialCycles(-1, 90, true, 30).carryPercentPoints()); - } - - @Test - public void isValidDesignCapacity_enforcesRange() { - assertTrue(BatteryHealthTracker.isValidDesignCapacity(BatteryHealthTracker.MIN_DESIGN_CAPACITY_MAH)); - assertTrue(BatteryHealthTracker.isValidDesignCapacity(BatteryHealthTracker.MAX_DESIGN_CAPACITY_MAH)); - assertTrue(BatteryHealthTracker.isValidDesignCapacity(4000)); - assertFalse(BatteryHealthTracker.isValidDesignCapacity(BatteryHealthTracker.MIN_DESIGN_CAPACITY_MAH - 1)); - assertFalse(BatteryHealthTracker.isValidDesignCapacity(BatteryHealthTracker.MAX_DESIGN_CAPACITY_MAH + 1)); - assertFalse(BatteryHealthTracker.isValidDesignCapacity(0)); - assertFalse(BatteryHealthTracker.isValidDesignCapacity(-1)); + /** + * {@link BatteryHealthTracker#computeMeasuredHealth(int, int)} — measured capacity as a percentage + * of design capacity, clamped to 1..100, or -1 when the inputs are unusable. + */ + @RunWith(Parameterized.class) + public static class ComputeMeasuredHealth { + + @Parameter(0) public String label; + @Parameter(1) public int measuredMah; + @Parameter(2) public int designMah; + @Parameter(3) public int expected; + + @Parameters(name = "{0}") + public static Collection data() { + return Arrays.asList(new Object[][]{ + {"3800 of 4000 -> 95%", 3800, 4000, 95}, + {"rated capacity -> 100%", 4000, 4000, 100}, + {"worn 3000 of 5000 -> 60%", 3000, 5000, 60}, + // #103: shown regardless of charge level; ~4400 of a 4700 design reads 94%. + {"4400 of 4700 -> 94%", 4400, 4700, 94}, + // Clamped to range: above rated -> 100, tiny positive -> 1 (never 0). + {"above rated clamped to 100", 4200, 4000, 100}, + {"smallest positive is 1", 1, 15000, 1}, + // Unusable inputs -> -1. + {"zero measured", 0, 4000, -1}, + {"zero design", 3800, 0, -1}, + {"negative measured", -5, 4000, -1}, + }); + } + + @Test + public void matchesExpected() { + assertEquals(label, expected, BatteryHealthTracker.computeMeasuredHealth(measuredMah, designMah)); + } } - @Test - public void gradeForPercentage_bucketsMatchGrades() { - assertEquals(BatteryHealthGrade.EXCELLENT, BatteryHealthTracker.gradeForPercentage(100)); - assertEquals(BatteryHealthGrade.EXCELLENT, BatteryHealthTracker.gradeForPercentage(90)); - assertEquals(BatteryHealthGrade.GOOD, BatteryHealthTracker.gradeForPercentage(89)); - assertEquals(BatteryHealthGrade.GOOD, BatteryHealthTracker.gradeForPercentage(80)); - assertEquals(BatteryHealthGrade.FAIR, BatteryHealthTracker.gradeForPercentage(79)); - assertEquals(BatteryHealthGrade.FAIR, BatteryHealthTracker.gradeForPercentage(70)); - assertEquals(BatteryHealthGrade.POOR, BatteryHealthTracker.gradeForPercentage(69)); - assertEquals(BatteryHealthGrade.POOR, BatteryHealthTracker.gradeForPercentage(0)); + /** + * {@link BatteryHealthTracker#gradeForPercentage(int)} — bucket boundaries mapping a health + * percentage to a {@link BatteryHealthGrade}. + */ + @RunWith(Parameterized.class) + public static class GradeForPercentage { + + @Parameter(0) public int percentage; + @Parameter(1) public BatteryHealthGrade expected; + + @Parameters(name = "{0}% -> {1}") + public static Collection data() { + return Arrays.asList(new Object[][]{ + {100, BatteryHealthGrade.EXCELLENT}, + {90, BatteryHealthGrade.EXCELLENT}, + {89, BatteryHealthGrade.GOOD}, + {80, BatteryHealthGrade.GOOD}, + {79, BatteryHealthGrade.FAIR}, + {70, BatteryHealthGrade.FAIR}, + {69, BatteryHealthGrade.POOR}, + {0, BatteryHealthGrade.POOR}, + }); + } + + @Test + public void matchesExpected() { + assertEquals(expected, BatteryHealthTracker.gradeForPercentage(percentage)); + } } - @Test - public void isEstimateImplausible_flagsReadingsFarFromDesign() { - // Broken counter (the real #94 case): an 852 mAh estimate against a 4000 mAh design is - // implausibly low, so the measured figure (21%) can't be trusted. - assertTrue(BatteryHealthTracker.isEstimateImplausible(852, 4000)); - // Impossibly high: the estimate is well above the rated capacity. - assertTrue(BatteryHealthTracker.isEstimateImplausible(5000, 4000)); + /** + * {@link BatteryHealthTracker#isValidDesignCapacity(int)} — accepts only capacities inside the + * supported mAh range. + */ + @RunWith(Parameterized.class) + public static class IsValidDesignCapacity { + + @Parameter(0) public String label; + @Parameter(1) public int capacityMah; + @Parameter(2) public boolean expected; + + @Parameters(name = "{0}") + public static Collection data() { + return Arrays.asList(new Object[][]{ + {"min boundary", BatteryHealthTracker.MIN_DESIGN_CAPACITY_MAH, true}, + {"max boundary", BatteryHealthTracker.MAX_DESIGN_CAPACITY_MAH, true}, + {"typical 4000", 4000, true}, + {"below min", BatteryHealthTracker.MIN_DESIGN_CAPACITY_MAH - 1, false}, + {"above max", BatteryHealthTracker.MAX_DESIGN_CAPACITY_MAH + 1, false}, + {"zero", 0, false}, + {"negative", -1, false}, + }); + } + + @Test + public void matchesExpected() { + assertEquals(label, expected, BatteryHealthTracker.isValidDesignCapacity(capacityMah)); + } } - @Test - public void isEstimateImplausible_acceptsPlausibleReadings() { - // Healthy / lightly worn batteries stay within the plausibility window. - assertFalse(BatteryHealthTracker.isEstimateImplausible(4000, 4000)); - assertFalse(BatteryHealthTracker.isEstimateImplausible(3800, 4000)); - // A genuinely worn but believable battery (50% of rated) is still shown as a number, not "unknown". - assertFalse(BatteryHealthTracker.isEstimateImplausible(2000, 4000)); + /** + * {@link BatteryHealthTracker#isEstimateImplausible(int, int)} — flags measured estimates that sit + * too far from the design capacity to be trusted (issue #94). + */ + @RunWith(Parameterized.class) + public static class IsEstimateImplausible { + + @Parameter(0) public String label; + @Parameter(1) public int estimateMah; + @Parameter(2) public int designMah; + @Parameter(3) public boolean expected; + + @Parameters(name = "{0}") + public static Collection data() { + return Arrays.asList(new Object[][]{ + // Far from design -> implausible. An 852 mAh estimate against a 4000 mAh design is + // implausibly low (the real #94 case); 5000 against 4000 is impossibly high. + {"852 of 4000 implausibly low", 852, 4000, true}, + {"5000 of 4000 impossibly high", 5000, 4000, true}, + // Healthy / lightly worn batteries stay within the plausibility window. + {"rated capacity", 4000, 4000, false}, + {"lightly worn 3800", 3800, 4000, false}, + {"genuinely worn 2000 (50%)", 2000, 4000, false}, + // Missing inputs are "unavailable", not "implausible". + {"no estimate (0)", 0, 4000, false}, + {"no design capacity", 852, 0, false}, + {"negative estimate", -5, 4000, false}, + }); + } + + @Test + public void matchesExpected() { + assertEquals(label, expected, BatteryHealthTracker.isEstimateImplausible(estimateMah, designMah)); + } } - @Test - public void isEstimateImplausible_missingInputs_returnsFalse() { - // No estimate available (0) is "unavailable", not "implausible". - assertFalse(BatteryHealthTracker.isEstimateImplausible(0, 4000)); - // No design capacity set: nothing to cross-check against. - assertFalse(BatteryHealthTracker.isEstimateImplausible(852, 0)); - assertFalse(BatteryHealthTracker.isEstimateImplausible(-5, 4000)); + /** + * {@link BatteryHealthTracker#accruePartialCycles(int, int, boolean, int)} carries partial charge + * deltas across calls and only counts a full cycle once 100 percentage-points have accrued while + * charging. Its stateful, two-step behaviour reads clearer as named scenarios than as a data table. + */ + public static class AccruePartialCycles { + + @Test + public void accumulatesPositiveDeltasWhileCharging() { + // +50 (40 -> 90) while charging, no prior carry -> 0 cycles, carry 50 + final BatteryHealthTracker.CycleAccrual first = + BatteryHealthTracker.accruePartialCycles(40, 90, true, 0); + assertEquals(0, first.completedCycles()); + assertEquals(50, first.carryPercentPoints()); + + // Another +50 on top of carry 50 -> exactly one cycle, carry resets to 0 + final BatteryHealthTracker.CycleAccrual second = + BatteryHealthTracker.accruePartialCycles(40, 90, true, 50); + assertEquals(1, second.completedCycles()); + assertEquals(0, second.carryPercentPoints()); + } + + @Test + public void ignoresDischargeFlatNotChargingAndUnknownPrev() { + // Discharging (current < prev): no accrual, carry unchanged + assertEquals(0, BatteryHealthTracker.accruePartialCycles(90, 40, true, 30).completedCycles()); + assertEquals(30, BatteryHealthTracker.accruePartialCycles(90, 40, true, 30).carryPercentPoints()); + // Flat level + assertEquals(30, BatteryHealthTracker.accruePartialCycles(50, 50, true, 30).carryPercentPoints()); + // Not charging, even though the level rose + assertEquals(30, BatteryHealthTracker.accruePartialCycles(40, 90, false, 30).carryPercentPoints()); + // Unknown previous level (-1): first observation only, nothing accrues + assertEquals(30, BatteryHealthTracker.accruePartialCycles(-1, 90, true, 30).carryPercentPoints()); + } } } diff --git a/app/src/test/java/com/almothafar/simplebatterynotifier/service/NotificationServiceTest.java b/app/src/test/java/com/almothafar/simplebatterynotifier/service/NotificationServiceTest.java index e66b316..181613a 100644 --- a/app/src/test/java/com/almothafar/simplebatterynotifier/service/NotificationServiceTest.java +++ b/app/src/test/java/com/almothafar/simplebatterynotifier/service/NotificationServiceTest.java @@ -1,97 +1,98 @@ package com.almothafar.simplebatterynotifier.service; import org.junit.Test; +import org.junit.experimental.runners.Enclosed; +import org.junit.runner.RunWith; +import org.junit.runners.Parameterized; +import org.junit.runners.Parameterized.Parameter; +import org.junit.runners.Parameterized.Parameters; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertTrue; +import java.util.Arrays; +import java.util.Collection; + +import static org.junit.Assert.assertEquals; /** - * Unit tests for the pure quiet-hours range logic in {@link NotificationService}. + * Unit tests for the pure quiet-hours logic in {@link NotificationService}. * Times are expressed as minutes since midnight (hour * 60 + minute). */ +@RunWith(Enclosed.class) public class NotificationServiceTest { - // Daytime window 08:00 (480) – 23:00 (1380) - private static final int DAY_START = 8 * 60; - private static final int DAY_END = 23 * 60; - - // Overnight window 22:00 (1320) – 06:00 (360) - private static final int NIGHT_START = 22 * 60; - private static final int NIGHT_END = 6 * 60; - - @Test - public void daytime_inside() { - assertTrue(NotificationService.isWithinTimeRange(10 * 60, DAY_START, DAY_END)); - } - - @Test - public void daytime_beforeStart_excluded() { - assertFalse(NotificationService.isWithinTimeRange(6 * 60 + 40, DAY_START, DAY_END)); - } - - @Test - public void daytime_startIsInclusive() { - assertTrue(NotificationService.isWithinTimeRange(DAY_START, DAY_START, DAY_END)); - } - - @Test - public void daytime_endIsExclusive() { - assertFalse(NotificationService.isWithinTimeRange(DAY_END, DAY_START, DAY_END)); - } - - @Test - public void overnight_lateNight_inside() { - assertTrue(NotificationService.isWithinTimeRange(22 * 60 + 30, NIGHT_START, NIGHT_END)); - } - - @Test - public void overnight_earlyMorning_inside() { - assertTrue(NotificationService.isWithinTimeRange(2 * 60, NIGHT_START, NIGHT_END)); - } - - @Test - public void overnight_midday_outside() { - assertFalse(NotificationService.isWithinTimeRange(12 * 60, NIGHT_START, NIGHT_END)); - } - /** - * Overnight window whose start/end share the same hour bucket, e.g. 22:30 → 22:00. - * The old hour-only logic mishandled this; the minute-based logic must not. + * {@link NotificationService#isWithinTimeRange(int, int, int)} across daytime, overnight-wrap and + * degenerate windows. The label documents each row's intent. */ - @Test - public void overnight_sameHourBucket() { - final int start = 22 * 60 + 30; // 22:30 - final int end = 22 * 60; // 22:00 - assertTrue(NotificationService.isWithinTimeRange(23 * 60 + 30, start, end)); // 23:30 inside - assertFalse(NotificationService.isWithinTimeRange(22 * 60 + 15, start, end)); // 22:15 outside - } - - @Test - public void equalTimes_meansWholeDay() { - assertTrue(NotificationService.isWithinTimeRange(0, 10 * 60, 10 * 60)); - assertTrue(NotificationService.isWithinTimeRange(23 * 60 + 59, 10 * 60, 10 * 60)); - } - - // --- alertsAllowedNow: quiet-hours gating with the critical override (issue #111) --- - - @Test - public void insideWindow_alwaysAllowed() { - assertTrue(NotificationService.alertsAllowedNow(true, false, false)); - assertTrue(NotificationService.alertsAllowedNow(true, true, false)); - } - - @Test - public void outsideWindow_nonCritical_silenced() { - assertFalse(NotificationService.alertsAllowedNow(false, false, true)); + @RunWith(Parameterized.class) + public static class TimeRange { + + // Daytime window 08:00 (480) – 23:00 (1380) + private static final int DAY_START = 8 * 60; + private static final int DAY_END = 23 * 60; + + // Overnight window 22:00 (1320) – 06:00 (360) + private static final int NIGHT_START = 22 * 60; + private static final int NIGHT_END = 6 * 60; + + @Parameter(0) public String label; + @Parameter(1) public int now; + @Parameter(2) public int start; + @Parameter(3) public int end; + @Parameter(4) public boolean expected; + + @Parameters(name = "{0}") + public static Collection data() { + return Arrays.asList(new Object[][]{ + {"daytime inside", 10 * 60, DAY_START, DAY_END, true}, + {"daytime before start excluded", 6 * 60 + 40, DAY_START, DAY_END, false}, + {"daytime start is inclusive", DAY_START, DAY_START, DAY_END, true}, + {"daytime end is exclusive", DAY_END, DAY_START, DAY_END, false}, + {"overnight late night inside", 22 * 60 + 30, NIGHT_START, NIGHT_END, true}, + {"overnight early morning inside", 2 * 60, NIGHT_START, NIGHT_END, true}, + {"overnight midday outside", 12 * 60, NIGHT_START, NIGHT_END, false}, + // Overnight window whose start/end share the same hour bucket (22:30 -> 22:00). The old + // hour-only logic mishandled this; the minute-based logic must not. + {"same-hour-bucket 23:30 inside", 23 * 60 + 30, 22 * 60 + 30, 22 * 60, true}, + {"same-hour-bucket 22:15 outside", 22 * 60 + 15, 22 * 60 + 30, 22 * 60, false}, + // start == end means the whole day is covered. + {"equal times means whole day (00:00)", 0, 10 * 60, 10 * 60, true}, + {"equal times means whole day (23:59)", 23 * 60 + 59, 10 * 60, 10 * 60, true}, + }); + } + + @Test + public void matchesExpected() { + assertEquals(label, expected, NotificationService.isWithinTimeRange(now, start, end)); + } } - @Test - public void outsideWindow_critical_breaksThroughWhenEnabled() { - assertTrue(NotificationService.alertsAllowedNow(false, true, true)); - } - - @Test - public void outsideWindow_critical_silencedWhenOverrideOff() { - assertFalse(NotificationService.alertsAllowedNow(false, true, false)); + /** + * {@link NotificationService#alertsAllowedNow(boolean, boolean, boolean)} — quiet-hours gating with + * the critical override (issue #111). + */ + @RunWith(Parameterized.class) + public static class AlertsAllowedNow { + + @Parameter(0) public boolean withinWindow; + @Parameter(1) public boolean isCritical; + @Parameter(2) public boolean criticalIgnoresQuietHours; + @Parameter(3) public boolean expected; + + @Parameters(name = "within={0} critical={1} override={2} -> {3}") + public static Collection data() { + return Arrays.asList(new Object[][]{ + {true, false, false, true}, // inside window -> always allowed + {true, true, false, true}, // inside window -> allowed even without the override + {false, false, true, false}, // outside, non-critical -> silenced + {false, true, true, true}, // outside, critical breaks through when enabled + {false, true, false, false}, // outside, critical silenced when override off + }); + } + + @Test + public void matchesExpected() { + assertEquals(expected, + NotificationService.alertsAllowedNow(withinWindow, isCritical, criticalIgnoresQuietHours)); + } } } diff --git a/app/src/test/java/com/almothafar/simplebatterynotifier/service/SystemServiceTest.java b/app/src/test/java/com/almothafar/simplebatterynotifier/service/SystemServiceTest.java index 4f84bcc..e32398e 100644 --- a/app/src/test/java/com/almothafar/simplebatterynotifier/service/SystemServiceTest.java +++ b/app/src/test/java/com/almothafar/simplebatterynotifier/service/SystemServiceTest.java @@ -1,78 +1,102 @@ package com.almothafar.simplebatterynotifier.service; import org.junit.Test; +import org.junit.experimental.runners.Enclosed; +import org.junit.runner.RunWith; +import org.junit.runners.Parameterized; +import org.junit.runners.Parameterized.Parameter; +import org.junit.runners.Parameterized.Parameters; + +import java.util.Arrays; +import java.util.Collection; import static org.junit.Assert.assertEquals; /** - * Unit tests for the pure capacity-estimation helper in {@link SystemService}. + * Unit tests for the pure capacity-estimation helpers in {@link SystemService}. */ +@RunWith(Enclosed.class) public class SystemServiceTest { - @Test - public void estimateFullCapacity_typicalReadings() { - // 2,000,000 µAh remaining at 50% -> 4000 mAh full - assertEquals(4000, SystemService.estimateFullCapacityMah(2_000_000, 50)); - // 3,000,000 µAh at 75% -> 4000 mAh - assertEquals(4000, SystemService.estimateFullCapacityMah(3_000_000, 75)); - // 4,000,000 µAh at 100% -> 4000 mAh - assertEquals(4000, SystemService.estimateFullCapacityMah(4_000_000, 100)); - } + /** + * {@link SystemService#estimateFullCapacityMah(int, int)} — derives full capacity (mAh) from a + * CHARGE_COUNTER reading (µAh) at a given charge level, rejecting implausible results as 0. + */ + @RunWith(Parameterized.class) + public static class EstimateFullCapacity { - @Test - public void estimateFullCapacity_unsupportedOrInvalid_returnsZero() { - // getIntProperty returns Integer.MIN_VALUE for unsupported properties - assertEquals(0, SystemService.estimateFullCapacityMah(Integer.MIN_VALUE, 50)); - assertEquals(0, SystemService.estimateFullCapacityMah(0, 50)); - assertEquals(0, SystemService.estimateFullCapacityMah(2_000_000, 0)); - assertEquals(0, SystemService.estimateFullCapacityMah(2_000_000, -1)); - assertEquals(0, SystemService.estimateFullCapacityMah(2_000_000, 101)); - } + @Parameter(0) public String label; + @Parameter(1) public int chargeCounterMicroAmpHours; + @Parameter(2) public int level; + @Parameter(3) public int expectedMah; - @Test - public void estimateFullCapacity_implausibleResult_returnsZero() { - // Kirin/HiSilicon device reporting CHARGE_COUNTER in the wrong unit: raw 9000 at 100% -> 9 mAh, - // far below any real battery, so it must be rejected as "unknown" (issue #69). - assertEquals(0, SystemService.estimateFullCapacityMah(9000, 100)); - assertEquals(0, SystemService.estimateFullCapacityMah(2000, 50)); // -> 4 mAh - // Absurdly large (wrong unit the other way) is rejected too: 200,000,000 µAh at 100% -> 200000 mAh - assertEquals(0, SystemService.estimateFullCapacityMah(200_000_000, 100)); - } + @Parameters(name = "{0}") + public static Collection data() { + return Arrays.asList(new Object[][]{ + // Typical readings: 2,000,000 µAh at 50% / 3,000,000 at 75% / 4,000,000 at 100% -> 4000 mAh. + {"typical 50%", 2_000_000, 50, 4000}, + {"typical 75%", 3_000_000, 75, 4000}, + {"typical 100%", 4_000_000, 100, 4000}, + // Unsupported / invalid inputs -> 0. getIntProperty returns Integer.MIN_VALUE when unsupported. + {"unsupported property", Integer.MIN_VALUE, 50, 0}, + {"zero counter", 0, 50, 0}, + {"zero level", 2_000_000, 0, 0}, + {"negative level", 2_000_000, -1, 0}, + {"level over 100", 2_000_000, 101, 0}, + // Implausible results are rejected (issue #69). Kirin/HiSilicon reports the wrong unit: + // raw 9000 at 100% -> 9 mAh, far below any real battery. + {"implausibly low (wrong unit)", 9000, 100, 0}, + {"implausibly low 4 mAh", 2000, 50, 0}, + // Absurdly large (wrong unit the other way): 200,000,000 µAh at 100% -> 200000 mAh. + {"implausibly high (wrong unit)", 200_000_000, 100, 0}, + // Plausible boundaries are kept: a small-but-real 500 mAh and a large 15000 mAh. + {"lower plausible boundary", 500_000, 100, 500}, + {"upper plausible boundary", 15_000_000, 100, 15000}, + }); + } - @Test - public void estimateFullCapacity_plausibleBoundaries_areKept() { - // A small-but-real 500 mAh battery (lower bound) and a large 15000 mAh (upper bound) are kept. - assertEquals(500, SystemService.estimateFullCapacityMah(500_000, 100)); - assertEquals(15000, SystemService.estimateFullCapacityMah(15_000_000, 100)); + @Test + public void matchesExpected() { + assertEquals(label, expectedMah, + SystemService.estimateFullCapacityMah(chargeCounterMicroAmpHours, level)); + } } - @Test - public void designCapacityFromMicroAmpHours_typicalReadings() { - // charge_full_design is reported in µAh: 4,700,000 µAh -> 4700 mAh - assertEquals(4700, SystemService.designCapacityMahFromMicroAmpHours("4700000")); - // Surrounding whitespace / trailing newline from the sysfs node is tolerated - assertEquals(4000, SystemService.designCapacityMahFromMicroAmpHours(" 4000000\n")); - } + /** + * {@link SystemService#designCapacityMahFromMicroAmpHours(String)} — parses the sysfs + * charge_full_design node (µAh, possibly with surrounding whitespace) into mAh, rejecting junk and + * implausible values. + */ + @RunWith(Parameterized.class) + public static class DesignCapacityFromMicroAmpHours { - @Test - public void designCapacityFromMicroAmpHours_missingOrMalformed_returnsZero() { - assertEquals(0, SystemService.designCapacityMahFromMicroAmpHours(null)); - assertEquals(0, SystemService.designCapacityMahFromMicroAmpHours("")); - assertEquals(0, SystemService.designCapacityMahFromMicroAmpHours("N/A")); - assertEquals(0, SystemService.designCapacityMahFromMicroAmpHours("-4700000")); - } + @Parameter(0) public String label; + @Parameter(1) public String raw; + @Parameter(2) public int expectedMah; - @Test - public void designCapacityFromMicroAmpHours_implausibleResult_returnsZero() { - // A value already in mAh (non-standard) divides down to 4 mAh -> rejected as implausible. - assertEquals(0, SystemService.designCapacityMahFromMicroAmpHours("4700")); - // Absurdly large (wrong unit the other way): 200,000,000 µAh -> 200000 mAh -> rejected. - assertEquals(0, SystemService.designCapacityMahFromMicroAmpHours("200000000")); - } + @Parameters(name = "{0}") + public static Collection data() { + return Arrays.asList(new Object[][]{ + // Typical: 4,700,000 µAh -> 4700 mAh; surrounding whitespace / trailing newline is tolerated. + {"typical 4700000", "4700000", 4700}, + {"whitespace tolerated", " 4000000\n", 4000}, + // Missing / malformed -> 0. + {"null", null, 0}, + {"empty", "", 0}, + {"non-numeric", "N/A", 0}, + {"negative", "-4700000", 0}, + // Implausible -> 0. A value already in mAh divides down to 4 mAh; absurdly large is rejected too. + {"implausibly low (already mAh)", "4700", 0}, + {"implausibly high", "200000000", 0}, + // Plausible boundaries kept. + {"lower plausible boundary", "500000", 500}, + {"upper plausible boundary", "15000000", 15000}, + }); + } - @Test - public void designCapacityFromMicroAmpHours_plausibleBoundaries_areKept() { - assertEquals(500, SystemService.designCapacityMahFromMicroAmpHours("500000")); - assertEquals(15000, SystemService.designCapacityMahFromMicroAmpHours("15000000")); + @Test + public void matchesExpected() { + assertEquals(label, expectedMah, SystemService.designCapacityMahFromMicroAmpHours(raw)); + } } } diff --git a/app/src/test/java/com/almothafar/simplebatterynotifier/util/TemperatureUtilsTest.java b/app/src/test/java/com/almothafar/simplebatterynotifier/util/TemperatureUtilsTest.java index a226f91..725d1e9 100644 --- a/app/src/test/java/com/almothafar/simplebatterynotifier/util/TemperatureUtilsTest.java +++ b/app/src/test/java/com/almothafar/simplebatterynotifier/util/TemperatureUtilsTest.java @@ -1,76 +1,135 @@ package com.almothafar.simplebatterynotifier.util; import org.junit.Test; +import org.junit.experimental.runners.Enclosed; +import org.junit.runner.RunWith; +import org.junit.runners.Parameterized; +import org.junit.runners.Parameterized.Parameter; +import org.junit.runners.Parameterized.Parameters; + +import java.util.Arrays; +import java.util.Collection; import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertTrue; /** * Unit tests for the pure (context-free) parts of {@link TemperatureUtils}. */ +@RunWith(Enclosed.class) public class TemperatureUtilsTest { - @Test - public void celsiusToFahrenheit_knownValues() { - assertEquals(32, TemperatureUtils.celsiusToFahrenheit(0)); - assertEquals(104, TemperatureUtils.celsiusToFahrenheit(40)); - assertEquals(113, TemperatureUtils.celsiusToFahrenheit(45)); - assertEquals(140, TemperatureUtils.celsiusToFahrenheit(60)); - assertEquals(212, TemperatureUtils.celsiusToFahrenheit(100)); - } + /** + * {@link TemperatureUtils#celsiusToFahrenheit(int)} and its inverse + * {@link TemperatureUtils#fahrenheitToCelsius(int)} across known integer values. + */ + @RunWith(Parameterized.class) + public static class IntConversions { - @Test - public void celsiusToFahrenheit_float_roundsToNearestTenth() { - assertEquals(89.6f, TemperatureUtils.celsiusToFahrenheit(32.0f), 0.001f); - assertEquals(98.6f, TemperatureUtils.celsiusToFahrenheit(37.0f), 0.001f); - // 20.02 °C -> 68.036 °F: rounds DOWN to 68.0 (the old Math.ceil path returned 68.1). - assertEquals(68.0f, TemperatureUtils.celsiusToFahrenheit(20.02f), 0.001f); - } + @Parameter(0) public int celsius; + @Parameter(1) public int fahrenheit; - @Test - public void fahrenheitToCelsius_knownValues() { - assertEquals(0, TemperatureUtils.fahrenheitToCelsius(32)); - assertEquals(40, TemperatureUtils.fahrenheitToCelsius(104)); - assertEquals(45, TemperatureUtils.fahrenheitToCelsius(113)); - assertEquals(60, TemperatureUtils.fahrenheitToCelsius(140)); - assertEquals(100, TemperatureUtils.fahrenheitToCelsius(212)); - } + @Parameters(name = "{0}C = {1}F") + public static Collection data() { + return Arrays.asList(new Object[][]{ + {0, 32}, + {40, 104}, + {45, 113}, + {60, 140}, + {100, 212}, + }); + } - /** The canonical threshold values must survive a round-trip through Fahrenheit display. */ - @Test - public void roundTrip_isStableForThresholdRange() { - for (int c = TemperatureUtils.MIN_HIGH_TEMP_THRESHOLD_C; c <= TemperatureUtils.MAX_HIGH_TEMP_THRESHOLD_C; c++) { - final int back = TemperatureUtils.fahrenheitToCelsius(TemperatureUtils.celsiusToFahrenheit(c)); - assertEquals("°C should round-trip via °F for " + c, c, back); + @Test + public void celsiusToFahrenheit() { + assertEquals(fahrenheit, TemperatureUtils.celsiusToFahrenheit(celsius)); + } + + @Test + public void fahrenheitToCelsius() { + assertEquals(celsius, TemperatureUtils.fahrenheitToCelsius(fahrenheit)); } } - @Test - public void isAtOrAboveThreshold_comparesInCelsius() { - // 45.0 °C reading vs 45 °C threshold -> trips - assertTrue(TemperatureUtils.isAtOrAboveThreshold(450, 45)); - // 60.0 °C -> trips - assertTrue(TemperatureUtils.isAtOrAboveThreshold(600, 45)); - // 44.9 °C -> does not trip - assertFalse(TemperatureUtils.isAtOrAboveThreshold(449, 45)); + /** + * {@link TemperatureUtils#isAtOrAboveThreshold(int, int)} — reading (tenths of °C) vs threshold + * (°C), always compared in Celsius. + */ + @RunWith(Parameterized.class) + public static class AtOrAboveThreshold { + + @Parameter(0) public String label; + @Parameter(1) public int readingTenthsC; + @Parameter(2) public int thresholdC; + @Parameter(3) public boolean expected; + + @Parameters(name = "{0}") + public static Collection data() { + return Arrays.asList(new Object[][]{ + {"45.0C at 45 trips", 450, 45, true}, + {"60.0C at 45 trips", 600, 45, true}, + {"44.9C at 45 does not trip", 449, 45, false}, + // The bug this guards: a chilly 45°F (~7.2°C = 72 tenths) must NOT trip a 45°C threshold. + {"chilly 45F (7.2C) does not trip", 72, 45, false}, + }); + } + + @Test + public void matchesExpected() { + assertEquals(label, expected, TemperatureUtils.isAtOrAboveThreshold(readingTenthsC, thresholdC)); + } } /** - * The bug this guards against: a chilly 45 °F (~7.2 °C, i.e. 72 tenths) must NOT trip a - * 45 °C threshold. Temperature is always compared in Celsius. + * {@link TemperatureUtils#isBelowResetThreshold(int, int, int)} — hysteresis re-arm: true once the + * reading (tenths of °C) drops to/below threshold − hysteresis. */ - @Test - public void isAtOrAboveThreshold_chilly45Fahrenheit_doesNotTrip() { - final int sevenPointTwoCelsiusInTenths = 72; // == 45 °F - assertFalse(TemperatureUtils.isAtOrAboveThreshold(sevenPointTwoCelsiusInTenths, 45)); + @RunWith(Parameterized.class) + public static class BelowResetThreshold { + + @Parameter(0) public String label; + @Parameter(1) public int readingTenthsC; + @Parameter(2) public int thresholdC; + @Parameter(3) public int hysteresisC; + @Parameter(4) public boolean expected; + + @Parameters(name = "{0}") + public static Collection data() { + // threshold 45°C, hysteresis 3°C -> re-arm at/below 42.0°C (420 tenths). + return Arrays.asList(new Object[][]{ + {"42.0C re-arms", 420, 45, 3, true}, + {"30.0C re-arms", 300, 45, 3, true}, + {"43.0C still too warm", 430, 45, 3, false}, + }); + } + + @Test + public void matchesExpected() { + assertEquals(label, expected, + TemperatureUtils.isBelowResetThreshold(readingTenthsC, thresholdC, hysteresisC)); + } } - @Test - public void isBelowResetThreshold_appliesHysteresis() { - // threshold 45 °C, hysteresis 3 °C -> re-arm at/below 42.0 °C (420 tenths) - assertTrue(TemperatureUtils.isBelowResetThreshold(420, 45, 3)); - assertTrue(TemperatureUtils.isBelowResetThreshold(300, 45, 3)); - assertFalse(TemperatureUtils.isBelowResetThreshold(430, 45, 3)); // 43.0 °C still too warm + /** + * Float rounding and the °C↔°F round-trip stay as named tests — the behaviour, not a data row, is + * the point. + */ + public static class FloatAndRoundTrip { + + @Test + public void celsiusToFahrenheit_float_roundsToNearestTenth() { + assertEquals(89.6f, TemperatureUtils.celsiusToFahrenheit(32.0f), 0.001f); + assertEquals(98.6f, TemperatureUtils.celsiusToFahrenheit(37.0f), 0.001f); + // 20.02 °C -> 68.036 °F: rounds DOWN to 68.0 (the old Math.ceil path returned 68.1). + assertEquals(68.0f, TemperatureUtils.celsiusToFahrenheit(20.02f), 0.001f); + } + + /** The canonical threshold values must survive a round-trip through Fahrenheit display. */ + @Test + public void roundTrip_isStableForThresholdRange() { + for (int c = TemperatureUtils.MIN_HIGH_TEMP_THRESHOLD_C; c <= TemperatureUtils.MAX_HIGH_TEMP_THRESHOLD_C; c++) { + final int back = TemperatureUtils.fahrenheitToCelsius(TemperatureUtils.celsiusToFahrenheit(c)); + assertEquals("°C should round-trip via °F for " + c, c, back); + } + } } }