Skip to main content

sheetkit_core/
threaded_comment.rs

1//! Threaded comments management.
2//!
3//! Provides functions for adding, querying, resolving, and removing
4//! threaded comments (Excel 2019+ feature). Threaded comments support
5//! replies and resolved state, unlike legacy comments.
6
7use sheetkit_xml::threaded_comment::{Person, PersonList, ThreadedComment, ThreadedComments};
8
9use crate::error::{Error, Result};
10
11/// Input configuration for adding a threaded comment.
12#[derive(Debug, Clone, PartialEq)]
13pub struct ThreadedCommentInput {
14    /// Display name of the comment author.
15    pub author: String,
16    /// The comment text.
17    pub text: String,
18    /// Optional parent comment ID for replies.
19    pub parent_id: Option<String>,
20}
21
22/// Input configuration for adding a person.
23#[derive(Debug, Clone, PartialEq)]
24pub struct PersonInput {
25    /// Display name of the person.
26    pub display_name: String,
27    /// Optional user ID (e.g. email address).
28    pub user_id: Option<String>,
29    /// Optional provider ID (e.g. "ADAL").
30    pub provider_id: Option<String>,
31}
32
33/// Output data for a threaded comment.
34#[derive(Debug, Clone, PartialEq)]
35pub struct ThreadedCommentData {
36    /// Unique comment ID (GUID).
37    pub id: String,
38    /// Cell reference (e.g. "A1").
39    pub cell_ref: String,
40    /// Comment text.
41    pub text: String,
42    /// Author display name.
43    pub author: String,
44    /// Person ID (GUID).
45    pub person_id: String,
46    /// ISO 8601 timestamp.
47    pub date_time: String,
48    /// Parent comment ID (for replies).
49    pub parent_id: Option<String>,
50    /// Whether the comment thread is marked as resolved.
51    pub done: bool,
52}
53
54/// Output data for a person.
55#[derive(Debug, Clone, PartialEq)]
56pub struct PersonData {
57    /// Person ID (GUID).
58    pub id: String,
59    /// Display name.
60    pub display_name: String,
61    /// Optional user ID.
62    pub user_id: Option<String>,
63    /// Optional provider ID.
64    pub provider_id: Option<String>,
65}
66
67/// Generate a random UUID v4 wrapped in curly braces.
68fn generate_guid() -> String {
69    format!("{{{}}}", uuid::Uuid::new_v4().to_string().to_uppercase())
70}
71
72/// Get the current UTC timestamp in ISO 8601 format with two-digit
73/// fractional seconds, matching the format Excel uses for threaded comments.
74fn current_timestamp() -> String {
75    let now = chrono::Utc::now();
76    let base = now.format("%Y-%m-%dT%H:%M:%S").to_string();
77    let centiseconds = now.timestamp_subsec_millis() / 10;
78    format!("{base}.{centiseconds:02}")
79}
80
81/// Generate a GUID that does not collide with any existing person or comment ID.
82fn generate_unique_guid(
83    person_list: &PersonList,
84    threaded_comments: Option<&ThreadedComments>,
85) -> String {
86    loop {
87        let id = generate_guid();
88        let person_collision = person_list.persons.iter().any(|p| p.id == id);
89        let comment_collision = threaded_comments
90            .map(|tc| tc.comments.iter().any(|c| c.id == id))
91            .unwrap_or(false);
92        if !person_collision && !comment_collision {
93            return id;
94        }
95    }
96}
97
98/// Find or create a person in the person list, returning their ID.
99pub fn find_or_create_person(
100    person_list: &mut PersonList,
101    display_name: &str,
102    user_id: Option<&str>,
103    provider_id: Option<&str>,
104) -> String {
105    find_or_create_person_with_collision_check(
106        person_list,
107        display_name,
108        user_id,
109        provider_id,
110        None,
111    )
112}
113
114/// Find or create a person with collision checking against existing IDs.
115fn find_or_create_person_with_collision_check(
116    person_list: &mut PersonList,
117    display_name: &str,
118    user_id: Option<&str>,
119    provider_id: Option<&str>,
120    threaded_comments: Option<&ThreadedComments>,
121) -> String {
122    if let Some(existing) = person_list
123        .persons
124        .iter()
125        .find(|p| p.display_name == display_name)
126    {
127        return existing.id.clone();
128    }
129
130    let id = generate_unique_guid(person_list, threaded_comments);
131    person_list.persons.push(Person {
132        display_name: display_name.to_string(),
133        id: id.clone(),
134        user_id: user_id.map(|s| s.to_string()),
135        provider_id: provider_id.map(|s| s.to_string()),
136    });
137    id
138}
139
140/// Add a threaded comment to a sheet's threaded comments collection.
141///
142/// Validates the cell reference format before inserting. Returns the
143/// generated comment ID.
144pub fn add_threaded_comment(
145    threaded_comments: &mut Option<ThreadedComments>,
146    person_list: &mut PersonList,
147    cell: &str,
148    input: &ThreadedCommentInput,
149) -> Result<String> {
150    crate::utils::cell_ref::cell_name_to_coordinates(cell)?;
151
152    if let Some(ref parent_id) = input.parent_id {
153        let tc = threaded_comments.as_ref();
154        let parent_exists = tc
155            .map(|t| t.comments.iter().any(|c| c.id == *parent_id))
156            .unwrap_or(false);
157        if !parent_exists {
158            return Err(Error::ThreadedCommentNotFound {
159                id: parent_id.clone(),
160            });
161        }
162    }
163
164    let person_id = find_or_create_person_with_collision_check(
165        person_list,
166        &input.author,
167        None,
168        None,
169        threaded_comments.as_ref(),
170    );
171    let comment_id = generate_unique_guid(person_list, threaded_comments.as_ref());
172
173    let tc = threaded_comments.get_or_insert_with(ThreadedComments::default);
174    tc.comments.push(ThreadedComment {
175        cell_ref: cell.to_string(),
176        date_time: current_timestamp(),
177        person_id,
178        id: comment_id.clone(),
179        parent_id: input.parent_id.clone(),
180        done: None,
181        text: input.text.clone(),
182    });
183
184    Ok(comment_id)
185}
186
187/// Get all threaded comments for a sheet.
188pub fn get_threaded_comments(
189    threaded_comments: &Option<ThreadedComments>,
190    person_list: &PersonList,
191) -> Vec<ThreadedCommentData> {
192    let Some(tc) = threaded_comments.as_ref() else {
193        return Vec::new();
194    };
195
196    tc.comments
197        .iter()
198        .map(|c| {
199            let author = person_list
200                .persons
201                .iter()
202                .find(|p| p.id == c.person_id)
203                .map(|p| p.display_name.clone())
204                .unwrap_or_default();
205            ThreadedCommentData {
206                id: c.id.clone(),
207                cell_ref: c.cell_ref.clone(),
208                text: c.text.clone(),
209                author,
210                person_id: c.person_id.clone(),
211                date_time: c.date_time.clone(),
212                parent_id: c.parent_id.clone(),
213                done: matches!(c.done.as_deref(), Some("1" | "true")),
214            }
215        })
216        .collect()
217}
218
219/// Get threaded comments for a specific cell.
220pub fn get_threaded_comments_by_cell(
221    threaded_comments: &Option<ThreadedComments>,
222    person_list: &PersonList,
223    cell: &str,
224) -> Vec<ThreadedCommentData> {
225    get_threaded_comments(threaded_comments, person_list)
226        .into_iter()
227        .filter(|c| c.cell_ref == cell)
228        .collect()
229}
230
231/// Delete a threaded comment by its ID.
232///
233/// Returns an error if the comment was not found.
234pub fn delete_threaded_comment(
235    threaded_comments: &mut Option<ThreadedComments>,
236    comment_id: &str,
237) -> Result<()> {
238    if let Some(ref mut tc) = threaded_comments {
239        let before = tc.comments.len();
240        tc.comments.retain(|c| c.id != comment_id);
241        if tc.comments.len() == before {
242            return Err(Error::ThreadedCommentNotFound {
243                id: comment_id.to_string(),
244            });
245        }
246
247        if tc.comments.is_empty() {
248            *threaded_comments = None;
249        }
250
251        Ok(())
252    } else {
253        Err(Error::ThreadedCommentNotFound {
254            id: comment_id.to_string(),
255        })
256    }
257}
258
259/// Set the resolved (done) state of a threaded comment.
260///
261/// Returns an error if the comment was not found.
262pub fn resolve_threaded_comment(
263    threaded_comments: &mut Option<ThreadedComments>,
264    comment_id: &str,
265    done: bool,
266) -> Result<()> {
267    if let Some(ref mut tc) = threaded_comments {
268        if let Some(comment) = tc.comments.iter_mut().find(|c| c.id == comment_id) {
269            comment.done = if done { Some("1".to_string()) } else { None };
270            return Ok(());
271        }
272    }
273    Err(Error::ThreadedCommentNotFound {
274        id: comment_id.to_string(),
275    })
276}
277
278/// Add a person to the person list. Returns the person ID.
279pub fn add_person(person_list: &mut PersonList, input: &PersonInput) -> String {
280    find_or_create_person(
281        person_list,
282        &input.display_name,
283        input.user_id.as_deref(),
284        input.provider_id.as_deref(),
285    )
286}
287
288/// Get all persons from the person list.
289pub fn get_persons(person_list: &PersonList) -> Vec<PersonData> {
290    person_list
291        .persons
292        .iter()
293        .map(|p| PersonData {
294            id: p.id.clone(),
295            display_name: p.display_name.clone(),
296            user_id: p.user_id.clone(),
297            provider_id: p.provider_id.clone(),
298        })
299        .collect()
300}
301
302#[cfg(test)]
303mod tests {
304    use super::*;
305
306    #[test]
307    fn test_add_threaded_comment() {
308        let mut tc = None;
309        let mut pl = PersonList::default();
310        let input = ThreadedCommentInput {
311            author: "Alice".to_string(),
312            text: "Hello thread".to_string(),
313            parent_id: None,
314        };
315        let id = add_threaded_comment(&mut tc, &mut pl, "A1", &input).unwrap();
316        assert!(!id.is_empty());
317        assert!(tc.is_some());
318
319        let comments = get_threaded_comments(&tc, &pl);
320        assert_eq!(comments.len(), 1);
321        assert_eq!(comments[0].cell_ref, "A1");
322        assert_eq!(comments[0].text, "Hello thread");
323        assert_eq!(comments[0].author, "Alice");
324        assert!(!comments[0].done);
325        assert!(comments[0].parent_id.is_none());
326    }
327
328    #[test]
329    fn test_add_reply() {
330        let mut tc = None;
331        let mut pl = PersonList::default();
332
333        let parent_id = add_threaded_comment(
334            &mut tc,
335            &mut pl,
336            "A1",
337            &ThreadedCommentInput {
338                author: "Alice".to_string(),
339                text: "Initial comment".to_string(),
340                parent_id: None,
341            },
342        )
343        .unwrap();
344
345        let reply_id = add_threaded_comment(
346            &mut tc,
347            &mut pl,
348            "A1",
349            &ThreadedCommentInput {
350                author: "Bob".to_string(),
351                text: "This is a reply".to_string(),
352                parent_id: Some(parent_id.clone()),
353            },
354        )
355        .unwrap();
356
357        let comments = get_threaded_comments(&tc, &pl);
358        assert_eq!(comments.len(), 2);
359        assert_eq!(comments[1].parent_id, Some(parent_id));
360        assert_ne!(reply_id, comments[0].id);
361    }
362
363    #[test]
364    fn test_reply_to_nonexistent_parent() {
365        let mut tc = None;
366        let mut pl = PersonList::default();
367        let input = ThreadedCommentInput {
368            author: "Alice".to_string(),
369            text: "Bad reply".to_string(),
370            parent_id: Some("{NONEXISTENT}".to_string()),
371        };
372        let result = add_threaded_comment(&mut tc, &mut pl, "A1", &input);
373        assert!(result.is_err());
374    }
375
376    #[test]
377    fn test_get_by_cell() {
378        let mut tc = None;
379        let mut pl = PersonList::default();
380
381        add_threaded_comment(
382            &mut tc,
383            &mut pl,
384            "A1",
385            &ThreadedCommentInput {
386                author: "Alice".to_string(),
387                text: "On A1".to_string(),
388                parent_id: None,
389            },
390        )
391        .unwrap();
392
393        add_threaded_comment(
394            &mut tc,
395            &mut pl,
396            "B2",
397            &ThreadedCommentInput {
398                author: "Bob".to_string(),
399                text: "On B2".to_string(),
400                parent_id: None,
401            },
402        )
403        .unwrap();
404
405        let a1_comments = get_threaded_comments_by_cell(&tc, &pl, "A1");
406        assert_eq!(a1_comments.len(), 1);
407        assert_eq!(a1_comments[0].text, "On A1");
408
409        let b2_comments = get_threaded_comments_by_cell(&tc, &pl, "B2");
410        assert_eq!(b2_comments.len(), 1);
411        assert_eq!(b2_comments[0].text, "On B2");
412
413        let empty = get_threaded_comments_by_cell(&tc, &pl, "C3");
414        assert!(empty.is_empty());
415    }
416
417    #[test]
418    fn test_delete_threaded_comment() {
419        let mut tc = None;
420        let mut pl = PersonList::default();
421
422        let id = add_threaded_comment(
423            &mut tc,
424            &mut pl,
425            "A1",
426            &ThreadedCommentInput {
427                author: "Alice".to_string(),
428                text: "Delete me".to_string(),
429                parent_id: None,
430            },
431        )
432        .unwrap();
433
434        delete_threaded_comment(&mut tc, &id).unwrap();
435        assert!(tc.is_none());
436    }
437
438    #[test]
439    fn test_delete_nonexistent_comment() {
440        let mut tc: Option<ThreadedComments> = None;
441        let result = delete_threaded_comment(&mut tc, "{NONEXISTENT}");
442        assert!(result.is_err());
443    }
444
445    #[test]
446    fn test_delete_one_of_multiple() {
447        let mut tc = None;
448        let mut pl = PersonList::default();
449
450        let id1 = add_threaded_comment(
451            &mut tc,
452            &mut pl,
453            "A1",
454            &ThreadedCommentInput {
455                author: "Alice".to_string(),
456                text: "First".to_string(),
457                parent_id: None,
458            },
459        )
460        .unwrap();
461
462        add_threaded_comment(
463            &mut tc,
464            &mut pl,
465            "B2",
466            &ThreadedCommentInput {
467                author: "Bob".to_string(),
468                text: "Second".to_string(),
469                parent_id: None,
470            },
471        )
472        .unwrap();
473
474        delete_threaded_comment(&mut tc, &id1).unwrap();
475        assert!(tc.is_some());
476        let remaining = get_threaded_comments(&tc, &pl);
477        assert_eq!(remaining.len(), 1);
478        assert_eq!(remaining[0].text, "Second");
479    }
480
481    #[test]
482    fn test_resolve_threaded_comment() {
483        let mut tc = None;
484        let mut pl = PersonList::default();
485
486        let id = add_threaded_comment(
487            &mut tc,
488            &mut pl,
489            "A1",
490            &ThreadedCommentInput {
491                author: "Alice".to_string(),
492                text: "Resolve me".to_string(),
493                parent_id: None,
494            },
495        )
496        .unwrap();
497
498        resolve_threaded_comment(&mut tc, &id, true).unwrap();
499        let comments = get_threaded_comments(&tc, &pl);
500        assert!(comments[0].done);
501
502        resolve_threaded_comment(&mut tc, &id, false).unwrap();
503        let comments = get_threaded_comments(&tc, &pl);
504        assert!(!comments[0].done);
505    }
506
507    #[test]
508    fn test_done_field_accepts_true_string() {
509        let mut tc: Option<ThreadedComments> = Some(ThreadedComments::default());
510        let mut pl = PersonList::default();
511        let id = add_threaded_comment(
512            &mut tc,
513            &mut pl,
514            "A1",
515            &ThreadedCommentInput {
516                author: "Alice".to_string(),
517                text: "check done".to_string(),
518                parent_id: None,
519            },
520        )
521        .unwrap();
522
523        // Manually set done to "true" (some OOXML producers use this).
524        let tc_inner = tc.as_mut().unwrap();
525        tc_inner
526            .comments
527            .iter_mut()
528            .find(|c| c.id == id)
529            .unwrap()
530            .done = Some("true".to_string());
531
532        let comments = get_threaded_comments(&tc, &pl);
533        assert!(comments[0].done, "done='true' should be treated as done");
534    }
535
536    #[test]
537    fn test_resolve_nonexistent() {
538        let mut tc: Option<ThreadedComments> = None;
539        let result = resolve_threaded_comment(&mut tc, "{NONEXISTENT}", true);
540        assert!(result.is_err());
541    }
542
543    #[test]
544    fn test_add_person() {
545        let mut pl = PersonList::default();
546        let id = add_person(
547            &mut pl,
548            &PersonInput {
549                display_name: "Alice".to_string(),
550                user_id: Some("alice@example.com".to_string()),
551                provider_id: Some("ADAL".to_string()),
552            },
553        );
554
555        assert!(!id.is_empty());
556        let persons = get_persons(&pl);
557        assert_eq!(persons.len(), 1);
558        assert_eq!(persons[0].display_name, "Alice");
559        assert_eq!(persons[0].user_id, Some("alice@example.com".to_string()));
560    }
561
562    #[test]
563    fn test_add_duplicate_person() {
564        let mut pl = PersonList::default();
565        let id1 = add_person(
566            &mut pl,
567            &PersonInput {
568                display_name: "Alice".to_string(),
569                user_id: None,
570                provider_id: None,
571            },
572        );
573        let id2 = add_person(
574            &mut pl,
575            &PersonInput {
576                display_name: "Alice".to_string(),
577                user_id: None,
578                provider_id: None,
579            },
580        );
581
582        assert_eq!(id1, id2);
583        assert_eq!(get_persons(&pl).len(), 1);
584    }
585
586    #[test]
587    fn test_get_persons_empty() {
588        let pl = PersonList::default();
589        assert!(get_persons(&pl).is_empty());
590    }
591
592    #[test]
593    fn test_get_threaded_comments_empty() {
594        let tc: Option<ThreadedComments> = None;
595        let pl = PersonList::default();
596        assert!(get_threaded_comments(&tc, &pl).is_empty());
597    }
598
599    #[test]
600    fn test_person_auto_created_on_add_comment() {
601        let mut tc = None;
602        let mut pl = PersonList::default();
603
604        add_threaded_comment(
605            &mut tc,
606            &mut pl,
607            "A1",
608            &ThreadedCommentInput {
609                author: "Alice".to_string(),
610                text: "Auto person".to_string(),
611                parent_id: None,
612            },
613        )
614        .unwrap();
615
616        let persons = get_persons(&pl);
617        assert_eq!(persons.len(), 1);
618        assert_eq!(persons[0].display_name, "Alice");
619    }
620
621    #[test]
622    fn test_comment_has_timestamp() {
623        let mut tc = None;
624        let mut pl = PersonList::default();
625
626        add_threaded_comment(
627            &mut tc,
628            &mut pl,
629            "A1",
630            &ThreadedCommentInput {
631                author: "Alice".to_string(),
632                text: "Timestamp check".to_string(),
633                parent_id: None,
634            },
635        )
636        .unwrap();
637
638        let comments = get_threaded_comments(&tc, &pl);
639        assert!(!comments[0].date_time.is_empty());
640        assert!(comments[0].date_time.contains('T'));
641    }
642
643    #[test]
644    fn test_multiple_authors_multiple_cells() {
645        let mut tc = None;
646        let mut pl = PersonList::default();
647
648        add_threaded_comment(
649            &mut tc,
650            &mut pl,
651            "A1",
652            &ThreadedCommentInput {
653                author: "Alice".to_string(),
654                text: "Alice on A1".to_string(),
655                parent_id: None,
656            },
657        )
658        .unwrap();
659
660        add_threaded_comment(
661            &mut tc,
662            &mut pl,
663            "B2",
664            &ThreadedCommentInput {
665                author: "Bob".to_string(),
666                text: "Bob on B2".to_string(),
667                parent_id: None,
668            },
669        )
670        .unwrap();
671
672        add_threaded_comment(
673            &mut tc,
674            &mut pl,
675            "C3",
676            &ThreadedCommentInput {
677                author: "Alice".to_string(),
678                text: "Alice on C3".to_string(),
679                parent_id: None,
680            },
681        )
682        .unwrap();
683
684        let persons = get_persons(&pl);
685        assert_eq!(persons.len(), 2);
686
687        let all = get_threaded_comments(&tc, &pl);
688        assert_eq!(all.len(), 3);
689    }
690
691    #[test]
692    fn test_generated_ids_are_unique() {
693        let mut ids = std::collections::HashSet::new();
694        for _ in 0..1000 {
695            let id = generate_guid();
696            assert!(ids.insert(id.clone()), "duplicate ID generated: {}", id);
697        }
698    }
699
700    #[test]
701    fn test_generated_id_format() {
702        let id = generate_guid();
703        assert!(id.starts_with('{'));
704        assert!(id.ends_with('}'));
705        let inner = &id[1..id.len() - 1];
706        let parts: Vec<&str> = inner.split('-').collect();
707        assert_eq!(parts.len(), 5, "GUID should have 5 hyphen-separated parts");
708        assert_eq!(parts[0].len(), 8);
709        assert_eq!(parts[1].len(), 4);
710        assert_eq!(parts[2].len(), 4);
711        assert_eq!(parts[3].len(), 4);
712        assert_eq!(parts[4].len(), 12);
713    }
714
715    #[test]
716    fn test_no_id_collision_with_existing_workbook() {
717        let mut pl = PersonList::default();
718        pl.persons.push(Person {
719            display_name: "Existing".to_string(),
720            id: "{EXISTING-PERSON-ID}".to_string(),
721            user_id: None,
722            provider_id: None,
723        });
724
725        let mut tc = Some(ThreadedComments {
726            xmlns: sheetkit_xml::threaded_comment::THREADED_COMMENTS_NS.to_string(),
727            comments: vec![ThreadedComment {
728                cell_ref: "A1".to_string(),
729                date_time: "2024-01-01T00:00:00.00".to_string(),
730                person_id: "{EXISTING-PERSON-ID}".to_string(),
731                id: "{EXISTING-COMMENT-ID}".to_string(),
732                parent_id: None,
733                done: None,
734                text: "Pre-existing".to_string(),
735            }],
736        });
737
738        let new_id = add_threaded_comment(
739            &mut tc,
740            &mut pl,
741            "B2",
742            &ThreadedCommentInput {
743                author: "NewUser".to_string(),
744                text: "New comment".to_string(),
745                parent_id: None,
746            },
747        )
748        .unwrap();
749
750        assert_ne!(new_id, "{EXISTING-COMMENT-ID}");
751        assert_ne!(new_id, "{EXISTING-PERSON-ID}");
752
753        let persons = get_persons(&pl);
754        let new_person = persons
755            .iter()
756            .find(|p| p.display_name == "NewUser")
757            .unwrap();
758        assert_ne!(new_person.id, "{EXISTING-PERSON-ID}");
759        assert_ne!(new_person.id, "{EXISTING-COMMENT-ID}");
760    }
761
762    #[test]
763    fn test_delete_nonexistent_returns_error() {
764        let mut tc = None;
765        let mut pl = PersonList::default();
766
767        add_threaded_comment(
768            &mut tc,
769            &mut pl,
770            "A1",
771            &ThreadedCommentInput {
772                author: "Alice".to_string(),
773                text: "Exists".to_string(),
774                parent_id: None,
775            },
776        )
777        .unwrap();
778
779        let result = delete_threaded_comment(&mut tc, "{DOES-NOT-EXIST}");
780        assert!(result.is_err());
781        let err = result.unwrap_err();
782        assert!(
783            err.to_string().contains("DOES-NOT-EXIST"),
784            "error should contain the missing ID"
785        );
786    }
787
788    #[test]
789    fn test_resolve_nonexistent_returns_error() {
790        let mut tc = None;
791        let mut pl = PersonList::default();
792
793        add_threaded_comment(
794            &mut tc,
795            &mut pl,
796            "A1",
797            &ThreadedCommentInput {
798                author: "Alice".to_string(),
799                text: "Exists".to_string(),
800                parent_id: None,
801            },
802        )
803        .unwrap();
804
805        let result = resolve_threaded_comment(&mut tc, "{DOES-NOT-EXIST}", true);
806        assert!(result.is_err());
807        let err = result.unwrap_err();
808        assert!(
809            err.to_string().contains("DOES-NOT-EXIST"),
810            "error should contain the missing ID"
811        );
812    }
813
814    #[test]
815    fn test_add_comment_invalid_cell_reference() {
816        let mut tc = None;
817        let mut pl = PersonList::default();
818
819        let result = add_threaded_comment(
820            &mut tc,
821            &mut pl,
822            "INVALID",
823            &ThreadedCommentInput {
824                author: "Alice".to_string(),
825                text: "Bad cell".to_string(),
826                parent_id: None,
827            },
828        );
829        assert!(result.is_err());
830
831        let result = add_threaded_comment(
832            &mut tc,
833            &mut pl,
834            "",
835            &ThreadedCommentInput {
836                author: "Alice".to_string(),
837                text: "Empty cell".to_string(),
838                parent_id: None,
839            },
840        );
841        assert!(result.is_err());
842    }
843}