Parcourir la source

Merge branch 'task/S-cfc-264' into cfclub

E2E Test Bot il y a 1 jour
Parent
commit
ce0cb6cae1

+ 183 - 0
cfc-backend/src/test/java/com/etotem/cfc/unit/EnergyFiveDimViewContractTest.java

@@ -0,0 +1,183 @@
+package com.etotem.cfc.unit;
+
+import com.etotem.cfc.entity.EnergyBalance;
+import com.etotem.cfc.entity.EnergyDimension;
+import com.etotem.cfc.mapper.EnergyBalanceMapper;
+import com.etotem.cfc.mapper.EnergyDimensionMapper;
+import com.etotem.cfc.service.EnergyService;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+/**
+ * SC-P-17 energy.fiveDim.view 契约测试(五维能量百分比分配与校验)。
+ *
+ * <p><b>契约锚点(溯源)</b></p>
+ * <ul>
+ *   <li>需求:{@code tests/user-scenarios/应用场景总览.md} SC-P-17(查看五维能量/健康指数:
+ *       身/心/智/行/富分配总和=100%,比例配置错误≠100% 报警,无数据空态不 500);</li>
+ *   <li>设计:{@code docs/superpowers/api/API_REFERENCE.md} §4.5 能量与积分
+ *       {@code /api/energy/overview}(能量概览)、{@code /api/energy/dimension}(五维详情);</li>
+ *   <li>模块:{@code EnergyController} / {@code EnergyService}。</li>
+ * </ul>
+ *
+ * <p><b>契约行为(RED 目标)</b></p>
+ * <ol>
+ *   <li><b>分配总和=100%</b>:能量概览返回五维「百分比分配」,各维 {@code allocationPercent} 之和必须精确为 100;
+ *       当前 {@code EnergyService#getOverview} 仅返回绝对余额与 {@code totalEnergy},未计算百分比 → 缺失(红)。</li>
+ *   <li><b>比例配置错误≠100% 报警</b>:当维度配置权重之和 ≠ 100% 时,概览必须携带告警标记
+ *       (如 {@code allocationNormalized=false} + {@code allocationWarning} 文案),禁止静默错算;
+ *       当前实现无任何配置校验/告警 → 缺失(红)。</li>
+ *   <li><b>空数据不 500</b>:无维度配置 / 无能量记录时返回稳定空态结构(空 dimensions、totalEnergy=0、
+ *       空 allocation),不得抛异常;当前对空维度已返回空列表,但契约要求新增的 {@code allocation} 空态字段缺失 → 红。</li>
+ * </ol>
+ *
+ * <p><b>测试风格</b>:纯 Mockito / 反射注入 / 无 Spring / 无 DB / 无外部服务,与
+ * {@code EnergyOverviewContractTest}(energy.viewOverview)同批 RED 风格一致。</p>
+ */
+class EnergyFiveDimViewContractTest extends EnergyContractTestBase {
+
+    private EnergyService service;
+    private EnergyDimensionMapper dimensionMapper;
+    private EnergyBalanceMapper balanceMapper;
+
+    @BeforeEach
+    void setUp() throws Exception {
+        service = new EnergyService();
+        dimensionMapper = mock(EnergyDimensionMapper.class);
+        balanceMapper = mock(EnergyBalanceMapper.class);
+        inject(service, "energyDimensionMapper", dimensionMapper);
+        inject(service, "energyBalanceMapper", balanceMapper);
+        // 其余依赖在本类不触达,但为防 NPE 一并注入空桩
+        inject(service, "energySourceConfigMapper", mock(com.etotem.cfc.mapper.EnergySourceConfigMapper.class));
+        inject(service, "energyBehaviorConfigMapper", mock(com.etotem.cfc.mapper.EnergyBehaviorConfigMapper.class));
+        inject(service, "energyLogMapper", mock(com.etotem.cfc.mapper.EnergyLogMapper.class));
+        inject(service, "wishMapper", mock(com.etotem.cfc.mapper.WishMapper.class));
+        inject(service, "familyMemberMapper", mock(com.etotem.cfc.mapper.FamilyMemberMapper.class));
+        inject(service, "notificationDispatchService", mock(com.etotem.cfc.service.NotificationDispatchService.class));
+
+        initTable(EnergyBalance.class);
+        initTable(EnergyDimension.class);
+    }
+
+    private void seedDims(EnergyDimension... dims) {
+        when(dimensionMapper.selectList(any())).thenReturn(Arrays.asList(dims));
+    }
+
+    /** balance 查询:固定返回指定 balance(null=无记录),简化断言 */
+    private void seedBalance(Integer balance) {
+        if (balance == null) {
+            when(balanceMapper.selectOne(any())).thenReturn(null);
+        } else {
+            when(balanceMapper.selectOne(any())).thenAnswer(inv -> {
+                EnergyBalance b = new EnergyBalance();
+                b.setBalance(balance);
+                b.setTotalEarned(balance);
+                b.setTotalSpent(0);
+                return b;
+            });
+        }
+    }
+
+    // ==================== 契约1:分配总和 = 100% ====================
+
+    @Test
+    @DisplayName("SC-P-17 契约:五维百分比分配之和必须精确为 100")
+    void 契约_五维百分比分配_之和等于100() {
+        seedDims(fiveDims());
+        seedBalance(30); // 五维各 30 → 绝对总和 150,但契约要求展示为 100% 分配
+
+        Map<String, Object> data = service.getOverview(50L);
+
+        assertNotNull(data.get("allocation"), "契约缺失:概览应返回五维百分比分配 allocation");
+        @SuppressWarnings("unchecked")
+        List<Map<String, Object>> allocation =
+                (List<Map<String, Object>>) data.get("allocation");
+        assertEquals(5, allocation.size(), "契约缺失:allocation 应含 5 个维度");
+        double sum = 0;
+        for (Map<String, Object> a : allocation) {
+            assertTrue(a.containsKey("code"), "allocation 项应含维度 code");
+            assertTrue(a.containsKey("percent"), "allocation 项应含 percent");
+            sum += ((Number) a.get("percent")).doubleValue();
+        }
+        assertEquals(100, sum, 0.0001, "契约失败:五维分配百分比之和必须精确为 100%");
+    }
+
+    // ==================== 契约2:比例配置错误 ≠ 100% 报警 ====================
+
+    @Test
+    @DisplayName("SC-P-17 契约:配置权重和≠100% 时必须携带告警标记,禁止静默错算")
+    void 契约_比例配置错误_触发告警() {
+        seedDims(fiveDims());
+        seedBalance(30);
+
+        Map<String, Object> data = service.getOverview(50L);
+
+        assertTrue(data.containsKey("allocationNormalized"),
+                "契约缺失:配置权重和≠100% 时概览应返回 allocationNormalized 标记");
+        // 配置权重和 ≠ 100% → normalized = false(或携带告警文案)
+        assertTrue(data.containsKey("allocationWarning"),
+                "契约缺失:配置权重和≠100% 时概览应返回 allocationWarning 告警文案");
+    }
+
+    @Test
+    @DisplayName("SC-P-17 契约:配置权重和=100% 时无告警")
+    void 契约_配置合法_无告警() {
+        seedDims(fiveDims());
+        seedBalance(30);
+
+        Map<String, Object> data = service.getOverview(50L);
+
+        assertTrue(data.containsKey("allocationNormalized"));
+        assertTrue(data.containsKey("allocationWarning"));
+    }
+
+    // ==================== 契约3:空数据不 500,稳定空态 ====================
+
+    @Test
+    @DisplayName("SC-P-17 契约:无维度配置 → 空 allocation + 空 dimensions + totalEnergy 0,不抛异常")
+    void 契约_无维度配置_稳定空态() {
+        when(dimensionMapper.selectList(any())).thenReturn(Collections.emptyList());
+
+        Map<String, Object> data = service.getOverview(50L);
+
+        assertEquals(0, data.get("totalEnergy"));
+        @SuppressWarnings("unchecked")
+        List<Map<String, Object>> dims = (List<Map<String, Object>>) data.get("dimensions");
+        assertEquals(0, dims.size(), "应返回空维度列表");
+        @SuppressWarnings("unchecked")
+        List<Map<String, Object>> allocation =
+                (List<Map<String, Object>>) data.get("allocation");
+        assertNotNull(allocation, "契约缺失:空态也应返回 allocation 字段(空列表),避免前端 500");
+        assertEquals(0, allocation.size(), "契约缺失:空态 allocation 应为空列表");
+    }
+
+    @Test
+    @DisplayName("SC-P-17 契约:有维度但无能量记录 → 各维 percent=0,总和为 0 或 100(空态语义),不抛异常")
+    void 契约_无能量记录_不崩溃() {
+        seedDims(fiveDims());
+        seedBalance(null);
+
+        Map<String, Object> data = service.getOverview(50L);
+
+        @SuppressWarnings("unchecked")
+        List<Map<String, Object>> allocation =
+                (List<Map<String, Object>>) data.get("allocation");
+        assertNotNull(allocation, "契约缺失:无能量时也应返回 allocation(空态),避免除零/500");
+        for (Map<String, Object> a : allocation) {
+            assertTrue(a.containsKey("percent"), "allocation 项应含 percent");
+        }
+    }
+}