Browse Source

test(T02): 固化 20 条语义用例 + 时序/对照接口单测

T02 验收标准第 7 条为阻塞项(语义方向回归),全部固化为可重复执行的单测:

- IndicatorChangeServiceSemanticTest(新增):20 条语义用例 semantic01..semantic20,
  覆盖全部 5 个 trendCode 分支 + in_range_is_better 四分支 + 微小基数护栏 +
  基数为 0 + 单位不一致 + 非数值 + 跨期跳比。
- IndicatorTimelineServiceTest(新增):chartMode 在 n=0/1/2/5/25 下的
  empty/single/pair/line/line_aggregated;pairCompare 参数校验与排序;
  status=0 剔除;deltaPct=null 恒定排最后。
- HealthReportServiceIndicatorsTest(新增):报告指标三档形态(有上期/首次记录/跨期跳比)
  + 42 项指标 SQL ≤ 5 次(无 N+1)。
- IndicatorTrendServiceTest(改):适配新签名,覆盖 reportDate=null 不被当作最新值、
  status=0 剔除、magnitude 按 |Δ%| 排序且 null 排最后。
Sisyphus 2 weeks ago
parent
commit
9f6f18c8c9

+ 252 - 0
cfc-backend/src/test/java/com/etotem/cfc/service/HealthReportServiceIndicatorsTest.java

@@ -0,0 +1,252 @@
+package com.etotem.cfc.service;
+
+import com.baomidou.mybatisplus.core.MybatisConfiguration;
+import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
+import com.etotem.cfc.entity.HealthIndicator;
+import com.etotem.cfc.entity.HealthReport;
+import com.etotem.cfc.entity.IndicatorDefinition;
+import com.etotem.cfc.entity.IndicatorValue;
+import com.etotem.cfc.mapper.HealthReportMapper;
+import com.etotem.cfc.mapper.IndicatorValueMapper;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.apache.ibatis.builder.MapperBuilderAssistant;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.test.util.ReflectionTestUtils;
+
+import java.math.BigDecimal;
+import java.sql.Date;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyCollection;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.*;
+
+/**
+ * 报告指标接口(§4.1 /api/health/report/indicators)服务层单测。
+ *
+ * <p>覆盖验收标准:
+ * <ul>
+ *   <li>#1:三档形态数据齐全(有上一期 / 首次记录 / 历史数据整理中)</li>
+ *   <li>#6:单报告 42 项指标时 SQL 查询次数 ≤ 5(无 N+1)</li>
+ * </ul>
+ */
+class HealthReportServiceIndicatorsTest {
+
+    private HealthReportService service;
+    private HealthReportMapper healthReportMapper;
+    private IndicatorMappingService mappingService;
+    private IndicatorChangeService changeService;
+    private IndicatorValueMapper valueMapper;
+
+    @BeforeEach
+    void setUp() {
+        TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), ""),
+                HealthReport.class);
+        TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), ""),
+                IndicatorValue.class);
+
+        service = new HealthReportService();
+        healthReportMapper = mock(HealthReportMapper.class);
+        mappingService = mock(IndicatorMappingService.class);
+        IndicatorValueMapper valueMapper = mock(IndicatorValueMapper.class);
+        // 真实 changeService(computeChange 走真实计算)+ spy 拦截 batchResolve 的查库
+        IndicatorChangeService realChange = new IndicatorChangeService();
+        ReflectionTestUtils.setField(realChange, "valueMapper", valueMapper);
+        changeService = spy(realChange);
+
+        ReflectionTestUtils.setField(service, "healthReportMapper", healthReportMapper);
+        ReflectionTestUtils.setField(service, "mappingService", mappingService);
+        ReflectionTestUtils.setField(service, "changeService", changeService);
+        ReflectionTestUtils.setField(service, "objectMapper", new ObjectMapper());
+    }
+
+    // ---------- 工具 ----------
+
+    private HealthReport report() {
+        HealthReport r = new HealthReport();
+        r.setId(12345L);
+        r.setSubjectId(88L);
+        r.setReportType("gut_flora");
+        r.setReportDate(Date.valueOf("2026-10-08"));
+        r.setStatus("active");
+        r.setPayloadJson("{\"indicators\":[{\"indicatorName\":\"低密度脂蛋白 LDL-C\"},"
+                + "{\"indicatorName\":\"白细胞计数\"},{\"indicatorName\":\"未知指标 X\"}]}");
+        return r;
+    }
+
+    private HealthIndicator indicator(Long id, String name, String value, String status) {
+        HealthIndicator hi = new HealthIndicator();
+        hi.setId(id);
+        hi.setReportId(12345L);
+        hi.setIndicatorName(name);
+        hi.setIndicatorValue(value);
+        hi.setStatus(status);
+        hi.setUnit("mmol/L");
+        hi.setCategory("血脂");
+        hi.setRefRange("0.00-3.37");
+        return hi;
+    }
+
+    private IndicatorMappingService.ResolvedMapping resolved(String rawName, Long defId, String defName) {
+        IndicatorMappingService.ResolvedMapping rm = new IndicatorMappingService.ResolvedMapping();
+        rm.setRawName(rawName);
+        rm.setDefinitionId(defId);
+        rm.setDefId(defId);
+        rm.setDefName(defName);
+        rm.setDefGoodDirection("lower_is_better");
+        rm.setDefValueType("numeric");
+        rm.setDefUnit("mmol/L");
+        rm.setDefRefRange("0.00-3.37");
+        rm.setDefStatus(1);
+        rm.setRuleStatus(1);
+        rm.setPriority(0);
+        return rm;
+    }
+
+    @SuppressWarnings("unchecked")
+    private List<Map<String, Object>> itemsOf(Map<String, Object> data) {
+        return (List<Map<String, Object>>) data.get("items");
+    }
+
+    // ==================== #1 三档形态 ====================
+
+    @Test
+    void reportIndicators_threeTiersPresent() {
+        HealthReport r = report();
+        List<HealthIndicator> indicators = Arrays.asList(
+                indicator(9001L, "低密度脂蛋白 LDL-C", "3.62", "偏高"),   // 有上一期 → improved
+                indicator(9002L, "白细胞计数", "5.20", "正常"));          // 无上一期 + 有更早报告 → pending_backfill
+
+        // 映射:只有 LDL 命中字典,白细胞未命中(definitionId=null)
+        Map<String, IndicatorMappingService.ResolvedMapping> resolvedMap = new HashMap<>();
+        resolvedMap.put("低密度脂蛋白 LDL-C", resolved("低密度脂蛋白 LDL-C", 512L, "低密度脂蛋白 LDL-C"));
+        when(mappingService.resolveBatch(anyString(), anyCollection())).thenReturn(resolvedMap);
+
+        // 上一期:LDL 有上一期值;白细胞无
+        List<IndicatorValue> prevRows = new ArrayList<>();
+        IndicatorValue prev = new IndicatorValue();
+        prev.setDefinitionId(512L);
+        prev.setReportId(11800L);
+        prev.setReportDate(Date.valueOf("2026-08-12"));
+        prev.setValue("4.10");
+        prev.setNumericValue(new BigDecimal("4.10"));
+        prev.setUnit("mmol/L");
+        prev.setStatus("偏高");
+        prevRows.add(prev);
+        when(changeService.batchResolve(any(), any(), any(), any())).thenReturn(
+                new HashMap<Long, IndicatorValue>() {{ put(512L, prev); }});
+
+        // 更早报告 3 份 → olderReportCount=3(无上一期 → pending_backfill)
+        List<HealthReport> older = new ArrayList<>();
+        for (int i = 0; i < 3; i++) {
+            HealthReport o = new HealthReport();
+            o.setId(11000L + i);
+            o.setReportDate(Date.valueOf("2026-0" + (i + 1) + "-01"));
+            older.add(o);
+        }
+        when(healthReportMapper.selectList(any())).thenReturn(older);
+
+        Map<String, Object> data = service.getReportIndicatorsWithPrevious(r, indicators, true);
+
+        assertEquals("2026-10-08", data.get("reportDate"));
+        assertEquals(3L, ((Number) data.get("olderReportCount")).longValue());
+
+        List<Map<String, Object>> items = itemsOf(data);
+        assertEquals(2, items.size());
+
+        // 档 A:有上一期 → improved
+        Map<String, Object> ldl = items.get(0);
+        assertEquals(512L, ((Number) ldl.get("definitionId")).longValue());
+        assertNotNull(ldl.get("previous"));
+        @SuppressWarnings("unchecked")
+        Map<String, Object> ldlChange = (Map<String, Object>) ldl.get("change");
+        assertEquals("improved", ldlChange.get("trendCode"));
+        assertEquals("改善", ldlChange.get("trendLabel"));
+        assertEquals(-0.48, ((BigDecimal) ldlChange.get("delta")).doubleValue(), 1e-9);
+        assertEquals(true, ldlChange.get("comparable"));
+
+        // 档 B2:无上一期 + 有更早报告 → pending_backfill(诚实降级,不是"首次记录")
+        Map<String, Object> wbc = items.get(1);
+        assertNull(wbc.get("definitionId"), "未命中字典的指标 definitionId 为 null");
+        assertNull(wbc.get("previous"));
+        assertNull(wbc.get("change"), "未命中字典时无对比对象");
+
+        // summary
+        @SuppressWarnings("unchecked")
+        Map<String, Object> summary = (Map<String, Object>) data.get("summary");
+        assertEquals(2, ((Number) summary.get("totalCount")).intValue());
+        assertEquals(1, ((Number) summary.get("improvedCount")).intValue());
+        assertEquals(1, ((Number) summary.get("abnormalCount")).intValue());
+    }
+
+    @Test
+    void reportIndicators_firstRecordWhenNoOlderReports() {
+        HealthReport r = report();
+        r.setPayloadJson("{\"indicators\":[{\"indicatorName\":\"载脂蛋白 A1\"}]}");
+        List<HealthIndicator> indicators = Arrays.asList(
+                indicator(9002L, "载脂蛋白 A1", "1.45", "正常"));
+
+        Map<String, IndicatorMappingService.ResolvedMapping> resolvedMap = new HashMap<>();
+        resolvedMap.put("载脂蛋白 A1", resolved("载脂蛋白 A1", 513L, "载脂蛋白 A1"));
+        when(mappingService.resolveBatch(anyString(), anyCollection())).thenReturn(resolvedMap);
+        when(changeService.batchResolve(any(), any(), any(), any())).thenReturn(new HashMap<>());
+        // 无更早报告 → olderReportCount=0 → first_record
+        when(healthReportMapper.selectList(any())).thenReturn(new ArrayList<>());
+
+        Map<String, Object> data = service.getReportIndicatorsWithPrevious(r, indicators, true);
+        Map<String, Object> item = itemsOf(data).get(0);
+        @SuppressWarnings("unchecked")
+        Map<String, Object> change = (Map<String, Object>) item.get("change");
+        assertEquals("first_record", change.get("trendCode"));
+        assertEquals("首次记录 · 暂无对比", change.get("trendLabel"));
+        assertEquals("none", change.get("movement"));
+        assertNull(item.get("previous"));
+        assertNotNull(change, "B1 档的 change 也必须是非 null 对象(前端只消费 trendCode)");
+
+        @SuppressWarnings("unchecked")
+        Map<String, Object> summary = (Map<String, Object>) data.get("summary");
+        assertEquals(1, ((Number) summary.get("firstRecordCount")).intValue());
+        assertEquals(0, ((Number) summary.get("pendingCount")).intValue());
+        assertEquals(false, summary.get("dataIncomplete"));
+    }
+
+    // ==================== #6 SQL ≤ 5 ====================
+
+    @Test
+    void reportIndicators_sqlCountIsConstant_noNPlusOne() {
+        HealthReport r = report();
+        // 构造 42 项指标(单报告指标数实测上限约 60)
+        List<HealthIndicator> indicators = new ArrayList<>();
+        Map<String, IndicatorMappingService.ResolvedMapping> resolvedMap = new HashMap<>();
+        for (int i = 0; i < 42; i++) {
+            String name = "指标" + i;
+            indicators.add(indicator(9000L + i, name, "1.00", "正常"));
+            resolvedMap.put(name, resolved(name, 500L + i, name));
+        }
+        when(mappingService.resolveBatch(anyString(), anyCollection())).thenReturn(resolvedMap);
+        when(changeService.batchResolve(any(), any(), any(), any())).thenReturn(new HashMap<>());
+        when(healthReportMapper.selectList(any())).thenReturn(new ArrayList<>());
+
+        service.getReportIndicatorsWithPrevious(r, indicators, true);
+
+        // 服务层(报告与指标行由控制器加载后传入)固定 3 次查询:
+        //   ① health_reports(olderReportCount)
+        //   ② indicator_mapping_rules + indicator_definitions 联合查询(resolveBatch)
+        //   ③ indicator_values(batchResolve 上一期)
+        verify(healthReportMapper, times(1)).selectList(any());
+        verify(mappingService, times(1)).resolveBatch(anyString(), anyCollection());
+        verify(changeService, times(1)).batchResolve(any(), any(), any(), any());
+        // 绝不能逐指标查库:42 项指标只允许 1 次 indicator_values 查询
+        verify(valueMapper, times(1)).selectList(any());
+
+        assertEquals(42, itemsOf(service.getReportIndicatorsWithPrevious(r, indicators, true)).size(),
+                "42 项指标必须全部返回");
+    }
+}

