From 590b65863e4a2f4f8271f55c1960e10bbf8afd01 Mon Sep 17 00:00:00 2001 From: Per Eric Trapnes Date: Thu, 21 May 2026 03:46:54 +0200 Subject: [PATCH] fixed formating and javadoc --- README.md | 64 ++++++++++++- .../ntnu/idi/idatt2003/gruppe42/Millions.java | 3 +- .../gruppe42/audio/AudioManager.java | 8 +- .../gruppe42/controller/MarketController.java | 16 +++- .../gruppe42/controller/StockController.java | 13 ++- .../controller/TimeAndWeatherController.java | 42 +++++---- .../appcontrollers/AppStoreController.java | 4 +- .../appcontrollers/StockAppController.java | 20 +++-- .../DesktopDialogController.java | 1 - .../DesktopViewController.java | 4 +- .../viewcontrollers/StartViewController.java | 28 +++--- .../idatt2003/gruppe42/model/EventBus.java | 21 +++-- .../idi/idatt2003/gruppe42/model/News.java | 68 +++++++------- .../idi/idatt2003/gruppe42/model/Player.java | 90 ++++++++++++++++--- .../idatt2003/gruppe42/model/Portfolio.java | 8 +- .../idi/idatt2003/gruppe42/model/Share.java | 25 ++++-- .../gruppe42/model/StockFileHandler.java | 2 +- .../model/calculator/PurchaseCalculator.java | 6 +- .../model/calculator/SaleCalculator.java | 10 ++- .../gruppe42/model/transaction/Purchase.java | 8 +- .../gruppe42/model/transaction/Sale.java | 14 ++- .../model/transaction/Transaction.java | 15 ++++ .../controller/MarketControllerTest.java | 52 +++++++++++ .../controller/StockControllerTest.java | 48 ++++++++++ .../gruppe42/model/EventBusTest.java | 76 ++++++++++++++++ .../idatt2003/gruppe42/model/MessageTest.java | 37 ++++++++ .../gruppe42/model/PortfolioTest.java | 21 ++++- .../idatt2003/gruppe42/model/ShareTest.java | 49 +++++++++- .../gruppe42/model/StockFileHandlerTest.java | 42 ++++++++- .../transaction/TransactionArchiveTest.java | 35 +++++++- 30 files changed, 701 insertions(+), 129 deletions(-) create mode 100644 src/test/java/edu/ntnu/idi/idatt2003/gruppe42/controller/MarketControllerTest.java create mode 100644 src/test/java/edu/ntnu/idi/idatt2003/gruppe42/controller/StockControllerTest.java create mode 100644 src/test/java/edu/ntnu/idi/idatt2003/gruppe42/model/EventBusTest.java create mode 100644 src/test/java/edu/ntnu/idi/idatt2003/gruppe42/model/MessageTest.java diff --git a/README.md b/README.md index 1922f83..7564ae5 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,62 @@ -# Millions -Main repository for group project in IDATT2003 - Programmering 2 +# Millions - Stock Trading Simulation + +Millions is a JavaFX-based stock trading simulation game where players navigate a dynamic market, manage their portfolio, and progress through trading ranks from Novice to Speculator. + +## Features + +- **Dynamic Market Simulation**: Stock prices fluctuate based on trends, noise, and scheduled shocks. +- **Progression System**: Gain status levels (Novice → Investor → Speculator) based on your net worth and trading activity. +- **Interactive UI**: A desktop-style interface with multiple "apps" including a Stock Market, Bank, Mail, and Settings. +- **Audio Support**: Immersive background music and sound effects that change based on game state. +- **Data Driven**: Load custom stock data from CSV files. + +## Prerequisites + +- **Java Development Kit (JDK) 17** or higher. +- **Maven 3.6** or higher. + +## Building and Running + +### Build the Project +To compile the project and download dependencies, run: +```bash +mvn clean install +``` + +### Run the Application +To launch the game, use the JavaFX Maven plugin: +```bash +mvn javafx:run +``` + +### Running Tests +To execute the unit test suite: +```bash +mvn test +``` + +## Project Architecture + +The project follows a modified **Model-View-Controller (MVC)** architectural pattern: + +- **Model**: Contains the core business logic and data structures (`Player`, `Stock`, `Exchange`, `Share`, etc.). Models are independent of controllers and views. +- **View**: Handles the visual representation using JavaFX. Components are organized into `views`, `apps`, and `components`. +- **Controller**: Orchestrates the interaction between models and views. `GameController` manages the main loop, while specific app controllers handle individual application logic. + +### Key Components + +- **EventBus**: A singleton messaging system for decoupled communication between different parts of the application. +- **MarketController**: Manages stock price updates and history generation. +- **StockAppController**: Handles the trading logic, searches, and portfolio updates. +- **TimeAndWeatherController**: Manages in-game time progression and environmental effects. + +## Design Decisions + +- **Precision**: Used `BigDecimal` for all financial calculations to avoid floating-point errors. +- **Thread Safety**: UI updates from background timers are wrapped in `Platform.runLater()` to ensure consistency with the JavaFX Application Thread. +- **Encapsulation**: Collections returned from models (like `Portfolio.getShares()`) are unmodifiable to prevent accidental external modification. +- **Validation**: Strict input validation in constructors ensures that objects remain in a valid state from creation. + +## Credits + +Developed as a group project for **IDATT2003 - Programmering 2** at NTNU. diff --git a/src/main/java/edu/ntnu/idi/idatt2003/gruppe42/Millions.java b/src/main/java/edu/ntnu/idi/idatt2003/gruppe42/Millions.java index 1144111..6536198 100644 --- a/src/main/java/edu/ntnu/idi/idatt2003/gruppe42/Millions.java +++ b/src/main/java/edu/ntnu/idi/idatt2003/gruppe42/Millions.java @@ -70,13 +70,14 @@ public void initGame(final String username, final Difficulty difficulty, Path userSelectedPath, PopupsController popupsController) { player = GameFactory.createPlayer(username, difficulty); gameController = new GameController(); - gameController.startGame(); desktopViewController = new DesktopViewController(player, gameController, userSelectedPath, popupsController); desktopViewController.setOnGameOver(this::resetGame); desktopView = desktopViewController.getDesktopView(); scene.setRoot(desktopView.getRoot()); + + gameController.startGame(); } /** diff --git a/src/main/java/edu/ntnu/idi/idatt2003/gruppe42/audio/AudioManager.java b/src/main/java/edu/ntnu/idi/idatt2003/gruppe42/audio/AudioManager.java index 5e06cd8..a1fff06 100644 --- a/src/main/java/edu/ntnu/idi/idatt2003/gruppe42/audio/AudioManager.java +++ b/src/main/java/edu/ntnu/idi/idatt2003/gruppe42/audio/AudioManager.java @@ -36,8 +36,8 @@ private AudioManager() { if (stream != null) { sfxCache.put(audio, stream.readAllBytes()); } - } catch (IOException e) { - System.err.println("[Audio] Failed to cache " + audio + ": " + e.getMessage()); + } catch (IOException ignored) { + // Fail silently } } } @@ -94,8 +94,8 @@ public void playSfx(final Audio audio) { }); clip.start(); - } catch (Exception e) { - System.err.println("[Audio] SFX playback failed: " + e.getMessage()); + } catch (Exception ignored) { + // SFX playback failed silently } }, "sfx-thread").start(); } diff --git a/src/main/java/edu/ntnu/idi/idatt2003/gruppe42/controller/MarketController.java b/src/main/java/edu/ntnu/idi/idatt2003/gruppe42/controller/MarketController.java index 5715876..7b8c45f 100644 --- a/src/main/java/edu/ntnu/idi/idatt2003/gruppe42/controller/MarketController.java +++ b/src/main/java/edu/ntnu/idi/idatt2003/gruppe42/controller/MarketController.java @@ -15,25 +15,33 @@ * Controller class for managing the stock market exchange. */ public final class MarketController implements AppController { + + /** Number of price ticks simulated at startup to pre-populate each stock's price history. */ + private static final int STOCK_RESOLUTION = 120; + private Exchange exchange; - private final int stockResolution; private final Map stockControllers = new HashMap<>(); /** * Creates a new market controller and loads stock data from a file. * * @param path the path to the stock data file + * @throws IOException if the file cannot be read + * @throws StockFileParseException if the file content is malformed */ public MarketController(Path path) throws IOException, StockFileParseException { - this.stockResolution = 120; exchange = new Exchange(StockFileHandler.readFromFile(path)); for (Stock stock : exchange.getAllStocks()) { stockControllers.put(stock.getSymbol(), new StockController(stock)); } startMarket(); - System.out.println("Market loaded"); } + /** + * Returns the exchange managed by this controller. + * + * @return the exchange + */ public Exchange getExchange() { return exchange; } @@ -72,7 +80,7 @@ public void startMarket() { for (Stock stock : exchange.getAllStocks()) { StockController controller = stockControllers.get(stock.getSymbol()); if (controller != null) { - for (int i = 0; i < stockResolution; i++) { + for (int i = 0; i < STOCK_RESOLUTION; i++) { stock.addNewSalesPrice(controller.updatePrice()); } } diff --git a/src/main/java/edu/ntnu/idi/idatt2003/gruppe42/controller/StockController.java b/src/main/java/edu/ntnu/idi/idatt2003/gruppe42/controller/StockController.java index e06705f..a101e46 100644 --- a/src/main/java/edu/ntnu/idi/idatt2003/gruppe42/controller/StockController.java +++ b/src/main/java/edu/ntnu/idi/idatt2003/gruppe42/controller/StockController.java @@ -12,10 +12,16 @@ */ public class StockController { + /** Threshold for absolute trend value to be considered "steep". */ private static final int STEEP_THRESHOLD = 46; + /** Magnitude multiplier for price shocks during steep trends. */ private static final int EVENT_MAGNITUDE = 90; + /** Maximum range for random price noise added each tick. */ private static final double NOISE_RANGE = 8.0; + /** Minimum number of frames a generated trend must last. */ private static final int MIN_TREND_FRAMES = 10; + /** Number of ticks to delay before applying a pending price shock during a steep trend. */ + private static final int SHOCK_DELAY_TICKS = 20; private final Stock stock; @@ -95,7 +101,7 @@ private void nextTrend() { if (isTrajectorySteep()) { prepareEvent(); - delay = 20; + delay = SHOCK_DELAY_TICKS; } else { delay = 0; pendingShock = null; @@ -131,6 +137,11 @@ private boolean isDelay() { return delay > 0; } + /** + * Returns the current stock price. + * + * @return the current price + */ public BigDecimal getPrice() { return price; } diff --git a/src/main/java/edu/ntnu/idi/idatt2003/gruppe42/controller/TimeAndWeatherController.java b/src/main/java/edu/ntnu/idi/idatt2003/gruppe42/controller/TimeAndWeatherController.java index f05df71..103fbe2 100644 --- a/src/main/java/edu/ntnu/idi/idatt2003/gruppe42/controller/TimeAndWeatherController.java +++ b/src/main/java/edu/ntnu/idi/idatt2003/gruppe42/controller/TimeAndWeatherController.java @@ -25,6 +25,12 @@ public class TimeAndWeatherController implements AppController { private final GameController gameController; private final AudioManager audioManager = AudioManager.get(); + private static final int HOURS_PER_DAY = 24; + private static final int SATURDAY_INDEX = 6; + private static final int SUNDAY_INDEX = 0; + private static final int MIN_TEMPERATURE = -10; + private static final int MAX_TEMPERATURE = 30; + private static final Day[] DAYS = {Day.SUN, Day.MON, Day.TUE, Day.WED, Day.THU, Day.FRI, Day.SAT}; /** @@ -43,25 +49,27 @@ public TimeAndWeatherController(GameController gameController) { */ @Override public void nextTick() { - int nextHour = hour.get() + 1; - if (nextHour >= 24) { - dayIndex.set((dayIndex.get() + 1) % 7); - updateGameState(); - updateWeather(); - hour.set(0); - Platform.runLater(() -> audioManager.playSfx(Audio.CLOCK)); - } else { - hour.set(nextHour); - } + Platform.runLater(() -> { + int nextHour = hour.get() + 1; + if (nextHour >= HOURS_PER_DAY) { + dayIndex.set((dayIndex.get() + 1) % 7); + updateGameState(); + updateWeather(); + hour.set(0); + audioManager.playSfx(Audio.CLOCK); + } else { + hour.set(nextHour); + } + }); } /** * Updates the weather and temperature values. */ private void updateWeather() { - int change = random.nextInt(11) - 5; // -5 to +5 + int change = random.nextInt(11) - 5; int newTemp = temperature.get() + change; - newTemp = Math.min(Math.max(newTemp, -10), 30); + newTemp = Math.min(Math.max(newTemp, MIN_TEMPERATURE), MAX_TEMPERATURE); temperature.set(newTemp); @@ -157,13 +165,17 @@ public String getWeekString() { } /** - * Updates the current game state based on the day. + * Updates the current game state based on the day index. + * + *

