This commit is contained in:
2026-05-17 19:23:06 +02:00
commit 75a3390ef4
36 changed files with 2035 additions and 0 deletions

17
source/.gitignore vendored Normal file
View File

@@ -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

51
source/pom.xml Normal file
View File

@@ -0,0 +1,51 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>se.kth.iv1350</groupId>
<artifactId>repairbike</artifactId>
<version>1.0-SNAPSHOT</version>
<packaging>jar</packaging>
<properties>
<maven.compiler.source>11</maven.compiler.source>
<maven.compiler.target>11</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<dependencies>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>5.10.0</version>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<sourceDirectory>src/main</sourceDirectory>
<testSourceDirectory>src/test</testSourceDirectory>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.1.2</version>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-jar-plugin</artifactId>
<version>3.3.0</version>
<configuration>
<archive>
<manifest>
<mainClass>startup.Main</mainClass>
</manifest>
</archive>
</configuration>
</plugin>
</plugins>
</build>
</project>

View File

@@ -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();
}
}

View File

@@ -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;
}
}

View File

@@ -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.
*
* <p>The bike ID {@code "DB-FAIL-999"} is hardcoded to always trigger a
* {@link DatabaseFailureException}, simulating an unavailable database server.</p>
*/
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<String, BikeDTO> 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;
}
}

View File

@@ -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;
}
}

View File

@@ -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<String, CustomerDTO> 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;
}
}

View File

@@ -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;
}
}

View File

@@ -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<String, TaskDTO> 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;
}
}

View File

@@ -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;
}
}

View File

@@ -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}.
*
* <p>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.</p>
*/
class ActiveRepair {
private BikeDTO bike;
private final List<TaskDTO> tasks = new ArrayList<>();
private String diagnosticReport = "";
private Amount total = new Amount(0);
private DiscountStrategy discountStrategy;
private final List<RepairOrderObserver> 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<TaskDTO> 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<TaskDTO> 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);
}
}
}

View File

@@ -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);
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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);
}

View File

@@ -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);
}
}

View File

@@ -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;
}
}

View File

@@ -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<TaskDTO> 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<TaskDTO> 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<TaskDTO> 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;
}
}

View File

@@ -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);
}

View File

@@ -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}.
*
* <p>Observers registered via {@link #addObserver(RepairOrderObserver)} are
* automatically forwarded to every new repair session that is started.</p>
*/
public class RepairShop {
private ActiveRepair currentRepair;
private final List<RepairOrderObserver> 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();
}
}

View File

@@ -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;
}
}

View File

@@ -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();
}
}

View File

@@ -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 <em>not</em> 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);
}
}

View File

@@ -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);
}
}
}

View File

@@ -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();
}
}

View File

@@ -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();
}
}

View File

@@ -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("========================================");
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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"));
}
}

View File

@@ -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"));
}
}

View File

@@ -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());
}
}

View File

@@ -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"));
}
}

View File

@@ -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);
}
}

View File

@@ -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");
}
}

View File

@@ -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<String> 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<String> lines = Files.readAllLines(logPath);
assertEquals(2, lines.size(), "Two messages should produce two lines");
}
}