+ 419 - 0
cfc-backend/src/test/java/com/etotem/cfc/service/IndicatorChangeServiceSemanticTest.java

@@ -0,0 +1,419 @@
+package com.etotem.cfc.service;
+
+import com.baomidou.mybatisplus.core.MybatisConfiguration;
+import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
+import com.etotem.cfc.entity.IndicatorDefinition;
+import com.etotem.cfc.entity.IndicatorValue;
+import com.etotem.cfc.mapper.IndicatorValueMapper;
+import org.apache.ibatis.builder.MapperBuilderAssistant;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.test.util.ReflectionTestUtils;
+
+import java.math.BigDecimal;
+import java.sql.Date;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+/**
+ * 指标变化语义回归测试(T02 验收标准第 7 条 · §1.2.3 的 20 条语义用例)。
+ *
+ * <p><b>为什么必须固化</b>:`goodDirection` / `refRange` 是<b>字典配置驱动</b>的,
+ * 后续运营改字典会静默改变判定结果。一次性人工核验挡不住回归 ——
+ * 本文件是这 20 条的可重复执行版本,任何对 {@code computeChange} 或字典的改动都会跑它。
+ *
+ * <p><b>覆盖矩阵</b>:
+ * <ul>
+ *   <li>全部 5 个 trendCode 分支:improved / worsened / flat / first_record / pending_backfill</li>
+ *   <li>in_range 四分支:区外→区内(improved) / 区内→区外(worsened) / 都在区内(flat) / 都在区外(靠近=improved)</li>
+ *   <li>微小基数(deltaPct=null 但 delta 有值)</li>
+ *   <li>基数为 0(deltaPct=null)</li>
+ *   <li>单位跨期不一致(deltaPct=null + unitMismatch=true)</li>
+ *   <li>非数值(movement=changed/unchanged,trendCode 恒 flat,不给颜色)</li>
+ *   <li>跨期跳比(skipGaps=2)与相邻两期(skipGaps=0)</li>
+ * </ul>
+ *
+ * <p><b>放行标准</b>:20 条全绿。方向判反(把恶化标成改善)0 容忍。
+ */
+class IndicatorChangeServiceSemanticTest {
+
+    private IndicatorChangeService service;
+    private IndicatorValueMapper valueMapper;
+
+    @BeforeEach
+    void setUp() {
+        // LambdaQueryWrapper.select(SFunction) 需要实体元数据(MP 3.5.3 lambda cache),
+        // 无 Spring 容器时必须手动初始化,否则抛 "can not find lambda cache for this entity"
+        TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), ""),
+                IndicatorValue.class);
+        service = new IndicatorChangeService();
+        valueMapper = mock(IndicatorValueMapper.class);
+        ReflectionTestUtils.setField(service, "valueMapper", valueMapper);
+    }
+
+    // ---------- 工具 ----------
+
+    private IndicatorValue num(String val, String unit, String status, String refSnapshot) {
+        IndicatorValue v = new IndicatorValue();
+        v.setValue(val);
+        v.setNumericValue(new BigDecimal(val));
+        v.setUnit(unit);
+        v.setStatus(status);
+        v.setRefRangeSnapshot(refSnapshot);
+        v.setReportDate(Date.valueOf("2026-10-01"));
+        return v;
+    }
+
+    private IndicatorValue text(String val, String status) {
+        IndicatorValue v = new IndicatorValue();
+        v.setValue(val);
+        v.setNumericValue(null);
+        v.setStatus(status);
+        v.setReportDate(Date.valueOf("2026-10-01"));
+        return v;
+    }
+
+    private IndicatorDefinition def(String goodDirection, String valueType, String refRange, String unit) {
+        IndicatorDefinition d = new IndicatorDefinition();
+        d.setGoodDirection(goodDirection);
+        d.setValueType(valueType);
+        d.setRefRange(refRange);
+        d.setUnit(unit);
+        return d;
+    }
+
+    // ==================================================================
+    // 语义用例 01-05:明确 goodDirection 的方向判定(改善 / 恶化)
+    // ==================================================================
+
+    /**
+     * 语义 01:LDL-C 降 → improved(lower_is_better)。
+     */
+    @Test
+    void semantic01_ldlC_decrease_isImproved() {
+        IndicatorDefinition d = def("lower_is_better", "numeric", "0.00-3.37", "mmol/L");
+        IndicatorChangeService.ChangeResult r = service.computeChange(d,
+                num("4.10", "mmol/L", "偏高", "0.00-3.37"),
+                num("3.62", "mmol/L", "偏高", "0.00-3.37"));
+        assertEquals("improved", r.getTrendCode());
+        assertEquals("改善", r.getTrendLabel());
+        assertEquals("down", r.getMovement());
+        assertEquals(-0.48, r.getDelta().doubleValue(), 1e-9);
+        assertEquals(-11.7, r.getDeltaPct().doubleValue(), 1e-9);
+        assertTrue(r.isComparable());
+    }
+
+    /**
+     * 语义 02:HDL-C 升 → improved(higher_is_better)。
+     */
+    @Test
+    void semantic02_hdlC_increase_isImproved() {
+        IndicatorDefinition d = def("higher_is_better", "numeric", "1.00-1.60", "mmol/L");
+        IndicatorChangeService.ChangeResult r = service.computeChange(d,
+                num("1.20", "mmol/L", "正常", "1.00-1.60"),
+                num("1.45", "mmol/L", "正常", "1.00-1.60"));
+        assertEquals("improved", r.getTrendCode());
+        assertEquals("改善", r.getTrendLabel());
+        assertEquals("up", r.getMovement());
+    }
+
+    /**
+     * 语义 03:血糖 升 → worsened(lower_is_better)。
+     */
+    @Test
+    void semantic03_glucose_increase_isWorsened() {
+        IndicatorDefinition d = def("lower_is_better", "numeric", "3.90-6.10", "mmol/L");
+        IndicatorChangeService.ChangeResult r = service.computeChange(d,
+                num("5.20", "mmol/L", "正常", "3.90-6.10"),
+                num("6.50", "mmol/L", "偏高", "3.90-6.10"));
+        assertEquals("worsened", r.getTrendCode());
+        assertEquals("恶化", r.getTrendLabel());
+        assertEquals("up", r.getMovement());
+    }
+
+    /**
+     * 语义 04:higher_is_better 指标下降 → worsened(反向分支,防止字典方向写反)。
+     */
+    @Test
+    void semantic04_higherIsBetter_decrease_isWorsened() {
+        IndicatorDefinition d = def("higher_is_better", "numeric", "1.00-5.00", "U");
+        IndicatorChangeService.ChangeResult r = service.computeChange(d,
+                num("4.00", "U", "正常", "1.00-5.00"),
+                num("2.00", "U", "偏低", "1.00-5.00"));
+        assertEquals("worsened", r.getTrendCode());
+        assertEquals("恶化", r.getTrendLabel());
+        assertEquals("down", r.getMovement());
+    }
+
+    /**
+     * 语义 05:同型半胱氨酸 降 → improved(lower_is_better)。
+     */
+    @Test
+    void semantic05_homocysteine_decrease_isImproved() {
+        IndicatorDefinition d = def("lower_is_better", "numeric", "<15.0", "μmol/L");
+        IndicatorChangeService.ChangeResult r = service.computeChange(d,
+                num("12.0", "μmol/L", "正常", "<15.0"),
+                num("9.8", "μmol/L", "正常", "<15.0"));
+        assertEquals("improved", r.getTrendCode());
+        assertEquals("改善", r.getTrendLabel());
+        assertEquals("down", r.getMovement());
+    }
+
+    // ==================================================================
+    // 语义用例 06-10:in_range_is_better 四分支(修 PRD S10:不再恒为中性)
+    // ==================================================================
+
+    /**
+     * 语义 06:BMI 区间内移动 → flat(in_range 四分支之"都在区内")。
+     */
+    @Test
+    void semantic06_bmi_moveWithinRange_isFlat() {
+        IndicatorDefinition d = def("in_range_is_better", "numeric", "18.50-24.00", "kg/m²");
+        IndicatorChangeService.ChangeResult r = service.computeChange(d,
+                num("22.00", "kg/m²", "正常", "18.50-24.00"),
+                num("22.60", "kg/m²", "正常", "18.50-24.00"));
+        assertEquals("flat", r.getTrendCode());
+        assertEquals("基本持平", r.getTrendLabel());
+        assertEquals("up", r.getMovement());
+    }
+
+    /**
+     * 语义 07:BMI 入区间 → improved(in_range 四分支之"区外→区内")。
+     */
+    @Test
+    void semantic07_bmi_enterRange_isImproved() {
+        IndicatorDefinition d = def("in_range_is_better", "numeric", "18.50-24.00", "kg/m²");
+        IndicatorChangeService.ChangeResult r = service.computeChange(d,
+                num("25.50", "kg/m²", "偏高", "18.50-24.00"),
+                num("23.20", "kg/m²", "正常", "18.50-24.00"));
+        assertEquals("improved", r.getTrendCode());
+        assertEquals("改善", r.getTrendLabel());
+    }
+
+    /**
+     * 语义 08:BMI 出区间 → worsened(in_range 四分支之"区内→区外")。
+     */
+    @Test
+    void semantic08_bmi_exitRange_isWorsened() {
+        IndicatorDefinition d = def("in_range_is_better", "numeric", "18.50-24.00", "kg/m²");
+        IndicatorChangeService.ChangeResult r = service.computeChange(d,
+                num("23.20", "kg/m²", "正常", "18.50-24.00"),
+                num("25.50", "kg/m²", "偏高", "18.50-24.00"));
+        assertEquals("worsened", r.getTrendCode());
+        assertEquals("恶化", r.getTrendLabel());
+    }
+
+    /**
+     * 语义 09:白细胞 升 → worsened(in_range,出区间上界)。
+     */
+    @Test
+    void semantic09_whiteBloodCell_exitAboveUpper_isWorsened() {
+        IndicatorDefinition d = def("in_range_is_better", "numeric", "3.50-9.50", "10^9/L");
+        IndicatorChangeService.ChangeResult r = service.computeChange(d,
+                num("8.20", "10^9/L", "正常", "3.50-9.50"),
+                num("11.30", "10^9/L", "偏高", "3.50-9.50"));
+        assertEquals("worsened", r.getTrendCode());
+        assertEquals("恶化", r.getTrendLabel());
+        assertEquals("up", r.getMovement());
+    }
+
+    /**
+     * 语义 10:in_range 始终在区外但靠近区间 → improved(in_range 四分支之"都在区外·靠近")。
+     */
+    @Test
+    void semantic10_inRange_alwaysOutside_closer_isImproved() {
+        IndicatorDefinition d = def("in_range_is_better", "numeric", "18.50-24.00", "kg/m²");
+        IndicatorChangeService.ChangeResult r = service.computeChange(d,
+                num("26.50", "kg/m²", "偏高", "18.50-24.00"),
+                num("25.00", "kg/m²", "偏高", "18.50-24.00"));
+        assertEquals("improved", r.getTrendCode());
+        assertEquals("改善", r.getTrendLabel());
+    }
+
+    // ==================================================================
+    // 语义用例 11-14:降级路径(微小基数 / 基数为 0 / 单位不一致 / 非数值)
+    // ==================================================================
+
+    /**
+     * 语义 11:微小基数(0.01→0.05)→ deltaPct=null(排最后),但 delta 照常有值。
+     */
+    @Test
+    void semantic11_tinyBase_deltaPctNull_butDeltaPresent() {
+        // refHigh=3.37 → floor=0.0337;before=0.01 低于护栏 → 百分比不适用
+        IndicatorDefinition d = def("higher_is_better", "numeric", "0.00-3.37", "mmol/L");
+        IndicatorChangeService.ChangeResult r = service.computeChange(d,
+                num("0.01", "mmol/L", "正常", "0.00-3.37"),
+                num("0.05", "mmol/L", "正常", "0.00-3.37"));
+        assertNull(r.getDeltaPct(), "微小基数的百分比必须为 null(前端显示 —)");
+        assertNotNull(r.getDelta(), "微小基数的 Δ 绝对值必须保留(用户仍需知道涨跌)");
+        assertEquals(0.04, r.getDelta().doubleValue(), 1e-9);
+        assertEquals("up", r.getMovement(), "箭头方向不受 FLOOR 影响");
+        assertNull(service.rankKeyOf(r), "deltaPct=null → 排序键为 null → 恒排最后");
+    }
+
+    /**
+     * 语义 12:基数为 0 → deltaPct=null。
+     */
+    @Test
+    void semantic12_zeroBase_deltaPctNull() {
+        IndicatorDefinition d = def("lower_is_better", "numeric", "0.00-3.37", "mmol/L");
+        IndicatorChangeService.ChangeResult r = service.computeChange(d,
+                num("0.00", "mmol/L", "正常", "0.00-3.37"),
+                num("1.00", "mmol/L", "正常", "0.00-3.37"));
+        assertNull(r.getDeltaPct(), "基数为 0 时百分比无意义");
+        assertEquals(1.0, r.getDelta().doubleValue(), 1e-9);
+        assertEquals("up", r.getMovement());
+        assertNull(service.rankKeyOf(r));
+    }
+
+    /**
+     * 语义 13:单位跨期不一致 → deltaPct=null + unitMismatch=true(delta 保留)。
+     */
+    @Test
+    void semantic13_unitMismatch_deltaPctNull_unitMismatchTrue() {
+        IndicatorDefinition d = def("higher_is_better", "numeric", "1.00-5.00", "mg/L");
+        IndicatorChangeService.ChangeResult r = service.computeChange(d,
+                num("3.00", "mg/L", "正常", "1.00-5.00"),
+                num("3.50", "g/L", "正常", "1.00-5.00"));
+        assertTrue(r.isUnitMismatch(), "单位跨期不一致必须被识别");
+        assertNull(r.getDeltaPct(), "单位不一致时百分比不可比");
+        assertEquals(0.50, r.getDelta().doubleValue(), 1e-9, "Δ 绝对值仍保留");
+    }
+
+    /**
+     * 语义 14:非数值(舌象薄白→厚腻)→ movement=changed + trendCode=flat(不给颜色)。
+     */
+    @Test
+    void semantic14_nonNumeric_changed_trendCodeFlat_noColor() {
+        IndicatorDefinition d = def(null, "categorical", null, null);
+        IndicatorChangeService.ChangeResult r = service.computeChange(d,
+                text("薄白", "正常"), text("厚腻", "注意"));
+        assertEquals("changed", r.getMovement());
+        assertEquals("flat", r.getTrendCode(), "非数值永不标改善/恶化(合规红线)");
+        assertFalse(r.isComparable());
+        assertNull(r.getDelta());
+        assertNull(r.getDeltaPct());
+        assertEquals("有变化", r.getTrendLabel());
+    }
+
+    // ==================================================================
+    // 语义用例 15-16:非数值同值 / 持平阈值
+    // ==================================================================
+
+    /**
+     * 语义 15:非数值同值 → movement=unchanged + trendCode=flat。
+     */
+    @Test
+    void semantic15_nonNumeric_unchanged_movementUnchanged() {
+        IndicatorDefinition d = def(null, "categorical", null, null);
+        IndicatorChangeService.ChangeResult r = service.computeChange(d,
+                text("薄白", "正常"), text("薄白", "正常"));
+        assertEquals("unchanged", r.getMovement());
+        assertEquals("flat", r.getTrendCode());
+        assertFalse(r.isComparable());
+        assertEquals("无变化", r.getTrendLabel());
+    }
+
+    /**
+     * 语义 16:持平阈值(|Δ%| < 2% 且状态未变)→ flat + "基本持平"(箭头保留)。
+     */
+    @Test
+    void semantic16_belowFlatThreshold_isFlat_butKeepsArrow() {
+        IndicatorDefinition d = def("higher_is_better", "numeric", "1.00-100.00", "U");
+        IndicatorChangeService.ChangeResult r = service.computeChange(d,
+                num("100.0", "U", "正常", "1.00-100.00"),
+                num("101.0", "U", "正常", "1.00-100.00"));
+        assertEquals("flat", r.getTrendCode());
+        assertEquals("基本持平", r.getTrendLabel());
+        assertEquals("up", r.getMovement(), "持平仍保留箭头方向");
+        assertNotNull(r.getDeltaPct());
+    }
+
+    // ==================================================================
+    // 语义用例 17-18:无上一期两档(first_record / pending_backfill)
+    // ==================================================================
+
+    /**
+     * 语义 17:无上一期 + 无更早报告 → first_record。
+     */
+    @Test
+    void semantic17_noPrevious_noOlderReport_firstRecord() {
+        IndicatorChangeService.ChangeResult r = service.computeChange(null, null,
+                num("1.00", "U", "正常", null), 0);
+        assertEquals("first_record", r.getTrendCode());
+        assertEquals("首次记录 · 暂无对比", r.getTrendLabel());
+        assertEquals("none", r.getMovement());
+        assertFalse(r.isComparable());
+        assertNull(r.getDelta());
+        assertNull(r.getDeltaPct());
+    }
+
+    /**
+     * 语义 18:无上一期 + 有 3 份更早报告 → pending_backfill(诚实降级,不是"首次记录")。
+     */
+    @Test
+    void semantic18_noPrevious_has3OlderReports_pendingBackfill() {
+        IndicatorChangeService.ChangeResult r = service.computeChange(null, null,
+                num("1.00", "U", "正常", null), 3);
+        assertEquals("pending_backfill", r.getTrendCode());
+        assertEquals("历史数据整理中,暂无法对比", r.getTrendLabel());
+        assertEquals("none", r.getMovement());
+        assertFalse(r.isComparable());
+    }
+
+    // ==================================================================
+    // 语义用例 19-20:跨期跳比(skipGaps)
+    // ==================================================================
+
+    /**
+     * 语义 19:跨期跳比(中间缺 2 期)→ skipGaps=2。
+     */
+    @Test
+    void semantic19_crossPeriodSkip_twoMissingPeriods_skipGaps2() {
+        Date from = Date.valueOf("2026-03-14");
+        Date to = Date.valueOf("2026-10-08");
+        // 全部报告期:03-14 / 05-02 / 07-15 / 08-12 / 10-08(中间缺 05-02 与 07-15)
+        List<IndicatorValue> allPeriods = new ArrayList<>();
+        allPeriods.add(dateOnly(from));
+        allPeriods.add(dateOnly(Date.valueOf("2026-05-02")));
+        allPeriods.add(dateOnly(Date.valueOf("2026-07-15")));
+        allPeriods.add(dateOnly(Date.valueOf("2026-08-12")));
+        allPeriods.add(dateOnly(to));
+        // 该指标在中间有值的期:只有 08-12(1 期)→ reportsBetween=3, obsBetween=1 → skipGaps=2
+        List<IndicatorValue> obsBetween = new ArrayList<>();
+        obsBetween.add(dateOnly(Date.valueOf("2026-08-12")));
+        when(valueMapper.selectList(any())).thenReturn(allPeriods, obsBetween);
+
+        long skipGaps = service.computeSkipGaps(88L, "gut_flora", 512L, from, to);
+        assertEquals(2L, skipGaps);
+    }
+
+    /**
+     * 语义 20:相邻两期(中间无缺期)→ skipGaps=0。
+     */
+    @Test
+    void semantic20_adjacentPeriods_skipGaps0() {
+        Date from = Date.valueOf("2026-08-12");
+        Date to = Date.valueOf("2026-10-08");
+        List<IndicatorValue> allPeriods = new ArrayList<>();
+        allPeriods.add(dateOnly(from));
+        allPeriods.add(dateOnly(to));
+        List<IndicatorValue> obsBetween = new ArrayList<>();
+        when(valueMapper.selectList(any())).thenReturn(allPeriods, obsBetween);
+
+        long skipGaps = service.computeSkipGaps(88L, "gut_flora", 512L, from, to);
+        assertEquals(0L, skipGaps);
+    }
+
+    private IndicatorValue dateOnly(Date d) {
+        IndicatorValue v = new IndicatorValue();
+        v.setReportDate(d);
+        return v;
+    }
+}