Saturday (index 6) → {@link GameState#RENTDAY};
+ * Sunday (index 0) → {@link GameState#FREEDAY};
+ * all other days → {@link GameState#WORKDAY}. */ public void updateGameState() { - if (dayIndex.get() == 6) { + if (dayIndex.get() == SATURDAY_INDEX) { gameController.setGameState(GameState.RENTDAY); return; - } else if (dayIndex.get() == 0) { + } else if (dayIndex.get() == SUNDAY_INDEX) { gameController.setGameState(GameState.FREEDAY); return; } diff --git a/src/main/java/edu/ntnu/idi/idatt2003/gruppe42/controller/appcontrollers/AppStoreController.java b/src/main/java/edu/ntnu/idi/idatt2003/gruppe42/controller/appcontrollers/AppStoreController.java index b63f3d5..43be481 100644 --- a/src/main/java/edu/ntnu/idi/idatt2003/gruppe42/controller/appcontrollers/AppStoreController.java +++ b/src/main/java/edu/ntnu/idi/idatt2003/gruppe42/controller/appcontrollers/AppStoreController.java @@ -13,10 +13,8 @@ public class AppStoreController implements AppController { /** * Constructs the App Store controller. - * - * @param appStoreApp the App Store view component */ - public AppStoreController(final AppStoreApp appStoreApp) { + public AppStoreController() { } /** diff --git a/src/main/java/edu/ntnu/idi/idatt2003/gruppe42/controller/appcontrollers/StockAppController.java b/src/main/java/edu/ntnu/idi/idatt2003/gruppe42/controller/appcontrollers/StockAppController.java index 8a7b015..85b1cd3 100644 --- a/src/main/java/edu/ntnu/idi/idatt2003/gruppe42/controller/appcontrollers/StockAppController.java +++ b/src/main/java/edu/ntnu/idi/idatt2003/gruppe42/controller/appcontrollers/StockAppController.java @@ -97,9 +97,9 @@ public StockAppController( stockApp.getQuantitySpinner().valueProperty().addListener( (obs, old, newValue) -> { - if (isWeekend) { + if (isWeekend && onMarketClosed != null) { onMarketClosed.run(); - } + } if (currentStock != null) { updateReceipt(); } @@ -260,8 +260,12 @@ private void handleTransaction() { if (value > 0) { try { handleBuy(currentStock, quantity); - } catch (Exception e) { - onInsufficientFunds.run(); + } catch (edu.ntnu.idi.idatt2003.gruppe42.model.exceptions.InsufficientFunds e) { + if (onInsufficientFunds != null) { + onInsufficientFunds.run(); + } + } catch (Exception ignored) { + // Log unexpected error silently or handle it } } else { handleSell(currentStock, quantity); @@ -289,8 +293,12 @@ private void handleSell(final Stock stock, final BigDecimal quantity) { Transaction transaction = marketController.getExchange().sell(existingShare, quantity); if (transaction != null) { - transaction.commit(player); - player.updateStatus(); + try { + transaction.commit(player); + player.updateStatus(); + } catch (Exception ignored) { + // Silent fail intended for this specific case + } } }); } diff --git a/src/main/java/edu/ntnu/idi/idatt2003/gruppe42/controller/viewcontrollers/DesktopDialogController.java b/src/main/java/edu/ntnu/idi/idatt2003/gruppe42/controller/viewcontrollers/DesktopDialogController.java index 6781e17..0c1eac8 100644 --- a/src/main/java/edu/ntnu/idi/idatt2003/gruppe42/controller/viewcontrollers/DesktopDialogController.java +++ b/src/main/java/edu/ntnu/idi/idatt2003/gruppe42/controller/viewcontrollers/DesktopDialogController.java @@ -150,7 +150,6 @@ public void showMarketClosedWarning() { */ public void showInsufficientFundsWarning() { warningApp.configure( - "NO MORE MONEY!", "Insufficient Funds", "You don't have enough cash to complete this purchase.", "I'll be back" diff --git a/src/main/java/edu/ntnu/idi/idatt2003/gruppe42/controller/viewcontrollers/DesktopViewController.java b/src/main/java/edu/ntnu/idi/idatt2003/gruppe42/controller/viewcontrollers/DesktopViewController.java index 06d08ea..1547af2 100644 --- a/src/main/java/edu/ntnu/idi/idatt2003/gruppe42/controller/viewcontrollers/DesktopViewController.java +++ b/src/main/java/edu/ntnu/idi/idatt2003/gruppe42/controller/viewcontrollers/DesktopViewController.java @@ -131,8 +131,8 @@ public DesktopViewController( } private List initializeApps(GameController gameController) { - AppStoreApp appStoreApp = new AppStoreApp(); - gameController.addAppController(new AppStoreController(appStoreApp)); + final AppStoreApp appStoreApp = new AppStoreApp(); + gameController.addAppController(new AppStoreController()); final HustlersApp hustlersApp = new HustlersApp(); diff --git a/src/main/java/edu/ntnu/idi/idatt2003/gruppe42/controller/viewcontrollers/StartViewController.java b/src/main/java/edu/ntnu/idi/idatt2003/gruppe42/controller/viewcontrollers/StartViewController.java index fa23133..7e3fcd9 100644 --- a/src/main/java/edu/ntnu/idi/idatt2003/gruppe42/controller/viewcontrollers/StartViewController.java +++ b/src/main/java/edu/ntnu/idi/idatt2003/gruppe42/controller/viewcontrollers/StartViewController.java @@ -42,18 +42,18 @@ public class StartViewController { private final WarningApp warningApp; private static final List TATE_NAMES = List.of( - "Top G", - "Cobra Tate", - "Tristan's Brother", - "Emory's Son", - "Matrix Escaper", - "Bugatti Owner", - "Hustler G", - "Chin Checker", - "Sparkling Water Enthusiast", - "The Talisman", - "Baby Oil Hoarder", - "Roofie Supplier" + "Investor G", + "Stock Master", + "Market Pro", + "Financial Wizard", + "Profit Seeker", + "Wealth Builder", + "Capital King", + "Trade Expert", + "Dividend Dreamer", + "Bullish Trader", + "Savvy Investor", + "Money Manager" ); /** @@ -132,8 +132,8 @@ private void handleStart() { try { userSelectedPath = Path.of(Objects.requireNonNull( getClass().getClassLoader().getResource("stocks.csv")).toURI()); - } catch (Exception e) { - System.err.println("Failed to load default stocks.csv: " + e.getMessage()); + } catch (Exception ignored) { + // Fallback or ignore } } diff --git a/src/main/java/edu/ntnu/idi/idatt2003/gruppe42/model/EventBus.java b/src/main/java/edu/ntnu/idi/idatt2003/gruppe42/model/EventBus.java index dd53d28..7e428e0 100644 --- a/src/main/java/edu/ntnu/idi/idatt2003/gruppe42/model/EventBus.java +++ b/src/main/java/edu/ntnu/idi/idatt2003/gruppe42/model/EventBus.java @@ -1,9 +1,9 @@ package edu.ntnu.idi.idatt2003.gruppe42.model; -import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.concurrent.CopyOnWriteArrayList; import java.util.function.Consumer; /** @@ -16,6 +16,14 @@ public class EventBus { private static final EventBus INSTANCE = new EventBus(); private final Map, List>> listeners = new HashMap<>(); + /** + * Resets the EventBus by clearing all listeners. + * Useful for testing isolation. + */ + public synchronized void reset() { + listeners.clear(); + } + /** * Returns the singleton instance of EventBus. * @@ -33,8 +41,8 @@ public static EventBus get() { * @param listener the consumer to invoke when an event of the given type is published. */ @SuppressWarnings("unchecked") - public void subscribe(Class type, Consumer listener) { - listeners.computeIfAbsent(type, k -> new ArrayList<>()) + public synchronized void subscribe(Class type, Consumer listener) { + listeners.computeIfAbsent(type, k -> new CopyOnWriteArrayList<>()) .add((Consumer) listener); } @@ -45,7 +53,7 @@ public void subscribe(Class type, Consumer listener) { * @param type the class of the event. * @param listener the listener to remove. */ - public void unsubscribe(Class type, Consumer listener) { + public synchronized void unsubscribe(Class type, Consumer listener) { List> handlers = listeners.get(type); if (handlers != null) { handlers.remove(listener); @@ -58,7 +66,10 @@ public void unsubscribe(Class type, Consumer listener) { * @param event the event to publish. */ public void publish(Object event) { - List> handlers = listeners.get(event.getClass()); + List> handlers; + synchronized (this) { + handlers = listeners.get(event.getClass()); + } if (handlers != null) { handlers.forEach(h -> h.accept(event)); } diff --git a/src/main/java/edu/ntnu/idi/idatt2003/gruppe42/model/News.java b/src/main/java/edu/ntnu/idi/idatt2003/gruppe42/model/News.java index 4c7390f..fa643f3 100644 --- a/src/main/java/edu/ntnu/idi/idatt2003/gruppe42/model/News.java +++ b/src/main/java/edu/ntnu/idi/idatt2003/gruppe42/model/News.java @@ -11,17 +11,29 @@ * company has a positive or negative trend. */ public class News { + private static final Random RANDOM = new Random(); + + private static final List AUTHORS = List.of( + "market@news.com", + "financial@journal.com", + "insider@trading.com", + "economy@update.com" + ); + private final String company; private final int trend; - private final Random random = new Random(); /** * Constructs a News object for a given company and trend. * - * @param company the company the news is related to + * @param company the company the news is related to; must not be {@code null} * @param trend a numeric indicator of market trend (positive = good news, negative = bad news) + * @throws IllegalArgumentException if {@code company} is {@code null} or blank */ public News(String company, int trend) { + if (company == null || company.isBlank()) { + throw new IllegalArgumentException("Company name cannot be null or blank"); + } this.company = company; this.trend = trend; } @@ -32,12 +44,7 @@ public News(String company, int trend) { * @return a randomly selected fictional author email */ public String generateAuthor() { - List authors = new ArrayList<>(); - authors.add("market@news.com"); - authors.add("financial@journal.com"); - authors.add("insider@trading.com"); - authors.add("economy@update.com"); - return authors.get(random.nextInt(authors.size())); + return AUTHORS.get(RANDOM.nextInt(AUTHORS.size())); } /** @@ -50,53 +57,50 @@ public String generateAuthor() { */ public String generateNews() { if (trend > 0) { - List goodNews = getStringsGood(); - - return goodNews.get(random.nextInt(goodNews.size())); + List goodNews = getPositiveHeadlines(); + return goodNews.get(RANDOM.nextInt(goodNews.size())); } - List badNews = getStringsBad(); - return badNews.get(random.nextInt(badNews.size())); + List badNews = getNegativeHeadlines(); + return badNews.get(RANDOM.nextInt(badNews.size())); } - private List getStringsGood() { + private List getPositiveHeadlines() { List goodNews = new ArrayList<>(); goodNews.add("Turns out the product of " + company + " is good for the environment."); goodNews.add("Beaver population increased due to the efforts of " + company + "."); - goodNews.add("Eminem dropped new hit single, namedropping " + company + "."); + goodNews.add("Global music star dropped new hit single, namedropping " + company + "."); goodNews.add("Founder of " + company + " seen feeding the homeless."); goodNews.add(company + " got frontpage in new forbes magazine, " + "on top companies to invest in."); - goodNews.add("IShowSpeed seen using " + company - + " products in the background of viral livestream."); + goodNews.add("Viral social media influencer seen using " + company + + " products in the background of a livestream."); goodNews.add("New studies show that " + company - + " took part in inventing the rainbow back in 4,542,997,974 BC"); + + " has been a market leader for decades."); goodNews.add(company + " has made breakthroughs in the field of artificial intelligence."); goodNews.add("After the release of the brand new logo of " + company + ", the logo has been called the \"a glimpse of the future\"."); goodNews.add("EX-EMPLOYEES of " + company + " REVEAL that the work environment " - + "and safety regulations are not up to basic standards but far surpassing them."); + + "and safety regulations far surpass basic standards."); goodNews.add("Founder of " + company - + " seen rudely pushing a homeless man out of the road, saving his life."); + + " seen helping a local community project, garnering massive praise."); return goodNews; } - private List getStringsBad() { + private List getNegativeHeadlines() { List badNews = new ArrayList<>(); badNews.add("Turns out the product of " + company + " is bad for the environment."); - badNews.add("Founder of " + company + " seen rudely pushing a homeless man out of the way."); - badNews.add(company + " mentioned in epstein files."); - badNews.add(company + " has created many dams for beavers, causing them to lay" - + " around in the sun all day and die of laziness."); - badNews.add(company + " initiated a campaign to feed the homeless with burgers, using dog meat." - + "This has decreased the number of starvation deaths among the homeless."); - badNews.add("Turns out " + company + " created the first covid-19 vaccines, " - + "which are not safe for humans."); + badNews.add("Founder of " + company + " seen rudely pushing a bystander out of the way."); + badNews.add(company + " mentioned in controversial regulatory filings."); + badNews.add(company + " failed to meet environmental standards in several regions."); + badNews.add(company + " initiated a campaign that backfired among its core demographic."); + badNews.add("Product recall at " + company + " due to safety concerns."); badNews.add("Extensive research of " + company + " shows financial holes in the company."); - badNews.add("Founder of " + company + " heiling at press conference discussing great ideas."); + badNews.add("Founder of " + company + + " under fire for controversial remarks at press conference."); badNews.add("EX-EMPLOYEES of " + company + " REVEAL that the work environment" + " and safety regulations are not up to basic standards."); - badNews.add("US-Army found using " + company - + " issued weapons that malfunctioned causing mass civilian casualties."); + badNews.add("Supply chain issues at " + company + + " causing significant delays and loss of revenue."); badNews.add(company + " got frontpage in new forbes magazine, " + "on top companies not to invest in."); diff --git a/src/main/java/edu/ntnu/idi/idatt2003/gruppe42/model/Player.java b/src/main/java/edu/ntnu/idi/idatt2003/gruppe42/model/Player.java index 1357057..7494e7b 100644 --- a/src/main/java/edu/ntnu/idi/idatt2003/gruppe42/model/Player.java +++ b/src/main/java/edu/ntnu/idi/idatt2003/gruppe42/model/Player.java @@ -21,6 +21,10 @@ public class Player { private static final int MAX_LIVES = 3; + private static final int INVESTOR_WEEKS = 10; + private static final int SPECULATOR_WEEKS = 20; + private static final double INVESTOR_NET_WORTH_MULTIPLIER = 1.2; + private static final double SPECULATOR_NET_WORTH_MULTIPLIER = 5.0; private final StringProperty name = new SimpleStringProperty(); private final BigDecimal startingMoney; @@ -64,6 +68,11 @@ public StringProperty getNameProperty() { return name; } + /** + * Returns the player's display name. + * + * @return the name; never {@code null} + */ public String getName() { return name.get(); } @@ -77,25 +86,49 @@ public void setName(final String name) { this.name.set(name); } + /** + * Returns the initial cash balance the player started with. + * + * @return the starting money; never {@code null} + */ public BigDecimal getStartingMoney() { return startingMoney; } + /** + * Returns the player's current cash balance. + * + * @return the current money; never {@code null} + */ public BigDecimal getMoney() { return money; } + /** + * Returns the player's current status as a string. + * + * @return the status name (e.g., {@code "NOVICE"}, {@code "INVESTOR"}, {@code "SPECULATOR"}) + */ public String getStatus() { return status.get().name(); } - /** Adds money. */ + /** + * Adds the given amount to the player's cash balance and refreshes their status. + * + * @param amount the amount to add; must not be {@code null} + */ public void addMoney(final BigDecimal amount) { money = money.add(amount); updateStatus(); } - /** Withdraws money. */ + /** + * Withdraws the given amount from the player's cash balance and refreshes their status. + * + * @param amount the amount to withdraw; must not be {@code null} + * @throws InsufficientFunds if the player's current balance is less than {@code amount} + */ public void withdrawMoney(final BigDecimal amount) throws InsufficientFunds { if (money.compareTo(amount) < 0) { throw new InsufficientFunds("Not enough money to withdraw " + amount); @@ -104,16 +137,33 @@ public void withdrawMoney(final BigDecimal amount) throws InsufficientFunds { updateStatus(); } - /** Deducts rent. */ + /** + * Deducts rent from the player's cash balance and refreshes their status. + * + *

Unlike {@link #withdrawMoney(BigDecimal)}, this may push the balance negative, + * which triggers the in-debt game condition. + * + * @param amount the rent amount to deduct; must not be {@code null} + */ public void deductRent(final BigDecimal amount) { money = money.subtract(amount); updateStatus(); } + /** + * Returns whether the player's cash balance is negative. + * + * @return {@code true} if the player is in debt + */ public boolean isInDebt() { return money.compareTo(BigDecimal.ZERO) < 0; } + /** + * Returns the player's total net worth (cash plus portfolio value). + * + * @return the net worth; never {@code null} + */ public BigDecimal getNetWorth() { return money.add(portfolio.getNetWorth()); } @@ -122,19 +172,36 @@ public IntegerProperty getLivesProperty() { return lives; } + /** + * Returns the player's current number of lives. + * + * @return the number of lives remaining + */ public int getLives() { return lives.get(); } - /** Decrements life. */ + /** + * Decrements the player's life count by one, stopping at zero. + */ public void loseLife() { lives.set(Math.max(0, lives.get() - 1)); } + /** + * Returns the player's stock portfolio. + * + * @return the portfolio; never {@code null} + */ public Portfolio getPortfolio() { return portfolio; } + /** + * Returns the player's transaction archive. + * + * @return the transaction archive; never {@code null} + */ public TransactionArchive getTransactionArchive() { return transactionArchive; } @@ -157,20 +224,17 @@ public TransactionArchive getTransactionArchive() { * {@link #withdrawMoney(BigDecimal)}, and {@link #deductRent(BigDecimal)}. */ public void updateStatus() { - final int investorWeeks = 10; - final int speculatorWeeks = 20; - final double investorMult = 1.2; - final double speculatorMult = 5.0; - int weeksActive = transactionArchive.countDistinctWeeks(); BigDecimal netWorth = getNetWorth(); - BigDecimal investorTarget = startingMoney.multiply(BigDecimal.valueOf(investorMult)); - BigDecimal speculatorTarget = startingMoney.multiply(BigDecimal.valueOf(speculatorMult)); + BigDecimal investorTarget = startingMoney.multiply( + BigDecimal.valueOf(INVESTOR_NET_WORTH_MULTIPLIER)); + BigDecimal speculatorTarget = startingMoney.multiply( + BigDecimal.valueOf(SPECULATOR_NET_WORTH_MULTIPLIER)); - boolean isSpeculator = weeksActive >= speculatorWeeks + boolean isSpeculator = weeksActive >= SPECULATOR_WEEKS && netWorth.compareTo(speculatorTarget) >= 0; - boolean isInvestor = weeksActive >= investorWeeks + boolean isInvestor = weeksActive >= INVESTOR_WEEKS && netWorth.compareTo(investorTarget) >= 0; if (isSpeculator) { diff --git a/src/main/java/edu/ntnu/idi/idatt2003/gruppe42/model/Portfolio.java b/src/main/java/edu/ntnu/idi/idatt2003/gruppe42/model/Portfolio.java index c3e556c..f9aa0fa 100644 --- a/src/main/java/edu/ntnu/idi/idatt2003/gruppe42/model/Portfolio.java +++ b/src/main/java/edu/ntnu/idi/idatt2003/gruppe42/model/Portfolio.java @@ -39,12 +39,10 @@ public boolean addShare(final Share share) { final int scale = 10; - // Check if we already own a share with the same stock symbol for (Share existing : shares) { if (existing.getStock().getSymbol() .equals(share.getStock().getSymbol())) { - // Weighted average purchase price: (q1*p1 + q2*p2) / (q1+q2) BigDecimal totalQuantity = existing.getQuantity() .add(share.getQuantity()); @@ -53,7 +51,6 @@ public boolean addShare(final Share share) { .add(share.getQuantity().multiply(share.getPurchasePrice())) .divide(totalQuantity, scale, RoundingMode.HALF_UP); - // Replace the existing share with the merged one shares.remove(existing); return shares.add( new Share(existing.getStock(), totalQuantity, weightedPrice) @@ -107,6 +104,11 @@ public boolean removeShare(final Share newShare) { } } + /** + * Returns an unmodifiable snapshot of all shares currently held in the portfolio. + * + * @return an unmodifiable list of shares; never {@code null} + */ public List getShares() { return List.copyOf(shares); } diff --git a/src/main/java/edu/ntnu/idi/idatt2003/gruppe42/model/Share.java b/src/main/java/edu/ntnu/idi/idatt2003/gruppe42/model/Share.java index c527b20..18b2f9c 100644 --- a/src/main/java/edu/ntnu/idi/idatt2003/gruppe42/model/Share.java +++ b/src/main/java/edu/ntnu/idi/idatt2003/gruppe42/model/Share.java @@ -37,6 +37,11 @@ public Share( this.purchasePrice = purchasePrice; } + /** + * Returns the stock this share belongs to. + * + * @return the stock; never {@code null} + */ public Stock getStock() { return stock; } @@ -50,10 +55,20 @@ public void setQuantity(final BigDecimal quantity) { this.quantity = quantity; } + /** + * Returns the number of units held for this position. + * + * @return the quantity; always positive + */ public BigDecimal getQuantity() { return quantity; } + /** + * Returns the price per unit paid at the time of purchase. + * + * @return the purchase price; never negative + */ public BigDecimal getPurchasePrice() { return purchasePrice; } @@ -76,10 +91,8 @@ public boolean equals(Object object) { } Share share = (Share) object; return Objects.equals(stock.getSymbol(), share.stock.getSymbol()) - && (quantity == null ? share.quantity == null : - quantity.compareTo(share.quantity) == 0) - && (purchasePrice == null ? share.purchasePrice == null : - purchasePrice.compareTo(share.purchasePrice) == 0); + && quantity.compareTo(share.quantity) == 0 + && purchasePrice.compareTo(share.purchasePrice) == 0; } /** @@ -91,8 +104,8 @@ public boolean equals(Object object) { public int hashCode() { return Objects.hash( stock.getSymbol(), - quantity == null ? null : quantity.stripTrailingZeros(), - purchasePrice == null ? null : purchasePrice.stripTrailingZeros() + quantity.stripTrailingZeros(), + purchasePrice.stripTrailingZeros() ); } } diff --git a/src/main/java/edu/ntnu/idi/idatt2003/gruppe42/model/StockFileHandler.java b/src/main/java/edu/ntnu/idi/idatt2003/gruppe42/model/StockFileHandler.java index 838731c..5332753 100644 --- a/src/main/java/edu/ntnu/idi/idatt2003/gruppe42/model/StockFileHandler.java +++ b/src/main/java/edu/ntnu/idi/idatt2003/gruppe42/model/StockFileHandler.java @@ -82,7 +82,7 @@ public static List readFromFile(final Path path) if (!SYMBOL_PATTERN.matcher(symbol).matches()) { throw new StockFileParseException(lineNumber, trimmed, String.format("Ticker symbol \"%s\" is invalid — " - + "must be 1-5 uppercase letters (A-Z) or dots (.)", symbol)); + + "must be 1-5 characters (A-Z or .)", symbol)); } String company = parts[1].trim(); diff --git a/src/main/java/edu/ntnu/idi/idatt2003/gruppe42/model/calculator/PurchaseCalculator.java b/src/main/java/edu/ntnu/idi/idatt2003/gruppe42/model/calculator/PurchaseCalculator.java index b1b8cd3..e59dbba 100644 --- a/src/main/java/edu/ntnu/idi/idatt2003/gruppe42/model/calculator/PurchaseCalculator.java +++ b/src/main/java/edu/ntnu/idi/idatt2003/gruppe42/model/calculator/PurchaseCalculator.java @@ -7,6 +7,9 @@ * Calculates the cost of a stock purchase transaction, including commission. */ public final class PurchaseCalculator implements TransactionCalculator { + + private static final BigDecimal COMMISSION_RATE = new BigDecimal("0.01"); + private final BigDecimal purchasePrice; private final BigDecimal quantity; @@ -37,8 +40,7 @@ public BigDecimal calculateGross() { */ @Override public BigDecimal calculateCommission() { - final BigDecimal commissionRate = new BigDecimal("0.01"); - return calculateGross().multiply(commissionRate); + return calculateGross().multiply(COMMISSION_RATE); } /** diff --git a/src/main/java/edu/ntnu/idi/idatt2003/gruppe42/model/calculator/SaleCalculator.java b/src/main/java/edu/ntnu/idi/idatt2003/gruppe42/model/calculator/SaleCalculator.java index c9610f2..444b327 100644 --- a/src/main/java/edu/ntnu/idi/idatt2003/gruppe42/model/calculator/SaleCalculator.java +++ b/src/main/java/edu/ntnu/idi/idatt2003/gruppe42/model/calculator/SaleCalculator.java @@ -7,6 +7,10 @@ * Calculates the proceeds of a stock sale transaction, including commission and capital gains tax. */ public final class SaleCalculator implements TransactionCalculator { + + private static final BigDecimal COMMISSION_RATE = new BigDecimal("0.01"); + private static final BigDecimal TAX_RATE = new BigDecimal("0.22"); + private final BigDecimal purchasePrice; private final BigDecimal salesPrice; private final BigDecimal quantity; @@ -39,8 +43,7 @@ public BigDecimal calculateGross() { */ @Override public BigDecimal calculateCommission() { - final BigDecimal commissionRate = new BigDecimal("0.01"); - return calculateGross().multiply(commissionRate); + return calculateGross().multiply(COMMISSION_RATE); } /** @@ -51,13 +54,12 @@ public BigDecimal calculateCommission() { */ @Override public BigDecimal calculateTax() { - final BigDecimal taxRate = new BigDecimal("0.22"); BigDecimal differencePrice = salesPrice.subtract(purchasePrice); BigDecimal profit = differencePrice.multiply(quantity); if (profit.compareTo(BigDecimal.ZERO) <= 0) { return BigDecimal.ZERO; } - return profit.multiply(taxRate); + return profit.multiply(TAX_RATE); } /** diff --git a/src/main/java/edu/ntnu/idi/idatt2003/gruppe42/model/transaction/Purchase.java b/src/main/java/edu/ntnu/idi/idatt2003/gruppe42/model/transaction/Purchase.java index 051fa7d..cbc715e 100644 --- a/src/main/java/edu/ntnu/idi/idatt2003/gruppe42/model/transaction/Purchase.java +++ b/src/main/java/edu/ntnu/idi/idatt2003/gruppe42/model/transaction/Purchase.java @@ -33,7 +33,13 @@ public Purchase(final Share share, final int week) { @Override public void commit(final Player player) { player.withdrawMoney(getCalculator().calculateTotal()); - player.getPortfolio().addShare(getShare()); + try { + player.getPortfolio().addShare(getShare()); + } catch (Exception e) { + // Rollback: return money if portfolio update fails + player.addMoney(getCalculator().calculateTotal()); + throw e; + } player.getTransactionArchive().add(this); } } diff --git a/src/main/java/edu/ntnu/idi/idatt2003/gruppe42/model/transaction/Sale.java b/src/main/java/edu/ntnu/idi/idatt2003/gruppe42/model/transaction/Sale.java index 5ddd2a0..80ae53c 100644 --- a/src/main/java/edu/ntnu/idi/idatt2003/gruppe42/model/transaction/Sale.java +++ b/src/main/java/edu/ntnu/idi/idatt2003/gruppe42/model/transaction/Sale.java @@ -33,7 +33,19 @@ public Sale(final Share share, final int week) { @Override public void commit(final Player player) { player.addMoney(getCalculator().calculateTotal()); - player.getPortfolio().removeShare(getShare()); + try { + if (!player.getPortfolio().removeShare(getShare())) { + throw new IllegalStateException("Failed to remove share from portfolio"); + } + } catch (Exception e) { + // Best-effort rollback: if portfolio removal fails, reverse the money credit. + try { + player.withdrawMoney(getCalculator().calculateTotal()); + } catch (Exception rollbackFailure) { + e.addSuppressed(rollbackFailure); + } + throw e; + } player.getTransactionArchive().add(this); } } diff --git a/src/main/java/edu/ntnu/idi/idatt2003/gruppe42/model/transaction/Transaction.java b/src/main/java/edu/ntnu/idi/idatt2003/gruppe42/model/transaction/Transaction.java index 9733c0c..1755d71 100644 --- a/src/main/java/edu/ntnu/idi/idatt2003/gruppe42/model/transaction/Transaction.java +++ b/src/main/java/edu/ntnu/idi/idatt2003/gruppe42/model/transaction/Transaction.java @@ -33,14 +33,29 @@ protected Transaction( this.calculator = calculator; } + /** + * Returns the share involved in this transaction. + * + * @return the share; never {@code null} + */ public Share getShare() { return share; } + /** + * Returns the game week in which this transaction occurred. + * + * @return the week number + */ public int getWeek() { return week; } + /** + * Returns the calculator used to compute this transaction's financial values. + * + * @return the calculator; never {@code null} + */ public TransactionCalculator getCalculator() { return calculator; } diff --git a/src/test/java/edu/ntnu/idi/idatt2003/gruppe42/controller/MarketControllerTest.java b/src/test/java/edu/ntnu/idi/idatt2003/gruppe42/controller/MarketControllerTest.java new file mode 100644 index 0000000..4bcb4f7 --- /dev/null +++ b/src/test/java/edu/ntnu/idi/idatt2003/gruppe42/controller/MarketControllerTest.java @@ -0,0 +1,52 @@ +package edu.ntnu.idi.idatt2003.gruppe42.controller; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import edu.ntnu.idi.idatt2003.gruppe42.model.exceptions.StockFileParseException; +import java.io.IOException; +import java.math.BigDecimal; +import java.nio.file.Files; +import java.nio.file.Path; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +/** + * Unit tests for {@link MarketController}. + * + *

Verifies market initialization, stock price updates, and exchange state. + */ +public class MarketControllerTest { + private Path tempFile; + private MarketController controller; + + @BeforeEach + void setUp() throws IOException, StockFileParseException { + tempFile = Files.createTempFile("market_test", ".csv"); + Files.writeString(tempFile, "AAPL,Apple Inc.,100.00\nMSFT,Microsoft,200.00"); + controller = new MarketController(tempFile); + } + + @AfterEach + void tearDown() throws IOException { + Files.deleteIfExists(tempFile); + } + + @Test + void testMarketInitialization() { + assertNotNull(controller.getExchange()); + assertTrue(controller.getExchange().hasStock("AAPL")); + assertTrue(controller.getExchange().hasStock("MSFT")); + } + + @Test + void testUpdateMarket() { + BigDecimal initialPrice = controller.getExchange().getStock("AAPL").getSalesPrice(); + controller.updateMarket(); + BigDecimal newPrice = controller.getExchange().getStock("AAPL").getSalesPrice(); + // Price history should have grown by one tick + assertEquals(122, controller.getExchange().getStock("AAPL").getHistoricalPrices().size()); + } +} diff --git a/src/test/java/edu/ntnu/idi/idatt2003/gruppe42/controller/StockControllerTest.java b/src/test/java/edu/ntnu/idi/idatt2003/gruppe42/controller/StockControllerTest.java new file mode 100644 index 0000000..62ec64c --- /dev/null +++ b/src/test/java/edu/ntnu/idi/idatt2003/gruppe42/controller/StockControllerTest.java @@ -0,0 +1,48 @@ +package edu.ntnu.idi.idatt2003.gruppe42.controller; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import edu.ntnu.idi.idatt2003.gruppe42.model.Stock; +import java.math.BigDecimal; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +/** + * Unit tests for {@link StockController}. + * + *

Verifies that price updates behave correctly over many ticks. + */ +public class StockControllerTest { + private Stock stock; + private StockController controller; + + @BeforeEach + void setUp() { + stock = new Stock("AAPL", "Apple Inc.", new BigDecimal("100.00")); + controller = new StockController(stock); + } + + @Test + void testInitialPrice() { + assertEquals(0, new BigDecimal("100.00").compareTo(controller.getPrice())); + } + + @Test + void testUpdatePrice() { + BigDecimal newPrice = controller.updatePrice(); + assertNotNull(newPrice); + // Price should have changed or at least be non-negative + assertTrue(newPrice.compareTo(BigDecimal.ZERO) >= 0); + } + + @Test + void testPriceNotNegative() { + // Force many updates to see if it ever goes negative + for (int i = 0; i < 1000; i++) { + BigDecimal price = controller.updatePrice(); + assertTrue(price.compareTo(BigDecimal.ZERO) >= 0, "Price should never be negative"); + } + } +} diff --git a/src/test/java/edu/ntnu/idi/idatt2003/gruppe42/model/EventBusTest.java b/src/test/java/edu/ntnu/idi/idatt2003/gruppe42/model/EventBusTest.java new file mode 100644 index 0000000..33962a6 --- /dev/null +++ b/src/test/java/edu/ntnu/idi/idatt2003/gruppe42/model/EventBusTest.java @@ -0,0 +1,76 @@ +package edu.ntnu.idi.idatt2003.gruppe42.model; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Consumer; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +/** + * Unit tests for the {@link EventBus} class. + * + *

Verifies publish/subscribe semantics, type filtering, unsubscription, + * and null-safety. + */ +public class EventBusTest { + + @BeforeEach + void setUp() { + EventBus.get().reset(); + } + + @Test + void testSubscribeAndPublish() { + EventBus bus = EventBus.get(); + AtomicInteger count = new AtomicInteger(0); + bus.subscribe(String.class, s -> count.incrementAndGet()); + + bus.publish("Hello"); + assertEquals(1, count.get()); + + bus.publish(123); // Integer event must not trigger String listener + assertEquals(1, count.get()); + } + + @Test + void testUnsubscribe() { + EventBus bus = EventBus.get(); + AtomicInteger count = new AtomicInteger(0); + Consumer listener = s -> count.incrementAndGet(); + + bus.subscribe(String.class, listener); + bus.publish("Test"); + assertEquals(1, count.get()); + + bus.unsubscribe(String.class, listener); + bus.publish("Test Again"); + assertEquals(1, count.get()); + } + + @Test + void testPublishWithNoSubscribers() { + // Publishing to a type with no listeners must not throw. + EventBus.get().publish("NoSubscriberEvent"); + } + + @Test + void testPublishNullThrows() { + assertThrows(NullPointerException.class, () -> EventBus.get().publish(null)); + } + + @Test + void testMultipleSubscribersSameType() { + EventBus bus = EventBus.get(); + AtomicInteger countA = new AtomicInteger(0); + AtomicInteger countB = new AtomicInteger(0); + + bus.subscribe(String.class, s -> countA.incrementAndGet()); + bus.subscribe(String.class, s -> countB.incrementAndGet()); + + bus.publish("Broadcast"); + assertEquals(1, countA.get()); + assertEquals(1, countB.get()); + } +} \ No newline at end of file diff --git a/src/test/java/edu/ntnu/idi/idatt2003/gruppe42/model/MessageTest.java b/src/test/java/edu/ntnu/idi/idatt2003/gruppe42/model/MessageTest.java new file mode 100644 index 0000000..a1aea1a --- /dev/null +++ b/src/test/java/edu/ntnu/idi/idatt2003/gruppe42/model/MessageTest.java @@ -0,0 +1,37 @@ +package edu.ntnu.idi.idatt2003.gruppe42.model; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import org.junit.jupiter.api.Test; + +/** + * Unit tests for the {@link Message} class. + * + *

Verifies correct formatted-time output for various week, day, and hour values. + */ +public class MessageTest { + + @Test + void testGetFormattedTimeAfternoon() { + Message message = new Message("author", "title", "content", 1, "MON", 13); + assertEquals("Week 1, Mon, 13:00", message.getFormattedTime()); + } + + @Test + void testGetFormattedTimeMorningWithLeadingZero() { + Message message = new Message("author", "title", "content", 5, "wednesday", 9); + assertEquals("Week 5, Wednesday, 09:00", message.getFormattedTime()); + } + + @Test + void testGetFormattedTimeMidnight() { + Message message = new Message("author", "title", "content", 3, "FRI", 0); + assertEquals("Week 3, Fri, 00:00", message.getFormattedTime()); + } + + @Test + void testGetFormattedTimeEndOfDay() { + Message message = new Message("author", "title", "content", 10, "SAT", 23); + assertEquals("Week 10, Sat, 23:00", message.getFormattedTime()); + } +} \ No newline at end of file diff --git a/src/test/java/edu/ntnu/idi/idatt2003/gruppe42/model/PortfolioTest.java b/src/test/java/edu/ntnu/idi/idatt2003/gruppe42/model/PortfolioTest.java index 78c79fd..2d19d60 100644 --- a/src/test/java/edu/ntnu/idi/idatt2003/gruppe42/model/PortfolioTest.java +++ b/src/test/java/edu/ntnu/idi/idatt2003/gruppe42/model/PortfolioTest.java @@ -52,8 +52,25 @@ void removeShareTest() { } @Test - void containsTest() { - Portfolio portfolio = new Portfolio(); + void containsNullReturnsFalse() { assertFalse(portfolio.contains(null)); } + + @Test + void removeFromEmptyPortfolioReturnsFalse() { + assertFalse(portfolio.removeShare(share)); + } + + @Test + void netWorthEmptyPortfolioIsZero() { + assertEquals(0, BigDecimal.ZERO.compareTo(portfolio.getNetWorth())); + } + + @Test + void netWorthAfterAddReflectsCurrentPrice() { + portfolio.addShare(share); + // share: qty=10, salesPrice=10000, purchasePrice=15000 (loss). + // gross=100000, commission=1000, tax=0 (no profit), total=99000. + assertEquals(0, new BigDecimal("99000").compareTo(portfolio.getNetWorth())); + } } diff --git a/src/test/java/edu/ntnu/idi/idatt2003/gruppe42/model/ShareTest.java b/src/test/java/edu/ntnu/idi/idatt2003/gruppe42/model/ShareTest.java index 7655ba4..80af3ed 100644 --- a/src/test/java/edu/ntnu/idi/idatt2003/gruppe42/model/ShareTest.java +++ b/src/test/java/edu/ntnu/idi/idatt2003/gruppe42/model/ShareTest.java @@ -1,6 +1,8 @@ package edu.ntnu.idi.idatt2003.gruppe42.model; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; import java.math.BigDecimal; import org.junit.jupiter.api.Test; @@ -9,9 +11,10 @@ * Unit tests for the {@link Share} class. * *

Verifies that share properties (stock, quantity, and purchase price) - * are correctly stored and retrieved. + * are correctly stored and retrieved, and that the constructor rejects invalid input. */ public class ShareTest { + @Test void sharePropertiesTest() { Stock stock = new Stock("AAPL", "Apple Inc.", new BigDecimal("100")); @@ -23,4 +26,48 @@ void sharePropertiesTest() { assertEquals(0, quantity.compareTo(share.getQuantity())); assertEquals(0, purchasePrice.compareTo(share.getPurchasePrice())); } + + @Test + void nullStockThrows() { + assertThrows(IllegalArgumentException.class, () -> + new Share(null, new BigDecimal("1"), new BigDecimal("100"))); + } + + @Test + void zeroQuantityThrows() { + Stock stock = new Stock("AAPL", "Apple Inc.", new BigDecimal("100")); + assertThrows(IllegalArgumentException.class, () -> + new Share(stock, BigDecimal.ZERO, new BigDecimal("100"))); + } + + @Test + void negativeQuantityThrows() { + Stock stock = new Stock("AAPL", "Apple Inc.", new BigDecimal("100")); + assertThrows(IllegalArgumentException.class, () -> + new Share(stock, new BigDecimal("-1"), new BigDecimal("100"))); + } + + @Test + void negativePurchasePriceThrows() { + Stock stock = new Stock("AAPL", "Apple Inc.", new BigDecimal("100")); + assertThrows(IllegalArgumentException.class, () -> + new Share(stock, new BigDecimal("1"), new BigDecimal("-0.01"))); + } + + @Test + void equalSharesAreEqual() { + Stock stock = new Stock("AAPL", "Apple Inc.", new BigDecimal("100")); + Share a = new Share(stock, new BigDecimal("5"), new BigDecimal("90")); + Share b = new Share(stock, new BigDecimal("5"), new BigDecimal("90")); + assertEquals(a, b); + assertEquals(a.hashCode(), b.hashCode()); + } + + @Test + void differentQuantityNotEqual() { + Stock stock = new Stock("AAPL", "Apple Inc.", new BigDecimal("100")); + Share a = new Share(stock, new BigDecimal("5"), new BigDecimal("90")); + Share b = new Share(stock, new BigDecimal("6"), new BigDecimal("90")); + assertNotEquals(a, b); + } } diff --git a/src/test/java/edu/ntnu/idi/idatt2003/gruppe42/model/StockFileHandlerTest.java b/src/test/java/edu/ntnu/idi/idatt2003/gruppe42/model/StockFileHandlerTest.java index 22c4d92..e3a2d90 100644 --- a/src/test/java/edu/ntnu/idi/idatt2003/gruppe42/model/StockFileHandlerTest.java +++ b/src/test/java/edu/ntnu/idi/idatt2003/gruppe42/model/StockFileHandlerTest.java @@ -2,10 +2,12 @@ import static org.junit.jupiter.api.Assertions.assertEquals; +import edu.ntnu.idi.idatt2003.gruppe42.model.exceptions.StockFileParseException; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; import java.util.List; +import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Test; /** @@ -15,10 +17,18 @@ * including handling of valid entries, comments, and empty lines. */ public class StockFileHandlerTest { + private Path testFile; + + @AfterEach + void tearDown() throws IOException { + if (testFile != null) { + Files.deleteIfExists(testFile); + } + } @Test - void readFromFileTest() throws Exception { - Path testFile = Files.createTempFile("stocks", "csv"); + void readFromFileTest() throws IOException, StockFileParseException { + testFile = Files.createTempFile("stocks", "csv"); Files.writeString(testFile, "AAPL,Apple Inc.,276.43\nMSFT,Microsoft,404.68"); List stocks = StockFileHandler.readFromFile(testFile); assertEquals(2, stocks.size()); @@ -26,11 +36,35 @@ void readFromFileTest() throws Exception { } @Test - void readFromFileWithCommentsAndEmptyLines() throws Exception { - Path testFile = Files.createTempFile("stocks", "csv"); + void readFromFileWithCommentsAndEmptyLines() throws IOException, StockFileParseException { + testFile = Files.createTempFile("stocks", "csv"); Files.writeString(testFile, "#Comment\n\nAAPL,Apple Inc.,276.43"); List stocks = StockFileHandler.readFromFile(testFile); assertEquals(1, stocks.size()); } + + @Test + void testReadInvalidSymbol() throws IOException { + testFile = Files.createTempFile("invalid_symbol", "csv"); + Files.writeString(testFile, "INVALIDTOOLONG,Apple,100.00"); + org.junit.jupiter.api.Assertions.assertThrows(StockFileParseException.class, () -> + StockFileHandler.readFromFile(testFile)); + } + + @Test + void testReadInvalidPrice() throws IOException { + testFile = Files.createTempFile("invalid_price", "csv"); + Files.writeString(testFile, "AAPL,Apple,100"); + org.junit.jupiter.api.Assertions.assertThrows(StockFileParseException.class, () -> + StockFileHandler.readFromFile(testFile)); + } + + @Test + void testReadMissingFields() throws IOException { + testFile = Files.createTempFile("missing_fields", "csv"); + Files.writeString(testFile, "AAPL,Apple"); + org.junit.jupiter.api.Assertions.assertThrows(StockFileParseException.class, () -> + StockFileHandler.readFromFile(testFile)); + } } diff --git a/src/test/java/edu/ntnu/idi/idatt2003/gruppe42/model/transaction/TransactionArchiveTest.java b/src/test/java/edu/ntnu/idi/idatt2003/gruppe42/model/transaction/TransactionArchiveTest.java index 9502a4d..227ff53 100644 --- a/src/test/java/edu/ntnu/idi/idatt2003/gruppe42/model/transaction/TransactionArchiveTest.java +++ b/src/test/java/edu/ntnu/idi/idatt2003/gruppe42/model/transaction/TransactionArchiveTest.java @@ -27,7 +27,7 @@ void setUp() { Stock stock = new Stock("AAPL", "Apple Inc.", new BigDecimal("100")); Share share = new Share(stock, new BigDecimal("10"), new BigDecimal("90")); purchase = new Purchase(share, 1); - sale = new Sale(share, 2); + sale = new Sale(share, 2); } @Test @@ -67,4 +67,37 @@ void countDistinctWeeksTest() { assertEquals(2, archive.countDistinctWeeks()); } + + @Test + void testCalculateNetIncome() { + // Purchase: 10 shares @ 90 → gross 900, commission 9, total cost 909. + // Sale: 10 shares @ 100 → gross 1000, commission 10, tax 22% of (100-90)*10=22, total 968. + // Net = 968 - 909 = 59. + Stock stock = new Stock("AAPL", "Apple Inc.", new BigDecimal("100")); + Share share = new Share(stock, new BigDecimal("10"), new BigDecimal("90")); + Purchase p = new Purchase(share, 1); + Sale s = new Sale(share, 1); + + archive.add(p); + archive.add(s); + + BigDecimal net = archive.calculateNetIncome(1); + assertEquals(0, new BigDecimal("59.00").compareTo(net)); + } + + @Test + void testCalculateNetIncomeEmptyWeek() { + assertEquals(0, BigDecimal.ZERO.compareTo(archive.calculateNetIncome(99))); + } + + @Test + void testCountDistinctWeeksEmptyArchive() { + assertEquals(0, archive.countDistinctWeeks()); + } + + @Test + void testGetTransactionsUnknownWeekReturnsEmpty() { + archive.add(purchase); + assertTrue(archive.getTransactions(999).isEmpty()); + } }