diff --git a/src/test/java/edu/ntnu/idi/idatt2003/g40/mappe/service/SaveGameServiceTest.java b/src/test/java/edu/ntnu/idi/idatt2003/g40/mappe/service/SaveGameServiceTest.java new file mode 100644 index 0000000..bdd9487 --- /dev/null +++ b/src/test/java/edu/ntnu/idi/idatt2003/g40/mappe/service/SaveGameServiceTest.java @@ -0,0 +1,449 @@ +package edu.ntnu.idi.idatt2003.g40.mappe.service; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import edu.ntnu.idi.idatt2003.g40.mappe.model.OwnedShareData; +import edu.ntnu.idi.idatt2003.g40.mappe.model.SaveGame; +import edu.ntnu.idi.idatt2003.g40.mappe.model.Stock; +import edu.ntnu.idi.idatt2003.g40.mappe.model.TransactionData; +import java.io.IOException; +import java.math.BigDecimal; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +/** + * Test class for {@link SaveGameService}. + * + *

+ * Each test uses a fresh {@link TempDir} so the on-disk state is + * fully isolated from the real saves directory and from other tests. + *

+ */ +class SaveGameServiceTest { + + /** + * Temporary directory used as the saves folder for each test. + */ + @TempDir + private Path tempDir; + + /** + * Service under test, pointed at {@link #tempDir}. + */ + private SaveGameService testService; + + /** + * A populated {@link SaveGame} used as a baseline for write/read tests. + */ + private SaveGame testSaveGame; + + @BeforeEach + void setUp() { + testService = new SaveGameService(tempDir.toString()); + + List ownedShares = List.of( + new OwnedShareData("AAPL", + new BigDecimal("5"), + new BigDecimal("150.00")), + new OwnedShareData("NVID", + new BigDecimal("2"), + new BigDecimal("241.59"))); + + List transactions = List.of( + new TransactionData(TransactionType.PURCHASE, + "AAPL", + new BigDecimal("5"), + new BigDecimal("150.00"), + 1), + new TransactionData(TransactionType.SALE, + "NVID", + new BigDecimal("1"), + new BigDecimal("245.00"), + 2)); + + Stock apple = new Stock("AAPL", "Apple Inc", new BigDecimal("150.00")); + apple.addNewSalesPrice(new BigDecimal("152.30")); + apple.addNewSalesPrice(new BigDecimal("155.25")); + apple.setFortune(1.5); + + Stock nvidia = new Stock("NVID", "Nvidia Corporation", + new BigDecimal("241.59")); + nvidia.addNewSalesPrice(new BigDecimal("245.00")); + + List exchangeStocks = List.of(apple, nvidia); + + List netWorthHistory = List.of( + new BigDecimal("10000.00"), + new BigDecimal("10026.25"), + new BigDecimal("10125.75")); + + testSaveGame = new SaveGame("MySave", + 10026.25, + 10000.00, + null, + 3, + ownedShares, + transactions, + exchangeStocks, + netWorthHistory); + } + + @Test + void saveGameWritesFileWithSanitisedName() throws IOException { + SaveGame messyName = new SaveGame("My Save / Game!", + 500, 500, null); + + testService.saveGame(messyName); + + // Slashes, spaces and exclamation marks become underscores. + Path expected = tempDir.resolve("My_Save___Game_.json"); + assertTrue(Files.isRegularFile(expected), + "Expected sanitised file to exist at " + expected); + } + + @Test + void saveGameCreatesDirectoryWhenItDoesNotExist() throws IOException { + Path nested = tempDir.resolve("nested").resolve("saves"); + SaveGameService nestedService = new SaveGameService(nested.toString()); + + nestedService.saveGame(new SaveGame("Nested", 100, 100, null)); + + assertTrue(Files.isDirectory(nested), + "Saves directory should be auto-created"); + assertTrue(Files.isRegularFile(nested.resolve("Nested.json"))); + } + + @Test + void saveGameThrowsExceptionOnNullSave() { + assertThrows(IllegalArgumentException.class, + () -> testService.saveGame(null)); + } + + @Test + void saveGameWritesAndReadsBackPaddedName() throws IOException { + SaveGame padded = new SaveGame(" Padded ", 100, 100, null); + + testService.saveGame(padded); + + Path expected = tempDir.resolve("Padded.json"); + assertTrue(Files.isRegularFile(expected), + "Expected trimmed file name on disk"); + } + + @Test + void saveExistsReturnsTrueAfterWritingAndFalseOtherwise() + throws IOException { + assertFalse(testService.saveExists("MySave")); + + testService.saveGame(testSaveGame); + + assertTrue(testService.saveExists("MySave")); + assertFalse(testService.saveExists("DoesNotExist")); + } + + @Test + void saveExistsReturnsFalseForNullOrBlankName() { + assertFalse(testService.saveExists(null)); + assertFalse(testService.saveExists("")); + assertFalse(testService.saveExists(" ")); + } + + @Test + void loadSavesReturnsEmptyListWhenDirectoryMissing() { + SaveGameService missingDirService = new SaveGameService( + tempDir.resolve("does-not-exist").toString()); + + List result = missingDirService.loadSaves(); + + assertNotNull(result); + assertTrue(result.isEmpty()); + } + + @Test + void loadSavesIgnoresFilesWithNonJsonExtensions() throws IOException { + Files.writeString(tempDir.resolve("notes.txt"), + "this should be ignored", StandardCharsets.UTF_8); + testService.saveGame(testSaveGame); + + List loaded = testService.loadSaves(); + + assertEquals(1, loaded.size()); + assertEquals("MySave", loaded.get(0).getName()); + } + + @Test + void loadSavesSkipsMalformedJsonFilesAndKeepsValidOnes() + throws IOException { + testService.saveGame(testSaveGame); + Files.writeString(tempDir.resolve("broken.json"), + "this is not valid json", StandardCharsets.UTF_8); + Files.writeString(tempDir.resolve("empty.json"), + "", StandardCharsets.UTF_8); + + List loaded = testService.loadSaves(); + + assertEquals(1, loaded.size()); + assertEquals("MySave", loaded.get(0).getName()); + } + + @Test + void loadSavesReturnsSavesSortedByFileName() throws IOException { + testService.saveGame(new SaveGame("B-save", 100, 100, null)); + testService.saveGame(new SaveGame("A-save", 100, 100, null)); + testService.saveGame(new SaveGame("C-save", 100, 100, null)); + + List loaded = testService.loadSaves(); + + assertEquals(3, loaded.size()); + assertEquals("A-save", loaded.get(0).getName()); + assertEquals("B-save", loaded.get(1).getName()); + assertEquals("C-save", loaded.get(2).getName()); + } + + @Test + void saveAndLoadRoundTripPreservesAllFields() throws IOException { + testService.saveGame(testSaveGame); + + List loaded = testService.loadSaves(); + assertEquals(1, loaded.size()); + + SaveGame result = loaded.get(0); + assertEquals("MySave", result.getName()); + assertEquals(10026.25, result.getBalance()); + assertEquals(10000.00, result.getStartingCapital()); + assertNull(result.getStockDataPath()); + assertEquals(3, result.getWeek()); + + // Owned shares. + assertEquals(2, result.getOwnedShares().size()); + OwnedShareData firstShare = result.getOwnedShares().get(0); + assertEquals("AAPL", firstShare.getSymbol()); + assertEquals(new BigDecimal("5"), firstShare.getQuantity()); + assertEquals(new BigDecimal("150.00"), firstShare.getPurchasePrice()); + + // Transactions. + assertEquals(2, result.getTransactions().size()); + TransactionData firstTx = result.getTransactions().get(0); + assertEquals(TransactionType.PURCHASE, firstTx.getType()); + assertEquals("AAPL", firstTx.getSymbol()); + assertEquals(new BigDecimal("5"), firstTx.getQuantity()); + assertEquals(new BigDecimal("150.00"), firstTx.getPrice()); + assertEquals(1, firstTx.getWeek()); + + TransactionData secondTx = result.getTransactions().get(1); + assertEquals(TransactionType.SALE, secondTx.getType()); + assertEquals(2, secondTx.getWeek()); + + // Exchange stocks - full price history must round-trip. + assertEquals(2, result.getExchangeStocks().size()); + Stock loadedApple = result.getExchangeStocks().get(0); + assertEquals("AAPL", loadedApple.getSymbol()); + assertEquals("Apple Inc", loadedApple.getCompany()); + assertEquals(3, loadedApple.getHistoricalPrices().size()); + assertEquals(new BigDecimal("150.00"), + loadedApple.getHistoricalPrices().get(0)); + assertEquals(new BigDecimal("152.30"), + loadedApple.getHistoricalPrices().get(1)); + assertEquals(new BigDecimal("155.25"), loadedApple.getSalesPrice()); + assertEquals(1.5, loadedApple.getFortune()); + + // Net worth history. + assertEquals(3, result.getNetWorthHistory().size()); + assertEquals(new BigDecimal("10000.00"), + result.getNetWorthHistory().get(0)); + assertEquals(new BigDecimal("10125.75"), + result.getNetWorthHistory().get(2)); + } + + @Test + void saveAndLoadRoundTripPreservesNonNullStockDataPath() + throws IOException { + SaveGame withPath = new SaveGame("WithPath", + 500.0, 500.0, + "C:\\some\\stocks.txt", + 1, + Collections.emptyList(), + Collections.emptyList(), + Collections.emptyList(), + Collections.emptyList()); + + testService.saveGame(withPath); + List loaded = testService.loadSaves(); + + assertEquals(1, loaded.size()); + assertEquals("C:\\some\\stocks.txt", loaded.get(0).getStockDataPath()); + } + + @Test + void saveOverwritesExistingFileWithSameName() throws IOException { + SaveGame first = new SaveGame("Same", 100.0, 100.0, null); + testService.saveGame(first); + + SaveGame second = new SaveGame("Same", 250.0, 100.0, null); + testService.saveGame(second); + + List loaded = testService.loadSaves(); + assertEquals(1, loaded.size()); + assertEquals(250.0, loaded.get(0).getBalance()); + } + + @Test + void loadSavesParsesLegacyFileWithoutWeekOrListFields() + throws IOException { + String legacy = """ + { + "name": "Legacy", + "balance": 750.50, + "startingCapital": 500.00, + "stockDataPath": null + } + """; + Files.writeString(tempDir.resolve("Legacy.json"), + legacy, StandardCharsets.UTF_8); + + List loaded = testService.loadSaves(); + + assertEquals(1, loaded.size()); + SaveGame result = loaded.get(0); + assertEquals("Legacy", result.getName()); + assertEquals(750.50, result.getBalance()); + assertEquals(500.00, result.getStartingCapital()); + assertNull(result.getStockDataPath()); + assertEquals(1, result.getWeek()); + assertTrue(result.getOwnedShares().isEmpty()); + assertTrue(result.getTransactions().isEmpty()); + assertTrue(result.getExchangeStocks().isEmpty()); + assertTrue(result.getNetWorthHistory().isEmpty()); + } + + @Test + void loadSavesParsesLegacyStockWithoutPriceHistory() + throws IOException { + String legacy = """ + { + "name": "OldStocks", + "balance": 1000.00, + "startingCapital": 1000.00, + "stockDataPath": null, + "week": 1, + "ownedShares": [], + "transactions": [], + "stocks": [ + { "symbol": "AAPL", "name": "Apple Inc", "price": 150.00 } + ], + "netWorthHistory": [] + } + """; + Files.writeString(tempDir.resolve("OldStocks.json"), + legacy, StandardCharsets.UTF_8); + + List loaded = testService.loadSaves(); + + assertEquals(1, loaded.size()); + Stock stock = loaded.get(0).getExchangeStocks().get(0); + assertEquals("AAPL", stock.getSymbol()); + assertEquals(1, stock.getHistoricalPrices().size()); + assertEquals(new BigDecimal("150.00"), stock.getSalesPrice()); + assertEquals(0.0, stock.getFortune()); + } + + @Test + void loadSavesSkipsMalformedOwnedSharesAndKeepsValidOnes() + throws IOException { + String content = """ + { + "name": "MixedShares", + "balance": 100.00, + "startingCapital": 100.00, + "stockDataPath": null, + "week": 1, + "ownedShares": [ + { "symbol": "AAPL", "quantity": 5, "purchasePrice": 150.00 }, + { "symbol": "NVID", "purchasePrice": 240.00 }, + { "symbol": "TSLA", "quantity": "abc", "purchasePrice": 200.00 } + ], + "transactions": [], + "stocks": [], + "netWorthHistory": [] + } + """; + Files.writeString(tempDir.resolve("MixedShares.json"), + content, StandardCharsets.UTF_8); + + List loaded = testService.loadSaves(); + + assertEquals(1, loaded.size()); + List shares = loaded.get(0).getOwnedShares(); + assertEquals(1, shares.size()); + assertEquals("AAPL", shares.get(0).getSymbol()); + } + + @Test + void loadSaveFromFileReturnsNullForNullOrMissingFile() { + assertNull(testService.loadSaveFromFile(null)); + assertNull(testService.loadSaveFromFile( + tempDir.resolve("does-not-exist.json"))); + } + + @Test + void loadSaveFromFileParsesValidSaveOutsideSavesDirectory() + throws IOException { + Path external = tempDir.resolve("external"); + Files.createDirectories(external); + Path target = external.resolve("Imported.json"); + + testService.saveGame(testSaveGame); + Files.copy(tempDir.resolve("MySave.json"), target); + + SaveGame loaded = testService.loadSaveFromFile(target); + + assertNotNull(loaded); + assertEquals("MySave", loaded.getName()); + assertEquals(2, loaded.getOwnedShares().size()); + } + + @Test + void saveAndLoadHandlesEmptyOwnedSharesAndTransactions() + throws IOException { + SaveGame empty = new SaveGame("Empty", + 100.0, 100.0, null, 1, + new ArrayList<>(), new ArrayList<>(), + new ArrayList<>(), new ArrayList<>()); + + testService.saveGame(empty); + List loaded = testService.loadSaves(); + + assertEquals(1, loaded.size()); + assertTrue(loaded.get(0).getOwnedShares().isEmpty()); + assertTrue(loaded.get(0).getTransactions().isEmpty()); + assertTrue(loaded.get(0).getExchangeStocks().isEmpty()); + assertTrue(loaded.get(0).getNetWorthHistory().isEmpty()); + } + + @Test + void saveAndLoadEscapesSpecialCharactersInNameField() + throws IOException { + SaveGame special = new SaveGame( + "Name with \"quotes\" and \\ backslash", + 100.0, 100.0, null); + + testService.saveGame(special); + List loaded = testService.loadSaves(); + + assertEquals(1, loaded.size()); + assertEquals("Name with \"quotes\" and \\ backslash", + loaded.get(0).getName()); + } +} \ No newline at end of file