commit 75a3390ef4c0988d77dd10dc40ea12d27d954e6a Author: Phuriphat Yuanyee Date: Sun May 17 19:23:06 2026 +0200 init diff --git a/source/.gitignore b/source/.gitignore new file mode 100644 index 0000000..2e2966e --- /dev/null +++ b/source/.gitignore @@ -0,0 +1,17 @@ +# Maven build output +target/ + +# Generated log files +*.log + +# IDE files +.idea/ +*.iml +.vscode/ +*.classpath +*.project +.settings/ + +# OS files +.DS_Store +Thumbs.db diff --git a/source/pom.xml b/source/pom.xml new file mode 100644 index 0000000..07d721a --- /dev/null +++ b/source/pom.xml @@ -0,0 +1,51 @@ + + + 4.0.0 + + se.kth.iv1350 + repairbike + 1.0-SNAPSHOT + jar + + + 11 + 11 + UTF-8 + + + + + org.junit.jupiter + junit-jupiter + 5.10.0 + test + + + + + src/main + src/test + + + org.apache.maven.plugins + maven-surefire-plugin + 3.1.2 + + + org.apache.maven.plugins + maven-jar-plugin + 3.3.0 + + + + startup.Main + + + + + + + diff --git a/source/src/main/controller/Controller.java b/source/src/main/controller/Controller.java new file mode 100644 index 0000000..c01dfe6 --- /dev/null +++ b/source/src/main/controller/Controller.java @@ -0,0 +1,103 @@ +package controller; + +import integration.BikeNotFoundException; +import integration.BikeRegistry; +import integration.CustomerNotFoundException; +import integration.CustomerRegistry; +import integration.RepairTaskCatalog; +import integration.TaskNotFoundException; +import model.Amount; +import model.BikeDTO; +import model.CustomerDTO; +import model.RepairOrder; +import model.RepairShop; +import model.TaskDTO; + +/** + * Coordinates use-case execution between the view and the model and integration layers. + * All calls from the view pass through this class. + */ +public class Controller { + + private final RepairShop repairShop; + private final BikeRegistry bikeRegistry; + private final RepairTaskCatalog taskCatalog; + private final CustomerRegistry customerRegistry; + + /** + * Creates a Controller wired to the given dependencies. + * + * @param repairShop The repair shop facade (model layer). + * @param bikeRegistry The bike registry (integration layer). + * @param taskCatalog The repair task catalog (integration layer). + * @param customerRegistry The customer registry (integration layer). + */ + public Controller(RepairShop repairShop, BikeRegistry bikeRegistry, + RepairTaskCatalog taskCatalog, CustomerRegistry customerRegistry) { + this.repairShop = repairShop; + this.bikeRegistry = bikeRegistry; + this.taskCatalog = taskCatalog; + this.customerRegistry = customerRegistry; + } + + /** + * Starts a new repair session. + */ + public void startNewRepair() { + repairShop.startRepair(); + } + + /** + * Looks up a customer by phone number. + * + * @param phoneNumber The customer's phone number. + * @return The {@link CustomerDTO} of the found customer. + * @throws CustomerNotFoundException If no customer with that phone number exists. + */ + public CustomerDTO lookupCustomer(String phoneNumber) throws CustomerNotFoundException { + return customerRegistry.findCustomer(phoneNumber); + } + + /** + * Registers the bike with the given ID for the current repair session. + * + * @param bikeID The unique identifier of the bike to register. + * @return The {@link BikeDTO} of the registered bike. + * @throws BikeNotFoundException If no bike with the given ID exists in the registry. + */ + public BikeDTO enterBikeID(String bikeID) throws BikeNotFoundException { + BikeDTO bike = bikeRegistry.findBike(bikeID); + repairShop.registerBike(bike); + return bike; + } + + /** + * Adds the repair task with the given name to the current repair session. + * + * @param taskName The name of the repair task to add. + * @return The updated running total as an {@link Amount}. + * @throws TaskNotFoundException If no task with the given name exists in the catalog. + */ + public Amount addRepairTask(String taskName) throws TaskNotFoundException { + TaskDTO task = taskCatalog.findTask(taskName); + return repairShop.addTask(task); + } + + /** + * Records the mechanic's diagnostic notes for the current repair session. + * + * @param report The diagnostic report text entered by the mechanic. + */ + public void enterDiagnosticReport(String report) { + repairShop.enterDiagnosticReport(report); + } + + /** + * Ends the current repair session and returns the completed repair order. + * + * @return The {@link RepairOrder} summarising all repair details. + */ + public RepairOrder endRepair() { + return repairShop.endRepair(); + } +} diff --git a/source/src/main/integration/BikeNotFoundException.java b/source/src/main/integration/BikeNotFoundException.java new file mode 100644 index 0000000..2b2057b --- /dev/null +++ b/source/src/main/integration/BikeNotFoundException.java @@ -0,0 +1,30 @@ +package integration; + +/** + * Thrown when a bike ID search yields no match in the bike registry. + * This is a checked exception because the caller is expected to handle the case + * where a bike is not found and prompt the user to re-enter a valid ID. + */ +public class BikeNotFoundException extends Exception { + + private final String bikeID; + + /** + * Creates a new instance indicating which bike ID was not found. + * + * @param bikeID The bike ID that had no matching record. + */ + public BikeNotFoundException(String bikeID) { + super("No bike found with ID: " + bikeID); + this.bikeID = bikeID; + } + + /** + * Returns the bike ID that was not found in the registry. + * + * @return The missing bike ID string. + */ + public String getBikeID() { + return bikeID; + } +} diff --git a/source/src/main/integration/BikeRegistry.java b/source/src/main/integration/BikeRegistry.java new file mode 100644 index 0000000..d6e7874 --- /dev/null +++ b/source/src/main/integration/BikeRegistry.java @@ -0,0 +1,66 @@ +package integration; + +import model.BikeDTO; + +import java.util.HashMap; +import java.util.Map; + +/** + * Handles retrieval of bike information from persistent storage. + * Implemented as a Singleton to ensure only one instance of the registry exists + * throughout the application's lifetime, guaranteeing a single consistent data source. + * In this implementation, bikes are stored in memory as sample data. + * + *

The bike ID {@code "DB-FAIL-999"} is hardcoded to always trigger a + * {@link DatabaseFailureException}, simulating an unavailable database server.

