kawasimaさんのこれやってる。ETC割引の適用ルールが意外と面倒で死にそう。https://t.co/9cOzGQVTZR
— Ryo Shindo (@shindo_ryo) 2019年5月22日
なんか面白そうなのが流れてきたので、やってみる。
できあがったコードは GitHub にあげてます。
やることの確認
http://www.driveplaza.com/traffic/tolls_etc/
ここにある、ETC割引の計算ロジックを実装します。
...問題
上記の業務ルールにしたがい、割引率を計算するインタフェースDiscountServiceを実装して下さい。
public interface DisountService { public long calc(HighwayDrive drive); }
走行記録はHighwayDriveクラスで表現され、DiscountService#calcに渡されるものとします。 また、割引率はパーセンテージの正の整数で表現されます。
GitHub の README.md の方には、 ETC 割引として次の3つが挙げられている。
- 平日朝夕割引
- 休日割引
- 深夜割引
ただ、参考に挙がっているリンク先では、他にも「ETC2.0割引」とか「外環道迂回利用割引」などの「その他のETC割引」も存在する。
これらも対象とするのか?
既に実装が用意されている HighwayDrive
の中身を確認してみる。
public class HighwayDrive implements Serializable {
private LocalDateTime enteredAt;
private LocalDateTime exitedAt;
private VehicleFamily vehicleFamily;
private RouteType routeType;
private Driver driver;
出入り時刻、車種、道路種別、ドライバ(月間の走行回数を保持)の情報しかないので、 ETC2.0 かどうかなどは判断できない。
ということで、その他ETC割引は対象外っぽい。
これ以外にも「このモデルだと判定できないもの」は対象外ということで除外する感じで進めることにする。1
仕様を整理する
とりあえず https://www.driveplaza.com/traffic/tolls_etc/ を読み込んで、仕様を整理する。。。
前提
前述の README.md に記載されている前提。
- ただし、平日朝夕割引は実際には後日還元なのですが、ここでは他の割引と同じく即時適用かつ走行距離による還元率の変化はないものとします。
- 走行記録は、24時間を超えないものとします。
平日朝夕割引
平日朝夕割引 | 料金・割引・ETC | 料金・ルート・交通情報 | ドラぷら
- 次の条件をすべて満たすと割引対象となる
- 出入り口のいずれかを通過した時刻が、割引対象日時である
- 走行した道路が割引対象道路である
- 割引対象車種
- すべての車種
- 割引対象日時
-
平日の6~9時、17~20時
- 9:00, 20:00 丁度は範囲になるのか明確な記述がなかったので、範囲は次の前提で進める2
- $[6:00, 9:00)$
- $[17:00, 20:00)$
- 9:00, 20:00 丁度は範囲になるのか明確な記述がなかったので、範囲は次の前提で進める2
- 平日 = 月~金曜日(祝日を除く)
-
平日の6~9時、17~20時
- 割引対象道路
- 地方部のみ
- 割引率
- 1ヶ月の間に割引対象となった利用回数に応じて割引率が変わる
- 0~4回の場合は0%
- 5~9回の場合は30%
- 10回以上の場合は50%
休日割引
休日割引 | 料金・割引・ETC | 料金・ルート・交通情報 | ドラぷら
- 次の条件をすべて満たすと割引対象となる
- 車種が割引対象車種である
- 出入り口のいずれかを通過した日付が割引対象日である3
- 走行した道路が割引対象道路である
- 割引対象車種
- 普通車
- 軽自動車
- 二輪車
- 割引対象日
- 土曜
- 日曜
- 祝日
- 1月2日
- 1月3日
- 割引対象道路
- 地方部のみ
- 割引率
- 30%
深夜割引
深夜割引 | 料金・割引・ETC | 料金・ルート・交通情報 | ドラぷら
- 次のいずれかの時間が割引対象時間を満たす場合に、割引対象となる
- 出入り口のいずれかを通過した時間
- 出入り口を通過するまでに跨いだ時間
- 割引対象車種
- すべての車種
- 割引対象時間
- 0~4時
- こちらも $[00:00, 04:00)$ という前提とする
- 0~4時
- 割引対象道路
- すべての道路
- 現実の深夜割引では対象道路に条件があるが、 RouteType には「都市部」と「地方部」しかないので、ここではすべての道路を対象とする
- 割引率
- 30%
割引が重複した場合の扱い
平日朝夕割引[ご利用上の注意] | 料金・割引・ETC | 料金・ルート・交通情報 | ドラぷら
休日割引[ご利用上の注意] | 料金・割引・ETC | 料金・ルート・交通情報 | ドラぷら
深夜割引[ご利用上の注意] | 料金・割引・ETC | 料金・ルート・交通情報 | ドラぷら
- 平日朝夕割引と休日割引が重複した場合は、休日割引を採用する
- 平日朝夕割引と深夜割引が重複した場合は、深夜割引を採用する
- 休日割引と深夜割引が重複した場合は、休日割引を採用する
- 実際の割引では、障害者割引なども加味した上で最も割引率が高くなる割引が採用される
- しかし、今回扱う「平日朝夕割引」以外の割引は「休日割引」「深夜割引」の2つに限られている
- この2つはどちらも割引率が30%固定なので、ここでは「休日割引」を固定で採用することにした
- 3つすべての割引が重複した場合は、休日割引を採用する
作戦会議
単純に各割引種別ごとに、全パターン洗い出してテストを書いていこうかと一瞬考えたけど、組み合わせ爆発でパターン数がえぐい事になりそうだと思った。
よくよくみると、どの割引も「割引対象車種」「割引対象日時(時間/日)」「割引対象道路」のような割引対象と判定するための条件を持っている。
そして、それらの条件をすべて満たしたときに割引対象となる、という感じで考えられそうに見える。
この「割引対象~~」という単位で部品化して、テストを書いたら少しは楽にならないか?
雰囲気は、下図のような感じ。
なんか、いけそうな気がしてきた。
実装
割引対象日時の実装
一番複雑そうな「平日朝夕割引対象日時」を例に実装をしてみる。
まずは、テストを書いてみる。
package kata.ex01.model.discount.weekday;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import static kata.ex01.model.discount.DiscountTestUtils.*;
import static org.assertj.core.api.Assertions.*;
class WeekdayDrivingTimeDiscountTest {
private WeekdayDrivingTimeDiscount discount = new WeekdayDrivingTimeDiscount();
@Nested
class 平日 {
@Test
void 早朝_to_朝_true() {
assertThat(discount.matches(weekday("05:59"), weekday("06:00"))).isTrue();
}
@Test
void 朝_to_朝_true() {
assertThat(discount.matches(weekday("06:00"), weekday("08:59"))).isTrue();
}
@Test
void 朝_to_昼_true() {
assertThat(discount.matches(weekday("08:59"), weekday("09:00"))).isTrue();
}
@Test
void 早朝_to_昼_false() {
assertThat(discount.matches(weekday("05:59"), weekday("09:00"))).isFalse();
}
@Test
void 昼_to_昼_false() {
assertThat(discount.matches(weekday("09:00"), weekday("16:59"))).isFalse();
}
@Test
void 昼_to_夕_true() {
assertThat(discount.matches(weekday("16:59"), weekday("17:00"))).isTrue();
}
@Test
void 夕_to_夕_true() {
assertThat(discount.matches(weekday("17:00"), weekday("19:59"))).isTrue();
}
@Test
void 夕_to_夜_true() {
assertThat(discount.matches(weekday("19:59"), weekday("20:00"))).isTrue();
}
@Test
void 昼_to_夜_false() {
assertThat(discount.matches(weekday("16:59"), weekday("20:00"))).isFalse();
}
@Test
void 夜_to_夜_false() {
assertThat(discount.matches(weekday("20:00"), weekday("23:00"))).isFalse();
}
}
@Nested
class 土曜日 {
@Test
void 平日金曜夕_to_土曜夜_true() {
assertThat(discount.matches(dateTime("2019-05-24 19:59"), dateTime("2019-05-25 01:00"))).isTrue();
}
@Test
void 平日金曜夜_to_土曜朝_false() {
assertThat(discount.matches(dateTime("2019-05-24 20:00"), dateTime("2019-05-25 06:00"))).isFalse();
}
@Test
void 朝_to_朝_false() {
assertThat(discount.matches(saturday("06:00"), saturday("08:59"))).isFalse();
}
@Test
void 夕_to_夕_false() {
assertThat(discount.matches(saturday("17:00"), saturday("19:59"))).isFalse();
}
}
@Nested
class 日曜日 {
@Test
void 朝_to_朝_false() {
assertThat(discount.matches(sunday("06:00"), sunday("08:59"))).isFalse();
}
@Test
void 夕_to_夕_false() {
assertThat(discount.matches(sunday("17:00"), sunday("18:59"))).isFalse();
}
@Test
void 日曜夕_to_平日月曜夜_false() {
assertThat(discount.matches(dateTime("2019-05-26 19:59"), dateTime("2019-05-27 01:00"))).isFalse();
}
@Test
void 日曜夜_to_平日月曜朝_true() {
assertThat(discount.matches(dateTime("2019-05-26 20:00"), dateTime("2019-05-27 06:00"))).isTrue();
}
}
@Nested
class 祝日 {
@Test
void 平日夕_to_祝日夜_true() {
assertThat(discount.matches(dateTime("2019-03-20 19:59"), dateTime("2019-03-22 01:00"))).isTrue();
}
@Test
void 平日夜_to_祝日朝_false() {
assertThat(discount.matches(dateTime("2019-03-20 20:00"), dateTime("2019-03-21 06:00"))).isFalse();
}
@Test
void 朝_to_朝_false() {
assertThat(discount.matches(holiday("06:00"), holiday("08:59"))).isFalse();
}
@Test
void 夕_to_夕_false() {
assertThat(discount.matches(holiday("17:00"), holiday("18:59"))).isFalse();
}
@Test
void 祝日夕_to_平日夜_false() {
assertThat(discount.matches(dateTime("2019-03-21 19:59"), dateTime("2019-03-22 01:00"))).isFalse();
}
@Test
void 祝日夜_to_平日朝_true() {
assertThat(discount.matches(dateTime("2019-03-21 20:00"), dateTime("2019-03-22 06:00"))).isTrue();
}
}
}
実装は次のような感じになった。4
package kata.ex01.model.discount.weekday;
import kata.ex01.model.discount.DrivingDateTime;
import kata.ex01.model.discount.DrivingTimeDiscount;
import java.time.LocalTime;
/**
* 平日朝夕割引対象日時.
*/
public class WeekdayDrivingTimeDiscount implements DrivingTimeDiscount {
private static final LocalTime MORNING_START_TIME = LocalTime.of(6, 0);
private static final LocalTime MORNING_END_TIME = LocalTime.of(9, 0);
private static final LocalTime EVENING_START_TIME = LocalTime.of(17, 0);
private static final LocalTime EVENING_END_TIME = LocalTime.of(20, 0);
@Override
public boolean matches(DrivingDateTime enteredAt, DrivingDateTime exitedAt) {
return this.isWeekdayMorning(enteredAt) || this.isWeekdayEvening(enteredAt)
|| this.isWeekdayMorning(exitedAt) || this.isWeekdayEvening(exitedAt);
}
private boolean isWeekdayMorning(DrivingDateTime dateTime) {
return this.isWeekday(dateTime) && dateTime.isIn(MORNING_START_TIME, MORNING_END_TIME);
}
private boolean isWeekdayEvening(DrivingDateTime dateTime) {
return this.isWeekday(dateTime) && dateTime.isIn(EVENING_START_TIME, EVENING_END_TIME);
}
private boolean isWeekday(DrivingDateTime dateTime) {
return !dateTime.isSaturday()
&& !dateTime.isSunday()
&& !dateTime.isHoliday();
}
}
DrivingDateTime
は、実装していく中で他の割引対象日時クラスで発生した重複処理を共通クラスとしてくくりだしたものになる。
実装は、以下のような感じ。
package kata.ex01.model.discount;
import kata.ex01.util.HolidayUtils;
import java.time.DayOfWeek;
import java.time.LocalDateTime;
import java.time.LocalTime;
import java.time.MonthDay;
import java.util.Objects;
/**
* 走行日時.
*/
public class DrivingDateTime {
private final LocalDateTime dateTime;
public DrivingDateTime(LocalDateTime dateTime) {
this.dateTime = Objects.requireNonNull(dateTime);
}
/**
* この日付が土曜日か確認する.
* @return 土曜日の場合は true
*/
public boolean isSunday() {
return this.dateTime.getDayOfWeek() == DayOfWeek.SUNDAY;
}
/**
* この日付が日曜日か確認する.
* @return 日曜日の場合は true
*/
public boolean isSaturday() {
return this.dateTime.getDayOfWeek() == DayOfWeek.SATURDAY;
}
/**
* この日付が祝日か確認する.
* @return 祝日の場合は true
*/
public boolean isHoliday() {
return HolidayUtils.isHoliday(this.dateTime.toLocalDate());
}
/**
* この日付の「月日」が指定した「月日」と一致するか確認する.
* @param monthDay 比較対象の「月日」
* @return 一致する場合は true
*/
public boolean is(MonthDay monthDay) {
return MonthDay.from(this.dateTime).equals(monthDay);
}
/**
* この日時が指定した日時と一致するか確認する.
* @param dateTime 比較対象の日時
* @return 一致する場合は true
*/
public boolean is(LocalDateTime dateTime) {
return this.dateTime.equals(dateTime);
}
/**
* この日時の「時間」が、指定した期間内に入るか確認する.
* @param fromInclude 開始時間(この時刻を含む)
* @param toExclude 終了時間(この時刻を含まない)
* @return 指定した期間内に入る場合は true
*/
public boolean isIn(LocalTime fromInclude, LocalTime toExclude) {
LocalTime thisTime = this.dateTime.toLocalTime();
return (fromInclude.isBefore(thisTime) || fromInclude.equals(thisTime))
&& thisTime.isBefore(toExclude);
}
/**
* この日時が、指定した日時より前であることを確認する.
* @param dateTime 比較対象の日時
* @return 指定した日時より前の場合は true
*/
public boolean isBefore(LocalDateTime dateTime) {
return this.dateTime.isBefore(dateTime);
}
/**
* この日時が、指定した日時より後であることを確認する.
* @param dateTime 比較対象の日時
* @return 指定した日時より後の場合は true
*/
public boolean isAfter(LocalDateTime dateTime) {
return this.dateTime.isAfter(dateTime);
}
/**
* この日付に指定した時刻を設定した新しい日時を取得する.
* @param time 新たに設定する時刻
* @return 生成した日時
*/
public LocalDateTime with(LocalTime time) {
return this.dateTime.with(time);
}
}
割引日付以外の実装はシンプルで、例えば対象車種の割引は次のような感じになった。
package kata.ex01.model.discount.weekday;
import kata.ex01.model.VehicleFamily;
import kata.ex01.model.discount.VehicleFamilyDiscount;
/**
* 平日朝夕割引対象車種.
*/
public class WeekdayVehicleFamilyDiscount implements VehicleFamilyDiscount {
@Override
public boolean matches(VehicleFamily vehicleFamily) {
return true; // 全車種対象
}
}
package kata.ex01.model.discount.holiday;
import kata.ex01.model.VehicleFamily;
import kata.ex01.model.discount.VehicleFamilyDiscount;
/**
* 休日割引対象車種.
*/
public class HolidayVehicleFamilyDiscount implements VehicleFamilyDiscount {
@Override
public boolean matches(VehicleFamily vehicleFamily) {
return vehicleFamily == VehicleFamily.STANDARD
|| vehicleFamily == VehicleFamily.MINI
|| vehicleFamily == VehicleFamily.MOTORCYCLE;
}
}
ETC 割引クラスの実装
あとは、こいつらを組み合わせて ETC 割引を計算するクラスを作る。
各クラスの関係は次のような感じ。
まずは、一番トップの ETC 割引インターフェース。
package kata.ex01.model.discount;
import kata.ex01.model.HighwayDrive;
/**
* ETC 割引.
*/
public interface EtcDiscount {
/**
* 指定した走行記録が、この ETC 割引の対象となるか判定する.
* @param highwayDrive 走行記録
* @return 割引の対象となる場合は true
*/
boolean matches(HighwayDrive highwayDrive);
/**
* 指定した走行記録に対してこの割引を適用した場合の割引率を計算する.
* @param highwayDrive 走行記録
* @return 割引率(割引の対象とならない場合は 0 を返す)
*/
long calc(HighwayDrive highwayDrive);
}
次に、各割引クラスのスケルトン実装を提供する抽象クラス。
package kata.ex01.model.discount;
import kata.ex01.model.HighwayDrive;
import java.util.Objects;
/**
* ETC 割引のスケルトン実装を提供する抽象クラス.
*/
public abstract class AbstractEtcDiscount implements EtcDiscount {
private final RouteTypeDiscount routeTypeDiscount;
private final VehicleFamilyDiscount vehicleFamilyDiscount;
private final DrivingTimeDiscount drivingTimeDiscount;
private final DiscountRate discountRate;
protected AbstractEtcDiscount(
RouteTypeDiscount routeTypeDiscount,
VehicleFamilyDiscount vehicleFamilyDiscount,
DrivingTimeDiscount drivingTimeDiscount,
DiscountRate discountRate
) {
this.routeTypeDiscount = Objects.requireNonNull(routeTypeDiscount);
this.vehicleFamilyDiscount = Objects.requireNonNull(vehicleFamilyDiscount);
this.drivingTimeDiscount = Objects.requireNonNull(drivingTimeDiscount);
this.discountRate = Objects.requireNonNull(discountRate);
}
@Override
public boolean matches(HighwayDrive highwayDrive) {
DrivingDateTime enteredAt = new DrivingDateTime(highwayDrive.getEnteredAt());
DrivingDateTime exitedAt = new DrivingDateTime(highwayDrive.getExitedAt());
return this.routeTypeDiscount.matches(highwayDrive.getRouteType())
&& this.vehicleFamilyDiscount.matches(highwayDrive.getVehicleFamily())
&& this.drivingTimeDiscount.matches(enteredAt, exitedAt);
}
@Override
public long calc(HighwayDrive highwayDrive) {
return this.discountRate.decideDiscountRate(highwayDrive);
}
}
これに対して、たとえば平日朝夕割引クラスの実装は次のような感じになる。
package kata.ex01.model.discount.weekday;
import kata.ex01.model.discount.AbstractEtcDiscount;
/**
* 平日朝夕割引.
*/
public class WeekdayDiscount extends AbstractEtcDiscount {
public WeekdayDiscount() {
super(
new WeekdayRouteTypeDiscount(),
new WeekdayVehicleFamilyDiscount(),
new WeekdayDrivingTimeDiscount(),
new WeekdayDiscountRate()
);
}
}
割引なしのクラスは次のような感じ。
package kata.ex01.model.discount;
import kata.ex01.model.HighwayDrive;
/**
* 割引なし.
*/
public class NoDiscount implements EtcDiscount {
@Override
public boolean matches(HighwayDrive highwayDrive) {
return true;
}
@Override
public long calc(HighwayDrive highwayDrive) {
return 0L;
}
}
最終的な割引を決めるクラス
複数の割引が条件を満たした場合、ルールに従ってどれか1つに割引を決める必要がある。
それを行うクラスを作る。
package kata.ex01.model.discount;
import kata.ex01.model.HighwayDrive;
import kata.ex01.model.discount.holiday.HolidayDiscount;
import kata.ex01.model.discount.midnight.MidnightDiscount;
import kata.ex01.model.discount.weekday.WeekdayDiscount;
import java.util.Objects;
/**
* 最終的に採用する ETC 割引を決定する調停クラス.
*/
public class EtcDiscountCoordinator {
private final WeekdayDiscount weekdayDiscount;
private final HolidayDiscount holidayDiscount;
private final MidnightDiscount midnightDiscount;
private final NoDiscount noDiscount = new NoDiscount();
public EtcDiscountCoordinator(WeekdayDiscount weekdayDiscount, HolidayDiscount holidayDiscount, MidnightDiscount midnightDiscount) {
this.weekdayDiscount = Objects.requireNonNull(weekdayDiscount);
this.holidayDiscount = Objects.requireNonNull(holidayDiscount);
this.midnightDiscount = Objects.requireNonNull(midnightDiscount);
}
public EtcDiscount decideDiscount(HighwayDrive highwayDrive) {
if (this.holidayDiscount.matches(highwayDrive)) {
return this.holidayDiscount;
} else if (this.midnightDiscount.matches(highwayDrive)) {
return this.midnightDiscount;
} else if (this.weekdayDiscount.matches(highwayDrive)) {
return this.weekdayDiscount;
} else {
return this.noDiscount;
}
}
}
これのテストは、↓のような感じになった。
package kata.ex01.model.discount;
import kata.ex01.model.HighwayDrive;
import kata.ex01.model.discount.holiday.HolidayDiscount;
import kata.ex01.model.discount.midnight.MidnightDiscount;
import kata.ex01.model.discount.weekday.WeekdayDiscount;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
class EtcDiscountCoordinatorTest {
@Test
void どの割引も対象とならなかった場合は割引なしを採用() {
EtcDiscountCoordinator coordinator = new EtcDiscountCoordinator(
weekday(false),
holiday(false),
midnight(false)
);
EtcDiscount discount = coordinator.decideDiscount(new HighwayDrive());
assertThat(discount).isInstanceOf(NoDiscount.class);
}
@Test
void 平日朝夕割引のみが対象となった場合は_平日朝夕割引を採用() {
EtcDiscountCoordinator coordinator = new EtcDiscountCoordinator(
weekday(true),
holiday(false),
midnight(false)
);
EtcDiscount discount = coordinator.decideDiscount(new HighwayDrive());
assertThat(discount).isInstanceOf(WeekdayDiscount.class);
}
@Test
void 休日割引のみが対象となった場合は_休日割引を採用() {
...
}
@Test
void 深夜割引のみが対象となった場合は_深夜割引を採用() {
...
}
@Test
void 平日朝夕割引と休日割引が対象となった場合は_休日割引を採用() {
...
}
@Test
void 平日朝夕割引と深夜割引が対象となった場合は_深夜割引を採用() {
...
}
@Test
void 休日割引と深夜割引が対象となった場合は_休日割引を採用() {
EtcDiscountCoordinator coordinator = new EtcDiscountCoordinator(
weekday(false),
holiday(true),
midnight(true)
);
EtcDiscount discount = coordinator.decideDiscount(new HighwayDrive());
assertThat(discount).isInstanceOf(HolidayDiscount.class);
}
@Test
void すべての割引が対象となった場合は休日割引を採用() {
EtcDiscountCoordinator coordinator = new EtcDiscountCoordinator(
weekday(true),
holiday(true),
midnight(true)
);
EtcDiscount discount = coordinator.decideDiscount(new HighwayDrive());
assertThat(discount).isInstanceOf(HolidayDiscount.class);
}
private WeekdayDiscount weekday(boolean match) {
WeekdayDiscount mock = mock(WeekdayDiscount.class);
when(mock.matches(any())).thenReturn(match);
return mock;
}
private HolidayDiscount holiday(boolean match) {
...
}
private MidnightDiscount midnight(boolean match) {
...
}
}
できたクラスを使う
一通り実装できたので、お題である DiscountServiceImpl
のなかを実装する。
package kata.ex01;
import kata.ex01.model.HighwayDrive;
import kata.ex01.model.discount.EtcDiscount;
import kata.ex01.model.discount.EtcDiscountCoordinator;
import kata.ex01.model.discount.holiday.HolidayDiscount;
import kata.ex01.model.discount.midnight.MidnightDiscount;
import kata.ex01.model.discount.weekday.WeekdayDiscount;
/**
* @author kawasima
*/
public class DiscountServiceImpl implements DiscountService {
@Override
public long calc(HighwayDrive drive) {
EtcDiscountCoordinator coordinator = new EtcDiscountCoordinator(new WeekdayDiscount(), new HolidayDiscount(), new MidnightDiscount());
EtcDiscount discount = coordinator.decideDiscount(drive);
return discount.calc(drive);
}
}
こんな感じになった。
感想
- 個々の割引対象を部品として分離したことで、「個別のテスト」と「組み合わせのテスト」が書きやすくなったんじゃないかと思う
- お題では割引ルールをかなりマイルドにしているが、ガチの ETC 割引のロジックは人類には難しすぎると思った5