Skip to main content

pptx_edit/
undo.rs

1#[cfg(any(feature = "wasm", test))]
2use std::collections::BTreeSet;
3use std::collections::{HashMap, HashSet};
4use std::sync::atomic::{AtomicBool, Ordering};
5use std::sync::{Arc, Mutex};
6
7#[cfg(any(feature = "wasm", test))]
8use serde::Serialize;
9use yrs::sync::time::Clock;
10#[cfg(any(feature = "wasm", test))]
11use yrs::types::{Event, PathSegment};
12use yrs::undo::StackItem;
13#[cfg(any(feature = "wasm", test))]
14use yrs::{DeepObservable, Map, MapRef, Subscription};
15use yrs::{Doc, IdSet, Origin, ReadTxn, Transact};
16
17use crate::{COMMENTS, EditError, EditResult, META, SHAPES, SLIDE_ORDER, SLIDES, STORIES};
18
19use crate::DeckSession;
20
21const CAPTURE_TIMEOUT_MS: u64 = 500;
22
23#[cfg(test)]
24type HistorySets = Vec<(yrs::IdSet, yrs::IdSet)>;
25#[cfg(test)]
26type ClientClockCounts = Vec<(yrs::ClientID, u64)>;
27#[cfg(test)]
28type HistoryCounts = Vec<(ClientClockCounts, ClientClockCounts)>;
29
30/// Policy for grouping tracked local transactions.
31#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
32pub enum UndoCaptureMode {
33    #[default]
34    Auto,
35    Manual,
36}
37
38struct CaptureClock {
39    source: Arc<dyn Clock>,
40    state: Mutex<CaptureClockState>,
41}
42
43struct CaptureClockState {
44    mode: UndoCaptureMode,
45    holds: usize,
46    ticks: u64,
47    last_source: u64,
48}
49
50impl CaptureClock {
51    fn new(source: Arc<dyn Clock>) -> Self {
52        Self {
53            state: Mutex::new(CaptureClockState {
54                mode: UndoCaptureMode::Auto,
55                holds: 0,
56                ticks: 1,
57                last_source: source.now(),
58            }),
59            source,
60        }
61    }
62
63    fn mode(&self) -> UndoCaptureMode {
64        self.state.lock().unwrap().mode
65    }
66
67    fn set_mode(&self, mode: UndoCaptureMode) {
68        self.state.lock().unwrap().mode = mode;
69    }
70}
71
72impl Clock for CaptureClock {
73    fn now(&self) -> u64 {
74        let mut state = self.state.lock().unwrap();
75        let now = self.source.now();
76        let elapsed = match (state.mode, state.holds) {
77            (UndoCaptureMode::Auto, 0) => now.saturating_sub(state.last_source),
78            _ => 0,
79        };
80        state.last_source = now;
81        state.ticks = state.ticks.saturating_add(elapsed);
82        state.ticks
83    }
84}
85
86struct CaptureClockHold(Arc<CaptureClock>);
87
88impl Drop for CaptureClockHold {
89    fn drop(&mut self) {
90        self.0.state.lock().unwrap().holds -= 1;
91    }
92}
93
94impl DeckSession {
95    pub(crate) fn with_undo_capture<T>(
96        &self,
97        operation: impl FnOnce() -> EditResult<T>,
98    ) -> EditResult<T> {
99        self.automatic_undo_barrier();
100        let clock = self.undo.borrow().clock.clone();
101        clock.state.lock().unwrap().holds += 1;
102        let hold = CaptureClockHold(clock);
103        let result = operation();
104        drop(hold);
105        self.automatic_undo_barrier();
106        result
107    }
108}
109
110pub struct DeckUndoManager {
111    clock: Arc<CaptureClock>,
112    inner: yrs::undo::UndoManager<()>,
113    kept: Arc<AtomicBool>,
114    client_id: u64,
115    tables: HashMap<String, IdSet>,
116    #[cfg(any(feature = "wasm", test))]
117    history: Option<HistoryObserver>,
118}
119
120#[cfg(any(feature = "wasm", test))]
121#[derive(Default, Serialize)]
122#[serde(rename_all = "camelCase")]
123pub(crate) struct HistoryReceipt {
124    pub applied: bool,
125    pub changed_slide_ids: BTreeSet<String>,
126    pub deck_size_changed: bool,
127    pub slide_order_changed: bool,
128}
129
130#[cfg(any(feature = "wasm", test))]
131#[derive(Default)]
132struct HistoryState {
133    owners: HashMap<String, String>,
134    receipt: Option<HistoryReceipt>,
135}
136
137#[cfg(any(feature = "wasm", test))]
138struct HistoryObserver {
139    state: Arc<Mutex<HistoryState>>,
140    _subscriptions: Vec<Subscription>,
141}
142
143#[cfg(any(feature = "wasm", test))]
144fn index_shape<T: ReadTxn>(txn: &T, state: &mut HistoryState, shape_id: &str, slide_id: &str) {
145    if state
146        .owners
147        .insert(shape_id.to_owned(), slide_id.to_owned())
148        .as_deref()
149        == Some(slide_id)
150    {
151        return;
152    }
153    refresh_shape(txn, state, shape_id, slide_id);
154}
155
156#[cfg(any(feature = "wasm", test))]
157fn refresh_shape<T: ReadTxn>(txn: &T, state: &mut HistoryState, shape_id: &str, slide_id: &str) {
158    let Ok(shape) = crate::deck::shape_ref(txn, shape_id) else {
159        return;
160    };
161    let Ok(stories) = crate::deck::map_string_array(&shape, txn, "textStories") else {
162        return;
163    };
164    for story in stories {
165        if let Ok(key) = crate::table::storage_key(txn, &story) {
166            state.owners.insert(key, slide_id.to_owned());
167        }
168        state.owners.insert(story, slide_id.to_owned());
169    }
170    let Ok(children) = crate::deck::map_string_array(&shape, txn, "children") else {
171        return;
172    };
173    for child in children {
174        index_shape(txn, state, &child, slide_id);
175    }
176}
177
178#[cfg(any(feature = "wasm", test))]
179fn index_slide<T: ReadTxn>(txn: &T, state: &mut HistoryState, slide_id: &str) {
180    let Ok(slide) = crate::deck::slide_ref(txn, slide_id) else {
181        return;
182    };
183    if let Ok(order) = crate::deck::slide_shape_order(&slide, txn) {
184        for id in crate::deck::live_shape_order(&order, txn).unwrap_or_default() {
185            index_shape(txn, state, &id, slide_id);
186        }
187    }
188}
189
190#[cfg(any(feature = "wasm", test))]
191impl DeckSession {
192    pub(crate) fn enable_history_receipts(&self) -> EditResult<()> {
193        let mut undo = self.undo.borrow_mut();
194        if undo.history.is_some() {
195            return Ok(());
196        }
197        let txn = self.doc.transact();
198        let mut initial = HistoryState::default();
199        for id in crate::deck::live_slide_ids(&txn)? {
200            index_slide(&txn, &mut initial, &id);
201        }
202        for (id, value) in crate::deck::required_map(&txn, COMMENTS)?.iter(&txn) {
203            if let Ok(map) = value.cast::<MapRef>()
204                && let Some(slide_id) = crate::deck::map_string(&map, &txn, "slideId")
205                && crate::deck::slide_ref(&txn, &slide_id).is_ok()
206            {
207                initial.owners.insert(id.to_owned(), slide_id);
208            }
209        }
210        let state = Arc::new(Mutex::new(initial));
211        let mut subscriptions = Vec::new();
212        for root in [SLIDES, SHAPES, STORIES, COMMENTS, META] {
213            let map = crate::deck::required_map(&txn, root)?;
214            let state = state.clone();
215            subscriptions.push(map.observe_deep(move |txn, events| {
216                let mut state = state.lock().unwrap();
217                let mut keys = BTreeSet::new();
218                for event in events.iter() {
219                    if let Some(PathSegment::Key(key)) = event.path().front() {
220                        keys.insert(key.to_string());
221                    } else if let Event::Map(event) = event {
222                        keys.extend(event.keys(txn).keys().map(|key| key.to_string()));
223                    }
224                }
225                for key in keys {
226                    let previous_owner = state.owners.get(&key).cloned();
227                    let owner = match root {
228                        SLIDES => Some(key.clone()),
229                        COMMENTS => txn
230                            .get_map(COMMENTS)
231                            .and_then(|map| map.get(txn, &key))
232                            .and_then(|value| value.cast::<MapRef>().ok())
233                            .and_then(|map| crate::deck::map_string(&map, txn, "slideId"))
234                            .or_else(|| previous_owner.clone()),
235                        _ => previous_owner.clone(),
236                    };
237                    if let Some(receipt) = &mut state.receipt {
238                        if root == META {
239                            if key == "widthEmu" || key == "heightEmu" {
240                                receipt.deck_size_changed = true;
241                            } else {
242                                receipt
243                                    .changed_slide_ids
244                                    .extend(crate::deck::live_slide_ids(txn).unwrap_or_default());
245                            }
246                        }
247                        if let Some(id) = &owner
248                            && crate::deck::slide_ref(txn, id).is_ok()
249                        {
250                            receipt.changed_slide_ids.insert(id.clone());
251                        }
252                        if root == COMMENTS {
253                            receipt.changed_slide_ids.extend(
254                                previous_owner.filter(|id| crate::deck::slide_ref(txn, id).is_ok()),
255                            );
256                        }
257                    }
258                    if root == SLIDES {
259                        index_slide(txn, &mut state, &key);
260                    } else if let Some(id) = owner {
261                        if root == SHAPES {
262                            refresh_shape(txn, &mut state, &key, &id);
263                        } else if root == COMMENTS {
264                            state.owners.insert(key, id);
265                        }
266                    }
267                }
268            }));
269        }
270        let order = txn
271            .get_array(SLIDE_ORDER)
272            .ok_or_else(|| EditError::InvalidState("missing slide order".to_owned()))?;
273        let order_state = state.clone();
274        subscriptions.push(order.observe_deep(move |_, _| {
275            if let Some(receipt) = &mut order_state.lock().unwrap().receipt {
276                receipt.slide_order_changed = true;
277            }
278        }));
279        undo.history = Some(HistoryObserver {
280            state,
281            _subscriptions: subscriptions,
282        });
283        Ok(())
284    }
285
286    pub(crate) fn history_step(&self, redo: bool) -> EditResult<HistoryReceipt> {
287        self.enable_history_receipts()?;
288        let mut undo = self.undo.borrow_mut();
289        let state = undo.history.as_ref().unwrap().state.clone();
290        state.lock().unwrap().receipt = Some(HistoryReceipt::default());
291        let applied = if redo { undo.redo() } else { undo.undo() };
292        let mut receipt = state.lock().unwrap().receipt.take().unwrap();
293        receipt.applied = applied;
294        let txn = self.doc.transact();
295        receipt
296            .changed_slide_ids
297            .retain(|id| crate::deck::slide_ref(&txn, id).is_ok());
298        Ok(receipt)
299    }
300}
301
302impl DeckUndoManager {
303    pub(crate) fn new(doc: &Doc, client_id: u64) -> EditResult<Self> {
304        let clock = Arc::new(CaptureClock::new(default_clock()));
305        let kept = Arc::new(AtomicBool::new(false));
306        let inner = build_inner(doc, client_id, &clock, &kept, Vec::new(), Vec::new())?;
307        Ok(Self {
308            inner,
309            clock,
310            kept,
311            client_id,
312            tables: HashMap::new(),
313            #[cfg(any(feature = "wasm", test))]
314            history: None,
315        })
316    }
317
318    /// Ties `changes` to the table `shape_id`, so its history can be dropped on its own.
319    pub(crate) fn track_table(&mut self, shape_id: &str, changes: IdSet) {
320        self.tables
321            .entry(shape_id.to_owned())
322            .or_default()
323            .merge_with(changes);
324    }
325
326    pub(crate) fn tracked_tables(&self) -> impl Iterator<Item = &String> {
327        self.tables.keys()
328    }
329
330    /// Drops every undo and redo item that changed the structure of table `shape_id`.
331    pub(crate) fn drop_table(&mut self, doc: &Doc, shape_id: &str) -> EditResult<()> {
332        let Some(changes) = self.tables.remove(shape_id) else {
333            return Ok(());
334        };
335        let keep = |stack: &[StackItem<()>]| -> Vec<_> {
336            stack
337                .iter()
338                .filter(|item| !touches(item, &changes))
339                .cloned()
340                .collect()
341        };
342        let undo = keep(self.inner.undo_stack());
343        let redo = keep(self.inner.redo_stack());
344        if undo.len() == self.inner.undo_stack().len()
345            && redo.len() == self.inner.redo_stack().len()
346        {
347            return Ok(());
348        }
349        self.inner = build_inner(doc, self.client_id, &self.clock, &self.kept, undo, redo)?;
350        Ok(())
351    }
352
353    fn step(&mut self, redo: bool) -> bool {
354        let stacks = |inner: &yrs::undo::UndoManager<()>| {
355            if redo {
356                (inner.redo_stack().last().cloned(), inner.undo_stack().len())
357            } else {
358                (inner.undo_stack().last().cloned(), inner.redo_stack().len())
359            }
360        };
361        let (popped, before) = stacks(&self.inner);
362        let applied = if redo {
363            self.inner.redo_blocking()
364        } else {
365            self.inner.undo_blocking()
366        };
367        let target = if redo {
368            self.inner.undo_stack()
369        } else {
370            self.inner.redo_stack()
371        };
372        if applied
373            && target.len() > before
374            && let (Some(popped), Some(pushed)) = (popped, target.last())
375        {
376            let pushed = pushed.insertions().merge(pushed.deletions());
377            for changes in self.tables.values_mut() {
378                if touches(&popped, changes) {
379                    changes.merge_with(pushed.clone());
380                }
381            }
382        }
383        applied
384    }
385
386    pub(crate) fn assert_replay_capture(&self) -> Result<(), crate::peer::PeerError> {
387        if self.capture_mode() != UndoCaptureMode::Manual {
388            return Err(crate::peer::PeerError::new(
389                "capturePolicy",
390                "replay requires manual undo capture",
391            ));
392        }
393        Ok(())
394    }
395
396    /// Whether retained Yrs items may still reference deleted parents.
397    pub(crate) fn ever_kept(&self) -> bool {
398        self.kept.load(Ordering::Relaxed)
399    }
400
401    pub(crate) fn diagnostics(&self) -> (usize, usize) {
402        (self.inner.undo_stack().len(), self.inner.redo_stack().len())
403    }
404
405    #[cfg(test)]
406    pub(crate) fn stack_diagnostics(&self) -> (HistorySets, HistorySets) {
407        let capture = |stack: &[yrs::undo::StackItem<()>]| {
408            stack
409                .iter()
410                .map(|item| (item.deletions().clone(), item.insertions().clone()))
411                .collect()
412        };
413        (
414            capture(self.inner.undo_stack()),
415            capture(self.inner.redo_stack()),
416        )
417    }
418
419    #[cfg(test)]
420    pub(crate) fn stack_clock_counts(&self) -> (HistoryCounts, HistoryCounts) {
421        let counts = |set: &yrs::IdSet| -> ClientClockCounts {
422            set.iter()
423                .map(|(client, ranges)| {
424                    let count = ranges
425                        .iter()
426                        .map(|range| u64::from(range.end - range.start))
427                        .sum();
428                    (*client, count)
429                })
430                .collect()
431        };
432        let capture = |stack: &[yrs::undo::StackItem<()>]| -> HistoryCounts {
433            stack
434                .iter()
435                .map(|item| (counts(item.deletions()), counts(item.insertions())))
436                .collect()
437        };
438        (
439            capture(self.inner.undo_stack()),
440            capture(self.inner.redo_stack()),
441        )
442    }
443
444    pub fn capture_mode(&self) -> UndoCaptureMode {
445        self.clock.mode()
446    }
447
448    pub fn set_capture_mode(&mut self, mode: UndoCaptureMode) {
449        if self.capture_mode() != mode {
450            self.add_undo_barrier();
451            self.clock.set_mode(mode);
452        }
453    }
454
455    pub fn undo(&mut self) -> bool {
456        self.step(false)
457    }
458
459    pub fn redo(&mut self) -> bool {
460        self.step(true)
461    }
462
463    pub fn clear(&mut self) {
464        self.inner.clear_all();
465        self.inner.reset();
466        self.tables.clear();
467    }
468
469    pub fn can_undo(&self) -> bool {
470        self.inner.can_undo()
471    }
472
473    pub fn can_redo(&self) -> bool {
474        self.inner.can_redo()
475    }
476
477    pub fn add_undo_barrier(&mut self) {
478        self.inner.reset();
479    }
480}
481
482fn touches(item: &StackItem<()>, changes: &IdSet) -> bool {
483    !item.insertions().intersect(changes).is_empty()
484        || !item.deletions().intersect(changes).is_empty()
485}
486
487fn build_inner(
488    doc: &Doc,
489    client_id: u64,
490    clock: &Arc<CaptureClock>,
491    kept: &Arc<AtomicBool>,
492    init_undo_stack: Vec<StackItem<()>>,
493    init_redo_stack: Vec<StackItem<()>>,
494) -> EditResult<yrs::undo::UndoManager<()>> {
495    let txn = doc.transact();
496    let order = txn
497        .get_array(SLIDE_ORDER)
498        .ok_or_else(|| EditError::InvalidState("missing slide order".to_owned()))?;
499    let slides = txn
500        .get_map(SLIDES)
501        .ok_or_else(|| EditError::InvalidState("missing slides map".to_owned()))?;
502    let shapes = txn
503        .get_map(SHAPES)
504        .ok_or_else(|| EditError::InvalidState("missing shapes map".to_owned()))?;
505    let stories = txn
506        .get_map(STORIES)
507        .ok_or_else(|| EditError::InvalidState("missing stories map".to_owned()))?;
508    let comments = txn
509        .get_map(COMMENTS)
510        .ok_or_else(|| EditError::InvalidState("missing comments map".to_owned()))?;
511    let meta = txn
512        .get_map(META)
513        .ok_or_else(|| EditError::InvalidState("missing meta map".to_owned()))?;
514    drop(txn);
515    let options = yrs::undo::Options {
516        capture_timeout_millis: CAPTURE_TIMEOUT_MS,
517        tracked_origins: HashSet::from([Origin::from(client_id)]),
518        capture_transaction: None,
519        timestamp: clock.clone(),
520        init_undo_stack,
521        init_redo_stack,
522    };
523    let mut inner = yrs::undo::UndoManager::with_options(options);
524    inner.expand_scope(doc, &order);
525    inner.expand_scope(doc, &slides);
526    inner.expand_scope(doc, &shapes);
527    inner.expand_scope(doc, &stories);
528    inner.expand_scope(doc, &comments);
529    inner.expand_scope(doc, &meta);
530    let flag = Arc::clone(kept);
531    inner.observe_item_added_with("kept", move |_, _| flag.store(true, Ordering::Relaxed));
532    Ok(inner)
533}
534
535fn default_clock() -> Arc<dyn Clock> {
536    #[cfg(not(all(target_family = "wasm", target_os = "unknown")))]
537    {
538        Arc::new(|| {
539            std::time::SystemTime::now()
540                .duration_since(std::time::UNIX_EPOCH)
541                .map(|elapsed| elapsed.as_millis() as u64)
542                .unwrap_or_default()
543        })
544    }
545    #[cfg(all(target_family = "wasm", target_os = "unknown"))]
546    {
547        use std::sync::atomic::{AtomicU64, Ordering};
548        let ticks = AtomicU64::new(0);
549        Arc::new(move || ticks.fetch_add(CAPTURE_TIMEOUT_MS + 1, Ordering::Relaxed))
550    }
551}
552
553#[cfg(test)]
554mod tests {
555    use super::*;
556    use crate::{EditCtx, ReadRequest, ShapeDraft, ShapeRect, TextStyle};
557
558    fn session() -> DeckSession {
559        let mut package = pptx_parse::PptxPackage::default();
560        package.presentation.width_emu = 9144000;
561        package.presentation.height_emu = 6858000;
562        DeckSession::from_package_with_fingerprint(package, "history-receipts".to_owned(), 7)
563            .unwrap()
564    }
565
566    #[test]
567    fn history_receipts_exclude_deleted_slides_for_text_and_meta_changes() {
568        let session = session();
569        let context = EditCtx::local("test");
570        let deleted = session.insert_slide(&context, 0, None).unwrap().slide_id;
571        let live = session.insert_slide(&context, 1, None).unwrap().slide_id;
572        let shape = session
573            .add_text_box(
574                &context,
575                &deleted,
576                &ShapeDraft {
577                    name: "Text".to_owned(),
578                    rect: ShapeRect {
579                        x: 0,
580                        y: 0,
581                        width: 1000,
582                        height: 1000,
583                    },
584                    text: "alpha".to_owned(),
585                    style: TextStyle::default(),
586                },
587            )
588            .unwrap();
589        session.clear_undo_history();
590        let story = format!("story:{}:0", shape.shape_id);
591        session
592            .insert_text(&context, &story, 0, "X", &TextStyle::default())
593            .unwrap();
594        session.add_undo_barrier();
595        session
596            .delete_slide(
597                &EditCtx {
598                    origin: crate::EditOrigin::Remote,
599                    author: "peer".to_owned(),
600                },
601                &deleted,
602            )
603            .unwrap();
604        session.enable_history_receipts().unwrap();
605        assert!(
606            session
607                .history_step(false)
608                .unwrap()
609                .changed_slide_ids
610                .is_empty()
611        );
612        {
613            let mut txn = session.transact_for(&context);
614            let meta = txn.get_map(META).unwrap();
615            meta.insert(&mut txn, "title", "changed");
616        }
617        session.add_undo_barrier();
618        for redo in [false, true] {
619            assert_eq!(
620                session.history_step(redo).unwrap().changed_slide_ids,
621                BTreeSet::from([live.clone()])
622            );
623        }
624    }
625
626    #[test]
627    fn history_receipts_scope_text_without_reading_untouched_slide() {
628        let session = session();
629        let context = EditCtx::local("test");
630        session.set_undo_capture_mode(UndoCaptureMode::Manual);
631        session.enable_history_receipts().unwrap();
632        let mut slides = Vec::new();
633        let mut shapes = Vec::new();
634        for index in 0..2 {
635            let slide = session.insert_slide(&context, index, None).unwrap();
636            let shape = session
637                .add_text_box(
638                    &context,
639                    &slide.slide_id,
640                    &ShapeDraft {
641                        name: "Text".to_owned(),
642                        rect: ShapeRect {
643                            x: 0,
644                            y: 0,
645                            width: 1000,
646                            height: 1000,
647                        },
648                        text: "alpha".to_owned(),
649                        style: TextStyle::default(),
650                    },
651                )
652                .unwrap();
653            slides.push(slide.slide_id);
654            shapes.push(shape.shape_id);
655        }
656        session.add_undo_barrier();
657        {
658            let mut txn = session.doc.transact_mut_with(crate::REMOTE_ORIGIN);
659            let shape = crate::deck::shape_ref(&txn, &shapes[1]).unwrap();
660            shape.remove(&mut txn, "width");
661        }
662        assert!(session.snapshot().is_err());
663        let story_id = format!("story:{}:0", shapes[0]);
664        session
665            .insert_text(&context, &story_id, 0, "Z", &TextStyle::default())
666            .unwrap();
667        session.add_undo_barrier();
668        for (redo, text) in [(false, "alpha"), (true, "Zalpha")] {
669            let receipt = session.history_step(redo).unwrap();
670            assert!(receipt.applied);
671            assert_eq!(
672                receipt.changed_slide_ids,
673                BTreeSet::from([slides[0].clone()])
674            );
675            assert!(!receipt.deck_size_changed);
676            assert!(!receipt.slide_order_changed);
677            let read = session
678                .read_command_content(&ReadRequest {
679                    slide_ids: Some(vec![slides[0].clone()]),
680                })
681                .unwrap()
682                .unwrap();
683            let value = serde_json::to_value(read).unwrap();
684            assert_eq!(value["stories"][0]["text"], text);
685        }
686        assert!(session.snapshot().is_err());
687    }
688
689    #[test]
690    fn history_receipts_report_slide_order_and_deck_size() {
691        let session = session();
692        let context = EditCtx::local("test");
693        session.enable_history_receipts().unwrap();
694        session.set_undo_capture_mode(UndoCaptureMode::Manual);
695        let slide = session.insert_slide(&context, 0, None).unwrap();
696        session.add_undo_barrier();
697        for redo in [false, true] {
698            let receipt = session.history_step(redo).unwrap();
699            assert!(receipt.applied);
700            assert!(receipt.slide_order_changed);
701            assert!(!receipt.deck_size_changed);
702            assert_eq!(
703                receipt.changed_slide_ids,
704                if redo {
705                    BTreeSet::from([slide.slide_id.clone()])
706                } else {
707                    BTreeSet::new()
708                }
709            );
710        }
711        session.add_undo_barrier();
712        {
713            let mut txn = session.transact_for(&context);
714            let meta = crate::deck::required_map(&txn, META).unwrap();
715            meta.insert(&mut txn, "widthEmu", 1000_f64);
716        }
717        session.add_undo_barrier();
718        for redo in [false, true] {
719            let receipt = session.history_step(redo).unwrap();
720            assert!(receipt.applied);
721            assert!(receipt.deck_size_changed);
722            assert!(!receipt.slide_order_changed);
723        }
724    }
725}