From 52493cebe08fe45a4ce1fbfd9936862bd3b15cbf Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 09:50:58 +0000 Subject: [PATCH] Add unit tests for SkillExperienceContext, StateTypeWarningLog, and PendingReward Cover three previously untested classes: - SkillExperienceContext: base experience clamping, accessor correctness, default gain reason, BlockBreakContext and EntityDamageContext overrides - StateTypeWarningLog: warn-once deduplication, distinct key handling, throwable forwarding, namespace-aware key distinction - PendingReward: accessor correctness, defensive copy of serialized config map, immutability of returned map, empty map handling Co-Authored-By: Claude Opus 4.6 Claude-Session: https://claude.ai/code/session_01SJuWHCP6cgJ8oxugegnuxP --- .../combat/state/StateTypeWarningLogTest.java | 113 +++++++++++++ .../mcrpg/quest/reward/PendingRewardTest.java | 129 ++++++++++++++ .../context/SkillExperienceContextTest.java | 159 ++++++++++++++++++ 3 files changed, 401 insertions(+) create mode 100644 src/test/java/us/eunoians/mcrpg/combat/state/StateTypeWarningLogTest.java create mode 100644 src/test/java/us/eunoians/mcrpg/quest/reward/PendingRewardTest.java create mode 100644 src/test/java/us/eunoians/mcrpg/skill/experience/context/SkillExperienceContextTest.java diff --git a/src/test/java/us/eunoians/mcrpg/combat/state/StateTypeWarningLogTest.java b/src/test/java/us/eunoians/mcrpg/combat/state/StateTypeWarningLogTest.java new file mode 100644 index 000000000..8d994631f --- /dev/null +++ b/src/test/java/us/eunoians/mcrpg/combat/state/StateTypeWarningLogTest.java @@ -0,0 +1,113 @@ +package us.eunoians.mcrpg.combat.state; + +import org.bukkit.NamespacedKey; +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 us.eunoians.mcrpg.McRPGBaseTest; + +import java.util.logging.Level; +import java.util.logging.Logger; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +@DisplayName("StateTypeWarningLog") +class StateTypeWarningLogTest extends McRPGBaseTest { + + private Logger mockLogger; + private StateTypeWarningLog warningLog; + + @BeforeEach + void setUp() { + mockLogger = mock(Logger.class); + warningLog = new StateTypeWarningLog(mockLogger); + } + + @Nested + @DisplayName("warnOnce") + class WarnOnce { + + @Test + @DisplayName("logs warning on first call for a key") + @SuppressWarnings("deprecation") + void warnOnce_firstCall_logsWarning() { + NamespacedKey key = new NamespacedKey("mcrpg", "test_state"); + String message = "resolver threw for test_state"; + + warningLog.warnOnce(key, message, null); + + verify(mockLogger).log(Level.WARNING, message, (Throwable) null); + } + + @Test + @DisplayName("does not log on second call for the same key") + @SuppressWarnings("deprecation") + void warnOnce_secondCallSameKey_doesNotLog() { + NamespacedKey key = new NamespacedKey("mcrpg", "test_state"); + + warningLog.warnOnce(key, "first warning", null); + warningLog.warnOnce(key, "second warning", null); + + verify(mockLogger).log(Level.WARNING, "first warning", (Throwable) null); + verify(mockLogger, never()).log(Level.WARNING, "second warning", (Throwable) null); + } + + @Test + @DisplayName("logs warning for each distinct key") + @SuppressWarnings("deprecation") + void warnOnce_differentKeys_logsBoth() { + NamespacedKey key1 = new NamespacedKey("mcrpg", "state_a"); + NamespacedKey key2 = new NamespacedKey("mcrpg", "state_b"); + + warningLog.warnOnce(key1, "warning for A", null); + warningLog.warnOnce(key2, "warning for B", null); + + verify(mockLogger).log(Level.WARNING, "warning for A", (Throwable) null); + verify(mockLogger).log(Level.WARNING, "warning for B", (Throwable) null); + } + + @Test + @DisplayName("passes throwable cause to the logger") + @SuppressWarnings("deprecation") + void warnOnce_withCause_passesCauseToLogger() { + NamespacedKey key = new NamespacedKey("mcrpg", "broken_resolver"); + RuntimeException cause = new RuntimeException("resolver failed"); + + warningLog.warnOnce(key, "resolver threw", cause); + + verify(mockLogger).log(Level.WARNING, "resolver threw", cause); + } + + @Test + @DisplayName("suppresses duplicate even when cause differs") + @SuppressWarnings("deprecation") + void warnOnce_sameKeyDifferentCause_suppresses() { + NamespacedKey key = new NamespacedKey("mcrpg", "same_key"); + RuntimeException cause1 = new RuntimeException("first"); + RuntimeException cause2 = new RuntimeException("second"); + + warningLog.warnOnce(key, "warning", cause1); + warningLog.warnOnce(key, "warning", cause2); + + verify(mockLogger).log(Level.WARNING, "warning", cause1); + verify(mockLogger, never()).log(Level.WARNING, "warning", cause2); + } + + @Test + @DisplayName("keys from different namespaces are treated as distinct") + @SuppressWarnings("deprecation") + void warnOnce_differentNamespaces_logsBoth() { + NamespacedKey key1 = new NamespacedKey("mcrpg", "my_state"); + NamespacedKey key2 = new NamespacedKey("thirdparty", "my_state"); + + warningLog.warnOnce(key1, "mcrpg warning", null); + warningLog.warnOnce(key2, "thirdparty warning", null); + + verify(mockLogger).log(Level.WARNING, "mcrpg warning", (Throwable) null); + verify(mockLogger).log(Level.WARNING, "thirdparty warning", (Throwable) null); + } + } +} diff --git a/src/test/java/us/eunoians/mcrpg/quest/reward/PendingRewardTest.java b/src/test/java/us/eunoians/mcrpg/quest/reward/PendingRewardTest.java new file mode 100644 index 000000000..61bb29d30 --- /dev/null +++ b/src/test/java/us/eunoians/mcrpg/quest/reward/PendingRewardTest.java @@ -0,0 +1,129 @@ +package us.eunoians.mcrpg.quest.reward; + +import org.bukkit.NamespacedKey; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import us.eunoians.mcrpg.McRPGBaseTest; + +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotSame; +import static org.junit.jupiter.api.Assertions.assertThrows; + +@DisplayName("PendingReward") +@SuppressWarnings("deprecation") +class PendingRewardTest extends McRPGBaseTest { + + private static final UUID REWARD_ID = UUID.randomUUID(); + private static final UUID PLAYER_UUID = UUID.randomUUID(); + private static final NamespacedKey REWARD_TYPE_KEY = new NamespacedKey("mcrpg", "command"); + private static final NamespacedKey QUEST_KEY = new NamespacedKey("mcrpg", "test_quest"); + private static final long CREATED_AT = 1000L; + private static final long EXPIRES_AT = 2000L; + + @Nested + @DisplayName("accessors") + class Accessors { + + @Test + @DisplayName("getId returns the provided ID") + void getId_returnsProvidedId() { + PendingReward reward = createReward(Map.of("cmd", "give diamonds")); + assertEquals(REWARD_ID, reward.getId()); + } + + @Test + @DisplayName("getPlayerUUID returns the provided player UUID") + void getPlayerUUID_returnsProvidedUUID() { + PendingReward reward = createReward(Map.of("cmd", "give diamonds")); + assertEquals(PLAYER_UUID, reward.getPlayerUUID()); + } + + @Test + @DisplayName("getRewardTypeKey returns the provided type key") + void getRewardTypeKey_returnsProvidedKey() { + PendingReward reward = createReward(Map.of("cmd", "give diamonds")); + assertEquals(REWARD_TYPE_KEY, reward.getRewardTypeKey()); + } + + @Test + @DisplayName("getQuestKey returns the provided quest key") + void getQuestKey_returnsProvidedKey() { + PendingReward reward = createReward(Map.of("cmd", "give diamonds")); + assertEquals(QUEST_KEY, reward.getQuestKey()); + } + + @Test + @DisplayName("getCreatedAt returns the provided timestamp") + void getCreatedAt_returnsProvidedTimestamp() { + PendingReward reward = createReward(Map.of("cmd", "give diamonds")); + assertEquals(CREATED_AT, reward.getCreatedAt()); + } + + @Test + @DisplayName("getExpiresAt returns the provided timestamp") + void getExpiresAt_returnsProvidedTimestamp() { + PendingReward reward = createReward(Map.of("cmd", "give diamonds")); + assertEquals(EXPIRES_AT, reward.getExpiresAt()); + } + } + + @Nested + @DisplayName("serialized config immutability") + class SerializedConfigImmutability { + + @Test + @DisplayName("getSerializedConfig returns the correct entries") + void getSerializedConfig_returnsCorrectEntries() { + Map config = Map.of("command", "give {player} diamond 5", "amount", 5); + PendingReward reward = createReward(config); + assertEquals(config, reward.getSerializedConfig()); + } + + @Test + @DisplayName("modifications to the source map do not affect the reward") + void getSerializedConfig_sourceMapModification_doesNotAffectReward() { + Map mutableConfig = new HashMap<>(); + mutableConfig.put("key", "original"); + + PendingReward reward = createReward(mutableConfig); + mutableConfig.put("key", "modified"); + mutableConfig.put("extra", "sneaky"); + + assertEquals("original", reward.getSerializedConfig().get("key")); + assertEquals(1, reward.getSerializedConfig().size()); + } + + @Test + @DisplayName("returned config map is unmodifiable") + void getSerializedConfig_returnedMap_isUnmodifiable() { + PendingReward reward = createReward(Map.of("cmd", "test")); + Map returnedConfig = reward.getSerializedConfig(); + assertThrows(UnsupportedOperationException.class, () -> returnedConfig.put("new", "value")); + } + + @Test + @DisplayName("empty config map is handled correctly") + void getSerializedConfig_emptyMap_isHandled() { + PendingReward reward = createReward(Map.of()); + assertEquals(0, reward.getSerializedConfig().size()); + } + + @Test + @DisplayName("returned config is a defensive copy, not the internal reference") + void getSerializedConfig_returnedMap_isDefensiveCopy() { + Map config = new HashMap<>(); + config.put("key", "value"); + PendingReward reward = createReward(config); + assertNotSame(config, reward.getSerializedConfig()); + } + } + + private PendingReward createReward(Map config) { + return new PendingReward(REWARD_ID, PLAYER_UUID, REWARD_TYPE_KEY, config, QUEST_KEY, CREATED_AT, EXPIRES_AT); + } +} diff --git a/src/test/java/us/eunoians/mcrpg/skill/experience/context/SkillExperienceContextTest.java b/src/test/java/us/eunoians/mcrpg/skill/experience/context/SkillExperienceContextTest.java new file mode 100644 index 000000000..a3bacc857 --- /dev/null +++ b/src/test/java/us/eunoians/mcrpg/skill/experience/context/SkillExperienceContextTest.java @@ -0,0 +1,159 @@ +package us.eunoians.mcrpg.skill.experience.context; + +import org.bukkit.event.Event; +import org.bukkit.event.block.BlockBreakEvent; +import org.bukkit.event.entity.EntityDamageByEntityEvent; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import us.eunoians.mcrpg.McRPGBaseTest; +import us.eunoians.mcrpg.entity.holder.SkillHolder; +import us.eunoians.mcrpg.skill.Skill; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.mockito.Mockito.mock; + +@DisplayName("SkillExperienceContext") +class SkillExperienceContextTest extends McRPGBaseTest { + + private final SkillHolder mockSkillHolder = mock(SkillHolder.class); + private final Skill mockSkill = mock(Skill.class); + private final Event mockEvent = mock(Event.class); + + @Nested + @DisplayName("base experience clamping") + class BaseExperienceClamping { + + @Test + @DisplayName("positive base experience is preserved") + void getBaseExperience_positiveValue_isPreserved() { + MockExperienceContext context = new MockExperienceContext(mockSkillHolder, mockSkill, 50, mockEvent); + assertEquals(50, context.getBaseExperience()); + } + + @Test + @DisplayName("zero base experience is preserved") + void getBaseExperience_zero_isPreserved() { + MockExperienceContext context = new MockExperienceContext(mockSkillHolder, mockSkill, 0, mockEvent); + assertEquals(0, context.getBaseExperience()); + } + + @Test + @DisplayName("negative base experience is clamped to zero") + void getBaseExperience_negativeValue_isClampedToZero() { + MockExperienceContext context = new MockExperienceContext(mockSkillHolder, mockSkill, -10, mockEvent); + assertEquals(0, context.getBaseExperience()); + } + + @Test + @DisplayName("large negative base experience is clamped to zero") + void getBaseExperience_largeNegativeValue_isClampedToZero() { + MockExperienceContext context = new MockExperienceContext(mockSkillHolder, mockSkill, Integer.MIN_VALUE, mockEvent); + assertEquals(0, context.getBaseExperience()); + } + + @Test + @DisplayName("Integer.MAX_VALUE base experience is preserved") + void getBaseExperience_maxValue_isPreserved() { + MockExperienceContext context = new MockExperienceContext(mockSkillHolder, mockSkill, Integer.MAX_VALUE, mockEvent); + assertEquals(Integer.MAX_VALUE, context.getBaseExperience()); + } + } + + @Nested + @DisplayName("accessors") + class Accessors { + + @Test + @DisplayName("getSkillHolder returns the provided skill holder") + void getSkillHolder_returnsProvided() { + MockExperienceContext context = new MockExperienceContext(mockSkillHolder, mockSkill, 10, mockEvent); + assertSame(mockSkillHolder, context.getSkillHolder()); + } + + @Test + @DisplayName("getSkill returns the provided skill") + void getSkill_returnsProvided() { + MockExperienceContext context = new MockExperienceContext(mockSkillHolder, mockSkill, 10, mockEvent); + assertSame(mockSkill, context.getSkill()); + } + + @Test + @DisplayName("getEvent returns the provided event") + void getEvent_returnsProvided() { + MockExperienceContext context = new MockExperienceContext(mockSkillHolder, mockSkill, 10, mockEvent); + assertSame(mockEvent, context.getEvent()); + } + } + + @Nested + @DisplayName("default gain reason") + class DefaultGainReason { + + @Test + @DisplayName("base context returns OTHER as default gain reason") + void getGainReason_baseContext_returnsOther() { + MockExperienceContext context = new MockExperienceContext(mockSkillHolder, mockSkill, 10, mockEvent); + assertEquals(McRPGGainReason.OTHER, context.getGainReason()); + } + } + + @Nested + @DisplayName("BlockBreakContext") + class BlockBreakContextTests { + + @Test + @DisplayName("returns BLOCK_BREAK as gain reason") + void getGainReason_returnsBlockBreak() { + BlockBreakEvent blockBreakEvent = mock(BlockBreakEvent.class); + BlockBreakContext context = new BlockBreakContext(mockSkillHolder, mockSkill, 25, blockBreakEvent); + assertEquals(McRPGGainReason.BLOCK_BREAK, context.getGainReason()); + } + + @Test + @DisplayName("preserves the BlockBreakEvent") + void getEvent_returnsBlockBreakEvent() { + BlockBreakEvent blockBreakEvent = mock(BlockBreakEvent.class); + BlockBreakContext context = new BlockBreakContext(mockSkillHolder, mockSkill, 25, blockBreakEvent); + assertSame(blockBreakEvent, context.getEvent()); + } + + @Test + @DisplayName("clamps negative experience to zero") + void getBaseExperience_negativeValue_isClampedToZero() { + BlockBreakEvent blockBreakEvent = mock(BlockBreakEvent.class); + BlockBreakContext context = new BlockBreakContext(mockSkillHolder, mockSkill, -5, blockBreakEvent); + assertEquals(0, context.getBaseExperience()); + } + } + + @Nested + @DisplayName("EntityDamageContext") + class EntityDamageContextTests { + + @Test + @DisplayName("returns ENTITY_DAMAGE as gain reason") + void getGainReason_returnsEntityDamage() { + EntityDamageByEntityEvent damageEvent = mock(EntityDamageByEntityEvent.class); + EntityDamageContext context = new EntityDamageContext(mockSkillHolder, mockSkill, 30, damageEvent); + assertEquals(McRPGGainReason.ENTITY_DAMAGE, context.getGainReason()); + } + + @Test + @DisplayName("preserves the EntityDamageByEntityEvent") + void getEvent_returnsEntityDamageEvent() { + EntityDamageByEntityEvent damageEvent = mock(EntityDamageByEntityEvent.class); + EntityDamageContext context = new EntityDamageContext(mockSkillHolder, mockSkill, 30, damageEvent); + assertSame(damageEvent, context.getEvent()); + } + + @Test + @DisplayName("clamps negative experience to zero") + void getBaseExperience_negativeValue_isClampedToZero() { + EntityDamageByEntityEvent damageEvent = mock(EntityDamageByEntityEvent.class); + EntityDamageContext context = new EntityDamageContext(mockSkillHolder, mockSkill, -100, damageEvent); + assertEquals(0, context.getBaseExperience()); + } + } +}