byCategory = BY_CATEGORY.get(categoryName);
+ if (byCategory != null) {
+ return byCategory;
+ }
+ }
+ return GENERIC;
+ }
+}
diff --git a/src/main/java/com/accounting/util/NutritionUnit.java b/src/main/java/com/accounting/util/NutritionUnit.java
new file mode 100644
index 0000000..3c4e20a
--- /dev/null
+++ b/src/main/java/com/accounting/util/NutritionUnit.java
@@ -0,0 +1,48 @@
+package com.accounting.util;
+
+import java.math.BigDecimal;
+import java.math.RoundingMode;
+
+/**
+ * 单位换算:食物表存的是千焦,界面看的是大卡。
+ *
+ * 换算系数取 1 kcal = 4.184 kJ(热化学卡定义)。这是食品标签的通行口径,
+ * 和《中国食物成分表》本身用的系数一致。
+ */
+public final class NutritionUnit {
+
+ private NutritionUnit() {
+ }
+
+ private static final BigDecimal KJ_PER_KCAL = new BigDecimal("4.184");
+
+ /**
+ * kJ → kcal,保留 1 位小数。
+ *
+ * 入参为 null(表里允许空值)时返回 null,让调用方自己决定退化成什么,
+ * 而不是在这里悄悄变成 0 —— 0 kcal 和「没测过」是两回事。
+ */
+ public static BigDecimal kjToKcal(BigDecimal kj) {
+ if (kj == null) {
+ return null;
+ }
+ return kj.divide(KJ_PER_KCAL, 1, RoundingMode.HALF_UP);
+ }
+
+ /**
+ * 按份量把「每 100g」的值换算成「这一份」的值。
+ *
+ * {@code grams} 为 0 或负数时返回 0 而不是抛异常:份量在界面里
+ * 是个输入框,用户可能清空,这种输入应该得到「零热量」而不是 500。
+ */
+ public static BigDecimal per100gToAmount(BigDecimal per100g, BigDecimal grams) {
+ if (per100g == null || grams == null) {
+ return BigDecimal.ZERO;
+ }
+ if (grams.signum() <= 0) {
+ return BigDecimal.ZERO;
+ }
+ return per100g.multiply(grams)
+ .divide(new BigDecimal("100"), 2, RoundingMode.HALF_UP);
+ }
+}
diff --git a/src/main/java/com/accounting/util/WeightPlanCalculator.java b/src/main/java/com/accounting/util/WeightPlanCalculator.java
new file mode 100644
index 0000000..73cb54c
--- /dev/null
+++ b/src/main/java/com/accounting/util/WeightPlanCalculator.java
@@ -0,0 +1,94 @@
+package com.accounting.util;
+
+import java.math.BigDecimal;
+import java.math.RoundingMode;
+import java.time.LocalDate;
+import java.time.temporal.ChronoUnit;
+
+/**
+ * 减重计划的计算:把「从 A 减到 B,用 N 天」拆成「每周该减多少」。
+ *
+ * 纯函数,不碰数据库 —— 目标是算术问题,单独放出来才好测边界。
+ */
+public final class WeightPlanCalculator {
+
+ private WeightPlanCalculator() {
+ }
+
+ /** 每周减重超过这个值就提示不健康(掉的多半是肌肉和水) */
+ public static final BigDecimal HEALTHY_WEEKLY_LOSS_KG = BigDecimal.ONE;
+
+ /** 一周按 7 天算 */
+ private static final BigDecimal DAYS_PER_WEEK = new BigDecimal("7");
+
+ /**
+ * 总周数(向上取整,至少 1 周)。
+ *
+ * 向上取整的理由:3 天也应该算「1 周内完成」而不是 0 周 ——
+ * 0 周会让「每周应减」除零。同日(0 天)也归到 1 周。
+ */
+ public static int totalWeeks(LocalDate from, LocalDate to) {
+ long days = ChronoUnit.DAYS.between(from, to);
+ if (days <= 0) {
+ return 1;
+ }
+ return (int) Math.ceil(days / 7.0);
+ }
+
+ /**
+ * 每周应减重 kg(保留 2 位小数)。
+ *
+ * 总减重为 0 或负数(目标比起始还大)时返回 0,不返回负数 ——
+ * 「增重计划」不是这个模块的场景,返回负数会让进度条算反。
+ */
+ public static BigDecimal weeklyLoss(BigDecimal startWeight, BigDecimal targetWeight,
+ int weeks) {
+ if (startWeight == null || targetWeight == null) {
+ return BigDecimal.ZERO;
+ }
+ BigDecimal total = startWeight.subtract(targetWeight);
+ if (total.signum() <= 0) {
+ return BigDecimal.ZERO;
+ }
+ int safeWeeks = Math.max(weeks, 1);
+ return total.divide(new BigDecimal(safeWeeks), 2, RoundingMode.HALF_UP);
+ }
+
+ /**
+ * 第 N 周末的理想体重 = 起始体重 - 每周应减 × N。
+ *
+ * 不会低于目标体重 —— 计划结束时线应该平在目标值上,
+ * 而不是继续往下穿。
+ */
+ public static BigDecimal idealWeightAt(BigDecimal startWeight, BigDecimal targetWeight,
+ BigDecimal weeklyLoss, int week) {
+ if (startWeight == null || targetWeight == null) {
+ return BigDecimal.ZERO;
+ }
+ BigDecimal ideal = startWeight.subtract(
+ weeklyLoss.multiply(new BigDecimal(Math.max(week, 0))));
+ // 已经低于目标就钉在目标上
+ if (ideal.compareTo(targetWeight) < 0) {
+ return targetWeight;
+ }
+ return ideal.setScale(1, RoundingMode.HALF_UP);
+ }
+
+ /**
+ * 已过天数占总天数的比例(0.0 ~ 1.0),给进度条用。
+ *
+ * 还没开始返回 0,已经超期返回 1.0(不是 >1)——
+ * 进度条宽度是可变的 1 倍,超期再往下撑会溢出布局。
+ */
+ public static double timeProgress(LocalDate start, LocalDate target, LocalDate today) {
+ long total = ChronoUnit.DAYS.between(start, target);
+ if (total <= 0) {
+ return 1.0;
+ }
+ long passed = ChronoUnit.DAYS.between(start, today);
+ if (passed <= 0) {
+ return 0.0;
+ }
+ return Math.min(1.0, (double) passed / total);
+ }
+}
diff --git a/src/main/resources/db/migration_add_health.sql b/src/main/resources/db/migration_add_health.sql
new file mode 100644
index 0000000..6ec5226
--- /dev/null
+++ b/src/main/resources/db/migration_add_health.sql
@@ -0,0 +1,92 @@
+-- 「强宝健康」模块建表(2026-09-28)
+--
+-- 三张表:
+-- weight_record 体重记录(一天一条,同日覆盖)
+-- weight_goal 减重目标(单条覆盖:只保留一个进行中的目标)
+-- diet_record 饮食记录(营养值冗余存快照)
+--
+-- 依赖:food_nutrition 表(见 migration_add_food_nutrition.sql),
+-- diet_record.food_id 关联的是 food_nutrition.id(主键),不是来源 food_id。
+--
+-- 执行方式(在 231 上):
+-- docker exec -i mysql-8.0.30 mysql --default-character-set=utf8mb4 \
+-- -uroot -p密码 accounting_db < migration_add_health.sql
+
+
+-- ==================================================================
+-- 体重记录
+--
+-- 一天一条:unique(user_id, record_date) + Service 层「先查再更新」。
+-- 早上称一次晚上再称一次的情况,后记的覆盖前面的 —— 图表就干净了。
+-- ==================================================================
+CREATE TABLE IF NOT EXISTS `weight_record` (
+ `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID',
+ `user_id` BIGINT NOT NULL COMMENT '用户ID',
+ `record_date` DATE NOT NULL COMMENT '记录日期(一天一条)',
+ `weight` DECIMAL(5,1) NOT NULL COMMENT '体重 kg',
+ `note` VARCHAR(200) NULL COMMENT '备注(比如「早上空腹」)',
+ `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+ `update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+ PRIMARY KEY (`id`),
+ UNIQUE KEY `uk_user_date` (`user_id`, `record_date`) COMMENT '一天一条,重复记录覆盖',
+ INDEX `idx_user_date` (`user_id`, `record_date` DESC) COMMENT '按日期倒序取最近记录'
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='体重记录(一天一条)';
+
+
+-- ==================================================================
+-- 减重目标
+--
+-- is_active 只有一条为 1:设置新目标时把旧的置 0。
+-- 保留历史行而不是物理删除,是为了将来能做「历史战绩」——
+-- 现在界面只读 is_active=1 那条,所以行为上等价于「单条覆盖」。
+-- ==================================================================
+CREATE TABLE IF NOT EXISTS `weight_goal` (
+ `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID',
+ `user_id` BIGINT NOT NULL COMMENT '用户ID',
+ `start_weight` DECIMAL(5,1) NOT NULL COMMENT '起始体重 kg',
+ `target_weight` DECIMAL(5,1) NOT NULL COMMENT '目标体重 kg',
+ `start_date` DATE NOT NULL COMMENT '开始日期',
+ `target_date` DATE NOT NULL COMMENT '目标完成日期',
+ `is_active` TINYINT NOT NULL DEFAULT 1 COMMENT '是否进行中:0否 1是',
+ `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+ `update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+ PRIMARY KEY (`id`),
+ INDEX `idx_user_active` (`user_id`, `is_active`) COMMENT '取当前进行中的目标'
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='减重目标(is_active=1 为当前目标)';
+
+
+-- ==================================================================
+-- 饮食记录
+--
+-- 【为什么冗余存营养值】food_nutrition 是《中国食物成分表》的落库快照,
+-- 将来重新抓取时数值可能被修正。如果只存 food_id + 克数,那么食物表
+-- 一更新,三个月前那顿饭的热量就跟着变了 —— 那是既成事实,不该被改写。
+-- 所以记录时就把算好的值抄一份存下来(快照语义)。
+--
+-- meal_type 取值:breakfast / lunch / dinner / snack
+-- ==================================================================
+CREATE TABLE IF NOT EXISTS `diet_record` (
+ `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID',
+ `user_id` BIGINT NOT NULL COMMENT '用户ID',
+ `record_date` DATE NOT NULL COMMENT '记录日期',
+ `meal_type` VARCHAR(20) NOT NULL COMMENT '餐次:breakfast/lunch/dinner/snack',
+ `food_id` BIGINT NOT NULL COMMENT '食物ID(food_nutrition.id)',
+ `food_name` VARCHAR(200) NOT NULL COMMENT '食物名(冗余,食物被删也能显示)',
+ `amount_grams` DECIMAL(7,1) NOT NULL COMMENT '份量 g',
+
+ -- ==== 营养快照(按 amount_grams 换算后的实际值,不是每 100g)====
+ `energy_kcal` DECIMAL(8,1) NOT NULL DEFAULT 0 COMMENT '能量 kcal',
+ `protein` DECIMAL(7,2) NOT NULL DEFAULT 0 COMMENT '蛋白质 g',
+ `fat` DECIMAL(7,2) NOT NULL DEFAULT 0 COMMENT '脂肪 g',
+ `carbohydrate` DECIMAL(7,2) NOT NULL DEFAULT 0 COMMENT '碳水化合物 g',
+ `dietary_fiber` DECIMAL(7,2) NOT NULL DEFAULT 0 COMMENT '膳食纤维 g',
+ `sodium` DECIMAL(9,1) NOT NULL DEFAULT 0 COMMENT '钠 mg',
+
+ `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+ `update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+ PRIMARY KEY (`id`),
+ INDEX `idx_user_date` (`user_id`, `record_date`) COMMENT '按天查饮食列表'
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='饮食记录(营养值为记录时的快照)';
+
+-- 核对:跑完应该能看到三张表
+-- SHOW TABLES LIKE 'weight_%'; SHOW TABLES LIKE 'diet_record';
diff --git a/src/main/resources/db/schema.sql b/src/main/resources/db/schema.sql
index 31b6880..2e4020e 100644
--- a/src/main/resources/db/schema.sql
+++ b/src/main/resources/db/schema.sql
@@ -287,3 +287,53 @@ CREATE TABLE IF NOT EXISTS `food_nutrition` (
INDEX `idx_category` (`category_id`, `sub_category_id`),
INDEX `idx_name` (`name`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='食物营养成分表(每100g可食部,数据来源:中国食物成分表)';
+
+-- ==================== 强宝健康 ====================
+-- 与 db/migration_add_health.sql 保持一致。已有环境跑迁移脚本,全新环境跑本文件。
+
+CREATE TABLE IF NOT EXISTS `weight_record` (
+ `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID',
+ `user_id` BIGINT NOT NULL COMMENT '用户ID',
+ `record_date` DATE NOT NULL COMMENT '记录日期(一天一条)',
+ `weight` DECIMAL(5,1) NOT NULL COMMENT '体重 kg',
+ `note` VARCHAR(200) NULL COMMENT '备注',
+ `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+ `update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+ PRIMARY KEY (`id`),
+ UNIQUE KEY `uk_user_date` (`user_id`, `record_date`),
+ INDEX `idx_user_date` (`user_id`, `record_date` DESC)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='体重记录(一天一条)';
+
+CREATE TABLE IF NOT EXISTS `weight_goal` (
+ `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID',
+ `user_id` BIGINT NOT NULL COMMENT '用户ID',
+ `start_weight` DECIMAL(5,1) NOT NULL COMMENT '起始体重 kg',
+ `target_weight` DECIMAL(5,1) NOT NULL COMMENT '目标体重 kg',
+ `start_date` DATE NOT NULL COMMENT '开始日期',
+ `target_date` DATE NOT NULL COMMENT '目标完成日期',
+ `is_active` TINYINT NOT NULL DEFAULT 1 COMMENT '是否进行中:0否 1是',
+ `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+ `update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+ PRIMARY KEY (`id`),
+ INDEX `idx_user_active` (`user_id`, `is_active`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='减重目标(is_active=1 为当前目标)';
+
+CREATE TABLE IF NOT EXISTS `diet_record` (
+ `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID',
+ `user_id` BIGINT NOT NULL COMMENT '用户ID',
+ `record_date` DATE NOT NULL COMMENT '记录日期',
+ `meal_type` VARCHAR(20) NOT NULL COMMENT '餐次:breakfast/lunch/dinner/snack',
+ `food_id` BIGINT NOT NULL COMMENT '食物ID(food_nutrition.id)',
+ `food_name` VARCHAR(200) NOT NULL COMMENT '食物名(冗余)',
+ `amount_grams` DECIMAL(7,1) NOT NULL COMMENT '份量 g',
+ `energy_kcal` DECIMAL(8,1) NOT NULL DEFAULT 0 COMMENT '能量 kcal',
+ `protein` DECIMAL(7,2) NOT NULL DEFAULT 0 COMMENT '蛋白质 g',
+ `fat` DECIMAL(7,2) NOT NULL DEFAULT 0 COMMENT '脂肪 g',
+ `carbohydrate` DECIMAL(7,2) NOT NULL DEFAULT 0 COMMENT '碳水化合物 g',
+ `dietary_fiber` DECIMAL(7,2) NOT NULL DEFAULT 0 COMMENT '膳食纤维 g',
+ `sodium` DECIMAL(9,1) NOT NULL DEFAULT 0 COMMENT '钠 mg',
+ `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+ `update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+ PRIMARY KEY (`id`),
+ INDEX `idx_user_date` (`user_id`, `record_date`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='饮食记录(营养值为记录时的快照)';
diff --git a/src/main/resources/static/admin/admin.js b/src/main/resources/static/admin/admin.js
index 8079dc6..080f950 100644
--- a/src/main/resources/static/admin/admin.js
+++ b/src/main/resources/static/admin/admin.js
@@ -2210,7 +2210,6 @@
} else {
doLogout();
}
-})();
// ================================================================ AI 模型配置
//
@@ -2395,3 +2394,4 @@
return raw ? parseInt(raw, 10) : null;
} catch (e) { return null; }
}
+})();
diff --git a/src/test/java/com/accounting/util/FoodPortionRulesTest.java b/src/test/java/com/accounting/util/FoodPortionRulesTest.java
new file mode 100644
index 0000000..40ce7b7
--- /dev/null
+++ b/src/test/java/com/accounting/util/FoodPortionRulesTest.java
@@ -0,0 +1,106 @@
+package com.accounting.util;
+
+import com.accounting.dto.FoodPortionItem;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * 份量规则测试。
+ *
+ * 这个函数的失败模式很隐蔽:匹配不上不会报错,只会静默退化成
+ * 通用档位(「一小份/一份/一大份」)。对一条鱼来说「一份 100g」还算合理,
+ * 但对一碗米饭来说就很糟 —— 用户看到「米饭 一份」根本不知道是多少。
+ * 所以这里重点测「该命中的有没有命中」。
+ */
+class FoodPortionRulesTest {
+
+ @Test
+ @DisplayName("永远返回非空 —— 用户点进去不该看到空白")
+ void alwaysReturnsSomething() {
+ List portions = FoodPortionRules.portions(null, null);
+ assertFalse(portions.isEmpty());
+ assertFalse(FoodPortionRules.portions("某个没见过的分类", "某种食物").isEmpty());
+ }
+
+ @Test
+ @DisplayName("按名称优先:米饭用碗、馒头用个 —— 同一个大类里份量差异极大")
+ void nameRuleBeatsCategory() {
+ List rice = FoodPortionRules.portions("谷类及制品", "米饭(蒸)");
+ assertTrue(rice.stream().anyMatch(p -> p.label().equals("一碗")));
+ assertEquals(150, rice.stream()
+ .filter(p -> p.label().equals("一碗")).findFirst().orElseThrow().grams());
+
+ List bun = FoodPortionRules.portions("谷类及制品", "馒头(标准粉)");
+ assertTrue(bun.stream().anyMatch(p -> p.label().equals("一个")));
+ assertEquals(100, bun.stream()
+ .filter(p -> p.label().equals("一个")).findFirst().orElseThrow().grams());
+ }
+
+ @Test
+ @DisplayName("名称匹配变体:名字里含「米饭」就能命中,不用穷举完整名")
+ void matchesNameSubstring() {
+ for (String name : List.of("米饭(蒸)", "米饭(标准米)", "蒸米饭", "米饭")) {
+ List portions = FoodPortionRules.portions(null, name);
+ assertTrue(portions.stream().anyMatch(p -> p.label().equals("一碗")),
+ name + " 应该命中米饭规则");
+ }
+ }
+
+ @Test
+ @DisplayName("名称匹配不上时退回分类档位")
+ void fallsBackToCategory() {
+ // 这个名字不在精修表里,但分类有规则
+ List fish = FoodPortionRules.portions("鱼虾蟹贝类", "草鱼");
+ assertTrue(fish.stream().anyMatch(p -> p.label().equals("一条")));
+ assertEquals(150, fish.stream()
+ .filter(p -> p.label().equals("一条")).findFirst().orElseThrow().grams());
+ }
+
+ @Test
+ @DisplayName("分类也没规则时用通用档位兜底")
+ void fallsBackToGeneric() {
+ List generic = FoodPortionRules.portions("未收录的分类", "未收录的食物");
+ assertEquals(3, generic.size());
+ assertEquals(100, generic.stream()
+ .filter(p -> p.label().equals("一份")).findFirst().orElseThrow().grams());
+ }
+
+ @Test
+ @DisplayName("每个档位的克数都大于 0 —— 否则换算出来全是零热量")
+ void allPortionsHavePositiveGrams() {
+ List categories = List.of(
+ "谷类及制品", "蔬菜类及制品", "水果类及制品", "畜肉类及制品",
+ "蛋类及制品", "乳类及制品", "油脂类", "坚果、种子类",
+ "鱼虾蟹贝类", "饮料类", "调味品类", "薯类、淀粉及制品");
+ List foods = List.of("米饭(蒸)", "馒头", "草鱼", "鸡蛋", "牛奶", "苹果", "花生");
+
+ for (String category : categories) {
+ for (String food : foods) {
+ for (FoodPortionItem portion : FoodPortionRules.portions(category, food)) {
+ assertTrue(portion.grams() > 0,
+ category + "/" + food + " 的「" + portion.label() + "」克数应为正");
+ }
+ }
+ }
+ }
+
+ @Test
+ @DisplayName("牛奶要命中 200/250 而不是被「牛」类规则抢走")
+ void milkMatchesItsOwnRule() {
+ List milk = FoodPortionRules.portions("乳类及制品", "牛乳(全脂)");
+ // 「牛乳」不含「牛奶」这个子串,所以这里走的是分类规则 ——
+ // 断言的是「结果对」,不是「走了哪条路」
+ assertTrue(milk.stream().anyMatch(p -> p.grams() == 200 || p.grams() == 250),
+ "牛奶应该有杯/盒这样的档位");
+
+ List literalMilk = FoodPortionRules.portions(null, "牛奶");
+ assertEquals(200, literalMilk.stream()
+ .filter(p -> p.label().equals("一杯")).findFirst().orElseThrow().grams());
+ }
+}
diff --git a/src/test/java/com/accounting/util/NutritionUnitTest.java b/src/test/java/com/accounting/util/NutritionUnitTest.java
new file mode 100644
index 0000000..cf02f04
--- /dev/null
+++ b/src/test/java/com/accounting/util/NutritionUnitTest.java
@@ -0,0 +1,63 @@
+package com.accounting.util;
+
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import java.math.BigDecimal;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+
+/**
+ * 营养单位换算测试。
+ *
+ * kJ → kcal 是这次唯一的「数值口径」转换,算错的话所有热量都是错的,
+ * 而且错得不像 bug(比如全偏大 4 倍,看起来只是「热量好高」)。
+ */
+class NutritionUnitTest {
+
+ @Test
+ @DisplayName("1478 kJ = 353.3 kcal(小麦粉标准粉)")
+ void convertsKjToKcal() {
+ assertEquals(new BigDecimal("353.3"),
+ NutritionUnit.kjToKcal(new BigDecimal("1478")));
+ }
+
+ @Test
+ @DisplayName("0 kJ = 0 kcal")
+ void convertsZero() {
+ assertEquals(new BigDecimal("0.0"),
+ NutritionUnit.kjToKcal(BigDecimal.ZERO));
+ }
+
+ @Test
+ @DisplayName("null 进 null 出 —— 不能悄悄变成 0,「没测过」和「零热量」是两回事")
+ void nullStaysNull() {
+ assertNull(NutritionUnit.kjToKcal(null));
+ }
+
+ @Test
+ @DisplayName("按份量换算:每 100g 有 353.3 kcal,吃 150g 就是 530 kcal")
+ void scalesByAmount() {
+ assertEquals(new BigDecimal("529.95"),
+ NutritionUnit.per100gToAmount(new BigDecimal("353.3"), new BigDecimal("150")));
+ }
+
+ @Test
+ @DisplayName("份量为 0 或负数时返回 0,不抛异常 —— 输入框可能被清空")
+ void handlesZeroAndNegativeGrams() {
+ assertEquals(BigDecimal.ZERO, NutritionUnit.per100gToAmount(
+ new BigDecimal("353.3"), BigDecimal.ZERO));
+ assertEquals(BigDecimal.ZERO, NutritionUnit.per100gToAmount(
+ new BigDecimal("353.3"), new BigDecimal("-10")));
+ }
+
+ @Test
+ @DisplayName("任一入参为 null 都返回 0(表的字段允许为空)")
+ void handlesNulls() {
+ assertEquals(BigDecimal.ZERO,
+ NutritionUnit.per100gToAmount(null, new BigDecimal("100")));
+ assertEquals(BigDecimal.ZERO,
+ NutritionUnit.per100gToAmount(new BigDecimal("100"), null));
+ }
+}
diff --git a/src/test/java/com/accounting/util/WeightPlanCalculatorTest.java b/src/test/java/com/accounting/util/WeightPlanCalculatorTest.java
new file mode 100644
index 0000000..0b4feb9
--- /dev/null
+++ b/src/test/java/com/accounting/util/WeightPlanCalculatorTest.java
@@ -0,0 +1,126 @@
+package com.accounting.util;
+
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import java.math.BigDecimal;
+import java.time.LocalDate;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * 减重计划计算测试。
+ *
+ * 这块是「设置目标 → 每周该减多少」的核心算术,也是最容易出现
+ * 边界问题的地方:同日、超期、目标比起始还重、除零。所以边界用例
+ * 写得比正常用例还多。
+ */
+class WeightPlanCalculatorTest {
+
+ @Test
+ @DisplayName("三个月 65→58kg:总减 7kg 分到 13 周,每周 0.54kg")
+ void weeklyLossForThreeMonths() {
+ LocalDate start = LocalDate.of(2026, 1, 1);
+ LocalDate target = LocalDate.of(2026, 4, 1); // 90 天
+
+ int weeks = WeightPlanCalculator.totalWeeks(start, target);
+ assertEquals(13, weeks);
+
+ BigDecimal weekly = WeightPlanCalculator.weeklyLoss(
+ new BigDecimal("65"), new BigDecimal("58"), weeks);
+ assertEquals(new BigDecimal("0.54"), weekly);
+ }
+
+ @Test
+ @DisplayName("总天数除不尽 7 时向上取整 —— 少算一周会让每周目标偏轻")
+ void roundsUpPartialWeek() {
+ // 8 天 = 1 周 + 1 天,应该算 2 周而不是 1 周
+ assertEquals(2, WeightPlanCalculator.totalWeeks(
+ LocalDate.of(2026, 1, 1), LocalDate.of(2026, 1, 9)));
+ // 6 天算 1 周
+ assertEquals(1, WeightPlanCalculator.totalWeeks(
+ LocalDate.of(2026, 1, 1), LocalDate.of(2026, 1, 7)));
+ }
+
+ @Test
+ @DisplayName("同日或倒填日期都算 1 周,不除零")
+ void sameDayFallsBackToOneWeek() {
+ LocalDate day = LocalDate.of(2026, 1, 1);
+
+ assertEquals(1, WeightPlanCalculator.totalWeeks(day, day));
+ // 目标日期早于开始日期也不该崩
+ assertEquals(1, WeightPlanCalculator.totalWeeks(day, day.minusDays(5)));
+
+ // 1 周减 7kg 是个荒谬的值,但必须是个数、不能是异常
+ assertEquals(new BigDecimal("7.00"), WeightPlanCalculator.weeklyLoss(
+ new BigDecimal("65"), new BigDecimal("58"), 1));
+ }
+
+ @Test
+ @DisplayName("目标比起始还重时每周应减为 0,不能是负数")
+ void gainingWeightGivesZeroNotNegative() {
+ // 负数会让进度条宽度算反、也会让「每周应减」显示成 -0.5kg
+ assertEquals(BigDecimal.ZERO, WeightPlanCalculator.weeklyLoss(
+ new BigDecimal("58"), new BigDecimal("65"), 13));
+ assertEquals(BigDecimal.ZERO, WeightPlanCalculator.weeklyLoss(
+ new BigDecimal("60"), new BigDecimal("60"), 13));
+ }
+
+ @Test
+ @DisplayName("理想体重曲线:第 0 周是起始体重,最后一周落在目标上")
+ void idealCurveEndsAtTarget() {
+ BigDecimal start = new BigDecimal("65");
+ BigDecimal target = new BigDecimal("58");
+ BigDecimal weekly = new BigDecimal("0.54");
+
+ assertEquals(new BigDecimal("65.0"),
+ WeightPlanCalculator.idealWeightAt(start, target, weekly, 0));
+
+ // 第 13 周:65 - 0.54*13 = 57.98,低于目标 58 → 应该钉在 58
+ assertEquals(new BigDecimal("58"),
+ WeightPlanCalculator.idealWeightAt(start, target, weekly, 13));
+ }
+
+ @Test
+ @DisplayName("理想体重不会跌破目标 —— 否则参照线会穿到目标下方")
+ void idealWeightNeverGoesBelowTarget() {
+ BigDecimal target = new BigDecimal("58");
+ // 刻意给一个过大的每周减重
+ BigDecimal ideal = WeightPlanCalculator.idealWeightAt(
+ new BigDecimal("65"), target, new BigDecimal("2.0"), 10);
+ assertEquals(target, ideal);
+ }
+
+ @Test
+ @DisplayName("时间进度:未开始 0、进行中在 0~1、超期封顶 1.0")
+ void timeProgressIsClamped() {
+ LocalDate start = LocalDate.of(2026, 1, 1);
+ LocalDate target = LocalDate.of(2026, 1, 11); // 10 天
+
+ // 还没开始
+ assertEquals(0.0, WeightPlanCalculator.timeProgress(start, target,
+ LocalDate.of(2025, 12, 20)));
+ // 正中间
+ assertEquals(0.5, WeightPlanCalculator.timeProgress(start, target,
+ LocalDate.of(2026, 1, 6)), 0.001);
+ // 已超期:封顶 1.0,不是 1.5 —— 进度条宽度不能溢出
+ assertEquals(1.0, WeightPlanCalculator.timeProgress(start, target,
+ LocalDate.of(2026, 3, 1)));
+ }
+
+ @Test
+ @DisplayName("每周超过 1kg 会被标记成不健康")
+ void flagsUnhealthyPace() {
+ // 65→58 只用 3 周 = 每周 2.33kg
+ BigDecimal weekly = WeightPlanCalculator.weeklyLoss(
+ new BigDecimal("65"), new BigDecimal("58"), 3);
+ assertTrue(weekly.compareTo(WeightPlanCalculator.HEALTHY_WEEKLY_LOSS_KG) > 0);
+
+ // 正常节奏不该被标
+ BigDecimal healthy = WeightPlanCalculator.weeklyLoss(
+ new BigDecimal("65"), new BigDecimal("58"), 13);
+ assertFalse(healthy.compareTo(WeightPlanCalculator.HEALTHY_WEEKLY_LOSS_KG) > 0);
+ }
+}