1use std::collections::VecDeque;
7
8use serde::{Deserialize, Serialize};
9
10use crate::{
11 block_model::Live,
12 commit::Commit,
13 document::{Document, FoldError},
14 entity::Entity,
15 opcode::{Crud, OpCodes},
16 rev::{Confirmed, Provisional, Rev, RevKind},
17};
18
19#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug, Serialize, Deserialize)]
22pub struct Nonce(u64);
23
24impl Nonce {
25 pub(crate) const fn new(count: u64) -> Self {
26 Self(count)
27 }
28}
29
30#[derive(Clone, PartialEq, Eq, Debug, thiserror::Error)]
31pub enum SessionError {
32 #[error("expected rev {expected:?} on this connection, got {got:?}")]
34 RevGap { expected: Rev, got: Rev },
35 #[error("expected an answer for {expected:?}, got one for {got:?}")]
37 NonceMismatch { expected: Option<Nonce>, got: Nonce },
38 #[error("a sequenced commit was refused: {0}")]
41 Corrupt(#[from] FoldError),
42}
43
44#[derive(Default)]
46pub struct Host {
47 state: Document<Confirmed>,
48 log: Vec<Commit>,
49}
50
51pub struct Accepted {
56 state: Document<Confirmed>,
57 commit: Commit,
58}
59
60impl Accepted {
61 pub fn rev(&self) -> Rev {
62 self.state.rev()
63 }
64
65 pub fn commit(&self) -> &Commit {
66 &self.commit
67 }
68}
69
70impl Host {
71 pub fn accept(&self, commit: Commit) -> Result<Accepted, FoldError> {
77 Ok(Accepted {
78 state: self.state.try_apply(&commit)?,
79 commit,
80 })
81 }
82
83 pub fn publish(&mut self, accepted: Accepted) -> Rev {
84 self.state = accepted.state;
85 self.log.push(accepted.commit);
86 self.state.rev()
87 }
88
89 pub fn ingest(&mut self, commit: &Commit) -> Result<Rev, FoldError> {
94 let accepted = self.accept(commit.clone())?;
95 Ok(self.publish(accepted))
96 }
97
98 pub fn state(&self) -> &Document<Confirmed> {
99 &self.state
100 }
101
102 pub fn rev(&self) -> Rev {
103 self.state.rev()
104 }
105
106 pub fn commits_after(&self, rev: Rev) -> &[Commit] {
109 let already_held = (rev.get() as usize).min(self.log.len());
110 &self.log[already_held..]
111 }
112}
113
114struct Pending {
115 nonce: Nonce,
116 commit: Commit,
117}
118
119#[derive(Clone, Copy, PartialEq, Eq, Debug)]
124enum JournalAs {
125 Edit,
126 Undo,
127 Redo,
128}
129
130struct JournalEntry {
131 nonce: Nonce,
139 inverse: Commit,
140 pending: bool,
142}
143
144#[derive(Default)]
145struct Journal {
146 undo: Vec<JournalEntry>,
147 redo: Vec<JournalEntry>,
148}
149
150impl Journal {
151 fn push(&mut self, entry: JournalEntry, journal_as: JournalAs) {
152 match journal_as {
153 JournalAs::Edit => {
154 self.redo.clear();
155 self.undo.push(entry);
156 }
157 JournalAs::Undo => self.redo.push(entry),
158 JournalAs::Redo => self.undo.push(entry),
159 }
160 }
161
162 fn ack(&mut self, nonce: Nonce) {
163 for entry in self.undo.iter_mut().chain(self.redo.iter_mut()) {
164 if entry.nonce == nonce {
165 entry.pending = false;
166 }
167 }
168 }
169
170 fn discard(&mut self, nonce: Nonce) {
173 self.undo
174 .retain(|entry| !(entry.pending && entry.nonce == nonce));
175 self.redo
176 .retain(|entry| !(entry.pending && entry.nonce == nonce));
177 }
178}
179
180#[derive(Debug, thiserror::Error, PartialEq)]
182pub enum UndoRefusal {
183 #[error("nothing stands ready to be inverted")]
184 Spent,
185 #[error("the step names an edit this session no longer holds on top ({top:?})")]
186 Stale { top: Nonce },
187 #[error("the inverse was refused: {0}")]
188 Refused(#[from] FoldError),
189}
190
191#[derive(Default)]
193pub struct ClientSession {
194 confirmed: Document<Confirmed>,
195 pending: VecDeque<Pending>,
197 optimistic: Document<Provisional>,
199 journal: Journal,
200 next_nonce: u64,
201}
202
203impl ClientSession {
204 pub fn welcome(commits: &[Commit]) -> Result<Self, SessionError> {
207 let mut session = Self::default();
208 for commit in commits {
209 session.confirmed = session.confirmed.try_apply(commit)?;
210 }
211 session.rebuild();
212 Ok(session)
213 }
214
215 pub fn rev(&self) -> Rev {
217 self.confirmed.rev()
218 }
219
220 pub fn confirmed(&self) -> &Document<Confirmed> {
221 &self.confirmed
222 }
223
224 pub fn optimistic(&self) -> &Document<Provisional> {
225 &self.optimistic
226 }
227
228 pub fn last_submission(&self) -> Option<(Nonce, &Commit)> {
231 self.pending
232 .back()
233 .map(|pending| (pending.nonce, &pending.commit))
234 }
235
236 pub fn can_undo(&self) -> bool {
237 !self.journal.undo.is_empty()
238 }
239
240 pub fn undo_depth(&self) -> usize {
245 self.journal.undo.len()
246 }
247
248 pub fn redo_depth(&self) -> usize {
249 self.journal.redo.len()
250 }
251
252 pub fn can_redo(&self) -> bool {
253 !self.journal.redo.is_empty()
254 }
255
256 pub fn submit(&mut self, commit: Commit) -> Result<Nonce, FoldError> {
260 self.submit_journaled(commit, JournalAs::Edit)
261 }
262
263 fn submit_journaled(
264 &mut self,
265 commit: Commit,
266 journal_as: JournalAs,
267 ) -> Result<Nonce, FoldError> {
268 let pre_image = &self.optimistic;
271 let predicted = pre_image.try_apply(&commit)?;
272 let inverse = inverse_of(pre_image, &commit);
273
274 let nonce = Nonce::new(self.next_nonce);
275 self.next_nonce += 1;
276 self.journal.push(
277 JournalEntry {
278 nonce,
279 inverse,
280 pending: true,
281 },
282 journal_as,
283 );
284 self.pending.push_back(Pending { nonce, commit });
285 self.optimistic = predicted;
287 Ok(nonce)
288 }
289
290 pub fn next_undo(&self) -> Option<Nonce> {
295 self.journal.undo.last().map(|entry| entry.nonce)
296 }
297
298 pub fn next_redo(&self) -> Option<Nonce> {
299 self.journal.redo.last().map(|entry| entry.nonce)
300 }
301
302 pub fn undo_nonces(&self) -> Vec<Nonce> {
305 self.journal.undo.iter().map(|entry| entry.nonce).collect()
306 }
307
308 pub fn undo(&mut self, edit: Nonce) -> Result<Nonce, UndoRefusal> {
323 self.step(edit, JournalAs::Undo)
324 }
325
326 pub fn redo(&mut self, edit: Nonce) -> Result<Nonce, UndoRefusal> {
329 self.step(edit, JournalAs::Redo)
330 }
331
332 fn step(&mut self, edit: Nonce, direction: JournalAs) -> Result<Nonce, UndoRefusal> {
333 let stack = match direction {
334 JournalAs::Undo => &mut self.journal.undo,
335 JournalAs::Redo => &mut self.journal.redo,
336 JournalAs::Edit => unreachable!("an edit is not a history step"),
337 };
338 let top = stack.last().ok_or(UndoRefusal::Spent)?;
339 if top.nonce != edit {
340 return Err(UndoRefusal::Stale { top: top.nonce });
341 }
342 let inverse = top.inverse.clone();
343 let submitted = self.submit_journaled(inverse, direction)?;
344 match direction {
345 JournalAs::Undo => self.journal.undo.pop(),
346 JournalAs::Redo => self.journal.redo.pop(),
347 JournalAs::Edit => None,
348 };
349 Ok(submitted)
350 }
351
352 pub fn apply_foreign(&mut self, rev: Rev, commit: &Commit) -> Result<(), SessionError> {
358 self.expect_next(rev)?;
359 self.confirmed = self.confirmed.try_apply(commit)?;
360 self.rebuild();
361 Ok(())
362 }
363
364 pub fn committed(&mut self, nonce: Nonce, rev: Rev) -> Result<(), SessionError> {
370 self.expect_front(nonce)?;
371 self.expect_next(rev)?;
372 if let Some(acked) = self.pending.front() {
373 self.confirmed = self.confirmed.try_apply(&acked.commit)?;
374 self.pending.pop_front();
375 self.journal.ack(nonce);
376 self.rebuild();
377 }
378 Ok(())
379 }
380
381 pub fn rejected(&mut self, nonce: Nonce) -> Result<(), SessionError> {
386 self.expect_front(nonce)?;
387 self.pending.pop_front();
388 self.journal.discard(nonce);
389 self.rebuild();
390 Ok(())
391 }
392
393 fn expect_next(&self, rev: Rev) -> Result<(), SessionError> {
394 let expected = self.confirmed.rev().next();
395 debug_assert_eq!(rev, expected, "a gap in the per-connection rev sequence");
396 if rev == expected {
397 Ok(())
398 } else {
399 Err(SessionError::RevGap { expected, got: rev })
400 }
401 }
402
403 fn expect_front(&self, nonce: Nonce) -> Result<(), SessionError> {
404 let front = self.pending.front().map(|pending| pending.nonce);
405 if front == Some(nonce) {
406 Ok(())
407 } else {
408 Err(SessionError::NonceMismatch {
409 expected: front,
410 got: nonce,
411 })
412 }
413 }
414
415 fn rebuild(&mut self) {
418 self.optimistic = self
419 .pending
420 .iter()
421 .fold(self.confirmed.predict(), |doc, pending| {
422 match doc.try_apply(&pending.commit) {
423 Ok(predicted) => predicted,
424 Err(_) => doc,
429 }
430 });
431 }
432}
433
434fn inverse_of<R: RevKind>(pre_image: &Document<R>, commit: &Commit) -> Commit {
442 let mut ops: Vec<OpCodes> = commit
443 .ops()
444 .iter()
445 .filter_map(|op| invert_op(pre_image, op))
446 .collect();
447 ops.reverse();
448 Commit::new(format!("Undo {}", commit.label()), ops)
449}
450
451fn invert_op<R: RevKind>(pre_image: &Document<R>, op: &OpCodes) -> Option<OpCodes> {
452 match op {
453 OpCodes::Document(update) => {
454 Some(OpCodes::Document(pre_image.title_block().invert(update)))
455 }
456 OpCodes::Block(id, crud) => {
457 invert_crud(pre_image.block(id), crud).map(|crud| OpCodes::Block(*id, crud))
458 }
459 OpCodes::Pin(id, crud) => {
460 invert_crud(pre_image.pin(id), crud).map(|crud| OpCodes::Pin(*id, crud))
461 }
462 OpCodes::Route(id, crud) => {
463 invert_crud(pre_image.route(id), crud).map(|crud| OpCodes::Route(*id, crud))
464 }
465 OpCodes::RouteLabel(id, crud) => {
466 invert_crud(pre_image.route_label(id), crud).map(|crud| OpCodes::RouteLabel(*id, crud))
467 }
468 OpCodes::Text(id, crud) => {
469 invert_crud(pre_image.text(id), crud).map(|crud| OpCodes::Text(*id, crud))
470 }
471 OpCodes::Comment(id, crud) => {
472 invert_crud(pre_image.comment(id), crud).map(|crud| OpCodes::Comment(*id, crud))
473 }
474 OpCodes::Image(id, crud) => {
475 invert_crud(pre_image.image(id), crud).map(|crud| OpCodes::Image(*id, crud))
476 }
477 OpCodes::Asset(..) => None,
483 }
484}
485
486fn invert_crud<E: Entity>(
492 pre_image: Option<&Live<E>>,
493 crud: &Crud<E::Init, E::Update>,
494) -> Option<Crud<E::Init, E::Update>> {
495 match crud {
496 Crud::Update(update) => Some(Crud::Update(pre_image?.as_ref().invert(update))),
497 lifecycle => lifecycle.invert_lifecycle(),
498 }
499}
500
501#[cfg(test)]
502mod tests {
503 use super::*;
504 use crate::fixtures::{block_id, commit, image_id, pin_id, projection};
505 use crate::{
506 block_model::{Asset, BlockInit, BlockUpdate, Icon, ImageInit, LabelInit, PinInit},
507 document::TitleBlockUpdate,
508 geometry::{FracVal, GridPoint, GridRect, GridSize, PinSlot, ScreenRect},
509 id::BlockId,
510 values::{LabelSide, PinDir, Role},
511 };
512
513 fn rect(x: i32, y: i32) -> GridRect {
514 GridRect {
515 top_left: GridPoint { x, y },
516 size: GridSize { w: 4, h: 4 },
517 }
518 }
519
520 fn label_init(name: String) -> LabelInit {
521 LabelInit {
522 name,
523 side: LabelSide::default(),
524 offset: FracVal::default(),
525 hidden: false,
526 }
527 }
528
529 fn block_create(byte: u8) -> OpCodes {
530 OpCodes::Block(
531 block_id(byte),
532 Crud::Create(BlockInit {
533 parent: BlockId::NULL,
534 rect: rect(0, 0),
535 locked: false,
536 role: Role::default(),
537 title: label_init(format!("b{byte}")),
538 type_label: label_init(String::new()),
539 icon: Icon::default(),
540 }),
541 )
542 }
543
544 fn reparent(child: u8, parent: BlockId) -> OpCodes {
545 OpCodes::Block(block_id(child), Crud::Update(BlockUpdate::Parent(parent)))
546 }
547
548 fn resize(byte: u8, to: GridRect) -> OpCodes {
549 OpCodes::Block(block_id(byte), Crud::Update(BlockUpdate::Rect(to)))
550 }
551
552 fn orphan_pin(byte: u8) -> OpCodes {
555 OpCodes::Pin(
556 pin_id(byte),
557 Crud::Create(PinInit {
558 owner: block_id(200),
559 name: format!("p{byte}"),
560 type_name: String::new(),
561 tag: String::new(),
562 tag_hidden: false,
563 rect: rect(0, 0),
564 slot: PinSlot::default(),
565 dir: PinDir::default(),
566 port_accent: Role::default(),
567 flip_lr: false,
568 }),
569 )
570 }
571
572 fn parent_of(session: &ClientSession, byte: u8) -> BlockId {
573 *session
574 .optimistic()
575 .block(&block_id(byte))
576 .expect("the block is in the document")
577 .as_ref()
578 .parent
579 .as_ref()
580 }
581
582 fn ack_last(host: &mut Host, session: &mut ClientSession) {
585 let (nonce, commit) = session.last_submission().expect("a submission is queued");
586 let commit = commit.clone();
587 let rev = host.ingest(&commit).expect("the host accepts it");
588 session.committed(nonce, rev).expect("the ack lands");
589 }
590
591 fn edit(host: &mut Host, session: &mut ClientSession, commit: Commit) {
592 session.submit(commit).expect("the edit folds");
593 ack_last(host, session);
594 }
595
596 fn one_block() -> (Host, ClientSession) {
598 let (mut host, mut session) = (Host::default(), ClientSession::default());
599 edit(
600 &mut host,
601 &mut session,
602 commit("Added a block", vec![block_create(1)]),
603 );
604 (host, session)
605 }
606
607 #[test]
608 fn an_ack_leaves_the_prediction_equal_to_the_confirmed_document() {
609 let (host, session) = one_block();
610
611 assert!(session.last_submission().is_none(), "the queue drains");
612 assert_eq!(
613 session.optimistic().content_hash(),
614 session.confirmed().content_hash(),
615 "with nothing pending the prediction is the confirmed document",
616 );
617 assert_eq!(
618 session.confirmed().content_hash(),
619 host.state().content_hash(),
620 "and matches the host's fold, revs included",
621 );
622 }
623
624 #[test]
628 fn a_foreign_commit_rebuilds_the_prediction_over_the_new_head() {
629 let (mut host, mut session) = one_block();
630 session
631 .submit(commit("Moved it", vec![resize(1, rect(5, 5))]))
632 .expect("the edit folds");
633
634 let foreign = commit("Added another", vec![block_create(2)]);
635 let rev = host.ingest(&foreign).expect("the host accepts it");
636 session
637 .apply_foreign(rev, &foreign)
638 .expect("the delivery lands");
639
640 let pending = session
641 .last_submission()
642 .expect("the edit is still in flight")
643 .1
644 .clone();
645 let from_scratch = session
646 .confirmed()
647 .try_apply(&pending)
648 .expect("the pending edit folds on the new head");
649 assert_eq!(
650 session.optimistic().content_hash(),
651 from_scratch.content_hash(),
652 "optimistic == confirmed ⊕ pending, byte for byte",
653 );
654 assert_eq!(
655 *session
656 .optimistic()
657 .block(&block_id(1))
658 .expect("block 1 is present")
659 .as_ref()
660 .rect
661 .as_ref(),
662 rect(5, 5),
663 "the unacked local edit still stands above the foreign one",
664 );
665 }
666
667 #[test]
668 #[should_panic(expected = "gap in the per-connection rev sequence")]
669 fn a_rev_gap_panics_in_debug() {
670 let (_host, mut session) = one_block();
671 let skipped = session.rev().next().next();
672 let _ = session.apply_foreign(skipped, &commit("Skipped ahead", vec![block_create(9)]));
673 }
674
675 #[test]
676 fn an_answer_for_anything_but_the_front_of_the_queue_is_refused() {
677 let (host, mut session) = one_block();
678 let first = session
679 .submit(commit("First", vec![resize(1, rect(1, 1))]))
680 .expect("the edit folds");
681 let second = session
682 .submit(commit("Second", vec![resize(1, rect(2, 2))]))
683 .expect("the edit folds");
684 assert_ne!(first, second, "each submission gets its own nonce");
685
686 assert_eq!(
687 session.committed(second, host.rev().next()),
688 Err(SessionError::NonceMismatch {
689 expected: Some(first),
690 got: second,
691 }),
692 "answers arrive in submission order",
693 );
694 }
695
696 #[test]
697 fn a_local_edit_that_cannot_fold_is_never_queued() {
698 let mut session = ClientSession::default();
699 let before = session.optimistic().content_hash();
700
701 assert!(
702 session
703 .submit(commit("Orphan pin", vec![orphan_pin(3)]))
704 .is_err(),
705 "the client refuses what the server would refuse",
706 );
707 assert!(session.last_submission().is_none(), "nothing was queued");
708 assert!(!session.can_undo(), "and nothing was journalled");
709 assert_eq!(
710 session.optimistic().content_hash(),
711 before,
712 "the prediction is untouched",
713 );
714 }
715
716 #[test]
723 fn a_rejection_in_the_middle_makes_a_step_stale_rather_than_wrong() {
724 let (mut host, mut session) = one_block();
725 edit(
727 &mut host,
728 &mut session,
729 commit("Moved it", vec![resize(1, rect(5, 5))]),
730 );
731 let first = session
732 .next_undo()
733 .expect("the acknowledged edit is journalled");
734 let doomed = session
735 .submit(commit("Moved it again", vec![resize(1, rect(6, 6))]))
736 .expect("the edit folds locally");
737 let last = session
738 .submit(commit("And again", vec![resize(1, rect(7, 7))]))
739 .expect("the edit folds locally");
740 assert!(
741 session.undo_nonces().ends_with(&[first, doomed, last]),
742 "precondition: the doomed edit sits in the middle, not on top",
743 );
744
745 session.rejected(doomed).expect("the rejection lands");
746
747 let after = session.undo_nonces();
748 assert!(
749 after.ends_with(&[first, last]),
750 "the rejected entry left from the middle, not from the top: {after:?}",
751 );
752 assert!(!after.contains(&doomed));
753 assert_eq!(
754 session.undo(first),
755 Err(UndoRefusal::Stale { top: last }),
756 "a step naming the wrong edit is refused, not taken as the top",
757 );
758 assert!(
759 session.undo(last).is_ok(),
760 "and the step that names the top is taken",
761 );
762 }
763
764 #[test]
766 fn a_step_on_a_spent_stack_is_refused() {
767 let (_host, mut session) = one_block();
768 let only = session.next_undo().expect("the setup edit is journalled");
769 session.undo(only).expect("the inverse folds");
770 assert_eq!(session.next_undo(), None);
771 assert_eq!(session.undo(only), Err(UndoRefusal::Spent));
772 }
773
774 #[test]
775 fn a_rejection_drops_the_edit_and_its_journal_entry() {
776 let (mut host, mut session) = one_block();
777 let confirmed = session.confirmed().content_hash();
778
779 let doomed = session
780 .submit(commit("Moved it", vec![resize(1, rect(5, 5))]))
781 .expect("the edit folds locally");
782 session.rejected(doomed).expect("the rejection lands");
783
784 assert_eq!(
785 session.optimistic().content_hash(),
786 confirmed,
787 "the edit visibly reverts",
788 );
789 assert!(session.can_undo(), "the accepted create is still undoable");
790
791 let top = session.next_undo().expect("something to undo");
795 session.undo(top).expect("the inverse folds");
796 ack_last(&mut host, &mut session);
797 assert!(
798 projection(session.confirmed()).is_empty(),
799 "undo removed the block, so the entry inverted the create",
800 );
801 }
802
803 #[test]
807 fn a_pending_commit_the_head_invalidated_is_skipped_then_returns() {
808 let (mut host, mut session) = (Host::default(), ClientSession::default());
809 let setup = commit("Two blocks", vec![block_create(1), block_create(2)]);
810 let rev = host.ingest(&setup).expect("the host accepts it");
811 session.apply_foreign(rev, &setup).expect("the setup lands");
812
813 let nonce = session
814 .submit(commit("Nested 1 under 2", vec![reparent(1, block_id(2))]))
815 .expect("the edit folds on today's head");
816 assert_eq!(parent_of(&session, 1), block_id(2), "predicted immediately");
817
818 let cycle = commit("Nested 2 under 1", vec![reparent(2, block_id(1))]);
819 let rev = host.ingest(&cycle).expect("no cycle at the host yet");
820 session
821 .apply_foreign(rev, &cycle)
822 .expect("the delivery lands");
823 assert_eq!(
824 parent_of(&session, 1),
825 BlockId::NULL,
826 "the pending edit would close a cycle, so it is not predicted",
827 );
828
829 let undo_cycle = commit("Freed 2", vec![reparent(2, BlockId::NULL)]);
830 let rev = host.ingest(&undo_cycle).expect("the host accepts it");
831 session
832 .apply_foreign(rev, &undo_cycle)
833 .expect("the delivery lands");
834 assert_eq!(
835 parent_of(&session, 1),
836 block_id(2),
837 "and reappears once the head makes it valid again",
838 );
839
840 let pending = session
841 .last_submission()
842 .expect("the edit stayed queued through both rebuilds")
843 .1
844 .clone();
845 let rev = host.ingest(&pending).expect("the host accepts it now");
846 session.committed(nonce, rev).expect("the ack lands");
847 assert_eq!(
848 *session
849 .confirmed()
850 .block(&block_id(1))
851 .expect("block 1 is present")
852 .as_ref()
853 .parent
854 .as_ref(),
855 block_id(2),
856 );
857 }
858
859 #[test]
860 fn a_refused_ingest_consumes_no_rev_and_leaves_the_log_alone() {
861 let mut host = Host::default();
862 let accepted = host
863 .ingest(&commit("Added a block", vec![block_create(1)]))
864 .expect("the host accepts it");
865
866 assert!(
867 host.ingest(&commit("Orphan pin", vec![orphan_pin(3)]))
868 .is_err(),
869 );
870 assert_eq!(host.rev(), accepted, "a refusal mints no rev");
871 assert_eq!(host.commits_after(Rev::ZERO).len(), 1, "and logs nothing");
872
873 let next = host
874 .ingest(&commit("Added another", vec![block_create(2)]))
875 .expect("the host accepts it");
876 assert_eq!(
877 next,
878 accepted.next(),
879 "the next commit takes the rev the refusal did not consume",
880 );
881 }
882
883 #[test]
884 fn commits_after_replays_the_tail_in_rev_order() {
885 let mut host = Host::default();
886 for byte in 1..=3 {
887 host.ingest(&commit("Added a block", vec![block_create(byte)]))
888 .expect("the host accepts it");
889 }
890
891 assert_eq!(host.commits_after(Rev::ZERO).len(), 3, "the whole log");
892 assert_eq!(
893 host.commits_after(Rev::new(2)).len(),
894 1,
895 "log[i] is Rev(i+1)"
896 );
897 assert!(host.commits_after(host.rev()).is_empty(), "caught up");
898 assert!(
899 host.commits_after(host.rev().next()).is_empty(),
900 "and a rev beyond the head does not panic",
901 );
902 }
903
904 fn assert_round_trip<R: RevKind>(start: &Document<R>, edit: &Commit) -> Commit {
907 let after = start.try_apply(edit).expect("the edit folds");
908 assert_ne!(
909 projection(&after),
910 projection(start),
911 "the edit must be observable or the round trip proves nothing",
912 );
913
914 let inverse = inverse_of(start, edit);
915 let back = after.try_apply(&inverse).expect("the inverse folds");
916 assert_eq!(projection(&back), projection(start));
917 inverse
918 }
919
920 #[test]
921 fn the_journal_inverts_a_commit_that_writes_one_register_twice() {
922 let start = Document::<Confirmed>::default()
923 .try_apply(&commit("Added a block", vec![block_create(1)]))
924 .expect("the fold succeeds");
925
926 let inverse = assert_round_trip(
929 &start,
930 &commit(
931 "Dragged it",
932 vec![resize(1, rect(5, 5)), resize(1, rect(9, 9))],
933 ),
934 );
935 assert_eq!(inverse.ops().len(), 2);
936 }
937
938 #[test]
939 fn the_journal_inverts_a_create_and_a_delete() {
940 let start = Document::<Confirmed>::default()
941 .try_apply(&commit("Added a block", vec![block_create(1)]))
942 .expect("the fold succeeds");
943
944 assert_round_trip(&start, &commit("Added another", vec![block_create(2)]));
945 assert_round_trip(
946 &start,
947 &commit(
948 "Deleted it",
949 vec![OpCodes::Block(block_id(1), Crud::Delete)],
950 ),
951 );
952 }
953
954 #[test]
960 fn the_inverse_of_a_lifecycle_pair_unwinds_in_reverse() {
961 let start = Document::<Confirmed>::default();
962 let edit = commit(
963 "Added and dropped a block",
964 vec![block_create(1), OpCodes::Block(block_id(1), Crud::Delete)],
965 );
966 let after = start.try_apply(&edit).expect("the edit folds");
967 assert!(
968 projection(&after).is_empty(),
969 "the block ends tombstoned, so it was never visible",
970 );
971
972 let inverse = inverse_of(&start, &edit);
973 assert!(
974 matches!(inverse.ops().last(), Some(OpCodes::Block(_, Crud::Delete))),
975 "the create's inverse must run last",
976 );
977 let back = after.try_apply(&inverse).expect("the inverse folds");
978 assert!(
979 back.block(&block_id(1)).is_none_or(|live| !live.is_alive()),
980 "undo must not resurrect what the commit only ever tombstoned",
981 );
982 }
983
984 #[test]
985 fn the_journal_skips_an_update_to_an_entity_the_commit_created() {
986 let start = Document::<Confirmed>::default();
987 let inverse = assert_round_trip(
988 &start,
989 &commit(
990 "Added and placed a block",
991 vec![block_create(1), resize(1, rect(5, 5))],
992 ),
993 );
994
995 assert_eq!(
996 inverse.ops().len(),
997 1,
998 "the update has no baseline to restore; the create's delete covers it",
999 );
1000 assert!(matches!(
1001 inverse.ops().first(),
1002 Some(OpCodes::Block(_, Crud::Delete)),
1003 ));
1004 }
1005
1006 #[test]
1011 fn undoing_a_placement_un_creates_the_image_and_keeps_its_payload() {
1012 let asset = Asset::Png(b"artwork".as_slice().into());
1013 let start = Document::<Confirmed>::default()
1014 .try_apply(&commit("Added a block", vec![block_create(1)]))
1015 .expect("the fold succeeds");
1016 let edit = commit(
1017 "Placed an image",
1018 vec![
1019 OpCodes::Asset(asset.hash(), asset.clone()),
1020 OpCodes::Image(
1021 image_id(2),
1022 Crud::Create(ImageInit {
1023 owner: block_id(1),
1024 asset: asset.hash(),
1025 rect: ScreenRect::default(),
1026 }),
1027 ),
1028 ],
1029 );
1030
1031 let after = start.try_apply(&edit).expect("the edit folds");
1032 assert!(
1033 after.image(&image_id(2)).is_some_and(Live::is_alive),
1034 "precondition: the placement landed",
1035 );
1036 assert_eq!(after.asset(&asset.hash()), Some(&asset));
1037
1038 let inverse = inverse_of(&start, &edit);
1039 assert_eq!(
1040 inverse.ops().len(),
1041 1,
1042 "only the image create inverts; the payload has no inverse to emit",
1043 );
1044 let back = after.try_apply(&inverse).expect("the inverse folds");
1045 assert!(
1046 back.image(&image_id(2)).is_none_or(|live| !live.is_alive()),
1047 "the placement is undone",
1048 );
1049 assert_eq!(
1050 back.asset(&asset.hash()),
1051 Some(&asset),
1052 "and the bytes stay: create-only, with reclamation left to compaction",
1053 );
1054 }
1055
1056 #[test]
1057 fn the_journal_inverts_the_singleton() {
1058 let start = Document::<Confirmed>::default()
1059 .try_apply(&commit(
1060 "Named it",
1061 vec![OpCodes::Document(TitleBlockUpdate::Name("first".into()))],
1062 ))
1063 .expect("the fold succeeds");
1064
1065 let edit = commit(
1066 "Renamed it",
1067 vec![OpCodes::Document(TitleBlockUpdate::Name("second".into()))],
1068 );
1069 let after = start.try_apply(&edit).expect("the edit folds");
1070 let back = after
1071 .try_apply(&inverse_of(&start, &edit))
1072 .expect("the inverse folds");
1073
1074 assert_eq!(after.title_block().name.as_ref().as_str(), "second");
1075 assert_eq!(back.title_block().name.as_ref().as_str(), "first");
1076 }
1077
1078 #[test]
1079 fn undo_and_redo_travel_as_ordinary_commits() {
1080 let (mut host, mut session) = one_block();
1081 let placed = projection(session.confirmed());
1082 edit(
1083 &mut host,
1084 &mut session,
1085 commit("Moved it", vec![resize(1, rect(5, 5))]),
1086 );
1087 let moved = projection(session.confirmed());
1088 assert_ne!(placed, moved);
1089
1090 let top = session.next_undo().expect("something to undo");
1091 session.undo(top).expect("the inverse folds");
1092 ack_last(&mut host, &mut session);
1093 assert_eq!(projection(session.confirmed()), placed, "undo restored it");
1094 assert!(session.can_redo());
1095
1096 let top = session.next_redo().expect("something to redo");
1097 session.redo(top).expect("the inverse folds");
1098 ack_last(&mut host, &mut session);
1099 assert_eq!(projection(session.confirmed()), moved, "redo re-applied it");
1100 assert!(session.can_undo());
1101 assert!(!session.can_redo(), "the redo future is spent");
1102 }
1103
1104 #[test]
1105 fn a_fresh_edit_abandons_the_redo_future() {
1106 let (mut host, mut session) = one_block();
1107 edit(
1108 &mut host,
1109 &mut session,
1110 commit("Moved it", vec![resize(1, rect(5, 5))]),
1111 );
1112 let top = session.next_undo().expect("something to undo");
1113 session.undo(top).expect("the inverse folds");
1114 ack_last(&mut host, &mut session);
1115 assert!(session.can_redo());
1116
1117 edit(
1118 &mut host,
1119 &mut session,
1120 commit("Moved it elsewhere", vec![resize(1, rect(7, 7))]),
1121 );
1122 assert!(!session.can_redo(), "a new edit forks the history");
1123 }
1124
1125 #[test]
1126 fn welcome_folds_a_log_into_a_session() {
1127 let mut host = Host::default();
1128 for byte in 1..=3 {
1129 host.ingest(&commit("Added a block", vec![block_create(byte)]))
1130 .expect("the host accepts it");
1131 }
1132
1133 let session = ClientSession::welcome(host.commits_after(Rev::ZERO)).expect("the log folds");
1134 assert_eq!(session.rev(), host.rev());
1135 assert_eq!(
1136 session.confirmed().content_hash(),
1137 host.state().content_hash(),
1138 "a replayed log reproduces the host's document exactly",
1139 );
1140 }
1141}