diff --git a/src/main/java/com/gmail/nossr50/datatypes/experience/DiminishedReturnsCache.java b/src/main/java/com/gmail/nossr50/datatypes/experience/DiminishedReturnsCache.java new file mode 100644 index 000000000..4ec702ecc --- /dev/null +++ b/src/main/java/com/gmail/nossr50/datatypes/experience/DiminishedReturnsCache.java @@ -0,0 +1,84 @@ +package com.gmail.nossr50.datatypes.experience; + +import com.gmail.nossr50.config.experience.ExperienceConfig; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +/** + * Server-side cache that keeps {@link DiminishedReturnsState} alive across a player's + * disconnect/reconnect cycle, preventing players from resetting their DR window by logging out. + * + *

Entries are keyed by player UUID. When the DR time window elapses the entry becomes + * eligible for removal; call {@link #evictExpired()} periodically (e.g. from + * {@link com.gmail.nossr50.runnables.player.ClearRegisteredXPGainTask}) to release stale entries + * and prevent unbounded cache growth.

+ * + *

All public methods are safe to call from multiple threads.

+ */ +public final class DiminishedReturnsCache { + + private static final ConcurrentHashMap cache = + new ConcurrentHashMap<>(); + + private DiminishedReturnsCache() {} + + /** + * Returns the existing {@link DiminishedReturnsState} for {@code uuid} if it still has active + * DR entries, or atomically replaces/creates a fresh one. + * + *

Passing {@code null} (offline or legacy profiles without a UUID) always returns a fresh, + * uncached state — those profiles never participate in the disconnect/reconnect bypass.

+ * + * @param uuid the player's unique identifier, or {@code null} for uncached profiles + * @return a non-null {@link DiminishedReturnsState} for this player + */ + public static @NotNull DiminishedReturnsState getOrCreate(@Nullable final UUID uuid) { + if (uuid == null || !ExperienceConfig.getInstance().getDiminishedReturnsEnabled()) { + return new DiminishedReturnsState(); + } + return cache.compute(uuid, (k, existing) -> { + // Keep the existing instance if it is either still active OR fresh (never used). + // A fresh existing state means the player's PlayerProfile (which holds a strong + // reference to it) has not yet gained any XP — replacing it now would orphan + // that reference and break the disconnect/reconnect bypass protection on the + // next XP gain. + if (existing != null && !existing.isEvictable()) { + return existing; + } + return new DiminishedReturnsState(); + }); + } + + /** + * Removes all cache entries whose DR time window has fully elapsed. Fresh entries that + * have never registered XP are preserved — see {@link DiminishedReturnsState#isEvictable()}. + * Safe to call from any thread; intended for use in the periodic cleanup task. + */ + public static void evictExpired() { + cache.values().removeIf(DiminishedReturnsState::isEvictable); + } + + /** Removes the cache entry for {@code uuid}. For test teardown only. */ + static void remove(@Nullable final UUID uuid) { + if (uuid != null) { + cache.remove(uuid); + } + } + + /** Clears every entry from the cache. For test teardown only. */ + static void clearAll() { + cache.clear(); + } + + /** Returns the number of entries currently held in the cache. For test assertions only. */ + static int size() { + return cache.size(); + } + + /** Returns {@code true} if the cache contains an entry for {@code uuid}. For test assertions only. */ + static boolean contains(@NotNull final UUID uuid) { + return cache.containsKey(uuid); + } +} diff --git a/src/main/java/com/gmail/nossr50/datatypes/experience/DiminishedReturnsState.java b/src/main/java/com/gmail/nossr50/datatypes/experience/DiminishedReturnsState.java new file mode 100644 index 000000000..c65d8e968 --- /dev/null +++ b/src/main/java/com/gmail/nossr50/datatypes/experience/DiminishedReturnsState.java @@ -0,0 +1,103 @@ +package com.gmail.nossr50.datatypes.experience; + +import com.gmail.nossr50.datatypes.skills.PrimarySkillType; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.DelayQueue; +import org.jetbrains.annotations.NotNull; + +/** + * Thread-safe container for a player's Diminished Returns tracking data. + * + *

Stored in {@link DiminishedReturnsCache} keyed by player UUID so that the DR window + * survives a disconnect/reconnect cycle. Each skill's rolling XP total and the expiry + * queue are held here rather than directly in {@code PlayerProfile}.

+ */ +public final class DiminishedReturnsState { + + private final DelayQueue gainedSkillsXp = new DelayQueue<>(); + private final ConcurrentHashMap rollingSkillsXp = new ConcurrentHashMap<>(); + + /** + * The epoch-millisecond timestamp of the last-expiring {@link SkillXpGain} that has been + * registered. When the current time exceeds this value every entry in the queue has expired + * and this state can safely be evicted from the cache without losing any DR data. + * + *

{@code volatile} ensures the write from one thread (e.g. the main thread registering XP) + * is visible to the scheduler thread that runs {@link DiminishedReturnsCache#evictExpired()}. + */ + private volatile long latestExpiryTimeMillis = 0L; + + // Package-private: only DiminishedReturnsCache (same package) and PlayerProfile + // (via DiminishedReturnsCache.getOrCreate) should instantiate this class. + DiminishedReturnsState() {} + + /** + * Returns {@code true} if at least one DR entry is still within its time window. + * + *

This is intentionally distinct from {@link #isEvictable()}: a freshly constructed state + * that has never registered any XP has no active entries but is also not evictable, + * because its owning {@code PlayerProfile} still holds a reference and may yet register XP + * on it. Removing it from the cache prematurely would break the disconnect/reconnect + * bypass protection.

+ */ + public boolean hasActiveEntries() { + return latestExpiryTimeMillis != 0L && System.currentTimeMillis() < latestExpiryTimeMillis; + } + + /** + * Returns {@code true} if this state has held DR entries and they have all expired. + * Fresh states (no XP ever registered) are never evictable. + */ + public boolean isEvictable() { + return latestExpiryTimeMillis != 0L && System.currentTimeMillis() >= latestExpiryTimeMillis; + } + + /** + * Returns the rolling XP total recorded for the given skill within the current DR window. + * Returns {@code 0} if no XP has been registered or all entries have expired. + * + * @param skill the skill to query + * @return total registered XP for the skill, or {@code 0} + */ + public float getRegisteredXpGain(@NotNull final PrimarySkillType skill) { + return rollingSkillsXp.getOrDefault(skill, 0F); + } + + /** + * Records an XP gain for DR tracking and advances the latest-expiry ceiling accordingly. + * + * @param skill the skill that gained XP + * @param xp the amount of XP gained + */ + public void registerXpGain(@NotNull final PrimarySkillType skill, final float xp) { + final SkillXpGain gain = new SkillXpGain(skill, xp); + gainedSkillsXp.add(gain); + rollingSkillsXp.merge(skill, xp, Float::sum); + + // Advance the expiry ceiling so we know the exact moment all entries will have expired. + final long entryExpiry = gain.getExpiryTimeMillis(); + if (entryExpiry > latestExpiryTimeMillis) { + latestExpiryTimeMillis = entryExpiry; + } + } + + /** + * Polls the delay queue for expired entries and decrements the rolling skill totals. + * Called periodically by + * {@link com.gmail.nossr50.runnables.player.ClearRegisteredXPGainTask}. + */ + public void purgeExpiredXpGains() { + SkillXpGain gain; + while ((gain = gainedSkillsXp.poll()) != null) { + final PrimarySkillType skill = gain.getSkill(); + final float expiredXp = gain.getXp(); + rollingSkillsXp.compute(skill, (k, existing) -> { + if (existing == null) { + return null; + } + final float updated = existing - expiredXp; + return updated <= 0F ? null : updated; + }); + } + } +} diff --git a/src/main/java/com/gmail/nossr50/datatypes/experience/SkillXpGain.java b/src/main/java/com/gmail/nossr50/datatypes/experience/SkillXpGain.java index c166ae10a..23e896d63 100644 --- a/src/main/java/com/gmail/nossr50/datatypes/experience/SkillXpGain.java +++ b/src/main/java/com/gmail/nossr50/datatypes/experience/SkillXpGain.java @@ -25,6 +25,14 @@ public class SkillXpGain implements Delayed { return xp; } + /** + * Returns the absolute epoch-millisecond timestamp at which this entry expires. + * Used by {@link DiminishedReturnsState} to track the latest expiry across all entries. + */ + public long getExpiryTimeMillis() { + return expiryTime; + } + private static long getDuration() { return TimeUnit.MINUTES.toMillis( ExperienceConfig.getInstance().getDiminishedReturnsTimeInterval()); diff --git a/src/main/java/com/gmail/nossr50/datatypes/player/PlayerProfile.java b/src/main/java/com/gmail/nossr50/datatypes/player/PlayerProfile.java index 1c61af81d..9ab19f9d2 100644 --- a/src/main/java/com/gmail/nossr50/datatypes/player/PlayerProfile.java +++ b/src/main/java/com/gmail/nossr50/datatypes/player/PlayerProfile.java @@ -1,8 +1,9 @@ package com.gmail.nossr50.datatypes.player; import com.gmail.nossr50.config.experience.ExperienceConfig; +import com.gmail.nossr50.datatypes.experience.DiminishedReturnsCache; +import com.gmail.nossr50.datatypes.experience.DiminishedReturnsState; import com.gmail.nossr50.datatypes.experience.FormulaType; -import com.gmail.nossr50.datatypes.experience.SkillXpGain; import com.gmail.nossr50.datatypes.skills.PrimarySkillType; import com.gmail.nossr50.datatypes.skills.SuperAbilityType; import com.gmail.nossr50.mcMMO; @@ -15,7 +16,6 @@ import java.util.EnumMap; import java.util.Map; import java.util.Objects; import java.util.UUID; -import java.util.concurrent.DelayQueue; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; @@ -41,10 +41,8 @@ public class PlayerProfile { private final Map uniquePlayerData = new EnumMap<>( UniqueDataType.class); //Misc data that doesn't fit into other categories (chimaera wing, etc..) - // Store previous XP gains for diminished returns - private final DelayQueue gainedSkillsXp = new DelayQueue<>(); - private final Map rollingSkillsXp = new EnumMap<>( - PrimarySkillType.class); + // Store previous XP gains for diminished returns (persisted across reconnects via cache) + private final DiminishedReturnsState diminishedReturnsState; @Deprecated public PlayerProfile(String playerName) { @@ -64,6 +62,7 @@ public class PlayerProfile { public PlayerProfile(String playerName, @Nullable UUID uuid, int startingLevel) { this.uuid = uuid; this.playerName = playerName; + this.diminishedReturnsState = DiminishedReturnsCache.getOrCreate(uuid); scoreboardTipsShown = 0; @@ -99,6 +98,8 @@ public class PlayerProfile { this.playerName = playerName; this.uuid = uuid; this.scoreboardTipsShown = scoreboardTipsShown; + // This constructor is used for save copies only — do not pull DR state from cache. + this.diminishedReturnsState = DiminishedReturnsCache.getOrCreate(null); skills.putAll(levelData); skillsXp.putAll(xpData); @@ -404,13 +405,7 @@ public class PlayerProfile { * @return xp Experience amount registered */ public float getRegisteredXpGain(PrimarySkillType primarySkillType) { - float xp = 0F; - - if (rollingSkillsXp.get(primarySkillType) != null) { - xp = rollingSkillsXp.get(primarySkillType); - } - - return xp; + return diminishedReturnsState.getRegisteredXpGain(primarySkillType); } /** @@ -420,19 +415,16 @@ public class PlayerProfile { * @param xp Experience amount to add */ public void registerXpGain(PrimarySkillType primarySkillType, float xp) { - gainedSkillsXp.add(new SkillXpGain(primarySkillType, xp)); - rollingSkillsXp.put(primarySkillType, getRegisteredXpGain(primarySkillType) + xp); + if (ExperienceConfig.getInstance().getDiminishedReturnsEnabled()) { + diminishedReturnsState.registerXpGain(primarySkillType, xp); + } } /** * Remove experience gains older than a given time This is used for diminished XP returns */ public void purgeExpiredXpGains() { - SkillXpGain gain; - while ((gain = gainedSkillsXp.poll()) != null) { - rollingSkillsXp.put(gain.getSkill(), - getRegisteredXpGain(gain.getSkill()) - gain.getXp()); - } + diminishedReturnsState.purgeExpiredXpGains(); } /** diff --git a/src/main/java/com/gmail/nossr50/runnables/player/ClearRegisteredXPGainTask.java b/src/main/java/com/gmail/nossr50/runnables/player/ClearRegisteredXPGainTask.java index f4ba32d16..fe58d8d64 100644 --- a/src/main/java/com/gmail/nossr50/runnables/player/ClearRegisteredXPGainTask.java +++ b/src/main/java/com/gmail/nossr50/runnables/player/ClearRegisteredXPGainTask.java @@ -1,5 +1,6 @@ package com.gmail.nossr50.runnables.player; +import com.gmail.nossr50.datatypes.experience.DiminishedReturnsCache; import com.gmail.nossr50.datatypes.player.McMMOPlayer; import com.gmail.nossr50.util.CancellableRunnable; import com.gmail.nossr50.util.player.UserManager; @@ -10,5 +11,7 @@ public class ClearRegisteredXPGainTask extends CancellableRunnable { for (McMMOPlayer mmoPlayer : UserManager.getPlayers()) { mmoPlayer.getProfile().purgeExpiredXpGains(); } + // Evict cache entries whose DR window has fully elapsed to prevent unbounded growth. + DiminishedReturnsCache.evictExpired(); } } diff --git a/src/test/java/com/gmail/nossr50/datatypes/experience/DiminishedReturnsCacheTest.java b/src/test/java/com/gmail/nossr50/datatypes/experience/DiminishedReturnsCacheTest.java new file mode 100644 index 000000000..c06e636ce --- /dev/null +++ b/src/test/java/com/gmail/nossr50/datatypes/experience/DiminishedReturnsCacheTest.java @@ -0,0 +1,291 @@ +package com.gmail.nossr50.datatypes.experience; + +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.assertNotSame; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.when; + +import com.gmail.nossr50.config.experience.ExperienceConfig; +import java.lang.reflect.Field; +import java.util.UUID; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.mockito.MockedStatic; + +/** + * Unit tests for {@link DiminishedReturnsCache}. + * + *

These tests verify the cache's UUID-keyed persistence semantics and eviction logic. + * {@link ExperienceConfig} is mocked so tests do not require a running Minecraft server. + * By default DR is enabled; individual tests that need DR disabled override that mock. + */ +class DiminishedReturnsCacheTest { + + private MockedStatic mockedExperienceConfig; + private ExperienceConfig experienceConfigMock; + + @BeforeEach + void setupMocks() { + experienceConfigMock = mock(ExperienceConfig.class); + mockedExperienceConfig = mockStatic(ExperienceConfig.class); + mockedExperienceConfig.when(ExperienceConfig::getInstance).thenReturn(experienceConfigMock); + // Default: DR is enabled so UUID-based caching is exercised + when(experienceConfigMock.getDiminishedReturnsEnabled()).thenReturn(true); + } + + @AfterEach + void teardown() { + mockedExperienceConfig.close(); + DiminishedReturnsCache.clearAll(); + } + + /** + * Uses reflection to force the {@code latestExpiryTimeMillis} field to an arbitrary value. + * This lets tests drive the {@code hasActiveEntries()} return value without waiting for real + * time to elapse or calling into ExperienceConfig. + */ + private static void setLatestExpiry(final DiminishedReturnsState state, final long epochMillis) + throws Exception { + final Field field = DiminishedReturnsState.class.getDeclaredField("latestExpiryTimeMillis"); + field.setAccessible(true); + field.setLong(state, epochMillis); + } + + @Nested + class DisabledBehavior { + + @Test + void returnsUncachedStateWhenDrDisabled() { + // Given - DR is disabled in config + when(experienceConfigMock.getDiminishedReturnsEnabled()).thenReturn(false); + final UUID playerUuid = UUID.randomUUID(); + + // When - two getOrCreate calls for the same UUID + final DiminishedReturnsState firstState = DiminishedReturnsCache.getOrCreate(playerUuid); + final DiminishedReturnsState secondState = DiminishedReturnsCache.getOrCreate(playerUuid); + + // Then - a fresh uncached state is returned each time; no bypass protection needed + assertNotSame(firstState, secondState, + "DR disabled: states must not be cached across calls"); + assertEquals(0, DiminishedReturnsCache.size(), + "DR disabled: cache must remain empty"); + } + + @Test + void cacheRemainsEmptyWhenDrDisabled() { + // Given - DR is disabled + when(experienceConfigMock.getDiminishedReturnsEnabled()).thenReturn(false); + + // When - several players look up their DR state + DiminishedReturnsCache.getOrCreate(UUID.randomUUID()); + DiminishedReturnsCache.getOrCreate(UUID.randomUUID()); + DiminishedReturnsCache.getOrCreate(UUID.randomUUID()); + + // Then - nothing is stored + assertEquals(0, DiminishedReturnsCache.size(), + "DR disabled: cache must never grow"); + } + } + + @Nested + class NullUuidBehavior { + + @Test + void alwaysReturnsFreshInstance() { + // Given - two calls with null (offline / legacy profile) + // When + final DiminishedReturnsState firstState = DiminishedReturnsCache.getOrCreate(null); + final DiminishedReturnsState secondState = DiminishedReturnsCache.getOrCreate(null); + + // Then - every call returns a distinct, uncached object + assertNotNull(firstState); + assertNotNull(secondState); + assertNotSame(firstState, secondState, + "null-UUID calls must never share a cached state"); + } + + @Test + void doesNotGrowCache() { + // Given - several calls with null + // When + DiminishedReturnsCache.getOrCreate(null); + DiminishedReturnsCache.getOrCreate(null); + DiminishedReturnsCache.getOrCreate(null); + + // Then - cache should be empty (null profiles are never stored) + assertEquals(0, DiminishedReturnsCache.size(), + "null-UUID states must not be stored in the cache"); + } + } + + @Nested + class SameUuidPersistence { + + @Test + void returnsSameInstanceForSameUuidWhileActive() throws Exception { + // Given - a UUID with an active DR state (expiry set far in the future) + final UUID playerUuid = UUID.randomUUID(); + final DiminishedReturnsState firstState = DiminishedReturnsCache.getOrCreate(playerUuid); + setLatestExpiry(firstState, System.currentTimeMillis() + 600_000L); // 10 min ahead + + // When - a second lookup with the same UUID (simulates reconnect while DR is active) + final DiminishedReturnsState secondState = DiminishedReturnsCache.getOrCreate(playerUuid); + + // Then - identical instance returned, so DR window is preserved + assertSame(firstState, secondState, + "active DR state must be returned as-is on reconnect to prevent bypass"); + } + + @Test + void returnsFreshInstanceWhenPreviousStateHasExpired() throws Exception { + // Given - a UUID whose cached state has fully elapsed (latestExpiry in the past, non-zero) + final UUID playerUuid = UUID.randomUUID(); + final DiminishedReturnsState expiredState = DiminishedReturnsCache.getOrCreate(playerUuid); + // 1L = had XP at some point AND that XP has now expired + setLatestExpiry(expiredState, 1L); + + // When - player reconnects after their DR window has elapsed + final DiminishedReturnsState freshState = DiminishedReturnsCache.getOrCreate(playerUuid); + + // Then - a new state is created; no stale XP data carries over + assertNotSame(expiredState, freshState, + "expired state should be replaced with a fresh one on next getOrCreate"); + } + } + + @Nested + class UuidIsolation { + + @Test + void differentUuidsGetIndependentStates() throws Exception { + // Given - two distinct players + final UUID uuidAlpha = UUID.randomUUID(); + final UUID uuidBeta = UUID.randomUUID(); + + // When - both look up their DR state (mark both active so cache keeps them) + final DiminishedReturnsState stateAlpha = DiminishedReturnsCache.getOrCreate(uuidAlpha); + final DiminishedReturnsState stateBeta = DiminishedReturnsCache.getOrCreate(uuidBeta); + setLatestExpiry(stateAlpha, System.currentTimeMillis() + 600_000L); + setLatestExpiry(stateBeta, System.currentTimeMillis() + 600_000L); + + // Then - they receive independent state containers + assertNotSame(stateAlpha, stateBeta, + "each player UUID must have its own isolated DR state"); + } + } + + @Nested + class Eviction { + + @Test + void evictExpiredRemovesStateWithExpiredEntries() throws Exception { + // Given - a player whose DR state has expired (latestExpiry in the past, non-zero) + final UUID playerUuid = UUID.randomUUID(); + final DiminishedReturnsState expiredState = DiminishedReturnsCache.getOrCreate(playerUuid); + // 1L = "had XP registered" (non-zero) AND "already expired" (< currentTimeMillis) + setLatestExpiry(expiredState, 1L); + assertTrue(DiminishedReturnsCache.contains(playerUuid), + "state should be in cache before eviction"); + + // When + DiminishedReturnsCache.evictExpired(); + + // Then + assertFalse(DiminishedReturnsCache.contains(playerUuid), + "expired state should be removed by evictExpired"); + assertEquals(0, DiminishedReturnsCache.size()); + } + + @Test + void evictExpiredPreservesFreshStateThatNeverRegisteredXp() { + // Regression: a player who joined but has not yet gained any XP must keep their cached + // state. Otherwise the cache entry is evicted, then once XP is registered on the + // PlayerProfile's orphaned reference, a disconnect/reconnect creates a new state and + // bypasses the DR window. + // Given - a fresh state (latestExpiryTimeMillis defaults to 0) + final UUID playerUuid = UUID.randomUUID(); + DiminishedReturnsCache.getOrCreate(playerUuid); + assertTrue(DiminishedReturnsCache.contains(playerUuid)); + + // When + DiminishedReturnsCache.evictExpired(); + + // Then - fresh state must NOT be evicted + assertTrue(DiminishedReturnsCache.contains(playerUuid), + "fresh state with no registered XP must survive eviction " + + "to preserve reconnect bypass protection"); + } + + @Test + void evictExpiredPreservesStateWithActiveEntries() throws Exception { + // Given - a player with an active DR state + final UUID playerUuid = UUID.randomUUID(); + final DiminishedReturnsState activeState = DiminishedReturnsCache.getOrCreate(playerUuid); + setLatestExpiry(activeState, System.currentTimeMillis() + 600_000L); // active + assertTrue(activeState.hasActiveEntries()); + + // When + DiminishedReturnsCache.evictExpired(); + + // Then - active entry must survive the sweep + assertTrue(DiminishedReturnsCache.contains(playerUuid), + "active DR state must not be evicted"); + assertEquals(1, DiminishedReturnsCache.size()); + } + + @Test + void evictExpiredOnlyRemovesExpiredEntries() throws Exception { + // Given - one active player and one whose DR window has elapsed + final UUID activePlayerUuid = UUID.randomUUID(); + final UUID expiredPlayerUuid = UUID.randomUUID(); + + final DiminishedReturnsState activeState = DiminishedReturnsCache.getOrCreate(activePlayerUuid); + final DiminishedReturnsState expiredState = DiminishedReturnsCache.getOrCreate(expiredPlayerUuid); + + setLatestExpiry(activeState, System.currentTimeMillis() + 600_000L); + setLatestExpiry(expiredState, 1L); + + // When + DiminishedReturnsCache.evictExpired(); + + // Then - only the expired entry is removed + assertTrue(DiminishedReturnsCache.contains(activePlayerUuid), + "active player state must survive eviction"); + assertFalse(DiminishedReturnsCache.contains(expiredPlayerUuid), + "expired player state must be removed"); + assertEquals(1, DiminishedReturnsCache.size()); + } + } + + @Nested + class HasActiveEntries { + + @Test + void freshStateHasNoActiveEntries() { + // Given - a freshly constructed state (latestExpiryTimeMillis defaults to 0) + final DiminishedReturnsState freshState = new DiminishedReturnsState(); + + // When / Then + assertFalse(freshState.hasActiveEntries(), + "a newly created state with no XP registrations should report no active entries"); + } + + @Test + void stateWithFutureExpiryHasActiveEntries() throws Exception { + // Given - a state whose expiry ceiling has been pushed into the future + final DiminishedReturnsState state = new DiminishedReturnsState(); + setLatestExpiry(state, System.currentTimeMillis() + 600_000L); + + // When / Then + assertTrue(state.hasActiveEntries(), + "state with a future latestExpiryTimeMillis should report active entries"); + } + } +}