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

@@ -430,4 +430,140 @@ mod tests {
let json = serde_json::to_string(&resp).unwrap();
assert!(json.contains("\"is_active\":true"));
}
#[test]
fn achievement_resp_serializes() {
let resp = AchievementResp {
id: uuid::Uuid::nil(),
code: "first_diary".into(),
name: "初次落笔".into(),
description: Some("写下第一篇日记".into()),
icon: Some("✏️".into()),
category: "writing".into(),
is_unlocked: true,
unlocked_at: None,
};
let json = serde_json::to_string(&resp).unwrap();
assert!(json.contains("\"code\":\"first_diary\""));
assert!(json.contains("\"is_unlocked\":true"));
}
#[test]
fn achievement_resp_unlocked_at_present() {
let now = chrono::Utc::now();
let resp = AchievementResp {
id: uuid::Uuid::nil(),
code: "streak_7".into(),
name: "坚持一周".into(),
description: None,
icon: None,
category: "writing".into(),
is_unlocked: true,
unlocked_at: Some(now),
};
let json = serde_json::to_string(&resp).unwrap();
assert!(json.contains("unlocked_at"));
}
#[test]
fn mood_stats_resp_structure() {
let resp = MoodStatsResp {
mood_counts: vec![
MoodCount {
mood: Mood::Happy,
count: 12,
percentage: 60.0,
},
MoodCount {
mood: Mood::Calm,
count: 8,
percentage: 40.0,
},
],
streak_days: 7,
total_journals: 20,
dominant_mood: Some(Mood::Happy),
};
let json = serde_json::to_string(&resp).unwrap();
assert!(json.contains("\"streak_days\":7"));
assert!(json.contains("\"total_journals\":20"));
assert!(json.contains("\"dominant_mood\":\"happy\""));
}
#[test]
fn sticker_resp_fields() {
let resp = StickerResp {
id: uuid::Uuid::nil(),
pack_id: uuid::Uuid::nil(),
name: "笑脸".into(),
image_url: "https://cdn.example.com/smile.png".into(),
category: Some("表情".into()),
};
let json = serde_json::to_string(&resp).unwrap();
assert!(json.contains("\"image_url\""));
assert!(json.contains("\"category\":\"表情\""));
}
#[test]
fn template_resp_with_layout_data() {
let resp = TemplateResp {
id: uuid::Uuid::nil(),
name: "校园日记".into(),
description: Some("在学校的一天".into()),
preview_url: Some("https://cdn.example.com/preview.png".into()),
template_data: Some(serde_json::json!({
"sections": [{"type": "title"}, {"type": "body"}]
})),
category: Some("校园".into()),
is_free: true,
};
let json = serde_json::to_string(&resp).unwrap();
assert!(json.contains("\"sections\""));
assert!(json.contains("\"preview_url\""));
}
#[test]
fn create_journal_req_with_class() {
let json = r#"{
"title": "班级日记",
"date": "2026-06-01",
"mood": "happy",
"weather": "sunny",
"tags": ["校园"],
"is_private": false,
"class_id": "00000000-0000-0000-0000-000000000001"
}"#;
let req: CreateJournalReq = serde_json::from_str(json).unwrap();
assert!(!req.is_private);
assert!(req.class_id.is_some());
}
#[test]
fn join_class_req() {
let json = r#"{"class_code": "ABC123"}"#;
let req: JoinClassReq = serde_json::from_str(json).unwrap();
assert_eq!(req.class_code, "ABC123");
}
#[test]
fn create_class_req() {
let json = r#"{"name": "三年二班", "school_name": "阳光小学"}"#;
let req: CreateClassReq = serde_json::from_str(json).unwrap();
assert_eq!(req.name, "三年二班");
assert_eq!(req.school_name, Some("阳光小学".into()));
}
#[test]
fn notification_payload_structure() {
let payload = NotificationPayload {
notification_type: NotificationType::AchievementUnlocked,
recipient_id: uuid::Uuid::nil(),
title: "成就解锁".into(),
body: "你解锁了「初次落笔」成就".into(),
business_id: Some(uuid::Uuid::nil()),
extra: None,
};
let json = serde_json::to_string(&payload).unwrap();
assert!(json.contains("\"notification_type\":\"achievement_unlocked\""));
}
}

View File

@@ -2,7 +2,6 @@
use axum::extract::{Extension, FromRef, Path, State};
use axum::response::Json;
use uuid::Uuid;
use erp_core::error::AppError;
use erp_core::rbac::require_permission;

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()));
}
}