Skip to main content

iris_chat_core/
test_fixtures.rs

1use crate::{
2    AccountSnapshot, AppState, ChatKind, ChatMessageKind, ChatMessageSnapshot, ChatThreadSnapshot,
3    CurrentChatSnapshot, DeliveryState, DeviceAuthorizationState, GroupDetailsSnapshot,
4    GroupMemberSnapshot, MessageDeliveryTraceSnapshot, MessageReactionSnapshot, MessageReactor,
5    MessageSearchHit, PreferencesSnapshot, Router, Screen, SearchResultSnapshot,
6};
7
8const MAX_FIXTURE_THREADS: u32 = 2_000;
9const MAX_FIXTURE_MESSAGES: u32 = 10_000;
10const BASE_TIME_SECS: u64 = 1_800_000_000;
11
12/// Deterministic high-volume shell fixture for list, navigation, and message
13/// rendering tests. This is intentionally synthetic: relay-backed tests cover
14/// protocol behaviour, while this keeps UI/perf tests fast and reproducible.
15#[uniffi::export]
16pub fn build_large_test_app_state(
17    direct_chat_count: u32,
18    group_chat_count: u32,
19    messages_in_current_chat: u32,
20) -> AppState {
21    let direct_chat_count = direct_chat_count.min(MAX_FIXTURE_THREADS);
22    let group_chat_count = group_chat_count.min(MAX_FIXTURE_THREADS);
23    let messages_in_current_chat = messages_in_current_chat.min(MAX_FIXTURE_MESSAGES);
24
25    let mut chat_list =
26        Vec::with_capacity((direct_chat_count as usize) + (group_chat_count as usize));
27    for index in 0..direct_chat_count {
28        chat_list.push(fixture_thread(ChatKind::Direct, index));
29    }
30    for index in 0..group_chat_count {
31        chat_list.push(fixture_thread(ChatKind::Group, index));
32    }
33
34    let current_thread = chat_list
35        .first()
36        .cloned()
37        .unwrap_or_else(|| fixture_thread(ChatKind::Direct, 0));
38    let messages = (0..messages_in_current_chat)
39        .map(|index| fixture_message(&current_thread.chat_id, index))
40        .collect();
41    let current_chat = CurrentChatSnapshot {
42        chat_id: current_thread.chat_id.clone(),
43        kind: current_thread.kind.clone(),
44        display_name: current_thread.display_name.clone(),
45        nickname: current_thread.nickname.clone(),
46        profile_name: current_thread.profile_name.clone(),
47        subtitle: current_thread.subtitle.clone(),
48        picture_url: current_thread.picture_url.clone(),
49        about: current_thread.about.clone(),
50        group_id: match &current_thread.kind {
51            ChatKind::Direct => None,
52            ChatKind::Group => Some(current_thread.chat_id.clone()),
53        },
54        member_count: current_thread.member_count,
55        message_ttl_seconds: Some(86_400),
56        is_muted: current_thread.is_muted,
57        participants: Vec::new(),
58        messages,
59        typing_indicators: Vec::new(),
60        draft: current_thread.draft.clone(),
61        is_request: current_thread.is_request,
62    };
63
64    AppState {
65        rev: 1,
66        router: Router {
67            default_screen: Screen::ChatList,
68            screen_stack: vec![
69                Screen::ChatList,
70                Screen::Chat {
71                    chat_id: current_thread.chat_id.clone(),
72                },
73            ],
74        },
75        account: Some(fixture_account()),
76        device_roster: None,
77        busy: Default::default(),
78        chat_list,
79        current_chat: Some(current_chat),
80        group_details: Some(fixture_group_details(group_chat_count.max(1))),
81        public_invite: None,
82        link_device: None,
83        network_status: None,
84        mobile_push: Default::default(),
85        preferences: PreferencesSnapshot {
86            send_typing_indicators: true,
87            nearby_bluetooth_enabled: true,
88            nearby_lan_enabled: true,
89            ..PreferencesSnapshot::default()
90        },
91        toast: None,
92    }
93}
94
95/// Deterministic grouped search fixture for shell tests that need to verify
96/// collapsed initial rendering plus "View more" expansion without writing a
97/// large on-disk message index first.
98#[uniffi::export]
99pub fn build_large_test_search_result(
100    query: String,
101    contact_count: u32,
102    group_count: u32,
103    message_count: u32,
104) -> SearchResultSnapshot {
105    let contact_count = contact_count.min(MAX_FIXTURE_THREADS);
106    let group_count = group_count.min(MAX_FIXTURE_THREADS);
107    let message_count = message_count.min(MAX_FIXTURE_MESSAGES);
108    let query = if query.trim().is_empty() {
109        "needle".to_string()
110    } else {
111        query
112    };
113
114    SearchResultSnapshot {
115        query: query.clone(),
116        scope_chat_id: None,
117        contacts: (0..contact_count)
118            .map(|index| {
119                let mut thread = fixture_thread(ChatKind::Direct, index);
120                thread.display_name = format!("{} Contact {:04}", title_token(&query), index + 1);
121                thread.last_message_preview =
122                    Some(format!("{query} appears in contact preview {}", index + 1));
123                thread
124            })
125            .collect(),
126        groups: (0..group_count)
127            .map(|index| {
128                let mut thread = fixture_thread(ChatKind::Group, index);
129                thread.display_name = format!("{} Group {:04}", title_token(&query), index + 1);
130                thread.last_message_preview =
131                    Some(format!("{query} appears in group preview {}", index + 1));
132                thread
133            })
134            .collect(),
135        messages: (0..message_count)
136            .map(|index| fixture_search_hit(&query, index))
137            .collect(),
138        shortcut: None,
139    }
140}
141
142fn fixture_account() -> AccountSnapshot {
143    AccountSnapshot {
144        public_key_hex: fixture_hex(1),
145        npub: "npub1fixtureowner".to_string(),
146        display_name: "Fixture User".to_string(),
147        picture_url: None,
148        about: None,
149        device_public_key_hex: fixture_hex(2),
150        device_npub: "npub1fixturedevice".to_string(),
151        has_owner_signing_authority: true,
152        authorization_state: DeviceAuthorizationState::Authorized,
153    }
154}
155
156fn fixture_thread(kind: ChatKind, index: u32) -> ChatThreadSnapshot {
157    let is_group = matches!(kind, ChatKind::Group);
158    let prefix = if is_group { "group" } else { "direct" };
159    let display_prefix = if is_group { "Project Group" } else { "Contact" };
160    let member_count = if is_group {
161        3 + u64::from(index % 12)
162    } else {
163        2
164    };
165
166    ChatThreadSnapshot {
167        chat_id: format!("{prefix}-{:04}", index + 1),
168        kind,
169        display_name: format!("{display_prefix} {:04}", index + 1),
170        nickname: None,
171        profile_name: None,
172        subtitle: Some(if is_group {
173            format!("{member_count} people")
174        } else {
175            format!("user {}", index + 1)
176        }),
177        picture_url: None,
178        about: None,
179        member_count,
180        last_message_preview: Some(format!(
181            "Fixture preview {} with searchable token needle",
182            index + 1
183        )),
184        last_message_at_secs: Some(BASE_TIME_SECS.saturating_sub(u64::from(index) * 60)),
185        last_message_is_outgoing: Some(index % 3 == 0),
186        last_message_delivery: Some(match index % 5 {
187            0 => DeliveryState::Seen,
188            1 => DeliveryState::Sent,
189            2 => DeliveryState::Received,
190            3 => DeliveryState::Pending,
191            _ => DeliveryState::Failed,
192        }),
193        unread_count: u64::from(index % 4),
194        is_typing: index % 17 == 0,
195        is_muted: index % 19 == 0,
196        is_pinned: index < 3,
197        draft: if index % 23 == 0 {
198            format!("draft {}", index + 1)
199        } else {
200            String::new()
201        },
202        is_request: false,
203    }
204}
205
206fn fixture_message(chat_id: &str, index: u32) -> ChatMessageSnapshot {
207    let outgoing = index % 2 == 0;
208    ChatMessageSnapshot {
209        id: format!("{chat_id}-message-{:05}", index + 1),
210        chat_id: chat_id.to_string(),
211        kind: if index % 29 == 0 {
212            ChatMessageKind::System
213        } else {
214            ChatMessageKind::User
215        },
216        author: if outgoing {
217            fixture_hex(1)
218        } else {
219            fixture_hex(10_000 + index)
220        },
221        author_owner_pubkey_hex: None,
222        author_picture_url: None,
223        body: format!(
224            "Fixture message {:05} for render and search stress with needle token",
225            index + 1
226        ),
227        attachments: Vec::new(),
228        reactions: fixture_reactions(index),
229        reactors: fixture_reactors(index),
230        is_outgoing: outgoing,
231        created_at_secs: BASE_TIME_SECS + u64::from(index),
232        expires_at_secs: if index % 31 == 0 {
233            Some(BASE_TIME_SECS + u64::from(index) + 86_400)
234        } else {
235            None
236        },
237        delivery: if outgoing {
238            DeliveryState::Seen
239        } else {
240            DeliveryState::Received
241        },
242        recipient_deliveries: Vec::new(),
243        delivery_trace: MessageDeliveryTraceSnapshot::default(),
244        source_event_id: Some(fixture_hex(50_000 + index)),
245    }
246}
247
248fn fixture_reactions(index: u32) -> Vec<MessageReactionSnapshot> {
249    if index % 4 != 0 {
250        return Vec::new();
251    }
252    vec![
253        MessageReactionSnapshot {
254            emoji: "\u{1f44d}".to_string(),
255            count: 1 + u64::from(index % 5),
256            reacted_by_me: index % 8 == 0,
257        },
258        MessageReactionSnapshot {
259            emoji: "\u{2764}\u{fe0f}".to_string(),
260            count: 1,
261            reacted_by_me: false,
262        },
263    ]
264}
265
266fn fixture_reactors(index: u32) -> Vec<MessageReactor> {
267    if index % 4 != 0 {
268        return Vec::new();
269    }
270    vec![MessageReactor {
271        author: fixture_hex(20_000 + index),
272        display_name: String::new(),
273        picture_url: None,
274        emoji: "\u{1f44d}".to_string(),
275    }]
276}
277
278fn fixture_search_hit(query: &str, index: u32) -> MessageSearchHit {
279    let kind = if index % 5 == 0 {
280        ChatKind::Group
281    } else {
282        ChatKind::Direct
283    };
284    let chat_id = match &kind {
285        ChatKind::Direct => format!("direct-{:04}", (index % 500) + 1),
286        ChatKind::Group => format!("group-{:04}", (index % 200) + 1),
287    };
288    MessageSearchHit {
289        chat_id,
290        message_id: format!("search-message-{:05}", index + 1),
291        chat_display_name: match &kind {
292            ChatKind::Direct => format!("Contact {:04}", (index % 500) + 1),
293            ChatKind::Group => format!("Project Group {:04}", (index % 200) + 1),
294        },
295        chat_picture_url: None,
296        chat_kind: kind,
297        author_pubkey: fixture_hex(30_000 + index),
298        body: format!("{query} search fixture message body {:05}", index + 1),
299        is_outgoing: index % 2 == 0,
300        created_at_secs: BASE_TIME_SECS.saturating_sub(u64::from(index) * 30),
301    }
302}
303
304fn fixture_group_details(group_count: u32) -> GroupDetailsSnapshot {
305    let member_count = group_count.min(32).max(3);
306    GroupDetailsSnapshot {
307        group_id: "group-0001".to_string(),
308        name: "Project Group 0001".to_string(),
309        picture_url: None,
310        about: None,
311        created_by_display_name: "Fixture User".to_string(),
312        created_by_npub: "npub1fixtureowner".to_string(),
313        can_manage: true,
314        is_muted: false,
315        revision: 1,
316        members: (0..member_count)
317            .map(|index| GroupMemberSnapshot {
318                owner_pubkey_hex: fixture_hex(40_000 + index),
319                display_name: format!("Member {:02}", index + 1),
320                npub: format!("npub1fixturemember{:02}", index + 1),
321                picture_url: None,
322                is_admin: index < 2,
323                is_creator: index == 0,
324                is_local_owner: index == 0,
325            })
326            .collect(),
327    }
328}
329
330fn fixture_hex(seed: u32) -> String {
331    format!("{seed:064x}")
332}
333
334fn title_token(query: &str) -> String {
335    let trimmed = query.trim();
336    let mut chars = trimmed.chars();
337    match chars.next() {
338        Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
339        None => "Needle".to_string(),
340    }
341}