新增: Rust Op reducer + 逆操作计算(B2)

- op.rs: 纯函数 apply_op(deck,op) → new_deck,覆盖全部 11 种 Op 类型
- invert_op(deck,op) → 逆 Op,支持完整的 undo 链路
- 所有 reducer 不 mutate 输入,返回新 deck
- 8 个单元测试: add_slide/del_slide/set_theme/update_element/del_element/
  move_slide/undo_update/undo_add_slide/replace_deck_undo
- ElementStyle 加 rename_all=camelCase 匹配 JS 协议
- 32 个测试全部通过(B1:23 + B2:9)
This commit is contained in:
2026-07-12 22:33:41 +08:00
parent 1430e9e061
commit 664ca9c65c
4 changed files with 561 additions and 1 deletions

View File

@@ -12,7 +12,7 @@ export type ChatMessage = { role: string, content: string, };
export type Deck = { v: number, theme: string, slides: Array<Slide>, chat_id: string | null, }; export type Deck = { v: number, theme: string, slides: Array<Slide>, chat_id: string | null, };
export type ElementStyle = { font_size: number | null, color: string | null, align: string | null, bold: boolean | null, italic: boolean | null, anim: string | null, label: string | null, label_color: string | null, label_size: number | null, fit: string | null, shape_type: ShapeType | null, fill: string | null, gradient: boolean | null, opacity: number | null, radius: number | null, max: number | null, chart_type: ChartType | null, legend: boolean | null, stack: boolean | null, grid: boolean | null, header: boolean | null, lang: string | null, inline: boolean | null, icon: string | null, accent: string | null, }; export type ElementStyle = { fontSize: number | null, color: string | null, align: string | null, bold: boolean | null, italic: boolean | null, anim: string | null, label: string | null, labelColor: string | null, labelSize: number | null, fit: string | null, shapeType: ShapeType | null, fill: string | null, gradient: boolean | null, opacity: number | null, radius: number | null, max: number | null, chartType: ChartType | null, legend: boolean | null, stack: boolean | null, grid: boolean | null, header: boolean | null, lang: string | null, inline: boolean | null, icon: string | null, accent: string | null, };
export type ElementType = "title" | "text" | "list" | "stat" | "quote" | "image" | "shape" | "chart" | "card" | "table" | "code" | "formula"; export type ElementType = "title" | "text" | "list" | "stat" | "quote" | "image" | "shape" | "chart" | "card" | "table" | "code" | "formula";

View File

@@ -9,6 +9,7 @@
mod color; mod color;
mod model; mod model;
mod op;
/// IPC 验证命令 /// IPC 验证命令
#[tauri::command] #[tauri::command]

View File

