Skip to content

Commit 8997248

Browse files
committed
feat(flashcards): 暴露只读真实间隔字段
Flashcard += interval_days(线格式 intervalDays,只读;字段落在末尾 ⇒ 既有 10 键相对序不变): ① review_card 返回体 = 当次调度精确值(显式覆写,防 ..card 漏出 row_to_card 旧值); ② 行派生(row_to_card,覆盖 5 个查询方法)= 整天粒度;新卡/无复习记录/劣化输入 ⇒ 0.0。 命令壳抽出 review_card_inner 以便单测 ①域(仓内 inner 约定;tauri State 无法在单测装配)。
1 parent 0042ecf commit 8997248

5 files changed

Lines changed: 104 additions & 14 deletions

File tree

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

Lines changed: 17 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ use tauri::State;
1111

1212
use crate::card_generate::{card_from_fragment, cards_from_note};
1313
use crate::commands::AppState;
14+
use crate::db::Db;
1415
use crate::db_flashcards::NewFlashcard;
1516
use crate::scheduler::{schedule, CardState, Rating};
1617
use crate::types::Flashcard;
@@ -162,21 +163,27 @@ pub fn count_due_cards(state: State<'_, AppState>, group_id: Option<i64>) -> Res
162163
}
163164

164165
/// 复习评分(提取优先闭环:front→回忆→back→评分→调度推进)。
165-
///
166-
/// @ai-context: state_json 损坏 → 回退新卡状态重学(诚实降级不 panic);
167-
/// card_reviewed 指标每次必记(北极星组成①的数据源)。
168166
#[tauri::command]
169167
pub fn review_card(
170168
state: State<'_, AppState>,
171169
card_id: i64,
172170
rating: String,
173171
) -> Result<Flashcard, String> {
172+
review_card_inner(&state.db, card_id, &rating)
173+
}
174+
175+
/// 复习评分编排(inner——命令壳只解引用 State;仓内约定「inner 等价于测全命令」)。
176+
///
177+
/// @ai-context: state_json 损坏 → 回退新卡状态重学(诚实降级不 panic);
178+
/// card_reviewed 指标每次必记(北极星组成①的数据源)。
179+
/// @ai-context: 返回体的 interval_days 必须**显式覆写**为当次调度的精确值——`..card`
180+
/// 结构体更新语法会把 row_to_card 的旧值(整天粒度)漏出去。
181+
pub(crate) fn review_card_inner(db: &Db, card_id: i64, rating: &str) -> Result<Flashcard, String> {
174182
if card_id <= 0 {
175183
return Err("无效的卡片 id".to_string());
176184
}
177-
let rating = Rating::parse(&rating).ok_or_else(|| format!("不支持的评分: {}", rating))?;
178-
let card = state
179-
.db
185+
let rating = Rating::parse(rating).ok_or_else(|| format!("不支持的评分: {}", rating))?;
186+
let card = db
180187
.get_card(card_id)
181188
.map_err(|e| e.to_string())?
182189
.ok_or_else(|| format!("卡片不存在: {}", card_id))?;
@@ -190,22 +197,19 @@ pub fn review_card(
190197
now,
191198
);
192199
let state_json = serde_json::to_string(&outcome.next).unwrap_or_default();
193-
state
194-
.db
195-
.update_card_schedule(card_id, &state_json, outcome.due_at_ms as i64)
200+
db.update_card_schedule(card_id, &state_json, outcome.due_at_ms as i64)
196201
.map_err(|e| e.to_string())?;
197-
state
198-
.db
199-
.add_review_log(card_id, &rating.to_string_lower(), now as i64)
202+
db.add_review_log(card_id, &rating.to_string_lower(), now as i64)
200203
.map_err(|e| e.to_string())?;
201204
// 北极星埋点(组有复习记录=组成①;payload 带组 id 供按组聚合)
202205
let payload =
203206
serde_json::json!({ "cardId": card_id, "groupId": card.group_id, "rating": rating.to_string_lower() })
204207
.to_string();
205-
let _ = state.db.add_metric_event("card_reviewed", &payload);
208+
let _ = db.add_metric_event("card_reviewed", &payload);
206209
Ok(Flashcard {
207210
state_json,
208211
due_at: outcome.due_at_ms as i64,
212+
interval_days: outcome.interval_days,
209213
..card
210214
})
211215
}

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

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ impl Db {
5151
state_json: new.state_json.clone(),
5252
due_at: new.due_at,
5353
created_at: now,
54+
interval_days: 0.0,
5455
})
5556
})
5657
}
@@ -283,8 +284,15 @@ fn row_to_card(row: &rusqlite::Row<'_>) -> rusqlite::Result<Flashcard> {
283284
state_json: row.get(7)?,
284285
due_at: row.get(8)?,
285286
created_at: row.get(9)?,
287+
interval_days: interval_days_from(row.get(8)?, &row.get::<_, String>(7)?),
286288
})
287289
}
290+
/// 行派生间隔(整天粒度):`due_at − stateJson.lastReviewMs` 反推;无复习记录/劣化输入 ⇒ 0.0。
291+
fn interval_days_from(due_at: i64, state_json: &str) -> f32 {
292+
let s: crate::scheduler::CardState = serde_json::from_str(state_json).unwrap_or_default();
293+
let days = (due_at.saturating_sub(s.last_review_ms as i64)) as f64 / 86_400_000.0;
294+
if s.last_review_ms == 0 || days <= 0.0 { 0.0 } else { days.round().max(1.0) as f32 }
295+
}
288296

