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/pom.xml b/pom.xml index f850370..a210996 100644 --- a/pom.xml +++ b/pom.xml @@ -38,7 +38,7 @@ org.junit.jupiter junit-jupiter - 6.0.1 + 5.11.4 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 d0f7b25..a3ea37b 100644 --- a/src/main/java/edu/ntnu/idi/idatt2003/gruppe42/Millions.java +++ b/src/main/java/edu/ntnu/idi/idatt2003/gruppe42/Millions.java @@ -71,13 +71,14 @@ public void initGame(final String username, final Difficulty difficulty, InputStream stocksStream, PopupsController popupsController) { player = GameFactory.createPlayer(username, difficulty); gameController = new GameController(); - gameController.startGame(); desktopViewController = new DesktopViewController(player, gameController, stocksStream, 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 0293dad..e449cba 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 @@ -21,6 +21,8 @@ */ public final class AudioManager { + private static final System.Logger LOGGER = + System.getLogger(AudioManager.class.getName()); private static final AudioManager INSTANCE = new AudioManager(); private final Map sfxCache = new EnumMap<>(Audio.class); @@ -37,7 +39,7 @@ private AudioManager() { sfxCache.put(audio, stream.readAllBytes()); } } catch (IOException e) { - System.err.println("[Audio] Failed to cache " + audio + ": " + e.getMessage()); + LOGGER.log(System.Logger.Level.WARNING, "Failed to cache SFX: {0}", audio.getPath()); } } } @@ -94,7 +96,7 @@ public void playSfx(final Audio audio) { clip.start(); } catch (Exception e) { - System.err.println("[Audio] SFX playback failed: " + e.getMessage()); + LOGGER.log(System.Logger.Level.WARNING, "SFX playback failed", e); } }, "sfx-thread").start(); } diff --git a/src/main/java/edu/ntnu/idi/idatt2003/gruppe42/controller/GameController.java b/src/main/java/edu/ntnu/idi/idatt2003/gruppe42/controller/GameController.java index 8c751ff..5a255d4 100644 --- a/src/main/java/edu/ntnu/idi/idatt2003/gruppe42/controller/GameController.java +++ b/src/main/java/edu/ntnu/idi/idatt2003/gruppe42/controller/GameController.java @@ -92,9 +92,9 @@ public void stopGame() { /** * Returns whether the current game state is a weekend. * - * @return {@code true} if the game state is not {@link GameState#WORKDAY}. + * @return {@code true} if the game state is {@link GameState#FREEDAY}. */ public boolean isWeekend() { - return gameState != GameState.WORKDAY; + return gameState == GameState.FREEDAY; } } \ No newline at end of file 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 c10afcd..2cc9048 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 @@ -1,5 +1,6 @@ package edu.ntnu.idi.idatt2003.gruppe42.controller; +import edu.ntnu.idi.idatt2003.gruppe42.controller.appcontrollers.AppController; import edu.ntnu.idi.idatt2003.gruppe42.model.Exchange; import edu.ntnu.idi.idatt2003.gruppe42.model.Stock; import edu.ntnu.idi.idatt2003.gruppe42.model.StockFileHandler; @@ -7,32 +8,39 @@ import java.io.IOException; import java.io.InputStream; import java.util.List; +import java.util.Map; /** * Controller class for managing the stock market exchange. */ -public final class MarketController { +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 stocksStream the path to the stock data 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(InputStream stocksStream) { - this.stockResolution = 120; - try { - exchange = new Exchange(StockFileHandler.readFromFile(stocksStream)); - startMarket(); - System.out.println("Market loaded"); - } catch (IOException exception) { - System.out.println("File not found"); - } catch (StockFileParseException exception) { - System.out.println("Invalid stock file format"); + public MarketController(InputStream stocksStream) throws IOException, StockFileParseException { + exchange = new Exchange(StockFileHandler.readFromFile(stocksStream)); + for (Stock stock : exchange.getAllStocks()) { + stockControllers.put(stock.getSymbol(), new StockController(stock)); } + startMarket(); } + /** + * Returns the exchange managed by this controller. + * + * @return the exchange + */ public Exchange getExchange() { return exchange; } @@ -52,19 +60,29 @@ public List getMarket(final String searchTerm) { */ public void updateMarket() { for (Stock stock : exchange.getAllStocks()) { - stock.updatePrice(); - stock.updateStockGraph(); + StockController controller = stockControllers.get(stock.getSymbol()); + if (controller != null) { + stock.addNewSalesPrice(controller.updatePrice()); + } } } + @Override + public void nextTick() { + updateMarket(); + } + /** * Initializes the market by generating stock history and graphs. */ public void startMarket() { for (Stock stock : exchange.getAllStocks()) { - stock.fakeHistory(stockResolution); - stock.updateStockGraph(); - + StockController controller = stockControllers.get(stock.getSymbol()); + if (controller != null) { + 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 80a8b12..10add7b 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 @@ -23,6 +23,9 @@ /** Controller for StockApp. */ public final class StockAppController implements AppController { + private static final System.Logger LOGGER = + System.getLogger(StockAppController.class.getName()); + private final MarketController marketController; private final StockApp stockApp; private final Player player; @@ -97,9 +100,9 @@ public StockAppController( stockApp.getQuantitySpinner().valueProperty().addListener( (obs, old, newValue) -> { - if (isWeekend) { + if (isWeekend && onMarketClosed != null) { onMarketClosed.run(); - } + } if (currentStock != null) { updateReceipt(); } @@ -113,17 +116,15 @@ public StockAppController( /** Navigates to stock detail page. */ public void navigateToStock(final Stock stock) { currentStock = stock; - stock.getStockGraph().setVisibility(true); - Platform.runLater(stock::updateStockGraph); + stockApp.getStockGraph().setVisibility(true); stockApp.showStockPage(stock); + stockApp.getStockGraph().update(stock); updateReceipt(); } /** Navigates to search page. */ private void navigateToSearch() { - if (currentStock != null) { - currentStock.getStockGraph().setVisibility(false); - } + stockApp.getStockGraph().setVisibility(false); currentStock = null; stockApp.showSearchPage(); } @@ -262,20 +263,23 @@ private void handleTransaction() { if (value > 0) { try { handleBuy(currentStock, quantity); + } catch (edu.ntnu.idi.idatt2003.gruppe42.model.exceptions.InsufficientFunds e) { + if (onInsufficientFunds != null) { + onInsufficientFunds.run(); + } } catch (Exception e) { - onInsufficientFunds.run(); + LOGGER.log(System.Logger.Level.WARNING, "Unexpected error during buy transaction", e); } } else { handleSell(currentStock, quantity); } } - private void handleBuy(final Stock stock, final BigDecimal quantity) throws Exception { + private void handleBuy(final Stock stock, final BigDecimal quantity) { Transaction transaction = marketController.getExchange() .buy(stock.getSymbol(), quantity); if (transaction != null) { transaction.commit(player); - player.getPortfolio().addShare(transaction.getShare()); player.updateStatus(); } } @@ -294,11 +298,10 @@ private void handleSell(final Stock stock, final BigDecimal quantity) { if (transaction != null) { try { transaction.commit(player); + player.updateStatus(); } catch (Exception e) { - System.out.println("Transaction failed"); + LOGGER.log(System.Logger.Level.WARNING, "Unexpected error during sell transaction", e); } - player.getPortfolio().removeShare(transaction.getShare()); - player.updateStatus(); } }); } @@ -334,7 +337,6 @@ private void updateStockList() { /** Processes next tick. */ @Override public void nextTick() { - marketController.updateMarket(); Platform.runLater(() -> stockApp.getStockList().refresh()); if (currentStock != null) { stockApp.updatePriceLabel(currentStock); 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 9feae79..4b8e991 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 @@ -89,7 +89,13 @@ public DesktopViewController( this.timeAndWeatherController = new TimeAndWeatherController(gameController); gameController.addAppController(timeAndWeatherController); - this.marketController = new MarketController(stocksStream); + try { + this.marketController = new MarketController(stocksStream); + } catch (Exception e) { + // In a real app we'd show an error dialog, but here we'll at least not swallow it silently + throw new RuntimeException("Failed to load market", e); + } + this.mailController = new MailController(timeAndWeatherController); gameController.addAppController(mailController); @@ -126,14 +132,15 @@ 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(); StockApp stockApp = new StockApp(player); this.stockAppController = new StockAppController(stockApp, marketController, player); gameController.addAppController(stockAppController); + gameController.addAppController(marketController); final MailApp mailApp = new MailApp(mailController); final NewsApp newsApp = new NewsApp(); @@ -232,12 +239,12 @@ private void setupListeners() { timeAndWeatherController.temperatureProperty().addListener((obs, ov, nv) -> desktopView.updateTemperature(String.valueOf(nv))); - player.getNameProperty().addListener((obs, ov, nv) -> - desktopView.updatePlayerName(nv)); + player.addNameListener(name -> + Platform.runLater(() -> desktopView.updatePlayerName(name))); player.updateStatus(); - player.getStatusProperty().addListener((obs, ov, nv) -> - desktopView.updatePlayerStatus(nv.name())); + player.addStatusListener(s -> + Platform.runLater(() -> desktopView.updatePlayerStatus(s.name()))); desktopView.updateClock(timeAndWeatherController.getTimeString()); String startDay = timeAndWeatherController.getDayOfWeekString(); 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 eccac6a..3743dcf 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 @@ -17,6 +17,9 @@ public class StartViewController { + private static final System.Logger LOGGER = + System.getLogger(StartViewController.class.getName()); + private String username = ""; private final StartView startView; @@ -27,18 +30,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" ); public StartViewController(final Millions application, final StartView startView) { @@ -124,6 +127,14 @@ private Difficulty resolveDifficulty() { return Difficulty.EASY; } + if (Objects.isNull(userSelectedPath)) { + try { + userSelectedPath = Path.of(Objects.requireNonNull( + getClass().getClassLoader().getResource("stocks.csv")).toURI()); + } catch (Exception e) { + LOGGER.log(System.Logger.Level.WARNING, "Could not resolve default stocks.csv resource", e); + } + if (selectedMode.startsWith("Medium")) { return Difficulty.MEDIUM; } 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 13d1d48..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,18 +41,35 @@ 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); } + /** + * Unsubscribes a listener from events of the given type. + * + * @param the event type. + * @param type the class of the event. + * @param listener the listener to remove. + */ + public synchronized void unsubscribe(Class type, Consumer listener) { + List> handlers = listeners.get(type); + if (handlers != null) { + handlers.remove(listener); + } + } + /** * Publishes an event to all subscribers of its type. * * @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/Exchange.java b/src/main/java/edu/ntnu/idi/idatt2003/gruppe42/model/Exchange.java index a1f745f..4257a34 100644 --- a/src/main/java/edu/ntnu/idi/idatt2003/gruppe42/model/Exchange.java +++ b/src/main/java/edu/ntnu/idi/idatt2003/gruppe42/model/Exchange.java @@ -25,10 +25,15 @@ public class Exchange { * @param stocks the stocks to list on this exchange. */ public Exchange(final List stocks) { + if (stocks == null) { + throw new IllegalArgumentException("Stocks list cannot be null"); + } this.week = 0; this.stockMap = new HashMap<>(); for (Stock stock : stocks) { - stockMap.put(stock.getSymbol(), stock); + if (stock != null) { + stockMap.put(stock.getSymbol(), stock); + } } } 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 b92e457..a5abc74 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,10 +44,7 @@ public News(String company, int trend) { * @return a randomly selected fictional author email */ public String generateAuthor() { - List authors = new ArrayList<>(); - // Saved as List in-case new authors are added - authors.add("market@news.com"); - return authors.get(random.nextInt(authors.size())); + return AUTHORS.get(RANDOM.nextInt(AUTHORS.size())); } /** @@ -48,53 +57,52 @@ 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("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 c2517fa..cbedf32 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 @@ -3,12 +3,9 @@ import edu.ntnu.idi.idatt2003.gruppe42.model.exceptions.InsufficientFunds; import edu.ntnu.idi.idatt2003.gruppe42.model.transaction.TransactionArchive; import java.math.BigDecimal; -import javafx.beans.property.IntegerProperty; -import javafx.beans.property.ObjectProperty; -import javafx.beans.property.SimpleIntegerProperty; -import javafx.beans.property.SimpleObjectProperty; -import javafx.beans.property.SimpleStringProperty; -import javafx.beans.property.StringProperty; +import java.util.ArrayList; +import java.util.List; +import java.util.function.Consumer; /** * Represents a player in the game, holding their financial state, portfolio, @@ -21,14 +18,22 @@ 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 String name; private final BigDecimal startingMoney; private BigDecimal money; private final Portfolio portfolio; private final TransactionArchive transactionArchive; - private final IntegerProperty lives = new SimpleIntegerProperty(MAX_LIVES); - private final ObjectProperty status = new SimpleObjectProperty<>(Status.NOVICE); + private int lives = MAX_LIVES; + private Status status = Status.NOVICE; + + private final List> nameListeners = new ArrayList<>(); + private final List> livesListeners = new ArrayList<>(); + private final List> statusListeners = new ArrayList<>(); /** * Constructs a new {@code Player} with the given name and starting balance. @@ -40,7 +45,13 @@ public class Player { * @param startingMoney the initial cash balance; must not be {@code null} or negative */ public Player(final String name, final BigDecimal startingMoney) { - this.name.set(name); + if (name == null || name.isBlank()) { + throw new IllegalArgumentException("Name cannot be null or blank"); + } + if (startingMoney == null || startingMoney.compareTo(BigDecimal.ZERO) < 0) { + throw new IllegalArgumentException("Starting money cannot be null or negative"); + } + this.name = name; this.startingMoney = startingMoney; this.money = startingMoney; this.portfolio = new Portfolio(); @@ -48,48 +59,76 @@ public Player(final String name, final BigDecimal startingMoney) { } /** - * Returns the observable {@link StringProperty} backing the player's name. - * - *

Useful for binding UI components directly to the player's name. + * Registers a listener that is notified whenever the player's name changes. * - * @return the name property; never {@code null} + * @param listener the consumer to call with the new name; must not be {@code null} */ - public StringProperty getNameProperty() { - return name; + public void addNameListener(final Consumer listener) { + nameListeners.add(listener); } + /** + * Returns the player's display name. + * + * @return the name; never {@code null} + */ public String getName() { - return name.get(); + return name; } /** - * Sets the player's display name. + * Sets the player's display name and notifies registered name listeners. * * @param name the new name; must not be {@code null} */ public void setName(final String name) { - this.name.set(name); + this.name = name; + nameListeners.forEach(l -> l.accept(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(); + return status.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); @@ -98,37 +137,78 @@ 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()); } - public IntegerProperty getLivesProperty() { - return lives; + /** + * Registers a listener that is notified whenever the player's life count changes. + * + * @param listener the consumer to call with the new life count; must not be {@code null} + */ + public void addLivesListener(final Consumer listener) { + livesListeners.add(listener); } + /** + * Returns the player's current number of lives. + * + * @return the number of lives remaining + */ public int getLives() { - return lives.get(); + return lives; } - /** Decrements life. */ + /** + * Decrements the player's life count by one, stopping at zero, + * and notifies registered lives listeners. + */ public void loseLife() { - lives.set(Math.max(0, lives.get() - 1)); + lives = Math.max(0, lives - 1); + livesListeners.forEach(l -> l.accept(lives)); } + /** + * 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; } @@ -151,32 +231,40 @@ 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; + Status next; if (isSpeculator) { - status.set(Status.SPECULATOR); + next = Status.SPECULATOR; } else if (isInvestor) { - status.set(Status.INVESTOR); + next = Status.INVESTOR; } else { - status.set(Status.NOVICE); + next = Status.NOVICE; + } + + if (next != status) { + status = next; + statusListeners.forEach(l -> l.accept(status)); } } - public ObjectProperty getStatusProperty() { - return status; + /** + * Registers a listener that is notified whenever the player's status changes. + * + * @param listener the consumer to call with the new status; must not be {@code null} + */ + public void addStatusListener(final Consumer listener) { + statusListeners.add(listener); } } \ No newline at end of file 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 dd4700c..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,8 +104,13 @@ 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 shares; + 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 d3c782b..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 @@ -23,11 +23,25 @@ public Share( final BigDecimal quantity, final BigDecimal purchasePrice ) { + if (stock == null) { + throw new IllegalArgumentException("Stock cannot be null"); + } + if (quantity == null || quantity.compareTo(BigDecimal.ZERO) <= 0) { + throw new IllegalArgumentException("Quantity must be positive"); + } + if (purchasePrice == null || purchasePrice.compareTo(BigDecimal.ZERO) < 0) { + throw new IllegalArgumentException("Purchase price cannot be negative"); + } this.stock = stock; this.quantity = quantity; this.purchasePrice = purchasePrice; } + /** + * Returns the stock this share belongs to. + * + * @return the stock; never {@code null} + */ public Stock getStock() { return stock; } @@ -41,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; } @@ -67,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; } /** @@ -80,6 +102,10 @@ public boolean equals(Object object) { */ @Override public int hashCode() { - return Objects.hash(stock.getSymbol(), quantity, purchasePrice); + return Objects.hash( + stock.getSymbol(), + quantity.stripTrailingZeros(), + purchasePrice.stripTrailingZeros() + ); } } diff --git a/src/main/java/edu/ntnu/idi/idatt2003/gruppe42/model/Status.java b/src/main/java/edu/ntnu/idi/idatt2003/gruppe42/model/Status.java index 7c7d9c5..f36fe1a 100644 --- a/src/main/java/edu/ntnu/idi/idatt2003/gruppe42/model/Status.java +++ b/src/main/java/edu/ntnu/idi/idatt2003/gruppe42/model/Status.java @@ -11,7 +11,7 @@ *

  • {@link #INVESTOR}: Achieved by trading for at least 10 distinct weeks * and increasing net worth by at least 20%.
  • *
  • {@link #SPECULATOR}: Achieved by trading for at least 20 distinct weeks - * and doubling net worth.
  • + * and increasing net worth by at least 5 times. * */ public enum Status { diff --git a/src/main/java/edu/ntnu/idi/idatt2003/gruppe42/model/Stock.java b/src/main/java/edu/ntnu/idi/idatt2003/gruppe42/model/Stock.java index 430c5e1..0bea46a 100644 --- a/src/main/java/edu/ntnu/idi/idatt2003/gruppe42/model/Stock.java +++ b/src/main/java/edu/ntnu/idi/idatt2003/gruppe42/model/Stock.java @@ -1,7 +1,5 @@ package edu.ntnu.idi.idatt2003.gruppe42.model; -import edu.ntnu.idi.idatt2003.gruppe42.controller.StockController; -import edu.ntnu.idi.idatt2003.gruppe42.view.views.components.StockGraph; import java.math.BigDecimal; import java.util.ArrayList; import java.util.List; @@ -9,21 +7,17 @@ /** * Represents a publicly traded stock belonging to a company. * - *

    A stock maintains a full chronological price history, exposes summary - * statistics (highest, lowest, the latest change), and delegates price-update - * logic to a {@link StockController}. An optional {@link StockGraph} can be - * lazily attached for visual rendering. + *

    A stock maintains a full chronological price history and exposes summary + * statistics (highest, lowest, the latest change). * *

    Price history is append-only: new prices are recorded via - * {@link #addNewSalesPrice(BigDecimal)} or {@link #updatePrice()}, and the + * {@link #addNewSalesPrice(BigDecimal)} and the * most recent entry is always considered the current market price. */ public class Stock { private final String symbol; private final String company; - private StockGraph stockGraph; private final List prices = new ArrayList<>(); - private final StockController stockController; /** Constructs a new stock. */ public Stock( @@ -31,10 +25,18 @@ public Stock( final String company, final BigDecimal salesPrice ) { + if (symbol == null || symbol.isBlank()) { + throw new IllegalArgumentException("Symbol cannot be null or blank"); + } + if (company == null || company.isBlank()) { + throw new IllegalArgumentException("Company cannot be null or blank"); + } + if (salesPrice == null || salesPrice.compareTo(BigDecimal.ZERO) < 0) { + throw new IllegalArgumentException("Sales price cannot be negative"); + } this.symbol = symbol; this.company = company; prices.add(salesPrice); - stockController = new StockController(this); } public String getSymbol() { @@ -75,38 +77,4 @@ public BigDecimal getLatestPriceChange() { int index120Ago = Math.max(0, prices.size() - 121); return getSalesPrice().subtract(prices.get(index120Ago)); } - - /** Updates the price. */ - public void updatePrice() { - BigDecimal newPrice = stockController.updatePrice(); - prices.add(newPrice); - } - - /** - * Returns the {@link StockGraph} component associated with this stock, - * creating it lazily on the first call. - * - * @return the stock graph; never {@code null} - */ - public StockGraph getStockGraph() { - if (stockGraph == null) { - stockGraph = new StockGraph(); - } - return stockGraph; - } - - /** Generates fake history. */ - public void fakeHistory(int stockResolution) { - for (int i = 0; i < stockResolution; i++) { - updatePrice(); - } - java.util.Collections.reverse(prices); - } - - /** Updates the stock graph. */ - public void updateStockGraph() { - if (stockGraph != null && stockGraph.getVisibility()) { - stockGraph.update(this); - } - } } 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 1f497a5..31fb04f 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 @@ -89,14 +89,9 @@ public static List readFromFile(final InputStream inputStream) } if (!SYMBOL_PATTERN.matcher(symbol).matches()) { - throw new StockFileParseException( - lineNumber, - trimmed, - String.format( - "Ticker symbol \"%s\" is invalid", - symbol - ) - ); + throw new StockFileParseException(lineNumber, trimmed, + String.format("Ticker symbol \"%s\" is invalid — " + + "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 332b1f9..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,10 +54,12 @@ public BigDecimal calculateCommission() { */ @Override public BigDecimal calculateTax() { - final BigDecimal taxRate = new BigDecimal("0.22"); BigDecimal differencePrice = salesPrice.subtract(purchasePrice); - BigDecimal difference = differencePrice.multiply(quantity); - return difference.multiply(taxRate); + BigDecimal profit = differencePrice.multiply(quantity); + if (profit.compareTo(BigDecimal.ZERO) <= 0) { + return BigDecimal.ZERO; + } + 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 8f22e11..841f236 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,6 +33,23 @@ public Purchase(final Share share, final int week) { @Override public void commit(final Player player) { player.withdrawMoney(getCalculator().calculateTotal()); + 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); } + + /** + * Returns the total cost of this purchase as a negative contribution to weekly income. + * + * @return the purchase total negated + */ + @Override + public java.math.BigDecimal netIncomeContribution() { + return getCalculator().calculateTotal().negate(); + } } 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 1ccb8aa..e5ba743 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,6 +33,29 @@ public Sale(final Share share, final int week) { @Override public void commit(final Player player) { player.addMoney(getCalculator().calculateTotal()); + 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); } + + /** + * Returns the net proceeds of this sale as a positive contribution to weekly income. + * + * @return the sale total after commission and tax + */ + @Override + public java.math.BigDecimal netIncomeContribution() { + return getCalculator().calculateTotal(); + } } 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 60d72be..3918095 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 @@ -3,6 +3,7 @@ import edu.ntnu.idi.idatt2003.gruppe42.model.Player; import edu.ntnu.idi.idatt2003.gruppe42.model.Share; import edu.ntnu.idi.idatt2003.gruppe42.model.calculator.TransactionCalculator; +import java.math.BigDecimal; /** * Base class for all stock-related transactions. @@ -33,14 +34,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; } @@ -51,7 +67,16 @@ public TransactionCalculator getCalculator() { *

    Subclasses define the specific behavior (e.g., buying or selling shares). * * @param player the player executing the transaction - * @throws Exception if the transaction cannot be completed */ - public abstract void commit(Player player) throws Exception; + public abstract void commit(Player player); + + /** + * Returns this transaction's signed contribution to weekly net income. + * + *

    Sales return a positive value (money received); purchases return a negative + * value (money spent). Used by {@link TransactionArchive#calculateNetIncome(int)}. + * + * @return the signed net-income contribution of this transaction + */ + public abstract BigDecimal netIncomeContribution(); } diff --git a/src/main/java/edu/ntnu/idi/idatt2003/gruppe42/model/transaction/TransactionArchive.java b/src/main/java/edu/ntnu/idi/idatt2003/gruppe42/model/transaction/TransactionArchive.java index 647d881..9093438 100644 --- a/src/main/java/edu/ntnu/idi/idatt2003/gruppe42/model/transaction/TransactionArchive.java +++ b/src/main/java/edu/ntnu/idi/idatt2003/gruppe42/model/transaction/TransactionArchive.java @@ -126,11 +126,7 @@ public int countDistinctWeeks() { public BigDecimal calculateNetIncome(final int week) { BigDecimal net = BigDecimal.ZERO; for (Transaction t : getTransactions(week)) { - if (t instanceof Sale) { - net = net.add(t.getCalculator().calculateTotal()); - } else if (t instanceof Purchase) { - net = net.subtract(t.getCalculator().calculateTotal()); - } + net = net.add(t.netIncomeContribution()); } return net; } diff --git a/src/main/java/edu/ntnu/idi/idatt2003/gruppe42/view/apps/BankApp.java b/src/main/java/edu/ntnu/idi/idatt2003/gruppe42/view/apps/BankApp.java index fa9e4bd..b2c3b50 100644 --- a/src/main/java/edu/ntnu/idi/idatt2003/gruppe42/view/apps/BankApp.java +++ b/src/main/java/edu/ntnu/idi/idatt2003/gruppe42/view/apps/BankApp.java @@ -44,7 +44,7 @@ public class BankApp extends Popup { *

    Initializes the portfolio list view and builds the UI layout. */ public BankApp() { - super(550, 500, 220, 100, App.BANK); + super(550, 600, 220, 100, App.BANK); portfolioList = new ListView<>(); portfolioList.setSelectionModel(null); diff --git a/src/main/java/edu/ntnu/idi/idatt2003/gruppe42/view/apps/StockApp.java b/src/main/java/edu/ntnu/idi/idatt2003/gruppe42/view/apps/StockApp.java index 527cc78..94339f5 100644 --- a/src/main/java/edu/ntnu/idi/idatt2003/gruppe42/view/apps/StockApp.java +++ b/src/main/java/edu/ntnu/idi/idatt2003/gruppe42/view/apps/StockApp.java @@ -46,6 +46,7 @@ public class StockApp extends Popup { private final Button confirmButton; private final Button backButton; private final Receipt receipt; + private final StockGraph stockGraph; /** * Constructs the Stock trading popup. @@ -55,6 +56,8 @@ public class StockApp extends Popup { public StockApp(final Player player) { super(475, 450, 140, 140, App.STOCK); + this.stockGraph = new StockGraph(); + this.searchField = new TextField(); this.searchField.setPromptText("Search stocks..."); this.searchField.getStyleClass().add("search-field"); @@ -137,15 +140,15 @@ public void showStockPage(final Stock stock) { priceLabel = new Label("$" + String.format("%,.2f", stock.getSalesPrice())); priceLabel.getStyleClass().add("stock-price-large"); - StockGraph graph = stock.getStockGraph(); - VBox.setVgrow(graph, Priority.ALWAYS); + VBox.setVgrow(stockGraph, Priority.ALWAYS); + stockGraph.update(stock); receipt.update(stock, new BigDecimal(quantitySpinner.getValue())); HBox receiptCentered = new HBox(receipt); receiptCentered.setAlignment(Pos.CENTER); content.setSpacing(20); - content.getChildren().setAll(topControls, headerBox, priceLabel, graph, receiptCentered); + content.getChildren().setAll(topControls, headerBox, priceLabel, stockGraph, receiptCentered); } /** @@ -155,12 +158,19 @@ public void showStockPage(final Stock stock) { */ public void updatePriceLabel(final Stock stock) { if (priceLabel != null) { - Platform.runLater(() -> - priceLabel.setText("$" + String.format("%,.2f", stock.getSalesPrice())) - ); + Platform.runLater(() -> { + priceLabel.setText("$" + String.format("%,.2f", stock.getSalesPrice())); + if (stockGraph.getVisibility()) { + stockGraph.update(stock); + } + }); } } + public StockGraph getStockGraph() { + return stockGraph; + } + public TextField getSearchField() { return searchField; } diff --git a/src/main/java/edu/ntnu/idi/idatt2003/gruppe42/view/views/components/DesktopBottomBar.java b/src/main/java/edu/ntnu/idi/idatt2003/gruppe42/view/views/components/DesktopBottomBar.java index 96585c7..b427a7e 100644 --- a/src/main/java/edu/ntnu/idi/idatt2003/gruppe42/view/views/components/DesktopBottomBar.java +++ b/src/main/java/edu/ntnu/idi/idatt2003/gruppe42/view/views/components/DesktopBottomBar.java @@ -128,7 +128,7 @@ private HBox buildHeartsBox(final Player player) { hearts[i].getStyleClass().add(active ? "heart-active" : "heart-empty"); } }; - player.getLivesProperty().addListener((obs, ov, nv) -> Platform.runLater(refresh)); + player.addLivesListener(nv -> Platform.runLater(refresh)); refresh.run(); return box; 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/PlayerTest.java b/src/test/java/edu/ntnu/idi/idatt2003/gruppe42/model/PlayerTest.java index fb02274..c64d2d7 100644 --- a/src/test/java/edu/ntnu/idi/idatt2003/gruppe42/model/PlayerTest.java +++ b/src/test/java/edu/ntnu/idi/idatt2003/gruppe42/model/PlayerTest.java @@ -89,4 +89,32 @@ void getNetworthTest() { new BigDecimal("5000"))); assertEquals(new BigDecimal("14950.00"), player.getNetWorth()); } + + @Test + void testUpdateStatusToInvestor() { + // Requires 10 distinct weeks and net worth >= 1.2 * starting money (12000) + for (int i = 0; i < 10; i++) { + player.getTransactionArchive().add(new edu.ntnu.idi.idatt2003.gruppe42.model.transaction.Purchase( + new Share(new Stock("AAPL", "Apple Inc.", new BigDecimal("100")), new BigDecimal("1"), new BigDecimal("100")), + i + )); + } + player.addMoney(new BigDecimal("2000")); // Net worth becomes 12000 + player.updateStatus(); + assertEquals("INVESTOR", player.getStatus()); + } + + @Test + void testUpdateStatusToSpeculator() { + // Requires 20 distinct weeks and net worth >= 5 * starting money (50000) + for (int i = 0; i < 20; i++) { + player.getTransactionArchive().add(new edu.ntnu.idi.idatt2003.gruppe42.model.transaction.Purchase( + new Share(new Stock("AAPL", "Apple Inc.", new BigDecimal("100")), new BigDecimal("1"), new BigDecimal("100")), + i + )); + } + player.addMoney(new BigDecimal("40000")); // Net worth becomes 50000 + player.updateStatus(); + assertEquals("SPECULATOR", player.getStatus()); + } } 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 a71b58d..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 @@ -9,9 +9,9 @@ import org.junit.jupiter.api.Test; /** - * Unit tests for the {@link Exchange} class. + * Unit tests for the {@link Portfolio} class. * - *

    Verifies stock management, search functionality, and week progression behavior. + *

    Verifies share management, merging logic, and quantity tracking. */ public class PortfolioTest { private Portfolio portfolio; @@ -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 ff9c44a..f98aceb 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,13 @@ import static org.junit.jupiter.api.Assertions.assertEquals; +import edu.ntnu.idi.idatt2003.gruppe42.model.exceptions.StockFileParseException; +import java.io.IOException; import java.io.InputStream; 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,6 +18,14 @@ * 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); + } + } /** * Tests that a valid CSV file is correctly parsed into Stock objects. @@ -41,15 +52,34 @@ void readFromFileTest() throws Exception { * @throws Exception if file creation or reading fails */ @Test - void readFromFileWithCommentsAndEmptyLines() throws Exception { - Path testFile = Files.createTempFile("stocks", ".csv"); - Files.writeString(testFile, - "#Comment\n\nAAPL,Apple Inc.,276.43"); + void readFromFileWithCommentsAndEmptyLines() throws IOException, StockFileParseException { + testFile = Files.createTempFile("stocks", "csv"); + Files.writeString(testFile, "#Comment\n\nAAPL,Apple Inc.,276.43"); - try (InputStream in = Files.newInputStream(testFile)) { - List stocks = StockFileHandler.readFromFile(in); + List stocks = StockFileHandler.readFromFile(testFile); + assertEquals(1, stocks.size()); + } - 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)); } \ No newline at end of file diff --git a/src/test/java/edu/ntnu/idi/idatt2003/gruppe42/model/calculator/SaleCalculatorTest.java b/src/test/java/edu/ntnu/idi/idatt2003/gruppe42/model/calculator/SaleCalculatorTest.java index 1231d97..6c76b27 100644 --- a/src/test/java/edu/ntnu/idi/idatt2003/gruppe42/model/calculator/SaleCalculatorTest.java +++ b/src/test/java/edu/ntnu/idi/idatt2003/gruppe42/model/calculator/SaleCalculatorTest.java @@ -69,4 +69,21 @@ void testCalculateTotal() { assertNotEquals(0, new BigDecimal("1375.01").compareTo(calculator.calculateTotal())); assertNotEquals(0, new BigDecimal("1374.99").compareTo(calculator.calculateTotal())); } + + @Test + void testCalculateTaxLoss() { + BigDecimal purchasePrice = new BigDecimal("150"); + BigDecimal salesPrice = new BigDecimal("100"); + BigDecimal quantity = new BigDecimal("10"); + Stock stock = new Stock("AAPL", "Apple Inc.", salesPrice); + Share share = new Share(stock, quantity, purchasePrice); + SaleCalculator lossCalculator = new SaleCalculator(share); + + // Tax should be 0 on a loss, not negative + assertEquals(0, BigDecimal.ZERO.compareTo(lossCalculator.calculateTax()), "Tax should be zero on loss"); + + // Total should be: gross(1000) - commission(10) - tax(0) = 990 + BigDecimal expectedTotal = new BigDecimal("990"); + assertEquals(0, expectedTotal.compareTo(lossCalculator.calculateTotal()), "Total payout should not include negative tax"); + } } 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()); + } }