新增: 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 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";
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
|
|
||||||
mod color;
|
mod color;
|
||||||
mod model;
|
mod model;
|
||||||
|
mod op;
|
||||||
|
|
||||||
/// IPC 验证命令
|
/// IPC 验证命令
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
|||||||
@@ -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
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