|
| 1 | +//! commands_goals_lifecycle 单测(毕业仪式守卫矩阵/回顾流/放弃/档案——内存库)。 |
| 2 | +
|
| 3 | +use crate::commands_goals::{create_goal_inner, set_goal_milestone_status_inner, GoalCreateInput, GoalMilestoneInput}; |
| 4 | +use crate::commands_goals_lifecycle::{ |
| 5 | + goal_abandon_inner, goal_retro_inner, goal_settle_inner, list_goal_graduations_inner, |
| 6 | +}; |
| 7 | +use crate::db::Db; |
| 8 | +use crate::goal_schema::TIER_HANDS_ON; |
| 9 | +use crate::types::NewNoteGroup; |
| 10 | + |
| 11 | +fn mem_db() -> Db { |
| 12 | + Db::open(":memory:").expect("内存库打开") |
| 13 | +} |
| 14 | + |
| 15 | +fn group(db: &Db, name: &str) -> i64 { |
| 16 | + db.create_group(&NewNoteGroup { |
| 17 | + name: name.to_string(), |
| 18 | + terrain: "container".to_string(), |
| 19 | + kind: "standalone".to_string(), |
| 20 | + domain_tag: None, |
| 21 | + source: "route".to_string(), |
| 22 | + series_key: None, |
| 23 | + route_reason: None, |
| 24 | + }) |
| 25 | + .expect("建组") |
| 26 | + .id |
| 27 | +} |
| 28 | + |
| 29 | +fn input(tier: Option<&str>, scenario: Option<&str>, group_ids: Vec<i64>) -> GoalCreateInput { |
| 30 | + GoalCreateInput { |
| 31 | + name: "学会 Python".to_string(), |
| 32 | + domain_tag: Some("programming".to_string()), |
| 33 | + horizon: Some("3m".to_string()), |
| 34 | + tier: tier.map(str::to_string), |
| 35 | + scenario: scenario.map(str::to_string), |
| 36 | + level: Some("zero".to_string()), |
| 37 | + driver: Some("work".to_string()), |
| 38 | + criteria_statement: Some("能独立完成实例".to_string()), |
| 39 | + non_scope: Some("不做 Web 框架".to_string()), |
| 40 | + weekly_commitment: Some("5h+".to_string()), |
| 41 | + obstacles: None, |
| 42 | + group_ids, |
| 43 | + milestones: vec![ |
| 44 | + GoalMilestoneInput { title: "基础".to_string(), due_weeks: 4 }, |
| 45 | + GoalMilestoneInput { title: "应用".to_string(), due_weeks: 8 }, |
| 46 | + ], |
| 47 | + } |
| 48 | +} |
| 49 | + |
| 50 | +/// 达成毕业判据所需状态(hands_on:里程碑全 done + 组结算 1 次)。 |
| 51 | +fn make_ready(db: &Db, goal_id: i64, gid: i64) { |
| 52 | + db.create_settlement(gid, r#"{}"#).expect("结算"); |
| 53 | + for m in db.list_milestones(goal_id).expect("里程碑") { |
| 54 | + set_goal_milestone_status_inner(db, m.id, "done").expect("完成"); |
| 55 | + } |
| 56 | +} |
| 57 | + |
| 58 | +#[test] |
| 59 | +fn settle_rejects_when_criteria_not_met() { |
| 60 | + let db = mem_db(); |
| 61 | + let gid = group(&db, "组"); |
| 62 | + let goal = create_goal_inner(&db, &input(Some(TIER_HANDS_ON), Some("场景"), vec![gid])).expect("建"); |
| 63 | + // 未达标(里程碑 0/2) |
| 64 | + let err = goal_settle_inner(&db, goal.id).expect_err("判据不足"); |
| 65 | + assert!(err.contains("毕业判据未全部满足")); |
| 66 | + assert!(err.contains("里程碑")); |
| 67 | + // 状态未被改动 |
| 68 | + assert_eq!(db.get_goal(goal.id).expect("读").unwrap().status, "active"); |
| 69 | +} |
| 70 | + |
| 71 | +#[test] |
| 72 | +fn settle_guards_terminated_statuses() { |
| 73 | + let db = mem_db(); |
| 74 | + let gid = group(&db, "组"); |
| 75 | + let goal = create_goal_inner(&db, &input(Some(TIER_HANDS_ON), Some("场景"), vec![gid])).expect("建"); |
| 76 | + make_ready(&db, goal.id, gid); |
| 77 | + assert!(goal_settle_inner(&db, goal.id).expect("毕业").graduated_at > 0); |
| 78 | + // 重复毕业:已毕业终态拒绝 |
| 79 | + let err = goal_settle_inner(&db, goal.id).expect_err("重复毕业"); |
| 80 | + assert!(err.contains("非法毕业状态转移")); |
| 81 | + // 已放弃亦然 |
| 82 | + let g2 = create_goal_inner(&db, &input(Some(TIER_HANDS_ON), Some("场景2"), vec![])).expect("建2"); |
| 83 | + goal_abandon_inner(&db, g2.id, Some("没时间")).expect("放弃"); |
| 84 | + let err2 = goal_settle_inner(&db, g2.id).expect_err("放弃后毕业"); |
| 85 | + assert!(err2.contains("非法毕业状态转移")); |
| 86 | +} |
| 87 | + |
| 88 | +#[test] |
| 89 | +fn settle_writes_snapshot_and_graduated_status() { |
| 90 | + let db = mem_db(); |
| 91 | + let gid = group(&db, "Python 组"); |
| 92 | + let goal = create_goal_inner(&db, &input(Some(TIER_HANDS_ON), Some("场景"), vec![gid])).expect("建"); |
| 93 | + db.create_settlement(gid, r#"{"merged":2}"#).expect("结算"); |
| 94 | + for m in db.list_milestones(goal.id).expect("里程碑") { |
| 95 | + set_goal_milestone_status_inner(&db, m.id, "done").expect("完成"); |
| 96 | + } |
| 97 | + let report = goal_settle_inner(&db, goal.id).expect("毕业"); |
| 98 | + assert_eq!(report.goal_name, "学会 Python"); |
| 99 | + assert_eq!(report.milestones.len(), 2); |
| 100 | + assert_eq!(report.group_settlements[0].settlement_count, 1); |
| 101 | + assert!(report.criteria_statement.contains("结算 1 次"), "配方语句:{}", report.criteria_statement); |
| 102 | + let g = db.get_goal(goal.id).expect("读").unwrap(); |
| 103 | + assert_eq!(g.status, "graduated"); |
| 104 | + assert!(g.completed_at.is_some()); |
| 105 | + assert_eq!(db.count_metric_events("goal_graduated").expect("埋点"), 1); |
| 106 | +} |
| 107 | + |
| 108 | +#[test] |
| 109 | +fn graduation_report_survives_goal_delete() { |
| 110 | + // 验收 5:毕业报告在目标删除后仍可读(快照独立于 goals 行——FK SET NULL) |
| 111 | + let db = mem_db(); |
| 112 | + let gid = group(&db, "组"); |
| 113 | + let goal = create_goal_inner(&db, &input(Some(TIER_HANDS_ON), Some("场景"), vec![gid])).expect("建"); |
| 114 | + make_ready(&db, goal.id, gid); |
| 115 | + goal_settle_inner(&db, goal.id).expect("毕业"); |
| 116 | + crate::commands_goals::delete_goal_inner(&db, goal.id).expect("删目标"); |
| 117 | + let archives = list_goal_graduations_inner(&db).expect("档案"); |
| 118 | + assert_eq!(archives.len(), 1, "目标删除后报告保留"); |
| 119 | + assert_eq!(archives[0].goal_id, goal.id, "快照保留原 goal_id(行链接 SET NULL)"); |
| 120 | + assert_eq!(archives[0].goal_name, "学会 Python"); |
| 121 | +} |
| 122 | + |
| 123 | +#[test] |
| 124 | +fn retro_shows_timeline_with_graduation() { |
| 125 | + let db = mem_db(); |
| 126 | + let gid = group(&db, "组"); |
| 127 | + let goal = create_goal_inner(&db, &input(Some(TIER_HANDS_ON), Some("场景"), vec![gid])).expect("建"); |
| 128 | + make_ready(&db, goal.id, gid); |
| 129 | + goal_settle_inner(&db, goal.id).expect("毕业"); |
| 130 | + let view = goal_retro_inner(&db, goal.id).expect("回顾流"); |
| 131 | + assert!(view.graduation.is_some()); |
| 132 | + let kinds: Vec<&str> = view.entries.iter().map(|e| e.kind.as_str()).collect(); |
| 133 | + assert!(kinds.contains(&"created")); |
| 134 | + assert!(kinds.contains(&"milestone")); |
| 135 | + assert!(kinds.contains(&"settlement")); |
| 136 | + assert!(kinds.contains(&"graduated")); |
| 137 | + assert_eq!(view.status, "graduated"); |
| 138 | + // 未毕业目标:无 graduation 时间线节点 |
| 139 | + let g2 = create_goal_inner(&db, &input(Some(TIER_HANDS_ON), Some("场景2"), vec![])).expect("建2"); |
| 140 | + let v2 = goal_retro_inner(&db, g2.id).expect("回顾流2"); |
| 141 | + assert!(v2.graduation.is_none()); |
| 142 | + assert!(v2.entries.iter().all(|e| e.kind != "graduated")); |
| 143 | +} |
| 144 | + |
| 145 | +#[test] |
| 146 | +fn abandon_records_reason_and_guards() { |
| 147 | + let db = mem_db(); |
| 148 | + let goal = create_goal_inner(&db, &input(Some(TIER_HANDS_ON), Some("场景"), vec![])).expect("建"); |
| 149 | + assert!(goal_abandon_inner(&db, goal.id, Some(" 转行,不学了 ")).expect("放弃")); |
| 150 | + let g = db.get_goal(goal.id).expect("读").unwrap(); |
| 151 | + assert_eq!(g.status, "abandoned"); |
| 152 | + assert!(g.completed_at.is_some()); |
| 153 | + // 原因入埋点(审计可查)+ 超长截断 200 |
| 154 | + assert_eq!(db.count_metric_events("goal_abandoned").expect("埋点"), 1); |
| 155 | + // 终态拒绝重复放弃 |
| 156 | + let err = goal_abandon_inner(&db, goal.id, None).expect_err("重复放弃"); |
| 157 | + assert!(err.contains("非法放弃状态转移")); |
| 158 | + // 超长原因截断 |
| 159 | + let g2 = create_goal_inner(&db, &input(Some(TIER_HANDS_ON), Some("场景2"), vec![])).expect("建2"); |
| 160 | + let long = "长".repeat(300); |
| 161 | + goal_abandon_inner(&db, g2.id, Some(&long)).expect("放弃2"); |
| 162 | + assert_eq!(db.count_metric_events("goal_abandoned").expect("埋点2"), 2); |
| 163 | +} |
0 commit comments