1#![forbid(unsafe_code)]
2
3use std::any::Any;
32use std::fmt;
33use web_time::Instant;
34
35#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
40pub struct WidgetId(pub u64);
41
42impl WidgetId {
43 #[must_use]
45 pub const fn new(id: u64) -> Self {
46 Self(id)
47 }
48
49 #[must_use]
51 pub const fn raw(self) -> u64 {
52 self.0
53 }
54}
55
56#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
60pub enum CommandSource {
61 #[default]
63 User,
64 Programmatic,
66 Macro,
68 External,
70}
71
72#[derive(Debug, Clone)]
74pub struct CommandMetadata {
75 pub description: String,
77 pub timestamp: Instant,
79 pub source: CommandSource,
81 pub batch_id: Option<u64>,
83}
84
85impl CommandMetadata {
86 #[must_use]
88 pub fn new(description: impl Into<String>) -> Self {
89 Self {
90 description: description.into(),
91 timestamp: Instant::now(),
92 source: CommandSource::User,
93 batch_id: None,
94 }
95 }
96
97 #[must_use]
99 pub fn with_source(mut self, source: CommandSource) -> Self {
100 self.source = source;
101 self
102 }
103
104 #[must_use]
106 pub fn with_batch(mut self, batch_id: u64) -> Self {
107 self.batch_id = Some(batch_id);
108 self
109 }
110
111 #[must_use]
113 pub fn size_bytes(&self) -> usize {
114 std::mem::size_of::<Self>() + self.description.len()
115 }
116}
117
118impl Default for CommandMetadata {
119 fn default() -> Self {
120 Self::new("Unknown")
121 }
122}
123
124pub type CommandResult = Result<(), CommandError>;
128
129#[derive(Debug, Clone, PartialEq, Eq)]
131pub enum CommandError {
132 TargetNotFound(WidgetId),
134 PositionOutOfBounds { position: usize, length: usize },
136 StateDrift { expected: String, actual: String },
138 InvalidState(String),
140 Other(String),
142}
143
144impl fmt::Display for CommandError {
145 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
146 match self {
147 Self::TargetNotFound(id) => write!(f, "target widget {:?} not found", id),
148 Self::PositionOutOfBounds { position, length } => {
149 write!(f, "position {} out of bounds (length {})", position, length)
150 }
151 Self::StateDrift { expected, actual } => {
152 write!(f, "state drift: expected '{}', got '{}'", expected, actual)
153 }
154 Self::InvalidState(msg) => write!(f, "invalid state: {}", msg),
155 Self::Other(msg) => write!(f, "{}", msg),
156 }
157 }
158}
159
160impl std::error::Error for CommandError {}
161
162#[derive(Debug, Clone, Copy)]
164pub struct MergeConfig {
165 pub max_delay_ms: u64,
167 pub merge_across_words: bool,
169 pub max_merged_size: usize,
171}
172
173impl Default for MergeConfig {
174 fn default() -> Self {
175 Self {
176 max_delay_ms: 500,
177 merge_across_words: false,
178 max_merged_size: 1024,
179 }
180 }
181}
182
183pub trait UndoableCmd: Send + Sync {
188 fn execute(&mut self) -> CommandResult;
190
191 fn undo(&mut self) -> CommandResult;
193
194 fn redo(&mut self) -> CommandResult {
196 self.execute()
197 }
198
199 fn description(&self) -> &str;
201
202 fn size_bytes(&self) -> usize;
204
205 fn can_merge(&self, _other: &dyn UndoableCmd, _config: &MergeConfig) -> bool {
207 false
208 }
209
210 fn merge_text(&self) -> Option<&str> {
215 None
216 }
217
218 fn accept_merge(&mut self, _other: &dyn UndoableCmd) -> bool {
223 false
224 }
225
226 fn metadata(&self) -> &CommandMetadata;
228
229 fn target(&self) -> Option<WidgetId> {
231 None
232 }
233
234 fn as_any(&self) -> &dyn Any;
236
237 fn as_any_mut(&mut self) -> &mut dyn Any;
239
240 fn debug_name(&self) -> &'static str {
242 "UndoableCmd"
243 }
244}
245
246impl fmt::Debug for dyn UndoableCmd {
247 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
248 f.debug_struct(self.debug_name())
249 .field("description", &self.description())
250 .field("size_bytes", &self.size_bytes())
251 .finish()
252 }
253}
254
255pub struct CommandBatch {
260 commands: Vec<Box<dyn UndoableCmd>>,
262 metadata: CommandMetadata,
264 executed_to: usize,
266}
267
268impl fmt::Debug for CommandBatch {
269 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
270 f.debug_struct("CommandBatch")
271 .field("commands_count", &self.commands.len())
272 .field("metadata", &self.metadata)
273 .field("executed_to", &self.executed_to)
274 .finish()
275 }
276}
277
278impl CommandBatch {
279 #[must_use]
281 pub fn new(description: impl Into<String>) -> Self {
282 Self {
283 commands: Vec::new(),
284 metadata: CommandMetadata::new(description),
285 executed_to: 0,
286 }
287 }
288
289 pub fn push(&mut self, cmd: Box<dyn UndoableCmd>) {
291 self.commands.push(cmd);
292 }
293
294 pub fn push_executed(&mut self, cmd: Box<dyn UndoableCmd>) {
299 self.commands.push(cmd);
300 self.executed_to = self.commands.len();
301 }
302
303 #[must_use]
305 pub fn len(&self) -> usize {
306 self.commands.len()
307 }
308
309 #[must_use]
311 pub fn is_empty(&self) -> bool {
312 self.commands.is_empty()
313 }
314}
315
316impl UndoableCmd for CommandBatch {
317 fn execute(&mut self) -> CommandResult {
318 for (i, cmd) in self.commands.iter_mut().enumerate() {
319 if let Err(e) = cmd.execute() {
320 for j in (0..i).rev() {
322 let _ = self.commands[j].undo();
323 }
324 return Err(e);
325 }
326 self.executed_to = i + 1;
327 }
328 Ok(())
329 }
330
331 fn undo(&mut self) -> CommandResult {
332 for i in (0..self.executed_to).rev() {
334 self.commands[i].undo()?;
335 }
336 self.executed_to = 0;
337 Ok(())
338 }
339
340 fn redo(&mut self) -> CommandResult {
341 self.execute()
342 }
343
344 fn description(&self) -> &str {
345 &self.metadata.description
346 }
347
348 fn size_bytes(&self) -> usize {
349 std::mem::size_of::<Self>()
350 + self.metadata.size_bytes()
351 + self.commands.iter().map(|c| c.size_bytes()).sum::<usize>()
352 }
353
354 fn metadata(&self) -> &CommandMetadata {
355 &self.metadata
356 }
357
358 fn as_any(&self) -> &dyn Any {
359 self
360 }
361
362 fn as_any_mut(&mut self) -> &mut dyn Any {
363 self
364 }
365
366 fn debug_name(&self) -> &'static str {
367 "CommandBatch"
368 }
369}
370
371pub type TextApplyFn = Box<dyn Fn(WidgetId, usize, &str) -> CommandResult + Send + Sync>;
377pub type TextRemoveFn = Box<dyn Fn(WidgetId, usize, usize) -> CommandResult + Send + Sync>;
379pub type TextReplaceFn = Box<dyn Fn(WidgetId, usize, usize, &str) -> CommandResult + Send + Sync>;
381
382pub struct TextInsertCmd {
384 pub target: WidgetId,
386 pub position: usize,
388 pub text: String,
390 pub metadata: CommandMetadata,
392 apply: Option<TextApplyFn>,
394 remove: Option<TextRemoveFn>,
396}
397
398impl fmt::Debug for TextInsertCmd {
399 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
400 f.debug_struct("TextInsertCmd")
401 .field("target", &self.target)
402 .field("position", &self.position)
403 .field("text", &self.text)
404 .field("metadata", &self.metadata)
405 .field("has_apply", &self.apply.is_some())
406 .field("has_remove", &self.remove.is_some())
407 .finish()
408 }
409}
410
411impl TextInsertCmd {
412 #[must_use]
414 pub fn new(target: WidgetId, position: usize, text: impl Into<String>) -> Self {
415 Self {
416 target,
417 position,
418 text: text.into(),
419 metadata: CommandMetadata::new("Insert text"),
420 apply: None,
421 remove: None,
422 }
423 }
424
425 pub fn with_apply<F>(mut self, f: F) -> Self
427 where
428 F: Fn(WidgetId, usize, &str) -> CommandResult + Send + Sync + 'static,
429 {
430 self.apply = Some(Box::new(f));
431 self
432 }
433
434 pub fn with_remove<F>(mut self, f: F) -> Self
436 where
437 F: Fn(WidgetId, usize, usize) -> CommandResult + Send + Sync + 'static,
438 {
439 self.remove = Some(Box::new(f));
440 self
441 }
442}
443
444impl UndoableCmd for TextInsertCmd {
445 fn execute(&mut self) -> CommandResult {
446 if let Some(ref apply) = self.apply {
447 apply(self.target, self.position, &self.text)
448 } else {
449 Err(CommandError::InvalidState(
450 "no apply callback set".to_string(),
451 ))
452 }
453 }
454
455 fn undo(&mut self) -> CommandResult {
456 if let Some(ref remove) = self.remove {
457 remove(self.target, self.position, self.text.len())
458 } else {
459 Err(CommandError::InvalidState(
460 "no remove callback set".to_string(),
461 ))
462 }
463 }
464
465 fn description(&self) -> &str {
466 &self.metadata.description
467 }
468
469 fn size_bytes(&self) -> usize {
470 std::mem::size_of::<Self>() + self.text.len() + self.metadata.size_bytes()
471 }
472
473 fn can_merge(&self, other: &dyn UndoableCmd, config: &MergeConfig) -> bool {
474 let Some(other) = other.as_any().downcast_ref::<Self>() else {
475 return false;
476 };
477
478 if self.target != other.target {
480 return false;
481 }
482
483 if other.position != self.position + self.text.len() {
485 return false;
486 }
487
488 let elapsed = other
490 .metadata
491 .timestamp
492 .duration_since(self.metadata.timestamp);
493 if elapsed.as_millis() > config.max_delay_ms as u128 {
494 return false;
495 }
496
497 if self.text.len() + other.text.len() > config.max_merged_size {
499 return false;
500 }
501
502 if !config.merge_across_words && self.text.ends_with(' ') {
504 return false;
505 }
506
507 true
508 }
509
510 fn merge_text(&self) -> Option<&str> {
511 Some(&self.text)
512 }
513
514 fn accept_merge(&mut self, other: &dyn UndoableCmd) -> bool {
515 let Some(other_insert) = other.as_any().downcast_ref::<Self>() else {
516 return false;
517 };
518 self.text.push_str(&other_insert.text);
519 true
520 }
521
522 fn metadata(&self) -> &CommandMetadata {
523 &self.metadata
524 }
525
526 fn target(&self) -> Option<WidgetId> {
527 Some(self.target)
528 }
529
530 fn as_any(&self) -> &dyn Any {
531 self
532 }
533
534 fn as_any_mut(&mut self) -> &mut dyn Any {
535 self
536 }
537
538 fn debug_name(&self) -> &'static str {
539 "TextInsertCmd"
540 }
541}
542
543pub struct TextDeleteCmd {
545 pub target: WidgetId,
547 pub position: usize,
549 pub deleted_text: String,
551 pub metadata: CommandMetadata,
553 remove: Option<TextRemoveFn>,
555 insert: Option<TextApplyFn>,
557}
558
559impl fmt::Debug for TextDeleteCmd {
560 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
561 f.debug_struct("TextDeleteCmd")
562 .field("target", &self.target)
563 .field("position", &self.position)
564 .field("deleted_text", &self.deleted_text)
565 .field("metadata", &self.metadata)
566 .field("has_remove", &self.remove.is_some())
567 .field("has_insert", &self.insert.is_some())
568 .finish()
569 }
570}
571
572impl TextDeleteCmd {
573 #[must_use]
575 pub fn new(target: WidgetId, position: usize, deleted_text: impl Into<String>) -> Self {
576 Self {
577 target,
578 position,
579 deleted_text: deleted_text.into(),
580 metadata: CommandMetadata::new("Delete text"),
581 remove: None,
582 insert: None,
583 }
584 }
585
586 pub fn with_remove<F>(mut self, f: F) -> Self
588 where
589 F: Fn(WidgetId, usize, usize) -> CommandResult + Send + Sync + 'static,
590 {
591 self.remove = Some(Box::new(f));
592 self
593 }
594
595 pub fn with_insert<F>(mut self, f: F) -> Self
597 where
598 F: Fn(WidgetId, usize, &str) -> CommandResult + Send + Sync + 'static,
599 {
600 self.insert = Some(Box::new(f));
601 self
602 }
603}
604
605impl UndoableCmd for TextDeleteCmd {
606 fn execute(&mut self) -> CommandResult {
607 if let Some(ref remove) = self.remove {
608 remove(self.target, self.position, self.deleted_text.len())
609 } else {
610 Err(CommandError::InvalidState(
611 "no remove callback set".to_string(),
612 ))
613 }
614 }
615
616 fn undo(&mut self) -> CommandResult {
617 if let Some(ref insert) = self.insert {
618 insert(self.target, self.position, &self.deleted_text)
619 } else {
620 Err(CommandError::InvalidState(
621 "no insert callback set".to_string(),
622 ))
623 }
624 }
625
626 fn description(&self) -> &str {
627 &self.metadata.description
628 }
629
630 fn size_bytes(&self) -> usize {
631 std::mem::size_of::<Self>() + self.deleted_text.len() + self.metadata.size_bytes()
632 }
633
634 fn can_merge(&self, other: &dyn UndoableCmd, config: &MergeConfig) -> bool {
635 let Some(other) = other.as_any().downcast_ref::<Self>() else {
636 return false;
637 };
638
639 if self.target != other.target {
641 return false;
642 }
643
644 let is_backspace = other.position + other.deleted_text.len() == self.position;
647 let is_delete = other.position == self.position;
648
649 if !is_backspace && !is_delete {
650 return false;
651 }
652
653 let elapsed = other
655 .metadata
656 .timestamp
657 .duration_since(self.metadata.timestamp);
658 if elapsed.as_millis() > config.max_delay_ms as u128 {
659 return false;
660 }
661
662 if self.deleted_text.len() + other.deleted_text.len() > config.max_merged_size {
664 return false;
665 }
666
667 true
668 }
669
670 fn merge_text(&self) -> Option<&str> {
671 Some(&self.deleted_text)
672 }
673
674 fn accept_merge(&mut self, other: &dyn UndoableCmd) -> bool {
675 let Some(other_delete) = other.as_any().downcast_ref::<Self>() else {
676 return false;
677 };
678
679 let is_backspace = other_delete.position + other_delete.deleted_text.len() == self.position;
685 let is_forward = other_delete.position == self.position;
686 if !is_backspace && !is_forward {
687 return false;
688 }
689
690 if is_backspace {
691 self.deleted_text = format!("{}{}", other_delete.deleted_text, self.deleted_text);
693 self.position = other_delete.position;
694 } else {
695 self.deleted_text.push_str(&other_delete.deleted_text);
697 }
698 true
699 }
700
701 fn metadata(&self) -> &CommandMetadata {
702 &self.metadata
703 }
704
705 fn target(&self) -> Option<WidgetId> {
706 Some(self.target)
707 }
708
709 fn as_any(&self) -> &dyn Any {
710 self
711 }
712
713 fn as_any_mut(&mut self) -> &mut dyn Any {
714 self
715 }
716
717 fn debug_name(&self) -> &'static str {
718 "TextDeleteCmd"
719 }
720}
721
722pub struct TextReplaceCmd {
724 pub target: WidgetId,
726 pub position: usize,
728 pub old_text: String,
730 pub new_text: String,
732 pub metadata: CommandMetadata,
734 replace: Option<TextReplaceFn>,
736}
737
738impl fmt::Debug for TextReplaceCmd {
739 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
740 f.debug_struct("TextReplaceCmd")
741 .field("target", &self.target)
742 .field("position", &self.position)
743 .field("old_text", &self.old_text)
744 .field("new_text", &self.new_text)
745 .field("metadata", &self.metadata)
746 .field("has_replace", &self.replace.is_some())
747 .finish()
748 }
749}
750
751impl TextReplaceCmd {
752 #[must_use]
754 pub fn new(
755 target: WidgetId,
756 position: usize,
757 old_text: impl Into<String>,
758 new_text: impl Into<String>,
759 ) -> Self {
760 Self {
761 target,
762 position,
763 old_text: old_text.into(),
764 new_text: new_text.into(),
765 metadata: CommandMetadata::new("Replace text"),
766 replace: None,
767 }
768 }
769
770 pub fn with_replace<F>(mut self, f: F) -> Self
772 where
773 F: Fn(WidgetId, usize, usize, &str) -> CommandResult + Send + Sync + 'static,
774 {
775 self.replace = Some(Box::new(f));
776 self
777 }
778}
779
780impl UndoableCmd for TextReplaceCmd {
781 fn execute(&mut self) -> CommandResult {
782 if let Some(ref replace) = self.replace {
783 replace(
784 self.target,
785 self.position,
786 self.old_text.len(),
787 &self.new_text,
788 )
789 } else {
790 Err(CommandError::InvalidState(
791 "no replace callback set".to_string(),
792 ))
793 }
794 }
795
796 fn undo(&mut self) -> CommandResult {
797 if let Some(ref replace) = self.replace {
798 replace(
799 self.target,
800 self.position,
801 self.new_text.len(),
802 &self.old_text,
803 )
804 } else {
805 Err(CommandError::InvalidState(
806 "no replace callback set".to_string(),
807 ))
808 }
809 }
810
811 fn description(&self) -> &str {
812 &self.metadata.description
813 }
814
815 fn size_bytes(&self) -> usize {
816 std::mem::size_of::<Self>()
817 + self.old_text.len()
818 + self.new_text.len()
819 + self.metadata.size_bytes()
820 }
821
822 fn metadata(&self) -> &CommandMetadata {
823 &self.metadata
824 }
825
826 fn target(&self) -> Option<WidgetId> {
827 Some(self.target)
828 }
829
830 fn as_any(&self) -> &dyn Any {
831 self
832 }
833
834 fn as_any_mut(&mut self) -> &mut dyn Any {
835 self
836 }
837
838 fn debug_name(&self) -> &'static str {
839 "TextReplaceCmd"
840 }
841}
842
843#[cfg(test)]
848mod tests {
849 use super::*;
850 use std::sync::Arc;
851 use std::sync::Mutex;
852
853 #[test]
854 fn test_widget_id_creation() {
855 let id = WidgetId::new(42);
856 assert_eq!(id.raw(), 42);
857 }
858
859 #[test]
860 fn test_command_metadata_size() {
861 let meta = CommandMetadata::new("Test command");
862 let size = meta.size_bytes();
863 assert!(size > std::mem::size_of::<CommandMetadata>());
864 assert!(size >= std::mem::size_of::<CommandMetadata>() + "Test command".len());
865 }
866
867 #[test]
868 fn test_command_metadata_with_source() {
869 let meta = CommandMetadata::new("Test").with_source(CommandSource::Macro);
870 assert_eq!(meta.source, CommandSource::Macro);
871 }
872
873 #[test]
874 fn test_command_metadata_with_batch() {
875 let meta = CommandMetadata::new("Test").with_batch(123);
876 assert_eq!(meta.batch_id, Some(123));
877 }
878
879 #[test]
880 fn test_command_batch_execute_undo() {
881 let buffer = Arc::new(Mutex::new(String::new()));
883
884 let mut batch = CommandBatch::new("Test batch");
885
886 let b1 = buffer.clone();
888 let b2 = buffer.clone();
889 let b3 = buffer.clone();
890 let b4 = buffer.clone();
891
892 let cmd1 = TextInsertCmd::new(WidgetId::new(1), 0, "Hello")
893 .with_apply(move |_, pos, text| {
894 let mut buf = b1.lock().unwrap();
895 buf.insert_str(pos, text);
896 Ok(())
897 })
898 .with_remove(move |_, pos, len| {
899 let mut buf = b2.lock().unwrap();
900 buf.drain(pos..pos + len);
901 Ok(())
902 });
903
904 let cmd2 = TextInsertCmd::new(WidgetId::new(1), 5, " World")
905 .with_apply(move |_, pos, text| {
906 let mut buf = b3.lock().unwrap();
907 buf.insert_str(pos, text);
908 Ok(())
909 })
910 .with_remove(move |_, pos, len| {
911 let mut buf = b4.lock().unwrap();
912 buf.drain(pos..pos + len);
913 Ok(())
914 });
915
916 batch.push(Box::new(cmd1));
917 batch.push(Box::new(cmd2));
918
919 batch.execute().unwrap();
921 assert_eq!(*buffer.lock().unwrap(), "Hello World");
922
923 batch.undo().unwrap();
925 assert_eq!(*buffer.lock().unwrap(), "");
926 }
927
928 #[test]
929 fn test_command_batch_empty() {
930 let batch = CommandBatch::new("Empty");
931 assert!(batch.is_empty());
932 assert_eq!(batch.len(), 0);
933 }
934
935 #[test]
936 fn test_text_insert_can_merge_consecutive() {
937 let cmd1 = TextInsertCmd::new(WidgetId::new(1), 0, "a");
938 let mut cmd2 = TextInsertCmd::new(WidgetId::new(1), 1, "b");
939 cmd2.metadata.timestamp = cmd1.metadata.timestamp;
941
942 let config = MergeConfig::default();
943 assert!(cmd1.can_merge(&cmd2, &config));
944 }
945
946 #[test]
947 fn test_text_insert_no_merge_different_widget() {
948 let cmd1 = TextInsertCmd::new(WidgetId::new(1), 0, "a");
949 let mut cmd2 = TextInsertCmd::new(WidgetId::new(2), 1, "b");
950 cmd2.metadata.timestamp = cmd1.metadata.timestamp;
951
952 let config = MergeConfig::default();
953 assert!(!cmd1.can_merge(&cmd2, &config));
954 }
955
956 #[test]
957 fn test_text_insert_no_merge_non_consecutive() {
958 let cmd1 = TextInsertCmd::new(WidgetId::new(1), 0, "a");
959 let mut cmd2 = TextInsertCmd::new(WidgetId::new(1), 5, "b");
960 cmd2.metadata.timestamp = cmd1.metadata.timestamp;
961
962 let config = MergeConfig::default();
963 assert!(!cmd1.can_merge(&cmd2, &config));
964 }
965
966 #[test]
967 fn test_text_delete_can_merge_backspace() {
968 let cmd1 = TextDeleteCmd::new(WidgetId::new(1), 5, "b");
969 let mut cmd2 = TextDeleteCmd::new(WidgetId::new(1), 4, "a");
970 cmd2.metadata.timestamp = cmd1.metadata.timestamp;
971
972 let config = MergeConfig::default();
973 assert!(cmd1.can_merge(&cmd2, &config));
974 }
975
976 #[test]
977 fn test_text_delete_can_merge_delete_key() {
978 let cmd1 = TextDeleteCmd::new(WidgetId::new(1), 5, "a");
979 let mut cmd2 = TextDeleteCmd::new(WidgetId::new(1), 5, "b");
980 cmd2.metadata.timestamp = cmd1.metadata.timestamp;
981
982 let config = MergeConfig::default();
983 assert!(cmd1.can_merge(&cmd2, &config));
984 }
985
986 #[test]
987 fn test_command_error_display() {
988 let err = CommandError::TargetNotFound(WidgetId::new(42));
989 assert!(err.to_string().contains("42"));
990
991 let err = CommandError::PositionOutOfBounds {
992 position: 10,
993 length: 5,
994 };
995 assert!(err.to_string().contains("10"));
996 assert!(err.to_string().contains("5"));
997 }
998
999 #[test]
1000 fn test_merge_config_default() {
1001 let config = MergeConfig::default();
1002 assert_eq!(config.max_delay_ms, 500);
1003 assert!(!config.merge_across_words);
1004 assert_eq!(config.max_merged_size, 1024);
1005 }
1006
1007 #[test]
1008 fn test_text_replace_size_bytes() {
1009 let cmd = TextReplaceCmd::new(WidgetId::new(1), 0, "old", "new");
1010 let size = cmd.size_bytes();
1011 assert!(size >= std::mem::size_of::<TextReplaceCmd>() + 3 + 3);
1012 }
1013
1014 #[test]
1015 fn test_text_insert_accept_merge() {
1016 let mut cmd1 = TextInsertCmd::new(WidgetId::new(1), 0, "Hello");
1017 let cmd2 = TextInsertCmd::new(WidgetId::new(1), 5, " World");
1018 assert!(cmd1.accept_merge(&cmd2));
1019 assert_eq!(cmd1.text, "Hello World");
1020 }
1021
1022 #[test]
1023 fn test_text_delete_accept_merge_backspace() {
1024 let mut cmd1 = TextDeleteCmd::new(WidgetId::new(1), 4, "b");
1028 let cmd2 = TextDeleteCmd::new(WidgetId::new(1), 3, "a");
1029 assert!(cmd1.accept_merge(&cmd2));
1030 assert_eq!(cmd1.deleted_text, "ab");
1031 assert_eq!(cmd1.position, 3); }
1033
1034 #[test]
1035 fn test_text_delete_accept_merge_forward_delete() {
1036 let mut cmd1 = TextDeleteCmd::new(WidgetId::new(1), 3, "a");
1040 let cmd2 = TextDeleteCmd::new(WidgetId::new(1), 3, "b");
1041 assert!(cmd1.accept_merge(&cmd2));
1042 assert_eq!(cmd1.deleted_text, "ab");
1043 assert_eq!(cmd1.position, 3); }
1045
1046 #[test]
1047 fn test_debug_implementations() {
1048 let cmd = TextInsertCmd::new(WidgetId::new(1), 0, "test");
1049 let debug_str = format!("{:?}", cmd);
1050 assert!(debug_str.contains("TextInsertCmd"));
1051 assert!(debug_str.contains("test"));
1052
1053 let batch = CommandBatch::new("Test batch");
1054 let debug_str = format!("{:?}", batch);
1055 assert!(debug_str.contains("CommandBatch"));
1056 }
1057
1058 #[test]
1059 fn test_text_insert_execute_and_undo_with_callbacks() {
1060 let buf = Arc::new(Mutex::new(String::from("Hello")));
1061 let b1 = buf.clone();
1062 let b2 = buf.clone();
1063
1064 let mut cmd = TextInsertCmd::new(WidgetId::new(1), 5, " World")
1065 .with_apply(move |_, pos, text| {
1066 let mut b = b1.lock().unwrap();
1067 b.insert_str(pos, text);
1068 Ok(())
1069 })
1070 .with_remove(move |_, pos, len| {
1071 let mut b = b2.lock().unwrap();
1072 b.drain(pos..pos + len);
1073 Ok(())
1074 });
1075
1076 cmd.execute().unwrap();
1077 assert_eq!(*buf.lock().unwrap(), "Hello World");
1078 cmd.undo().unwrap();
1079 assert_eq!(*buf.lock().unwrap(), "Hello");
1080 }
1081
1082 #[test]
1083 fn test_text_insert_execute_without_callback_errors() {
1084 let mut cmd = TextInsertCmd::new(WidgetId::new(1), 0, "test");
1085 let err = cmd.execute().unwrap_err();
1086 assert!(matches!(err, CommandError::InvalidState(_)));
1087 }
1088
1089 #[test]
1090 fn test_text_insert_undo_without_callback_errors() {
1091 let mut cmd = TextInsertCmd::new(WidgetId::new(1), 0, "test");
1092 let err = cmd.undo().unwrap_err();
1093 assert!(matches!(err, CommandError::InvalidState(_)));
1094 }
1095
1096 #[test]
1097 fn test_text_insert_target() {
1098 let cmd = TextInsertCmd::new(WidgetId::new(42), 0, "x");
1099 assert_eq!(cmd.target(), Some(WidgetId::new(42)));
1100 }
1101
1102 #[test]
1103 fn test_text_insert_merge_text() {
1104 let cmd = TextInsertCmd::new(WidgetId::new(1), 0, "abc");
1105 assert_eq!(cmd.merge_text(), Some("abc"));
1106 }
1107
1108 #[test]
1109 fn test_text_delete_target() {
1110 let cmd = TextDeleteCmd::new(WidgetId::new(7), 0, "x");
1111 assert_eq!(cmd.target(), Some(WidgetId::new(7)));
1112 }
1113
1114 #[test]
1115 fn test_command_metadata_default() {
1116 let meta = CommandMetadata::default();
1117 assert_eq!(meta.description, "Unknown");
1118 assert_eq!(meta.source, CommandSource::User);
1119 assert_eq!(meta.batch_id, None);
1120 }
1121
1122 #[test]
1123 fn test_command_source_default_is_user() {
1124 assert_eq!(CommandSource::default(), CommandSource::User);
1125 }
1126
1127 #[test]
1128 fn test_command_error_state_drift_display() {
1129 let err = CommandError::StateDrift {
1130 expected: "foo".to_string(),
1131 actual: "bar".to_string(),
1132 };
1133 let s = err.to_string();
1134 assert!(s.contains("foo"));
1135 assert!(s.contains("bar"));
1136 }
1137
1138 #[test]
1139 fn test_command_error_other_display() {
1140 let err = CommandError::Other("something broke".to_string());
1141 assert!(err.to_string().contains("something broke"));
1142 }
1143
1144 #[test]
1145 fn test_command_batch_push_executed_tracks_index() {
1146 let buf = Arc::new(Mutex::new(String::new()));
1147 let b1 = buf.clone();
1148 let b2 = buf.clone();
1149
1150 {
1152 let mut b = buf.lock().unwrap();
1153 b.push_str("Hi");
1154 }
1155
1156 let cmd = TextInsertCmd::new(WidgetId::new(1), 0, "Hi")
1157 .with_apply(move |_, pos, text| {
1158 let mut b = b1.lock().unwrap();
1159 b.insert_str(pos, text);
1160 Ok(())
1161 })
1162 .with_remove(move |_, pos, len| {
1163 let mut b = b2.lock().unwrap();
1164 b.drain(pos..pos + len);
1165 Ok(())
1166 });
1167
1168 let mut batch = CommandBatch::new("Pre-executed batch");
1169 batch.push_executed(Box::new(cmd));
1170 assert_eq!(batch.len(), 1);
1171 batch.undo().unwrap();
1173 assert_eq!(*buf.lock().unwrap(), "");
1174 }
1175
1176 #[test]
1181 fn test_text_delete_execute_and_undo_with_callbacks() {
1182 let buf = Arc::new(Mutex::new(String::from("Hello World")));
1183 let b1 = buf.clone();
1184 let b2 = buf.clone();
1185
1186 let mut cmd = TextDeleteCmd::new(WidgetId::new(1), 5, " World")
1187 .with_remove(move |_, pos, len| {
1188 let mut b = b1.lock().unwrap();
1189 b.drain(pos..pos + len);
1190 Ok(())
1191 })
1192 .with_insert(move |_, pos, text| {
1193 let mut b = b2.lock().unwrap();
1194 b.insert_str(pos, text);
1195 Ok(())
1196 });
1197
1198 cmd.execute().unwrap();
1199 assert_eq!(*buf.lock().unwrap(), "Hello");
1200
1201 cmd.undo().unwrap();
1202 assert_eq!(*buf.lock().unwrap(), "Hello World");
1203 }
1204
1205 #[test]
1206 fn test_text_delete_execute_without_callback_errors() {
1207 let mut cmd = TextDeleteCmd::new(WidgetId::new(1), 0, "x");
1208 let err = cmd.execute().unwrap_err();
1209 assert!(matches!(err, CommandError::InvalidState(_)));
1210 }
1211
1212 #[test]
1213 fn test_text_delete_undo_without_callback_errors() {
1214 let mut cmd = TextDeleteCmd::new(WidgetId::new(1), 0, "x");
1215 let err = cmd.undo().unwrap_err();
1216 assert!(matches!(err, CommandError::InvalidState(_)));
1217 }
1218
1219 #[test]
1220 fn test_text_delete_size_bytes() {
1221 let cmd = TextDeleteCmd::new(WidgetId::new(1), 0, "abc");
1222 let size = cmd.size_bytes();
1223 assert!(size >= std::mem::size_of::<TextDeleteCmd>() + 3);
1224 }
1225
1226 #[test]
1227 fn test_text_delete_description() {
1228 let cmd = TextDeleteCmd::new(WidgetId::new(1), 0, "x");
1229 assert_eq!(cmd.description(), "Delete text");
1230 }
1231
1232 #[test]
1233 fn test_text_delete_merge_text() {
1234 let cmd = TextDeleteCmd::new(WidgetId::new(1), 5, "xyz");
1235 assert_eq!(cmd.merge_text(), Some("xyz"));
1236 }
1237
1238 #[test]
1239 fn test_text_delete_debug() {
1240 let cmd = TextDeleteCmd::new(WidgetId::new(1), 3, "abc");
1241 let s = format!("{:?}", cmd);
1242 assert!(s.contains("TextDeleteCmd"));
1243 assert!(s.contains("abc"));
1244 }
1245
1246 #[test]
1247 fn test_text_delete_debug_name() {
1248 let cmd = TextDeleteCmd::new(WidgetId::new(1), 0, "x");
1249 assert_eq!(cmd.debug_name(), "TextDeleteCmd");
1250 }
1251
1252 #[test]
1257 fn test_text_delete_no_merge_different_widget() {
1258 let cmd1 = TextDeleteCmd::new(WidgetId::new(1), 5, "a");
1259 let mut cmd2 = TextDeleteCmd::new(WidgetId::new(2), 5, "b");
1260 cmd2.metadata.timestamp = cmd1.metadata.timestamp;
1261
1262 let config = MergeConfig::default();
1263 assert!(!cmd1.can_merge(&cmd2, &config));
1264 }
1265
1266 #[test]
1267 fn test_text_delete_no_merge_non_adjacent() {
1268 let cmd1 = TextDeleteCmd::new(WidgetId::new(1), 5, "a");
1269 let mut cmd2 = TextDeleteCmd::new(WidgetId::new(1), 10, "b");
1270 cmd2.metadata.timestamp = cmd1.metadata.timestamp;
1271
1272 let config = MergeConfig::default();
1273 assert!(!cmd1.can_merge(&cmd2, &config));
1274 }
1275
1276 #[test]
1277 fn test_text_delete_no_merge_exceeds_max_size() {
1278 let long_text = "a".repeat(600);
1279 let cmd1 = TextDeleteCmd::new(WidgetId::new(1), 600, &long_text);
1280 let mut cmd2 = TextDeleteCmd::new(WidgetId::new(1), 600, &long_text);
1281 cmd2.metadata.timestamp = cmd1.metadata.timestamp;
1282
1283 let config = MergeConfig::default(); assert!(!cmd1.can_merge(&cmd2, &config));
1285 }
1286
1287 #[test]
1288 fn test_text_delete_accept_merge_non_adjacent_returns_false() {
1289 let mut cmd1 = TextDeleteCmd::new(WidgetId::new(1), 5, "a");
1290 let cmd2 = TextDeleteCmd::new(WidgetId::new(1), 10, "b");
1291 assert!(!cmd1.accept_merge(&cmd2));
1292 }
1293
1294 #[test]
1295 fn test_text_delete_accept_merge_wrong_type_returns_false() {
1296 let mut cmd1 = TextDeleteCmd::new(WidgetId::new(1), 5, "a");
1297 let cmd2 = TextInsertCmd::new(WidgetId::new(1), 5, "b");
1298 assert!(!cmd1.accept_merge(&cmd2));
1299 }
1300
1301 #[test]
1306 fn test_text_insert_no_merge_across_word_boundary() {
1307 let cmd1 = TextInsertCmd::new(WidgetId::new(1), 0, "hello ");
1308 let mut cmd2 = TextInsertCmd::new(WidgetId::new(1), 6, "world");
1309 cmd2.metadata.timestamp = cmd1.metadata.timestamp;
1310
1311 let config = MergeConfig::default(); assert!(!cmd1.can_merge(&cmd2, &config));
1313 }
1314
1315 #[test]
1316 fn test_text_insert_merge_across_word_boundary_when_configured() {
1317 let cmd1 = TextInsertCmd::new(WidgetId::new(1), 0, "hello ");
1318 let mut cmd2 = TextInsertCmd::new(WidgetId::new(1), 6, "world");
1319 cmd2.metadata.timestamp = cmd1.metadata.timestamp;
1320
1321 let config = MergeConfig {
1322 merge_across_words: true,
1323 ..MergeConfig::default()
1324 };
1325 assert!(cmd1.can_merge(&cmd2, &config));
1326 }
1327
1328 #[test]
1329 fn test_text_insert_no_merge_exceeds_max_size() {
1330 let long_text = "a".repeat(600);
1331 let cmd1 = TextInsertCmd::new(WidgetId::new(1), 0, &long_text);
1332 let mut cmd2 = TextInsertCmd::new(WidgetId::new(1), 600, &long_text);
1333 cmd2.metadata.timestamp = cmd1.metadata.timestamp;
1334
1335 let config = MergeConfig::default(); assert!(!cmd1.can_merge(&cmd2, &config));
1337 }
1338
1339 #[test]
1340 fn test_text_insert_accept_merge_wrong_type_returns_false() {
1341 let mut cmd1 = TextInsertCmd::new(WidgetId::new(1), 0, "a");
1342 let cmd2 = TextDeleteCmd::new(WidgetId::new(1), 0, "b");
1343 assert!(!cmd1.accept_merge(&cmd2));
1344 }
1345
1346 #[test]
1347 fn test_text_insert_size_bytes() {
1348 let cmd = TextInsertCmd::new(WidgetId::new(1), 0, "hello");
1349 let size = cmd.size_bytes();
1350 assert!(size >= std::mem::size_of::<TextInsertCmd>() + 5);
1351 }
1352
1353 #[test]
1354 fn test_text_insert_description() {
1355 let cmd = TextInsertCmd::new(WidgetId::new(1), 0, "x");
1356 assert_eq!(cmd.description(), "Insert text");
1357 }
1358
1359 #[test]
1360 fn test_text_insert_debug_name() {
1361 let cmd = TextInsertCmd::new(WidgetId::new(1), 0, "x");
1362 assert_eq!(cmd.debug_name(), "TextInsertCmd");
1363 }
1364
1365 #[test]
1370 fn test_text_replace_execute_and_undo_with_callbacks() {
1371 let buf = Arc::new(Mutex::new(String::from("Hello World")));
1372 let b1 = buf.clone();
1373
1374 let mut cmd = TextReplaceCmd::new(WidgetId::new(1), 6, "World", "Rust").with_replace(
1375 move |_, pos, old_len, new_text| {
1376 let mut b = b1.lock().unwrap();
1377 b.drain(pos..pos + old_len);
1378 b.insert_str(pos, new_text);
1379 Ok(())
1380 },
1381 );
1382
1383 cmd.execute().unwrap();
1385 assert_eq!(*buf.lock().unwrap(), "Hello Rust");
1386
1387 cmd.undo().unwrap();
1388 assert_eq!(*buf.lock().unwrap(), "Hello World");
1389 }
1390
1391 #[test]
1392 fn test_text_replace_execute_without_callback_errors() {
1393 let mut cmd = TextReplaceCmd::new(WidgetId::new(1), 0, "old", "new");
1394 let err = cmd.execute().unwrap_err();
1395 assert!(matches!(err, CommandError::InvalidState(_)));
1396 }
1397
1398 #[test]
1399 fn test_text_replace_undo_without_callback_errors() {
1400 let mut cmd = TextReplaceCmd::new(WidgetId::new(1), 0, "old", "new");
1401 let err = cmd.undo().unwrap_err();
1402 assert!(matches!(err, CommandError::InvalidState(_)));
1403 }
1404
1405 #[test]
1406 fn test_text_replace_target() {
1407 let cmd = TextReplaceCmd::new(WidgetId::new(99), 0, "a", "b");
1408 assert_eq!(cmd.target(), Some(WidgetId::new(99)));
1409 }
1410
1411 #[test]
1412 fn test_text_replace_description() {
1413 let cmd = TextReplaceCmd::new(WidgetId::new(1), 0, "a", "b");
1414 assert_eq!(cmd.description(), "Replace text");
1415 }
1416
1417 #[test]
1418 fn test_text_replace_metadata() {
1419 let cmd = TextReplaceCmd::new(WidgetId::new(1), 0, "a", "b");
1420 assert_eq!(cmd.metadata().description, "Replace text");
1421 assert_eq!(cmd.metadata().source, CommandSource::User);
1422 }
1423
1424 #[test]
1425 fn test_text_replace_debug() {
1426 let cmd = TextReplaceCmd::new(WidgetId::new(1), 3, "old", "new");
1427 let s = format!("{:?}", cmd);
1428 assert!(s.contains("TextReplaceCmd"));
1429 assert!(s.contains("old"));
1430 assert!(s.contains("new"));
1431 }
1432
1433 #[test]
1434 fn test_text_replace_debug_name() {
1435 let cmd = TextReplaceCmd::new(WidgetId::new(1), 0, "a", "b");
1436 assert_eq!(cmd.debug_name(), "TextReplaceCmd");
1437 }
1438
1439 #[test]
1444 fn test_command_batch_execute_rollback_on_failure() {
1445 let buf = Arc::new(Mutex::new(String::new()));
1446 let b1 = buf.clone();
1447 let b2 = buf.clone();
1448
1449 let mut batch = CommandBatch::new("Rollback test");
1450
1451 let cmd1 = TextInsertCmd::new(WidgetId::new(1), 0, "OK")
1453 .with_apply(move |_, pos, text| {
1454 let mut b = b1.lock().unwrap();
1455 b.insert_str(pos, text);
1456 Ok(())
1457 })
1458 .with_remove(move |_, pos, len| {
1459 let mut b = b2.lock().unwrap();
1460 b.drain(pos..pos + len);
1461 Ok(())
1462 });
1463
1464 let cmd2 = TextInsertCmd::new(WidgetId::new(1), 2, " FAIL");
1466
1467 batch.push(Box::new(cmd1));
1468 batch.push(Box::new(cmd2));
1469
1470 let err = batch.execute().unwrap_err();
1472 assert!(matches!(err, CommandError::InvalidState(_)));
1473
1474 assert_eq!(*buf.lock().unwrap(), "");
1476 }
1477
1478 #[test]
1479 fn test_command_batch_redo() {
1480 let buf = Arc::new(Mutex::new(String::new()));
1481 let b1 = buf.clone();
1482 let b2 = buf.clone();
1483
1484 let mut batch = CommandBatch::new("Redo test");
1485 let cmd = TextInsertCmd::new(WidgetId::new(1), 0, "Hi")
1486 .with_apply(move |_, pos, text| {
1487 let mut b = b1.lock().unwrap();
1488 b.insert_str(pos, text);
1489 Ok(())
1490 })
1491 .with_remove(move |_, pos, len| {
1492 let mut b = b2.lock().unwrap();
1493 b.drain(pos..pos + len);
1494 Ok(())
1495 });
1496 batch.push(Box::new(cmd));
1497
1498 batch.execute().unwrap();
1499 assert_eq!(*buf.lock().unwrap(), "Hi");
1500
1501 batch.undo().unwrap();
1502 assert_eq!(*buf.lock().unwrap(), "");
1503
1504 batch.redo().unwrap();
1505 assert_eq!(*buf.lock().unwrap(), "Hi");
1506 }
1507
1508 #[test]
1509 fn test_command_batch_size_bytes() {
1510 let batch = CommandBatch::new("Size test");
1511 let size = batch.size_bytes();
1512 assert!(size >= std::mem::size_of::<CommandBatch>());
1513 }
1514
1515 #[test]
1516 fn test_command_batch_size_bytes_with_commands() {
1517 let mut batch = CommandBatch::new("Size test");
1518 batch.push(Box::new(TextInsertCmd::new(WidgetId::new(1), 0, "hello")));
1519 let size = batch.size_bytes();
1520 let inner_size = TextInsertCmd::new(WidgetId::new(1), 0, "hello").size_bytes();
1522 assert!(size > inner_size);
1523 }
1524
1525 #[test]
1526 fn test_command_batch_metadata() {
1527 let batch = CommandBatch::new("Meta test");
1528 assert_eq!(batch.metadata().description, "Meta test");
1529 }
1530
1531 #[test]
1532 fn test_command_batch_debug_name() {
1533 let batch = CommandBatch::new("test");
1534 assert_eq!(batch.debug_name(), "CommandBatch");
1535 }
1536
1537 #[test]
1538 fn test_command_batch_can_merge_default_false() {
1539 let batch = CommandBatch::new("test");
1540 let other = CommandBatch::new("other");
1541 let config = MergeConfig::default();
1542 assert!(!batch.can_merge(&other, &config));
1543 }
1544
1545 #[test]
1546 fn test_command_batch_undo_empty() {
1547 let mut batch = CommandBatch::new("Empty undo");
1548 batch.undo().unwrap();
1550 }
1551
1552 #[test]
1557 fn test_dyn_undoable_cmd_debug() {
1558 let cmd: Box<dyn UndoableCmd> = Box::new(TextInsertCmd::new(WidgetId::new(1), 0, "test"));
1559 let s = format!("{:?}", cmd);
1560 assert!(s.contains("TextInsertCmd"));
1561 assert!(s.contains("Insert text"));
1562 }
1563
1564 #[test]
1569 fn test_command_error_invalid_state_display() {
1570 let err = CommandError::InvalidState("bad state".to_string());
1571 let s = err.to_string();
1572 assert!(s.contains("bad state"));
1573 }
1574
1575 #[test]
1576 fn test_command_error_is_std_error() {
1577 let err: Box<dyn std::error::Error> = Box::new(CommandError::Other("test".to_string()));
1578 assert!(err.to_string().contains("test"));
1580 }
1581
1582 #[test]
1587 fn test_widget_id_equality() {
1588 let a = WidgetId::new(1);
1589 let b = WidgetId::new(1);
1590 let c = WidgetId::new(2);
1591 assert_eq!(a, b);
1592 assert_ne!(a, c);
1593 }
1594
1595 #[test]
1596 fn test_widget_id_hash() {
1597 use std::collections::HashSet;
1598 let mut set = HashSet::new();
1599 set.insert(WidgetId::new(1));
1600 set.insert(WidgetId::new(1));
1601 set.insert(WidgetId::new(2));
1602 assert_eq!(set.len(), 2);
1603 }
1604
1605 #[test]
1606 fn test_widget_id_debug() {
1607 let id = WidgetId::new(42);
1608 let s = format!("{:?}", id);
1609 assert!(s.contains("42"));
1610 }
1611
1612 #[test]
1617 fn test_text_insert_redo() {
1618 let buf = Arc::new(Mutex::new(String::new()));
1619 let b1 = buf.clone();
1620 let b2 = buf.clone();
1621
1622 let mut cmd = TextInsertCmd::new(WidgetId::new(1), 0, "Hi")
1623 .with_apply(move |_, pos, text| {
1624 let mut b = b1.lock().unwrap();
1625 b.insert_str(pos, text);
1626 Ok(())
1627 })
1628 .with_remove(move |_, pos, len| {
1629 let mut b = b2.lock().unwrap();
1630 b.drain(pos..pos + len);
1631 Ok(())
1632 });
1633
1634 cmd.execute().unwrap();
1635 cmd.undo().unwrap();
1636 assert_eq!(*buf.lock().unwrap(), "");
1637
1638 cmd.redo().unwrap();
1639 assert_eq!(*buf.lock().unwrap(), "Hi");
1640 }
1641
1642 #[test]
1647 fn test_text_delete_redo() {
1648 let buf = Arc::new(Mutex::new(String::from("Hello")));
1649 let b1 = buf.clone();
1650 let b2 = buf.clone();
1651
1652 let mut cmd = TextDeleteCmd::new(WidgetId::new(1), 0, "Hello")
1653 .with_remove(move |_, pos, len| {
1654 let mut b = b1.lock().unwrap();
1655 b.drain(pos..pos + len);
1656 Ok(())
1657 })
1658 .with_insert(move |_, pos, text| {
1659 let mut b = b2.lock().unwrap();
1660 b.insert_str(pos, text);
1661 Ok(())
1662 });
1663
1664 cmd.execute().unwrap();
1665 assert_eq!(*buf.lock().unwrap(), "");
1666
1667 cmd.undo().unwrap();
1668 assert_eq!(*buf.lock().unwrap(), "Hello");
1669
1670 cmd.redo().unwrap();
1671 assert_eq!(*buf.lock().unwrap(), "");
1672 }
1673
1674 #[test]
1679 fn test_command_metadata_all_sources() {
1680 for source in [
1681 CommandSource::User,
1682 CommandSource::Programmatic,
1683 CommandSource::Macro,
1684 CommandSource::External,
1685 ] {
1686 let meta = CommandMetadata::new("test").with_source(source);
1687 assert_eq!(meta.source, source);
1688 }
1689 }
1690
1691 #[test]
1692 fn test_command_metadata_empty_description() {
1693 let meta = CommandMetadata::new("");
1694 assert_eq!(meta.size_bytes(), std::mem::size_of::<CommandMetadata>());
1695 }
1696
1697 #[test]
1702 fn test_text_insert_as_any_roundtrip() {
1703 let cmd = TextInsertCmd::new(WidgetId::new(1), 0, "x");
1704 let any_ref = cmd.as_any();
1705 let downcasted = any_ref.downcast_ref::<TextInsertCmd>().unwrap();
1706 assert_eq!(downcasted.text, "x");
1707 }
1708
1709 #[test]
1710 fn test_text_insert_as_any_mut_roundtrip() {
1711 let mut cmd = TextInsertCmd::new(WidgetId::new(1), 0, "x");
1712 let downcasted = cmd.as_any_mut().downcast_mut::<TextInsertCmd>().unwrap();
1713 downcasted.text = "modified".to_string();
1714 assert_eq!(cmd.text, "modified");
1715 }
1716
1717 #[test]
1718 fn test_text_delete_as_any_roundtrip() {
1719 let cmd = TextDeleteCmd::new(WidgetId::new(1), 0, "abc");
1720 let downcasted = cmd.as_any().downcast_ref::<TextDeleteCmd>().unwrap();
1721 assert_eq!(downcasted.deleted_text, "abc");
1722 }
1723
1724 #[test]
1725 fn test_text_delete_as_any_mut_roundtrip() {
1726 let mut cmd = TextDeleteCmd::new(WidgetId::new(1), 0, "abc");
1727 let downcasted = cmd.as_any_mut().downcast_mut::<TextDeleteCmd>().unwrap();
1728 downcasted.deleted_text = "xyz".to_string();
1729 assert_eq!(cmd.deleted_text, "xyz");
1730 }
1731
1732 #[test]
1733 fn test_text_replace_as_any_roundtrip() {
1734 let cmd = TextReplaceCmd::new(WidgetId::new(1), 0, "a", "b");
1735 let downcasted = cmd.as_any().downcast_ref::<TextReplaceCmd>().unwrap();
1736 assert_eq!(downcasted.old_text, "a");
1737 assert_eq!(downcasted.new_text, "b");
1738 }
1739
1740 #[test]
1741 fn test_text_replace_as_any_mut_roundtrip() {
1742 let mut cmd = TextReplaceCmd::new(WidgetId::new(1), 0, "a", "b");
1743 let downcasted = cmd.as_any_mut().downcast_mut::<TextReplaceCmd>().unwrap();
1744 downcasted.new_text = "replaced".to_string();
1745 assert_eq!(cmd.new_text, "replaced");
1746 }
1747
1748 #[test]
1749 fn test_command_batch_as_any_roundtrip() {
1750 let batch = CommandBatch::new("test batch");
1751 let downcasted = batch.as_any().downcast_ref::<CommandBatch>().unwrap();
1752 assert_eq!(downcasted.description(), "test batch");
1753 }
1754
1755 #[test]
1756 fn test_command_batch_as_any_mut_roundtrip() {
1757 let mut batch = CommandBatch::new("test batch");
1758 batch.push(Box::new(TextInsertCmd::new(WidgetId::new(1), 0, "x")));
1759 let downcasted = batch.as_any_mut().downcast_mut::<CommandBatch>().unwrap();
1760 assert_eq!(downcasted.len(), 1);
1761 }
1762
1763 #[test]
1768 fn test_command_batch_description_matches() {
1769 let batch = CommandBatch::new("My description");
1770 assert_eq!(batch.description(), "My description");
1771 }
1772
1773 #[test]
1774 fn test_command_batch_merge_text_default_none() {
1775 let batch = CommandBatch::new("test");
1776 assert_eq!(batch.merge_text(), None);
1777 }
1778
1779 #[test]
1780 fn test_command_batch_accept_merge_default_false() {
1781 let mut batch = CommandBatch::new("test");
1782 let other = CommandBatch::new("other");
1783 assert!(!batch.accept_merge(&other));
1784 }
1785
1786 #[test]
1787 fn test_command_batch_target_default_none() {
1788 let batch = CommandBatch::new("test");
1789 assert_eq!(batch.target(), None);
1790 }
1791
1792 #[test]
1797 fn test_text_insert_can_merge_rejects_delete_type() {
1798 let cmd1 = TextInsertCmd::new(WidgetId::new(1), 0, "a");
1799 let cmd2 = TextDeleteCmd::new(WidgetId::new(1), 0, "b");
1800 let config = MergeConfig::default();
1801 assert!(!cmd1.can_merge(&cmd2, &config));
1802 }
1803
1804 #[test]
1805 fn test_text_delete_can_merge_rejects_insert_type() {
1806 let cmd1 = TextDeleteCmd::new(WidgetId::new(1), 0, "a");
1807 let cmd2 = TextInsertCmd::new(WidgetId::new(1), 0, "b");
1808 let config = MergeConfig::default();
1809 assert!(!cmd1.can_merge(&cmd2, &config));
1810 }
1811
1812 #[test]
1817 fn test_text_insert_no_merge_time_exceeded() {
1818 let cmd1 = TextInsertCmd::new(WidgetId::new(1), 0, "a");
1819 let mut cmd2 = TextInsertCmd::new(WidgetId::new(1), 1, "b");
1823 cmd2.metadata.timestamp = cmd1.metadata.timestamp;
1826
1827 let config = MergeConfig {
1828 max_delay_ms: 0,
1829 merge_across_words: true,
1830 max_merged_size: 1024,
1831 };
1832 assert!(cmd1.can_merge(&cmd2, &config));
1834 }
1835
1836 #[test]
1837 fn test_text_delete_no_merge_time_exceeded() {
1838 let cmd1 = TextDeleteCmd::new(WidgetId::new(1), 5, "a");
1839 let mut cmd2 = TextDeleteCmd::new(WidgetId::new(1), 4, "b");
1840 cmd2.metadata.timestamp = cmd1.metadata.timestamp;
1841
1842 let config = MergeConfig {
1843 max_delay_ms: 0,
1844 merge_across_words: true,
1845 max_merged_size: 1024,
1846 };
1847 assert!(cmd1.can_merge(&cmd2, &config));
1849 }
1850
1851 #[test]
1856 fn test_text_replace_redo() {
1857 let buf = Arc::new(Mutex::new(String::from("Hello World")));
1858 let b1 = buf.clone();
1859
1860 let mut cmd = TextReplaceCmd::new(WidgetId::new(1), 6, "World", "Rust").with_replace(
1861 move |_, pos, old_len, new_text| {
1862 let mut b = b1.lock().unwrap();
1863 b.replace_range(pos..pos + old_len, new_text);
1864 Ok(())
1865 },
1866 );
1867
1868 cmd.execute().unwrap();
1869 assert_eq!(*buf.lock().unwrap(), "Hello Rust");
1870
1871 cmd.undo().unwrap();
1872 assert_eq!(*buf.lock().unwrap(), "Hello World");
1873
1874 cmd.redo().unwrap();
1875 assert_eq!(*buf.lock().unwrap(), "Hello Rust");
1876 }
1877
1878 #[test]
1883 fn test_command_batch_execute_empty_is_noop() {
1884 let mut batch = CommandBatch::new("Empty execute");
1885 assert!(batch.execute().is_ok());
1886 assert_eq!(batch.executed_to, 0);
1887 }
1888
1889 #[test]
1894 fn test_command_batch_multiple_push_executed() {
1895 let mut batch = CommandBatch::new("Multi pre-exec");
1896 batch.push_executed(Box::new(TextInsertCmd::new(WidgetId::new(1), 0, "a")));
1897 batch.push_executed(Box::new(TextInsertCmd::new(WidgetId::new(1), 1, "b")));
1898 assert_eq!(batch.len(), 2);
1899 assert_eq!(batch.executed_to, 2);
1900 }
1901
1902 #[test]
1907 fn test_command_error_clone_and_equality() {
1908 let err1 = CommandError::TargetNotFound(WidgetId::new(5));
1909 let err2 = err1.clone();
1910 assert_eq!(err1, err2);
1911
1912 let err3 = CommandError::PositionOutOfBounds {
1913 position: 10,
1914 length: 5,
1915 };
1916 let err4 = err3.clone();
1917 assert_eq!(err3, err4);
1918 }
1919
1920 #[test]
1925 fn test_merge_config_clone_and_copy() {
1926 let config = MergeConfig {
1927 max_delay_ms: 1000,
1928 merge_across_words: true,
1929 max_merged_size: 2048,
1930 };
1931 let config2 = config;
1932 assert_eq!(config2.max_delay_ms, 1000);
1933 assert!(config2.merge_across_words);
1934 assert_eq!(config2.max_merged_size, 2048);
1935 }
1936
1937 #[test]
1942 fn test_widget_id_copy() {
1943 let a = WidgetId::new(42);
1944 let b = a; assert_eq!(a, b); assert_eq!(a.raw(), 42);
1947 }
1948
1949 #[test]
1954 fn test_command_metadata_clone() {
1955 let meta = CommandMetadata::new("test")
1956 .with_source(CommandSource::Programmatic)
1957 .with_batch(99);
1958 let cloned = meta.clone();
1959 assert_eq!(cloned.description, "test");
1960 assert_eq!(cloned.source, CommandSource::Programmatic);
1961 assert_eq!(cloned.batch_id, Some(99));
1962 }
1963}