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