|
| 1 | +//! commands_goals 命令层单测(inner 纯编排——内存库 AAA 模式)。 |
| 2 | +
|
| 3 | +use crate::commands_goals::{ |
| 4 | + add_goal_milestone_inner, bind_goal_group_inner, create_goal_inner, delete_goal_inner, |
| 5 | + get_goal_detail_inner, get_goal_progress_inner, list_goals_inner, require_goal, |
| 6 | + set_goal_milestone_status_inner, unbind_goal_group_inner, update_goal_inner, |
| 7 | + update_goal_interview_inner, update_goal_status_inner, GoalCreateInput, GoalMilestoneInput, |
| 8 | +}; |
| 9 | +use crate::db::Db; |
| 10 | +use crate::goal_schema::{TIER_HANDS_ON, TIER_SOLO_PROJECT}; |
| 11 | +use crate::types::NewNoteGroup; |
| 12 | + |
| 13 | +fn mem_db() -> Db { |
| 14 | + Db::open(":memory:").expect("内存库打开") |
| 15 | +} |
| 16 | + |
| 17 | +fn group(db: &Db, name: &str) -> i64 { |
| 18 | + db.create_group(&NewNoteGroup { |
| 19 | + name: name.to_string(), |
| 20 | + terrain: "container".to_string(), |
| 21 | + kind: "standalone".to_string(), |
| 22 | + domain_tag: None, |
| 23 | + source: "route".to_string(), |
| 24 | + series_key: None, |
| 25 | + route_reason: None, |
| 26 | + }) |
| 27 | + .expect("建组") |
| 28 | + .id |
| 29 | +} |
| 30 | + |
| 31 | +fn input(tier: Option<&str>, scenario: Option<&str>, group_ids: Vec<i64>) -> GoalCreateInput { |
| 32 | + GoalCreateInput { |
| 33 | + name: "学会 Python".to_string(), |
| 34 | + domain_tag: Some("programming".to_string()), |
| 35 | + horizon: Some("3m".to_string()), |
| 36 | + tier: tier.map(str::to_string), |
| 37 | + scenario: scenario.map(str::to_string), |
| 38 | + level: Some("zero".to_string()), |
| 39 | + driver: Some("work".to_string()), |
| 40 | + criteria_statement: Some("能独立完成实例".to_string()), |
| 41 | + non_scope: Some("不做 Web 框架".to_string()), |
| 42 | + weekly_commitment: Some("5h+".to_string()), |
| 43 | + obstacles: None, |
| 44 | + group_ids, |
| 45 | + milestones: vec![ |
| 46 | + GoalMilestoneInput { title: "基础".to_string(), due_weeks: 4 }, |
| 47 | + GoalMilestoneInput { title: "应用".to_string(), due_weeks: 8 }, |
| 48 | + GoalMilestoneInput { title: "项目".to_string(), due_weeks: 12 }, |
| 49 | + ], |
| 50 | + } |
| 51 | +} |
| 52 | + |
| 53 | +#[test] |
| 54 | +fn create_rejects_missing_scenario_in_interview_mode() { |
| 55 | + let db = mem_db(); |
| 56 | + // 第 1 问必答:tier 给了、scenario 缺失 → 拒绝 |
| 57 | + let err = create_goal_inner(&db, &input(Some(TIER_HANDS_ON), None, vec![])).expect_err("必答"); |
| 58 | + assert!(err.contains("必答")); |
| 59 | +} |
| 60 | + |
| 61 | +#[test] |
| 62 | +fn create_quick_mode_default_tier_and_metric_events() { |
| 63 | + let db = mem_db(); |
| 64 | + // 快速模式:tier=scenario=None → 默认档 + 0 草稿(两步入口零负担) |
| 65 | + let quick = GoalCreateInput { |
| 66 | + name: "学乐理".to_string(), |
| 67 | + domain_tag: Some("music".to_string()), |
| 68 | + horizon: Some("none".to_string()), |
| 69 | + tier: None, |
| 70 | + scenario: None, |
| 71 | + level: None, |
| 72 | + driver: None, |
| 73 | + criteria_statement: None, |
| 74 | + non_scope: None, |
| 75 | + weekly_commitment: None, |
| 76 | + obstacles: None, |
| 77 | + group_ids: vec![], |
| 78 | + milestones: vec![], |
| 79 | + }; |
| 80 | + let goal = create_goal_inner(&db, &quick).expect("快速模式"); |
| 81 | + assert_eq!(goal.status, "active"); |
| 82 | + assert!(goal.success_criteria_json.contains("default"), "默认档配方"); |
| 83 | + assert!(goal.intent_json.contains("\"scenario\":null"), "未访谈不落空串"); |
| 84 | + assert_eq!(db.count_metric_events("goal_created").expect("计数"), 1, "埋点一次"); |
| 85 | + assert_eq!(db.list_milestones(goal.id).expect("里程碑").len(), 0, "快速模式无草案"); |
| 86 | +} |
| 87 | + |
| 88 | +#[test] |
| 89 | +fn create_full_interview_persists_criteria_intent_bindings() { |
| 90 | + let db = mem_db(); |
| 91 | + let gid = group(&db, "Python 组"); |
| 92 | + let goal = create_goal_inner(&db, &input(Some(TIER_SOLO_PROJECT), Some("独立项目"), vec![gid])) |
| 93 | + .expect("访谈模式"); |
| 94 | + assert!(goal.success_criteria_json.contains("solo_project")); |
| 95 | + assert!(goal.success_criteria_json.contains("\"applications\":1")); |
| 96 | + assert!(goal.intent_json.contains("\"scenario\":\"独立项目\"")); |
| 97 | + assert_eq!(db.list_goal_group_ids(goal.id).expect("绑定"), vec![gid]); |
| 98 | + assert_eq!(db.list_milestones(goal.id).expect("里程碑").len(), 3, "草案落库"); |
| 99 | +} |
| 100 | + |
| 101 | +#[test] |
| 102 | +fn create_rejects_missing_bound_group() { |
| 103 | + let db = mem_db(); |
| 104 | + let err = create_goal_inner(&db, &input(Some(TIER_HANDS_ON), Some("场景"), vec![999])).expect_err("绑组"); |
| 105 | + assert!(err.contains("笔记组不存在")); |
| 106 | +} |
| 107 | + |
| 108 | +#[test] |
| 109 | +fn list_cards_show_progress_and_ready_badge() { |
| 110 | + let db = mem_db(); |
| 111 | + let gid = group(&db, "组"); |
| 112 | + let goal = create_goal_inner(&db, &input(Some(TIER_HANDS_ON), Some("场景"), vec![gid])).expect("建"); |
| 113 | + db.create_settlement(gid, r#"{}"#).expect("结算"); |
| 114 | + let cards = list_goals_inner(&db).expect("列表"); |
| 115 | + assert_eq!(cards.len(), 1); |
| 116 | + assert!(!cards[0].ready, "里程碑 0/3 不可毕业"); |
| 117 | + // 全达成 → 🎓 可毕业 |
| 118 | + for m in db.list_milestones(goal.id).expect("里程碑") { |
| 119 | + set_goal_milestone_status_inner(&db, m.id, "done").expect("完成"); |
| 120 | + } |
| 121 | + let cards2 = list_goals_inner(&db).expect("列表2"); |
| 122 | + assert!(cards2[0].ready); |
| 123 | + assert!(cards2[0].statement.contains("100%")); |
| 124 | +} |
| 125 | + |
| 126 | +#[test] |
| 127 | +fn detail_includes_declaration_and_groups() { |
| 128 | + let db = mem_db(); |
| 129 | + let gid = group(&db, "乐理组"); |
| 130 | + let goal = create_goal_inner(&db, &input(Some(TIER_HANDS_ON), Some("兴趣分享"), vec![gid])).expect("建"); |
| 131 | + let detail = get_goal_detail_inner(&db, goal.id).expect("详情"); |
| 132 | + assert_eq!(detail.groups.len(), 1); |
| 133 | + assert_eq!(detail.groups[0].name, "乐理组"); |
| 134 | + assert!(detail.declaration.contains("学会 Python")); |
| 135 | + assert!(detail.declaration.contains("边界:不做 Web 框架")); |
| 136 | + assert_eq!(detail.progress.progress.milestone_total, 3); |
| 137 | +} |
| 138 | + |
| 139 | +#[test] |
| 140 | +fn progress_reflects_milestone_and_settlement() { |
| 141 | + let db = mem_db(); |
| 142 | + let gid = group(&db, "组"); |
| 143 | + let goal = create_goal_inner(&db, &input(Some(TIER_HANDS_ON), Some("场景"), vec![gid])).expect("建"); |
| 144 | + let view = get_goal_progress_inner(&db, goal.id).expect("进度"); |
| 145 | + assert_eq!(view.statement, "0% · 里程碑 0/3"); |
| 146 | + assert!(!view.ready); |
| 147 | + db.create_settlement(gid, r#"{}"#).expect("结算"); |
| 148 | + let ms = db.list_milestones(goal.id).expect("里程碑"); |
| 149 | + set_goal_milestone_status_inner(&db, ms[0].id, "done").expect("done"); |
| 150 | + let view2 = get_goal_progress_inner(&db, goal.id).expect("进度2"); |
| 151 | + assert_eq!(view2.statement, "33% · 里程碑 1/3"); |
| 152 | + // 判据明细可见(字段与详情/毕业确认同口径) |
| 153 | + assert_eq!(view2.checks.len(), 2, "hands_on 两项判据"); |
| 154 | +} |
| 155 | + |
| 156 | +#[test] |
| 157 | +fn milestone_done_metric_only_on_transition() { |
| 158 | + let db = mem_db(); |
| 159 | + let goal = create_goal_inner(&db, &input(Some(TIER_HANDS_ON), Some("场景"), vec![])).expect("建"); |
| 160 | + let m = db.list_milestones(goal.id).expect("里程碑").remove(0); |
| 161 | + set_goal_milestone_status_inner(&db, m.id, "done").expect("完成"); |
| 162 | + set_goal_milestone_status_inner(&db, m.id, "pending").expect("回退"); |
| 163 | + set_goal_milestone_status_inner(&db, m.id, "done").expect("再完成"); |
| 164 | + assert_eq!(db.count_metric_events("goal_milestone_done").expect("计数"), 2, "两次转变各记一次"); |
| 165 | + // 已完成 → done 幂等(不再记) |
| 166 | + set_goal_milestone_status_inner(&db, m.id, "done").expect("幂等"); |
| 167 | + assert_eq!(db.count_metric_events("goal_milestone_done").expect("计数"), 2); |
| 168 | +} |
| 169 | + |
| 170 | +#[test] |
| 171 | +fn status_transitions_guards() { |
| 172 | + let db = mem_db(); |
| 173 | + let goal = create_goal_inner(&db, &input(Some(TIER_HANDS_ON), Some("场景"), vec![])).expect("建"); |
| 174 | + assert!(update_goal_status_inner(&db, goal.id, "paused").expect("暂停")); |
| 175 | + assert_eq!(db.get_goal(goal.id).expect("读").unwrap().status, "paused"); |
| 176 | + assert!(update_goal_status_inner(&db, goal.id, "active").expect("恢复")); |
| 177 | + // 非法:重复暂停→暂停 / 跳过退出路径 / 终态 |
| 178 | + let err = update_goal_status_inner(&db, goal.id, "graduated").expect_err("M1 未开放毕业"); |
| 179 | + assert!(err.contains("M1 仅支持暂停/恢复")); |
| 180 | +} |
| 181 | + |
| 182 | +#[test] |
| 183 | +fn update_and_delete_goal() { |
| 184 | + let db = mem_db(); |
| 185 | + let goal = create_goal_inner(&db, &input(Some(TIER_HANDS_ON), Some("场景"), vec![])).expect("建"); |
| 186 | + assert!(update_goal_inner(&db, goal.id, "改名", None, Some("6m".to_string())).expect("改")); |
| 187 | + let g = db.get_goal(goal.id).expect("读").unwrap(); |
| 188 | + assert_eq!(g.name, "改名"); |
| 189 | + assert!(update_goal_status_inner(&db, goal.id, "paused").is_ok()); |
| 190 | + // 重访谈:信息仍可编辑 → 非法状态转移拦截(paused 状态更新元数据合法,但状态不在这条命令) |
| 191 | + assert!(update_goal_interview_inner(&db, goal.id, &input(Some(TIER_HANDS_ON), Some("新场景"), vec![])).expect("重访谈")); |
| 192 | + assert!(delete_goal_inner(&db, goal.id).expect("删除")); |
| 193 | + assert!(require_goal(&db, goal.id).is_err()); |
| 194 | +} |
| 195 | + |
| 196 | +#[test] |
| 197 | +fn milestone_validation_whitelists() { |
| 198 | + let db = mem_db(); |
| 199 | + let goal = create_goal_inner(&db, &input(Some(TIER_HANDS_ON), Some("场景"), vec![])).expect("建"); |
| 200 | + // 非法判据类型拒绝(self_test 占位契约 M3 前不可写) |
| 201 | + let err = add_goal_milestone_inner(&db, goal.id, "x", None, Some("self_test".to_string()), None) |
| 202 | + .expect_err("自测类型"); |
| 203 | + assert!(err.contains("不支持的里程碑判据类型")); |
| 204 | + // group_settled 必须绑组 |
| 205 | + let err2 = add_goal_milestone_inner(&db, goal.id, "x", None, Some("group_settled".to_string()), None) |
| 206 | + .expect_err("需绑组"); |
| 207 | + assert!(err2.contains("必须绑定组")); |
| 208 | + // 非法状态拒绝 |
| 209 | + let err3 = set_goal_milestone_status_inner(&db, 1, "archived").expect_err("非法状态"); |
| 210 | + assert!(err3.contains("不支持的里程碑状态")); |
| 211 | +} |
| 212 | + |
| 213 | +#[test] |
| 214 | +fn bind_unbind_guards() { |
| 215 | + let db = mem_db(); |
| 216 | + let gid = group(&db, "组"); |
| 217 | + let goal = create_goal_inner(&db, &input(Some(TIER_HANDS_ON), Some("场景"), vec![])).expect("建"); |
| 218 | + assert!(bind_goal_group_inner(&db, goal.id, gid).expect("绑定")); |
| 219 | + assert!(unbind_goal_group_inner(&db, goal.id, gid).expect("解绑")); |
| 220 | + let err = bind_goal_group_inner(&db, goal.id, 999).expect_err("缺组"); |
| 221 | + assert!(err.contains("笔记组不存在")); |
| 222 | +} |
0 commit comments