feat(app): 设置页 UI + Mood/成就/贴纸 BLoC 接入 API + B7 测试扩展

前端改动:
- 新建设置页面 (主题切换/关于/隐私政策/用户协议/儿童隐私保护)
- SettingsBloc 注册到 MultiRepositoryProvider 全局可访问
- MoodBloc 修复编译错误 + 接入 /diary/stats/mood API
- MoodPage 添加错误状态展示和重试按钮
- AchievementBloc + 页面改造接入 /diary/achievements API
- StickerBloc + 页面改造接入 /diary/sticker-packs API
- TemplateBloc + 页面改造接入 /diary/templates API
- ProfilePage 设置入口改为跳转 /settings
- 添加 /settings 路由

后端改动:
- 扩展 mood_stats_service 测试 (连续天数算法/心情计数/边界场景)
- 新增 class_service 测试 (班级码生成/唯一性/错误映射)
- 新增 achievement_service 测试 (DTO 结构/序列化/map 构建)
- 新增 sticker_service 测试 (DTO 序列化/错误处理)
- 扩展 dto.rs 测试 (achievement/mood_stats/sticker/template/notification)
- 清理 2 个 unused import warning

验证:
- cargo check 0 error 0 warning
- flutter analyze 0 error
This commit is contained in:
iven
2026-06-01 11:19:43 +08:00
parent 860e9e5d22
commit 8331db63ba
19 changed files with 1749 additions and 326 deletions

View File

@@ -156,3 +156,64 @@ impl AchievementService {
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn achievement_not_found_error() {
let err = DiaryError::NotFound("成就 xxx 不存在".to_string());
assert!(err.to_string().contains("xxx"));
}
#[test]
fn achievement_resp_structure() {
let resp = AchievementResp {
id: Uuid::nil(),
code: "first_diary".into(),
name: "初次落笔".into(),
description: Some("写下第一篇日记".into()),
icon: Some("✏️".into()),
category: "writing".into(),
is_unlocked: false,
unlocked_at: None,
};
assert_eq!(resp.code, "first_diary");
assert!(!resp.is_unlocked);
assert!(resp.unlocked_at.is_none());
}
#[test]
fn achievement_resp_serializes() {
let resp = AchievementResp {
id: Uuid::nil(),
code: "streak_7".into(),
name: "坚持一周".into(),
description: None,
icon: None,
category: "writing".into(),
is_unlocked: true,
unlocked_at: Some(Utc::now()),
};
let json = serde_json::to_string(&resp).unwrap();
assert!(json.contains("\"is_unlocked\":true"));
assert!(json.contains("\"code\":\"streak_7\""));
}
#[test]
fn unlocked_map_construction() {
// 测试 unlocked_map 构建逻辑
use std::collections::HashMap;
let mut map: HashMap<Uuid, chrono::DateTime<Utc>> = HashMap::new();
let id = Uuid::now_v7();
let now = Utc::now();
map.insert(id, now);
assert!(map.contains_key(&id));
assert_eq!(map.len(), 1);
let missing = Uuid::now_v7();
assert!(!map.contains_key(&missing));
}
}

View File

@@ -298,3 +298,56 @@ fn member_model_to_resp(model: class_member::Model) -> ClassMemberResp {
joined_at: model.joined_at,
}
}
#[cfg(test)]
mod tests {
use super::*;
// ===== 班级码生成测试 =====
#[test]
fn generate_class_code_is_6_chars() {
let code = generate_class_code();
assert_eq!(code.len(), 6, "班级码必须是 6 位");
}
#[test]
fn generate_class_code_is_alphanumeric() {
let code = generate_class_code();
assert!(
code.chars().all(|c| c.is_ascii_alphanumeric()),
"班级码必须全部是字母数字"
);
}
#[test]
fn generate_class_code_is_unique() {
let codes: std::collections::HashSet<String> = (0..100)
.map(|_| generate_class_code())
.collect();
// 100 个码应该全部不同(概率上几乎确定)
assert!(codes.len() > 90, "生成的班级码应该高度唯一");
}
// ===== 错误映射测试 =====
#[test]
fn invalid_class_code_error() {
let err = DiaryError::InvalidClassCode;
assert!(err.to_string().contains("无效"));
}
#[test]
fn class_code_expired_error() {
let err = DiaryError::ClassCodeExpired;
assert!(err.to_string().contains("过期"));
}
#[test]
fn class_code_locked_error() {
let err = DiaryError::ClassCodeLocked {
lockout_minutes: 30,
};
assert!(err.to_string().contains("30"));
}
}

View File