289297
/// 单测独立文件。
290298
#[cfg(test)]

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

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
//! db_flashcards 单测(内存库;AAA 模式)。
22
3+
use crate::commands_flashcards::review_card_inner;
34
use crate::db::Db;
45
use crate::db_flashcards::NewFlashcard;
6+
use crate::scheduler::{schedule, CardState, Rating};
57
use crate::types::NewNoteGroup;
68

79
/// 内存库。
@@ -224,3 +226,69 @@ fn update_card_back_roundtrip() {
224226
assert_eq!(fetched.back, new_back);
225227
assert!(!db.update_card_back(9999, "x").expect("miss"));
226228
}
229+
230+
/// 复习过的卡状态 JSON(`reps=0` ⇒ `review_card` 内部按新卡态调度:间隔与 now 无关)。
231+
fn reviewed_state(last_review_ms: i64) -> String {
232+
format!(
233+
r#"{{"stability":0.0,"difficulty":0.0,"reps":0,"lapses":0,"lastReviewMs":{}}}"#,
234+
last_review_ms
235+
)
236+
}
237+
238+
#[test]
239+
fn interval_days_new_card_is_zero_and_degraded_inputs_safe() {
240+
// Arrange:新卡(state_json="{}" ⇒ lastReviewMs 缺键 ⇒ 0)
241+
let db = mem_db();
242+
let gid = make_group(&db);
243+
// Act
244+
let created = db.create_card(&card(gid, "新卡", 1000)).expect("create");
245+
let fetched = db.get_card(created.id).expect("get").expect("exists");
246+
// Assert:新卡无间隔——两路都 0.0(不发明 due_at/86400000 这类数字)
247+
assert_eq!(created.interval_days, 0.0);
248+
assert_eq!(fetched.interval_days, 0.0);
249+
// Act/Assert(劣化输入):lastReviewMs > due_at ⇒ 0.0;due_at 为负 ⇒ 0.0;均不 panic
250+
db.update_card_schedule(created.id, &reviewed_state(9_000_000), 1000).expect("u1");
251+
assert_eq!(db.get_card(created.id).expect("g1").expect("e1").interval_days, 0.0);
252+
db.update_card_schedule(created.id, &reviewed_state(5000), -1).expect("u2");
253+
assert_eq!(db.get_card(created.id).expect("g2").expect("e2").interval_days, 0.0);
254+
}
255+
256+
#[test]
257+
fn interval_days_row_derived_is_whole_days() {
258+
// Arrange:上次复习 T0、到期 T0+31 天(夹具构造;不读 stateJson 语义)
259+
let db = mem_db();
260+
let gid = make_group(&db);
261+
let t0 = 1_700_000_000_000i64;
262+
let mut new = card(gid, "极限", t0 + 31 * 86_400_000);
263+
new.state_json = reviewed_state(t0);
264+
db.create_card(&new).expect("create");
265+
// Act:队列路径(5 个查询方法共用 row_to_card 供值)
266+
let due = db.list_due_cards(None, i64::MAX, 10).expect("due");
267+
// Assert:整天粒度 = round((due_at − lastReviewMs)/86400000).max(1)
268+
assert_eq!(due.len(), 1);
269+
assert_eq!(due[0].interval_days, 31.0);
270+
}
271+
272+
#[test]
273+
fn review_card_returns_precise_interval_not_stale_row_value() {
274+
// Arrange:行派生值刻意构造成 31 天(陈旧值)——本次评分必不等于它
275+
let db = mem_db();
276+
let gid = make_group(&db);
277+
let t0 = 1_700_000_000_000i64;
278+
let mut new = card(gid, "极限", t0 + 31 * 86_400_000);
279+
new.state_json = reviewed_state(t0);
280+
let id = db.create_card(&new).expect("create").id;
281+
let stale = db.get_card(id).expect("get").expect("exists");
282+
assert_eq!(stale.interval_days, 31.0, "②域:行派生整天粒度");
283+
// Act:复习编排(命令壳只解引用 State ⇒ inner 等价于测全命令)
284+
let returned = review_card_inner(&db, id, "good").expect("review");
285+
// Assert:①域 = 当次 ScheduleOutcome.interval_days(reps=0 ⇒ 新卡态 ⇒ 与 now 无关)
286+
assert_eq!(returned.interval_days, schedule(None, Rating::Good, 0).interval_days);
287+
assert_ne!(returned.interval_days, stale.interval_days, "`..card` 不得漏出旧值");
288+
// Assert:返回的间隔就是驱动 dueAt 的那一个(scheduler.rs:100 恒等式)
289+
let next: CardState = serde_json::from_str(&returned.state_json).expect("next");
290+
assert_eq!(
291+
returned.due_at,
292+
next.last_review_ms as i64 + (returned.interval_days.round() as i64).max(1) * 86_400_000
293+
);
294+
}

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ fn contract_keys_and_order() {
3131
assert_wire!("MoveFragmentResult", MoveFragmentResult { moved: true, auto_cleaned_groups: vec!["g".into()] }, r#"{"moved":true,"autoCleanedGroups":["g"]}"#);
3232
assert_wire!("PromoteNoteResult", PromoteNoteResult { note: note(1), auto_cleaned_groups: vec!["g".into()] }, r#"{"note":{"id":1,"title":"t","content":"c","source":"manual","session_id":1,"rule_version":"r","purify_stats":"p","tags":"[]","properties":"{}","pin":1,"group_id":2,"created_at":3,"updated_at":4},"autoCleanedGroups":["g"]}"#);
3333
assert_wire!("Fragment", Fragment { id: 1, text: "t".into(), image_path: Some("p".into()), domain_tag: Some("d".into()), group_id: Some(2), source: "manual".into(), status: "active".into(), created_at: 3 }, r#"{"id":1,"text":"t","imagePath":"p","domainTag":"d","groupId":2,"source":"manual","status":"active","createdAt":3}"#);
34-
assert_wire!("Flashcard", Flashcard { id: 1, group_id: 2, note_id: Some(3), fragment_id: Some(4), front: "f".into(), back: "b".into(), kind: "fact".into(), state_json: "{}".into(), due_at: 5, created_at: 6 }, r#"{"id":1,"groupId":2,"noteId":3,"fragmentId":4,"front":"f","back":"b","kind":"fact","stateJson":"{}","dueAt":5,"createdAt":6}"#);
34+
assert_wire!("Flashcard", Flashcard { id: 1, group_id: 2, note_id: Some(3), fragment_id: Some(4), front: "f".into(), back: "b".into(), kind: "fact".into(), state_json: "{}".into(), due_at: 5, created_at: 6, interval_days: 1.5 }, r#"{"id":1,"groupId":2,"noteId":3,"fragmentId":4,"front":"f","back":"b","kind":"fact","stateJson":"{}","dueAt":5,"createdAt":6,"intervalDays":1.5}"#);
3535
assert_wire!("WeekContract", WeekContract { id: 1, group_id: 2, week_start: 3, target_days: 4, target_cards: 5, created_at: 6 }, r#"{"id":1,"groupId":2,"weekStart":3,"targetDays":4,"targetCards":5,"createdAt":6}"#);
3636
assert_wire!("NoteSortMode", NoteSortMode::UpdatedDesc, r#""updated-desc""#);
3737
assert_wire!("Session", Session { id: 1, title: "t".into(), source_window: Some("w".into()), started_at: 2, ended_at: Some(3), status: "finished".into(), profile: Some("p".into()), kind: Some("photo".into()) }, r#"{"id":1,"title":"t","source_window":"w","started_at":2,"ended_at":3,"status":"finished","profile":"p","kind":"photo"}"#);

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

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -244,6 +244,16 @@ pub struct Flashcard {
244244
/// 到期时刻(Unix 毫秒;due_at ≤ now 进复习队列)
245245
pub due_at: i64,
246246
pub created_at: i64,
247+
/// 真实复习间隔天数(**只读**;前端唯一口径——不得用 dueAt 差值或字符数自造)。
248+
///
249+
/// @ai-context: 两个精度域(PB2 裁决):① `review_card` 返回体 = 当次调度的**精确值**
250+
/// (`ScheduleOutcome.interval_days` 当场显式覆写);② 行派生路径
251+
/// (`row_to_card` 由 `due_at − stateJson.lastReviewMs` 反推,覆盖
252+
/// list_due_cards / get_card / card_by_fragment / list_cards_by_group /
253+
/// find_card_by_front)= **整天粒度**(DB 无 interval 列,f32 原值不可恢复)。
254+
/// 新卡/无复习记录/劣化输入 ⇒ `0.0`(不发明数字);确切时刻看 `dueAt`。
255+
/// 契约不变:`stateJson` 仍是「后端调度契约,前端透传不解析」。
256+
pub interval_days: f32,
247257
}
248258

249259
/// 周契约(v0.11.4 REQ-200;弹性承诺呈现层——用户自设本周目标,非打卡 KPI)。

0 commit comments

Comments
 (0)