1use crabstep::TypedStreamDeserializer;
5use plist::Value;
6
7use crate::{
8 error::plist::PlistParseError,
9 message_types::variants::BalloonProvider,
10 tables::messages::{body::parse_body_typedstream, models::BubbleComponent},
11 util::{
12 dates::TIMESTAMP_FACTOR,
13 plist::{
14 extract_array_key, extract_bytes_key, extract_dictionary, extract_int_key,
15 plist_as_dictionary,
16 },
17 },
18};
19
20#[derive(Debug, PartialEq, Eq)]
22pub enum EditStatus {
23 Edited,
25 Unsent,
27 Original,
29}
30
31#[derive(Debug, PartialEq)]
33pub struct EditedEvent {
34 pub date: i64,
36 pub text: String,
39 pub components: Vec<BubbleComponent>,
41 pub guid: Option<String>,
43}
44
45impl EditedEvent {
46 pub(crate) fn new(
47 date: i64,
48 text: String,
49 components: Vec<BubbleComponent>,
50 guid: Option<String>,
51 ) -> Self {
52 Self {
53 date,
54 text,
55 components,
56 guid,
57 }
58 }
59}
60
61#[derive(Debug, PartialEq)]
63pub struct EditedMessagePart {
64 pub status: EditStatus,
66 pub edit_history: Vec<EditedEvent>,
68}
69
70impl Default for EditedMessagePart {
71 fn default() -> Self {
72 Self {
73 status: EditStatus::Original,
74 edit_history: vec![],
75 }
76 }
77}
78
79#[derive(Debug, PartialEq)]
102pub struct EditedMessage {
103 pub parts: Vec<EditedMessagePart>,
105}
106
107impl<'a> BalloonProvider<'a> for EditedMessage {
108 fn from_map(payload: &'a Value) -> Result<Self, PlistParseError> {
109 let plist_root = plist_as_dictionary(payload)?;
111
112 let message_parts = extract_dictionary(plist_root, "otr")?;
114
115 let mut edited = Self::with_capacity(message_parts.len());
117 message_parts
118 .values()
119 .for_each(|_| edited.parts.push(EditedMessagePart::default()));
120
121 if let Ok(edited_message_events) = extract_dictionary(plist_root, "ec") {
122 for (idx, (key, events)) in edited_message_events.iter().enumerate() {
123 let events = events
124 .as_array()
125 .ok_or_else(|| PlistParseError::InvalidTypeIndex(idx, "array".to_string()))?;
126 let parsed_key = key.parse::<usize>().map_err(|_| {
127 PlistParseError::InvalidType(
128 "ec dictionary key".to_string(),
129 "numeric string".to_string(),
130 )
131 })?;
132
133 for (event_idx, event) in events.iter().enumerate() {
134 let message_data = event.as_dictionary().ok_or_else(|| {
135 PlistParseError::InvalidTypeIndex(event_idx, "dictionary".to_string())
136 })?;
137
138 let timestamp = extract_int_key(message_data, "d")?
139 .checked_mul(TIMESTAMP_FACTOR)
140 .ok_or_else(|| {
141 PlistParseError::InvalidEditedMessage(
142 "edit timestamp out of range".to_string(),
143 )
144 })?;
145
146 let data = extract_bytes_key(message_data, "t")?;
147
148 let mut typedstream = TypedStreamDeserializer::new(data);
149 let result = parse_body_typedstream(Some(typedstream.iter_root()?), None)
150 .ok_or_else(|| {
151 PlistParseError::InvalidEditedMessage(
152 "Failed to parse typedstream data".to_string(),
153 )
154 })?;
155
156 let text = result.text.ok_or_else(|| {
157 PlistParseError::InvalidEditedMessage(
158 "Edit-history entry missing text!".to_string(),
159 )
160 })?;
161
162 let guid = message_data
163 .get("bcg")
164 .and_then(|item| item.as_string())
165 .map(Into::into);
166
167 if let Some(item) = edited.parts.get_mut(parsed_key) {
168 item.status = EditStatus::Edited;
169 item.edit_history.push(EditedEvent::new(
170 timestamp,
171 text,
172 result.components,
173 guid,
174 ));
175 }
176 }
177 }
178 }
179
180 if let Ok(unsent_message_indexes) = extract_array_key(plist_root, "rp") {
181 for (idx, unsent_message_idx) in unsent_message_indexes.iter().enumerate() {
182 let parsed_idx = unsent_message_idx
183 .as_signed_integer()
184 .ok_or_else(|| PlistParseError::InvalidTypeIndex(idx, "int".to_string()))?
185 as usize;
186 if let Some(item) = edited.parts.get_mut(parsed_idx) {
187 item.status = EditStatus::Unsent;
188 }
189 }
190 }
191
192 Ok(edited)
193 }
194}
195
196impl EditedMessage {
197 fn with_capacity(capacity: usize) -> Self {
199 EditedMessage {
200 parts: Vec::with_capacity(capacity),
201 }
202 }
203
204 #[must_use]
206 pub fn part(&self, index: usize) -> Option<&EditedMessagePart> {
207 self.parts.get(index)
208 }
209
210 #[must_use]
212 pub fn is_unedited_at(&self, index: usize) -> bool {
213 match self.parts.get(index) {
214 Some(part) => matches!(part.status, EditStatus::Original),
215 None => false,
216 }
217 }
218
219 #[must_use]
221 pub fn items(&self) -> usize {
222 self.parts.len()
223 }
224}
225
226#[cfg(test)]
227mod test_parser {
228 use crate::message_types::edited::{EditStatus, EditedEvent, EditedMessagePart};
229 use crate::message_types::text_effects::{style::Style, text_effect::TextEffect};
230 use crate::message_types::{edited::EditedMessage, variants::BalloonProvider};
231 use crate::tables::messages::models::{AttributedRange, BubbleComponent};
232
233 use plist::Value;
234 use std::env::current_dir;
235 use std::fs::File;
236
237 #[test]
238 fn test_parse_edited() {
239 let plist_path = current_dir()
240 .unwrap()
241 .as_path()
242 .join("test_data/edited_message/Edited.plist");
243 let plist_data = File::open(plist_path).unwrap();
244 let plist = Value::from_reader(plist_data).unwrap();
245 let parsed = EditedMessage::from_map(&plist).unwrap();
246
247 let expected = EditedMessage {
248 parts: vec![EditedMessagePart {
249 status: EditStatus::Edited,
250 edit_history: vec![
251 EditedEvent::new(
252 690513474000000000,
253 "First message ".to_string(),
254 vec![BubbleComponent::Run(vec![AttributedRange::text(
255 0,
256 15,
257 vec![TextEffect::Default],
258 )])],
259 None,
260 ),
261 EditedEvent::new(
262 690513480000000000,
263 "Edit 1".to_string(),
264 vec![BubbleComponent::Run(vec![AttributedRange::text(
265 0,
266 6,
267 vec![TextEffect::Default],
268 )])],
269 None,
270 ),
271 EditedEvent::new(
272 690513485000000000,
273 "Edit 2".to_string(),
274 vec![BubbleComponent::Run(vec![AttributedRange::text(
275 0,
276 6,
277 vec![TextEffect::Default],
278 )])],
279 None,
280 ),
281 EditedEvent::new(
282 690513494000000000,
283 "Edited message".to_string(),
284 vec![BubbleComponent::Run(vec![AttributedRange::text(
285 0,
286 14,
287 vec![TextEffect::Default],
288 )])],
289 None,
290 ),
291 ],
292 }],
293 };
294
295 assert_eq!(parsed, expected);
296
297 let expected_item = Some(expected.parts.first().unwrap());
298 assert_eq!(parsed.part(0), expected_item);
299 }
300
301 #[test]
302 fn test_parse_edited_to_link() {
303 let plist_path = current_dir()
304 .unwrap()
305 .as_path()
306 .join("test_data/edited_message/EditedToLink.plist");
307 let plist_data = File::open(plist_path).unwrap();
308 let plist = Value::from_reader(plist_data).unwrap();
309 let parsed = EditedMessage::from_map(&plist).unwrap();
310
311 let expected = EditedMessage {
312 parts: vec![
313 EditedMessagePart {
314 status: EditStatus::Original,
315 edit_history: vec![], },
317 EditedMessagePart {
318 status: EditStatus::Edited,
319 edit_history: vec![
320 EditedEvent::new(
321 690514004000000000,
322 "here we go!".to_string(),
323 vec![BubbleComponent::Run(vec![AttributedRange::text(
324 0,
325 11,
326 vec![TextEffect::Default],
327 )])],
328 None,
329 ),
330 EditedEvent::new(
331 690514772000000000,
332 "https://github.com/ReagentX/imessage-exporter/issues/10".to_string(),
333 vec![BubbleComponent::Run(vec![AttributedRange::text(
334 0,
335 55,
336 vec![TextEffect::Default],
337 )])],
338 Some("292BF9C6-C9B8-4827-BE65-6EA1C9B5B384".to_string()),
339 ),
340 ],
341 },
342 ],
343 };
344
345 assert_eq!(parsed, expected);
346 }
347
348 #[test]
349 fn test_parse_edited_to_link_and_back() {
350 let plist_path = current_dir()
351 .unwrap()
352 .as_path()
353 .join("test_data/edited_message/EditedToLinkAndBack.plist");
354 let plist_data = File::open(plist_path).unwrap();
355 let plist = Value::from_reader(plist_data).unwrap();
356 let parsed = EditedMessage::from_map(&plist).unwrap();
357
358 let expected = EditedMessage {
359 parts: vec![EditedMessagePart {
360 status: EditStatus::Edited,
361 edit_history: vec![
362 EditedEvent::new(
363 690514809000000000,
364 "This is a normal message".to_string(),
365 vec![BubbleComponent::Run(vec![AttributedRange::text(
366 0,
367 24,
368 vec![TextEffect::Default],
369 )])],
370 None,
371 ),
372 EditedEvent::new(
373 690514819000000000,
374 "Edit to a url https://github.com/ReagentX/imessage-exporter/issues/10"
375 .to_string(),
376 vec![BubbleComponent::Run(vec![AttributedRange::text(
377 0,
378 69,
379 vec![TextEffect::Default],
380 )])],
381 Some("0B9103FE-280C-4BD0-A66F-4EDEE3443247".to_string()),
382 ),
383 EditedEvent::new(
384 690514834000000000,
385 "And edit it back to a normal message...".to_string(),
386 vec![BubbleComponent::Run(vec![AttributedRange::text(
387 0,
388 39,
389 vec![TextEffect::Default],
390 )])],
391 Some("0D93DF88-05BA-4418-9B20-79918ADD9923".to_string()),
392 ),
393 ],
394 }],
395 };
396
397 assert_eq!(parsed, expected);
398 }
399
400 #[test]
401 fn test_parse_deleted() {
402 let plist_path = current_dir()
403 .unwrap()
404 .as_path()
405 .join("test_data/edited_message/Deleted.plist");
406 let plist_data = File::open(plist_path).unwrap();
407 let plist = Value::from_reader(plist_data).unwrap();
408 let parsed = EditedMessage::from_map(&plist).unwrap();
409
410 let expected = EditedMessage {
411 parts: vec![EditedMessagePart {
412 status: EditStatus::Unsent,
413 edit_history: vec![],
414 }],
415 };
416
417 assert_eq!(parsed, expected);
418 }
419
420 #[test]
421 fn test_parse_multipart_deleted() {
422 let plist_path = current_dir()
423 .unwrap()
424 .as_path()
425 .join("test_data/edited_message/MultiPartOneDeleted.plist");
426 let plist_data = File::open(plist_path).unwrap();
427 let plist = Value::from_reader(plist_data).unwrap();
428 let parsed = EditedMessage::from_map(&plist).unwrap();
429
430 let expected = EditedMessage {
431 parts: vec![
432 EditedMessagePart {
433 status: EditStatus::Original,
434 edit_history: vec![],
435 },
436 EditedMessagePart {
437 status: EditStatus::Original,
438 edit_history: vec![],
439 },
440 EditedMessagePart {
441 status: EditStatus::Original,
442 edit_history: vec![],
443 },
444 EditedMessagePart {
445 status: EditStatus::Unsent,
446 edit_history: vec![],
447 },
448 ],
449 };
450
451 assert_eq!(parsed, expected);
452 }
453
454 #[test]
455 fn test_parse_multipart_edited_and_deleted() {
456 let plist_path = current_dir()
457 .unwrap()
458 .as_path()
459 .join("test_data/edited_message/EditedAndDeleted.plist");
460 let plist_data = File::open(plist_path).unwrap();
461 let plist = Value::from_reader(plist_data).unwrap();
462 let parsed = EditedMessage::from_map(&plist).unwrap();
463
464 let expected = EditedMessage {
465 parts: vec![
466 EditedMessagePart {
467 status: EditStatus::Original,
468 edit_history: vec![],
469 },
470 EditedMessagePart {
471 status: EditStatus::Original,
472 edit_history: vec![],
473 },
474 EditedMessagePart {
475 status: EditStatus::Edited,
476 edit_history: vec![
477 EditedEvent::new(
478 743907180000000000,
479 "Second message".to_string(),
480 vec![BubbleComponent::Run(vec![AttributedRange::text(
481 0,
482 14,
483 vec![TextEffect::Default],
484 )])],
485 None,
486 ),
487 EditedEvent::new(
488 743907190000000000,
489 "Second message got edited!".to_string(),
490 vec![BubbleComponent::Run(vec![AttributedRange::text(
491 0,
492 26,
493 vec![TextEffect::Default],
494 )])],
495 None,
496 ),
497 ],
498 },
499 ],
500 };
501
502 assert_eq!(parsed, expected);
503 }
504
505 #[test]
506 fn test_parse_multipart_edited_and_unsent() {
507 let plist_path = current_dir()
508 .unwrap()
509 .as_path()
510 .join("test_data/edited_message/EditedAndUnsent.plist");
511 let plist_data = File::open(plist_path).unwrap();
512 let plist = Value::from_reader(plist_data).unwrap();
513 let parsed = EditedMessage::from_map(&plist).unwrap();
514
515 let expected = EditedMessage {
516 parts: vec![
517 EditedMessagePart {
518 status: EditStatus::Original,
519 edit_history: vec![],
520 },
521 EditedMessagePart {
522 status: EditStatus::Original,
523 edit_history: vec![],
524 },
525 EditedMessagePart {
526 status: EditStatus::Edited,
527 edit_history: vec![
528 EditedEvent::new(
529 743907435000000000,
530 "Second test".to_string(),
531 vec![BubbleComponent::Run(vec![AttributedRange::text(
532 0,
533 11,
534 vec![TextEffect::Default],
535 )])],
536 None,
537 ),
538 EditedEvent::new(
539 743907448000000000,
540 "Second test was edited!".to_string(),
541 vec![BubbleComponent::Run(vec![AttributedRange::text(
542 0,
543 23,
544 vec![TextEffect::Default],
545 )])],
546 None,
547 ),
548 ],
549 },
550 EditedMessagePart {
551 status: EditStatus::Unsent,
552 edit_history: vec![],
553 },
554 ],
555 };
556
557 assert_eq!(parsed, expected);
558 }
559
560 #[test]
561 fn test_parse_edited_with_formatting() {
562 let plist_path = current_dir()
563 .unwrap()
564 .as_path()
565 .join("test_data/edited_message/EditedWithFormatting.plist");
566 let plist_data = File::open(plist_path).unwrap();
567 let plist = Value::from_reader(plist_data).unwrap();
568 let parsed = EditedMessage::from_map(&plist).unwrap();
569
570 let expected = EditedMessage {
571 parts: vec![EditedMessagePart {
572 status: EditStatus::Edited,
573 edit_history: vec![
574 EditedEvent::new(
575 758573156000000000,
576 "Test".to_string(),
577 vec![BubbleComponent::Run(vec![AttributedRange::text(
578 0,
579 4,
580 vec![TextEffect::Default],
581 )])],
582 None,
583 ),
584 EditedEvent::new(
585 758573166000000000,
586 "Test".to_string(),
587 vec![BubbleComponent::Run(vec![AttributedRange::text(
588 0,
589 4,
590 vec![TextEffect::Styles(vec![Style::Strikethrough])],
591 )])],
592 Some("76A466B8-D21E-4A20-AF62-FF2D3A20D31C".to_string()),
593 ),
594 ],
595 }],
596 };
597
598 assert_eq!(parsed, expected);
599
600 let expected_item = Some(expected.parts.first().unwrap());
601 assert_eq!(parsed.part(0), expected_item);
602 }
603}
604
605#[cfg(test)]
606mod test_gen {
607 use plist::Value;
608 use std::env::current_dir;
609 use std::fs::File;
610
611 use crate::message_types::text_effects::{style::Style, text_effect::TextEffect};
612 use crate::message_types::{edited::EditedMessage, variants::BalloonProvider};
613 use crate::tables::messages::models::{AttributedRange, BubbleComponent};
614
615 #[test]
616 fn test_parse_edited_memoji() {
617 let plist_path = current_dir()
622 .unwrap()
623 .as_path()
624 .join("test_data/edited_message/MemojiEdited.plist");
625 let plist_data = File::open(plist_path).unwrap();
626 let plist = Value::from_reader(plist_data).unwrap();
627 let parsed = EditedMessage::from_map(&plist).unwrap();
628
629 let history = &parsed.parts[0].edit_history;
630 assert_eq!(history.len(), 2);
631 assert_eq!(history[1].text, "Check this out: \u{FFFC} 😀");
632
633 let BubbleComponent::Run(ranges) = &history[1].components[0] else {
634 panic!("expected a Run, got {:?}", history[1].components);
635 };
636 let memoji = ranges
637 .iter()
638 .find(|range| range.attachment.is_some())
639 .expect("the latest version should contain the Memoji attachment range");
640 assert!(
641 memoji.emoji_image,
642 "the Memoji must be flagged as an inline (emoji_image) sticker"
643 );
644 assert_eq!(
645 memoji.attachment.as_ref().unwrap().guid.as_deref(),
646 Some("F2C223DB-0140-4D49-B38A-C1A3553B4CBA"),
647 );
648 }
649
650 #[test]
651 fn test_parse_edited() {
652 let plist_path = current_dir()
653 .unwrap()
654 .as_path()
655 .join("test_data/edited_message/Edited.plist");
656 let plist_data = File::open(plist_path).unwrap();
657 let plist = Value::from_reader(plist_data).unwrap();
658 let parsed = EditedMessage::from_map(&plist).unwrap();
659
660 let expected_attrs = [
661 vec![BubbleComponent::Run(vec![AttributedRange::text(
662 0,
663 15,
664 vec![TextEffect::Default],
665 )])],
666 vec![BubbleComponent::Run(vec![AttributedRange::text(
667 0,
668 6,
669 vec![TextEffect::Default],
670 )])],
671 vec![BubbleComponent::Run(vec![AttributedRange::text(
672 0,
673 6,
674 vec![TextEffect::Default],
675 )])],
676 vec![BubbleComponent::Run(vec![AttributedRange::text(
677 0,
678 14,
679 vec![TextEffect::Default],
680 )])],
681 ];
682
683 for event in parsed.parts {
684 for (idx, part) in event.edit_history.iter().enumerate() {
685 assert_eq!(part.components, expected_attrs[idx]);
686 }
687 }
688 }
689
690 #[test]
691 fn test_parse_edited_to_link() {
692 let plist_path = current_dir()
693 .unwrap()
694 .as_path()
695 .join("test_data/edited_message/EditedToLink.plist");
696 let plist_data = File::open(plist_path).unwrap();
697 let plist = Value::from_reader(plist_data).unwrap();
698 let parsed = EditedMessage::from_map(&plist).unwrap();
699
700 let expected_attrs = [
701 vec![BubbleComponent::Run(vec![AttributedRange::text(
702 0,
703 11,
704 vec![TextEffect::Default],
705 )])],
706 vec![BubbleComponent::Run(vec![AttributedRange::text(
707 0,
708 55,
709 vec![TextEffect::Default],
710 )])],
711 ];
712
713 for event in parsed.parts {
714 for (idx, part) in event.edit_history.iter().enumerate() {
715 assert_eq!(part.components, expected_attrs[idx]);
716 }
717 }
718 }
719
720 #[test]
721 fn test_parse_edited_to_link_and_back() {
722 let plist_path = current_dir()
723 .unwrap()
724 .as_path()
725 .join("test_data/edited_message/EditedToLinkAndBack.plist");
726 let plist_data = File::open(plist_path).unwrap();
727 let plist = Value::from_reader(plist_data).unwrap();
728 let parsed = EditedMessage::from_map(&plist).unwrap();
729
730 let expected_attrs = [
731 vec![BubbleComponent::Run(vec![AttributedRange::text(
732 0,
733 24,
734 vec![TextEffect::Default],
735 )])],
736 vec![BubbleComponent::Run(vec![AttributedRange::text(
737 0,
738 69,
739 vec![TextEffect::Default],
740 )])],
741 vec![BubbleComponent::Run(vec![AttributedRange::text(
742 0,
743 39,
744 vec![TextEffect::Default],
745 )])],
746 ];
747
748 for event in parsed.parts {
749 for (idx, part) in event.edit_history.iter().enumerate() {
750 assert_eq!(part.components, expected_attrs[idx]);
751 }
752 }
753 }
754
755 #[test]
756 fn test_parse_deleted() {
757 let plist_path = current_dir()
758 .unwrap()
759 .as_path()
760 .join("test_data/edited_message/Deleted.plist");
761 let plist_data = File::open(plist_path).unwrap();
762 let plist = Value::from_reader(plist_data).unwrap();
763 let parsed = EditedMessage::from_map(&plist).unwrap();
764
765 let expected_attrs: [Vec<BubbleComponent>; 0] = [];
766
767 for event in parsed.parts {
768 for (idx, part) in event.edit_history.iter().enumerate() {
769 assert_eq!(part.components, expected_attrs[idx]);
770 }
771 }
772 }
773
774 #[test]
775 fn test_parse_multipart_deleted() {
776 let plist_path = current_dir()
777 .unwrap()
778 .as_path()
779 .join("test_data/edited_message/MultiPartOneDeleted.plist");
780 let plist_data = File::open(plist_path).unwrap();
781 let plist = Value::from_reader(plist_data).unwrap();
782 let parsed = EditedMessage::from_map(&plist).unwrap();
783
784 let expected_attrs: [Vec<BubbleComponent>; 0] = [];
785
786 for event in parsed.parts {
787 for (idx, part) in event.edit_history.iter().enumerate() {
788 assert_eq!(part.components, expected_attrs[idx]);
789 }
790 }
791 }
792
793 #[test]
794 fn test_parse_multipart_edited_and_deleted() {
795 let plist_path = current_dir()
796 .unwrap()
797 .as_path()
798 .join("test_data/edited_message/EditedAndDeleted.plist");
799 let plist_data = File::open(plist_path).unwrap();
800 let plist = Value::from_reader(plist_data).unwrap();
801 let parsed = EditedMessage::from_map(&plist).unwrap();
802
803 let expected_attrs = [
804 vec![BubbleComponent::Run(vec![AttributedRange::text(
805 0,
806 14,
807 vec![TextEffect::Default],
808 )])],
809 vec![BubbleComponent::Run(vec![AttributedRange::text(
810 0,
811 26,
812 vec![TextEffect::Default],
813 )])],
814 ];
815
816 for event in parsed.parts {
817 for (idx, part) in event.edit_history.iter().enumerate() {
818 assert_eq!(part.components, expected_attrs[idx]);
819 }
820 }
821 }
822
823 #[test]
824 fn test_parse_multipart_edited_and_unsent() {
825 let plist_path = current_dir()
826 .unwrap()
827 .as_path()
828 .join("test_data/edited_message/EditedAndUnsent.plist");
829 let plist_data = File::open(plist_path).unwrap();
830 let plist = Value::from_reader(plist_data).unwrap();
831 let parsed = EditedMessage::from_map(&plist).unwrap();
832
833 for parts in &parsed.parts {
834 for part in &parts.edit_history {
835 println!("{:#?}", part.components);
836 }
837 }
838
839 let expected_attrs: [Vec<BubbleComponent>; 2] = [
840 vec![BubbleComponent::Run(vec![AttributedRange::text(
841 0,
842 11,
843 vec![TextEffect::Default],
844 )])],
845 vec![BubbleComponent::Run(vec![AttributedRange::text(
846 0,
847 23,
848 vec![TextEffect::Default],
849 )])],
850 ];
851
852 for event in parsed.parts {
853 for (idx, part) in event.edit_history.iter().enumerate() {
854 assert_eq!(part.components, expected_attrs[idx]);
855 }
856 }
857 }
858
859 #[test]
860 fn test_parse_edited_with_formatting() {
861 let plist_path = current_dir()
862 .unwrap()
863 .as_path()
864 .join("test_data/edited_message/EditedWithFormatting.plist");
865 let plist_data = File::open(plist_path).unwrap();
866 let plist = Value::from_reader(plist_data).unwrap();
867 let parsed = EditedMessage::from_map(&plist).unwrap();
868
869 let expected_attrs: [Vec<BubbleComponent>; 2] = [
870 vec![BubbleComponent::Run(vec![AttributedRange::text(
871 0,
872 4,
873 vec![TextEffect::Default],
874 )])],
875 vec![BubbleComponent::Run(vec![AttributedRange::text(
876 0,
877 4,
878 vec![TextEffect::Styles(vec![Style::Strikethrough])],
879 )])],
880 ];
881
882 for event in parsed.parts {
883 for (idx, part) in event.edit_history.iter().enumerate() {
884 assert_eq!(part.components, expected_attrs[idx]);
885 }
886 }
887 }
888}