|
|
@@ -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");
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|