diff --git a/.mvn/maven.config b/.mvn/maven.config index 668f9bb..f7b56a1 100644 --- a/.mvn/maven.config +++ b/.mvn/maven.config @@ -1,2 +1,3 @@ -T4 --ntp \ No newline at end of file +-ntp +-Dmaven.compiler.fork=true \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..99afc2b --- /dev/null +++ b/Makefile @@ -0,0 +1,7 @@ +.PHONY: tests releases + +tests: + mvn clean install test + +releases: + mvn clean install verify test jacoco:report \ No newline at end of file diff --git a/pom.xml b/pom.xml index 45693f7..5fa2a25 100644 --- a/pom.xml +++ b/pom.xml @@ -34,7 +34,7 @@ true - 1.18.32 + 1.18.40 5.20.0 5.10.2 1.10.2 @@ -135,6 +135,12 @@ ${junit-platform.version} test + + org.junit.platform + junit-platform-suite-api + ${junit-platform.version} + test + org.instancio instancio-junit @@ -242,8 +248,16 @@ ${java.version} ${java.version} false + ${java.version} false ${project.build.sourceEncoding} + + + org.projectlombok + lombok + ${lombok.version} + + @@ -259,6 +273,10 @@ *com.shortthirdman.primekit.common.* *com.shortthirdman.primekit.core.* + + -Dnet.bytebuddy.experimental=true + --add-opens java.net.http/java.net.http=ALL-UNNAMED + junit.jupiter.extensions.autodetection.enabled = true diff --git a/src/main/java/com/shortthirdman/primekit/hackerearth/TokenExpiryTracker.java b/src/main/java/com/shortthirdman/primekit/hackerearth/TokenExpiryTracker.java new file mode 100644 index 0000000..2b33fdd --- /dev/null +++ b/src/main/java/com/shortthirdman/primekit/hackerearth/TokenExpiryTracker.java @@ -0,0 +1,91 @@ +package com.shortthirdman.primekit.hackerearth; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +/** + * Manages the lifecycle, renewal, and state evaluation of authentication tokens. + *

+ * This class tracks token creation and reset commands over discrete time units, + * enforcing global expiration limits and determining token availability at the + * conclusion of event processing. + *

+ * + * @author ShortThirdMan + * @since 1.2.1 + */ +public class TokenExpiryTracker { + + private static final int CMD_TYPE_INDEX = 0; + private static final int CMD_TOKEN_ID_INDEX = 1; + private static final int CMD_TIME_INDEX = 2; + + private static final int ACTION_CREATE = 0; + private static final int ACTION_RESET = 1; + + /** + * Calculates the number of tokens that remain active after executing all specified commands. + *

+ * A token is active immediately upon creation at time {@code T} and expires at {@code T + expiryLimit}. + * Subsequent reset commands at time {@code T} are valid only if the token already exists and has not yet + * expired (i.e., its current expiration time is greater than or equal to {@code T}). + *

+ *

+ * After processing all commands, a token is considered active if its expiration time is strictly + * greater than the timestamp of the last executed command. + *

+ * + * @param expiryLimit the fixed lifespan added to the execution timestamp upon token creation or reset; + * must be greater than or equal to 1 + * @param commands a chronological list of 3-element integer tuples representing operations: + *
    + *
  • {@code [0, tokenId, T]} - Creates a new token with ID {@code tokenId} at time {@code T}.
  • + *
  • {@code [1, tokenId, T]} - Resets the expiration of an existing token with ID {@code tokenId} at time {@code T}.
  • + *
+ * Must be sorted in non-decreasing order of {@code T}. + * @return the total count of active tokens remaining after all commands have been evaluated; + * returns {@code 0} if {@code commands} is empty + * @throws NullPointerException if {@code commands} is {@code null} or contains {@code null} entries + * @throws IllegalArgumentException if {@code expiryLimit} is non-positive or if any command does not + * contain exactly three elements + */ + public int numberOfTokens(int expiryLimit, List> commands) { + Objects.requireNonNull(commands, "commands list must not be null"); + if (expiryLimit <= 0) { + throw new IllegalArgumentException("expiryLimit must be positive: " + expiryLimit); + } + if (commands.isEmpty()) { + return 0; + } + + Map tokenExpiries = new HashMap<>(); + + for (List cmd : commands) { + Objects.requireNonNull(cmd, "command entry must not be null"); + if (cmd.size() != 3) { + throw new IllegalArgumentException("Command must contain exactly 3 integers: [type, tokenId, T]"); + } + + int type = cmd.get(CMD_TYPE_INDEX); + int tokenId = cmd.get(CMD_TOKEN_ID_INDEX); + int timestamp = cmd.get(CMD_TIME_INDEX); + + if (type == ACTION_CREATE) { + tokenExpiries.put(tokenId, timestamp + expiryLimit); + } else if (type == ACTION_RESET) { + Integer currentExpiry = tokenExpiries.get(tokenId); + if (currentExpiry != null && currentExpiry >= timestamp) { + tokenExpiries.put(tokenId, timestamp + expiryLimit); + } + } + } + + int finalTimestamp = commands.getLast().get(CMD_TIME_INDEX); + + return (int) tokenExpiries.values().stream() + .filter(expiry -> expiry > finalTimestamp) + .count(); + } +} diff --git a/src/main/java/com/shortthirdman/primekit/hackerearth/UpgradeEnhancementOptimizer.java b/src/main/java/com/shortthirdman/primekit/hackerearth/UpgradeEnhancementOptimizer.java new file mode 100644 index 0000000..a54904d --- /dev/null +++ b/src/main/java/com/shortthirdman/primekit/hackerearth/UpgradeEnhancementOptimizer.java @@ -0,0 +1,85 @@ +package com.shortthirdman.primekit.hackerearth; + +import java.util.List; +import java.util.Objects; + +/** + * Solves the optimal upgrade selection problem under a fixed currency constraint. + * + *

Each available upgrade index {@code i} incurs a given cost and yields an + * enhancement gain equal to {@code 2^i}[cite: 1]. Because every power of two is strictly greater + * than the sum of all preceding powers of two (i.e., {@code \sum_{j=0}^{i-1} 2^j = 2^i - 1 < 2^i}), + * the problem exhibits the greedy-choice property. This optimizer selects upgrades + * greedily starting from the highest available power down to zero. + * + *

Thread Safety: This class is stateless, immutable, and thread-safe. + * + * @since 1.2.1 + * @author ShortThirdMan + */ +public class UpgradeEnhancementOptimizer { + + private static final int MOD = 1_000_000_007; + + /** + * Calculates the maximum enhancement obtainable without exceeding the provided budget. + * + *

Enhancement values grow exponentially such that the item at index {@code i} + * contributes {@code 2^i} enhancement[cite: 1]. The returned sum is reduced modulo {@code 10^9 + 7}[cite: 1]. + * + *

Algorithm Details

+ *
    + *
  • Precomputes powers of two modulo {@code 1,000,000,007}.
  • + *
  • Greedy Selection: Evaluates items in descending index order + * ({@code n - 1} down to {@code 0}), deducting the item cost whenever sufficient + * budget remains.
  • + *
+ * + *

Complexity

+ *
    + *
  • Time Complexity: {@code O(n)}, where {@code n} is the size of {@code upgradeCosts}.
  • + *
  • Space Complexity: {@code O(n)} auxiliary memory for modular exponent precomputations.
  • + *
+ * + * @param upgradeCosts non-null list of non-negative integers representing upgrade costs, + * where element at index {@code i} yields enhancement {@code 2^i}[cite: 1] + * @param budget maximum spendable currency, must be non-negative + * @return the maximum enhancement score achievable within budget, modulo {@code 10^9 + 7}[cite: 1] + * @throws NullPointerException if {@code upgradeCosts} is {@code null} + * @throws IllegalArgumentException if {@code budget} is negative + */ + public int optimizeInAppUpgrades(List upgradeCosts, int budget) { + Objects.requireNonNull(upgradeCosts, "upgradeCosts must not be null"); + if (budget < 0) { + throw new IllegalArgumentException("budget cannot be negative: " + budget); + } + + int n = upgradeCosts.size(); + if (n == 0) { + return 0; + } + + // Precompute powers of 2: 2^0, 2^1, ..., 2^(n-1) modulo 10^9 + 7 + int[] pow2 = new int[n]; + pow2[0] = 1; + for (int i = 1; i < n; i++) { + pow2[i] = (pow2[i - 1] * 2) % MOD; + } + + long remainingBudget = budget; + int totalEnhancement = 0; + + for (int i = n - 1; i >= 0; i--) { + int cost = upgradeCosts.get(i); + if (remainingBudget >= cost) { + remainingBudget -= cost; + totalEnhancement += pow2[i]; + if (totalEnhancement >= MOD) { + totalEnhancement -= MOD; + } + } + } + + return totalEnhancement; + } +} diff --git a/src/main/java/com/shortthirdman/primekit/leetcode/WordLadders.java b/src/main/java/com/shortthirdman/primekit/leetcode/WordLadders.java index eed9495..a852d02 100644 --- a/src/main/java/com/shortthirdman/primekit/leetcode/WordLadders.java +++ b/src/main/java/com/shortthirdman/primekit/leetcode/WordLadders.java @@ -18,7 +18,7 @@ public class WordLadders { * @param beginWord the word to begin with * @param endWord the word to end with * @param wordList the list of words - * @return + * @return the list of ladders */ public List> findLadders(String beginWord, String endWord, List wordList) { List> result = new ArrayList<>(); @@ -33,7 +33,7 @@ public List> findLadders(String beginWord, String endWord, List minLen) { return result; } @@ -61,7 +61,7 @@ public List> findLadders(String beginWord, String endWord, List mockResponse = (HttpResponse) mock(HttpResponse.class); when(mockResponse.body()).thenReturn(json); - when(mockClient.send( - any(HttpRequest.class), - ArgumentMatchers.>any() - )).thenReturn(mockResponse); - // Intercept static factory `HttpClient.newHttpClient()` + // Fix: Use doReturn to avoid Java compiler generic mismatch on send() + doReturn(mockResponse).when(mockClient).send(any(HttpRequest.class), any()); + try (MockedStatic mockedStatic = mockStatic(HttpClient.class)) { mockedStatic.when(HttpClient::newHttpClient).thenReturn(mockClient); @@ -53,8 +44,7 @@ void testFetchPosts_UsingMockito_NoHttpCall() throws Exception { assertEquals(2, posts.size()); mockedStatic.verify(HttpClient::newHttpClient); - verify(mockClient, times(1)) - .send(any(HttpRequest.class), any(HttpResponse.BodyHandler.class)); + verify(mockClient, times(1)).send(any(HttpRequest.class), any()); } } diff --git a/src/test/java/com/shortthirdman/primekit/hackerearth/TokenExpiryTrackerTest.java b/src/test/java/com/shortthirdman/primekit/hackerearth/TokenExpiryTrackerTest.java new file mode 100644 index 0000000..c046d77 --- /dev/null +++ b/src/test/java/com/shortthirdman/primekit/hackerearth/TokenExpiryTrackerTest.java @@ -0,0 +1,267 @@ +package com.shortthirdman.primekit.hackerearth; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +@DisplayName("TokenExpiryTracker Tests") +class TokenExpiryTrackerTest { + + TokenExpiryTracker app; + + @BeforeEach + void setUp() { + app = new TokenExpiryTracker(); + } + + @Nested + @DisplayName("Happy Path & Standard Scenarios") + class StandardExecutionTests { + + @Test + @DisplayName("Should return correct active count for standard problem example") + void testProblemExampleScenario() { + int expiryLimit = 4; + List> commands = List.of( + List.of(0, 1, 1), // Create 1 at T=1 -> exp: 5 + List.of(0, 2, 2), // Create 2 at T=2 -> exp: 6 + List.of(1, 1, 5), // Reset 1 at T=5 -> exp: 9 + List.of(1, 2, 7) // Reset 2 at T=7 -> ignored (expired at 6) + ); + + int result = app.numberOfTokens(expiryLimit, commands); + + assertEquals(1, result, "Only token 1 should remain active at T=7 (expires at 9 > 7)"); + } + + @Test + @DisplayName("Should keep all tokens active when expiry exceeds maximum timestamp") + void testAllTokensRemainActive() { + int expiryLimit = 10; + List> commands = List.of( + List.of(0, 101, 1), // exp: 11 + List.of(0, 102, 2), // exp: 12 + List.of(0, 103, 3) // exp: 13 + ); + + int result = app.numberOfTokens(expiryLimit, commands); + + assertEquals(3, result, "All tokens expire after T=3 and must be active"); + } + + @Test + @DisplayName("Should report zero active tokens when all tokens expire before or at final timestamp") + void testAllTokensExpired() { + int expiryLimit = 2; + List> commands = List.of( + List.of(0, 1, 1), // exp: 3 + List.of(0, 2, 2), // exp: 4 + List.of(1, 999, 10) // ignored reset on non-existent token at T=10 + ); + + int result = app.numberOfTokens(expiryLimit, commands); + + assertEquals(0, result, "Tokens 1 and 2 expired prior to final timestamp T=10"); + } + + @Test + @DisplayName("Should support multiple resets extending token lifespan repeatedly") + void testMultipleSuccessfulResetsOnSameToken() { + int expiryLimit = 5; + List> commands = List.of( + List.of(0, 42, 1), // exp: 6 + List.of(1, 42, 4), // reset -> exp: 9 + List.of(1, 42, 8), // reset -> exp: 13 + List.of(1, 42, 12) // reset -> exp: 17 + ); + + int result = app.numberOfTokens(expiryLimit, commands); + + assertEquals(1, result, "Token 42 should remain active up to T=17"); + } + } + + @Nested + @DisplayName("Boundary & Edge Cases") + class EdgeCaseTests { + + @Test + @DisplayName("Should return 0 when commands list is empty") + void testEmptyCommandsList() { + int result = app.numberOfTokens(5, Collections.emptyList()); + + assertEquals(0, result, "Empty commands list must produce 0 active tokens"); + } + + @Test + @DisplayName("Should keep token active for a single create command") + void testSingleCreateCommand() { + List> commands = List.of(List.of(0, 1, 10)); + + int result = app.numberOfTokens(3, commands); + + assertEquals(1, result, "Token created at T=10 with expiry 13 is active at T=10"); + } + + @Test + @DisplayName("Should consider token expired when expiration exactly equals final timestamp") + void testTokenExpiresExactlyAtFinalTimestamp() { + int expiryLimit = 5; + List> commands = List.of( + List.of(0, 1, 1), // exp: 6 + List.of(1, 99, 6) // non-existent token reset at T=6 + ); + + int result = app.numberOfTokens(expiryLimit, commands); + + assertEquals(0, result, "Token expires at 6, which is not strictly greater than max time 6"); + } + + @Test + @DisplayName("Should allow token reset exactly on its expiration timestamp") + void testTokenResetExactlyOnBoundaryTimestamp() { + int expiryLimit = 4; + List> commands = List.of( + List.of(0, 1, 2), // exp: 6 + List.of(1, 1, 6) // reset exactly at T=6 -> exp: 10 + ); + + int result = app.numberOfTokens(expiryLimit, commands); + + assertEquals(1, result, "Reset on expiration timestamp is valid, new expiry becomes 10 > 6"); + } + + @Test + @DisplayName("Should overwrite token expiration when recreation occurs with same token ID") + void testTokenRecreationOverwritesPriorState() { + int expiryLimit = 3; + List> commands = List.of( + List.of(0, 1, 1), // exp: 4 + List.of(0, 1, 10) // re-created at T=10 -> exp: 13 + ); + + int result = app.numberOfTokens(expiryLimit, commands); + + assertEquals(1, result, "Re-creating token 1 updates its lifetime to 13 > 10"); + } + + @Test + @DisplayName("Should handle multiple commands occurring at the exact same timestamp") + void testConcurrentTimestamps() { + int expiryLimit = 3; + List> commands = List.of( + List.of(0, 1, 5), // exp: 8 + List.of(0, 2, 5), // exp: 8 + List.of(1, 1, 5) // reset at T=5 -> exp: 8 + ); + + int result = app.numberOfTokens(expiryLimit, commands); + + assertEquals(2, result, "Both tokens active at final timestamp T=5 with exp=8"); + } + + @Test + @DisplayName("Should ignore reset commands for tokens that never existed") + void testResetNonExistentTokenIgnored() { + int expiryLimit = 5; + List> commands = List.of( + List.of(1, 555, 2), + List.of(1, 666, 4) + ); + + int result = app.numberOfTokens(expiryLimit, commands); + + assertEquals(0, result, "Non-existent tokens must not be created or counted"); + } + + @Test + @DisplayName("Should ignore reset attempt strictly after expiration has passed") + void testResetAfterExpirationIgnored() { + int expiryLimit = 2; + List> commands = List.of( + List.of(0, 1, 1), // exp: 3 + List.of(1, 1, 4) // ignored: T=4 is strictly greater than current exp=3 + ); + + int result = app.numberOfTokens(expiryLimit, commands); + + assertEquals(0, result, "Expired token cannot be renewed and remains inactive"); + } + } + + @Nested + @DisplayName("Negative Scenarios & Exception Handling") + class ExceptionTests { + + @Test + @DisplayName("Should throw NullPointerException when commands list is null") + void testNullCommandsListThrowsException() { + assertThrows(NullPointerException.class, () -> + app.numberOfTokens(5, null) + ); + } + + @Test + @DisplayName("Should throw NullPointerException when a command row is null") + void testNullCommandEntryThrowsException() { + List> commands = new ArrayList<>(); + commands.add(List.of(0, 1, 1)); + commands.add(null); + + assertThrows(NullPointerException.class, () -> + app.numberOfTokens(5, commands) + ); + } + + @Test + @DisplayName("Should throw IllegalArgumentException when expiryLimit is zero") + void testZeroExpiryLimitThrowsException() { + List> commands = List.of(List.of(0, 1, 1)); + + assertThrows(IllegalArgumentException.class, () -> + app.numberOfTokens(0, commands) + ); + } + + @Test + @DisplayName("Should throw IllegalArgumentException when expiryLimit is negative") + void testNegativeExpiryLimitThrowsException() { + List> commands = List.of(List.of(0, 1, 1)); + + assertThrows(IllegalArgumentException.class, () -> + app.numberOfTokens(-5, commands) + ); + } + + @Test + @DisplayName("Should throw IllegalArgumentException when command tuple has fewer than 3 elements") + void testCommandTupleUnderflowThrowsException() { + List> commands = List.of( + List.of(0, 1) // Missing timestamp + ); + + assertThrows(IllegalArgumentException.class, () -> + app.numberOfTokens(5, commands) + ); + } + + @Test + @DisplayName("Should throw IllegalArgumentException when command tuple has more than 3 elements") + void testCommandTupleOverflowThrowsException() { + List> commands = List.of( + List.of(0, 1, 2, 999) // Extra element + ); + + assertThrows(IllegalArgumentException.class, () -> + app.numberOfTokens(5, commands) + ); + } + } +} \ No newline at end of file diff --git a/src/test/java/com/shortthirdman/primekit/hackerearth/UpgradeEnhancementOptimizerTest.java b/src/test/java/com/shortthirdman/primekit/hackerearth/UpgradeEnhancementOptimizerTest.java new file mode 100644 index 0000000..db81172 --- /dev/null +++ b/src/test/java/com/shortthirdman/primekit/hackerearth/UpgradeEnhancementOptimizerTest.java @@ -0,0 +1,205 @@ +package com.shortthirdman.primekit.hackerearth; + +import org.junit.jupiter.api.*; + +import java.util.Collections; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +@DisplayName("UpgradeEnhancementOptimizer Tests") +class UpgradeEnhancementOptimizerTest { + + UpgradeEnhancementOptimizer app; + + @BeforeEach + void setUp() { + app = new UpgradeEnhancementOptimizer(); + } + + @AfterEach + void tearDown() { + app = null; + } + + @Nested + @DisplayName("Sample and Positive Functional Cases") + class PositiveScenarios { + + @Test + @DisplayName("Should solve the HackerRank example case correctly") + void shouldSolveStandardExampleCase() { + List costs = List.of(10, 20, 14, 40, 50); // indices 0 to 4[cite: 1] + int budget = 70; //[cite: 1] + + // Upgrade 4 (cost 50, val 16) + Upgrade 2 (cost 14, val 4) = 20[cite: 1] + int result = app.optimizeInAppUpgrades(costs, budget); + + assertEquals(20, result); //[cite: 1] + } + + @Test + @DisplayName("Should solve Sample Case 0 where all upgrades can be afforded") + void shouldSolveSampleCase0AffordingAllUpgrades() { + List costs = List.of(3, 4, 1); //[cite: 2] + int budget = 8; //[cite: 2] + + // Total cost = 3 + 4 + 1 = 8 <= 8; total value = 2^0 + 2^1 + 2^2 = 1 + 2 + 4 = 7[cite: 2] + int result = app.optimizeInAppUpgrades(costs, budget); + + assertEquals(7, result); //[cite: 2] + } + + @Test + @DisplayName("Should select only the single highest upgrade if budget permits nothing else") + void shouldSelectSingleHighestUpgradeWhenRemainingBudgetIsInsufficient() { + List costs = List.of(15, 10, 100); + int budget = 105; + + // Upgrade 2 (cost 100, val 4) leaves budget 5; cannot afford index 1 or 0 + int result = app.optimizeInAppUpgrades(costs, budget); + + assertEquals(4, result); + } + + @Test + @DisplayName("Should greedily skip expensive higher upgrade when cost exceeds budget") + void shouldSkipExpensiveHigherUpgradeAndPickAffordableLowerUpgrades() { + List costs = List.of(5, 5, 200); + int budget = 10; + + // Upgrade 2 (cost 200) skipped; Upgrade 1 (cost 5, val 2) + Upgrade 0 (cost 5, val 1) = 3 + int result = app.optimizeInAppUpgrades(costs, budget); + + assertEquals(3, result); + } + + @Test + @DisplayName("Should return correct value when exact budget equals single item cost") + void shouldHandleSingleItemExactBudget() { + List costs = List.of(42); + int budget = 42; + + // Upgrade 0 has value 2^0 = 1 + int result = app.optimizeInAppUpgrades(costs, budget); + + assertEquals(1, result); + } + + @Test + @DisplayName("Should correctly wrap modulo (10^9 + 7) for large power sum results") + void shouldApplyModuloWhenTotalEnhancementExceedsModuloBoundary() { + // 31 upgrades all with cost 0 -> sum = 2^31 - 1 = 2,147,483,647 + // Modulo 1,000,000,007 -> 2,147,483,647 % 1,000,000,007 = 147,483,633 + List costs = Collections.nCopies(31, 0); + int budget = 0; + + int result = app.optimizeInAppUpgrades(costs, budget); + + assertEquals(147_483_633, result); + } + } + + @Nested + @DisplayName("Edge Cases") + class EdgeCases { + + @Test + @DisplayName("Should return 0 when upgrade costs list is empty") + void shouldReturnZeroWhenUpgradeCostsListIsEmpty() { + int result = app.optimizeInAppUpgrades(List.of(), 500); + + assertEquals(0, result); + } + + @Test + @DisplayName("Should return 0 when budget is 0 and all items cost greater than 0") + void shouldReturnZeroWhenBudgetIsZeroAndCostsPositive() { + List costs = List.of(10, 20, 30); + int budget = 0; + + int result = app.optimizeInAppUpgrades(costs, budget); + + assertEquals(0, result); + } + + @Test + @DisplayName("Should return 0 when budget is strictly less than minimum item cost") + void shouldReturnZeroWhenBudgetIsLessThanAnyCost() { + List costs = List.of(50, 100, 200); + int budget = 25; + + int result = app.optimizeInAppUpgrades(costs, budget); + + assertEquals(0, result); + } + + @Test + @DisplayName("Should handle 0 cost upgrades when budget is 0") + void shouldIncludeZeroCostUpgradesEvenWithZeroBudget() { + List costs = List.of(0, 10, 0); // index 0 and 2 are free + int budget = 0; + + // index 2 (val 4) + index 0 (val 1) = 5 + int result = app.optimizeInAppUpgrades(costs, budget); + + assertEquals(5, result); + } + + @Test + @DisplayName("Should handle max budget constraint up to 10^9 without integer overflow") + void shouldHandleMaximumAllowedBudget() { + List costs = List.of(100_000, 100_000); //[cite: 2] + int budget = 1_000_000_000; // 10^9[cite: 2] + + // Both can be afforded: 2^0 + 2^1 = 3 + int result = app.optimizeInAppUpgrades(costs, budget); + + assertEquals(3, result); + } + } + + @Nested + @DisplayName("Exception and Negative Validations") + class ExceptionCases { + + @Test + @DisplayName("Should throw NullPointerException when upgradeCosts list is null") + void shouldThrowNullPointerExceptionWhenListIsNull() { + NullPointerException exception = assertThrows( + NullPointerException.class, + () -> app.optimizeInAppUpgrades(null, 50) + ); + + assertEquals("upgradeCosts must not be null", exception.getMessage()); + } + + @Test + @DisplayName("Should throw IllegalArgumentException when budget is negative") + void shouldThrowIllegalArgumentExceptionWhenBudgetIsNegative() { + List costs = List.of(10, 20); + int invalidBudget = -1; + + IllegalArgumentException exception = assertThrows( + IllegalArgumentException.class, + () -> app.optimizeInAppUpgrades(costs, invalidBudget) + ); + + assertEquals("budget cannot be negative: -1", exception.getMessage()); + } + + @Test + @DisplayName("Should throw IllegalArgumentException with large negative budget") + void shouldThrowIllegalArgumentExceptionForLargeNegativeBudget() { + List costs = List.of(1, 2, 3); + int invalidBudget = Integer.MIN_VALUE; + + IllegalArgumentException exception = assertThrows( + IllegalArgumentException.class, + () -> app.optimizeInAppUpgrades(costs, invalidBudget) + ); + + assertEquals("budget cannot be negative: " + Integer.MIN_VALUE, exception.getMessage()); + } + } +} \ No newline at end of file