diff --git a/README.md b/README.md index af5c7f6c..cccddf01 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ as well as the logic to recommend the cheapest price plan for a particular house >Unfortunately, as the codebase has evolved, it has gathered tech debt in the form of a number of code smells and some questionable design decisions. Our goal for the upcoming exercise would be to deliver value by implementing a new -feature using _Test Driven Development_ (TDD), while refactoring away the code smells we see. +feature using _[Test Driven Development](https://martinfowler.com/bliki/TestDrivenDevelopment.html)_ (TDD), while refactoring away the code smells we see. > >In preparation for this, please take some time to go through the code and identify any improvements, big or small, that would improve its maintainability, testability, and design. @@ -46,7 +46,9 @@ The project requires [Java 21](https://adoptium.net/) or higher. ## Useful commands -Compile the project, run the tests and creates an executable JAR file +### Build the project + +Compile the project, run the tests and creates an executable JAR file: ```console ./gradlew build @@ -67,11 +69,10 @@ You can run it with the following command: ## API Documentation -The codebase contains two service classes, _MeterReadingManager_ and _PricePlanComparator_, that serve as entry points to -the implemented features. +The codebase contains two service classes, `MeterReadingManager` and `PricePlanComparator` that serve as entry points to the implemented features. ### MeterReadingManager -Provides methods to store and fetch the energy consumption readings from a given Smart Meter +Provides methods to store and fetch the energy consumption readings from a given Smart Meter. > #### _public void_ storeReadings(_String smartMeterId, List electricityReadings_) Stores the provided _ElectricityReading_ collection in the indicated _SmartMeter_. If no @@ -91,13 +92,13 @@ An _ElectricityReading_ record consists of the following fields: Example readings -| Date (`GMT`) | Epoch timestamp | Reading (kWh) | -|-------------------|----------------:|--------------:| -| `2020-11-29 8:00` | 1606636800 | 600.05 | -| `2020-11-29 9:00` | 1606640400 | 602.06 | -| `2020-11-30 7:30` | 1606721400 | 610.09 | -| `2020-12-01 8:30` | 1606811400 | 627.12 | -| `2020-12-02 8:30` | 1606897800 | 635.14 | +| Date (`GMT`) | Epoch timestamp (seconds) | Reading (kWh) | +|-------------------|--------------------------:|--------------:| +| `2020-11-29 8:00` | 1606636800 | 600.05 | +| `2020-11-29 9:00` | 1606640400 | 602.06 | +| `2020-11-30 7:30` | 1606721400 | 610.09 | +| `2020-12-01 8:30` | 1606811400 | 627.12 | +| `2020-12-02 8:30` | 1606897800 | 635.14 | Thee above table shows some readings sampled by a smart meter over multiple days. Note that since the smart meter is reporting the total energy consumed up to that point in time, a reading's value will always be higher or the same as diff --git a/build.gradle.kts b/build.gradle.kts index 7c73ae47..15b89e8e 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -29,7 +29,7 @@ application { mainClass = "tw.joi.energy.App" } -tasks.withType() { +tasks.withType { this.options.isDeprecation = true } diff --git a/src/main/java/tw/joi/energy/App.java b/src/main/java/tw/joi/energy/App.java index cc89b947..237ca9d2 100644 --- a/src/main/java/tw/joi/energy/App.java +++ b/src/main/java/tw/joi/energy/App.java @@ -25,7 +25,8 @@ public static void main(String[] args) { printAllAvailablePricePlans(pricePlanRepository); printSmartMeterInformation(smartMeterRepository, "Before storing readings..."); - var readingsToSave = ElectricityReadingsGenerator.generate(3); + var readingsToSave = + ElectricityReadingsGenerator.generateElectricityReadingStream(3).toList(); meterReadingManager.storeReadings(TEST_SMART_METER, readingsToSave); printSmartMeterInformation(smartMeterRepository, "After storing readings..."); diff --git a/src/main/java/tw/joi/energy/config/ElectricityReadingsGenerator.java b/src/main/java/tw/joi/energy/config/ElectricityReadingsGenerator.java index d1ffae84..2bc0d421 100644 --- a/src/main/java/tw/joi/energy/config/ElectricityReadingsGenerator.java +++ b/src/main/java/tw/joi/energy/config/ElectricityReadingsGenerator.java @@ -1,35 +1,41 @@ package tw.joi.energy.config; import java.math.BigDecimal; -import java.math.RoundingMode; -import java.time.Instant; -import java.util.ArrayList; -import java.util.Comparator; -import java.util.List; +import java.time.Clock; +import java.time.temporal.ChronoUnit; import java.util.Random; +import java.util.stream.Stream; import tw.joi.energy.domain.ElectricityReading; public class ElectricityReadingsGenerator { - public static List generate(int number) { - List readings = new ArrayList<>(); - Instant now = Instant.now(); - BigDecimal previousReading = BigDecimal.ONE; - Instant previousReadingTime = now.minusSeconds(2 * number * 60L); + public static final double AVG_HOURLY_USAGE = 0.3; + public static final double VARIANCE = 0.2; + public static final double MIN_HOURLY_USAGE = AVG_HOURLY_USAGE - VARIANCE; + public static final double MAX_HOURLY_USAGE = AVG_HOURLY_USAGE + VARIANCE; - Random readingRandomiser = new Random(); + private ElectricityReadingsGenerator() {} - for (int i = 0; i < number; i++) { - double positiveIncrement = Math.abs(readingRandomiser.nextGaussian()); - BigDecimal currentReading = - previousReading.add(BigDecimal.valueOf(positiveIncrement)).setScale(4, RoundingMode.CEILING); - ElectricityReading electricityReading = - new ElectricityReading(previousReadingTime.plusSeconds(i * 60L), currentReading); - readings.add(electricityReading); - previousReading = currentReading; - } + public static Stream generateElectricityReadingStream(int days) { + return generateElectricityReadingStream(Clock.systemDefaultZone(), BigDecimal.ZERO, days); + } + + // we'll provide hourly readings for the specified number of days assuming 24 hours a day + // we'll assume that a house consumes ca 2700 kWh a year, so about 0.3 kWh per hour - readings.sort(Comparator.comparing(ElectricityReading::time)); - return readings; + // the assumed starting point is the time on the clock, the ending point 24 hours later - so for 1 day, we'll get 25 + // readings + public static Stream generateElectricityReadingStream( + Clock clock, BigDecimal initialReading, int days) { + var now = clock.instant(); + var readingRandomiser = new Random(); + var seed = new ElectricityReading(now, initialReading); + var lastTimeToBeSupplied = now.plus(days * 24L, ChronoUnit.HOURS); + return Stream.iterate(seed, er -> !er.time().isAfter(lastTimeToBeSupplied), er -> { + var hoursWorthOfEnergy = + BigDecimal.valueOf(readingRandomiser.nextDouble(MIN_HOURLY_USAGE, MAX_HOURLY_USAGE)); + return new ElectricityReading( + er.time().plus(1, ChronoUnit.HOURS), er.readingInKwH().add(hoursWorthOfEnergy)); + }); } } diff --git a/src/main/java/tw/joi/energy/config/TestData.java b/src/main/java/tw/joi/energy/config/TestData.java index fd70503b..2add51da 100644 --- a/src/main/java/tw/joi/energy/config/TestData.java +++ b/src/main/java/tw/joi/energy/config/TestData.java @@ -12,23 +12,39 @@ public final class TestData { private static final PricePlan MOST_EVIL_PRICE_PLAN = - new PricePlan("price-plan-0", "Dr Evil's Dark Energy", BigDecimal.TEN, emptyList()); + new PricePlan("price-plan-0", "Dr Evil's Dark Energy", BigDecimal.TEN); private static final PricePlan RENEWABLES_PRICE_PLAN = - new PricePlan("price-plan-1", "The Green Eco", BigDecimal.valueOf(2), null); + new PricePlan("price-plan-1", "The Green Eco", BigDecimal.valueOf(2)); private static final PricePlan STANDARD_PRICE_PLAN = - new PricePlan("price-plan-2", "Power for Everyone", BigDecimal.ONE, emptyList()); + new PricePlan("price-plan-2", "Power for Everyone", BigDecimal.ONE); public static SmartMeterRepository smartMeterRepository() { var smartMeterRepository = new SmartMeterRepository(); smartMeterRepository.save("smart-meter-0", new SmartMeter(MOST_EVIL_PRICE_PLAN, emptyList())); smartMeterRepository.save( - "smart-meter-1", new SmartMeter(RENEWABLES_PRICE_PLAN, ElectricityReadingsGenerator.generate(7))); + "smart-meter-1", + new SmartMeter( + RENEWABLES_PRICE_PLAN, + ElectricityReadingsGenerator.generateElectricityReadingStream(7) + .toList())); smartMeterRepository.save( - "smart-meter-2", new SmartMeter(MOST_EVIL_PRICE_PLAN, ElectricityReadingsGenerator.generate(20))); + "smart-meter-2", + new SmartMeter( + MOST_EVIL_PRICE_PLAN, + ElectricityReadingsGenerator.generateElectricityReadingStream(20) + .toList())); smartMeterRepository.save( - "smart-meter-3", new SmartMeter(STANDARD_PRICE_PLAN, ElectricityReadingsGenerator.generate(12))); + "smart-meter-3", + new SmartMeter( + STANDARD_PRICE_PLAN, + ElectricityReadingsGenerator.generateElectricityReadingStream(12) + .toList())); smartMeterRepository.save( - "smart-meter-4", new SmartMeter(RENEWABLES_PRICE_PLAN, ElectricityReadingsGenerator.generate(3))); + "smart-meter-4", + new SmartMeter( + RENEWABLES_PRICE_PLAN, + ElectricityReadingsGenerator.generateElectricityReadingStream(3) + .toList())); return smartMeterRepository; } diff --git a/src/main/java/tw/joi/energy/domain/ElectricityReading.java b/src/main/java/tw/joi/energy/domain/ElectricityReading.java index 34ff26b5..79aa05ec 100644 --- a/src/main/java/tw/joi/energy/domain/ElectricityReading.java +++ b/src/main/java/tw/joi/energy/domain/ElectricityReading.java @@ -1,9 +1,16 @@ package tw.joi.energy.domain; import java.math.BigDecimal; +import java.time.Clock; import java.time.Instant; /** - * @param reading kWh + * @param time point in time + * @param readingInKwH energy consumed in total to this point in time in kWh */ -public record ElectricityReading(Instant time, BigDecimal reading) {} +public record ElectricityReading(Instant time, BigDecimal readingInKwH) { + + public ElectricityReading(Clock clock, double readingInKwH) { + this(clock.instant(), BigDecimal.valueOf(readingInKwH)); + } +} diff --git a/src/main/java/tw/joi/energy/domain/PeakTimeMultiplier.java b/src/main/java/tw/joi/energy/domain/PeakTimeMultiplier.java new file mode 100644 index 00000000..00476492 --- /dev/null +++ b/src/main/java/tw/joi/energy/domain/PeakTimeMultiplier.java @@ -0,0 +1,6 @@ +package tw.joi.energy.domain; + +import java.math.BigDecimal; +import java.time.DayOfWeek; + +public record PeakTimeMultiplier(DayOfWeek dayOfWeek, BigDecimal multiplier) {} diff --git a/src/main/java/tw/joi/energy/domain/PricePlan.java b/src/main/java/tw/joi/energy/domain/PricePlan.java index e195cdfc..c2a22275 100644 --- a/src/main/java/tw/joi/energy/domain/PricePlan.java +++ b/src/main/java/tw/joi/energy/domain/PricePlan.java @@ -1,48 +1,50 @@ package tw.joi.energy.domain; -import java.io.*; import java.math.BigDecimal; -import java.text.*; import java.time.DayOfWeek; -import java.time.LocalDateTime; -import java.util.List; +import java.time.ZonedDateTime; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; public class PricePlan { - private String energySupplier; - private String planName; + private final String energySupplier; + private final String planName; private final BigDecimal unitRate; // unit price per kWh - private final List peakTimeMultipliers; + private final Map peakTimeMultipliers; + + public PricePlan(String planName, String energySupplier, BigDecimal unitRate) { + this.planName = planName; + this.energySupplier = energySupplier; + this.unitRate = unitRate; + this.peakTimeMultipliers = Collections.emptyMap(); + } public PricePlan( - String planName, String energySupplier, BigDecimal unitRate, List peakTimeMultipliers) { + String planName, + String energySupplier, + BigDecimal unitRate, + Map peakTimeMultipliers) { this.planName = planName; this.energySupplier = energySupplier; this.unitRate = unitRate; - this.peakTimeMultipliers = peakTimeMultipliers; + this.peakTimeMultipliers = Collections.unmodifiableMap(new HashMap<>(peakTimeMultipliers)); } public String getEnergySupplier() { return energySupplier; } - public void setEnergySupplier(String supplierName) { - this.energySupplier = supplierName; - } - public String getPlanName() { return planName; } - public void setPlanName(String name) { - this.planName = name; - } - public BigDecimal getUnitRate() { return unitRate; } - public BigDecimal getPrice(LocalDateTime dateTime) { + public BigDecimal getPrice(ZonedDateTime dateTime) { return unitRate; } @@ -50,15 +52,4 @@ public BigDecimal getPrice(LocalDateTime dateTime) { public String toString() { return "Name: '" + planName + "', Unit Rate: " + unitRate + ", Supplier: '" + energySupplier + "'"; } - - static class PeakTimeMultiplier { - - DayOfWeek dayOfWeek; - BigDecimal multiplier; - - public PeakTimeMultiplier(DayOfWeek dayOfWeek, BigDecimal multiplier) { - this.dayOfWeek = dayOfWeek; - this.multiplier = multiplier; - } - } } diff --git a/src/main/java/tw/joi/energy/domain/SmartMeter.java b/src/main/java/tw/joi/energy/domain/SmartMeter.java index 4a7966a5..ddad3373 100644 --- a/src/main/java/tw/joi/energy/domain/SmartMeter.java +++ b/src/main/java/tw/joi/energy/domain/SmartMeter.java @@ -8,6 +8,7 @@ import java.util.stream.Collectors; public class SmartMeter { + private final PricePlan pricePlan; private final List electricityReadings; diff --git a/src/main/java/tw/joi/energy/repository/PricePlanRepository.java b/src/main/java/tw/joi/energy/repository/PricePlanRepository.java index db256c53..23ccac3b 100644 --- a/src/main/java/tw/joi/energy/repository/PricePlanRepository.java +++ b/src/main/java/tw/joi/energy/repository/PricePlanRepository.java @@ -1,14 +1,13 @@ package tw.joi.energy.repository; -import static java.util.Comparator.*; -import static java.util.stream.Collectors.*; +import static java.util.Comparator.comparing; +import static java.util.stream.Collectors.toMap; import java.math.BigDecimal; -import java.time.LocalDateTime; +import java.time.ZonedDateTime; import java.util.Collection; import java.util.List; import java.util.Map; -import java.util.regex.*; import tw.joi.energy.domain.ElectricityReading; import tw.joi.energy.domain.PricePlan; import tw.joi.energy.domain.SmartMeter; @@ -34,8 +33,8 @@ private BigDecimal calculateCost(Collection electricityReadi .max(comparing(ElectricityReading::time)) .get(); - BigDecimal energyConsumed = latest.reading().subtract(oldest.reading()); - return energyConsumed.multiply(pricePlan.getPrice(LocalDateTime.now())); + BigDecimal energyConsumed = latest.readingInKwH().subtract(oldest.readingInKwH()); + return energyConsumed.multiply(pricePlan.getPrice(ZonedDateTime.now())); } public List getAllPricePlans() { diff --git a/src/test/java/tw/joi/energy/config/ElectricityReadingsGeneratorTest.java b/src/test/java/tw/joi/energy/config/ElectricityReadingsGeneratorTest.java new file mode 100644 index 00000000..82243523 --- /dev/null +++ b/src/test/java/tw/joi/energy/config/ElectricityReadingsGeneratorTest.java @@ -0,0 +1,77 @@ +package tw.joi.energy.config; + +import static org.assertj.core.api.Assertions.assertThat; +import static tw.joi.energy.config.ElectricityReadingsGenerator.MAX_HOURLY_USAGE; +import static tw.joi.energy.config.ElectricityReadingsGenerator.MIN_HOURLY_USAGE; +import static tw.joi.energy.config.ElectricityReadingsGenerator.generateElectricityReadingStream; + +import java.math.BigDecimal; +import java.time.Duration; +import java.util.function.BiConsumer; +import java.util.stream.Stream; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import tw.joi.energy.domain.ElectricityReading; + +class ElectricityReadingsGeneratorTest { + + @Test + @DisplayName("Stream for one day should have 25 entries") + void streamForOneDayShouldHave25Entries() { + assertThat(generateElectricityReadingStream(1)).hasSize(25); + } + + @Test + @DisplayName("Stream for two days should have 49 entries") + void streamForTwoDaysShouldHave49Entries() { + assertThat(generateElectricityReadingStream(2).count()).isEqualTo(49); + } + + @Test + @DisplayName("Stream for one day should end 24 hours after initial entry") + void streamForOneDayShouldHave24HoursAfterInitialEntry() { + var streamAsList = generateElectricityReadingStream(1).toList(); + var firstEntry = streamAsList.getFirst(); + var lastEntry = streamAsList.getLast(); + assertThat(Duration.between(firstEntry.time(), lastEntry.time())).hasHours(24); + } + + @Test + @DisplayName("Stream entries should be one hour apart") + void streamEntriesShouldBeOneHourApart() { + validateOrderedPairsOfEntries(generateElectricityReadingStream(1), (earlierReading, laterReading) -> assertThat( + Duration.between(earlierReading.time(), laterReading.time())) + .hasHours(1)); + } + + @Test + @DisplayName("Stream entries should have an increasing energy consumption over time") + void streamEntriesShouldHaveAnIncreasingEnergyConsumptionOverTime() { + validateOrderedPairsOfEntries(generateElectricityReadingStream(1), (earlierReading, laterReading) -> assertThat( + laterReading.readingInKwH().compareTo(earlierReading.readingInKwH())) + .isEqualTo(1)); + } + + @Test + @DisplayName("Stream entries should have an energy consumption in the expected range") + void streamEntriesShouldHaveAnIncreasingEnergyConsumptionInExpectedRange() { + var min = BigDecimal.valueOf(MIN_HOURLY_USAGE); + var max = BigDecimal.valueOf(MAX_HOURLY_USAGE); + + validateOrderedPairsOfEntries(generateElectricityReadingStream(1), (earlierReading, laterReading) -> { + var energyBetweenReadings = laterReading.readingInKwH().subtract(earlierReading.readingInKwH()); + assertThat(energyBetweenReadings.compareTo(min)).isEqualTo(1); + assertThat(energyBetweenReadings.compareTo(max)).isEqualTo(-1); + }); + } + + private void validateOrderedPairsOfEntries( + Stream stream, BiConsumer validator) { + var streamAsList = stream.toList(); + for (int i = 1; i <= streamAsList.size() - 1; i++) { + var laterElectricityReading = streamAsList.get(i); + var earlierElectricityReading = streamAsList.get(i - 1); + validator.accept(earlierElectricityReading, laterElectricityReading); + } + } +} diff --git a/src/test/java/tw/joi/energy/domain/PricePlanTest.java b/src/test/java/tw/joi/energy/domain/PricePlanTest.java index c49e5b3c..6ab7b995 100644 --- a/src/test/java/tw/joi/energy/domain/PricePlanTest.java +++ b/src/test/java/tw/joi/energy/domain/PricePlanTest.java @@ -1,42 +1,45 @@ package tw.joi.energy.domain; -import org.assertj.core.data.Percentage; -import org.junit.jupiter.api.Test; +import static org.assertj.core.api.AssertionsForInterfaceTypes.assertThat; import java.math.BigDecimal; import java.time.LocalDateTime; import java.time.Month; - -import static java.util.Collections.emptyList; -import static org.assertj.core.api.AssertionsForInterfaceTypes.assertThat; +import java.time.ZoneId; +import java.time.ZonedDateTime; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; public class PricePlanTest { @Test - public void get_energy_supplier_should_return_the_energy_supplier_given_supplier_is_existent() { - PricePlan pricePlan = new PricePlan(null, "Energy Supplier Name", null, null); + @DisplayName("Get energy supplier should return supplier if not null") + public void getSupplierShouldReturnSupplierIfNotNull() { + PricePlan pricePlan = new PricePlan("Test Plan Name", "Energy Supplier Name", BigDecimal.ONE); assertThat(pricePlan.getEnergySupplier()).isEqualTo("Energy Supplier Name"); } @Test - public void get_price_should_return_the_base_price_given_an_ordinary_date_time() throws Exception { - LocalDateTime normalDateTime = LocalDateTime.of(2017, Month.AUGUST, 31, 12, 0, 0); - PricePlan pricePlan = new PricePlan(null, null, BigDecimal.ONE, emptyList()); + @DisplayName("Get price should return price given non-peak date and time") + public void getPriceShouldReturnPriceGivenNonPeakDateAndTime() { + ZonedDateTime nonPeakDateTime = + ZonedDateTime.of(LocalDateTime.of(2017, Month.AUGUST, 31, 12, 0, 0), ZoneId.of("GMT")); + // the price plan has no peak days, so all times are non-peak + PricePlan pricePlan = new PricePlan("test plan", "test supplier", BigDecimal.ONE); - BigDecimal price = pricePlan.getPrice(normalDateTime); + BigDecimal price = pricePlan.getPrice(nonPeakDateTime); - assertThat(price).isCloseTo(BigDecimal.ONE, Percentage.withPercentage(1)); + assertThat(price).isEqualByComparingTo(BigDecimal.ONE); } @Test - public void get_unit_rate_should_return_unit_rate_given_unit_rate_is_present() { - PricePlan pricePlan = new PricePlan(null, null, BigDecimal.TWO, null); - pricePlan.setPlanName("test-price-plan"); - pricePlan.setEnergySupplier("test-energy-supplier"); + @DisplayName("Get unit rate should return unit rate if not null") + public void getUnitRateShouldReturnUnitRateIfNotNull() { + PricePlan pricePlan = new PricePlan("test-price-plan", "test-energy-supplier", BigDecimal.TWO); BigDecimal rate = pricePlan.getUnitRate(); - assertThat(rate).isEqualTo(BigDecimal.TWO); + assertThat(rate).isEqualByComparingTo(BigDecimal.TWO); } } diff --git a/src/test/java/tw/joi/energy/domain/SmartMeterTest.java b/src/test/java/tw/joi/energy/domain/SmartMeterTest.java index 925c2566..b98c127f 100644 --- a/src/test/java/tw/joi/energy/domain/SmartMeterTest.java +++ b/src/test/java/tw/joi/energy/domain/SmartMeterTest.java @@ -1,16 +1,17 @@ package tw.joi.energy.domain; -import org.junit.jupiter.api.Test; - -import java.util.Collections; - +import static java.util.Collections.emptyList; import static org.assertj.core.api.Assertions.assertThat; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + class SmartMeterTest { @Test - void price_plan_id_should_be_null_given_no_price_plan_has_been_provided() { - var smartMeter = new SmartMeter(null, Collections.emptyList()); + @DisplayName("Price plan should be null if none has been supplied") + void pricePlanShouldBeNullIfNoneHasBeenSupplied() { + var smartMeter = new SmartMeter(null, emptyList()); var pricePlanId = smartMeter.getPricePlanId(); diff --git a/src/test/java/tw/joi/energy/fixture/ElectricityReadingFixture.java b/src/test/java/tw/joi/energy/fixture/ElectricityReadingFixture.java deleted file mode 100644 index 254502e2..00000000 --- a/src/test/java/tw/joi/energy/fixture/ElectricityReadingFixture.java +++ /dev/null @@ -1,18 +0,0 @@ -package tw.joi.energy.fixture; - -import java.math.BigDecimal; -import java.time.LocalDate; -import java.time.LocalDateTime; -import java.time.ZoneId; -import tw.joi.energy.domain.ElectricityReading; - -public class ElectricityReadingFixture { - public static ElectricityReading createReading(LocalDateTime timeToRead, Double reading) { - return new ElectricityReading( - timeToRead.atZone(ZoneId.systemDefault()).toInstant(), BigDecimal.valueOf(reading)); - } - - public static ElectricityReading createReading(LocalDate dateToRead, Double reading) { - return createReading(dateToRead.atStartOfDay(), reading); - } -} diff --git a/src/test/java/tw/joi/energy/fixture/PricePlanFixture.java b/src/test/java/tw/joi/energy/fixture/PricePlanFixture.java index a312b002..3b40e7e4 100644 --- a/src/test/java/tw/joi/energy/fixture/PricePlanFixture.java +++ b/src/test/java/tw/joi/energy/fixture/PricePlanFixture.java @@ -1,7 +1,6 @@ package tw.joi.energy.fixture; import java.math.BigDecimal; -import java.util.Collections; import tw.joi.energy.domain.PricePlan; public class PricePlanFixture { @@ -11,11 +10,9 @@ public class PricePlanFixture { public static final String SECOND_BEST_PLAN_ID = "second-best-supplier"; public static final PricePlan DEFAULT_PRICE_PLAN = - new PricePlan(SECOND_BEST_PLAN_ID, "energy-supplier", BigDecimal.TWO, Collections.emptyList()); + new PricePlan(SECOND_BEST_PLAN_ID, "energy-supplier", BigDecimal.TWO); - public static final PricePlan WORST_PRICE_PLAN = - new PricePlan(WORST_PLAN_ID, null, BigDecimal.TEN, Collections.emptyList()); + public static final PricePlan WORST_PRICE_PLAN = new PricePlan(WORST_PLAN_ID, null, BigDecimal.TEN); - public static final PricePlan BEST_PRICE_PLAN = - new PricePlan(BEST_PLAN_ID, null, BigDecimal.ONE, Collections.emptyList()); + public static final PricePlan BEST_PRICE_PLAN = new PricePlan(BEST_PLAN_ID, null, BigDecimal.ONE); } diff --git a/src/test/java/tw/joi/energy/repository/PricePlanRepositoryTest.java b/src/test/java/tw/joi/energy/repository/PricePlanRepositoryTest.java index 393deaa6..77af1753 100644 --- a/src/test/java/tw/joi/energy/repository/PricePlanRepositoryTest.java +++ b/src/test/java/tw/joi/energy/repository/PricePlanRepositoryTest.java @@ -3,12 +3,14 @@ import static java.util.Collections.emptyList; import static org.assertj.core.api.Assertions.assertThat; +import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; class PricePlanRepositoryTest { @Test - void should_return_empty_list_when_get_all_price_plans_given_no_price_plans_available() { + @DisplayName("Should return empty list of plans if none available") + void shouldReturnEmptyListOfPlansIfNoneAvailable() { var repository = new PricePlanRepository(emptyList()); var allPlans = repository.getAllPricePlans(); diff --git a/src/test/java/tw/joi/energy/repository/SmartMeterRepositoryTest.java b/src/test/java/tw/joi/energy/repository/SmartMeterRepositoryTest.java index 5ee7991d..214d2175 100644 --- a/src/test/java/tw/joi/energy/repository/SmartMeterRepositoryTest.java +++ b/src/test/java/tw/joi/energy/repository/SmartMeterRepositoryTest.java @@ -3,20 +3,23 @@ import static org.assertj.core.api.Assertions.assertThat; import java.util.List; +import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import tw.joi.energy.domain.SmartMeter; class SmartMeterRepositoryTest { @Test - void should_return_empty_smart_meter_when_find_by_id_given_a_non_existent_id() { + @DisplayName("findById should return empty option when searching for non-existent id") + void findById_shouldReturnEmptyOptionWhenSearchingForNonExistentId() { var repository = new SmartMeterRepository(); assertThat(repository.findById("non-existent")).isEmpty(); } @Test - void should_return_smart_meters_when_find_by_id_given_existent_smart_meter_ids() { + @DisplayName("findById should return appropriate smart meter if parameter exists in repository") + void findById_shouldReturnSmartMeterIfParameterExistsInRepository() { var repository = new SmartMeterRepository(); SmartMeter smartMeter0 = new SmartMeter(null, List.of()); SmartMeter smartMeter1 = new SmartMeter(null, List.of()); diff --git a/src/test/java/tw/joi/energy/service/MeterReadingManagerTest.java b/src/test/java/tw/joi/energy/service/MeterReadingManagerTest.java index 84a98bb1..5260e5b9 100644 --- a/src/test/java/tw/joi/energy/service/MeterReadingManagerTest.java +++ b/src/test/java/tw/joi/energy/service/MeterReadingManagerTest.java @@ -1,55 +1,64 @@ package tw.joi.energy.service; -import org.junit.jupiter.api.Test; -import tw.joi.energy.domain.ElectricityReading; -import tw.joi.energy.repository.SmartMeterRepository; - -import java.time.LocalDate; -import java.util.ArrayList; -import java.util.List; - import static java.util.Collections.emptyList; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static tw.joi.energy.fixture.ElectricityReadingFixture.createReading; -public class MeterReadingManagerTest { +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneId; +import java.util.ArrayList; +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import tw.joi.energy.domain.ElectricityReading; +import tw.joi.energy.repository.SmartMeterRepository; + +class MeterReadingManagerTest { + private static final ZoneId GMT = ZoneId.of("GMT"); private static final String SMART_METER_ID = "10101010"; + private static final long ARBITRARY_TIME_STAMP = 1721124813L; + private static final Clock FIXED_CLOCK = Clock.fixed(Instant.ofEpochSecond(ARBITRARY_TIME_STAMP), GMT); private final SmartMeterRepository smartMeterRepository = new SmartMeterRepository(); private final MeterReadingManager meterReadingManager = new MeterReadingManager(smartMeterRepository); @Test - public void store_readings_should_throw_exception_given_meter_id_is_null() { + @DisplayName("storeReadings should throw exception given a null meterId") + public void storeReadingsShouldThrowExceptionGivenNullMeterId() { assertThatThrownBy(() -> meterReadingManager.storeReadings(null, emptyList())) .isInstanceOf(IllegalArgumentException.class) .hasMessageContaining("smartMeterId"); } @Test - public void store_readings_should_throw_exception_given_meter_id_is_empty() { + @DisplayName("storeReadings should throw exception given meterId is empty string") + void storeReadingsShouldThrowExceptionGivenEmptyMeterId() { assertThatThrownBy(() -> meterReadingManager.storeReadings("", emptyList())) .isInstanceOf(IllegalArgumentException.class) .hasMessageContaining("smartMeterId"); } @Test - public void store_readings_should_throw_exception_given_readings_is_null() { + @DisplayName("storeReadings should throw exception given readings is null") + void storeReadingsShouldThrowExceptionGivenNullReadings() { assertThatThrownBy(() -> meterReadingManager.storeReadings(SMART_METER_ID, null)) .isInstanceOf(IllegalArgumentException.class) .hasMessageContaining("readings"); } @Test - public void store_readings_should_throw_exception_given_readings_is_empty() { + @DisplayName("storeReadings should throw exception given readings is emtpy list") + void storeReadingsShouldThrowExceptionGivenEmptyReadings() { assertThatThrownBy(() -> meterReadingManager.storeReadings(SMART_METER_ID, emptyList())) .isInstanceOf(IllegalArgumentException.class) .hasMessageContaining("readings"); } @Test - public void store_readings_should_success_given_meter_readings() { - var readingsToStore = List.of(createReading(LocalDate.now(), 1.0)); + @DisplayName("storeReadings should succeed given non-empty list of readings") + void storeReadingsShouldSucceedGivenNonEmptyReadings() { + var readingsToStore = List.of(new ElectricityReading(FIXED_CLOCK, 1.0)); meterReadingManager.storeReadings(SMART_METER_ID, readingsToStore); @@ -58,9 +67,10 @@ public void store_readings_should_success_given_meter_readings() { } @Test - public void store_readings_should_success_given_multiple_batches_of_meter_readings() { - var meterReadings = List.of(createReading(LocalDate.now(), 1.0)); - var otherMeterReadings = List.of(createReading(LocalDate.now(), 2.0)); + @DisplayName("storeReadings should succeed when called multiple times") + void storeReadingsShouldSucceedWhenCalledMultipleTimes() { + var meterReadings = List.of(new ElectricityReading(FIXED_CLOCK, 1.0)); + var otherMeterReadings = List.of(new ElectricityReading(FIXED_CLOCK, 2.0)); meterReadingManager.storeReadings(SMART_METER_ID, meterReadings); meterReadingManager.storeReadings(SMART_METER_ID, otherMeterReadings); @@ -74,10 +84,10 @@ public void store_readings_should_success_given_multiple_batches_of_meter_readin } @Test - public void - readings_should_store_to_associate_meter_given_multiple_meters_are_existent() { - var meterReadings = List.of(createReading(LocalDate.now(), 1.0)); - var otherMeterReadings = List.of(createReading(LocalDate.now(), 2.0)); + @DisplayName("storeReadings should write supplied readings to correct meter") + void storeReadingsShouldWriteSuppliedReadingsToCorrectMeter() { + var meterReadings = List.of(new ElectricityReading(FIXED_CLOCK, 1.0)); + var otherMeterReadings = List.of(new ElectricityReading(FIXED_CLOCK, 2.0)); meterReadingManager.storeReadings(SMART_METER_ID, meterReadings); meterReadingManager.storeReadings("00001", otherMeterReadings); @@ -87,16 +97,18 @@ public void store_readings_should_success_given_multiple_batches_of_meter_readin } @Test - public void read_readings_should_throw_exception_given_meter_id_is_not_existent() { + @DisplayName("readReadings should throw exception if supplied meterId is not persisted") + void readReadingsShouldThrowExceptionIfSupplierNotPersisted() { assertThatThrownBy(() -> meterReadingManager.readReadings(SMART_METER_ID)) .isInstanceOf(IllegalArgumentException.class) .hasMessageContaining("smartMeterId"); } @Test - public void read_readings_should_return_readings_given_readings_are_existent() { + @DisplayName("readReadings should return previously supplied readings for a known meterId") + void readReadingsShouldReturnPreviouslySuppliedReadings() { // given - var meterReadings = List.of(createReading(LocalDate.now(), 1.0)); + var meterReadings = List.of(new ElectricityReading(FIXED_CLOCK, 1.0)); meterReadingManager.storeReadings(SMART_METER_ID, meterReadings); // expect assertThat(meterReadingManager.readReadings(SMART_METER_ID)).isEqualTo(meterReadings); diff --git a/src/test/java/tw/joi/energy/service/PricePlanComparatorTest.java b/src/test/java/tw/joi/energy/service/PricePlanComparatorTest.java index 5c62e6bc..03d7a01c 100644 --- a/src/test/java/tw/joi/energy/service/PricePlanComparatorTest.java +++ b/src/test/java/tw/joi/energy/service/PricePlanComparatorTest.java @@ -1,20 +1,7 @@ package tw.joi.energy.service; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import tw.joi.energy.domain.PricePlan; -import tw.joi.energy.domain.SmartMeter; -import tw.joi.energy.repository.PricePlanRepository; -import tw.joi.energy.repository.SmartMeterRepository; - -import java.math.BigDecimal; -import java.time.LocalDateTime; -import java.util.List; -import java.util.Map; - import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.assertj.core.api.AssertionsForClassTypes.assertThat; -import static tw.joi.energy.fixture.ElectricityReadingFixture.createReading; import static tw.joi.energy.fixture.PricePlanFixture.BEST_PLAN_ID; import static tw.joi.energy.fixture.PricePlanFixture.BEST_PRICE_PLAN; import static tw.joi.energy.fixture.PricePlanFixture.DEFAULT_PRICE_PLAN; @@ -22,15 +9,34 @@ import static tw.joi.energy.fixture.PricePlanFixture.WORST_PLAN_ID; import static tw.joi.energy.fixture.PricePlanFixture.WORST_PRICE_PLAN; -public class PricePlanComparatorTest { +import java.math.BigDecimal; +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneId; +import java.time.temporal.ChronoUnit; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import tw.joi.energy.domain.ElectricityReading; +import tw.joi.energy.domain.PricePlan; +import tw.joi.energy.domain.SmartMeter; +import tw.joi.energy.repository.PricePlanRepository; +import tw.joi.energy.repository.SmartMeterRepository; + +class PricePlanComparatorTest { private static final String SMART_METER_ID = "smart-meter-id"; private PricePlanComparator comparator; private SmartMeterRepository smartMeterRepository; - private final LocalDateTime today = LocalDateTime.now(); - private final LocalDateTime tenDaysAgo = today.minusDays(10); + + private static final ZoneId GMT = ZoneId.of("GMT"); + private static final Instant testInstant = Instant.ofEpochSecond(1721124813L); + private static final Clock today = Clock.fixed(testInstant, GMT); + private static final Clock tenDaysAgo = Clock.fixed(testInstant.minus(10, ChronoUnit.DAYS), GMT); @BeforeEach - public void setUp() { + void setUp() { List pricePlans = List.of(WORST_PRICE_PLAN, BEST_PRICE_PLAN, DEFAULT_PRICE_PLAN); PricePlanRepository pricePlanRepository = new PricePlanRepository(pricePlans); @@ -39,8 +45,9 @@ public void setUp() { } @Test - public void recommend_should_return_all_costs_given_no_limit() { - var readings = List.of(createReading(tenDaysAgo, 3.0), createReading(today, 35.0)); + @DisplayName("recommend should return costs for all plans when no limit specified") + void recommendShouldReturnCostsForAllPlansWhenNoLimitSpecified() { + var readings = List.of(new ElectricityReading(tenDaysAgo, 3.0), new ElectricityReading(today, 35.0)); var smartMeter = new SmartMeter(WORST_PRICE_PLAN, readings); smartMeterRepository.save(SMART_METER_ID, smartMeter); @@ -54,8 +61,9 @@ public void recommend_should_return_all_costs_given_no_limit() { } @Test - public void recommend_should_return_top_2_cheapest_costs_given_limit_is_2() { - var readings = List.of(createReading(tenDaysAgo, 5.0), createReading(today, 20.0)); + @DisplayName("recommend should return top two cheapest costings if limit of 2 supplied ") + void recommendShouldReturnTopTwoCheapestCostingsIfLimitOf2Supplied() { + var readings = List.of(new ElectricityReading(tenDaysAgo, 5.0), new ElectricityReading(today, 20.0)); var smartMeter = new SmartMeter(WORST_PRICE_PLAN, readings); smartMeterRepository.save(SMART_METER_ID, smartMeter); @@ -68,9 +76,9 @@ public void recommend_should_return_top_2_cheapest_costs_given_limit_is_2() { } @Test - public void - recommend_should_return_all_costs_given_limit_is_bigger_than_count_of_price_plans() { - var readings = List.of(createReading(tenDaysAgo, 3.0), createReading(today, 25.0)); + @DisplayName("recommend should return all costs if limit is larger than sum of known price plans") + void recommendShouldReturnAllCostsIfLimitIsLargerThanSumOfKnownPricePlans() { + var readings = List.of(new ElectricityReading(tenDaysAgo, 3.0), new ElectricityReading(today, 25.0)); var smartMeter = new SmartMeter(WORST_PRICE_PLAN, readings); smartMeterRepository.save(SMART_METER_ID, smartMeter); @@ -84,7 +92,8 @@ public void recommend_should_return_top_2_cheapest_costs_given_limit_is_2() { } @Test - public void recommend_should_throw_exception_given_smart_meter_is_not_existent() { + @DisplayName("recommend should throw exception given a missing smartId") + void recommendShouldThrowExceptionGivenMissingSmartId() { assertThatThrownBy(() -> comparator.recommendCheapestPricePlans("not_existent_id", null)) .isInstanceOf(RuntimeException.class) .hasMessageContaining("missing args");