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:
@@ -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\""));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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