From e2a4078199cb582e55520107dfdce203e9da9142 Mon Sep 17 00:00:00 2001 From: Dao <comnuoc@users.noreply.gitlab.utu.fi> Date: Sat, 29 Jun 2024 05:15:17 +0300 Subject: [PATCH] feat(part2): Exercise 4. --- part_2/exercise_4/.gitignore | 30 +++ part_2/exercise_4/exercise_4.iml | 11 ++ part_2/exercise_4/src/Card.java | 110 +++++++++++ part_2/exercise_4/src/CardId.java | 20 ++ part_2/exercise_4/src/CommandLineApp.java | 175 ++++++++++++++++++ part_2/exercise_4/src/CommandLineHelper.java | 137 ++++++++++++++ part_2/exercise_4/src/Main.java | 6 + part_2/exercise_4/src/MoneyHelper.java | 14 ++ part_2/exercise_4/src/TicketType.java | 37 ++++ .../exercise_4/src/TicketTypeRepository.java | 56 ++++++ 10 files changed, 596 insertions(+) create mode 100644 part_2/exercise_4/.gitignore create mode 100644 part_2/exercise_4/exercise_4.iml create mode 100644 part_2/exercise_4/src/Card.java create mode 100644 part_2/exercise_4/src/CardId.java create mode 100644 part_2/exercise_4/src/CommandLineApp.java create mode 100644 part_2/exercise_4/src/CommandLineHelper.java create mode 100644 part_2/exercise_4/src/Main.java create mode 100644 part_2/exercise_4/src/MoneyHelper.java create mode 100644 part_2/exercise_4/src/TicketType.java create mode 100644 part_2/exercise_4/src/TicketTypeRepository.java diff --git a/part_2/exercise_4/.gitignore b/part_2/exercise_4/.gitignore new file mode 100644 index 0000000..d578053 --- /dev/null +++ b/part_2/exercise_4/.gitignore @@ -0,0 +1,30 @@ +### IntelliJ IDEA ### +/.idea/ +out/ +!**/src/main/**/out/ +!**/src/test/**/out/ + +### Eclipse ### +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache +bin/ +!**/src/main/**/bin/ +!**/src/test/**/bin/ + +### NetBeans ### +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ + +### VS Code ### +.vscode/ + +### Mac OS ### +.DS_Store \ No newline at end of file diff --git a/part_2/exercise_4/exercise_4.iml b/part_2/exercise_4/exercise_4.iml new file mode 100644 index 0000000..c90834f --- /dev/null +++ b/part_2/exercise_4/exercise_4.iml @@ -0,0 +1,11 @@ +<?xml version="1.0" encoding="UTF-8"?> +<module type="JAVA_MODULE" version="4"> + <component name="NewModuleRootManager" inherit-compiler-output="true"> + <exclude-output /> + <content url="file://$MODULE_DIR$"> + <sourceFolder url="file://$MODULE_DIR$/src" isTestSource="false" /> + </content> + <orderEntry type="inheritedJdk" /> + <orderEntry type="sourceFolder" forTests="false" /> + </component> +</module> \ No newline at end of file diff --git a/part_2/exercise_4/src/Card.java b/part_2/exercise_4/src/Card.java new file mode 100644 index 0000000..0e831df --- /dev/null +++ b/part_2/exercise_4/src/Card.java @@ -0,0 +1,110 @@ +import java.util.Objects; +import java.util.Optional; + +/* + * @.classInvariant: getId() != null && getBalanceInCents() >= 0 + */ +public class Card { + private final CardId id; + private long balanceInCents = 0; + private TicketType lastPurchasedTicketType = null; + private long ticketExpiredAt = 0; // number of milliseconds passed since 1970 + + /* + * @.pre: true + * @.post: Object is constructed + * throw NullPointerException if id == null + */ + public Card(CardId id) { + this.id = Objects.requireNonNull(id, "Id should not be null"); + } + + /* + * @.pre: true + * @.post: RESULT != null + */ + public CardId getId() { + return id; + } + + /* + * @.pre: true + * @.post: getBalanceInCents() = OLD(getBalanceInCents()) + amountInCents + * throw ZeroOrNegativeAmountException if amountInCents <= 0 + */ + public void topUp(long amountInCents) throws ZeroOrNegativeAmountException { + if (amountInCents <= 0) { + throw new ZeroOrNegativeAmountException("Top up amount should be greater than zero"); + } + + balanceInCents += amountInCents; + } + + /* + * @.pre: true + * @.post: RESULT >= 0 + */ + public long getBalanceInCents() { + return balanceInCents; + } + + /* + * @.pre: true + * @.post: RESULT == true if a ticket is purchased and is not expired, otherwise RESULT == false + */ + public boolean isTicketValid() { + return isTicketPurchased() && !isTicketExpired(); + } + + /* + * @.pre: true + * @.post: RESULT == null if there is no ticket + */ + public Optional<TicketType> getLastPurchasedTicketType() { + return Optional.ofNullable(lastPurchasedTicketType); + } + + /* + * @.pre: ticketType != null + * @.post: isTicketValid() == true && getBalanceInCents() == OLD(getBalanceInCents()) - ticketType.priceInCents() + * throw NotEnoughMoneyForTicketException if getBalanceInCents() < ticketType.priceInCents() + */ + public void buyTicket(TicketType ticketType) throws NotEnoughMoneyForTicketException { + if (balanceInCents < ticketType.priceInCents()) { + throw new NotEnoughMoneyForTicketException("Your balance is not sufficient for the ticket"); + } + + balanceInCents -= ticketType.priceInCents(); + lastPurchasedTicketType = ticketType; + ticketExpiredAt = getTimestamp() + ticketType.durationInMilliseconds(); + } + + @Override + public String toString() { + return id.toString(); + } + + private boolean isTicketPurchased() { + return null != lastPurchasedTicketType; + } + + private boolean isTicketExpired() { + return getTimestamp() > ticketExpiredAt; + } + + private long getTimestamp() { + return System.currentTimeMillis(); + } +} + +class ZeroOrNegativeAmountException extends RuntimeException { + public ZeroOrNegativeAmountException(String message) { + super(message); + } +} + +class NotEnoughMoneyForTicketException extends RuntimeException { + public NotEnoughMoneyForTicketException(String message) { + super(message); + } +} \ No newline at end of file diff --git a/part_2/exercise_4/src/CardId.java b/part_2/exercise_4/src/CardId.java new file mode 100644 index 0000000..bc4e957 --- /dev/null +++ b/part_2/exercise_4/src/CardId.java @@ -0,0 +1,20 @@ +import java.util.Objects; + +/* + * @.classInvariant: id() != null + */ +public record CardId(Object id) { + /* + * @.pre: true + * @.post: Object is constructed + * throw NullPointerException if id == null + */ + public CardId { + Objects.requireNonNull(id, "Id should not be null"); + } + + @Override + public String toString() { + return id.toString(); + } +} diff --git a/part_2/exercise_4/src/CommandLineApp.java b/part_2/exercise_4/src/CommandLineApp.java new file mode 100644 index 0000000..22538e9 --- /dev/null +++ b/part_2/exercise_4/src/CommandLineApp.java @@ -0,0 +1,175 @@ +import java.util.*; + +public class CommandLineApp { + private final Card card; + private final CommandLineHelper commandLineHelper; + private final TicketTypeRepository ticketTypeRepository; + private final MoneyHelper moneyHelper; + + public CommandLineApp() { + card = createCard(); + commandLineHelper = new CommandLineHelper(); + ticketTypeRepository = new TicketTypeRepository(); + moneyHelper = new MoneyHelper(); + } + + public void run() { + int choice = getMenuChoice(); + commandLineHelper.clearScreen(); + + switch (choice) { + case 1: + showBalance(); + break; + + case 2: + topUp(); + break; + + case 3: + checkTicketValidity(); + break; + } + } + + private int getMenuChoice() { + List<String> menu = new ArrayList<>(); + menu.add("Your card ID: " + card.toString()); + menu.add(" "); + menu.add("1. Show balance"); + menu.add("2. Top-up"); + menu.add("3. Buy ticket"); + menu.add("4. Exit"); + commandLineHelper.printMenu(menu); + + return commandLineHelper.getIntInput( + "Please choose", + "Error: please select a number between 1 and 4", + (number) -> number >= 1 && number <= 4 + ); + } + + private void showBalance() { + commandLineHelper.printSuccessMenu("Your balance is: " + moneyHelper.formatCents(card.getBalanceInCents())); + System.out.println(); + run(); + } + + private void topUp() { + boolean isSucceed = false; + + while (true) { + try { + long euro = commandLineHelper.getLongInput("Please enter amount in Euro", (number) -> number >= 0); + long cents = commandLineHelper.getLongInput("Please enter amount in cents", (number) -> number >= 0); + long amount = moneyHelper.euroToCent(euro) + cents; + + card.topUp(amount); + isSucceed = true; + + break; + } catch (ZeroOrNegativeAmountException e) { + commandLineHelper.printErrorMessage("Error: " + e.getMessage()); + String isContinue = commandLineHelper.getStringInput("Do you want to continue? (y/n)"); + + if (isContinue.equals("n")) { + break; + } + } + } + + if (isSucceed) { + commandLineHelper.printSuccessMessage("Top-up successfully"); + showBalance(); + return; + } + + System.out.println(); + run(); + } + + private void checkTicketValidity() { + boolean isBuyNewTicket = !card.isTicketValid(); + + if (!isBuyNewTicket) { + commandLineHelper.printSuccessMessage("Your ticket is still valid. Current ticket type:"); + commandLineHelper.printSuccessMessage(formatTicketType(card.getLastPurchasedTicketType().get())); + String isContinue = commandLineHelper.getStringInput("Do you want to buy another? (y/n)"); + isBuyNewTicket = isContinue.equals("y"); + } + + if (isBuyNewTicket) { + System.out.println(); + buyTicket(); + } else { + System.out.println(); + run(); + } + } + + private void buyTicket() { + Optional<TicketType> optChoseType = getChoseTicketType(); + + if (optChoseType.isPresent()) { + try { + card.buyTicket(optChoseType.get()); + commandLineHelper.printSuccessMessage("Buy ticket successfully"); + } catch (NotEnoughMoneyForTicketException e) { + commandLineHelper.printErrorMessage("Error: " + e.getMessage()); + } + } + + System.out.println(); + run(); + } + + private Optional<TicketType> getChoseTicketType() { + TicketType choseType = null; + + List<String> menu = new ArrayList<>(); + menu.add("Ticket types: "); + menu.add(" "); + + for (TicketType type: ticketTypeRepository.getAllTypes()) { + menu.add(formatTicketType(type)); + } + + while (true) { + try { + commandLineHelper.printMenu(menu); + + String typeName = commandLineHelper.getStringInput("Please enter the ticket type name or zero to cancel"); + + if (typeName.equals("0")) { + break; + } + + choseType = ticketTypeRepository.getByName(typeName); + + break; + } catch (TicketTypeNotFoundException e) { + commandLineHelper.printErrorMessage("Error: " + e.getMessage()); + String isContinue = commandLineHelper.getStringInput("Do you want to continue? (y/n)"); + + if (isContinue.equals("n")) { + break; + } + } + } + + return Optional.ofNullable(choseType); + } + + private String formatTicketType(TicketType type) { + return "Name: '" + type.name() + + "'. Description: " + type.description() + + ". Price: " + moneyHelper.formatCents(type.priceInCents()) + "."; + } + + private Card createCard() { + UUID uuid = UUID.randomUUID(); + CardId cardId = new CardId(uuid); + + return new Card(cardId); + } +} \ No newline at end of file diff --git a/part_2/exercise_4/src/CommandLineHelper.java b/part_2/exercise_4/src/CommandLineHelper.java new file mode 100644 index 0000000..77a0405 --- /dev/null +++ b/part_2/exercise_4/src/CommandLineHelper.java @@ -0,0 +1,137 @@ +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Scanner; +import java.util.function.Predicate; + +public class CommandLineHelper { + private final String ANSI_RESET = "\u001B[0m"; + private final String ANSI_GREEN = "\u001B[32m"; + private final String ANSI_RED = "\u001B[31m"; + + private final Scanner reader; + + public CommandLineHelper() { + reader = new Scanner(System.in); + } + + public void printMenu(List<String> strings) { + this.printMenu(strings, '*'); + } + + public void printMenu(List<String> strings, char character) { + int maxLen = strings.stream() + .mapToInt(String::length) + .max() + .orElse(0); + + System.out.println(repeatCharacter(character, maxLen + 4)); + + for (String str: strings) { + System.out.println( + character + " " + str + + repeatCharacter(' ', maxLen - str.length()) + + " " + character + ); + } + + System.out.println(repeatCharacter(character, maxLen + 4)); + } + + public void printMenu(List<String> strings, char character, String color) { + System.out.print(color); + printMenu(strings, character); + System.out.print(ANSI_RESET); + } + + public void printMenu(List<String> strings, String color) { + this.printMenu(strings, '*', color); + } + + public void printMenu(String string, String color) { + List<String> messages = new ArrayList<>(); + messages.add(string); + + printMenu(messages, color); + } + + public void printSuccessMenu(String string) { + printMenu(string, ANSI_GREEN); + } + + public void printSuccessMessage(String string) { + System.out.println(ANSI_GREEN + string + ANSI_RESET); + } + + public void printErrorMessage(String string) { + System.out.println(ANSI_RED + string + ANSI_RESET); + } + + public String repeatCharacter(char character, int number) { + if (number <= 0) { + return ""; + } + + char[] repeat = new char[number]; + Arrays.fill(repeat, character); + + return new String(repeat); + } + + public void clearScreen() { + System.out.print("\033[H\033[2J"); + System.out.flush(); + } + + public String getStringInput(String message) { + System.out.print(message + ": "); + + return reader.nextLine(); + } + + public int getIntInput(String message, String errorMessage, Predicate<Integer> tester) { + int input; + + while (true) { + try { + System.out.print(message + ": "); + input = Integer.parseInt(reader.nextLine()); + + if (null == tester || tester.test(input)) { + break; + } + + printErrorMessage(errorMessage); + } catch (NumberFormatException e) { + printErrorMessage(errorMessage); + } + } + + return input; + } + + public long getLongInput(String message, Predicate<Long> tester) { + return getLongInput(message, "Error: please enter a number", tester); + } + + public long getLongInput(String message, String errorMessage, Predicate<Long> tester) { + long input; + + while (true) { + try { + System.out.print(message + ": "); + input = Long.parseLong(reader.nextLine()); + + if (null == tester || tester.test(input)) { + break; + } + + printErrorMessage(errorMessage); + } catch (NumberFormatException e) { + printErrorMessage(errorMessage); + } + } + + return input; + } +} \ No newline at end of file diff --git a/part_2/exercise_4/src/Main.java b/part_2/exercise_4/src/Main.java new file mode 100644 index 0000000..df4ad8d --- /dev/null +++ b/part_2/exercise_4/src/Main.java @@ -0,0 +1,6 @@ +public class Main { + public static void main(String[] args) { + CommandLineApp app = new CommandLineApp(); + app.run(); + } +} \ No newline at end of file diff --git a/part_2/exercise_4/src/MoneyHelper.java b/part_2/exercise_4/src/MoneyHelper.java new file mode 100644 index 0000000..d6674f0 --- /dev/null +++ b/part_2/exercise_4/src/MoneyHelper.java @@ -0,0 +1,14 @@ +import java.text.NumberFormat; +import java.util.Locale; + +public class MoneyHelper { + public long euroToCent(long euro) { + return euro * 100; + } + + public String formatCents(long cents) { + NumberFormat nf = NumberFormat.getCurrencyInstance(Locale.FRANCE); + + return nf.format(cents / 100.0); + } +} diff --git a/part_2/exercise_4/src/TicketType.java b/part_2/exercise_4/src/TicketType.java new file mode 100644 index 0000000..f77e3a1 --- /dev/null +++ b/part_2/exercise_4/src/TicketType.java @@ -0,0 +1,37 @@ +import java.util.Objects; + +/* + * @.classInvariant: name() != null && durationInMilliseconds() > 0 && priceInCents() >= 0 + */ +public record TicketType(String name, String description, long durationInMilliseconds, long priceInCents) { + /* + * @.pre: true + * @.post: Object is constructed + * throw NullPointerException if name == null + * throw ZeroOrNegativeDurationException if durationInMilliseconds <= 0 + * throw NegativePriceException if priceInCents < 0 + */ + public TicketType { + Objects.requireNonNull(name, "Id should not be null"); + + if (durationInMilliseconds <= 0) { + throw new ZeroOrNegativeDurationException("Duration should be greater than zero"); + } + + if (priceInCents < 0) { + throw new NegativePriceException("Price should be greater than or equal to zero"); + } + } +} + +class ZeroOrNegativeDurationException extends RuntimeException { + public ZeroOrNegativeDurationException(String message) { + super(message); + } +} + +class NegativePriceException extends RuntimeException { + public NegativePriceException(String message) { + super(message); + } +} \ No newline at end of file diff --git a/part_2/exercise_4/src/TicketTypeRepository.java b/part_2/exercise_4/src/TicketTypeRepository.java new file mode 100644 index 0000000..570dca3 --- /dev/null +++ b/part_2/exercise_4/src/TicketTypeRepository.java @@ -0,0 +1,56 @@ +import java.util.ArrayList; +import java.util.List; + +public class TicketTypeRepository { + private final List<TicketType> types; + + public TicketTypeRepository() { + types = new ArrayList<>(); + types.add(new TicketType( + "single", + "Single ticket. Valid for 2 hours", + (long) 2 * 60 * 60 * 1000, + 3 * 100 + )); + types.add(new TicketType( + "day", + "Day ticket. Valid for 24 hours", + (long) 24 * 60 * 60 * 1000, + 8 * 100 + )); + types.add(new TicketType( + "monthly", + "Monthly ticket. Valid for 30 days", + (long) 30 * 24 * 60 * 60 * 1000, + 55 * 100 + )); + } + + public List<TicketType> getAllTypes() { + return types; + } + + /* + * @.pre: name != null + * @.post: RESULT != null + * throw TicketTypeNotFoundException if there is no ticket type with this name. + */ + public TicketType getByName(String name) { + List<TicketType> result = types.stream() + .filter((type) -> type.name().equals(name)) + .limit(2) + .toList(); + + if (result.isEmpty()) { + throw new TicketTypeNotFoundException("Ticket type '" + name + "' is not found"); + } + + return result.getFirst(); + } +} + +class TicketTypeNotFoundException extends RuntimeException { + public TicketTypeNotFoundException(String message) { + super(message); + } +} -- GitLab