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:
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user