@@ -157,6 +157,7 @@ pub struct RichLine {
#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)] #[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]
#[ts(export, export_to = "../bindings/types.ts")] #[ts(export, export_to = "../bindings/types.ts")]
#[serde(rename_all = "camelCase")]
pub struct ElementStyle { pub struct ElementStyle {
pub font_size: Option<f64>, pub font_size: Option<f64>,
pub color: Option<String>, pub color: Option<String>,

558
src-tauri/src/op.rs Normal file
View File

@@ -0,0 +1,558 @@
/* =====================================================================
* op.rs — Op Log reducer对应 src/core/op.ts
*
* 纯函数apply_op(deck, op) → new deck不 mutate 输入
* invert_op(deck, op) → 能撤销 op 的逆 op
*
* 所有 Op 通过 serde_json::Value 传递 payload按 op_type 分发。
* ===================================================================== */
use crate::model::{Deck, Slide, SlideElement};
/* ---------- 工具 ---------- */
fn clone_deck(deck: &Deck) -> Deck {
serde_json::from_str(&serde_json::to_string(deck).unwrap()).unwrap()
}
fn find_element(deck: &Deck, slide_idx: usize, el_id: &str) -> Option<usize> {
let slide = deck.slides.get(slide_idx)?;
slide.elements.iter().position(|e| e.id == el_id)
}
/* ---------- reducer ---------- */
pub fn apply_op(input_deck: &Deck, op: &crate::model::Op) -> Deck {
let mut deck = clone_deck(input_deck);
match op.op_type.as_str() {
"add_slide" => {
if let Some(slide) = op.payload.get("slide").and_then(|v| v.as_object()) {
if let Ok(s) = serde_json::from_value(serde_json::Value::Object(slide.clone())) as Result<Slide, _> {
let at_index = op.payload.get("atIndex")
.or_else(|| op.payload.get("at_index"))
.and_then(|v| v.as_f64())
.map(|f| f as usize)
.unwrap_or(deck.slides.len());
let idx = at_index.min(deck.slides.len());
deck.slides.insert(idx, s);
}
}
}
"del_slide" => {
if deck.slides.len() <= 1 {
return deck;
}
if let Some(index) = op.payload.get("index").and_then(|v| v.as_f64()).map(|f| f as usize) {
if index < deck.slides.len() {
deck.slides.remove(index);
}
}
}
"move_slide" => {
let from = op.payload.get("from").and_then(|v| v.as_f64()).map(|f| f as usize);
let to = op.payload.get("to").and_then(|v| v.as_f64()).map(|f| f as usize);
if let (Some(from), Some(to)) = (from, to) {
if from != to && from < deck.slides.len() && to < deck.slides.len() {
let item = deck.slides.remove(from);
deck.slides.insert(to, item);
}
}
}
"replace_slide" => {
if let Some(slide_val) = op.payload.get("slide") {
if let Ok(mut s) = serde_json::from_value(slide_val.clone()) as Result<Slide, _> {
if let Some(index) = op.payload.get("index").and_then(|v| v.as_f64()).map(|f| f as usize) {
if let Some(existing) = deck.slides.get(index) {
s.id = existing.id.clone(); // 保持 id 不变
}
if index < deck.slides.len() {
deck.slides[index] = s;
}
}
}
}
}
"set_slide_bg" => {
if let Some(index) = op.payload.get("index").and_then(|v| v.as_f64()).map(|f| f as usize) {
if let Some(bg) = op.payload.get("bg").and_then(|v| v.as_str()) {
if let Some(slide) = deck.slides.get_mut(index) {
slide.background = bg.to_string();
}
}
}
}
"add_element" => {
if let Some(el_val) = op.payload.get("element") {
if let Ok(el) = serde_json::from_value(el_val.clone()) as Result<SlideElement, _> {
if let Some(slide_idx) = op.payload.get("slideIdx")
.or_else(|| op.payload.get("slide_idx"))
.and_then(|v| v.as_f64())
.map(|f| f as usize)
{
if let Some(slide) = deck.slides.get_mut(slide_idx) {
slide.elements.push(el);
}
}
}
}
}
"update_element" => {
let slide_idx = op.payload.get("slideIdx")
.or_else(|| op.payload.get("slide_idx"))
.and_then(|v| v.as_f64())
.map(|f| f as usize);
let element_id = op.payload.get("elementId")
.or_else(|| op.payload.get("element_id"))
.and_then(|v| v.as_str());
if let (Some(sidx), Some(eid)) = (slide_idx, element_id) {
if let Some(eidx) = find_element(&deck, sidx, eid) {
let slide = &mut deck.slides[sidx];
let el = &mut slide.elements[eidx];
// 应用 patch
if let Some(patch) = op.payload.get("patch").and_then(|v| v.as_object()) {
if let Some(style_patch) = patch.get("style").and_then(|v| v.as_object()) {
// 字段级 merge style
let current_style_val = serde_json::to_value(&el.style).unwrap_or_default();
if let Ok(mut current_style_map) = current_style_val.as_object().cloned().ok_or(()) {
for (k, v) in style_patch {
current_style_map.insert(k.clone(), v.clone());
}
if let Ok(new_style) = serde_json::from_value(serde_json::Value::Object(current_style_map)) {
el.style = new_style;
}
}
}
// 非 style 字段
let el_val = serde_json::to_value(&el.clone()).unwrap_or_default();
if let Ok(mut el_map) = el_val.as_object().cloned().ok_or(()) {
for (k, v) in patch {
if k != "style" {
el_map.insert(k.clone(), v.clone());
}
}
if let Ok(new_el) = serde_json::from_value(serde_json::Value::Object(el_map)) {
*el = new_el;
}
}
}
}
}
}
"del_element" => {
let slide_idx = op.payload.get("slideIdx")
.or_else(|| op.payload.get("slide_idx"))
.and_then(|v| v.as_f64())
.map(|f| f as usize);
let element_id = op.payload.get("elementId")
.or_else(|| op.payload.get("element_id"))
.and_then(|v| v.as_str());
if let (Some(sidx), Some(eid)) = (slide_idx, element_id) {
if let Some(slide) = deck.slides.get_mut(sidx) {
slide.elements.retain(|e| e.id != eid);
}
}
}
"move_element_z" => {
let slide_idx = op.payload.get("slideIdx")
.or_else(|| op.payload.get("slide_idx"))
.and_then(|v| v.as_f64())
.map(|f| f as usize);
let element_id = op.payload.get("elementId")
.or_else(|| op.payload.get("element_id"))
.and_then(|v| v.as_str());
let dir = op.payload.get("dir").and_then(|v| v.as_f64()).map(|f| f as i64);
if let (Some(sidx), Some(eid), Some(dir)) = (slide_idx, element_id, dir) {
if let Some(eidx) = find_element(&deck, sidx, eid) {
let j = if dir > 0 { eidx + 1 } else { eidx.saturating_sub(1) };
if let Some(slide) = deck.slides.get_mut(sidx) {
if j < slide.elements.len() {
slide.elements.swap(eidx, j);
}
}
}
}
}
"set_theme" => {
if let Some(theme) = op.payload.get("theme").and_then(|v| v.as_str()) {
deck.theme = theme.to_string();
}
}
"replace_deck" => {
if let Some(new_deck) = op.payload.get("deck") {
if let Ok(mut d) = serde_json::from_value(new_deck.clone()) as Result<Deck, _> {
d.v = deck.v; // 保持版本号
return d;
}
}
}
_ => {} // 未知 Op不修改
}
deck
}
/* ---------- 逆操作计算 ---------- */
pub fn invert_op(input_deck: &Deck, op: &crate::model::Op) -> crate::model::Op {
use crate::model::Op;
use serde_json::json;
match op.op_type.as_str() {
"add_slide" => {
// 逆:删除加的那页
let slide_id = op.payload.get("slide").and_then(|v| v.get("id")).and_then(|v| v.as_str());
let idx = if let Some(sid) = slide_id {
input_deck.slides.iter().position(|s| s.id == sid)
} else {
op.payload.get("atIndex").and_then(|v| v.as_f64()).map(|f| f as usize)
};
Op {
op_type: "del_slide".into(),
client_id: None,
timestamp: None,
payload: json!({ "index": idx.unwrap_or(input_deck.slides.len().saturating_sub(1)) }),
}
}
"del_slide" => {
// 逆:把删掉的页插回原位
let index = op.payload.get("index").and_then(|v| v.as_f64()).map(|f| f as usize);
if let Some(deleted) = op.payload.get("deletedSlide").or_else(|| op.payload.get("deleted_slide")) {
Op {
op_type: "add_slide".into(),
client_id: None,
timestamp: None,
payload: json!({ "atIndex": index, "slide": deleted }),
}
} else {
Op { op_type: "replace_deck".into(), client_id: None, timestamp: None, payload: json!({ "deck": clone_deck(input_deck) }) }
}
}
"move_slide" => {
let from = op.payload.get("from").cloned().unwrap_or_default();
let to = op.payload.get("to").cloned().unwrap_or_default();
Op {
op_type: "move_slide".into(),
client_id: None,
timestamp: None,
payload: json!({ "from": to, "to": from }),
}
}
"replace_slide" => {
let index = op.payload.get("index").cloned().unwrap_or_default();
let old = op.payload.get("oldSlide").or_else(|| op.payload.get("old_slide")).cloned()
.unwrap_or_else(|| {
let idx = index.as_f64().map(|f| f as usize).unwrap_or(0);
serde_json::to_value(input_deck.slides.get(idx).cloned().unwrap_or_else(|| Slide { id: String::new(), background: "bg".into(), elements: vec![] })).unwrap_or_default()
});
Op {
op_type: "replace_slide".into(),
client_id: None,
timestamp: None,
payload: json!({ "index": index, "slide": old }),
}
}
"set_slide_bg" => {
let index = op.payload.get("index").and_then(|v| v.as_f64()).map(|f| f as usize);
let old_bg = index.and_then(|i| input_deck.slides.get(i)).map(|s| s.background.clone()).unwrap_or_else(|| "bg".into());
Op {
op_type: "set_slide_bg".into(),
client_id: None,
timestamp: None,
payload: json!({ "index": index, "bg": old_bg }),
}
}
"add_element" => {
let slide_idx = op.payload.get("slideIdx").or_else(|| op.payload.get("slide_idx")).cloned().unwrap_or_default();
let element_id = op.payload.get("element").and_then(|v| v.get("id")).cloned().unwrap_or_default();
Op {
op_type: "del_element".into(),
client_id: None,
timestamp: None,
payload: json!({ "slideIdx": slide_idx, "elementId": element_id }),
}
}
"update_element" => {
// 构造反向 patch取当前值
let slide_idx = op.payload.get("slideIdx").or_else(|| op.payload.get("slide_idx")).and_then(|v| v.as_f64()).map(|f| f as usize);
let element_id = op.payload.get("elementId").or_else(|| op.payload.get("element_id")).and_then(|v| v.as_str()).unwrap_or("").to_string();
let patch = op.payload.get("patch").cloned().unwrap_or_default();
if let Some(sidx) = slide_idx {
if let Some(eidx) = find_element(input_deck, sidx, &element_id) {
let el = &input_deck.slides[sidx].elements[eidx];
let el_val = serde_json::to_value(el).unwrap_or_default();
let mut backward_patch = serde_json::Map::new();
if let Some(patch_obj) = patch.as_object() {
if let Some(style_patch) = patch_obj.get("style").and_then(|v| v.as_object()) {
let current_style = &el.style;
let style_val = serde_json::to_value(current_style).unwrap_or_default();
let mut old_style = serde_json::Map::new();
if let Some(style_map) = style_val.as_object() {
for (k, _) in style_patch {
if let Some(v) = style_map.get(k) {
old_style.insert(k.clone(), v.clone());
}
}
}
backward_patch.insert("style".into(), serde_json::Value::Object(old_style));
}
for (k, _) in patch_obj {
if k != "style" {
if let Some(v) = el_val.get(k) {
backward_patch.insert(k.clone(), v.clone());
}
}
}
}
return Op {
op_type: "update_element".into(),
client_id: None,
timestamp: None,
payload: json!({ "slideIdx": sidx, "elementId": element_id, "patch": serde_json::Value::Object(backward_patch) }),
};
}
}
Op { op_type: "replace_deck".into(), client_id: None, timestamp: None, payload: json!({ "deck": clone_deck(input_deck) }) }
}
"del_element" => {
let slide_idx = op.payload.get("slideIdx").or_else(|| op.payload.get("slide_idx")).and_then(|v| v.as_f64()).map(|f| f as usize);
let element_id = op.payload.get("elementId").or_else(|| op.payload.get("element_id")).and_then(|v| v.as_str()).unwrap_or("").to_string();
if let Some(deleted) = op.payload.get("deletedElement").or_else(|| op.payload.get("deleted_element")) {
Op {
op_type: "add_element".into(),
client_id: None,
timestamp: None,
payload: json!({ "slideIdx": slide_idx, "element": deleted }),
}
} else {
Op { op_type: "replace_deck".into(), client_id: None, timestamp: None, payload: json!({ "deck": clone_deck(input_deck) }) }
}
}
"move_element_z" => {
let slide_idx = op.payload.get("slideIdx").or_else(|| op.payload.get("slide_idx")).cloned().unwrap_or_default();
let element_id = op.payload.get("elementId").or_else(|| op.payload.get("element_id")).cloned().unwrap_or_default();
let dir = op.payload.get("dir").and_then(|v| v.as_f64()).unwrap_or(0.0);
Op {
op_type: "move_element_z".into(),
client_id: None,
timestamp: None,
payload: json!({ "slideIdx": slide_idx, "elementId": element_id, "dir": -dir }),
}
}
"set_theme" => {
Op {
op_type: "set_theme".into(),
client_id: None,
timestamp: None,
payload: json!({ "theme": input_deck.theme }),
}
}
"replace_deck" => {
Op {
op_type: "replace_deck".into(),
client_id: None,
timestamp: None,
payload: json!({ "deck": clone_deck(input_deck) }),
}
}
_ => Op {
op_type: "replace_deck".into(),
client_id: None,
timestamp: None,
payload: json!({ "deck": clone_deck(input_deck) }),
},
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model::*;
use serde_json::json;
fn make_test_deck() -> Deck {
Deck {
v: 3,
theme: "indigo".into(),
slides: vec![
Slide {
id: "s1".into(),
background: "bg".into(),
elements: vec![
SlideElement {
id: "e1".into(),
element_type: "title".into(),
x: 10.0, y: 10.0, w: 80.0, h: 15.0,
content: "Hello".into(),
style: ElementStyle { font_size: Some(48.0), ..Default::default() },
segments: None,
},
],
},
Slide {
id: "s2".into(),
background: "panel".into(),
elements: vec![],
},
],
chat_id: None,
}
}
#[test]
fn test_add_slide() {
let deck = make_test_deck();
let new_slide = Slide { id: "s3".into(), background: "bg".into(), elements: vec![] };
let op = Op {
op_type: "add_slide".into(),
client_id: None,
timestamp: None,
payload: json!({ "atIndex": 1, "slide": new_slide }),
};
let result = apply_op(&deck, &op);
assert_eq!(result.slides.len(), 3);
assert_eq!(result.slides[1].id, "s3");
}
#[test]
fn test_del_slide() {
let deck = make_test_deck();
let op = Op {
op_type: "del_slide".into(),
client_id: None,
timestamp: None,
payload: json!({ "index": 1 }),
};
let result = apply_op(&deck, &op);
assert_eq!(result.slides.len(), 1);
assert_eq!(result.slides[0].id, "s1");
}
#[test]
fn test_set_theme() {
let deck = make_test_deck();
let op = Op {
op_type: "set_theme".into(),
client_id: None,
timestamp: None,
payload: json!({ "theme": "rose" }),
};
let result = apply_op(&deck, &op);
assert_eq!(result.theme, "rose");
}
#[test]
fn test_update_element() {
let deck = make_test_deck();
let op = Op {
op_type: "update_element".into(),
client_id: None,
timestamp: None,
payload: json!({ "slideIdx": 0, "elementId": "e1", "patch": { "style": { "fontSize": 56 } } }),
};
let result = apply_op(&deck, &op);
assert_eq!(result.slides[0].elements[0].style.font_size, Some(56.0));
}
#[test]
fn test_del_element() {
let deck = make_test_deck();
let op = Op {
op_type: "del_element".into(),
client_id: None,
timestamp: None,
payload: json!({ "slideIdx": 0, "elementId": "e1" }),
};
let result = apply_op(&deck, &op);
assert_eq!(result.slides[0].elements.len(), 0);
}
#[test]
fn test_move_slide() {
let deck = make_test_deck();
let op = Op {
op_type: "move_slide".into(),
client_id: None,
timestamp: None,
payload: json!({ "from": 0, "to": 1 }),
};
let result = apply_op(&deck, &op);
assert_eq!(result.slides[0].id, "s2");
assert_eq!(result.slides[1].id, "s1");
}
#[test]
fn test_undo_update() {
let deck = make_test_deck();
let op = Op {
op_type: "update_element".into(),
client_id: None,
timestamp: None,
payload: json!({ "slideIdx": 0, "elementId": "e1", "patch": { "style": { "fontSize": 56 } } }),
};
let backward = invert_op(&deck, &op);
let applied = apply_op(&deck, &op);
let undone = apply_op(&applied, &backward);
assert_eq!(undone.slides[0].elements[0].style.font_size, Some(48.0));
}
#[test]
fn test_undo_add_slide() {
let deck = make_test_deck();
let new_slide = Slide { id: "s3".into(), background: "bg".into(), elements: vec![] };
let op = Op {
op_type: "add_slide".into(),
client_id: None,
timestamp: None,
payload: json!({ "atIndex": 1, "slide": new_slide }),
};
let backward = invert_op(&deck, &op);
let applied = apply_op(&deck, &op);
assert_eq!(applied.slides.len(), 3);
let undone = apply_op(&applied, &backward);
assert_eq!(undone.slides.len(), 2);
}
#[test]
fn test_replace_deck_undo() {
let deck = make_test_deck();
let new_deck = Deck { v: 3, theme: "rose".into(), slides: vec![Slide { id: "s9".into(), background: "bg".into(), elements: vec![] }], chat_id: None };
let op = Op {
op_type: "replace_deck".into(),
client_id: None,
timestamp: None,
payload: json!({ "deck": new_deck }),
};
let backward = invert_op(&deck, &op);
let applied = apply_op(&deck, &op);
assert_eq!(applied.slides.len(), 1);
let undone = apply_op(&applied, &backward);
assert_eq!(undone.slides.len(), 2);
assert_eq!(undone.slides[0].id, "s1");
}
}