+ */ +public class BikeRegistry { + + /** Hardcoded ID that always causes a simulated database failure. */ + public static final String DB_FAILURE_ID = "DB-FAIL-999"; + + private static BikeRegistry instance; + private final Map bikes = new HashMap<>(); + + /** + * Private constructor — use {@link #getInstance()} to obtain the single instance. + */ + private BikeRegistry() { + bikes.put("BIKE-001", new BikeDTO("BIKE-001", "Alice Svensson")); + bikes.put("BIKE-002", new BikeDTO("BIKE-002", "Bob Lindqvist")); + bikes.put("BIKE-003", new BikeDTO("BIKE-003", "Carl Johansson")); + } + + /** + * Returns the single shared instance of {@code BikeRegistry}, creating it on + * first call. + * + * @return The singleton {@code BikeRegistry} instance. + */ + public static BikeRegistry getInstance() { + if (instance == null) { + instance = new BikeRegistry(); + } + return instance; + } + + /** + * Looks up and returns the bike with the given ID. + * + * @param bikeID The unique identifier of the bike to look up. + * @return The {@link BikeDTO} of the found bike. + * @throws BikeNotFoundException If no bike with the given ID exists in the registry. + * @throws DatabaseFailureException If the bike ID equals the hardcoded failure trigger, + * simulating an unavailable database server. + */ + public BikeDTO findBike(String bikeID) throws BikeNotFoundException { + if (DB_FAILURE_ID.equals(bikeID)) { + throw new DatabaseFailureException("BikeRegistry.findBike(\"" + bikeID + "\")"); + } + BikeDTO bike = bikes.get(bikeID); + if (bike == null) { + throw new BikeNotFoundException(bikeID); + } + return bike; + } +} diff --git a/source/src/main/integration/CustomerNotFoundException.java b/source/src/main/integration/CustomerNotFoundException.java new file mode 100644 index 0000000..0bde60f --- /dev/null +++ b/source/src/main/integration/CustomerNotFoundException.java @@ -0,0 +1,30 @@ +package integration; + +/** + * Thrown when a customer phone number search yields no match in the customer registry. + * This is a checked exception because the caller is expected to handle the case + * where a customer is not found and inform the user accordingly. + */ +public class CustomerNotFoundException extends Exception { + + private final String phoneNumber; + + /** + * Creates a new instance indicating which phone number was not found. + * + * @param phoneNumber The phone number that had no matching customer record. + */ + public CustomerNotFoundException(String phoneNumber) { + super("No customer found with phone number: " + phoneNumber); + this.phoneNumber = phoneNumber; + } + + /** + * Returns the phone number that was not found in the registry. + * + * @return The missing phone number string. + */ + public String getPhoneNumber() { + return phoneNumber; + } +} diff --git a/source/src/main/integration/CustomerRegistry.java b/source/src/main/integration/CustomerRegistry.java new file mode 100644 index 0000000..f483a9e --- /dev/null +++ b/source/src/main/integration/CustomerRegistry.java @@ -0,0 +1,41 @@ +package integration; + +import model.CustomerDTO; + +import java.util.HashMap; +import java.util.Map; + +/** + * Handles retrieval of customer information from persistent storage. + * Customers are looked up by their phone number. In this implementation, + * customers are stored in memory as sample data. + */ +public class CustomerRegistry { + + private final Map customers = new HashMap<>(); + + /** + * Creates a CustomerRegistry pre-loaded with sample customer records. + */ + public CustomerRegistry() { + customers.put("0701234567", new CustomerDTO("0701234567", "Alice Svensson")); + customers.put("0709876543", new CustomerDTO("0709876543", "Bob Lindqvist")); + customers.put("0705551234", new CustomerDTO("0705551234", "Carl Johansson")); + } + + /** + * Looks up and returns the customer with the given phone number. + * + * @param phoneNumber The phone number of the customer to look up. + * @return The {@link CustomerDTO} of the found customer. + * @throws CustomerNotFoundException If no customer with the given phone number + * exists in the registry. + */ + public CustomerDTO findCustomer(String phoneNumber) throws CustomerNotFoundException { + CustomerDTO customer = customers.get(phoneNumber); + if (customer == null) { + throw new CustomerNotFoundException(phoneNumber); + } + return customer; + } +} diff --git a/source/src/main/integration/DatabaseFailureException.java b/source/src/main/integration/DatabaseFailureException.java new file mode 100644 index 0000000..12f7218 --- /dev/null +++ b/source/src/main/integration/DatabaseFailureException.java @@ -0,0 +1,44 @@ +package integration; + +/** + * Thrown when the underlying database or data source cannot be reached. + * This is an unchecked exception because a database failure is an infrastructure + * fault that the application cannot meaningfully recover from at runtime. Callers + * should catch this exception only at the outermost layer (the view) in order to + * log the failure and notify the user that the system is temporarily unavailable. + */ +public class DatabaseFailureException extends RuntimeException { + + private final String operation; + + /** + * Creates a new instance describing which database operation failed. + * + * @param operation A short description of the operation that caused the failure + * (e.g., {@code "BikeRegistry.findBike"}). + */ + public DatabaseFailureException(String operation) { + super("Database failure during operation: " + operation); + this.operation = operation; + } + + /** + * Creates a new instance with a root-cause exception. + * + * @param operation A short description of the operation that caused the failure. + * @param cause The underlying throwable that triggered this exception. + */ + public DatabaseFailureException(String operation, Throwable cause) { + super("Database failure during operation: " + operation, cause); + this.operation = operation; + } + + /** + * Returns a description of the database operation that failed. + * + * @return The operation string. + */ + public String getOperation() { + return operation; + } +} diff --git a/source/src/main/integration/RepairTaskCatalog.java b/source/src/main/integration/RepairTaskCatalog.java new file mode 100644 index 0000000..4831645 --- /dev/null +++ b/source/src/main/integration/RepairTaskCatalog.java @@ -0,0 +1,41 @@ +package integration; + +import model.Amount; +import model.TaskDTO; + +import java.util.HashMap; +import java.util.Map; + +/** + * Handles retrieval of repair task information from persistent storage. + * In this implementation, tasks are stored in memory as sample data. + */ +public class RepairTaskCatalog { + + private final Map tasks = new HashMap<>(); + + /** + * Creates a RepairTaskCatalog pre-loaded with sample repair task records. + */ + public RepairTaskCatalog() { + tasks.put("Brake Pad Replacement", new TaskDTO("Brake Pad Replacement", new Amount(350.00))); + tasks.put("Tire Replacement", new TaskDTO("Tire Replacement", new Amount(500.00))); + tasks.put("Battery Check", new TaskDTO("Battery Check", new Amount(200.00))); + tasks.put("Chain Lubrication", new TaskDTO("Chain Lubrication", new Amount(150.00))); + } + + /** + * Looks up and returns the repair task with the given name. + * + * @param taskName The name of the repair task to look up. + * @return The {@link TaskDTO} of the found task. + * @throws TaskNotFoundException If no task with the given name exists in the catalog. + */ + public TaskDTO findTask(String taskName) throws TaskNotFoundException { + TaskDTO task = tasks.get(taskName); + if (task == null) { + throw new TaskNotFoundException(taskName); + } + return task; + } +} diff --git a/source/src/main/integration/TaskNotFoundException.java b/source/src/main/integration/TaskNotFoundException.java new file mode 100644 index 0000000..52cae3a --- /dev/null +++ b/source/src/main/integration/TaskNotFoundException.java @@ -0,0 +1,30 @@ +package integration; + +/** + * Thrown when a repair task name search yields no match in the task catalog. + * This is a checked exception because the caller is expected to handle the case + * where a task is not found and prompt the user to re-enter a valid task name. + */ +public class TaskNotFoundException extends Exception { + + private final String taskName; + + /** + * Creates a new instance indicating which task name was not found. + * + * @param taskName The task name that had no matching record. + */ + public TaskNotFoundException(String taskName) { + super("No repair task found with name: " + taskName); + this.taskName = taskName; + } + + /** + * Returns the task name that was not found in the catalog. + * + * @return The missing task name string. + */ + public String getTaskName() { + return taskName; + } +} diff --git a/source/src/main/model/ActiveRepair.java b/source/src/main/model/ActiveRepair.java new file mode 100644 index 0000000..4d98980 --- /dev/null +++ b/source/src/main/model/ActiveRepair.java @@ -0,0 +1,117 @@ +package model; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/** + * Represents one ongoing repair session for a single bike. + * Only accessible within the model package via {@link RepairShop}. + * + *

State invariant: the object never partially changes state when an operation + * fails. All modifications are applied atomically — if any step throws, the + * previous state is preserved.

+ */ +class ActiveRepair { + + private BikeDTO bike; + private final List tasks = new ArrayList<>(); + private String diagnosticReport = ""; + private Amount total = new Amount(0); + private DiscountStrategy discountStrategy; + private final List observers = new ArrayList<>(); + + /** + * Creates a new ActiveRepair with no discount and no observers. + * Observers and a discount strategy can be added after construction. + */ + ActiveRepair() { + this.discountStrategy = new NoDiscount(); + } + + /** + * Registers an observer that will be notified on every state change. + * + * @param observer The observer to add. + */ + void addObserver(RepairOrderObserver observer) { + observers.add(observer); + } + + /** + * Sets the discount strategy to apply when calculating the repair total. + * + * @param strategy The discount strategy to use; must not be {@code null}. + */ + void setDiscountStrategy(DiscountStrategy strategy) { + this.discountStrategy = strategy; + } + + /** + * Registers the bike that is being repaired in this session and notifies observers. + * + * @param bike The bike to register. + */ + void registerBike(BikeDTO bike) { + this.bike = bike; + notifyObservers(); + } + + /** + * Adds a repair task to this session, recalculates the total, and notifies observers. + * If an error occurs the task list and total remain unchanged. + * + * @param task The repair task to add. + * @return The new running total after adding the task. + */ + Amount addTask(TaskDTO task) { + tasks.add(task); + total = calculateTotal(); + notifyObservers(); + return total; + } + + /** + * Records the mechanic's diagnostic notes and notifies observers. + * + * @param report The diagnostic report text entered by the mechanic. + */ + void enterDiagnosticReport(String report) { + this.diagnosticReport = report; + notifyObservers(); + } + + /** + * Ends the repair session, applies the discount strategy, notifies observers, + * and returns a completed repair order. + * + * @return The {@link RepairOrder} summarising all repair details. + */ + RepairOrder endRepair() { + Amount discountedTotal = discountStrategy.applyDiscount(total); + List immutableTasks = Collections.unmodifiableList(new ArrayList<>(tasks)); + RepairOrder repairOrder = new RepairOrder(bike, immutableTasks, diagnosticReport, discountedTotal); + notifyObservers(repairOrder); + return repairOrder; + } + + private Amount calculateTotal() { + Amount sum = new Amount(0); + for (TaskDTO task : tasks) { + sum = sum.add(task.getCost()); + } + return sum; + } + + private void notifyObservers() { + List snapshot = Collections.unmodifiableList(new ArrayList<>(tasks)); + RepairOrder currentState = new RepairOrder(bike, snapshot, diagnosticReport, total); + notifyObservers(currentState); + } + + private void notifyObservers(RepairOrder repairOrder) { + for (RepairOrderObserver observer : observers) { + observer.repairOrderUpdated(repairOrder); + } + } +} diff --git a/source/src/main/model/Amount.java b/source/src/main/model/Amount.java new file mode 100644 index 0000000..ab5be9f --- /dev/null +++ b/source/src/main/model/Amount.java @@ -0,0 +1,66 @@ +package model; + +/** + * Represents a monetary amount. Immutable; all arithmetic returns new instances. + */ +public class Amount { + private final double value; + + /** + * Creates an Amount with the specified value. + * + * @param value The monetary value. + */ + public Amount(double value) { + this.value = value; + } + + /** + * Returns a new Amount that is the sum of this amount and the given amount. + * + * @param other The amount to add. + * @return A new Amount representing the sum. + */ + public Amount add(Amount other) { + return new Amount(this.value + other.value); + } + + /** + * Returns a new Amount that is the difference of this amount minus the given amount. + * + * @param other The amount to subtract. + * @return A new Amount representing the difference. + */ + public Amount subtract(Amount other) { + return new Amount(this.value - other.value); + } + + /** + * Returns a new Amount scaled by the given factor. + * + * @param factor The multiplier to apply. + * @return A new Amount representing the scaled value. + */ + public Amount multiply(double factor) { + return new Amount(this.value * factor); + } + + /** + * Returns the raw numeric value of this amount. + * + * @return The monetary value as a double. + */ + public double getValue() { + return value; + } + + /** + * Returns a formatted string representation of this amount. + * + * @return A string in the form {@code "123.45 SEK"}. + */ + @Override + public String toString() { + return String.format("%.2f SEK", value); + } +} diff --git a/source/src/main/model/BikeDTO.java b/source/src/main/model/BikeDTO.java new file mode 100644 index 0000000..d5dcafc --- /dev/null +++ b/source/src/main/model/BikeDTO.java @@ -0,0 +1,39 @@ +package model; + +/** + * Data Transfer Object that carries bike information across layer boundaries. + * Instances are immutable. + */ +public class BikeDTO { + private final String bikeID; + private final String ownerName; + + /** + * Creates a BikeDTO with the specified bike ID and owner name. + * + * @param bikeID The unique identifier of the bike. + * @param ownerName The full name of the bike's owner. + */ + public BikeDTO(String bikeID, String ownerName) { + this.bikeID = bikeID; + this.ownerName = ownerName; + } + + /** + * Returns the bike's unique identifier. + * + * @return The bike ID string. + */ + public String getBikeID() { + return bikeID; + } + + /** + * Returns the full name of the bike's owner. + * + * @return The owner's name. + */ + public String getOwnerName() { + return ownerName; + } +} diff --git a/source/src/main/model/CustomerDTO.java b/source/src/main/model/CustomerDTO.java new file mode 100644 index 0000000..ffac27a --- /dev/null +++ b/source/src/main/model/CustomerDTO.java @@ -0,0 +1,39 @@ +package model; + +/** + * Data Transfer Object that carries customer information across layer boundaries. + * Instances are immutable. + */ +public class CustomerDTO { + private final String phoneNumber; + private final String name; + + /** + * Creates a CustomerDTO with the specified phone number and name. + * + * @param phoneNumber The customer's phone number. + * @param name The full name of the customer. + */ + public CustomerDTO(String phoneNumber, String name) { + this.phoneNumber = phoneNumber; + this.name = name; + } + + /** + * Returns the customer's phone number. + * + * @return The phone number string. + */ + public String getPhoneNumber() { + return phoneNumber; + } + + /** + * Returns the customer's full name. + * + * @return The name string. + */ + public String getName() { + return name; + } +} diff --git a/source/src/main/model/DiscountStrategy.java b/source/src/main/model/DiscountStrategy.java new file mode 100644 index 0000000..68a6ae9 --- /dev/null +++ b/source/src/main/model/DiscountStrategy.java @@ -0,0 +1,17 @@ +package model; + +/** + * Strategy interface for calculating customer discounts on a repair total. + * Different discount rules (loyalty, seasonal, etc.) are each implemented as a + * separate class that implements this interface. + */ +public interface DiscountStrategy { + + /** + * Calculates and returns the discounted total for the given amount. + * + * @param total The original repair total before any discount. + * @return The total after applying the discount. + */ + Amount applyDiscount(Amount total); +} diff --git a/source/src/main/model/LoyaltyDiscount.java b/source/src/main/model/LoyaltyDiscount.java new file mode 100644 index 0000000..0aaf9cf --- /dev/null +++ b/source/src/main/model/LoyaltyDiscount.java @@ -0,0 +1,22 @@ +package model; + +/** + * A discount strategy that grants loyal customers a 10% reduction on the total + * repair cost. This strategy can be applied when a customer qualifies for the + * loyalty programme (e.g. every third repair). + */ +public class LoyaltyDiscount implements DiscountStrategy { + + private static final double DISCOUNT_RATE = 0.10; + + /** + * Returns the given total reduced by {@value #DISCOUNT_RATE} (10%). + * + * @param total The original repair total. + * @return The total after applying a 10% loyalty discount. + */ + @Override + public Amount applyDiscount(Amount total) { + return total.multiply(1.0 - DISCOUNT_RATE); + } +} diff --git a/source/src/main/model/NoDiscount.java b/source/src/main/model/NoDiscount.java new file mode 100644 index 0000000..86200c5 --- /dev/null +++ b/source/src/main/model/NoDiscount.java @@ -0,0 +1,20 @@ +package model; + +/** + * A discount strategy that applies no discount. The original total is returned + * unchanged. This is the default strategy used when the customer has no active + * discount. + */ +public class NoDiscount implements DiscountStrategy { + + /** + * Returns the given total unchanged. + * + * @param total The original repair total. + * @return The same total, with no reduction applied. + */ + @Override + public Amount applyDiscount(Amount total) { + return total; + } +} diff --git a/source/src/main/model/RepairOrder.java b/source/src/main/model/RepairOrder.java new file mode 100644 index 0000000..23dfd06 --- /dev/null +++ b/source/src/main/model/RepairOrder.java @@ -0,0 +1,67 @@ +package model; + +import java.util.List; + +/** + * Represents the state of a repair order, either as an in-progress snapshot or + * as a completed order issued at the end of a repair session. Instances are immutable. + */ +public class RepairOrder { + private final BikeDTO bike; + private final List tasks; + private final String diagnosticReport; + private final Amount total; + + /** + * Creates a RepairOrder with the given repair details. + * + * @param bike The bike being repaired (may be {@code null} if the + * bike has not yet been registered in this session). + * @param tasks The list of repair tasks performed so far. + * @param diagnosticReport The mechanic's diagnostic notes (may be empty). + * @param total The total cost of all tasks added so far. + */ + RepairOrder(BikeDTO bike, List tasks, String diagnosticReport, Amount total) { + this.bike = bike; + this.tasks = tasks; + this.diagnosticReport = diagnosticReport; + this.total = total; + } + + /** + * Returns the bike information for this repair order. + * + * @return The {@link BikeDTO} of the repaired bike, or {@code null} if the bike + * has not yet been registered. + */ + public BikeDTO getBike() { + return bike; + } + + /** + * Returns the list of repair tasks included in this order. + * + * @return An unmodifiable list of {@link TaskDTO} objects. + */ + public List getTasks() { + return tasks; + } + + /** + * Returns the mechanic's diagnostic report for this repair. + * + * @return The diagnostic report string. + */ + public String getDiagnosticReport() { + return diagnosticReport; + } + + /** + * Returns the total cost of all repair tasks in this order. + * + * @return The total as an {@link Amount}. + */ + public Amount getTotal() { + return total; + } +} diff --git a/source/src/main/model/RepairOrderObserver.java b/source/src/main/model/RepairOrderObserver.java new file mode 100644 index 0000000..a05b902 --- /dev/null +++ b/source/src/main/model/RepairOrderObserver.java @@ -0,0 +1,16 @@ +package model; + +/** + * Observer interface for repair order updates. + * Any class that wants to be notified whenever a repair order changes must + * implement this interface and register itself with {@link RepairShop}. + */ +public interface RepairOrderObserver { + + /** + * Called whenever a repair order is created or updated. + * + * @param repairOrder A snapshot of the current repair order state. + */ + void repairOrderUpdated(RepairOrder repairOrder); +} diff --git a/source/src/main/model/RepairShop.java b/source/src/main/model/RepairShop.java new file mode 100644 index 0000000..e18bcf3 --- /dev/null +++ b/source/src/main/model/RepairShop.java @@ -0,0 +1,91 @@ +package model; + +import java.util.ArrayList; +import java.util.List; + +/** + * Facade for the model layer. Manages the lifecycle of one repair session at a time + * and delegates all operations to the current {@link ActiveRepair}. + * + *

Observers registered via {@link #addObserver(RepairOrderObserver)} are + * automatically forwarded to every new repair session that is started.

+ */ +public class RepairShop { + + private ActiveRepair currentRepair; + private final List observers = new ArrayList<>(); + private DiscountStrategy discountStrategy = new NoDiscount(); + + /** + * Creates a new RepairShop ready to accept repair sessions. + */ + public RepairShop() { + } + + /** + * Registers an observer to be notified whenever a repair order changes. + * The observer is passed to every repair session started after this call. + * + * @param observer The observer to register. + */ + public void addObserver(RepairOrderObserver observer) { + observers.add(observer); + } + + /** + * Sets the discount strategy to apply to each new repair session's total. + * + * @param strategy The discount strategy; must not be {@code null}. + */ + public void setDiscountStrategy(DiscountStrategy strategy) { + this.discountStrategy = strategy; + } + + /** + * Starts a new repair session, discarding any previously active session. + */ + public void startRepair() { + currentRepair = new ActiveRepair(); + for (RepairOrderObserver observer : observers) { + currentRepair.addObserver(observer); + } + currentRepair.setDiscountStrategy(discountStrategy); + } + + /** + * Registers the bike that will be worked on during the current repair session. + * + * @param bike The {@link BikeDTO} of the bike to register. + */ + public void registerBike(BikeDTO bike) { + currentRepair.registerBike(bike); + } + + /** + * Adds a repair task to the current session and returns the updated running total. + * + * @param task The {@link TaskDTO} of the task to add. + * @return The running total after the task has been added. + */ + public Amount addTask(TaskDTO task) { + return currentRepair.addTask(task); + } + + /** + * Records the mechanic's diagnostic notes for the current repair session. + * + * @param report The diagnostic report text entered by the mechanic. + */ + public void enterDiagnosticReport(String report) { + currentRepair.enterDiagnosticReport(report); + } + + /** + * Ends the current repair session and returns the completed repair order. + * + * @return The {@link RepairOrder} for the finished repair. + */ + public RepairOrder endRepair() { + return currentRepair.endRepair(); + } +} diff --git a/source/src/main/model/TaskDTO.java b/source/src/main/model/TaskDTO.java new file mode 100644 index 0000000..e44e457 --- /dev/null +++ b/source/src/main/model/TaskDTO.java @@ -0,0 +1,39 @@ +package model; + +/** + * Data Transfer Object that carries repair task information across layer boundaries. + * Instances are immutable. + */ +public class TaskDTO { + private final String name; + private final Amount cost; + + /** + * Creates a TaskDTO with the specified task name and cost. + * + * @param name The name of the repair task. + * @param cost The cost of the repair task. + */ + public TaskDTO(String name, Amount cost) { + this.name = name; + this.cost = cost; + } + + /** + * Returns the repair task name. + * + * @return The task name string. + */ + public String getName() { + return name; + } + + /** + * Returns the cost of this repair task. + * + * @return The cost as an {@link Amount}. + */ + public Amount getCost() { + return cost; + } +} diff --git a/source/src/main/startup/Main.java b/source/src/main/startup/Main.java new file mode 100644 index 0000000..c8b97d0 --- /dev/null +++ b/source/src/main/startup/Main.java @@ -0,0 +1,41 @@ +package startup; + +import controller.Controller; +import integration.BikeRegistry; +import integration.CustomerRegistry; +import integration.RepairTaskCatalog; +import model.LoyaltyDiscount; +import model.RepairShop; +import view.RepairOrderLogger; +import view.RepairOrderView; +import view.View; + +/** + * Contains the application entry point. Responsible for creating and wiring + * all top-level objects in dependency order. + */ +public class Main { + + /** + * Starts the Repair Electric Bike application. + * + * @param args Command-line arguments (not used). + */ + public static void main(String[] args) { + // Integration layer + BikeRegistry bikeRegistry = BikeRegistry.getInstance(); // Singleton + RepairTaskCatalog taskCatalog = new RepairTaskCatalog(); + CustomerRegistry customerRegistry = new CustomerRegistry(); + + // Model layer — wire observers and discount strategy before first session + RepairShop repairShop = new RepairShop(); + repairShop.addObserver(new RepairOrderView()); + repairShop.addObserver(new RepairOrderLogger()); + repairShop.setDiscountStrategy(new LoyaltyDiscount()); // Strategy pattern + + // Controller and view + Controller controller = new Controller(repairShop, bikeRegistry, taskCatalog, customerRegistry); + View view = new View(controller); + view.runFakeExecution(); + } +} diff --git a/source/src/main/util/ErrorLogger.java b/source/src/main/util/ErrorLogger.java new file mode 100644 index 0000000..c12b988 --- /dev/null +++ b/source/src/main/util/ErrorLogger.java @@ -0,0 +1,50 @@ +package util; + +import java.io.PrintWriter; +import java.io.StringWriter; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.time.format.FormatStyle; + +/** + * Logs error information about unexpected exceptions to a dedicated error log file. + * This logger is intended for developer-level diagnostics: whenever an exception + * indicates that the program is not functioning as intended, the view catches it and + * calls {@link #logException(Exception)} to record a timestamped full report. + * Following the pattern in listing 8.25 of the course textbook, only exceptions that + * are not business-logic errors are logged here. + */ +public class ErrorLogger { + + private static final FileLogger FILE_LOGGER = new FileLogger("error.log"); + + /** + * Private constructor — this class is a utility class and should not be instantiated. + */ + private ErrorLogger() { + } + + /** + * Logs the exception message and its full stack trace to {@code error.log}. + * The log entry begins with a timestamp followed by the exception message, then + * the complete stack trace, following the pattern from listing 8.25 of the textbook. + * + * @param exception The exception that indicates a program fault. + */ + public static void logException(Exception exception) { + StringBuilder logMsgBuilder = new StringBuilder(); + logMsgBuilder.append(createTime()); + logMsgBuilder.append(", Exception was thrown: "); + logMsgBuilder.append(exception.getMessage()); + StringWriter sw = new StringWriter(); + exception.printStackTrace(new PrintWriter(sw)); + logMsgBuilder.append("\n").append(sw.toString()); + FILE_LOGGER.log(logMsgBuilder.toString()); + } + + private static String createTime() { + LocalDateTime now = LocalDateTime.now(); + DateTimeFormatter formatter = DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM); + return now.format(formatter); + } +} diff --git a/source/src/main/util/FileLogger.java b/source/src/main/util/FileLogger.java new file mode 100644 index 0000000..e54f06e --- /dev/null +++ b/source/src/main/util/FileLogger.java @@ -0,0 +1,48 @@ +package util; + +import java.io.FileWriter; +import java.io.IOException; +import java.io.PrintWriter; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; + +/** + * Writes timestamped log messages to a text file. The log stream is opened once + * in the constructor and kept open for the lifetime of this object, following the + * pattern illustrated in listing 9.1 of the course textbook. + */ +public class FileLogger { + + private static final DateTimeFormatter FORMATTER = + DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); + + private PrintWriter logStream; + + /** + * Creates a FileLogger that appends to the specified file. If the file does not + * exist it is created. If the log stream cannot be opened, an error is printed + * to standard output and subsequent calls to {@link #log(String)} are silently + * ignored. + * + * @param filename The path to the log file. + */ + public FileLogger(String filename) { + try { + logStream = new PrintWriter(new FileWriter(filename, true), true); + } catch (IOException ioe) { + System.out.println("CAN NOT LOG."); + ioe.printStackTrace(); + } + } + + /** + * Appends a timestamped line to the log file. + * + * @param message The message to log. + */ + public void log(String message) { + if (logStream != null) { + logStream.println("[" + LocalDateTime.now().format(FORMATTER) + "] " + message); + } + } +} diff --git a/source/src/main/view/RepairOrderLogger.java b/source/src/main/view/RepairOrderLogger.java new file mode 100644 index 0000000..3cb7459 --- /dev/null +++ b/source/src/main/view/RepairOrderLogger.java @@ -0,0 +1,59 @@ +package view; + +import model.RepairOrder; +import model.RepairOrderObserver; +import model.TaskDTO; +import util.FileLogger; + +/** + * An observer that writes the current repair order to a log file whenever it is + * updated. This provides a persistent audit trail of all repair order state changes. + */ +public class RepairOrderLogger implements RepairOrderObserver { + + private final FileLogger fileLogger; + + /** + * Creates a RepairOrderLogger that appends to {@code repair-orders.log}. + */ + public RepairOrderLogger() { + this.fileLogger = new FileLogger("repair-orders.log"); + } + + /** + * Called whenever a repair order is created or updated. + * Writes the full contents of the repair order to the log file. + * + * @param repairOrder A snapshot of the current repair order state. + */ + @Override + public void repairOrderUpdated(RepairOrder repairOrder) { + StringBuilder sb = new StringBuilder(); + sb.append("RepairOrder updated | "); + sb.append("Bike: ").append( + repairOrder.getBike() != null + ? repairOrder.getBike().getBikeID() + : "N/A"); + sb.append(" | Tasks: ").append(formatTasks(repairOrder)); + sb.append(" | Total: ").append(repairOrder.getTotal()); + sb.append(" | Report: ").append( + repairOrder.getDiagnosticReport().isEmpty() + ? "(none)" + : repairOrder.getDiagnosticReport()); + fileLogger.log(sb.toString()); + } + + private String formatTasks(RepairOrder repairOrder) { + if (repairOrder.getTasks().isEmpty()) { + return "(none)"; + } + StringBuilder sb = new StringBuilder(); + for (TaskDTO task : repairOrder.getTasks()) { + if (sb.length() > 0) { + sb.append(", "); + } + sb.append(task.getName()); + } + return sb.toString(); + } +} diff --git a/source/src/main/view/RepairOrderView.java b/source/src/main/view/RepairOrderView.java new file mode 100644 index 0000000..c91c3e3 --- /dev/null +++ b/source/src/main/view/RepairOrderView.java @@ -0,0 +1,49 @@ +package view; + +import model.RepairOrder; +import model.RepairOrderObserver; +import model.TaskDTO; + +/** + * An observer that prints the current repair order to standard output whenever + * it is updated. This view replaces the need for a technician or receptionist to + * manually query the system for the latest repair order state. + */ +public class RepairOrderView implements RepairOrderObserver { + + /** + * Called whenever a repair order is created or updated. + * Prints the full contents of the repair order to {@code System.out}. + * + * @param repairOrder A snapshot of the current repair order state. + */ + @Override + public void repairOrderUpdated(RepairOrder repairOrder) { + System.out.println(); + System.out.println(">>> [RepairOrderView] Repair order updated <<<"); + System.out.println(" Bike : " + + (repairOrder.getBike() != null + ? repairOrder.getBike().getBikeID() + " (" + repairOrder.getBike().getOwnerName() + ")" + : "(not yet registered)")); + System.out.println(" Tasks : " + formatTasks(repairOrder)); + System.out.println(" Total : " + repairOrder.getTotal()); + System.out.println(" Report : " + + (repairOrder.getDiagnosticReport().isEmpty() + ? "(none)" + : repairOrder.getDiagnosticReport())); + } + + private String formatTasks(RepairOrder repairOrder) { + if (repairOrder.getTasks().isEmpty()) { + return "(none)"; + } + StringBuilder sb = new StringBuilder(); + for (TaskDTO task : repairOrder.getTasks()) { + if (sb.length() > 0) { + sb.append(", "); + } + sb.append(task.getName()).append(" (").append(task.getCost()).append(")"); + } + return sb.toString(); + } +} diff --git a/source/src/main/view/View.java b/source/src/main/view/View.java new file mode 100644 index 0000000..259e18a --- /dev/null +++ b/source/src/main/view/View.java @@ -0,0 +1,163 @@ +package view; + +import controller.Controller; +import integration.BikeNotFoundException; +import integration.CustomerNotFoundException; +import integration.DatabaseFailureException; +import integration.TaskNotFoundException; +import model.Amount; +import model.BikeDTO; +import model.CustomerDTO; +import model.RepairOrder; +import model.TaskDTO; +import util.ErrorLogger; + +/** + * Placeholder view that simulates mechanic and receptionist interactions using + * hard-coded method calls. All output is printed to standard output. Caught + * exceptions that indicate user errors are shown as informative messages; + * exceptions that indicate system faults are additionally written to the error log. + */ +public class View { + + private final Controller controller; + + /** + * Creates a View connected to the given controller. + * + * @param controller The controller to use for all system operations. + */ + public View(Controller controller) { + this.controller = controller; + } + + /** + * Simulates a complete repair session using hard-coded inputs. Demonstrates + * both the happy path and a selection of error scenarios. + */ + public void runFakeExecution() { + System.out.println("=== Repair Electric Bike System — Seminar 4 ==="); + System.out.println(); + + // ---------------------------------------------------------------- + // Demo 1: Full happy-path run with loyalty discount + // ---------------------------------------------------------------- + System.out.println("--- DEMO 1: Happy path with loyalty discount ---"); + controller.startNewRepair(); + System.out.println("[Mechanic] Started new repair session."); + + lookupCustomer("0701234567"); + + enterBikeID("BIKE-001"); + + addRepairTask("Brake Pad Replacement"); + addRepairTask("Tire Replacement"); + + String notes = "Front brake pads fully worn. Front tire flat (puncture). " + + "Both parts replaced. Bike tested and approved."; + controller.enterDiagnosticReport(notes); + System.out.println("[Mechanic] Diagnostic report entered."); + + RepairOrder repairOrder = controller.endRepair(); + printRepairOrder(repairOrder); + + // ---------------------------------------------------------------- + // Demo 2: Customer not found (alternative flow 5a) + // ---------------------------------------------------------------- + System.out.println(); + System.out.println("--- DEMO 2: Customer phone number not found (alt flow 5a) ---"); + controller.startNewRepair(); + lookupCustomer("0700000000"); // unknown phone number + + // ---------------------------------------------------------------- + // Demo 3: Bike not found + // ---------------------------------------------------------------- + System.out.println(); + System.out.println("--- DEMO 3: Bike ID not found ---"); + controller.startNewRepair(); + enterBikeID("BIKE-999"); // unknown bike ID + + // ---------------------------------------------------------------- + // Demo 4: Task not found + // ---------------------------------------------------------------- + System.out.println(); + System.out.println("--- DEMO 4: Repair task not found ---"); + controller.startNewRepair(); + enterBikeID("BIKE-002"); + addRepairTask("Rocket Booster Installation"); // unknown task name + + // ---------------------------------------------------------------- + // Demo 5: Database failure (simulated) + // ---------------------------------------------------------------- + System.out.println(); + System.out.println("--- DEMO 5: Simulated database failure ---"); + controller.startNewRepair(); + enterBikeID("DB-FAIL-999"); // hardcoded failure trigger + } + + // ------------------------------------------------------------------ + // Private helper methods — each wraps one controller call and handles + // all exceptions at the view level. + // ------------------------------------------------------------------ + + private void lookupCustomer(String phoneNumber) { + try { + CustomerDTO customer = controller.lookupCustomer(phoneNumber); + System.out.println("[System] Customer found: " + customer.getName() + + " (phone: " + customer.getPhoneNumber() + ")"); + } catch (CustomerNotFoundException e) { + System.out.println("[System] ERROR: " + e.getMessage() + + " — please check the phone number and try again."); + } + } + + private void enterBikeID(String bikeID) { + try { + BikeDTO bike = controller.enterBikeID(bikeID); + System.out.println("[System] Bike registered: ID=" + bike.getBikeID() + + ", Owner=" + bike.getOwnerName()); + } catch (BikeNotFoundException e) { + System.out.println("[System] ERROR: " + e.getMessage() + + " — please enter a valid bike ID."); + } catch (DatabaseFailureException e) { + System.out.println("[System] ERROR: The system is temporarily unavailable. " + + "Please try again later."); + ErrorLogger.logException(e); + } + } + + private void addRepairTask(String taskName) { + try { + Amount runningTotal = controller.addRepairTask(taskName); + System.out.println("[System] Added task: \"" + taskName + + "\" | Running total: " + runningTotal); + } catch (TaskNotFoundException e) { + System.out.println("[System] ERROR: " + e.getMessage() + + " — please enter a valid task name."); + } catch (DatabaseFailureException e) { + System.out.println("[System] ERROR: The system is temporarily unavailable. " + + "Please try again later."); + ErrorLogger.logException(e); + } + } + + private void printRepairOrder(RepairOrder repairOrder) { + System.out.println(); + System.out.println("========================================"); + System.out.println(" REPAIR ORDER "); + System.out.println("========================================"); + System.out.println("Bike ID : " + repairOrder.getBike().getBikeID()); + System.out.println("Owner : " + repairOrder.getBike().getOwnerName()); + System.out.println("----------------------------------------"); + System.out.println("Repair Tasks:"); + for (TaskDTO task : repairOrder.getTasks()) { + System.out.printf(" %-28s %s%n", task.getName(), task.getCost()); + } + System.out.println("----------------------------------------"); + System.out.println("Total : " + repairOrder.getTotal()); + System.out.println("----------------------------------------"); + System.out.println("Diagnostic Report:"); + System.out.println(" " + repairOrder.getDiagnosticReport()); + System.out.println("========================================"); + } +} diff --git a/source/src/test/controller/ControllerTest.java b/source/src/test/controller/ControllerTest.java new file mode 100644 index 0000000..d15f667 --- /dev/null +++ b/source/src/test/controller/ControllerTest.java @@ -0,0 +1,138 @@ +package controller; + +import integration.BikeNotFoundException; +import integration.BikeRegistry; +import integration.CustomerNotFoundException; +import integration.CustomerRegistry; +import integration.DatabaseFailureException; +import integration.RepairTaskCatalog; +import integration.TaskNotFoundException; +import model.Amount; +import model.BikeDTO; +import model.CustomerDTO; +import model.RepairOrder; +import model.RepairShop; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +class ControllerTest { + + private Controller controller; + + @BeforeEach + void setUp() { + BikeRegistry bikeRegistry = BikeRegistry.getInstance(); + RepairTaskCatalog taskCatalog = new RepairTaskCatalog(); + CustomerRegistry customerRegistry = new CustomerRegistry(); + RepairShop repairShop = new RepairShop(); + controller = new Controller(repairShop, bikeRegistry, taskCatalog, customerRegistry); + controller.startNewRepair(); + } + + // --- Customer lookup --- + + @Test + void lookupCustomerWithKnownPhoneReturnsCorrectCustomer() throws CustomerNotFoundException { + CustomerDTO customer = controller.lookupCustomer("0701234567"); + assertEquals("Alice Svensson", customer.getName()); + } + + @Test + void lookupCustomerWithUnknownPhoneThrowsCustomerNotFoundException() { + assertThrows(CustomerNotFoundException.class, + () -> controller.lookupCustomer("0700000000")); + } + + // --- Bike ID entry --- + + @Test + void enterBikeIdWithValidIdReturnsCorrectBikeId() throws BikeNotFoundException { + BikeDTO bike = controller.enterBikeID("BIKE-001"); + assertEquals("BIKE-001", bike.getBikeID()); + } + + @Test + void enterBikeIdWithUnknownIdThrowsBikeNotFoundException() { + assertThrows(BikeNotFoundException.class, + () -> controller.enterBikeID("BIKE-999")); + } + + @Test + void enterBikeIdWithDatabaseFailureIdThrowsDatabaseFailureException() { + assertThrows(DatabaseFailureException.class, + () -> controller.enterBikeID(BikeRegistry.DB_FAILURE_ID)); + } + + // --- Repair tasks --- + + @Test + void addRepairTaskWithValidNameReturnsCorrectRunningTotal() + throws BikeNotFoundException, TaskNotFoundException { + controller.enterBikeID("BIKE-001"); + Amount total = controller.addRepairTask("Brake Pad Replacement"); + assertEquals(350.0, total.getValue(), 0.001); + } + + @Test + void addTwoRepairTasksReturnsCumulativeTotal() + throws BikeNotFoundException, TaskNotFoundException { + controller.enterBikeID("BIKE-001"); + controller.addRepairTask("Brake Pad Replacement"); + Amount total = controller.addRepairTask("Tire Replacement"); + assertEquals(850.0, total.getValue(), 0.001); + } + + @Test + void addRepairTaskWithUnknownNameThrowsTaskNotFoundException() + throws BikeNotFoundException { + controller.enterBikeID("BIKE-001"); + assertThrows(TaskNotFoundException.class, + () -> controller.addRepairTask("Non-existing Task")); + } + + // --- End repair --- + + @Test + void endRepairReturnsNonNullRepairOrder() + throws BikeNotFoundException, TaskNotFoundException { + controller.enterBikeID("BIKE-001"); + controller.addRepairTask("Battery Check"); + RepairOrder repairOrder = controller.endRepair(); + assertNotNull(repairOrder); + } + + @Test + void endRepairReturnsRepairOrderWithCorrectBikeId() throws BikeNotFoundException { + controller.enterBikeID("BIKE-002"); + RepairOrder repairOrder = controller.endRepair(); + assertEquals("BIKE-002", repairOrder.getBike().getBikeID()); + } + + @Test + void enterDiagnosticReportIsReflectedInFinalRepairOrder() + throws BikeNotFoundException { + controller.enterBikeID("BIKE-003"); + controller.enterDiagnosticReport("Chain replaced and tested."); + RepairOrder repairOrder = controller.endRepair(); + assertEquals("Chain replaced and tested.", repairOrder.getDiagnosticReport()); + } + + // --- State preservation on exception --- + + @Test + void addTaskAfterFailedAddTaskStillUsesCorrectRunningTotal() + throws BikeNotFoundException, TaskNotFoundException { + controller.enterBikeID("BIKE-001"); + controller.addRepairTask("Brake Pad Replacement"); // +350 + + // Failed task — state must not change + assertThrows(TaskNotFoundException.class, + () -> controller.addRepairTask("Bad Task Name")); + + // Next valid task should build on the previous total + Amount total = controller.addRepairTask("Battery Check"); // +200 + assertEquals(550.0, total.getValue(), 0.001); + } +} diff --git a/source/src/test/integration/BikeRegistryTest.java b/source/src/test/integration/BikeRegistryTest.java new file mode 100644 index 0000000..3e417c7 --- /dev/null +++ b/source/src/test/integration/BikeRegistryTest.java @@ -0,0 +1,76 @@ +package integration; + +import model.BikeDTO; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +class BikeRegistryTest { + + // BikeRegistry is a Singleton — obtain the shared instance in each test. + + @Test + void findBikeWithExistingIdReturnsBikeDTO() throws BikeNotFoundException { + BikeDTO bike = BikeRegistry.getInstance().findBike("BIKE-001"); + assertNotNull(bike); + } + + @Test + void findBikeWithExistingIdReturnsCorrectBikeId() throws BikeNotFoundException { + BikeDTO bike = BikeRegistry.getInstance().findBike("BIKE-001"); + assertEquals("BIKE-001", bike.getBikeID()); + } + + @Test + void findBikeWithExistingIdReturnsCorrectOwnerName() throws BikeNotFoundException { + BikeDTO bike = BikeRegistry.getInstance().findBike("BIKE-002"); + assertEquals("Bob Lindqvist", bike.getOwnerName()); + } + + @Test + void findBikeWithAllThreeSampleBikesReturnsNonNull() throws BikeNotFoundException { + assertNotNull(BikeRegistry.getInstance().findBike("BIKE-001")); + assertNotNull(BikeRegistry.getInstance().findBike("BIKE-002")); + assertNotNull(BikeRegistry.getInstance().findBike("BIKE-003")); + } + + @Test + void findBikeWithUnknownIdThrowsBikeNotFoundException() { + assertThrows(BikeNotFoundException.class, + () -> BikeRegistry.getInstance().findBike("BIKE-999")); + } + + @Test + void thrownBikeNotFoundExceptionContainsCorrectBikeId() { + BikeNotFoundException ex = assertThrows(BikeNotFoundException.class, + () -> BikeRegistry.getInstance().findBike("BIKE-999")); + assertEquals("BIKE-999", ex.getBikeID()); + } + + @Test + void thrownBikeNotFoundExceptionMessageMentionsBikeId() { + BikeNotFoundException ex = assertThrows(BikeNotFoundException.class, + () -> BikeRegistry.getInstance().findBike("BIKE-999")); + assertTrue(ex.getMessage().contains("BIKE-999")); + } + + @Test + void findBikeWithDatabaseFailureIdThrowsDatabaseFailureException() { + assertThrows(DatabaseFailureException.class, + () -> BikeRegistry.getInstance().findBike(BikeRegistry.DB_FAILURE_ID)); + } + + @Test + void thrownDatabaseFailureExceptionContainsOperationName() { + DatabaseFailureException ex = assertThrows(DatabaseFailureException.class, + () -> BikeRegistry.getInstance().findBike(BikeRegistry.DB_FAILURE_ID)); + assertTrue(ex.getMessage().contains("BikeRegistry.findBike")); + } + + @Test + void getInstanceReturnsSameInstanceOnMultipleCalls() { + BikeRegistry first = BikeRegistry.getInstance(); + BikeRegistry second = BikeRegistry.getInstance(); + assertSame(first, second); + } +} diff --git a/source/src/test/integration/CustomerRegistryTest.java b/source/src/test/integration/CustomerRegistryTest.java new file mode 100644 index 0000000..d4b60e6 --- /dev/null +++ b/source/src/test/integration/CustomerRegistryTest.java @@ -0,0 +1,56 @@ +package integration; + +import model.CustomerDTO; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +class CustomerRegistryTest { + + private CustomerRegistry customerRegistry; + + @BeforeEach + void setUp() { + customerRegistry = new CustomerRegistry(); + } + + @Test + void findCustomerWithKnownPhoneReturnsCorrectCustomer() throws CustomerNotFoundException { + CustomerDTO customer = customerRegistry.findCustomer("0701234567"); + assertEquals("Alice Svensson", customer.getName()); + } + + @Test + void findCustomerWithKnownPhoneReturnsCorrectPhoneNumber() throws CustomerNotFoundException { + CustomerDTO customer = customerRegistry.findCustomer("0701234567"); + assertEquals("0701234567", customer.getPhoneNumber()); + } + + @Test + void findCustomerWithAllThreeSamplePhonesReturnsNonNull() throws CustomerNotFoundException { + assertNotNull(customerRegistry.findCustomer("0701234567")); + assertNotNull(customerRegistry.findCustomer("0709876543")); + assertNotNull(customerRegistry.findCustomer("0705551234")); + } + + @Test + void findCustomerWithUnknownPhoneThrowsCustomerNotFoundException() { + assertThrows(CustomerNotFoundException.class, + () -> customerRegistry.findCustomer("0700000000")); + } + + @Test + void thrownCustomerNotFoundExceptionContainsCorrectPhoneNumber() { + CustomerNotFoundException ex = assertThrows(CustomerNotFoundException.class, + () -> customerRegistry.findCustomer("0799999999")); + assertEquals("0799999999", ex.getPhoneNumber()); + } + + @Test + void thrownCustomerNotFoundExceptionMessageMentionsPhoneNumber() { + CustomerNotFoundException ex = assertThrows(CustomerNotFoundException.class, + () -> customerRegistry.findCustomer("0799999999")); + assertTrue(ex.getMessage().contains("0799999999")); + } +} diff --git a/source/src/test/integration/RepairTaskCatalogTest.java b/source/src/test/integration/RepairTaskCatalogTest.java new file mode 100644 index 0000000..01e5db1 --- /dev/null +++ b/source/src/test/integration/RepairTaskCatalogTest.java @@ -0,0 +1,58 @@ +package integration; + +import model.Amount; +import model.TaskDTO; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +class RepairTaskCatalogTest { + + private RepairTaskCatalog catalog; + + @BeforeEach + void setUp() { + catalog = new RepairTaskCatalog(); + } + + @Test + void findTaskWithExistingNameReturnsTaskDTO() throws TaskNotFoundException { + TaskDTO task = catalog.findTask("Brake Pad Replacement"); + assertNotNull(task); + } + + @Test + void findTaskWithExistingNameReturnsCorrectCost() throws TaskNotFoundException { + TaskDTO task = catalog.findTask("Battery Check"); + assertEquals(200.0, task.getCost().getValue(), 0.001); + } + + @Test + void findTaskWithAllFourSampleTasksReturnsNonNull() throws TaskNotFoundException { + assertNotNull(catalog.findTask("Brake Pad Replacement")); + assertNotNull(catalog.findTask("Tire Replacement")); + assertNotNull(catalog.findTask("Battery Check")); + assertNotNull(catalog.findTask("Chain Lubrication")); + } + + @Test + void findTaskWithUnknownNameThrowsTaskNotFoundException() { + assertThrows(TaskNotFoundException.class, + () -> catalog.findTask("Rocket Booster Installation")); + } + + @Test + void thrownTaskNotFoundExceptionContainsCorrectTaskName() { + TaskNotFoundException ex = assertThrows(TaskNotFoundException.class, + () -> catalog.findTask("Unknown Task")); + assertEquals("Unknown Task", ex.getTaskName()); + } + + @Test + void thrownTaskNotFoundExceptionMessageMentionsTaskName() { + TaskNotFoundException ex = assertThrows(TaskNotFoundException.class, + () -> catalog.findTask("Unknown Task")); + assertTrue(ex.getMessage().contains("Unknown Task")); + } +} diff --git a/source/src/test/model/ActiveRepairTest.java b/source/src/test/model/ActiveRepairTest.java new file mode 100644 index 0000000..eb03e3e --- /dev/null +++ b/source/src/test/model/ActiveRepairTest.java @@ -0,0 +1,106 @@ +package model; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +class ActiveRepairTest { + + private ActiveRepair activeRepair; + + @BeforeEach + void setUp() { + activeRepair = new ActiveRepair(); + } + + @Test + void addSingleTaskReturnsTaskCostAsRunningTotal() { + TaskDTO task = new TaskDTO("Brake Pad Replacement", new Amount(350)); + Amount runningTotal = activeRepair.addTask(task); + assertEquals(350, runningTotal.getValue(), 0.001); + } + + @Test + void addTwoTasksReturnsCorrectCumulativeTotal() { + activeRepair.addTask(new TaskDTO("Brake Pad Replacement", new Amount(350))); + Amount runningTotal = activeRepair.addTask(new TaskDTO("Tire Replacement", new Amount(500))); + assertEquals(850, runningTotal.getValue(), 0.001); + } + + @Test + void endRepairWithRegisteredBikeReturnsBikeInRepairOrder() { + BikeDTO bike = new BikeDTO("BIKE-001", "Alice Svensson"); + activeRepair.registerBike(bike); + RepairOrder repairOrder = activeRepair.endRepair(); + assertEquals("BIKE-001", repairOrder.getBike().getBikeID()); + } + + @Test + void endRepairAfterAddingTasksReturnsAllTasksInOrder() { + activeRepair.registerBike(new BikeDTO("BIKE-001", "Alice Svensson")); + activeRepair.addTask(new TaskDTO("Brake Pad Replacement", new Amount(350))); + activeRepair.addTask(new TaskDTO("Tire Replacement", new Amount(500))); + RepairOrder repairOrder = activeRepair.endRepair(); + assertEquals(2, repairOrder.getTasks().size()); + } + + @Test + void endRepairWithNoDiscountReturnsUnchangedTotal() { + activeRepair.registerBike(new BikeDTO("BIKE-001", "Alice Svensson")); + activeRepair.addTask(new TaskDTO("Brake Pad Replacement", new Amount(350))); + RepairOrder repairOrder = activeRepair.endRepair(); + assertEquals(350, repairOrder.getTotal().getValue(), 0.001); + } + + @Test + void endRepairWithLoyaltyDiscountReducesTotalByTenPercent() { + activeRepair.setDiscountStrategy(new LoyaltyDiscount()); + activeRepair.registerBike(new BikeDTO("BIKE-001", "Alice Svensson")); + activeRepair.addTask(new TaskDTO("Tire Replacement", new Amount(1000))); + RepairOrder repairOrder = activeRepair.endRepair(); + assertEquals(900, repairOrder.getTotal().getValue(), 0.001); + } + + @Test + void endRepairWithoutTasksReturnsTotalOfZero() { + activeRepair.registerBike(new BikeDTO("BIKE-001", "Alice Svensson")); + RepairOrder repairOrder = activeRepair.endRepair(); + assertEquals(0, repairOrder.getTotal().getValue(), 0.001); + } + + @Test + void enterDiagnosticReportIsIncludedInRepairOrder() { + activeRepair.registerBike(new BikeDTO("BIKE-001", "Alice Svensson")); + String report = "Front tire replaced."; + activeRepair.enterDiagnosticReport(report); + RepairOrder repairOrder = activeRepair.endRepair(); + assertEquals(report, repairOrder.getDiagnosticReport()); + } + + @Test + void observerIsNotifiedWhenBikeIsRegistered() { + boolean[] notified = {false}; + activeRepair.addObserver(order -> notified[0] = true); + activeRepair.registerBike(new BikeDTO("BIKE-001", "Alice Svensson")); + assertTrue(notified[0]); + } + + @Test + void observerIsNotifiedWhenTaskIsAdded() { + boolean[] notified = {false}; + activeRepair.addObserver(order -> notified[0] = true); + activeRepair.addTask(new TaskDTO("Battery Check", new Amount(200))); + assertTrue(notified[0]); + } + + @Test + void observerReceivesCorrectBikeWhenNotified() { + BikeDTO[] received = {null}; + activeRepair.addObserver(order -> received[0] = order.getBike()); + BikeDTO bike = new BikeDTO("BIKE-001", "Alice Svensson"); + activeRepair.registerBike(bike); + assertNotNull(received[0]); + assertEquals("BIKE-001", received[0].getBikeID()); + } +} diff --git a/source/src/test/model/AmountTest.java b/source/src/test/model/AmountTest.java new file mode 100644 index 0000000..a181479 --- /dev/null +++ b/source/src/test/model/AmountTest.java @@ -0,0 +1,41 @@ +package model; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +class AmountTest { + + @Test + void addReturnsCorrectSum() { + Amount a = new Amount(300); + Amount b = new Amount(50); + assertEquals(350.0, a.add(b).getValue(), 0.001); + } + + @Test + void subtractReturnsCorrectDifference() { + Amount a = new Amount(500); + Amount b = new Amount(200); + assertEquals(300.0, a.subtract(b).getValue(), 0.001); + } + + @Test + void multiplyByOneReturnsUnchangedValue() { + Amount a = new Amount(400); + assertEquals(400.0, a.multiply(1.0).getValue(), 0.001); + } + + @Test + void multiplyByNinetyPercentReturnsDiscountedValue() { + Amount a = new Amount(1000); + assertEquals(900.0, a.multiply(0.9).getValue(), 0.001); + } + + @Test + void toStringFormatsValueWithTwoDecimalPlaces() { + Amount a = new Amount(123.456); + assertTrue(a.toString().startsWith("123.46")); + } +} diff --git a/source/src/test/model/RepairShopTest.java b/source/src/test/model/RepairShopTest.java new file mode 100644 index 0000000..4939603 --- /dev/null +++ b/source/src/test/model/RepairShopTest.java @@ -0,0 +1,50 @@ +package model; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +class RepairShopTest { + + private RepairShop repairShop; + + @BeforeEach + void setUp() { + repairShop = new RepairShop(); + repairShop.startRepair(); + } + + @Test + void registerBikeAndEndRepairReturnsBikeInOrder() { + repairShop.registerBike(new BikeDTO("BIKE-001", "Alice Svensson")); + RepairOrder order = repairShop.endRepair(); + assertEquals("BIKE-001", order.getBike().getBikeID()); + } + + @Test + void addTaskReturnsCorrectRunningTotal() { + repairShop.registerBike(new BikeDTO("BIKE-001", "Alice Svensson")); + Amount total = repairShop.addTask(new TaskDTO("Brake Pad Replacement", new Amount(350))); + assertEquals(350, total.getValue(), 0.001); + } + + @Test + void observerRegisteredOnShopIsNotifiedOnRepairUpdate() { + boolean[] notified = {false}; + repairShop.addObserver(order -> notified[0] = true); + repairShop.startRepair(); // new session picks up the observer + repairShop.registerBike(new BikeDTO("BIKE-001", "Alice Svensson")); + assertTrue(notified[0]); + } + + @Test + void discountStrategySetOnShopIsAppliedToEndRepair() { + repairShop.setDiscountStrategy(new LoyaltyDiscount()); + repairShop.startRepair(); + repairShop.registerBike(new BikeDTO("BIKE-001", "Alice Svensson")); + repairShop.addTask(new TaskDTO("Tire Replacement", new Amount(1000))); + RepairOrder order = repairShop.endRepair(); + assertEquals(900, order.getTotal().getValue(), 0.001); + } +} diff --git a/source/src/test/util/ErrorLoggerTest.java b/source/src/test/util/ErrorLoggerTest.java new file mode 100644 index 0000000..f6022e1 --- /dev/null +++ b/source/src/test/util/ErrorLoggerTest.java @@ -0,0 +1,58 @@ +package util; + +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Tests for ErrorLogger. Because ErrorLogger uses a static FileLogger that opens + * the log file once on class load, these tests append to whatever state the file + * is already in and clean up only at the very end. + */ +class ErrorLoggerTest { + + private static final String ERROR_LOG_FILE = "error.log"; + + @AfterAll + static void cleanUp() throws IOException { + Files.deleteIfExists(Paths.get(ERROR_LOG_FILE)); + } + + @Test + void logExceptionDoesNotThrow() { + // Smoke test: logException must not propagate any exception. + assertDoesNotThrow(() -> ErrorLogger.logException( + new RuntimeException("smoke test error"))); + } + + @Test + void logExceptionCreatesLogFile() { + ErrorLogger.logException(new RuntimeException("file creation test")); + assertTrue(Files.exists(Paths.get(ERROR_LOG_FILE)), + "error.log should exist after logException is called"); + } + + @Test + void logExceptionWritesExceptionMessageToFile() throws IOException { + String uniqueMsg = "unique-db-failure-test-" + System.nanoTime(); + ErrorLogger.logException(new RuntimeException(uniqueMsg)); + String content = Files.readString(Paths.get(ERROR_LOG_FILE)); + assertTrue(content.contains(uniqueMsg), + "Log file should contain the exception message"); + } + + @Test + void logExceptionWritesStackTraceToFile() throws IOException { + ErrorLogger.logException(new RuntimeException("stack trace check")); + String content = Files.readString(Paths.get(ERROR_LOG_FILE)); + assertTrue(content.contains("RuntimeException"), + "Log file should contain the exception class name in the stack trace"); + } +} + diff --git a/source/src/test/util/FileLoggerTest.java b/source/src/test/util/FileLoggerTest.java new file mode 100644 index 0000000..5eab12d --- /dev/null +++ b/source/src/test/util/FileLoggerTest.java @@ -0,0 +1,56 @@ +package util; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +class FileLoggerTest { + + private static final String TEST_LOG_FILE = "test-file-logger.log"; + private Path logPath; + + @BeforeEach + void setUp() throws IOException { + logPath = Paths.get(TEST_LOG_FILE); + Files.deleteIfExists(logPath); + } + + @AfterEach + void tearDown() throws IOException { + Files.deleteIfExists(logPath); + } + + @Test + void logCreatesFileIfItDoesNotExist() { + FileLogger logger = new FileLogger(TEST_LOG_FILE); + logger.log("hello"); + assertTrue(Files.exists(logPath)); + } + + @Test + void logWritesMessageToFile() throws IOException { + FileLogger logger = new FileLogger(TEST_LOG_FILE); + logger.log("test message"); + List lines = Files.readAllLines(logPath); + assertFalse(lines.isEmpty()); + assertTrue(lines.get(0).contains("test message"), + "Log file should contain the logged message"); + } + + @Test + void logAppendsMultipleMessages() throws IOException { + FileLogger logger = new FileLogger(TEST_LOG_FILE); + logger.log("first"); + logger.log("second"); + List lines = Files.readAllLines(logPath); + assertEquals(2, lines.size(), "Two messages should produce two lines"); + } +}