新增: 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:
@@ -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 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";
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
|
||||
mod color;
|
||||
mod model;
|
||||
mod op;
|
||||
|
||||
/// IPC 验证命令
|
||||
#[tauri::command]
|
||||
|
||||
@@ -157,6 +157,7 @@ pub struct RichLine {
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]
|
||||
#[ts(export, export_to = "../bindings/types.ts")]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ElementStyle {
|
||||
pub font_size: Option<f64>,
|
||||
pub color: Option<String>,
|
||||
|
||||
558
src-tauri/src/op.rs
Normal file
558
src-tauri/src/op.rs
Normal 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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user