Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Zoned date time #3

Open
wants to merge 11 commits into
base: option-0-no-api
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 13 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand All @@ -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<ElectricityReading> electricityReadings_)
Stores the provided _ElectricityReading_ collection in the indicated _SmartMeter_. If no
Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion src/main/java/tw/joi/energy/config/TestData.java
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ 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());
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), emptyList());
private static final PricePlan STANDARD_PRICE_PLAN =
new PricePlan("price-plan-2", "Power for Everyone", BigDecimal.ONE, emptyList());

Expand Down
5 changes: 3 additions & 2 deletions src/main/java/tw/joi/energy/domain/ElectricityReading.java
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
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) {}
19 changes: 5 additions & 14 deletions src/main/java/tw/joi/energy/domain/PricePlan.java
Original file line number Diff line number Diff line change
@@ -1,21 +1,20 @@
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.time.ZonedDateTime;
import java.util.List;

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<PeakTimeMultiplier> peakTimeMultipliers;

public PricePlan(
String planName, String energySupplier, BigDecimal unitRate, List<PeakTimeMultiplier> peakTimeMultipliers) {
String planName, String energySupplier, BigDecimal unitRate, List<PeakTimeMultiplier> peakTimeMultipliers) {
this.planName = planName;
this.energySupplier = energySupplier;
this.unitRate = unitRate;
Expand All @@ -26,23 +25,15 @@ 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;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,10 @@

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;
Expand All @@ -34,8 +34,8 @@ private BigDecimal calculateCost(Collection<ElectricityReading> 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<PricePlan> getAllPricePlans() {
Expand Down
35 changes: 21 additions & 14 deletions src/test/java/tw/joi/energy/domain/PricePlanTest.java
Original file line number Diff line number Diff line change
@@ -1,39 +1,46 @@
package tw.joi.energy.domain;

import org.assertj.core.data.Percentage;
import org.junit.jupiter.api.Test;
import static java.util.Collections.emptyList;
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.ZoneOffset;
import java.time.ZonedDateTime;
import java.util.TimeZone;
import org.assertj.core.data.Percentage;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;

public class PricePlanTest {

@Test
@DisplayName("Get energy supplier should return supplier if not null")
public void get_energy_supplier_should_return_the_energy_supplier_given_supplier_is_existent() {
PricePlan pricePlan = new PricePlan(null, "Energy Supplier Name", null, null);
PricePlan pricePlan = new PricePlan("Test Plan Name", "Energy Supplier Name", BigDecimal.ONE, emptyList());

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 get_price_should_return_the_base_price_given_an_ordinary_date_time() {
ZonedDateTime nonPeakDateTime = ZonedDateTime.of(LocalDateTime.of(2017, Month.AUGUST, 31, 12, 0, 0),
ZoneId.of("GMT"));

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I haven't thought this through completely, but what's the value in using a time zone? I presume:

  • the local date time is in the time zone of the meter that produced the reading
  • only the local date/time matters when determining the day and therefore the zone isn't necessary
  • we'll never compare dates from two different meters in meaningful ways

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consider the following. In the model, there is no time zone associated with a meter, rather the readings are captured as Instant - as in 'absolute' moments in time and not associated with a time zone.

So, such a reading could be in different days depending on where we are in the world. As the Javadoc says, there's no way to convert to an instant with an offset or timezone. Likewise, there's no way to map an Instant to a day of the week without an offset or timezone.

To make that conversion, you'd need to supply a Zone whichever way, and in the worst case you'd arbitrarily choose whatever the system property gave you, making yourself dependent on hidden global variables.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh, huh, yeah. Then it seems most correct to associate a zone with the measurements (or with the smart meter). But that'd be a bigger change.

// the price plan has no peak days....
PricePlan pricePlan = new PricePlan("test plan", "test supplier", BigDecimal.ONE, emptyList());

BigDecimal price = pricePlan.getPrice(normalDateTime);
BigDecimal price = pricePlan.getPrice(nonPeakDateTime);

assertThat(price).isCloseTo(BigDecimal.ONE, Percentage.withPercentage(1));
assertThat(price).isEqualTo(BigDecimal.ONE);
jejking-tw marked this conversation as resolved.
Show resolved Hide resolved
}

@Test
@DisplayName("Get unit rate should return unit rate if no null")
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");
PricePlan pricePlan = new PricePlan("test-price-plan", "test-energy-supplier", BigDecimal.TWO, emptyList());
jejking-tw marked this conversation as resolved.
Show resolved Hide resolved

BigDecimal rate = pricePlan.getUnitRate();

Expand Down
7 changes: 4 additions & 3 deletions src/test/java/tw/joi/energy/domain/SmartMeterTest.java
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
package tw.joi.energy.domain;

import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;

import java.util.Collections;

import static org.assertj.core.api.Assertions.assertThat;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;

class SmartMeterTest {

@Test
@DisplayName("Price plan should be null if none has been supplied")
void price_plan_id_should_be_null_given_no_price_plan_has_been_provided() {
var smartMeter = new SmartMeter(null, Collections.emptyList());

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,13 @@
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
@DisplayName("Should return empty list of plans if none available")
void should_return_empty_list_when_get_all_price_plans_given_no_price_plans_available() {
var repository = new PricePlanRepository(emptyList());

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,19 +3,22 @@
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
@DisplayName("findById should return empty option when searching for non-existent id")
void should_return_empty_smart_meter_when_find_by_id_given_a_non_existent_id() {
var repository = new SmartMeterRepository();

assertThat(repository.findById("non-existent")).isEmpty();
}

@Test
@DisplayName("findById should return appropriate smart meter if parameter exists in repository")
void should_return_smart_meters_when_find_by_id_given_existent_smart_meter_ids() {
var repository = new SmartMeterRepository();
SmartMeter smartMeter0 = new SmartMeter(null, List.of());
Expand Down
34 changes: 21 additions & 13 deletions src/test/java/tw/joi/energy/service/MeterReadingManagerTest.java
Original file line number Diff line number Diff line change
@@ -1,54 +1,59 @@
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;

import java.time.LocalDate;
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;

public class MeterReadingManagerTest {

private static final String SMART_METER_ID = "10101010";
private final SmartMeterRepository smartMeterRepository = new SmartMeterRepository();
private final MeterReadingManager meterReadingManager = new MeterReadingManager(smartMeterRepository);

@Test
@DisplayName("storeReadings should throw exception given a null meterId")
public void store_readings_should_throw_exception_given_meter_id_is_null() {
assertThatThrownBy(() -> meterReadingManager.storeReadings(null, emptyList()))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("smartMeterId");
}

@Test
@DisplayName("storeReadings should throw exception given meterId is empty string")
public void store_readings_should_throw_exception_given_meter_id_is_empty() {
assertThatThrownBy(() -> meterReadingManager.storeReadings("", emptyList()))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("smartMeterId");
}

@Test
@DisplayName("storeReadings should throw exception given readings is null")
public void store_readings_should_throw_exception_given_readings_is_null() {
assertThatThrownBy(() -> meterReadingManager.storeReadings(SMART_METER_ID, null))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("readings");
}

@Test
@DisplayName("storeReadings should throw exception given readings is emtpy list")
public void store_readings_should_throw_exception_given_readings_is_empty() {
assertThatThrownBy(() -> meterReadingManager.storeReadings(SMART_METER_ID, emptyList()))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("readings");
}

@Test
public void store_readings_should_success_given_meter_readings() {
@DisplayName("storeReadings should succeed given non-empty list of readings")
public void store_readings_should_succeed_given_meter_readings() {
var readingsToStore = List.of(createReading(LocalDate.now(), 1.0));

meterReadingManager.storeReadings(SMART_METER_ID, readingsToStore);
Expand All @@ -58,7 +63,8 @@ public void store_readings_should_success_given_meter_readings() {
}

@Test
public void store_readings_should_success_given_multiple_batches_of_meter_readings() {
@DisplayName("storeReadings should succeed when called multiple times")
public void store_readings_should_succeed_given_multiple_batches_of_meter_readings() {
var meterReadings = List.of(createReading(LocalDate.now(), 1.0));
var otherMeterReadings = List.of(createReading(LocalDate.now(), 2.0));

Expand All @@ -74,8 +80,8 @@ 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() {
@DisplayName("storeReadings should write supplied readings to correct meter")
public void store_readings_should_store_to_correct_meter_given_multiple_meters_exist() {
var meterReadings = List.of(createReading(LocalDate.now(), 1.0));
var otherMeterReadings = List.of(createReading(LocalDate.now(), 2.0));

Expand All @@ -87,13 +93,15 @@ 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")
public void read_readings_should_throw_exception_given_meter_id_is_not_persisted() {
assertThatThrownBy(() -> meterReadingManager.readReadings(SMART_METER_ID))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("smartMeterId");
}

@Test
@DisplayName("readReadings should return previously supplied readings for a known meterId")
public void read_readings_should_return_readings_given_readings_are_existent() {
// given
var meterReadings = List.of(createReading(LocalDate.now(), 1.0));
Expand Down
Loading