|
|
@@ -0,0 +1,417 @@
|
|
|
+package com.etotem.cfc.unit;
|
|
|
+
|
|
|
+import org.junit.jupiter.api.Test;
|
|
|
+
|
|
|
+import java.lang.reflect.Method;
|
|
|
+import java.util.ArrayList;
|
|
|
+import java.util.HashMap;
|
|
|
+import java.util.List;
|
|
|
+import java.util.Map;
|
|
|
+
|
|
|
+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.assertTrue;
|
|
|
+import static org.junit.jupiter.api.Assertions.fail;
|
|
|
+
|
|
|
+/**
|
|
|
+ * 契约级单测:能力键 {@code diet.mealPlan.adjust}(食谱收藏 / 手动微调替换食材 / 营养对齐)。
|
|
|
+ *
|
|
|
+ * <p><b>需求/设计锚点</b></p>
|
|
|
+ * <ul>
|
|
|
+ * <li>SC-P-29(食谱收藏/调整)— 目标:沉淀常用食谱。流程:收藏食谱 → 手动微调(替换食材)。
|
|
|
+ * 边界:调整不破坏营养目标;重复收藏幂等仅 1 条(TC-P-29 / TC-P-29-E)。</li>
|
|
|
+ * <li>SC-P-30(食谱与营养目标对齐)— 目标:确保食谱达标。流程:看食谱营养合计 vs 目标。
|
|
|
+ * 边界:超标/不足提示;目标未设时应提示「请先设置营养目标」且不得除零(TC-P-30 / TC-P-30-E)。</li>
|
|
|
+ * <li>接口溯源:/api/diet/preferences/save(保存饮食偏好)、/api/diet/record/save(饮食记录)、
|
|
|
+ * /api/health/meal/weekly(营养汇总);业务域 C「五维能量与健康」→ 饮食推荐系统。</li>
|
|
|
+ * </ul>
|
|
|
+ *
|
|
|
+ * <p><b>被校验的契约行为</b></p>
|
|
|
+ * <ul>
|
|
|
+ * <li><b>收藏 / 取消收藏</b>(TC-P-29 / TC-P-29-E):{@code favorite / unfavorite / getFavoriteList}。
|
|
|
+ * 同一 (mealPlanId, memberId) 重复收藏幂等仅 1 条;按成员视角隔离,防横向越权(TC-P-29-P)。</li>
|
|
|
+ * <li><b>手动微调替换食材</b>(TC-P-29 「调整不破坏营养目标」):{@code adjustDish} 以新旧食材替换后
|
|
|
+ * 重算聚合营养合计(nutritionSummary),返回替换后的菜单与重算摘要,不允许静默丢失营养键。</li>
|
|
|
+ * <li><b>营养合计对齐评估</b>(TC-P-30 / TC-P-30-E):{@code alignNutrition} 对比营养合计 vs 目标,
|
|
|
+ * 返回 {status, percent, message};超标明确给出「超出目标 X%」、不足给「低于目标 X%」;
|
|
|
+ * 目标未设 → status="NO_TARGET" + 提示「请先设置营养目标」,percent 不得为 NaN/Infinity(不除零红线)。</li>
|
|
|
+ * </ul>
|
|
|
+ *
|
|
|
+ * <p><b>宿主契约(TDD-RED 锚点)</b>:{@code com.etotem.cfc.service.MealPlanService} ——
|
|
|
+ * SC-P-29/30 的 mealPlan 域在 cfclub 主线(2026-11-14)<b>尚未落地</b>(src/main 无该类、无对应端点;
|
|
|
+ * 「收藏」仅缺口 G-1 登记)。本测试通过反射锚定宿主契约方法(避免直接 import 不存在类导致编译失败),
|
|
|
+ * 运行时宿主类缺失即断言失败(红),暴露「实现未落地」这一真实缺陷;实现落地后转为验证真实行为(GREEN)。</p>
|
|
|
+ *
|
|
|
+ * <p><b>契约方法签名(实现方按此落地)</b></p>
|
|
|
+ * <ul>
|
|
|
+ * <li>{@code boolean favorite(Long mealPlanId, Long memberId)}
|
|
|
+ * —— 收藏食谱;新增成功返回 true;已存在(幂等仅 1 条)返回 false,不抛异常。</li>
|
|
|
+ * <li>{@code boolean unfavorite(Long mealPlanId, Long memberId)}
|
|
|
+ * —— 取消收藏;存在且移除成功返回 true;不存在返回 false。</li>
|
|
|
+ * <li>{@code List<Map<String,Object>> getFavoriteList(Long memberId)}
|
|
|
+ * —— 该成员收藏列表,每项 Map 至少含 {@code mealPlanId};维度须按 memberId 隔离。</li>
|
|
|
+ * <li>{@code Map<String,Object> adjustDish(Map<String,Object> request)}
|
|
|
+ * —— request 键:{@code mealPlanId / mealIndex / dishIndex / replacementFoodId /
|
|
|
+ * replacementFood(Map: foodId,name,calories,protein,fat,carbs…) / menu(menu_json 结构)}。
|
|
|
+ * 返回 {status:"OK", menu(替换后), nutritionSummary(重算聚合)};非法参数 → 抛异常或 status!="OK"。
|
|
|
+ * menu_json 结构沿用 4.11 登记格式:{meals:[{type,name,dishes:[{name,type,calories,
|
|
|
+ * ingredients:[{name,amount}]}]}]}。</li>
|
|
|
+ * <li>{@code Map<String,Object> alignNutrition(Map<String,Number> nutritionActual,
|
|
|
+ * Map<String,Number> target)}
|
|
|
+ * —— 营养合计 vs 目标;返回 {status:"ALIGNED"/"OVER"/"UNDER"/"NO_TARGET",
|
|
|
+ * percent:Number, message:String}。percent = (actual-target)/target×100,取整;
|
|
|
+ * target == null 或空 → NO_TARGET + 「请先设置营养目标」类 message,percent 仍须为合法 Number(非 NaN)。
|
|
|
+ * 营养键沿用 nutritionSummary 口径(calories/protein/fat/carbs…)。</li>
|
|
|
+ * </ul>
|
|
|
+ *
|
|
|
+ * <p><b>GREEN 升级指引</b>:实现者落地 {@code MealPlanService}(含收藏表或内存态 + adjustDish 纯函数 +
|
|
|
+ * alignNutrition 纯函数)后,全部用例自动转绿;{@link #hostClass_notYetImplemented()}
|
|
|
+ * 为 RED 守卫探针,宿主出现后该探针转红,提示通知编排侧将本键从「契约新增(RED)」登记转为「已实现(GREEN)」。</p>
|
|
|
+ *
|
|
|
+ * <p><b>测试范围</b>:纯契约级单测,无 Spring / 无 DB / 无外部服务,与同批 RED 契约测试
|
|
|
+ * (TeamProfitHookContractTest / TeamStarMultiplierContractTest / TeamProfitPipelineContractTest /
|
|
|
+ * FamilyJoinValidationRulesContractTest)同风格。</p>
|
|
|
+ *
|
|
|
+ * <p><b>RED 预期</b>:宿主 {@code MealPlanService} 未实现,全部用例在反射探测处失败(红),
|
|
|
+ * 断言原文为「契约宿主 com.etotem.cfc.service.MealPlanService 未实现(实现未落地)」。</p>
|
|
|
+ */
|
|
|
+class DietMealPlanAdjustContractTest {
|
|
|
+
|
|
|
+ /** 规格设计宿主类(尚未实现)。 */
|
|
|
+ private static final String HOST_CLASS = "com.etotem.cfc.service.MealPlanService";
|
|
|
+
|
|
|
+ // ==================== 收藏 / 幂等(TC-P-29 / TC-P-29-E) ====================
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void favorite_firstCall_returnsTrue() {
|
|
|
+ Object host = contractHost();
|
|
|
+ assertTrue(callBoolean(host, "favorite", 1001L, 88L),
|
|
|
+ "[diet.mealPlan.adjust] 首次 favorite(1001,88) 应返回 true(新增收藏,TC-P-29 流程)");
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void favorite_duplicate_idempotent_returnsFalse() {
|
|
|
+ Object host = contractHost();
|
|
|
+ callBoolean(host, "favorite", 1002L, 88L);
|
|
|
+ assertFalse(callBoolean(host, "favorite", 1002L, 88L),
|
|
|
+ "[diet.mealPlan.adjust] 同一 (mealPlanId,memberId) 重复收藏应返回 false(幂等仅 1 条,TC-P-29-E)");
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void unfavorite_exists_returnsTrue() {
|
|
|
+ Object host = contractHost();
|
|
|
+ callBoolean(host, "favorite", 1003L, 88L);
|
|
|
+ assertTrue(callBoolean(host, "unfavorite", 1003L, 88L),
|
|
|
+ "[diet.mealPlan.adjust] 已收藏后 unfavorite(1003,88) 应返回 true(收藏已被移除,TC-P-29)");
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void unfavorite_notExists_returnsFalse() {
|
|
|
+ Object host = contractHost();
|
|
|
+ assertFalse(callBoolean(host, "unfavorite", 9999L, 88L),
|
|
|
+ "[diet.mealPlan.adjust] 未收藏的 (9999,88) 取消收藏应返回 false(不抛异常、不产生副作用)");
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void getFavoriteList_memberScopeIsolated() {
|
|
|
+ Object host = contractHost();
|
|
|
+ // 两个成员分别收藏不同食谱
|
|
|
+ callBoolean(host, "favorite", 1004L, 88L);
|
|
|
+ callBoolean(host, "favorite", 1005L, 77L);
|
|
|
+ List<Map<String, Object>> list = callListMap(host, "getFavoriteList", 88L);
|
|
|
+ assertNotNull(list, "getFavoriteList(88) 应返回列表(不得为 null)");
|
|
|
+ assertEquals(1, list.size(),
|
|
|
+ "[diet.mealPlan.adjust] getFavoriteList(88) 应仅含本人收藏 1 条(memberId 维度隔离,防横向越权 TC-P-29-P)");
|
|
|
+ Map<String, Object> first = list.get(0);
|
|
|
+ assertNotNull(first.get("mealPlanId"),
|
|
|
+ "收藏项 Map 应含 mealPlanId 字段(供前端展示/取消收藏定位)");
|
|
|
+ }
|
|
|
+
|
|
|
+ // ==================== 手动微调替换食材(TC-P-29 调整不破坏营养目标) ====================
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void adjustDish_replacesIngredientAndRecomputesNutritionSummary() {
|
|
|
+ Object host = contractHost();
|
|
|
+ Map<String, Object> request = new HashMap<>();
|
|
|
+ request.put("mealPlanId", 1006L);
|
|
|
+ request.put("mealIndex", 0);
|
|
|
+ request.put("dishIndex", 1);
|
|
|
+ request.put("replacementFoodId", 7001L);
|
|
|
+ request.put("replacementFood", replacementFood());
|
|
|
+ request.put("menu", sampleMenu());
|
|
|
+
|
|
|
+ Map<String, Object> result = callMap(host, "adjustDish", request);
|
|
|
+ assertNotNull(result, "adjustDish 应返回结果 Map");
|
|
|
+ assertEquals("OK", result.get("status"),
|
|
|
+ "adjustDish 替换成功应返回 status='OK'");
|
|
|
+ Object ns = result.get("nutritionSummary");
|
|
|
+ assertNotNull(ns,
|
|
|
+ "[diet.mealPlan.adjust] 替换食材后的 nutritionSummary 不得为 null(TC-P-29『调整不破坏营养目标』:"
|
|
|
+ + "必须基于替换后菜品集合重算聚合摘要,前端据此校验是否仍达标)");
|
|
|
+ assertTrue(ns instanceof Map && !((Map<?, ?>) ns).isEmpty(),
|
|
|
+ "nutritionSummary 应为非空 Map(含 calories/protein/fat 等聚合键,供对齐评估对齐 Nutrition 目标)");
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void adjustDish_missingReplacementFood_failsLoudly() {
|
|
|
+ Object host = contractHost();
|
|
|
+ Map<String, Object> request = new HashMap<>();
|
|
|
+ request.put("mealPlanId", 1006L);
|
|
|
+ request.put("mealIndex", 0);
|
|
|
+ request.put("dishIndex", 1);
|
|
|
+ // 缺 replacementFood / replacementFoodId —— 非法载荷
|
|
|
+ request.put("menu", sampleMenu());
|
|
|
+ try {
|
|
|
+ Map<String, Object> result = callMap(host, "adjustDish", request);
|
|
|
+ assertTrue(result == null || !"OK".equals(result.get("status")),
|
|
|
+ "[diet.mealPlan.adjust] 缺 replacementFood 的非法载荷应失败(抛异常或 status!='OK'),"
|
|
|
+ + "不允许静默以原食材被 null 顶替(破坏营养目标)");
|
|
|
+ } catch (RuntimeException e) {
|
|
|
+ assertNotNull(e.getMessage(), "抛出的异常必须带可读 message");
|
|
|
+ } catch (Exception e) {
|
|
|
+ fail("adjustDish 非法载荷应抛出可读异常,实际:" + e.getClass().getName());
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // ==================== 营养对齐评估(TC-P-30 / TC-P-30-E) ====================
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void alignNutrition_overTarget_statusOver_percentAndHint() {
|
|
|
+ Object host = contractHost();
|
|
|
+ Map<String, Number> actual = numMap("calories", 3000);
|
|
|
+ Map<String, Number> target = numMap("calories", 1000);
|
|
|
+ Map<String, Object> res = callMap(host, "alignNutrition", actual, target);
|
|
|
+ assertEquals("OVER", res.get("status"),
|
|
|
+ "[diet.mealPlan.adjust] 营养合计超目标应 status='OVER'(TC-P-30-E『超标明确标红提示』)");
|
|
|
+ assertEquals(200.0, num(res.get("percent")), 0.001,
|
|
|
+ "percent 应为 (3000-1000)/1000×100=200(TC-P-30-E『超出目标 X%』的 X)");
|
|
|
+ assertNotNull(res.get("message"));
|
|
|
+ assertTrue(String.valueOf(res.get("message")).contains("超出目标")
|
|
|
+ || String.valueOf(res.get("message")).contains("%"),
|
|
|
+ "message 应含『超出目标』或百分号,满足 TC-P-30-E 文案语义");
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void alignNutrition_underTarget_statusUnder_hint() {
|
|
|
+ Object host = contractHost();
|
|
|
+ Map<String, Number> actual = numMap("calories", 1000);
|
|
|
+ Map<String, Number> target = numMap("calories", 5000);
|
|
|
+ Map<String, Object> res = callMap(host, "alignNutrition", actual, target);
|
|
|
+ assertEquals("UNDER", res.get("status"),
|
|
|
+ "[diet.mealPlan.adjust] 营养合计低于目标应 status='UNDER'(TC-P-30『不足提示』分支)");
|
|
|
+ assertNotNull(res.get("message"));
|
|
|
+ assertTrue(String.valueOf(res.get("message")).contains("低于目标")
|
|
|
+ || String.valueOf(res.get("message")).contains("%"),
|
|
|
+ "不足分支 message 应含『低于目标』或百分号");
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void alignNutrition_noTarget_noDivideByZero_messageSaysSetTarget() {
|
|
|
+ Object host = contractHost();
|
|
|
+ Map<String, Number> actual = numMap("calories", 3000);
|
|
|
+ Map<String, Object> res = callMapWithNullTarget(host, "alignNutrition", actual);
|
|
|
+ assertEquals("NO_TARGET", res.get("status"),
|
|
|
+ "[diet.mealPlan.adjust] target 为 null 应 status='NO_TARGET'(目标未设时不强行计算,TC-P-30-E)");
|
|
|
+ String msg = String.valueOf(res.get("message"));
|
|
|
+ assertTrue(msg.contains("请先设置营养目标") || msg.contains("未设置营养目标")
|
|
|
+ || msg.contains("未设置"),
|
|
|
+ "message 应提示『请先设置营养目标』类文案(TC-P-30-E 硬性要求),实际:" + msg);
|
|
|
+ Object percent = res.get("percent");
|
|
|
+ assertNotNull(percent,
|
|
|
+ "NO_TARGET 分支 percent 键必须存在(字段语义稳定);取值不参与计算但不除零");
|
|
|
+ double pv = num(percent);
|
|
|
+ assertFalse(Double.isNaN(pv) || Double.isInfinite(pv),
|
|
|
+ "[diet.mealPlan.adjust] NO_TARGET 分支 percent 不得为 NaN/Infinity——契约红线:不除零(TC-P-30-E)。"
|
|
|
+ + "实际 percent=" + percent);
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void alignNutrition_alignedWithinTarget_statusAligned() {
|
|
|
+ Object host = contractHost();
|
|
|
+ Map<String, Number> actual = numMap("calories", 1000);
|
|
|
+ Map<String, Number> target = numMap("calories", 1000);
|
|
|
+ Map<String, Object> res = callMap(host, "alignNutrition", actual, target);
|
|
|
+ assertEquals("ALIGNED", res.get("status"),
|
|
|
+ "[diet.mealPlan.adjust] 营养合计等于目标应 status='ALIGNED'(达标态)");
|
|
|
+ }
|
|
|
+
|
|
|
+ // ==================== RED 守卫探针 ====================
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void hostClass_notYetImplemented() {
|
|
|
+ try {
|
|
|
+ Class.forName(HOST_CLASS);
|
|
|
+ fail("[diet.mealPlan.adjust] RED 契约宿主 " + HOST_CLASS
|
|
|
+ + " 已出现——本键实现已意外落地;请通知编排侧将此键从『契约新增(RED)』转为『实现(GREEN)』,"
|
|
|
+ + "并删除本探针后重验。");
|
|
|
+ } catch (ClassNotFoundException expected) {
|
|
|
+ // RED 预期:宿主缺失,主用例全部在此路径失败
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // ==================== fixture ====================
|
|
|
+
|
|
|
+ /** 替换食材(鸡胸肉)承载营养字段。 */
|
|
|
+ private static Map<String, Object> replacementFood() {
|
|
|
+ Map<String, Object> food = new HashMap<>();
|
|
|
+ food.put("foodId", 7001L);
|
|
|
+ food.put("name", "鸡胸肉");
|
|
|
+ food.put("calories", 165);
|
|
|
+ food.put("protein", 31);
|
|
|
+ food.put("fat", 3.6);
|
|
|
+ food.put("carbs", 0);
|
|
|
+ return food;
|
|
|
+ }
|
|
|
+
|
|
|
+ /** menu_json 结构(4.11 登记格式):meals → dishes → ingredients。 */
|
|
|
+ private static Map<String, Object> sampleMenu() {
|
|
|
+ List<Map<String, Object>> ingredients0 = new ArrayList<>();
|
|
|
+ Map<String, Object> ing0 = new HashMap<>();
|
|
|
+ ing0.put("name", "猪肉");
|
|
|
+ ing0.put("amount", "150g");
|
|
|
+ ingredients0.add(ing0);
|
|
|
+
|
|
|
+ List<Map<String, Object>> ingredients1 = new ArrayList<>();
|
|
|
+ Map<String, Object> ing1 = new HashMap<>();
|
|
|
+ ing1.put("name", "鸡蛋");
|
|
|
+ ing1.put("amount", "2个");
|
|
|
+ ingredients1.add(ing1);
|
|
|
+
|
|
|
+ Map<String, Object> dish0 = new HashMap<>();
|
|
|
+ dish0.put("name", "红烧肉");
|
|
|
+ dish0.put("type", "dish");
|
|
|
+ dish0.put("calories", 450);
|
|
|
+ dish0.put("ingredients", ingredients0);
|
|
|
+
|
|
|
+ Map<String, Object> dish1 = new HashMap<>();
|
|
|
+ dish1.put("name", "番茄炒蛋");
|
|
|
+ dish1.put("type", "dish");
|
|
|
+ dish1.put("calories", 180);
|
|
|
+ dish1.put("ingredients", ingredients1);
|
|
|
+
|
|
|
+ List<Map<String, Object>> dishes = new ArrayList<>();
|
|
|
+ dishes.add(dish0);
|
|
|
+ dishes.add(dish1);
|
|
|
+
|
|
|
+ Map<String, Object> meal = new HashMap<>();
|
|
|
+ meal.put("type", "lunch");
|
|
|
+ meal.put("name", "午餐");
|
|
|
+ meal.put("dishes", dishes);
|
|
|
+
|
|
|
+ List<Map<String, Object>> meals = new ArrayList<>();
|
|
|
+ meals.add(meal);
|
|
|
+
|
|
|
+ Map<String, Object> menu = new HashMap<>();
|
|
|
+ menu.put("meals", meals);
|
|
|
+ return menu;
|
|
|
+ }
|
|
|
+
|
|
|
+ private static Map<String, Number> numMap(String key, int v) {
|
|
|
+ Map<String, Number> m = new HashMap<>();
|
|
|
+ m.put(key, v);
|
|
|
+ return m;
|
|
|
+ }
|
|
|
+
|
|
|
+ private static double num(Object o) {
|
|
|
+ assertTrue(o instanceof Number, "期望 Number,实际 " + (o == null ? "null" : o.getClass().getSimpleName()));
|
|
|
+ return ((Number) o).doubleValue();
|
|
|
+ }
|
|
|
+
|
|
|
+ // ==================== 反射辅助(锚定宿主契约,避免编译期依赖不存在类) ====================
|
|
|
+
|
|
|
+ private Object contractHost() {
|
|
|
+ try {
|
|
|
+ Class<?> clazz = Class.forName(HOST_CLASS);
|
|
|
+ return clazz.getDeclaredConstructor().newInstance();
|
|
|
+ } catch (ClassNotFoundException e) {
|
|
|
+ fail("RED[diet.mealPlan.adjust] 契约宿主 " + HOST_CLASS + " 未实现(实现未落地):" + e.getMessage());
|
|
|
+ throw new IllegalStateException("unreachable");
|
|
|
+ } catch (Exception e) {
|
|
|
+ fail("契约宿主 " + HOST_CLASS + " 实例化失败:" + e.getMessage());
|
|
|
+ throw new IllegalStateException("unreachable");
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 按方法名 + 参数个数匹配并调用(宽松适配基本/包装类型),契约方法缺失即断言失败。 */
|
|
|
+ private Object call(Object host, String method, Object... args) {
|
|
|
+ for (Method m : host.getClass().getMethods()) {
|
|
|
+ if (m.getName().equals(method) && m.getParameterCount() == args.length) {
|
|
|
+ try {
|
|
|
+ return m.invoke(host, args);
|
|
|
+ } catch (Exception e) {
|
|
|
+ fail("契约方法 " + HOST_CLASS + "#" + method
|
|
|
+ + "(" + paramTypesDesc(args) + ") 调用失败:" + rootMessage(e));
|
|
|
+ throw new IllegalStateException("unreachable");
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ fail("RED[diet.mealPlan.adjust] 契约方法 " + HOST_CLASS + "#" + method
|
|
|
+ + "(" + paramTypesDesc(args) + ") 未实现");
|
|
|
+ throw new IllegalStateException("unreachable");
|
|
|
+ }
|
|
|
+
|
|
|
+ private boolean callBoolean(Object host, String method, Object... args) {
|
|
|
+ Object r = call(host, method, args);
|
|
|
+ if (r instanceof Boolean) {
|
|
|
+ return (Boolean) r;
|
|
|
+ }
|
|
|
+ fail("契约方法 " + method + "() 应返回布尔,实际 " + r + "(" + typeOf(r) + ")");
|
|
|
+ throw new IllegalStateException("unreachable");
|
|
|
+ }
|
|
|
+
|
|
|
+ @SuppressWarnings("unchecked")
|
|
|
+ private Map<String, Object> callMap(Object host, String method, Object... args) {
|
|
|
+ Object r = call(host, method, args);
|
|
|
+ if (r instanceof Map) {
|
|
|
+ return (Map<String, Object>) r;
|
|
|
+ }
|
|
|
+ fail("契约方法 " + method + "() 应返回 Map,实际 " + r + "(" + typeOf(r) + ")");
|
|
|
+ throw new IllegalStateException("unreachable");
|
|
|
+ }
|
|
|
+
|
|
|
+ /** alignNutrition 双参版第二参为 null 时的显式调用(避免 varargs 把 (actual, null) 折叠成 1 参)。 */
|
|
|
+ private Map<String, Object> callMapWithNullTarget(Object host, String method, Object actual) {
|
|
|
+ Object[] args = new Object[]{actual, null};
|
|
|
+ Object r = call(host, method, args);
|
|
|
+ if (r instanceof Map) {
|
|
|
+ return (Map<String, Object>) r;
|
|
|
+ }
|
|
|
+ fail("契约方法 " + method + "() 应返回 Map,实际 " + r + "(" + typeOf(r) + ")");
|
|
|
+ throw new IllegalStateException("unreachable");
|
|
|
+ }
|
|
|
+
|
|
|
+ @SuppressWarnings("unchecked")
|
|
|
+ private List<Map<String, Object>> callListMap(Object host, String method, Object... args) {
|
|
|
+ Object r = call(host, method, args);
|
|
|
+ if (r instanceof List) {
|
|
|
+ return (List<Map<String, Object>>) r;
|
|
|
+ }
|
|
|
+ fail("契约方法 " + method + "() 应返回 List<Map>,实际 " + r + "(" + typeOf(r) + ")");
|
|
|
+ throw new IllegalStateException("unreachable");
|
|
|
+ }
|
|
|
+
|
|
|
+ private static String rootMessage(Throwable t) {
|
|
|
+ Throwable cur = t;
|
|
|
+ while (cur.getCause() != null) {
|
|
|
+ cur = cur.getCause();
|
|
|
+ }
|
|
|
+ return cur.getMessage() == null ? cur.getClass().getSimpleName() : cur.getMessage();
|
|
|
+ }
|
|
|
+
|
|
|
+ private static String typeOf(Object o) {
|
|
|
+ return o == null ? "null" : o.getClass().getSimpleName();
|
|
|
+ }
|
|
|
+
|
|
|
+ private static String paramTypesDesc(Object[] args) {
|
|
|
+ StringBuilder sb = new StringBuilder();
|
|
|
+ for (int i = 0; i < args.length; i++) {
|
|
|
+ if (i > 0) {
|
|
|
+ sb.append(", ");
|
|
|
+ }
|
|
|
+ Object a = args[i];
|
|
|
+ sb.append(a == null ? "null" : a.getClass().getSimpleName());
|
|
|
+ }
|
|
|
+ return sb.toString();
|
|
|
+ }
|
|
|
+}
|