+ 351 - 0
cfc-backend/src/test/java/com/etotem/cfc/service/IndicatorTimelineServiceTest.java

@@ -0,0 +1,351 @@
+package com.etotem.cfc.service;
+
+import com.baomidou.mybatisplus.core.MybatisConfiguration;
+import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
+import com.etotem.cfc.entity.HealthReport;
+import com.etotem.cfc.entity.IndicatorDefinition;
+import com.etotem.cfc.entity.IndicatorValue;
+import com.etotem.cfc.mapper.HealthReportMapper;
+import com.etotem.cfc.mapper.IndicatorDefinitionMapper;
+import com.etotem.cfc.mapper.IndicatorValueMapper;
+import org.apache.ibatis.builder.MapperBuilderAssistant;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.test.util.ReflectionTestUtils;
+
+import java.math.BigDecimal;
+import java.sql.Date;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+/**
+ * 指标时序服务单测(T02 · §5.1),用于验收标准:
+ * <ul>
+ *   <li>#2:chartMode n=0/1/2/5/25 → empty/single/pair/line/line_aggregated</li>
+ *   <li>#3:scope=pair 双期对比、缺参错误、magnitude 按 |Δ%| 降序(nulls last)</li>
+ *   <li>#8:status=0 定义在 trend/pairCompare 中被剔除</li>
+ *   <li>#9:deltaPct=null 的项恒定排最后</li>
+ * </ul>
+ */
+class IndicatorTimelineServiceTest {
+
+    private IndicatorTimelineService service;
+    private IndicatorValueMapper valueMapper;
+    private IndicatorDefinitionMapper definitionMapper;
+    private HealthReportMapper healthReportMapper;
+
+    @BeforeEach
+    void setUp() {
+        // LambdaQueryWrapper.select(SFunction) 需要实体元数据(MP 3.5.3 lambda cache)
+        TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), ""),
+                IndicatorValue.class);
+        TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), ""),
+                IndicatorDefinition.class);
+        TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), ""),
+                HealthReport.class);
+
+        service = new IndicatorTimelineService();
+        valueMapper = mock(IndicatorValueMapper.class);
+        definitionMapper = mock(IndicatorDefinitionMapper.class);
+        healthReportMapper = mock(HealthReportMapper.class);
+
+        ReflectionTestUtils.setField(service, "valueMapper", valueMapper);
+        ReflectionTestUtils.setField(service, "definitionMapper", definitionMapper);
+        ReflectionTestUtils.setField(service, "healthReportMapper", healthReportMapper);
+
+        // 真实 changeService,注入同一组 mock mapper(保证单测不碰库)
+        IndicatorChangeService changeService = new IndicatorChangeService();
+        ReflectionTestUtils.setField(changeService, "valueMapper", valueMapper);
+        ReflectionTestUtils.setField(changeService, "definitionMapper", definitionMapper);
+        ReflectionTestUtils.setField(service, "changeService", changeService);
+    }
+
+    // ---------- 工具 ----------
+
+    private IndicatorDefinition def(Long id, String name, String goodDirection, String valueType,
+                                    String unit, String refRange) {
+        IndicatorDefinition d = new IndicatorDefinition();
+        d.setId(id);
+        d.setName(name);
+        d.setGoodDirection(goodDirection);
+        d.setValueType(valueType);
+        d.setUnit(unit);
+        d.setRefRange(refRange);
+        d.setStatus(1);
+        return d;
+    }
+
+    private IndicatorDefinition defStatus0(Long id, String name) {
+        IndicatorDefinition d = def(id, name, "higher_is_better", "numeric", "U", "1.00-5.00");
+        d.setStatus(0);
+        return d;
+    }
+
+    private IndicatorValue obs(Long defId, Long reportId, String dateStr, String val) {
+        IndicatorValue v = new IndicatorValue();
+        v.setDefinitionId(defId);
+        v.setReportId(reportId);
+        v.setReportDate(dateStr == null ? null : Date.valueOf(dateStr));
+        v.setValue(val);
+        // 非数值(如舌象「薄白」)→ numericValue 为 null
+        v.setNumericValue(toNumeric(val));
+        v.setUnit("mmol/L");
+        v.setStatus("正常");
+        v.setSourceType("gut_flora");
+        v.setDateSource("real");
+        return v;
+    }
+
+    /** 构造日期从早到晚的 n 条观测(同一个定义) */
+    private List<IndicatorValue> obsSeries(Long defId, String... dates) {
+        List<IndicatorValue> list = new ArrayList<>();
+        int i = 0;
+        for (String d : dates) {
+            list.add(obs(defId, 10000L + i, d, String.valueOf(10.0 + i * 0.5)));
+            i++;
+        }
+        return list;
+    }
+
+    private void mockDefs(IndicatorDefinition... defs) {
+        when(definitionMapper.selectList(any())).thenReturn(Arrays.asList(defs));
+    }
+
+    private void mockReports(String... dates) {
+        List<HealthReport> list = new ArrayList<>();
+        int i = 0;
+        for (String d : dates) {
+            HealthReport r = new HealthReport();
+            r.setId(1000L + i);
+            r.setReportDate(d == null ? null : Date.valueOf(d));
+            list.add(r);
+            i++;
+        }
+        when(healthReportMapper.selectList(any())).thenReturn(list);
+    }
+
+    private HealthReport report(Long id, String dateStr) {
+        HealthReport r = new HealthReport();
+        r.setId(id);
+        r.setReportDate(dateStr == null ? null : Date.valueOf(dateStr));
+        r.setReportType("gut_flora");
+        r.setStatus("active");
+        r.setSubjectId(88L);
+        return r;
+    }
+
+    // ==================== #2 chartMode ====================
+
+    @Test
+    void chartMode_n0_empty() {
+        // 无观测 → items 为空
+        when(valueMapper.selectList(any())).thenReturn(new ArrayList<>());
+        mockReports();
+        Map<String, Object> data = service.timeline(88L, Arrays.asList(512L), "gut_flora",
+                null, null, "raw", "all");
+        List<?> items = (List<?>) data.get("items");
+        assertTrue(items.isEmpty());
+    }
+
+    @Test
+    void chartMode_n1_single() {
+        IndicatorDefinition d = def(512L, "LDL-C", "lower_is_better", "numeric", "mmol/L", "0.00-3.37");
+        when(valueMapper.selectList(any())).thenReturn(obsSeries(512L, "2026-10-08"));
+        mockDefs(d);
+        mockReports("2026-10-08");
+        Map<String, Object> data = service.timeline(88L, Arrays.asList(512L), "gut_flora",
+                null, null, "raw", "all");
+        List<Map<String, Object>> items = castItems(data);
+        assertEquals(1, items.size());
+        assertEquals("single", items.get(0).get("chartMode"));
+    }
+
+    @Test
+    void chartMode_n2_pair() {
+        IndicatorDefinition d = def(512L, "LDL-C", "lower_is_better", "numeric", "mmol/L", "0.00-3.37");
+        when(valueMapper.selectList(any())).thenReturn(obsSeries(512L, "2026-08-12", "2026-10-08"));
+        mockDefs(d);
+        mockReports("2026-08-12", "2026-10-08");
+        Map<String, Object> data = service.timeline(88L, Arrays.asList(512L), "gut_flora",
+                null, null, "raw", "all");
+        List<Map<String, Object>> items = castItems(data);
+        assertEquals(1, items.size());
+        assertEquals("pair", items.get(0).get("chartMode"));
+    }
+
+    @Test
+    void chartMode_n5_line() {
+        IndicatorDefinition d = def(512L, "LDL-C", "lower_is_better", "numeric", "mmol/L", "0.00-3.37");
+        when(valueMapper.selectList(any())).thenReturn(obsSeries(512L,
+                "2026-03-14", "2026-05-02", "2026-07-15", "2026-08-12", "2026-10-08"));
+        mockDefs(d);
+        mockReports("2026-03-14", "2026-05-02", "2026-07-15", "2026-08-12", "2026-10-08");
+        Map<String, Object> data = service.timeline(88L, Arrays.asList(512L), "gut_flora",
+                null, null, "raw", "all");
+        List<Map<String, Object>> items = castItems(data);
+        assertEquals(1, items.size());
+        assertEquals("line", items.get(0).get("chartMode"));
+    }
+
+    @Test
+    void chartMode_n25_lineAggregated() {
+        IndicatorDefinition d = def(512L, "LDL-C", "lower_is_better", "numeric", "mmol/L", "0.00-3.37");
+        List<String> dates = new ArrayList<>();
+        List<IndicatorValue> rows = new ArrayList<>();
+        for (int i = 0; i < 25; i++) {
+            String dateStr = String.format("2026-%02d-%02d", 1 + i % 12, 1 + i % 28);
+            dates.add(dateStr);
+            rows.add(obs(512L, 10000L + i, dateStr, String.valueOf(10.0 + i * 0.1)));
+        }
+        when(valueMapper.selectList(any())).thenReturn(rows);
+        mockDefs(d);
+        mockReports(dates.toArray(new String[0]));
+        Map<String, Object> data = service.timeline(88L, Arrays.asList(512L), "gut_flora",
+                null, null, "raw", "all");
+        List<Map<String, Object>> items = castItems(data);
+        assertEquals(1, items.size());
+        assertEquals("line_aggregated", items.get(0).get("chartMode"));
+    }
+
+    // ==================== #3 scope=pair ====================
+
+    @Test
+    void pairCompareMissingParams_throws() {
+        assertThrows(IllegalArgumentException.class,
+                () -> service.pairCompare(88L, "gut_flora", null, "2026-10-08",
+                        null, "magnitude", "all", 1, 50));
+        assertThrows(IllegalArgumentException.class,
+                () -> service.pairCompare(88L, "gut_flora", "2026-03-14", null,
+                        null, "magnitude", "all", 1, 50));
+    }
+
+    @Test
+    void pairCompare_incomparableItemsIncludedAndMagnitudeNullsLast() {
+        IndicatorDefinition ldl = def(512L, "LDL-C", "lower_is_better", "numeric", "mmol/L", "0.00-3.37");
+        IndicatorDefinition tongue = def(777L, "舌苔厚薄", null, "categorical", null, null);
+        IndicatorDefinition onlyAfter = def(513L, "仅末期", "higher_is_better", "numeric", "U", null);
+        IndicatorDefinition onlyBefore = def(515L, "仅首期", "higher_is_better", "numeric", "U", null);
+
+        Long fromId = 10001L;
+        Long toId = 12345L;
+        List<IndicatorValue> rows = new ArrayList<>();
+        rows.add(obs(512L, fromId, "2026-03-14", "4.10"));   // from
+        rows.add(obs(512L, toId, "2026-10-08", "3.62"));     // to → LDL delta -0.48, deltaPct -11.7% → improved
+        rows.add(obs(512L, 10500L, "2026-05-02", "4.00"));   // between(有值)
+        rows.add(obs(512L, 10600L, "2026-07-15", "4.00"));   // between
+        rows.add(obs(512L, 10700L, "2026-08-12", "4.00"));   // between
+        rows.add(obs(777L, fromId, "2026-03-14", "薄白"));   // categorical 舌苔
+        rows.add(obs(777L, toId, "2026-10-08", "厚腻"));     // categorical 舌苔 → incomparable(非数值)
+        rows.add(obs(513L, toId, "2026-10-08", "1.00"));     // 仅末期有值 → incomparable
+        rows.add(obs(515L, fromId, "2026-03-14", "2.00"));   // 仅首期有值 → incomparable
+        when(valueMapper.selectList(any())).thenReturn(rows);
+        mockDefs(ldl, tongue, onlyAfter, onlyBefore);
+
+        // findReportByDate 先查 from 再查 to → 按调用顺序返回
+        when(healthReportMapper.selectList(any())).thenReturn(
+                Arrays.asList(report(fromId, "2026-03-14")),
+                Arrays.asList(report(toId, "2026-10-08")));
+
+        Map<String, Object> data = service.pairCompare(88L, "gut_flora",
+                "2026-03-14", "2026-10-08", null, "magnitude", "all", 1, 50);
+
+        List<Map<String, Object>> items = castItems(data);
+        // 4 个定义均应出现在 items 中(含不可比项)
+        assertEquals(4, items.size());
+        // magnitude 降序:|Δ%| 有值的在前,null 恒定排最后
+        assertEquals("LDL-C", items.get(0).get("name"));
+        assertEquals("improved", items.get(0).get("trendCode"));
+        assertEquals(-11.7, ((BigDecimal) items.get(0).get("deltaPct")).doubleValue(), 1e-9);
+        assertEquals(0L, ((Number) items.get(0).get("skipGaps")).longValue());
+        // 其余 3 项不可比(deltaPct=null),稳定排序保持相对位置
+        for (int i = 1; i < items.size(); i++) {
+            assertNull(items.get(i).get("deltaPct"), items.get(i).get("name") + " 的 deltaPct 应为 null");
+            assertFalse(Boolean.TRUE.equals(items.get(i).get("comparable")),
+                    items.get(i).get("name") + " 应不可比");
+        }
+        // from / to 回显带 indicatorCount(from 期 3 项,to 期 3 项)
+        Map<?, ?> fromMeta = (Map<?, ?>) data.get("from");
+        Map<?, ?> toMeta = (Map<?, ?>) data.get("to");
+        assertEquals("2026-03-14", fromMeta.get("reportDate"));
+        assertEquals("2026-10-08", toMeta.get("reportDate"));
+        assertEquals(3, ((Number) fromMeta.get("indicatorCount")).intValue());
+        assertEquals(3, ((Number) toMeta.get("indicatorCount")).intValue());
+        @SuppressWarnings("unchecked")
+        Map<String, Object> overall = (Map<String, Object>) data.get("overall");
+        assertEquals(1, ((Number) overall.get("improvedCount")).intValue());
+        assertEquals(3, ((Number) overall.get("incomparableCount")).intValue());
+    }
+
+    @Test
+    void pairCompare_filterIncomparable_returnsOnlyIncomparable() {
+        IndicatorDefinition ldl = def(512L, "LDL-C", "lower_is_better", "numeric", "mmol/L", "0.00-3.37");
+        IndicatorDefinition tongue = def(777L, "舌苔厚薄", null, "categorical", null, null);
+
+        Long fromId = 10001L;
+        Long toId = 12345L;
+        List<IndicatorValue> rows = new ArrayList<>();
+        rows.add(obs(512L, fromId, "2026-03-14", "4.10"));
+        rows.add(obs(512L, toId, "2026-10-08", "3.62"));
+        rows.add(obs(777L, fromId, "2026-03-14", "薄白"));
+        rows.add(obs(777L, toId, "2026-10-08", "厚腻"));
+        when(valueMapper.selectList(any())).thenReturn(rows);
+        mockDefs(ldl, tongue);
+        when(healthReportMapper.selectList(any())).thenReturn(
+                Arrays.asList(report(fromId, "2026-03-14")),
+                Arrays.asList(report(toId, "2026-10-08")));
+
+        Map<String, Object> data = service.pairCompare(88L, "gut_flora",
+                "2026-03-14", "2026-10-08", null, "magnitude", "incomparable", 1, 50);
+        List<Map<String, Object>> items = castItems(data);
+        assertEquals(1, items.size());
+        assertEquals("舌苔厚薄", items.get(0).get("name"));
+        assertEquals("changed", items.get(0).get("movement"));
+        assertEquals("flat", items.get(0).get("trendCode"));
+    }
+
+    // ==================== #8 QP17 status=0 剔除 ====================
+
+    @Test
+    void trendExcludesStatus0Definitions() {
+        IndicatorDefinition active = def(512L, "活跃指标", "lower_is_better", "numeric", "mmol/L", null);
+        IndicatorDefinition dead = defStatus0(999L, "废弃指标");
+        // loadActiveDefinitions 只会返回 status=1 的定义 → 999 的观测必须被剔除
+        when(definitionMapper.selectList(any())).thenReturn(Arrays.asList(active));
+        List<IndicatorValue> rows = new ArrayList<>();
+        rows.add(obs(512L, 10000L, "2026-08-12", "10"));
+        rows.add(obs(512L, 10001L, "2026-10-08", "9"));
+        rows.add(obs(999L, 10000L, "2026-08-12", "5"));
+        rows.add(obs(999L, 10001L, "2026-10-08", "4"));
+        when(valueMapper.selectList(any())).thenReturn(rows);
+        mockReports("2026-08-12", "2026-10-08");
+
+        Map<String, Object> data = service.timeline(88L, Arrays.asList(512L, 999L), "gut_flora",
+                null, null, "raw", "all");
+        List<Map<String, Object>> items = castItems(data);
+        assertEquals(1, items.size());
+        assertEquals("活跃指标", items.get(0).get("name"));
+    }
+
+    @SuppressWarnings("unchecked")
+    private List<Map<String, Object>> castItems(Map<String, Object> data) {
+        return (List<Map<String, Object>>) data.get("items");
+    }
+
+    private BigDecimal toNumeric(String val) {
+        if (val == null) {
+            return null;
+        }
+        try {
+            return new BigDecimal(val);
+        } catch (NumberFormatException e) {
+            return null;
+        }
+    }
+}

