From fcc4e94ae30de5a02de37a0be9067143a77dc6d7 Mon Sep 17 00:00:00 2001 From: Kaamya Shinde Date: Sat, 4 Apr 2026 18:14:14 +0200 Subject: [PATCH 1/6] chore: ignore unknown fields in saved account json --- .../model/persistence/UserAccountRecord.java | 3 ++ .../UserAccountRepositoryTest.java | 40 +++++++++++++++++++ 2 files changed, 43 insertions(+) create mode 100644 src/test/java/model/persistence/UserAccountRepositoryTest.java diff --git a/src/main/java/model/persistence/UserAccountRecord.java b/src/main/java/model/persistence/UserAccountRecord.java index 7b9a659..42e9d73 100644 --- a/src/main/java/model/persistence/UserAccountRecord.java +++ b/src/main/java/model/persistence/UserAccountRecord.java @@ -1,5 +1,7 @@ package model.persistence; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; + /** * Persisted account metadata for one local profile. * @@ -8,6 +10,7 @@ * @param saltBase64 random salt used for PIN hashing * @param pinHashBase64 PBKDF2 hash of the PIN */ +@JsonIgnoreProperties(ignoreUnknown = true) public record UserAccountRecord( String username, String normalizedUsername, diff --git a/src/test/java/model/persistence/UserAccountRepositoryTest.java b/src/test/java/model/persistence/UserAccountRepositoryTest.java new file mode 100644 index 0000000..5566600 --- /dev/null +++ b/src/test/java/model/persistence/UserAccountRepositoryTest.java @@ -0,0 +1,40 @@ +package model.persistence; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class UserAccountRepositoryTest { + + @TempDir + Path tempDir; + + @Test + void listUsernames_ignoresUnknownFieldsInOlderAccountJson() throws IOException { + Path profileDir = tempDir.resolve("kaamya"); + Files.createDirectories(profileDir); + Files.writeString( + profileDir.resolve("account.json"), + """ + { + "username": "Kaamya", + "normalizedUsername": "kaamya", + "saltBase64": "salt", + "pinHashBase64": "hash", + "displayName": "Kaamya Shinde" + } + """); + + UserAccountRepository repository = new UserAccountRepository(tempDir); + + assertEquals(List.of("Kaamya"), repository.listUsernames()); + assertTrue(repository.findByUsername("kaamya").isPresent()); + assertEquals("Kaamya", repository.findByUsername("kaamya").orElseThrow().username()); + } +} From 6486cb356387920a0aa7cb15747eb5de1385e450 Mon Sep 17 00:00:00 2001 From: Kaamya Shinde Date: Sat, 4 Apr 2026 18:14:28 +0200 Subject: [PATCH 2/6] feat: add leaderboard ranking support to session service --- .../model/session/PlayerLeaderboardEntry.java | 17 +++++ .../session/PlayerLeaderboardMetric.java | 9 +++ .../session/PlayerLeaderboardRanking.java | 73 +++++++++++++++++++ .../java/model/session/SessionService.java | 39 ++++++++++ .../model/session/SessionServiceTest.java | 48 ++++++++++++ 5 files changed, 186 insertions(+) create mode 100644 src/main/java/model/session/PlayerLeaderboardEntry.java create mode 100644 src/main/java/model/session/PlayerLeaderboardMetric.java create mode 100644 src/main/java/model/session/PlayerLeaderboardRanking.java diff --git a/src/main/java/model/session/PlayerLeaderboardEntry.java b/src/main/java/model/session/PlayerLeaderboardEntry.java new file mode 100644 index 0000000..c03b047 --- /dev/null +++ b/src/main/java/model/session/PlayerLeaderboardEntry.java @@ -0,0 +1,17 @@ +package model.session; + +import java.math.BigDecimal; + +/** + * Immutable leaderboard snapshot for one saved player profile. + * + * @param username display username + * @param netWorth current total net worth + * @param totalReturnPercent total return as a decimal ratio (for example 0.25 = 25%) + */ +public record PlayerLeaderboardEntry( + String username, + BigDecimal netWorth, + BigDecimal totalReturnPercent +) { +} diff --git a/src/main/java/model/session/PlayerLeaderboardMetric.java b/src/main/java/model/session/PlayerLeaderboardMetric.java new file mode 100644 index 0000000..ae02bd4 --- /dev/null +++ b/src/main/java/model/session/PlayerLeaderboardMetric.java @@ -0,0 +1,9 @@ +package model.session; + +/** + * Supported leaderboard ranking metrics. + */ +public enum PlayerLeaderboardMetric { + NET_WORTH, + TOTAL_RETURN_PERCENT +} diff --git a/src/main/java/model/session/PlayerLeaderboardRanking.java b/src/main/java/model/session/PlayerLeaderboardRanking.java new file mode 100644 index 0000000..e907755 --- /dev/null +++ b/src/main/java/model/session/PlayerLeaderboardRanking.java @@ -0,0 +1,73 @@ +package model.session; + +import java.math.BigDecimal; +import java.util.Comparator; + +/** + * Comparator helpers for leaderboard ordering and rank calculation. + */ +public final class PlayerLeaderboardRanking { + + private PlayerLeaderboardRanking() { + } + + /** + * Comparator used to calculate best-first leaderboard ranks for the supplied metric. + * + * @param metric active ranking metric + * @return comparator ordering strongest players first + */ + public static Comparator bestFirstComparator(PlayerLeaderboardMetric metric) { + return Comparator + .comparing( + (PlayerLeaderboardEntry entry) -> metricValue(entry, metric), + Comparator.reverseOrder()) + .thenComparing( + entry -> metricValue(entry, secondaryMetric(metric)), + Comparator.reverseOrder()) + .thenComparing(PlayerLeaderboardEntry::username, String.CASE_INSENSITIVE_ORDER) + .thenComparing(PlayerLeaderboardEntry::username); + } + + /** + * Comparator used for display ordering. + * + * @param metric active display metric + * @param ascending whether the primary metric should be shown low-to-high + * @return comparator matching the requested display order + */ + public static Comparator displayComparator( + PlayerLeaderboardMetric metric, + boolean ascending) { + if (!ascending) { + return bestFirstComparator(metric); + } + return Comparator + .comparing((PlayerLeaderboardEntry entry) -> metricValue(entry, metric), BigDecimal::compareTo) + .thenComparing( + entry -> metricValue(entry, secondaryMetric(metric)), + Comparator.reverseOrder()) + .thenComparing(PlayerLeaderboardEntry::username, String.CASE_INSENSITIVE_ORDER) + .thenComparing(PlayerLeaderboardEntry::username); + } + + /** + * Returns the numeric metric value used for sorting. + * + * @param entry leaderboard entry + * @param metric chosen metric + * @return numeric value for that metric + */ + public static BigDecimal metricValue(PlayerLeaderboardEntry entry, PlayerLeaderboardMetric metric) { + return switch (metric) { + case NET_WORTH -> entry.netWorth(); + case TOTAL_RETURN_PERCENT -> entry.totalReturnPercent(); + }; + } + + private static PlayerLeaderboardMetric secondaryMetric(PlayerLeaderboardMetric metric) { + return metric == PlayerLeaderboardMetric.NET_WORTH + ? PlayerLeaderboardMetric.TOTAL_RETURN_PERCENT + : PlayerLeaderboardMetric.NET_WORTH; + } +} diff --git a/src/main/java/model/session/SessionService.java b/src/main/java/model/session/SessionService.java index 4bc8960..96b1f41 100644 --- a/src/main/java/model/session/SessionService.java +++ b/src/main/java/model/session/SessionService.java @@ -1,6 +1,8 @@ package model.session; import java.math.BigDecimal; +import java.math.RoundingMode; +import java.util.ArrayList; import java.util.List; import java.util.Optional; import java.util.function.Supplier; @@ -166,6 +168,43 @@ public List listRegisteredUsers() { return userAccountRepository.listUsernames(); } + /** + * Lists leaderboard entries for all saved profiles, using live in-memory state for the active + * session when available. + * + * @return default-ranked leaderboard entries + */ + public List listLeaderboardEntries() { + List entries = new ArrayList<>(); + for (String username : userAccountRepository.listUsernames()) { + String normalizedUsername = ProfileDirectories.normalizeUsername(username); + if (activeSession != null && activeSession.normalizedUsername().equals(normalizedUsername)) { + entries.add(toLeaderboardEntry(activeSession.player())); + continue; + } + + GameStateSnapshot snapshot = gameStateRepository.load(normalizedUsername) + .orElseThrow(() -> new IllegalStateException("Saved game state not found for " + username + ".")); + Exchange exchange = gameStateMapper.restoreExchange(snapshot.exchange(), loadMarketData()); + Player player = gameStateMapper.restorePlayer(snapshot.player(), exchange); + entries.add(toLeaderboardEntry(player)); + } + return entries.stream() + .sorted(PlayerLeaderboardRanking.bestFirstComparator(PlayerLeaderboardMetric.NET_WORTH)) + .toList(); + } + + private static PlayerLeaderboardEntry toLeaderboardEntry(Player player) { + BigDecimal netWorth = player.getNetWorth(); + BigDecimal startingMoney = player.getStartingMoney(); + BigDecimal totalReturnPercent = BigDecimal.ZERO; + if (startingMoney.compareTo(BigDecimal.ZERO) != 0) { + totalReturnPercent = netWorth.subtract(startingMoney) + .divide(startingMoney, 8, RoundingMode.HALF_UP); + } + return new PlayerLeaderboardEntry(player.getName(), netWorth, totalReturnPercent); + } + private MarketData loadMarketData() { MarketData marketData = marketDataSupplier.get(); if (marketData == null || marketData.isEmpty()) { diff --git a/src/test/java/model/session/SessionServiceTest.java b/src/test/java/model/session/SessionServiceTest.java index dc6b703..926f141 100644 --- a/src/test/java/model/session/SessionServiceTest.java +++ b/src/test/java/model/session/SessionServiceTest.java @@ -5,6 +5,7 @@ import static org.junit.jupiter.api.Assertions.assertTrue; import java.math.BigDecimal; +import java.math.RoundingMode; import java.nio.file.Path; import java.util.List; import model.Stock; @@ -71,6 +72,53 @@ void switchingUsers_restoresCorrectSavedStateWithoutLeakage() { assertEquals(new BigDecimal("2025.00"), reloadedBob.player().getMoney()); } + @Test + void listLeaderboardEntries_usesLiveActiveSessionState() { + SessionService sessionService = createSessionService(); + + ActiveSession alice = sessionService.register("Alice", "1234".toCharArray(), new BigDecimal("1000.00")); + sessionService.saveActiveSession(); + alice.player().addMoney(new BigDecimal("50.00")); + + List entries = sessionService.listLeaderboardEntries(); + + assertEquals(1, entries.size()); + assertEquals("Alice", entries.getFirst().username()); + assertEquals( + 0, + entries.getFirst().netWorth().compareTo(new BigDecimal("1050.00"))); + assertEquals( + 0, + entries.getFirst().totalReturnPercent().compareTo(new BigDecimal("0.05000000"))); + } + + @Test + void listLeaderboardEntries_ordersTiesByReturnPercentThenUsername() { + SessionService sessionService = createSessionService(); + + sessionService.register("Alice", "1234".toCharArray(), new BigDecimal("100.00")); + sessionService.saveActiveSession(); + + ActiveSession charlie = sessionService.register("Charlie", "2345".toCharArray(), new BigDecimal("50.00")); + charlie.player().addMoney(new BigDecimal("50.00")); + sessionService.saveActiveSession(); + + ActiveSession bob = sessionService.register("Bob", "3456".toCharArray(), new BigDecimal("50.00")); + bob.player().addMoney(new BigDecimal("50.00")); + sessionService.saveActiveSession(); + + List entries = sessionService.listLeaderboardEntries(); + + assertEquals(List.of("Bob", "Charlie", "Alice"), entries.stream() + .map(PlayerLeaderboardEntry::username) + .toList()); + assertEquals( + List.of("100.00", "100.00", "100.00"), + entries.stream() + .map(entry -> entry.netWorth().setScale(2, RoundingMode.HALF_UP).toPlainString()) + .toList()); + } + private SessionService createSessionService() { return new SessionService( new UserAccountRepository(tempDir), From cfabcabda92621386d58e261cbab2acd9a7a7392 Mon Sep 17 00:00:00 2001 From: Kaamya Shinde Date: Sat, 4 Apr 2026 18:14:49 +0200 Subject: [PATCH 3/6] feat: add reusable app table view helper --- .../view/components/table/AppTableView.java | 119 ++++++++++++++++++ 1 file changed, 119 insertions(+) create mode 100644 src/main/java/view/components/table/AppTableView.java diff --git a/src/main/java/view/components/table/AppTableView.java b/src/main/java/view/components/table/AppTableView.java new file mode 100644 index 0000000..da4ea29 --- /dev/null +++ b/src/main/java/view/components/table/AppTableView.java @@ -0,0 +1,119 @@ +package view.components.table; + +import java.util.Objects; +import java.util.function.Function; +import javafx.beans.property.ReadOnlyObjectWrapper; +import javafx.beans.property.ReadOnlyStringWrapper; +import javafx.geometry.Pos; +import javafx.scene.control.Label; +import javafx.scene.control.TableCell; +import javafx.scene.control.TableColumn; +import javafx.scene.control.TableRow; +import javafx.scene.control.TableView; + +/** + * Small reusable table wrapper for consistent placeholder, sizing, and row styling. + * + * @param row type + */ +public class AppTableView extends TableView { + + private final Label placeholderLabel = new Label(); + private Function rowStyleProvider = item -> ""; + + /** + * Creates a table with the supplied empty-state placeholder. + * + * @param placeholderText text shown when the table has no rows + */ + public AppTableView(String placeholderText) { + placeholderLabel.setText(placeholderText); + setPlaceholder(placeholderLabel); + setColumnResizePolicy(TableView.CONSTRAINED_RESIZE_POLICY_FLEX_LAST_COLUMN); + setRowFactory(_ -> new TableRow<>() { + @Override + protected void updateItem(T item, boolean empty) { + super.updateItem(item, empty); + if (empty || item == null) { + setStyle(""); + return; + } + setStyle(rowStyleProvider.apply(item)); + } + }); + } + + /** + * Sets the empty-state text. + * + * @param placeholderText placeholder text + */ + public void setPlaceholderText(String placeholderText) { + placeholderLabel.setText(placeholderText); + } + + /** + * Supplies inline row styles for the current item. + * + * @param rowStyleProvider function returning a style string + */ + public void setRowStyleProvider(Function rowStyleProvider) { + this.rowStyleProvider = Objects.requireNonNullElse(rowStyleProvider, item -> ""); + refresh(); + } + + /** + * Creates a left-aligned text column. + * + * @param title column title + * @param extractor value extractor + * @param row type + * @return configured table column + */ + public static TableColumn createTextColumn( + String title, + Function extractor) { + TableColumn column = new TableColumn<>(title); + column.setCellValueFactory(cell -> new ReadOnlyStringWrapper(extractor.apply(cell.getValue()))); + return column; + } + + /** + * Creates a numeric column with custom formatting and natural-value sorting. + * + * @param title column title + * @param extractor raw numeric value extractor + * @param formatter cell text formatter + * @param row type + * @param comparable numeric type + * @return configured numeric table column + */ + public static > TableColumn createNumericColumn( + String title, + Function extractor, + Function formatter) { + TableColumn column = new TableColumn<>(title); + column.setCellValueFactory(cell -> new ReadOnlyObjectWrapper<>(extractor.apply(cell.getValue()))); + column.setComparator((left, right) -> { + if (left == right) { + return 0; + } + if (left == null) { + return -1; + } + if (right == null) { + return 1; + } + return left.compareTo(right); + }); + column.setCellFactory(_ -> new TableCell<>() { + @Override + protected void updateItem(N item, boolean empty) { + super.updateItem(item, empty); + setAlignment(Pos.CENTER_RIGHT); + setText(empty || item == null ? null : formatter.apply(item)); + } + }); + return column; + } +} From c9e28325ace51ff11eb5fb968a8a073eab09cf8b Mon Sep 17 00:00:00 2001 From: Kaamya Shinde Date: Sat, 4 Apr 2026 18:15:03 +0200 Subject: [PATCH 4/6] feat: add sortable player leaderboard panel --- .../java/view/PlayerLeaderboardPanel.java | 258 ++++++++++++++++++ .../java/view/PlayerLeaderboardPanelTest.java | 108 ++++++++ 2 files changed, 366 insertions(+) create mode 100644 src/main/java/view/PlayerLeaderboardPanel.java create mode 100644 src/test/java/view/PlayerLeaderboardPanelTest.java diff --git a/src/main/java/view/PlayerLeaderboardPanel.java b/src/main/java/view/PlayerLeaderboardPanel.java new file mode 100644 index 0000000..992e2e0 --- /dev/null +++ b/src/main/java/view/PlayerLeaderboardPanel.java @@ -0,0 +1,258 @@ +package view; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import javafx.beans.property.ReadOnlyObjectWrapper; +import javafx.collections.FXCollections; +import javafx.collections.ObservableList; +import javafx.geometry.Insets; +import javafx.scene.control.Label; +import javafx.scene.control.TableColumn; +import javafx.scene.control.TableColumn.SortType; +import javafx.scene.layout.BorderPane; +import javafx.scene.layout.Priority; +import javafx.scene.layout.VBox; +import javafx.scene.text.Font; +import javafx.scene.text.FontWeight; +import model.session.PlayerLeaderboardEntry; +import model.session.PlayerLeaderboardMetric; +import model.session.PlayerLeaderboardRanking; +import view.components.table.AppTableView; + +/** + * Dedicated auth-screen leaderboard for comparing saved players. + */ +public class PlayerLeaderboardPanel extends BorderPane { + + private static final String PANEL_STYLE = + "-fx-background-color: #f6f7fb;" + + "-fx-border-color: #d7dce5;" + + "-fx-background-radius: 12;" + + "-fx-border-radius: 12;" + + "-fx-padding: 14;"; + private static final String TOP_ONE_STYLE = + "-fx-background-color: #fff4cc;" + + "-fx-font-weight: bold;"; + private static final String TOP_TWO_STYLE = + "-fx-background-color: #eef3f8;" + + "-fx-font-weight: bold;"; + private static final String TOP_THREE_STYLE = + "-fx-background-color: #fbe9dd;" + + "-fx-font-weight: bold;"; + + private final AppTableView table = + new AppTableView<>("No saved players to compare yet."); + private final ObservableList rows = FXCollections.observableArrayList(); + private final List sourceEntries = new ArrayList<>(); + private final Map rankByUsername = new HashMap<>(); + + private final TableColumn rankColumn = new TableColumn<>("Rank"); + private final TableColumn playerColumn = + AppTableView.createTextColumn("Player", PlayerLeaderboardEntry::username); + private final TableColumn netWorthColumn = + AppTableView.createNumericColumn( + "Net worth", + PlayerLeaderboardEntry::netWorth, + PlayerLeaderboardPanel::formatCurrency); + private final TableColumn returnColumn = + AppTableView.createNumericColumn( + "Return %", + PlayerLeaderboardEntry::totalReturnPercent, + PlayerLeaderboardPanel::formatPercent); + + private PlayerLeaderboardMetric activeMetric = PlayerLeaderboardMetric.NET_WORTH; + private boolean ascending; + + /** + * Creates a leaderboard panel from the supplied entries. + * + * @param entries saved-player leaderboard entries + */ + public PlayerLeaderboardPanel(List entries) { + setStyle(PANEL_STYLE); + setPadding(new Insets(0)); + Label heading = new Label("Player leaderboard"); + heading.setFont(Font.font("System", FontWeight.BOLD, 18)); + + Label subheading = new Label("Compare saved profiles by net worth or total return."); + subheading.setWrapText(true); + + VBox top = new VBox(6, heading, subheading); + setTop(top); + + buildTable(); + table.setItems(rows); + VBox.setVgrow(table, Priority.ALWAYS); + setCenter(table); + + setEntries(entries); + sortByNetWorthDescending(); + } + + /** + * Replaces the leaderboard data source and reapplies the current sort. + * + * @param entries new leaderboard rows + */ + public void setEntries(List entries) { + sourceEntries.clear(); + sourceEntries.addAll(entries); + refreshRows(); + } + + /** + * Sorts by net worth descending. + */ + public void sortByNetWorthDescending() { + applySort(netWorthColumn, SortType.DESCENDING); + } + + /** + * Sorts by net worth ascending. + */ + public void sortByNetWorthAscending() { + applySort(netWorthColumn, SortType.ASCENDING); + } + + /** + * Sorts by return percent descending. + */ + public void sortByReturnDescending() { + applySort(returnColumn, SortType.DESCENDING); + } + + /** + * Sorts by return percent ascending. + */ + public void sortByReturnAscending() { + applySort(returnColumn, SortType.ASCENDING); + } + + /** + * Returns the usernames in the currently displayed order. + * + * @return visible usernames + */ + public List getDisplayedUsernames() { + return rows.stream().map(PlayerLeaderboardEntry::username).toList(); + } + + /** + * Returns the visible rank values in row order. + * + * @return rank labels in display order + */ + public List getDisplayedRanks() { + return rows.stream().map(entry -> rankByUsername.get(entry.username())).toList(); + } + + /** + * Returns the usernames currently highlighted as the top three. + * + * @return highlighted usernames in best-first ranking order + */ + public List getHighlightedUsernames() { + return sourceEntries.stream() + .sorted(PlayerLeaderboardRanking.bestFirstComparator(activeMetric)) + .limit(3) + .map(PlayerLeaderboardEntry::username) + .toList(); + } + + /** + * Returns how many rows are currently visible. + * + * @return displayed row count + */ + public int getRowCount() { + return rows.size(); + } + + /** + * Returns the formatted net worth for a specific username, if present. + * + * @param username target username + * @return formatted value or {@code null} if missing + */ + public String getDisplayedNetWorthForUser(String username) { + return sourceEntries.stream() + .filter(entry -> entry.username().equals(username)) + .findFirst() + .map(PlayerLeaderboardPanel::formatCurrency) + .orElse(null); + } + + private void buildTable() { + rankColumn.setCellValueFactory(cell -> + new ReadOnlyObjectWrapper<>(rankByUsername.getOrDefault(cell.getValue().username(), 0))); + rankColumn.setSortable(false); + playerColumn.setSortable(false); + + table.getColumns().setAll(List.of(rankColumn, playerColumn, netWorthColumn, returnColumn)); + table.setRowStyleProvider(this::rowStyleFor); + table.setSortPolicy(_ -> { + syncSortStateFromTable(); + refreshRows(); + return true; + }); + } + + private void applySort(TableColumn column, SortType sortType) { + column.setSortType(sortType); + table.getSortOrder().setAll(column); + table.sort(); + } + + private void syncSortStateFromTable() { + TableColumn primaryColumn = table.getSortOrder().isEmpty() + ? netWorthColumn + : table.getSortOrder().getFirst(); + activeMetric = primaryColumn == returnColumn + ? PlayerLeaderboardMetric.TOTAL_RETURN_PERCENT + : PlayerLeaderboardMetric.NET_WORTH; + SortType sortType = primaryColumn.getSortType() == null ? SortType.DESCENDING : primaryColumn.getSortType(); + ascending = sortType == SortType.ASCENDING; + } + + private void refreshRows() { + List ranked = new ArrayList<>(sourceEntries); + ranked.sort(PlayerLeaderboardRanking.bestFirstComparator(activeMetric)); + rankByUsername.clear(); + for (int index = 0; index < ranked.size(); index++) { + rankByUsername.put(ranked.get(index).username(), index + 1); + } + + List displayOrder = new ArrayList<>(sourceEntries); + displayOrder.sort(PlayerLeaderboardRanking.displayComparator(activeMetric, ascending)); + rows.setAll(displayOrder); + table.refresh(); + } + + private String rowStyleFor(PlayerLeaderboardEntry entry) { + int rank = rankByUsername.getOrDefault(entry.username(), Integer.MAX_VALUE); + return switch (rank) { + case 1 -> TOP_ONE_STYLE; + case 2 -> TOP_TWO_STYLE; + case 3 -> TOP_THREE_STYLE; + default -> ""; + }; + } + + private static String formatCurrency(PlayerLeaderboardEntry entry) { + return formatCurrency(entry.netWorth()); + } + + private static String formatCurrency(BigDecimal value) { + return value.setScale(2, RoundingMode.HALF_UP).toPlainString(); + } + + private static String formatPercent(BigDecimal value) { + return value.multiply(BigDecimal.valueOf(100)) + .setScale(2, RoundingMode.HALF_UP) + .toPlainString() + "%"; + } +} diff --git a/src/test/java/view/PlayerLeaderboardPanelTest.java b/src/test/java/view/PlayerLeaderboardPanelTest.java new file mode 100644 index 0000000..1bcab62 --- /dev/null +++ b/src/test/java/view/PlayerLeaderboardPanelTest.java @@ -0,0 +1,108 @@ +package view; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.math.BigDecimal; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; +import javafx.application.Platform; +import model.session.PlayerLeaderboardEntry; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +/** + * Tests leaderboard sorting, ranks, and highlight behavior. + */ +class PlayerLeaderboardPanelTest { + + @BeforeAll + static void initJavaFx() throws InterruptedException { + try { + CountDownLatch latch = new CountDownLatch(1); + Platform.startup(latch::countDown); + latch.await(5, TimeUnit.SECONDS); + } catch (IllegalStateException ignored) { + // toolkit already running + } + } + + @Test + void defaultSortIsNetWorthDescending() throws Exception { + PlayerLeaderboardPanel panel = runOnFxThread(() -> new PlayerLeaderboardPanel(List.of( + entry("Alice", "1000.00", "0.00"), + entry("Cara", "1300.00", "0.30"), + entry("Bob", "1400.00", "0.40")))); + + assertEquals(List.of("Bob", "Cara", "Alice"), panel.getDisplayedUsernames()); + assertEquals(List.of(1, 2, 3), panel.getDisplayedRanks()); + } + + @Test + void returnAscendingKeepsBestRanksAndHighlightsTopThree() throws Exception { + PlayerLeaderboardPanel panel = runOnFxThread(() -> new PlayerLeaderboardPanel(List.of( + entry("Alice", "900.00", "-0.10"), + entry("Bob", "1400.00", "0.40"), + entry("Cara", "1300.00", "0.30"), + entry("Dan", "1100.00", "0.10")))); + + runOnFxThread( + () -> { + panel.sortByReturnAscending(); + return panel; + }); + + assertEquals(List.of("Alice", "Dan", "Cara", "Bob"), panel.getDisplayedUsernames()); + assertEquals(List.of(4, 3, 2, 1), panel.getDisplayedRanks()); + assertEquals(List.of("Bob", "Cara", "Dan"), panel.getHighlightedUsernames()); + } + + @Test + void netWorthAscendingSortsLowestFirst() throws Exception { + PlayerLeaderboardPanel panel = runOnFxThread(() -> new PlayerLeaderboardPanel(List.of( + entry("Bob", "1400.00", "0.40"), + entry("Alice", "1000.00", "0.00"), + entry("Cara", "1300.00", "0.30")))); + + runOnFxThread( + () -> { + panel.sortByNetWorthAscending(); + return panel; + }); + + assertEquals(List.of("Alice", "Cara", "Bob"), panel.getDisplayedUsernames()); + } + + private static PlayerLeaderboardEntry entry(String username, String netWorth, String returnPercent) { + return new PlayerLeaderboardEntry( + username, + new BigDecimal(netWorth), + new BigDecimal(returnPercent)); + } + + private static PlayerLeaderboardPanel runOnFxThread(PanelSupplier supplier) throws Exception { + CountDownLatch latch = new CountDownLatch(1); + AtomicReference ref = new AtomicReference<>(); + AtomicReference err = new AtomicReference<>(); + Platform.runLater(() -> { + try { + ref.set(supplier.get()); + } catch (Exception exception) { + err.set(exception); + } finally { + latch.countDown(); + } + }); + latch.await(5, TimeUnit.SECONDS); + if (err.get() != null) { + throw err.get(); + } + return ref.get(); + } + + @FunctionalInterface + private interface PanelSupplier { + PlayerLeaderboardPanel get() throws Exception; + } +} From 88d948fb4d138ee9940cf1f794cd82cc2d1719b5 Mon Sep 17 00:00:00 2001 From: Kaamya Shinde Date: Sat, 4 Apr 2026 18:29:19 +0200 Subject: [PATCH 5/6] feat: show leaderboard in auth switch user flow --- src/main/java/view/AuthPane.java | 49 +++++++++++- src/main/java/view/GuiAppShell.java | 5 ++ src/main/java/view/SessionWorkspaceView.java | 2 +- src/test/java/view/AuthPaneTest.java | 84 ++++++++++++++++++++ src/test/java/view/GuiAppShellTest.java | 31 ++++++++ 5 files changed, 168 insertions(+), 3 deletions(-) create mode 100644 src/test/java/view/AuthPaneTest.java diff --git a/src/main/java/view/AuthPane.java b/src/main/java/view/AuthPane.java index 359ecb0..2d0699b 100644 --- a/src/main/java/view/AuthPane.java +++ b/src/main/java/view/AuthPane.java @@ -17,6 +17,7 @@ import javafx.scene.text.Font; import javafx.scene.text.FontWeight; import javafx.scene.text.Text; +import model.session.PlayerLeaderboardEntry; /** * Collects login and registration input for the session-based GUI shell. @@ -34,6 +35,8 @@ public class AuthPane extends BorderPane { private final PasswordField registerPinField = new PasswordField(); private final TextField registerStartingMoneyField = new TextField(); private final Label statusLabel = new Label(); + private final Button returnButton = new Button("Back to current session"); + private final PlayerLeaderboardPanel leaderboardPanel; /** * Builds the authentication view with login and registration forms. @@ -46,6 +49,7 @@ public class AuthPane extends BorderPane { */ public AuthPane( List users, + List leaderboardEntries, boolean allowReturnToSession, LoginAction loginAction, RegisterAction registerAction, @@ -120,9 +124,13 @@ public AuthPane( HBox forms = new HBox(20, usersBox, loginBox, registerBox); forms.setAlignment(Pos.TOP_LEFT); - setCenter(forms); + HBox.setHgrow(forms, Priority.ALWAYS); + + leaderboardPanel = new PlayerLeaderboardPanel(leaderboardEntries); + leaderboardPanel.setPrefWidth(440); + HBox.setHgrow(leaderboardPanel, Priority.ALWAYS); + setCenter(new HBox(24, forms, leaderboardPanel)); - Button returnButton = new Button("Back to current session"); styleButton(returnButton); returnButton.setVisible(allowReturnToSession); returnButton.setManaged(allowReturnToSession); @@ -165,6 +173,43 @@ public int getRegisteredUserCount() { return registeredUsersView.getItems().size(); } + /** + * Returns the embedded leaderboard panel for test assertions. + * + * @return auth-screen leaderboard panel + */ + public PlayerLeaderboardPanel getLeaderboardPanel() { + return leaderboardPanel; + } + + /** + * Returns the visible leaderboard usernames in display order. + * + * @return displayed leaderboard usernames + */ + public List getLeaderboardDisplayedUsernames() { + return leaderboardPanel.getDisplayedUsernames(); + } + + /** + * Returns the formatted net worth shown for a specific leaderboard user. + * + * @param username target username + * @return formatted net worth text or {@code null} if missing + */ + public String getLeaderboardNetWorthForUser(String username) { + return leaderboardPanel.getDisplayedNetWorthForUser(username); + } + + /** + * Simulates pressing the return button when it is visible. + */ + public void triggerReturnToSession() { + if (returnButton.isManaged()) { + returnButton.fire(); + } + } + /** * Fills the login form for tests or higher-level helpers. * diff --git a/src/main/java/view/GuiAppShell.java b/src/main/java/view/GuiAppShell.java index 332800f..32ee2e9 100644 --- a/src/main/java/view/GuiAppShell.java +++ b/src/main/java/view/GuiAppShell.java @@ -117,6 +117,7 @@ public void shutdown() { private void showAuthView(boolean allowReturnToSession) { authPane = new AuthPane( listRegisteredUsers(), + listLeaderboardEntries(), allowReturnToSession, this::handleLogin, this::handleRegistration, @@ -178,6 +179,10 @@ private List listRegisteredUsers() { return sessionService.listRegisteredUsers(); } + private List listLeaderboardEntries() { + return sessionService.listLeaderboardEntries(); + } + private void disposeWorkspace() { if (workspaceView != null) { workspaceView.dispose(); diff --git a/src/main/java/view/SessionWorkspaceView.java b/src/main/java/view/SessionWorkspaceView.java index 3ac7436..a67250e 100644 --- a/src/main/java/view/SessionWorkspaceView.java +++ b/src/main/java/view/SessionWorkspaceView.java @@ -77,7 +77,7 @@ public SessionWorkspaceView( heading.setFont(Font.font("System", FontWeight.BOLD, 26)); Button refreshButton = new Button("Refresh all"); - Button switchUserButton = new Button("Switch user"); + Button switchUserButton = new Button("Compare / switch user"); Button logoutButton = new Button("Log out"); styleButton(refreshButton); styleButton(switchUserButton); diff --git a/src/test/java/view/AuthPaneTest.java b/src/test/java/view/AuthPaneTest.java new file mode 100644 index 0000000..c3e82fe --- /dev/null +++ b/src/test/java/view/AuthPaneTest.java @@ -0,0 +1,84 @@ +package view; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.math.BigDecimal; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; +import javafx.application.Platform; +import model.session.PlayerLeaderboardEntry; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +/** + * Tests auth-screen leaderboard rendering and return flow. + */ +class AuthPaneTest { + + @BeforeAll + static void initJavaFx() throws InterruptedException { + try { + CountDownLatch latch = new CountDownLatch(1); + Platform.startup(latch::countDown); + latch.await(5, TimeUnit.SECONDS); + } catch (IllegalStateException ignored) { + // toolkit already running + } + } + + @Test + void authPaneShowsLeaderboardRowsAndReturnButtonWorks() throws Exception { + AtomicBoolean returned = new AtomicBoolean(false); + + AuthPane pane = runOnFxThread(() -> new AuthPane( + List.of("Alice", "Bob"), + List.of( + new PlayerLeaderboardEntry("Bob", new BigDecimal("1400.00"), new BigDecimal("0.40000000")), + new PlayerLeaderboardEntry("Alice", new BigDecimal("1000.00"), BigDecimal.ZERO)), + true, + (username, pin) -> { + }, + (username, pin, startingMoney) -> { + }, + () -> returned.set(true))); + + assertEquals(List.of("Bob", "Alice"), pane.getLeaderboardDisplayedUsernames()); + + runOnFxThread( + () -> { + pane.triggerReturnToSession(); + return pane; + }); + + assertTrue(returned.get()); + } + + private static AuthPane runOnFxThread(PaneSupplier supplier) throws Exception { + CountDownLatch latch = new CountDownLatch(1); + AtomicReference ref = new AtomicReference<>(); + AtomicReference err = new AtomicReference<>(); + Platform.runLater(() -> { + try { + ref.set(supplier.get()); + } catch (Exception exception) { + err.set(exception); + } finally { + latch.countDown(); + } + }); + latch.await(5, TimeUnit.SECONDS); + if (err.get() != null) { + throw err.get(); + } + return ref.get(); + } + + @FunctionalInterface + private interface PaneSupplier { + AuthPane get() throws Exception; + } +} diff --git a/src/test/java/view/GuiAppShellTest.java b/src/test/java/view/GuiAppShellTest.java index 0df2979..ef0b553 100644 --- a/src/test/java/view/GuiAppShellTest.java +++ b/src/test/java/view/GuiAppShellTest.java @@ -120,6 +120,37 @@ void switchingUsersRebuildsWorkspaceAndAvoidsCrossUserLeakage() throws Exception assertEquals(2, shell.getWorkspaceView().getNotificationService().getItems().size()); } + @Test + void switchUserFlowShowsLiveLeaderboardAndCanReturnToCurrentSession() throws Exception { + SessionService sessionService = createSessionService(); + GuiAppShell shell = runOnFxThread(() -> new GuiAppShell(sessionService)); + + runOnFxThread(() -> { + shell.submitRegistration("Alice", "1234", "1000.00"); + return shell; + }); + + ActiveSession alice = sessionService.getActiveSession().orElseThrow(); + sessionService.saveActiveSession(); + alice.player().addMoney(new BigDecimal("50.00")); + + runOnFxThread(() -> { + shell.beginSwitchUserFlow(); + return shell; + }); + + assertTrue(shell.isShowingAuthView()); + assertEquals("1050.00", shell.getAuthPane().getLeaderboardNetWorthForUser("Alice")); + + runOnFxThread(() -> { + shell.getAuthPane().triggerReturnToSession(); + return shell; + }); + + assertFalse(shell.isShowingAuthView()); + assertEquals("Alice", shell.getWorkspaceView().getDisplayedUsername()); + } + private SessionService createSessionService() { return new SessionService( new UserAccountRepository(tempDir), From 3e54e89aaa798815146971693f25e4ba2f9afbe7 Mon Sep 17 00:00:00 2001 From: Kaamya Shinde Date: Sat, 4 Apr 2026 18:42:36 +0200 Subject: [PATCH 6/6] test: merge fix --- src/test/java/view/AuthPaneTest.java | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/test/java/view/AuthPaneTest.java b/src/test/java/view/AuthPaneTest.java index c3e82fe..6bf71ae 100644 --- a/src/test/java/view/AuthPaneTest.java +++ b/src/test/java/view/AuthPaneTest.java @@ -44,7 +44,9 @@ void authPaneShowsLeaderboardRowsAndReturnButtonWorks() throws Exception { }, (username, pin, startingMoney) -> { }, - () -> returned.set(true))); + () -> returned.set(true), + () -> { + })); assertEquals(List.of("Bob", "Alice"), pane.getLeaderboardDisplayedUsernames());