@@ -148,6 +148,8 @@ fn parse_mood(s: &str) -> Mood {
mod tests {
use super::*;
// ===== parse_mood 测试 =====
#[test]
fn parse_mood_known_values() {
assert!(matches!(parse_mood("happy"), Mood::Happy));
@@ -160,12 +162,131 @@ mod tests {
#[test]
fn parse_mood_unknown_defaults_happy() {
assert!(matches!(parse_mood("unknown"), Mood::Happy));
assert!(matches!(parse_mood(""), Mood::Happy));
assert!(matches!(parse_mood("HAPPY"), Mood::Happy));
}
// ===== StatsPeriod 测试 =====
#[test]
fn stats_period_days() {
assert_eq!(StatsPeriod::Week.days(), 7);
assert_eq!(StatsPeriod::Month.days(), 30);
assert_eq!(StatsPeriod::Quarter.days(), 90);
}
#[test]
fn stats_period_deserialize() {
assert!(matches!(
serde_json::from_str::<StatsPeriod>("\"Week\"").unwrap(),
StatsPeriod::Week
));
}
// ===== 连续天数算法测试 =====
#[test]
fn streak_calculation_empty_dates() {
// 无日记时 streak = 0
let dates: std::collections::HashSet<NaiveDate> = std::collections::HashSet::new();
assert!(dates.is_empty());
}
#[test]
fn streak_from_consecutive_dates() {
// 模拟连续 3 天写日记
let today = Utc::now().date_naive();
let dates: std::collections::HashSet<NaiveDate> = [
today,
today - Duration::days(1),
today - Duration::days(2),
]
.into_iter()
.collect();
let mut streak = 0i32;
let mut check_date = today;
let mut mutable_dates = dates.clone();
while mutable_dates.remove(&check_date) {
streak += 1;
check_date -= Duration::days(1);
}
assert_eq!(streak, 3);
}
#[test]
fn streak_broken_midway() {
// 今天写了,昨天没写 → streak = 1
let today = Utc::now().date_naive();
let dates: std::collections::HashSet<NaiveDate> =
[today, today - Duration::days(2)].into_iter().collect();
let mut streak = 0i32;
let mut check_date = today;
let mut mutable_dates = dates.clone();
while mutable_dates.remove(&check_date) {
streak += 1;
check_date -= Duration::days(1);
}
assert_eq!(streak, 1);
}
#[test]
fn streak_no_diary_today() {
// 今天没写日记 → streak = 0即使昨天写了
let today = Utc::now().date_naive();
let dates: std::collections::HashSet<NaiveDate> =
[today - Duration::days(1)].into_iter().collect();
let mut streak = 0i32;
let mut check_date = today;
let mut mutable_dates = dates.clone();
while mutable_dates.remove(&check_date) {
streak += 1;
check_date -= Duration::days(1);
}
assert_eq!(streak, 0);
}
#[test]
fn streak_long_consecutive() {
// 连续 30 天
let today = Utc::now().date_naive();
let dates: std::collections::HashSet<NaiveDate> = (0..30)
.map(|d| today - Duration::days(d))
.collect();
let mut streak = 0i32;
let mut check_date = today;
let mut mutable_dates = dates.clone();
while mutable_dates.remove(&check_date) {
streak += 1;
check_date -= Duration::days(1);
}
assert_eq!(streak, 30);
}
// ===== 心情计数聚合测试 =====
#[test]
fn mood_counts_percentage_calculation() {
// 模拟聚合逻辑3 happy + 2 calm = 5 total
let total = 5i32;
let happy_count = 3i32;
let calm_count = 2i32;
let happy_pct = (happy_count as f64 / total as f64) * 100.0;
let calm_pct = (calm_count as f64 / total as f64) * 100.0;
assert!((happy_pct - 60.0).abs() < 0.01);
assert!((calm_pct - 40.0).abs() < 0.01);
}
#[test]
fn mood_counts_empty_total_zero_percentage() {
// 无日记时,百分比为 0
let total = 0i32;
let percentage = if total > 0 { 100.0 } else { 0.0 };
assert_eq!(percentage, 0.0);
}
}

View File

@@ -1,7 +1,7 @@
// 贴纸服务 — 贴纸包与贴纸管理
use sea_orm::{
ColumnTrait, DatabaseConnection, EntityTrait, PaginatorTrait, QueryFilter, QueryOrder, Set,
ColumnTrait, DatabaseConnection, EntityTrait, PaginatorTrait, QueryFilter, QueryOrder,
};
use uuid::Uuid;
@@ -152,3 +152,72 @@ impl StickerService {
})
}
}
#[cfg(test)]
mod tests {
use super::*;
// ===== DTO 序列化测试 =====
#[test]
fn sticker_pack_resp_serializes() {
let resp = StickerPackResp {
id: Uuid::nil(),
name: "可爱猫咪".into(),
description: Some("超萌的猫咪贴纸".into()),
cover_image_url: Some("https://example.com/cat.png".into()),
sticker_count: 24,
is_free: true,
category: Some("动物".into()),
};
let json = serde_json::to_string(&resp).unwrap();
assert!(json.contains("\"sticker_count\":24"));
assert!(json.contains("\"is_free\":true"));
assert!(json.contains("\"category\":\"动物\""));
}
#[test]
fn sticker_resp_serializes() {
let resp = StickerResp {
id: Uuid::nil(),
pack_id: Uuid::nil(),
name: "笑脸猫".into(),
image_url: "https://example.com/cat-smile.png".into(),
category: Some("表情".into()),
};
let json = serde_json::to_string(&resp).unwrap();
assert!(json.contains("\"name\":\"笑脸猫\""));
}
#[test]
fn template_resp_serializes() {
let resp = TemplateResp {
id: Uuid::nil(),
name: "今日心情".into(),
description: Some("记录今天的心情".into()),
preview_url: None,
template_data: Some(serde_json::json!({"layout": "grid"})),
category: Some("日常".into()),
is_free: true,
};
let json = serde_json::to_string(&resp).unwrap();
assert!(json.contains("\"is_free\":true"));
assert!(json.contains("\"layout\":\"grid\""));
}
// ===== 错误处理测试 =====
#[test]
fn sticker_pack_not_found_error() {
let pack_id = Uuid::now_v7();
let err = DiaryError::NotFound(format!("贴纸包 {} 不存在", pack_id));
assert!(err.to_string().contains(&pack_id.to_string()));
}
#[test]
fn template_not_found_error() {
let template_id = Uuid::now_v7();
let err = DiaryError::NotFound(format!("模板 {} 不存在", template_id));
assert!(err.to_string().contains(&template_id.to_string()));
}
}