+ 128 - 20
cfc-backend/src/test/java/com/etotem/cfc/service/IndicatorTrendServiceTest.java

@@ -24,21 +24,35 @@ class IndicatorTrendServiceTest {
     private IndicatorTrendService service;
     private IndicatorValueMapper valueMapper;
     private IndicatorDefinitionMapper definitionMapper;
+    private IndicatorDefinitionMapper changeDefinitionMapper;
+    private IndicatorTimelineService timelineService;
 
     @BeforeEach
     void setUp() {
         service = new IndicatorTrendService();
         valueMapper = mock(IndicatorValueMapper.class);
         ReflectionTestUtils.setField(service, "valueMapper", valueMapper);
-        // 计划测试漏加:实现均调用 definitionMapper.selectById,必须补 mock
         definitionMapper = mock(IndicatorDefinitionMapper.class);
         ReflectionTestUtils.setField(service, "definitionMapper", definitionMapper);
+
+        // 真实 IndicatorChangeService(Q17 后改走 changeService.loadActiveDefinitions),
+        // 注入 mock mapper 保证单测不碰库
+        IndicatorChangeService changeService = new IndicatorChangeService();
+        changeDefinitionMapper = mock(IndicatorDefinitionMapper.class);
+        ReflectionTestUtils.setField(changeService, "definitionMapper", changeDefinitionMapper);
+        ReflectionTestUtils.setField(changeService, "valueMapper", valueMapper);
         IndicatorDefinition def = new IndicatorDefinition();
+        def.setId(1L);
         def.setName("双歧杆菌属");
         def.setUnit("%");
         def.setCategory("肠道菌群");
         def.setGoodDirection("higher_is_better");
-        when(definitionMapper.selectById(any())).thenReturn(def);
+        def.setValueType("numeric");
+        when(changeDefinitionMapper.selectList(any())).thenReturn(new ArrayList<>(Arrays.asList(def)));
+        ReflectionTestUtils.setField(service, "changeService", changeService);
+
+        timelineService = mock(IndicatorTimelineService.class);
+        ReflectionTestUtils.setField(service, "timelineService", timelineService);
     }
 
     private IndicatorValue value(Long defId, String date, String val) {
@@ -47,25 +61,45 @@ class IndicatorTrendServiceTest {
         v.setReportDate(Date.valueOf(date));
         v.setValue(val);
         v.setNumericValue(new BigDecimal(val));
+        v.setUnit("%");
+        v.setReportId(100L);
         return v;
     }
 
+    /** report_date 为 null 的观测(用于验收标准 #4:null 日期不得被当成最新值) */
+    private IndicatorValue valueWithNullDate(Long defId, String val) {
+        IndicatorValue v = new IndicatorValue();
+        v.setDefinitionId(defId);
+        v.setReportDate(null);
+        v.setValue(val);
+        v.setNumericValue(new BigDecimal(val));
+        v.setUnit("%");
+        v.setReportId(101L);
+        return v;
+    }
+
+    private IndicatorDefinition def(Long id, String name, String goodDirection, String unit) {
+        IndicatorDefinition d = new IndicatorDefinition();
+        d.setId(id);
+        d.setName(name);
+        d.setUnit(unit);
+        d.setCategory("肠道菌群");
+        d.setGoodDirection(goodDirection);
+        d.setValueType("numeric");
+        d.setStatus(1);
+        return d;
+    }
+
     @Test
-    void trendReturnsAscendingPoints() {
-        List<IndicatorValue> rows = new ArrayList<>();
-        rows.add(value(1L, "2026-08-15", "10"));
-        rows.add(value(1L, "2026-10-01", "12.8"));
-        when(valueMapper.selectList(any())).thenReturn(rows);
+    void trendDelegatesToTimelineService() {
+        Map<String, Object> expected = new java.util.LinkedHashMap<>();
+        expected.put("items", new ArrayList<>());
+        when(timelineService.timeline(any(), any(), any(), any(), any(), any(), any()))
+                .thenReturn(expected);
 
-        Map<String, Object> data = service.trend(3L, Arrays.asList(1L), 2L);
-        List<?> items = (List<?>) data.get("items");
-        assertEquals(1, items.size());
-        Map<?, ?> item = (Map<?, ?>) items.get(0);
-        assertEquals("1", String.valueOf(item.get("definitionId")));
-        List<?> points = (List<?>) item.get("points");
-        assertEquals(2, points.size());
-        Map<?, ?> first = (Map<?, ?>) points.get(0);
-        assertEquals("2026-08-15", first.get("reportDate"));
+        Map<String, Object> data = service.trend(3L, Arrays.asList(1L), 2L, null, null, null, "raw", "all");
+        assertSame(expected, data);
+        verify(timelineService).timeline(eq(3L), eq(Arrays.asList(1L)), eq(null), eq(null), eq(null), eq("raw"), eq("all"));
     }
 
     @Test
@@ -76,25 +110,99 @@ class IndicatorTrendServiceTest {
         rows.add(value(1L, "2026-10-01", "12.8"));
         when(valueMapper.selectList(any())).thenReturn(rows);
 
-        java.util.Map<String, Object> data = service.changeRanking(3L, null, null, "adjacent", "magnitude", 50, 2L);
+        java.util.Map<String, Object> data = service.changeRanking(3L, null, null, "adjacent", "magnitude", 50, 2L,
+                null, null, null, null, null);
         List<?> items = (List<?>) data.get("items");
         assertEquals(1, items.size());
         Map<?, ?> item = (Map<?, ?>) items.get(0);
         assertEquals("up", item.get("movement"));
         assertEquals("改善", item.get("trendLabel"));
+        assertEquals("improved", item.get("trendCode"));
         Map<?, ?> overall = (Map<?, ?>) data.get("overall");
         assertEquals(1, ((Number) overall.get("improvedCount")).intValue());
     }
 
     @Test
-    void quickLookupReturnsItemsAndDimensions() {
+    void quickLookupReturnsItemsAndCategories() {
         List<IndicatorValue> rows = new ArrayList<>();
         rows.add(value(1L, "2026-10-01", "12.8"));
         when(valueMapper.selectList(any())).thenReturn(rows);
 
-        Map<String, Object> data = service.quickLookup(3L, null, 1, 30, 2L);
+        Map<String, Object> data = service.quickLookup(3L, null, 1, 30, 2L, false, 8);
         assertTrue(data.containsKey("items"));
         assertTrue(data.containsKey("categories"));
-        assertEquals(1, ((List<?>) data.get("items")).size());
+        List<?> items = (List<?>) data.get("items");
+        assertEquals(1, items.size());
+        Map<?, ?> item = (Map<?, ?>) items.get(0);
+        // reportDate=null 的观测被排在最后(修 bug)
+        assertEquals("2026-10-01", item.get("latestReportDate"));
+        assertNotNull(item.get("change"));
+    }
+
+    /** 验收标准 #4:quick-lookup 中 report_date=null 的观测不再被当成最新值 */
+    @Test
+    void quickLookup_nullReportDateNotTreatedAsLatest() {
+        List<IndicatorValue> rows = new ArrayList<>();
+        rows.add(valueWithNullDate(1L, "999"));      // null 日期 → 不参与最新值竞争
+        rows.add(value(1L, "2026-10-01", "12.8"));  // 有日期 → 最新
+        // 第一次 selectList 拉全部观测;resolvePrevious 的第二次 selectList 返回空(→ first_record)
+        when(valueMapper.selectList(any())).thenReturn(rows, new ArrayList<>());
+
+        Map<String, Object> data = service.quickLookup(3L, null, 1, 30, 2L, false, 8);
+        Map<?, ?> item = (Map<?, ?>) ((List<?>) data.get("items")).get(0);
+        assertEquals("2026-10-01", item.get("latestReportDate"), "null 日期观测不得排到最新");
+        assertEquals("12.8", item.get("value"), "最新值必须取有日期的观测");
+    }
+
+    /** 验收标准 #8:quickLookup 对 status=0 的 definition 一律剔除,不降级为「指标」+id 假名称 */
+    @Test
+    void quickLookup_excludesStatus0Definitions() {
+        IndicatorDefinition active = def(1L, "双歧杆菌属", "higher_is_better", "%");
+        IndicatorDefinition dead = def(2L, "废弃指标", "higher_is_better", "U");
+        dead.setStatus(0);
+        // loadActiveDefinitions 仅返回 status=1 的定义(模拟 DB 过滤)
+        when(changeDefinitionMapper.selectList(any())).thenReturn(new ArrayList<>(Arrays.asList(active)));
+
+        List<IndicatorValue> rows = new ArrayList<>();
+        rows.add(value(1L, "2026-10-01", "12.8"));
+        rows.add(value(2L, "2026-10-01", "5.5"));   // status=0 → 必须剔除
+        when(valueMapper.selectList(any())).thenReturn(rows, new ArrayList<>());
+
+        Map<String, Object> data = service.quickLookup(3L, null, 1, 30, 2L, false, 8);
+        List<?> items = (List<?>) data.get("items");
+        assertEquals(1, items.size(), "status=0 指标不得出现在结果中");
+        Map<?, ?> item = (Map<?, ?>) items.get(0);
+        assertEquals("双歧杆菌属", item.get("name"));
+        assertFalse(String.valueOf(item.get("name")).startsWith("指标"), "不得生成「指标」+id 的假名称");
+    }
+
+    /** 验收标准 #9:magnitude 排序中 deltaPct=null 的项(基数为 0)恒定排最后 */
+    @Test
+    void changeRanking_magnitudeDeltaPctNullAlwaysLast() {
+        // 三个定义:正常幅度 10%、轻微 1%、基数为 0(deltaPct=null)
+        IndicatorDefinition d1 = def(1L, "指标A", "higher_is_better", "%");
+        IndicatorDefinition d2 = def(2L, "指标B", "higher_is_better", "%");
+        IndicatorDefinition d3 = def(3L, "指标C", "higher_is_better", "%");
+        d3.setRefRange("0.00-1.00");
+        when(changeDefinitionMapper.selectList(any()))
+                .thenReturn(new ArrayList<>(Arrays.asList(d1, d2, d3)));
+
+        List<IndicatorValue> rows = new ArrayList<>();
+        rows.add(value(1L, "2026-08-01", "100"));
+        rows.add(value(1L, "2026-10-01", "110"));   // deltaPct = 10%
+        rows.add(value(2L, "2026-08-01", "100"));
+        rows.add(value(2L, "2026-10-01", "101"));   // deltaPct = 1%
+        rows.add(value(3L, "2026-08-01", "0"));
+        rows.add(value(3L, "2026-10-01", "5"));     // 基数为 0 → deltaPct=null
+        when(valueMapper.selectList(any())).thenReturn(rows);
+
+        Map<String, Object> data = service.changeRanking(3L, null, null, "adjacent", "magnitude", 50, 2L,
+                null, null, null, null, null);
+        List<Map<?, ?>> items = (List<Map<?, ?>>) data.get("items");
+        assertEquals(3, items.size());
+        assertEquals("指标A", items.get(0).get("name"), "|10%| 最大,应排第一");
+        assertEquals("指标B", items.get(1).get("name"), "|1%| 次之");
+        assertEquals("指标C", items.get(2).get("name"), "deltaPct=null 恒定排最后");
+        assertNull(items.get(2).get("deltaPct"), "基数为 0 的百分比必须为 null");
     }
 }