Skip to content

Commit 7a369cf

Browse files
committed
feat(goal): 学习目标层 M1 后端——三表/访谈纯函数/状态机/命令与埋点(REQ-248~250)
- 数据层:goals/goal_milestones/goal_groups 三表幂等 DDL + db_goals CRUD/N:M 绑定/ 组结算里程碑自动通过钩子 + db_goals_progress 聚合查询(结算历史/周契约/90 天活跃/ 应用记录/FSRS 低稳定性弱项)——:memory: 测试含组删除 CASCADE 影响面 - 纯函数:goal_interview(配方推导/suggest_milestones/宣言组装,table-driven golden 6 条端到端+参数矩阵)+ goal_rules(四态状态机/毕业判据守卫)+ goal_progress - 命令层:create/list/detail/progress/update/重访谈/删除/暂停恢复/里程碑 CRUD/绑定解绑/ 里程碑草案建议共 15 命令;第 1/3 问必答校验;goal_created/goal_milestone_done 埋点 - 结算钩子:execute_settlement 后 group_settled 型里程碑自动通过(只读聚合+事件钩子, 钩子失败仅记日志不影响结算);self_test_* 仅登记占位契约(M3 真实化)
1 parent ddc0ee9 commit 7a369cf

16 files changed

Lines changed: 2989 additions & 0 deletions

‎app/src-tauri/src/commands_goals.rs‎

Lines changed: 591 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 222 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,222 @@
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+
}

‎app/src-tauri/src/commands_settlement.rs‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,17 @@ pub fn execute_settlement(
113113
state.db.create_settlement(group_id, &stats).map_err(|e| e.to_string())?;
114114
let payload = serde_json::json!({ "groupId": group_id, "merged": merged, "archived": archived }).to_string();
115115
let _ = state.db.add_metric_event("group_settled", &payload);
116+
// v0.18.0(REQ-248):目标层事件钩子——绑该组的 group_settled 型里程碑
117+
// 随结算自动通过(目标层是纯只读聚合+事件钩子,不侵入结算状态机;
118+
// 钩子失败仅记日志——结算仪式不能因目标层增强而失败,规格 §九 ①)。
119+
match state.db.mark_group_settled_milestones(group_id) {
120+
Ok(n) if n > 0 => {
121+
let g_payload = serde_json::json!({ "groupId": group_id, "autoPassed": n }).to_string();
122+
let _ = state.db.add_metric_event("goal_milestone_done", &g_payload);
123+
}
124+
Ok(_) => {}
125+
Err(e) => eprintln!("[goal] 组结算里程碑自动通过失败(不影响结算): {}", e),
126+
}
116127
Ok(SettlementResult { merged, archived, core_note_id })
117128
}
118129

0 commit comments

Comments
 (0)