1use crate::app::{
9 App, FocusPanel, InputMode, ListContent, RunningState, SidebarSection, SortColumn,
10 SortDirection, ViewMode,
11};
12use crate::client::ApiError;
13use crate::client::types::ListParams;
14use crate::cmd::Cmd;
15use crate::models::{
16 EmailMessage, MailingList, Paginated, PatchId, Patchset, PatchsetDetail, ServerStats,
17};
18
19#[derive(Debug)]
24pub enum Message {
25 Quit,
27 Init,
29 Refresh,
31 Tick,
33 Render,
35 Resize(u16, u16),
37 PatchsetsLoaded(Result<Paginated<Patchset>, ApiError>),
39 PatchsetDetailLoaded(Box<Result<PatchsetDetail, ApiError>>),
41 StatsLoaded(Result<ServerStats, ApiError>),
43 ListsLoaded(Result<Vec<MailingList>, ApiError>),
45 ScrollDown,
47 ScrollUp,
49 HalfPageDown,
51 HalfPageUp,
53 Select,
55 NextMailbox,
57 PrevMailbox,
59 ToggleFocus,
61 Back,
63 ToggleHelp,
65 NextPage,
67 PrevPage,
69 SearchStart,
71 SearchInput(char),
73 SearchSubmit,
75 SearchCancel,
77 ViewRawLog,
79 BookmarkToggle,
81 BookmarksPersisted(Result<(), String>),
83 NextComment,
85 PrevComment,
87 ViewBaselineLog,
89 ToggleListContent,
91 MessagesLoaded(Result<Paginated<EmailMessage>, ApiError>),
93 MessageDetailLoaded(Box<Result<EmailMessage, ApiError>>),
95 ToggleBookmarkFilter,
97 CycleSort,
99 ReverseSort,
101}
102
103pub fn update(app: &mut App, msg: Message) -> Cmd {
125 log_message(&msg, app);
126 match msg {
127 Message::Quit => {
128 tracing::info!("app quit requested");
129 app.running_state = RunningState::Done;
130 Cmd::None
131 }
132 Message::Init => Cmd::Batch(vec![
133 Cmd::FetchLists,
134 Cmd::FetchPatchsets(app.list_params.clone()),
135 Cmd::FetchStats,
136 ]),
137 Message::Refresh => {
138 let content_fetch = match app.list_content {
139 ListContent::Patchsets => Cmd::FetchPatchsets(app.list_params.clone()),
140 ListContent::Messages => Cmd::FetchMessages(app.list_params.clone()),
141 };
142 Cmd::ClearCacheAndBatch(vec![content_fetch, Cmd::FetchLists, Cmd::FetchStats])
143 }
144 Message::Tick | Message::Render => Cmd::None,
145 Message::Resize(_w, h) => {
146 app.terminal_height = h;
147 Cmd::None
148 }
149 Message::PatchsetsLoaded(result) => handle_patchsets_loaded(app, result),
150 Message::PatchsetDetailLoaded(result) => handle_detail_loaded(app, *result),
151 Message::StatsLoaded(result) => handle_stats_loaded(app, result),
152 Message::ListsLoaded(result) => handle_lists_loaded(app, result),
153 Message::ScrollDown | Message::ScrollUp | Message::HalfPageDown | Message::HalfPageUp => {
154 handle_scroll(app, &msg)
155 }
156 Message::Select => handle_select(app),
157 Message::NextMailbox => {
158 if app.config.remotes.is_empty() {
159 return Cmd::None;
160 }
161 let new_idx = (app.active_remote_index + 1) % app.config.remotes.len();
162 switch_remote(app, new_idx)
163 }
164 Message::PrevMailbox => {
165 if app.config.remotes.is_empty() {
166 return Cmd::None;
167 }
168 let len = app.config.remotes.len();
169 let new_idx = app.active_remote_index.checked_sub(1).unwrap_or(len - 1);
170 switch_remote(app, new_idx)
171 }
172 Message::ToggleFocus => {
173 app.focus = match app.focus {
174 FocusPanel::PatchsetList => FocusPanel::Sidebar,
175 FocusPanel::Sidebar => FocusPanel::PatchsetList,
176 };
177 Cmd::None
178 }
179 Message::Back => handle_back(app),
180 Message::ToggleHelp => {
181 app.show_help = !app.show_help;
182 Cmd::None
183 }
184 Message::NextPage | Message::PrevPage => handle_page_nav(app, &msg),
185 Message::SearchStart
186 | Message::SearchInput(_)
187 | Message::SearchSubmit
188 | Message::SearchCancel => handle_search(app, &msg),
189 Message::ViewRawLog => handle_view_raw_log(app),
190 Message::BookmarkToggle => handle_bookmark_toggle(app),
191 Message::BookmarksPersisted(result) => {
192 if let Err(ref e) = result {
193 tracing::error!(error = %e, "bookmark persist failed");
194 }
195 Cmd::None
196 }
197 Message::NextComment => handle_comment_nav(app, true),
198 Message::PrevComment => handle_comment_nav(app, false),
199 Message::ViewBaselineLog => handle_view_baseline_log(app),
200 Message::ToggleListContent => handle_toggle_list_content(app),
201 Message::MessagesLoaded(result) => handle_messages_loaded(app, result),
202 Message::MessageDetailLoaded(result) => handle_message_detail_loaded(app, *result),
203 Message::ToggleBookmarkFilter => handle_bookmark_filter_toggle(app),
204 Message::CycleSort => handle_sort_cycle(app),
205 Message::ReverseSort => handle_reverse_sort(app),
206 }
207}
208
209fn log_message(msg: &Message, app: &App) {
211 match msg {
212 Message::Init => tracing::info!("init"),
213 Message::Refresh => tracing::debug!("refresh"),
214 Message::Select => {
215 tracing::debug!(index = app.selected_index, "select");
216 }
217 Message::NextMailbox => tracing::debug!("next mailbox"),
218 Message::PrevMailbox => tracing::debug!("prev mailbox"),
219 Message::NextPage => tracing::debug!(page = app.list_params.page, "next page"),
220 Message::PrevPage => tracing::debug!(page = app.list_params.page, "prev page"),
221 Message::Back => tracing::debug!("back"),
222 Message::ToggleFocus => tracing::debug!("toggle focus"),
223 Message::ToggleHelp => tracing::debug!("toggle help"),
224 Message::SearchStart => tracing::debug!("search start"),
225 Message::SearchSubmit => tracing::debug!("search submit"),
226 Message::SearchCancel => tracing::debug!("search cancel"),
227 Message::ViewRawLog => tracing::debug!("view raw log"),
228 Message::Tick | Message::Render => tracing::trace!("tick/render"),
229 Message::Resize(w, h) => tracing::trace!(w, h, "resize"),
230 Message::ScrollDown | Message::ScrollUp | Message::HalfPageDown | Message::HalfPageUp => {
231 tracing::trace!("scroll");
232 }
233 Message::SearchInput(_) => tracing::trace!("search input"),
234 Message::BookmarkToggle => tracing::debug!("bookmark toggle"),
235 Message::NextComment => tracing::debug!("next comment"),
236 Message::PrevComment => tracing::debug!("prev comment"),
237 Message::ViewBaselineLog => tracing::debug!("view baseline log"),
238 Message::ToggleListContent => tracing::debug!("toggle list content"),
239 Message::ToggleBookmarkFilter => tracing::debug!("toggle bookmark filter"),
240 Message::CycleSort => tracing::debug!("cycle sort"),
241 Message::ReverseSort => tracing::debug!("reverse sort"),
242 Message::PatchsetsLoaded(_)
244 | Message::PatchsetDetailLoaded(_)
245 | Message::StatsLoaded(_)
246 | Message::ListsLoaded(_)
247 | Message::BookmarksPersisted(_)
248 | Message::MessagesLoaded(_)
249 | Message::MessageDetailLoaded(_)
250 | Message::Quit => {}
251 }
252}
253
254fn handle_view_raw_log(app: &App) -> Cmd {
256 use std::fmt::Write;
257
258 let Some(ref detail) = app.selected_detail else {
259 return Cmd::None;
260 };
261
262 let mut log = String::new();
263 for review in &detail.reviews {
264 let _ = writeln!(
265 log,
266 "=== Review {} (patch {}) ===",
267 review.id, review.patch_id
268 );
269 let _ = writeln!(
270 log,
271 "Status: {} | Model: {}",
272 review.status,
273 review.model.as_deref().unwrap_or("?")
274 );
275 if let Some(ref summary) = review.summary {
276 let _ = write!(log, "\n--- Summary ---\n{summary}\n");
277 }
278 if let Some(ref inline) = review.inline_review {
279 let _ = write!(log, "\n--- Inline Review ---\n{inline}\n");
280 }
281 log.push_str("\n\n");
282 }
283
284 if log.is_empty() {
285 return Cmd::None;
286 }
287
288 let editor = app.config.resolved_editor();
289 Cmd::OpenEditor {
290 content: log,
291 editor,
292 }
293}
294
295fn handle_back(app: &mut App) -> Cmd {
297 if app.show_help {
298 app.show_help = false;
299 return Cmd::None;
300 }
301 if app.view_mode == ViewMode::List && app.list_params.search.is_some() {
303 app.list_params.search = None;
304 app.list_params.page = 1;
305 app.selected_index = 0;
306 return match app.list_content {
307 ListContent::Patchsets => Cmd::FetchPatchsets(app.list_params.clone()),
308 ListContent::Messages => Cmd::FetchMessages(app.list_params.clone()),
309 };
310 }
311 if app.view_mode == ViewMode::Detail || app.view_mode == ViewMode::Loading {
312 app.view_mode = ViewMode::List;
313 app.selected_detail = None;
314 app.selected_message = None;
315 app.loading_context = None;
316 app.detail_scroll_offset = 0;
317 app.comment_positions.clear();
318 app.current_comment_index = None;
319 }
320 Cmd::None
321}
322
323fn handle_toggle_list_content(app: &mut App) -> Cmd {
325 app.selected_index = 0;
326 app.list_params.page = 1;
327 match app.list_content {
329 ListContent::Patchsets => {
330 app.list_content = ListContent::Messages;
331 Cmd::FetchMessages(app.list_params.clone())
332 }
333 ListContent::Messages => {
334 app.list_content = ListContent::Patchsets;
335 Cmd::FetchPatchsets(app.list_params.clone())
336 }
337 }
338}
339
340fn handle_messages_loaded(app: &mut App, result: Result<Paginated<EmailMessage>, ApiError>) -> Cmd {
342 match result {
343 Ok(paginated) => {
344 tracing::info!(
345 count = paginated.items.len(),
346 total = paginated.total,
347 "messages loaded"
348 );
349 app.messages = paginated;
350 app.error_state = None;
351 }
352 Err(ref e) => {
353 tracing::error!(error = %e, "messages load failed");
354 app.error_state = Some(e.to_string());
355 }
356 }
357 Cmd::None
358}
359
360fn handle_message_detail_loaded(app: &mut App, result: Result<EmailMessage, ApiError>) -> Cmd {
362 match result {
363 Ok(msg) => {
364 tracing::info!(id = msg.id, "message detail loaded");
365 app.selected_message = Some(msg);
366 app.view_mode = ViewMode::Detail;
367 app.loading_context = None;
368 app.detail_scroll_offset = 0;
369 app.error_state = None;
370 }
371 Err(ref e) => {
372 tracing::error!(error = %e, "message detail load failed");
373 app.error_state = Some(e.to_string());
374 app.view_mode = ViewMode::List;
375 app.loading_context = None;
376 }
377 }
378 Cmd::None
379}
380
381fn handle_view_baseline_log(app: &App) -> Cmd {
383 let content = app
384 .selected_detail
385 .as_ref()
386 .and_then(|d| d.baseline_logs.as_ref())
387 .filter(|s| !s.is_empty());
388 match content {
389 Some(logs) => {
390 let editor = app.config.resolved_editor();
391 Cmd::OpenEditor {
392 content: logs.clone(),
393 editor,
394 }
395 }
396 None => Cmd::None,
397 }
398}
399
400fn handle_page_nav(app: &mut App, msg: &Message) -> Cmd {
402 let paginated_total = match app.list_content {
403 ListContent::Patchsets => app.patchsets.total_pages(),
404 ListContent::Messages => app.messages.total_pages(),
405 };
406 let fetch = |app: &App| match app.list_content {
407 ListContent::Patchsets => Cmd::FetchPatchsets(app.list_params.clone()),
408 ListContent::Messages => Cmd::FetchMessages(app.list_params.clone()),
409 };
410 match *msg {
411 Message::NextPage => {
412 if paginated_total > 0 && app.list_params.page < paginated_total {
413 app.list_params.page += 1;
414 app.selected_index = 0;
415 fetch(app)
416 } else {
417 Cmd::None
418 }
419 }
420 Message::PrevPage => {
421 if app.list_params.page > 1 {
422 app.list_params.page -= 1;
423 app.selected_index = 0;
424 fetch(app)
425 } else {
426 Cmd::None
427 }
428 }
429 _ => Cmd::None,
430 }
431}
432
433fn handle_bookmark_toggle(app: &mut App) -> Cmd {
435 if app.view_mode != ViewMode::List || app.focus != FocusPanel::PatchsetList {
436 return Cmd::None;
437 }
438 let Some(patchset) = app.patchsets.items.get(app.selected_index) else {
439 return Cmd::None;
440 };
441 let id = patchset.id;
442 let is_bookmarked = app.bookmarks.toggle(&app.active_remote, id);
443 tracing::debug!(
444 remote = %app.active_remote,
445 patchset_id = id,
446 bookmarked = is_bookmarked,
447 "bookmark toggle"
448 );
449 Cmd::PersistBookmarks {
450 bookmarks: app.bookmarks.clone(),
451 path: app.bookmarks_path.clone(),
452 }
453}
454
455fn compute_comment_positions(detail: &PatchsetDetail) -> Vec<usize> {
460 let mut positions = Vec::new();
461 let mut line = 0;
462
463 line += 1;
465 if detail.failed_reason.is_some() {
467 line += 1;
468 }
469 if detail.total_parts.is_some() && detail.received_parts.is_some() {
471 line += 1;
472 }
473 if detail.baseline.is_some() {
475 line += 1;
476 }
477 if detail.model_name.is_some() {
479 line += 1;
480 }
481 line += 1;
483
484 line += 1;
486
487 if detail.patches.is_empty() {
488 line += 1; } else {
490 for patch in &detail.patches {
491 positions.push(line); line += 1; let review = detail.reviews.iter().find(|r| r.patch_id == patch.id);
495 if let Some(rev) = review {
496 if rev.summary.is_some() {
497 line += 1; }
499 if let Some(ref inline) = rev.inline_review {
500 line += 1; line += inline.lines().count(); line += 1; }
504 } else {
505 line += 1; }
507 }
508 }
509
510 line += 1;
512
513 line += 1;
515
516 if detail.thread.is_empty() {
517 } else {
519 for _msg in &detail.thread {
520 positions.push(line); line += 2; }
523 }
524
525 positions
526}
527
528fn handle_comment_nav(app: &mut App, forward: bool) -> Cmd {
530 if app.view_mode != ViewMode::Detail || app.comment_positions.is_empty() {
531 return Cmd::None;
532 }
533
534 let positions = &app.comment_positions;
535 let offset = app.detail_scroll_offset;
536
537 let target = match app.current_comment_index {
538 Some(i) => {
539 if forward {
540 (i + 1).min(positions.len() - 1)
541 } else {
542 i.saturating_sub(1)
543 }
544 }
545 None => {
546 if forward {
547 positions
549 .iter()
550 .position(|&p| p >= offset)
551 .unwrap_or(positions.len() - 1)
552 } else {
553 positions.iter().rposition(|&p| p < offset).unwrap_or(0)
555 }
556 }
557 };
558
559 app.detail_scroll_offset = positions[target];
560 app.current_comment_index = Some(target);
561 Cmd::None
562}
563
564fn handle_search(app: &mut App, msg: &Message) -> Cmd {
566 match *msg {
567 Message::SearchStart => {
568 app.input_mode = InputMode::Search;
569 app.search_buffer = app.list_params.search.clone().unwrap_or_default();
570 app.search_cursor = app.search_buffer.len();
571 Cmd::None
572 }
573 Message::SearchInput(c) => {
574 if c == '\x08' {
575 if app.search_cursor > 0 {
577 app.search_cursor -= 1;
578 app.search_buffer.remove(app.search_cursor);
579 }
580 } else {
581 app.search_buffer.insert(app.search_cursor, c);
582 app.search_cursor += c.len_utf8();
583 }
584 Cmd::None
585 }
586 Message::SearchSubmit => {
587 app.input_mode = InputMode::Normal;
588 app.list_params.search = if app.search_buffer.is_empty() {
589 None
590 } else {
591 Some(app.search_buffer.clone())
592 };
593 app.list_params.page = 1;
594 app.selected_index = 0;
595 app.search_buffer.clear();
596 app.search_cursor = 0;
597 match app.list_content {
598 ListContent::Patchsets => Cmd::FetchPatchsets(app.list_params.clone()),
599 ListContent::Messages => Cmd::FetchMessages(app.list_params.clone()),
600 }
601 }
602 Message::SearchCancel => {
603 app.input_mode = InputMode::Normal;
604 app.search_buffer.clear();
605 app.search_cursor = 0;
606 Cmd::None
607 }
608 _ => Cmd::None,
609 }
610}
611
612fn handle_patchsets_loaded(app: &mut App, result: Result<Paginated<Patchset>, ApiError>) -> Cmd {
614 match result {
615 Ok(paginated) => {
616 tracing::info!(
617 count = paginated.items.len(),
618 total = paginated.total,
619 page = paginated.page,
620 "patchsets loaded"
621 );
622 app.patchsets = paginated;
623 app.error_state = None;
624 apply_sort(app);
625 }
626 Err(ref e) => {
627 tracing::error!(error = %e, "patchsets load failed");
628 app.error_state = Some(e.to_string());
629 }
630 }
631 Cmd::None
632}
633
634fn handle_select(app: &mut App) -> Cmd {
636 if app.view_mode == ViewMode::Loading {
637 return Cmd::None;
638 }
639 match app.focus {
640 FocusPanel::PatchsetList => match app.list_content {
641 ListContent::Patchsets => {
642 let Some(patchset) = app.patchsets.items.get(app.selected_index) else {
643 return Cmd::None;
644 };
645 app.loading_context = Some(crate::app::LoadingContext {
646 patchset_id: patchset.id,
647 subject: patchset.subject().to_string(),
648 status: patchset.status.to_string(),
649 });
650 app.view_mode = ViewMode::Loading;
651 app.detail_scroll_offset = 0;
652 app.comment_positions.clear();
653 app.current_comment_index = None;
654 Cmd::FetchPatchsetDetail(PatchId::Numeric(patchset.id))
655 }
656 ListContent::Messages => {
657 let Some(msg) = app.messages.items.get(app.selected_index) else {
658 return Cmd::None;
659 };
660 app.loading_context = Some(crate::app::LoadingContext {
661 patchset_id: msg.id,
662 subject: msg.subject.as_deref().unwrap_or("(no subject)").to_string(),
663 status: String::new(),
664 });
665 app.view_mode = ViewMode::Loading;
666 app.detail_scroll_offset = 0;
667 Cmd::FetchMessageDetail(PatchId::Numeric(msg.id))
668 }
669 },
670 FocusPanel::Sidebar => handle_sidebar_select(app),
671 }
672}
673
674fn handle_detail_loaded(app: &mut App, result: Result<PatchsetDetail, ApiError>) -> Cmd {
676 match result {
677 Ok(detail) => {
678 tracing::info!(
679 id = detail.id,
680 patches = detail.patches.len(),
681 reviews = detail.reviews.len(),
682 "patchset detail loaded"
683 );
684 app.comment_positions = compute_comment_positions(&detail);
685 app.current_comment_index = None;
686 app.selected_detail = Some(detail);
687 app.view_mode = ViewMode::Detail;
688 app.loading_context = None;
689 app.error_state = None;
690 }
691 Err(ref e) => {
692 tracing::error!(error = %e, "patchset detail load failed");
693 app.error_state = Some(e.to_string());
694 app.view_mode = ViewMode::List;
696 app.loading_context = None;
697 }
698 }
699 Cmd::None
700}
701
702fn handle_stats_loaded(app: &mut App, result: Result<ServerStats, ApiError>) -> Cmd {
704 match result {
705 Ok(stats) => {
706 tracing::info!(version = %stats.version, pending = stats.pending, "stats loaded");
707 app.stats = Some(stats);
708 app.error_state = None;
709 }
710 Err(ref e) => {
711 tracing::error!(error = %e, "stats load failed");
712 app.error_state = Some(e.to_string());
713 }
714 }
715 Cmd::None
716}
717
718fn handle_lists_loaded(app: &mut App, result: Result<Vec<MailingList>, ApiError>) -> Cmd {
720 match result {
721 Ok(lists) => {
722 tracing::info!(count = lists.len(), "mailing lists loaded");
723 app.mailing_lists = lists;
724 app.error_state = None;
725 }
726 Err(ref e) => {
727 tracing::error!(error = %e, "mailing lists load failed");
728 app.error_state = Some(e.to_string());
729 }
730 }
731 Cmd::None
732}
733
734fn handle_scroll(app: &mut App, msg: &Message) -> Cmd {
736 if app.view_mode == ViewMode::Loading {
738 return Cmd::None;
739 }
740 match app.focus {
741 FocusPanel::PatchsetList => {}
742 FocusPanel::Sidebar => return handle_sidebar_scroll(app, msg),
743 }
744 if app.view_mode == ViewMode::Detail {
746 match *msg {
747 Message::ScrollDown => app.detail_scroll_offset += 1,
748 Message::ScrollUp => {
749 app.detail_scroll_offset = app.detail_scroll_offset.saturating_sub(1);
750 }
751 Message::HalfPageDown => {
752 app.detail_scroll_offset += usize::from(app.terminal_height / 2).max(1);
753 }
754 Message::HalfPageUp => {
755 let half = usize::from(app.terminal_height / 2).max(1);
756 app.detail_scroll_offset = app.detail_scroll_offset.saturating_sub(half);
757 }
758 _ => {}
759 }
760 app.current_comment_index = None; return Cmd::None;
762 }
763 match *msg {
765 Message::ScrollDown => {
766 let len = app.patchsets.items.len();
767 if len > 0 {
768 app.selected_index = (app.selected_index + 1).min(len - 1);
769 }
770 }
771 Message::ScrollUp => {
772 app.selected_index = app.selected_index.saturating_sub(1);
773 }
774 Message::HalfPageDown => {
775 let len = app.patchsets.items.len();
776 let half = usize::from(app.terminal_height / 2).max(1);
777 if len > 0 {
778 app.selected_index = (app.selected_index + half).min(len - 1);
779 }
780 }
781 Message::HalfPageUp => {
782 let half = usize::from(app.terminal_height / 2).max(1);
783 app.selected_index = app.selected_index.saturating_sub(half);
784 }
785 _ => {}
786 }
787 Cmd::None
788}
789
790fn handle_sidebar_scroll(app: &mut App, msg: &Message) -> Cmd {
792 let is_down = matches!(*msg, Message::ScrollDown | Message::HalfPageDown);
793 let is_up = matches!(*msg, Message::ScrollUp | Message::HalfPageUp);
794
795 match app.sidebar_section {
796 SidebarSection::Remotes => {
797 if is_down {
798 if app.active_remote_index + 1 < app.config.remotes.len() {
799 app.active_remote_index += 1;
800 } else if !app.mailing_lists.is_empty() {
801 app.sidebar_section = SidebarSection::MailingLists;
803 app.sidebar_list_index = 0;
804 }
805 } else if is_up {
806 app.active_remote_index = app.active_remote_index.saturating_sub(1);
807 }
808 }
809 SidebarSection::MailingLists => {
810 let max_index = app.mailing_lists.len(); if is_down {
813 if app.sidebar_list_index < max_index {
814 app.sidebar_list_index += 1;
815 }
816 } else if is_up {
817 if app.sidebar_list_index > 0 {
818 app.sidebar_list_index -= 1;
819 } else {
820 app.sidebar_section = SidebarSection::Remotes;
822 app.active_remote_index = app.config.remotes.len().saturating_sub(1);
823 }
824 }
825 }
826 }
827 Cmd::None
828}
829
830fn handle_sidebar_select(app: &mut App) -> Cmd {
832 match app.sidebar_section {
833 SidebarSection::Remotes => switch_remote(app, app.active_remote_index),
834 SidebarSection::MailingLists => {
835 if app.sidebar_list_index == 0 {
836 app.list_params.mailing_list = None;
838 } else {
839 let idx = app.sidebar_list_index - 1;
840 if let Some(list) = app.mailing_lists.get(idx) {
841 app.list_params.mailing_list =
842 Some(list.group.clone().unwrap_or_else(|| list.name.clone()));
843 }
844 }
845 app.list_params.page = 1;
846 app.selected_index = 0;
847 Cmd::FetchPatchsets(app.list_params.clone())
848 }
849 }
850}
851
852fn handle_bookmark_filter_toggle(app: &mut App) -> Cmd {
854 app.show_bookmarks_only = !app.show_bookmarks_only;
855 app.selected_index = 0;
856 Cmd::None
857}
858
859fn status_sort_key(s: crate::models::PatchsetStatus) -> u8 {
861 use crate::models::PatchsetStatus;
862 match s {
863 PatchsetStatus::FailedToApply => 0,
864 PatchsetStatus::Failed => 1,
865 PatchsetStatus::Incomplete => 2,
866 PatchsetStatus::Pending => 3,
867 PatchsetStatus::InReview => 4,
868 PatchsetStatus::Cancelled => 5,
869 PatchsetStatus::Skipped => 6,
870 PatchsetStatus::Reviewed => 7,
871 PatchsetStatus::Unknown => 8,
872 }
873}
874
875pub fn apply_sort(app: &mut App) {
877 let asc = app.sort_direction == SortDirection::Ascending;
878 match app.sort_column {
879 SortColumn::Default => {} SortColumn::Status => app.patchsets.items.sort_by(|a, b| {
881 let ord = status_sort_key(a.status).cmp(&status_sort_key(b.status));
882 if asc { ord } else { ord.reverse() }
883 }),
884 SortColumn::Date => app.patchsets.items.sort_by(|a, b| {
885 let ord = match (a.date, b.date) {
887 (None, None) => std::cmp::Ordering::Equal,
888 (None, Some(_)) => std::cmp::Ordering::Greater,
889 (Some(_), None) => std::cmp::Ordering::Less,
890 (Some(da), Some(db)) => da.cmp(&db),
891 };
892 if asc { ord } else { ord.reverse() }
893 }),
894 SortColumn::Findings => app.patchsets.items.sort_by(|a, b| {
895 let ord = a.findings.total().cmp(&b.findings.total());
896 if asc { ord } else { ord.reverse() }
897 }),
898 SortColumn::Author => app.patchsets.items.sort_by(|a, b| {
899 let ord = a.author().cmp(b.author());
900 if asc { ord } else { ord.reverse() }
901 }),
902 }
903}
904
905fn handle_sort_cycle(app: &mut App) -> Cmd {
907 app.sort_column = match app.sort_column {
908 SortColumn::Default => SortColumn::Status,
909 SortColumn::Status => SortColumn::Date,
910 SortColumn::Date => SortColumn::Findings,
911 SortColumn::Findings => SortColumn::Author,
912 SortColumn::Author => SortColumn::Default,
913 };
914 app.sort_direction = SortDirection::Ascending;
915 app.selected_index = 0;
916 apply_sort(app);
917 Cmd::None
918}
919
920fn handle_reverse_sort(app: &mut App) -> Cmd {
922 app.sort_direction = match app.sort_direction {
923 SortDirection::Ascending => SortDirection::Descending,
924 SortDirection::Descending => SortDirection::Ascending,
925 };
926 app.selected_index = 0;
927 apply_sort(app);
928 Cmd::None
929}
930
931fn switch_remote(app: &mut App, new_index: usize) -> Cmd {
934 let Some(remote) = app.config.remotes.get(new_index) else {
935 return Cmd::None;
936 };
937 app.active_remote_index = new_index;
938 app.active_remote = remote.name.clone();
939 app.patchsets.items.clear();
940 app.patchsets.total = 0;
941 app.selected_index = 0;
942 app.error_state = None;
943 app.stats = None;
944 app.view_mode = ViewMode::List;
945 app.selected_detail = None;
946 app.loading_context = None;
947 app.detail_scroll_offset = 0;
948 app.list_params = ListParams::default();
949 app.input_mode = InputMode::Normal;
950 app.search_buffer.clear();
951 app.search_cursor = 0;
952 app.sidebar_section = SidebarSection::Remotes;
953 app.sidebar_list_index = 0;
954 app.comment_positions.clear();
955 app.current_comment_index = None;
956 app.list_content = ListContent::Patchsets;
957 app.messages.items.clear();
958 app.messages.total = 0;
959 app.selected_message = None;
960 app.sort_column = SortColumn::Default;
961 app.sort_direction = SortDirection::Ascending;
962 app.show_bookmarks_only = false;
963 Cmd::Batch(vec![
964 Cmd::FetchPatchsets(app.list_params.clone()),
965 Cmd::FetchLists,
966 Cmd::FetchStats,
967 ])
968}
969
970#[cfg(test)]
971#[allow(clippy::expect_used, clippy::panic)]
972mod tests {
973 use super::*;
974 use crate::config::Config;
975
976 #[test]
977 fn quit_returns_none() {
978 let mut app = App::new(Config::default());
979 let cmd = update(&mut app, Message::Quit);
980 assert_eq!(app.running_state, RunningState::Done);
981 assert!(matches!(cmd, Cmd::None));
982 }
983
984 #[test]
985 fn init_returns_batch_with_three_fetches() {
986 let mut app = App::new(Config::default());
987 let cmd = update(&mut app, Message::Init);
988 match cmd {
989 Cmd::Batch(cmds) => {
990 assert_eq!(cmds.len(), 3);
991 assert!(matches!(cmds[0], Cmd::FetchLists));
992 assert!(matches!(cmds[1], Cmd::FetchPatchsets(_)));
993 assert!(matches!(cmds[2], Cmd::FetchStats));
994 }
995 other => panic!("expected Cmd::Batch, got {other:?}"),
996 }
997 }
998
999 #[test]
1000 fn refresh_clears_cache_and_fetches() {
1001 let mut app = App::new(Config::default());
1002 let cmd = update(&mut app, Message::Refresh);
1003 match cmd {
1004 Cmd::ClearCacheAndBatch(cmds) => {
1005 assert_eq!(cmds.len(), 3);
1006 assert!(matches!(cmds[0], Cmd::FetchPatchsets(_)));
1007 assert!(matches!(cmds[1], Cmd::FetchLists));
1008 assert!(matches!(cmds[2], Cmd::FetchStats));
1009 }
1010 other => panic!("expected Cmd::ClearCacheAndBatch, got {other:?}"),
1011 }
1012 }
1013
1014 #[test]
1015 fn tick_and_render_return_none() {
1016 let mut app = App::new(Config::default());
1017 assert!(matches!(update(&mut app, Message::Tick), Cmd::None));
1018 assert!(matches!(update(&mut app, Message::Render), Cmd::None));
1019 }
1020
1021 #[test]
1022 fn resize_returns_none() {
1023 let mut app = App::new(Config::default());
1024 assert!(matches!(
1025 update(&mut app, Message::Resize(120, 40)),
1026 Cmd::None
1027 ));
1028 }
1029
1030 #[test]
1031 fn patchsets_loaded_success() {
1032 let mut app = App::new(Config::default());
1033 let paginated = Paginated {
1034 items: vec![Patchset::fixture()],
1035 total: 1,
1036 page: 1,
1037 per_page: 50,
1038 };
1039 let cmd = update(&mut app, Message::PatchsetsLoaded(Ok(paginated)));
1040 assert_eq!(app.patchsets.items.len(), 1);
1041 assert!(app.error_state.is_none());
1042 assert!(matches!(cmd, Cmd::None));
1043 }
1044
1045 #[test]
1046 fn patchsets_loaded_error() {
1047 let mut app = App::new(Config::default());
1048 let err = ApiError::Network {
1049 source: "connection refused".into(),
1050 remote: "test".to_string(),
1051 };
1052 let cmd = update(&mut app, Message::PatchsetsLoaded(Err(err)));
1053 assert!(app.error_state.is_some());
1054 assert!(matches!(cmd, Cmd::None));
1055 }
1056
1057 #[test]
1058 fn lists_loaded_success() {
1059 let mut app = App::new(Config::default());
1060 let lists = vec![MailingList {
1061 name: "LKML".to_string(),
1062 group: Some("org.kernel.vger.linux-kernel".to_string()),
1063 }];
1064 let cmd = update(&mut app, Message::ListsLoaded(Ok(lists)));
1065 assert_eq!(app.mailing_lists.len(), 1);
1066 assert!(matches!(cmd, Cmd::None));
1067 }
1068
1069 #[test]
1070 fn error_state_set_on_api_failure() {
1071 let mut app = App::new(Config::default());
1072 let err = ApiError::Timeout {
1073 endpoint: "/api/stats".to_string(),
1074 duration: std::time::Duration::from_secs(5),
1075 };
1076 let cmd = update(&mut app, Message::StatsLoaded(Err(err)));
1077 assert!(app.error_state.is_some());
1078 assert!(matches!(cmd, Cmd::None));
1079 }
1080
1081 fn app_with_patchsets(count: usize) -> App {
1082 let mut app = App::new(Config::default());
1083 app.patchsets = Paginated {
1084 items: (0..count)
1085 .map(|i| {
1086 let mut ps = Patchset::fixture();
1087 ps.id = i64::try_from(i).expect("test count fits i64");
1088 ps
1089 })
1090 .collect(),
1091 total: u32::try_from(count).expect("test count fits u32"),
1092 page: 1,
1093 per_page: 50,
1094 };
1095 app.terminal_height = 24;
1096 app
1097 }
1098
1099 #[test]
1100 fn scroll_down_increments_index() {
1101 let mut app = app_with_patchsets(10);
1102 assert_eq!(app.selected_index, 0);
1103 let cmd = update(&mut app, Message::ScrollDown);
1104 assert_eq!(app.selected_index, 1);
1105 assert!(matches!(cmd, Cmd::None));
1106 }
1107
1108 #[test]
1109 fn scroll_down_clamps_at_last_item() {
1110 let mut app = app_with_patchsets(5);
1111 app.selected_index = 4; let cmd = update(&mut app, Message::ScrollDown);
1113 assert_eq!(app.selected_index, 4); assert!(matches!(cmd, Cmd::None));
1115 }
1116
1117 #[test]
1118 fn scroll_down_empty_list_is_noop() {
1119 let mut app = app_with_patchsets(0);
1120 let cmd = update(&mut app, Message::ScrollDown);
1121 assert_eq!(app.selected_index, 0);
1122 assert!(matches!(cmd, Cmd::None));
1123 }
1124
1125 #[test]
1126 fn scroll_up_decrements_index() {
1127 let mut app = app_with_patchsets(10);
1128 app.selected_index = 5;
1129 let cmd = update(&mut app, Message::ScrollUp);
1130 assert_eq!(app.selected_index, 4);
1131 assert!(matches!(cmd, Cmd::None));
1132 }
1133
1134 #[test]
1135 fn scroll_up_clamps_at_zero() {
1136 let mut app = app_with_patchsets(10);
1137 app.selected_index = 0;
1138 let cmd = update(&mut app, Message::ScrollUp);
1139 assert_eq!(app.selected_index, 0);
1140 assert!(matches!(cmd, Cmd::None));
1141 }
1142
1143 #[test]
1144 fn half_page_down_advances_by_half_height() {
1145 let mut app = app_with_patchsets(50);
1146 app.terminal_height = 24;
1147 app.selected_index = 0;
1148 let cmd = update(&mut app, Message::HalfPageDown);
1149 assert_eq!(app.selected_index, 12); assert!(matches!(cmd, Cmd::None));
1151 }
1152
1153 #[test]
1154 fn half_page_down_clamps_at_last_item() {
1155 let mut app = app_with_patchsets(10);
1156 app.terminal_height = 24;
1157 app.selected_index = 5;
1158 let cmd = update(&mut app, Message::HalfPageDown);
1159 assert_eq!(app.selected_index, 9); assert!(matches!(cmd, Cmd::None));
1161 }
1162
1163 #[test]
1164 fn half_page_up_subtracts_half_height() {
1165 let mut app = app_with_patchsets(50);
1166 app.terminal_height = 24;
1167 app.selected_index = 20;
1168 let cmd = update(&mut app, Message::HalfPageUp);
1169 assert_eq!(app.selected_index, 8); assert!(matches!(cmd, Cmd::None));
1171 }
1172
1173 #[test]
1174 fn half_page_up_clamps_at_zero() {
1175 let mut app = app_with_patchsets(50);
1176 app.terminal_height = 24;
1177 app.selected_index = 3;
1178 let cmd = update(&mut app, Message::HalfPageUp);
1179 assert_eq!(app.selected_index, 0); assert!(matches!(cmd, Cmd::None));
1181 }
1182
1183 #[test]
1184 fn select_returns_fetch_detail() {
1185 let mut app = app_with_patchsets(5);
1186 app.selected_index = 2;
1187 let cmd = update(&mut app, Message::Select);
1188 assert_eq!(app.selected_index, 2); assert!(matches!(cmd, Cmd::FetchPatchsetDetail(_)));
1191 }
1192
1193 #[test]
1194 fn select_empty_list_returns_none() {
1195 let mut app = app_with_patchsets(0);
1196 let cmd = update(&mut app, Message::Select);
1197 assert!(matches!(cmd, Cmd::None));
1198 }
1199
1200 #[test]
1201 fn detail_loaded_stores_detail_and_switches_view() {
1202 let mut app = App::new(Config::default());
1203 let detail = PatchsetDetail::fixture();
1204 let cmd = update(
1205 &mut app,
1206 Message::PatchsetDetailLoaded(Box::new(Ok(detail))),
1207 );
1208 assert!(matches!(cmd, Cmd::None));
1209 assert_eq!(app.view_mode, ViewMode::Detail);
1210 assert!(app.selected_detail.is_some());
1211 assert!(app.error_state.is_none());
1212 }
1213
1214 #[test]
1215 fn detail_loaded_error_sets_error_state() {
1216 let mut app = App::new(Config::default());
1217 let err = ApiError::Network {
1218 source: "timeout".into(),
1219 remote: "test".to_string(),
1220 };
1221 let cmd = update(&mut app, Message::PatchsetDetailLoaded(Box::new(Err(err))));
1222 assert!(matches!(cmd, Cmd::None));
1223 assert!(app.error_state.is_some());
1224 assert!(app.selected_detail.is_none());
1225 }
1226
1227 #[test]
1228 fn resize_stores_terminal_height() {
1229 let mut app = App::new(Config::default());
1230 assert_eq!(app.terminal_height, 0);
1231 let cmd = update(&mut app, Message::Resize(120, 40));
1232 assert_eq!(app.terminal_height, 40);
1233 assert!(matches!(cmd, Cmd::None));
1234 }
1235
1236 fn app_with_remotes(names: &[&str]) -> App {
1237 let mut config = Config::default();
1238 for name in names {
1239 config
1240 .remotes
1241 .push(crate::config::RemoteConfig::fixture(name));
1242 }
1243 App::new(config)
1244 }
1245
1246 #[test]
1247 fn next_mailbox_cycles_forward() {
1248 let mut app = app_with_remotes(&["upstream", "staging", "local"]);
1249 assert_eq!(app.active_remote_index, 0);
1250 assert_eq!(app.active_remote, "upstream");
1251
1252 let cmd = update(&mut app, Message::NextMailbox);
1253 assert_eq!(app.active_remote_index, 1);
1254 assert_eq!(app.active_remote, "staging");
1255 assert!(matches!(cmd, Cmd::Batch(_)));
1256 }
1257
1258 #[test]
1259 fn next_mailbox_wraps_to_first() {
1260 let mut app = app_with_remotes(&["upstream", "staging"]);
1261 app.active_remote_index = 1;
1262 app.active_remote = "staging".to_string();
1263
1264 let cmd = update(&mut app, Message::NextMailbox);
1265 assert_eq!(app.active_remote_index, 0);
1266 assert_eq!(app.active_remote, "upstream");
1267 assert!(matches!(cmd, Cmd::Batch(_)));
1268 }
1269
1270 #[test]
1271 fn prev_mailbox_cycles_backward() {
1272 let mut app = app_with_remotes(&["upstream", "staging", "local"]);
1273 app.active_remote_index = 2;
1274 app.active_remote = "local".to_string();
1275
1276 let cmd = update(&mut app, Message::PrevMailbox);
1277 assert_eq!(app.active_remote_index, 1);
1278 assert_eq!(app.active_remote, "staging");
1279 assert!(matches!(cmd, Cmd::Batch(_)));
1280 }
1281
1282 #[test]
1283 fn prev_mailbox_wraps_to_last() {
1284 let mut app = app_with_remotes(&["upstream", "staging", "local"]);
1285 assert_eq!(app.active_remote_index, 0);
1286
1287 let cmd = update(&mut app, Message::PrevMailbox);
1288 assert_eq!(app.active_remote_index, 2);
1289 assert_eq!(app.active_remote, "local");
1290 assert!(matches!(cmd, Cmd::Batch(_)));
1291 }
1292
1293 #[test]
1294 fn next_mailbox_no_remotes_is_noop() {
1295 let mut app = App::new(Config::default());
1296 let cmd = update(&mut app, Message::NextMailbox);
1297 assert_eq!(app.active_remote_index, 0);
1298 assert!(matches!(cmd, Cmd::None));
1299 }
1300
1301 #[test]
1302 fn prev_mailbox_no_remotes_is_noop() {
1303 let mut app = App::new(Config::default());
1304 let cmd = update(&mut app, Message::PrevMailbox);
1305 assert_eq!(app.active_remote_index, 0);
1306 assert!(matches!(cmd, Cmd::None));
1307 }
1308
1309 #[test]
1310 fn mailbox_switch_clears_patchsets_and_resets_selection() {
1311 let mut app = app_with_remotes(&["upstream", "staging"]);
1312 app.patchsets = Paginated {
1314 items: vec![Patchset::fixture()],
1315 total: 1,
1316 page: 1,
1317 per_page: 50,
1318 };
1319 app.selected_index = 5;
1320 app.error_state = Some("old error".to_string());
1321
1322 update(&mut app, Message::NextMailbox);
1323 assert!(app.patchsets.items.is_empty());
1324 assert_eq!(app.patchsets.total, 0);
1325 assert_eq!(app.selected_index, 0);
1326 assert!(app.error_state.is_none());
1327 }
1328
1329 #[test]
1330 fn mailbox_switch_returns_fetch_batch() {
1331 let mut app = app_with_remotes(&["upstream", "staging"]);
1332 let cmd = update(&mut app, Message::NextMailbox);
1333 match cmd {
1334 Cmd::Batch(cmds) => {
1335 assert_eq!(cmds.len(), 3);
1336 assert!(matches!(cmds[0], Cmd::FetchPatchsets(_)));
1337 assert!(matches!(cmds[1], Cmd::FetchLists));
1338 assert!(matches!(cmds[2], Cmd::FetchStats));
1339 }
1340 other => panic!("expected Cmd::Batch, got {other:?}"),
1341 }
1342 }
1343
1344 #[test]
1345 fn toggle_focus_switches_panels() {
1346 let mut app = App::new(Config::default());
1347 assert_eq!(app.focus, FocusPanel::PatchsetList);
1348
1349 let cmd = update(&mut app, Message::ToggleFocus);
1350 assert_eq!(app.focus, FocusPanel::Sidebar);
1351 assert!(matches!(cmd, Cmd::None));
1352
1353 let cmd = update(&mut app, Message::ToggleFocus);
1354 assert_eq!(app.focus, FocusPanel::PatchsetList);
1355 assert!(matches!(cmd, Cmd::None));
1356 }
1357
1358 #[test]
1359 fn scroll_ignored_when_sidebar_focused() {
1360 let mut app = app_with_patchsets(10);
1361 app.focus = FocusPanel::Sidebar;
1362 app.selected_index = 0;
1363
1364 update(&mut app, Message::ScrollDown);
1365 assert_eq!(app.selected_index, 0); update(&mut app, Message::ScrollUp);
1368 assert_eq!(app.selected_index, 0);
1369
1370 update(&mut app, Message::HalfPageDown);
1371 assert_eq!(app.selected_index, 0);
1372
1373 update(&mut app, Message::HalfPageUp);
1374 assert_eq!(app.selected_index, 0);
1375 }
1376
1377 #[test]
1380 fn detail_scroll_adjusts_offset() {
1381 let mut app = app_with_patchsets(5);
1382 app.view_mode = ViewMode::Detail;
1383 app.terminal_height = 24;
1384 assert_eq!(app.detail_scroll_offset, 0);
1385
1386 update(&mut app, Message::ScrollDown);
1387 assert_eq!(app.detail_scroll_offset, 1);
1388
1389 update(&mut app, Message::ScrollDown);
1390 assert_eq!(app.detail_scroll_offset, 2);
1391
1392 update(&mut app, Message::ScrollUp);
1393 assert_eq!(app.detail_scroll_offset, 1);
1394 }
1395
1396 #[test]
1397 fn detail_half_page_scroll() {
1398 let mut app = app_with_patchsets(5);
1399 app.view_mode = ViewMode::Detail;
1400 app.terminal_height = 24;
1401
1402 update(&mut app, Message::HalfPageDown);
1403 assert_eq!(app.detail_scroll_offset, 12);
1404
1405 update(&mut app, Message::HalfPageUp);
1406 assert_eq!(app.detail_scroll_offset, 0);
1407 }
1408
1409 #[test]
1410 fn detail_scroll_up_clamps_at_zero() {
1411 let mut app = app_with_patchsets(5);
1412 app.view_mode = ViewMode::Detail;
1413
1414 update(&mut app, Message::ScrollUp);
1415 assert_eq!(app.detail_scroll_offset, 0);
1416 }
1417
1418 #[test]
1421 fn sidebar_select_mailing_list_filters() {
1422 let mut app = app_with_remotes(&["upstream"]);
1423 app.mailing_lists = vec![crate::models::MailingList {
1424 name: "LKML".to_string(),
1425 group: Some("org.kernel.vger.linux-kernel".to_string()),
1426 }];
1427 app.focus = FocusPanel::Sidebar;
1428 app.sidebar_section = SidebarSection::MailingLists;
1429 app.sidebar_list_index = 1; let cmd = update(&mut app, Message::Select);
1432 assert_eq!(
1433 app.list_params.mailing_list,
1434 Some("org.kernel.vger.linux-kernel".to_string())
1435 );
1436 assert_eq!(app.list_params.page, 1);
1437 assert!(matches!(cmd, Cmd::FetchPatchsets(_)));
1438 }
1439
1440 #[test]
1441 fn sidebar_select_all_clears_filter() {
1442 let mut app = app_with_remotes(&["upstream"]);
1443 app.mailing_lists = vec![crate::models::MailingList::fixture()];
1444 app.list_params.mailing_list = Some("old-filter".to_string());
1445 app.focus = FocusPanel::Sidebar;
1446 app.sidebar_section = SidebarSection::MailingLists;
1447 app.sidebar_list_index = 0; let cmd = update(&mut app, Message::Select);
1450 assert!(app.list_params.mailing_list.is_none());
1451 assert!(matches!(cmd, Cmd::FetchPatchsets(_)));
1452 }
1453
1454 #[test]
1457 fn search_start_enters_search_mode() {
1458 let mut app = App::new(Config::default());
1459 update(&mut app, Message::SearchStart);
1460 assert_eq!(app.input_mode, InputMode::Search);
1461 }
1462
1463 #[test]
1464 fn search_input_appends_char() {
1465 let mut app = App::new(Config::default());
1466 update(&mut app, Message::SearchStart);
1467 update(&mut app, Message::SearchInput('h'));
1468 update(&mut app, Message::SearchInput('i'));
1469 assert_eq!(app.search_buffer, "hi");
1470 assert_eq!(app.search_cursor, 2);
1471 }
1472
1473 #[test]
1474 fn search_backspace_deletes_char() {
1475 let mut app = App::new(Config::default());
1476 update(&mut app, Message::SearchStart);
1477 update(&mut app, Message::SearchInput('a'));
1478 update(&mut app, Message::SearchInput('b'));
1479 update(&mut app, Message::SearchInput('\x08')); assert_eq!(app.search_buffer, "a");
1481 }
1482
1483 #[test]
1484 fn search_submit_sets_filter_and_fetches() {
1485 let mut app = App::new(Config::default());
1486 app.list_params.page = 5;
1487 update(&mut app, Message::SearchStart);
1488 update(&mut app, Message::SearchInput('f'));
1489 update(&mut app, Message::SearchInput('i'));
1490 update(&mut app, Message::SearchInput('x'));
1491
1492 let cmd = update(&mut app, Message::SearchSubmit);
1493 assert_eq!(app.input_mode, InputMode::Normal);
1494 assert_eq!(app.list_params.search, Some("fix".to_string()));
1495 assert_eq!(app.list_params.page, 1); assert!(matches!(cmd, Cmd::FetchPatchsets(_)));
1497 }
1498
1499 #[test]
1500 fn search_cancel_discards_buffer() {
1501 let mut app = App::new(Config::default());
1502 app.list_params.search = Some("old".to_string());
1503 update(&mut app, Message::SearchStart);
1504 update(&mut app, Message::SearchInput('n'));
1505 update(&mut app, Message::SearchInput('e'));
1506 update(&mut app, Message::SearchInput('w'));
1507
1508 let cmd = update(&mut app, Message::SearchCancel);
1509 assert_eq!(app.input_mode, InputMode::Normal);
1510 assert_eq!(app.list_params.search, Some("old".to_string())); assert!(app.search_buffer.is_empty());
1512 assert!(matches!(cmd, Cmd::None));
1513 }
1514
1515 #[test]
1516 fn search_empty_submit_clears_filter() {
1517 let mut app = App::new(Config::default());
1518 app.list_params.search = Some("old query".to_string());
1519 update(&mut app, Message::SearchStart);
1520 app.search_buffer.clear();
1522 app.search_cursor = 0;
1523
1524 let cmd = update(&mut app, Message::SearchSubmit);
1525 assert!(app.list_params.search.is_none());
1526 assert!(matches!(cmd, Cmd::FetchPatchsets(_)));
1527 }
1528
1529 #[test]
1530 fn back_clears_filter_in_list_view() {
1531 let mut app = App::new(Config::default());
1532 app.view_mode = ViewMode::List;
1533 app.list_params.search = Some("test query".to_string());
1534 app.list_params.page = 3;
1535 app.selected_index = 5;
1536
1537 let cmd = update(&mut app, Message::Back);
1538 assert!(app.list_params.search.is_none());
1539 assert_eq!(app.list_params.page, 1);
1540 assert_eq!(app.selected_index, 0);
1541 assert!(matches!(cmd, Cmd::FetchPatchsets(_)));
1542 }
1543
1544 #[test]
1545 fn back_does_nothing_without_filter() {
1546 let mut app = App::new(Config::default());
1547 app.view_mode = ViewMode::List;
1548 app.list_params.search = None;
1549
1550 let cmd = update(&mut app, Message::Back);
1551 assert!(app.list_params.search.is_none());
1552 assert!(matches!(cmd, Cmd::None));
1553 }
1554
1555 #[test]
1558 fn view_raw_log_without_detail_is_noop() {
1559 let mut app = App::new(Config::default());
1560 let cmd = update(&mut app, Message::ViewRawLog);
1561 assert!(matches!(cmd, Cmd::None));
1562 }
1563
1564 #[test]
1565 fn view_raw_log_with_detail_returns_open_editor() {
1566 let mut app = App::new(Config::default());
1567 app.selected_detail = Some(PatchsetDetail::fixture());
1568 let cmd = update(&mut app, Message::ViewRawLog);
1569 assert!(matches!(cmd, Cmd::OpenEditor { .. }));
1570 }
1571
1572 #[test]
1573 fn view_raw_log_with_no_reviews_is_noop() {
1574 let mut app = App::new(Config::default());
1575 let mut detail = PatchsetDetail::fixture();
1576 detail.reviews.clear();
1577 app.selected_detail = Some(detail);
1578 let cmd = update(&mut app, Message::ViewRawLog);
1579 assert!(matches!(cmd, Cmd::None));
1580 }
1581
1582 #[test]
1585 fn cycle_sort_advances_column() {
1586 let mut app = App::new(Config::default());
1587 assert_eq!(app.sort_column, SortColumn::Default);
1588
1589 update(&mut app, Message::CycleSort);
1590 assert_eq!(app.sort_column, SortColumn::Status);
1591
1592 update(&mut app, Message::CycleSort);
1593 assert_eq!(app.sort_column, SortColumn::Date);
1594
1595 update(&mut app, Message::CycleSort);
1596 assert_eq!(app.sort_column, SortColumn::Findings);
1597
1598 update(&mut app, Message::CycleSort);
1599 assert_eq!(app.sort_column, SortColumn::Author);
1600
1601 update(&mut app, Message::CycleSort);
1602 assert_eq!(app.sort_column, SortColumn::Default);
1603 }
1604
1605 #[test]
1606 fn cycle_sort_resets_direction() {
1607 let mut app = App::new(Config::default());
1608 app.sort_direction = SortDirection::Descending;
1609 update(&mut app, Message::CycleSort);
1610 assert_eq!(app.sort_direction, SortDirection::Ascending);
1611 }
1612
1613 #[test]
1614 fn cycle_sort_resets_selected_index() {
1615 let mut app = App::new(Config::default());
1616 app.selected_index = 5;
1617 update(&mut app, Message::CycleSort);
1618 assert_eq!(app.selected_index, 0);
1619 }
1620
1621 #[test]
1622 fn reverse_sort_toggles_direction() {
1623 let mut app = App::new(Config::default());
1624 assert_eq!(app.sort_direction, SortDirection::Ascending);
1625
1626 update(&mut app, Message::ReverseSort);
1627 assert_eq!(app.sort_direction, SortDirection::Descending);
1628
1629 update(&mut app, Message::ReverseSort);
1630 assert_eq!(app.sort_direction, SortDirection::Ascending);
1631 }
1632
1633 #[test]
1634 fn reverse_sort_keeps_column() {
1635 let mut app = App::new(Config::default());
1636 app.sort_column = SortColumn::Date;
1637 update(&mut app, Message::ReverseSort);
1638 assert_eq!(app.sort_column, SortColumn::Date);
1639 }
1640
1641 #[test]
1642 fn reverse_sort_resets_selected_index() {
1643 let mut app = App::new(Config::default());
1644 app.selected_index = 3;
1645 update(&mut app, Message::ReverseSort);
1646 assert_eq!(app.selected_index, 0);
1647 }
1648
1649 #[test]
1650 fn sort_applied_on_patchsets_loaded() {
1651 let mut app = App::new(Config::default());
1652 app.sort_column = SortColumn::Date;
1653 app.sort_direction = SortDirection::Ascending;
1654
1655 let mut ps1 = Patchset::fixture();
1656 ps1.date = Some(200);
1657 let mut ps2 = Patchset::fixture();
1658 ps2.date = Some(100);
1659
1660 let paginated = Paginated {
1661 items: vec![ps1, ps2],
1662 total: 2,
1663 page: 1,
1664 per_page: 50,
1665 };
1666 update(&mut app, Message::PatchsetsLoaded(Ok(paginated)));
1667 assert_eq!(app.patchsets.items[0].date, Some(100));
1669 assert_eq!(app.patchsets.items[1].date, Some(200));
1670 }
1671
1672 #[test]
1673 fn switch_remote_resets_sort() {
1674 let mut config = Config::default();
1675 config
1676 .remotes
1677 .push(crate::config::RemoteConfig::fixture("r1"));
1678 config
1679 .remotes
1680 .push(crate::config::RemoteConfig::fixture("r2"));
1681 let mut app = App::new(config);
1682 app.sort_column = SortColumn::Status;
1683 app.sort_direction = SortDirection::Descending;
1684 update(&mut app, Message::NextMailbox);
1685 assert_eq!(app.sort_column, SortColumn::Default);
1686 assert_eq!(app.sort_direction, SortDirection::Ascending);
1687 }
1688
1689 #[test]
1690 fn status_sort_key_is_exhaustive_and_ordered() {
1691 use crate::models::PatchsetStatus;
1692 let keys: Vec<u8> = vec![
1693 status_sort_key(PatchsetStatus::FailedToApply),
1694 status_sort_key(PatchsetStatus::Failed),
1695 status_sort_key(PatchsetStatus::Incomplete),
1696 status_sort_key(PatchsetStatus::Pending),
1697 status_sort_key(PatchsetStatus::InReview),
1698 status_sort_key(PatchsetStatus::Cancelled),
1699 status_sort_key(PatchsetStatus::Skipped),
1700 status_sort_key(PatchsetStatus::Reviewed),
1701 status_sort_key(PatchsetStatus::Unknown),
1702 ];
1703 let mut sorted = keys.clone();
1705 sorted.sort_unstable();
1706 sorted.dedup();
1707 assert_eq!(sorted.len(), 9);
1708 for i in 1..keys.len() {
1710 assert!(keys[i] > keys[i - 1]);
1711 }
1712 }
1713
1714 #[test]
1715 fn apply_sort_default_is_noop() {
1716 let mut app = App::new(Config::default());
1717 let mut ps1 = Patchset::fixture();
1718 ps1.id = 1;
1719 ps1.date = Some(200);
1720 let mut ps2 = Patchset::fixture();
1721 ps2.id = 2;
1722 ps2.date = Some(100);
1723 app.patchsets.items = vec![ps1, ps2];
1724 app.sort_column = SortColumn::Default;
1725 apply_sort(&mut app);
1726 assert_eq!(app.patchsets.items[0].id, 1);
1728 assert_eq!(app.patchsets.items[1].id, 2);
1729 }
1730
1731 #[test]
1732 fn sort_by_status_ascending() {
1733 let mut app = App::new(Config::default());
1734 let mut ps1 = Patchset::fixture();
1735 ps1.status = crate::models::PatchsetStatus::Reviewed; let mut ps2 = Patchset::fixture();
1737 ps2.status = crate::models::PatchsetStatus::FailedToApply; app.patchsets.items = vec![ps1, ps2];
1739 app.sort_column = SortColumn::Status;
1740 app.sort_direction = SortDirection::Ascending;
1741 apply_sort(&mut app);
1742 assert_eq!(
1743 app.patchsets.items[0].status,
1744 crate::models::PatchsetStatus::FailedToApply
1745 );
1746 assert_eq!(
1747 app.patchsets.items[1].status,
1748 crate::models::PatchsetStatus::Reviewed
1749 );
1750 }
1751
1752 #[test]
1753 fn sort_by_findings_descending() {
1754 let mut app = App::new(Config::default());
1755 let mut ps1 = Patchset::fixture();
1756 ps1.findings = crate::models::FindingCounts {
1757 low: 1,
1758 medium: 0,
1759 high: 0,
1760 critical: 0,
1761 }; let mut ps2 = Patchset::fixture();
1763 ps2.findings = crate::models::FindingCounts {
1764 low: 5,
1765 medium: 3,
1766 high: 0,
1767 critical: 0,
1768 }; app.patchsets.items = vec![ps1, ps2];
1770 app.sort_column = SortColumn::Findings;
1771 app.sort_direction = SortDirection::Descending;
1772 apply_sort(&mut app);
1773 assert_eq!(app.patchsets.items[0].findings.total(), 8);
1774 assert_eq!(app.patchsets.items[1].findings.total(), 1);
1775 }
1776
1777 #[test]
1778 fn sort_by_author_ascending() {
1779 let mut app = App::new(Config::default());
1780 let mut ps1 = Patchset::fixture();
1781 ps1.author = Some("zebra@example.com".to_string());
1782 let mut ps2 = Patchset::fixture();
1783 ps2.author = Some("alice@example.com".to_string());
1784 app.patchsets.items = vec![ps1, ps2];
1785 app.sort_column = SortColumn::Author;
1786 app.sort_direction = SortDirection::Ascending;
1787 apply_sort(&mut app);
1788 assert_eq!(
1789 app.patchsets.items[0].author.as_deref(),
1790 Some("alice@example.com")
1791 );
1792 assert_eq!(
1793 app.patchsets.items[1].author.as_deref(),
1794 Some("zebra@example.com")
1795 );
1796 }
1797
1798 #[test]
1799 fn sort_by_date_none_sorts_last() {
1800 let mut app = App::new(Config::default());
1801 let mut ps1 = Patchset::fixture();
1802 ps1.id = 1;
1803 ps1.date = None;
1804 let mut ps2 = Patchset::fixture();
1805 ps2.id = 2;
1806 ps2.date = Some(100);
1807 let mut ps3 = Patchset::fixture();
1808 ps3.id = 3;
1809 ps3.date = Some(200);
1810 app.patchsets.items = vec![ps1, ps2, ps3];
1811 app.sort_column = SortColumn::Date;
1812 app.sort_direction = SortDirection::Ascending;
1813 apply_sort(&mut app);
1814 assert_eq!(app.patchsets.items[0].date, Some(100));
1816 assert_eq!(app.patchsets.items[1].date, Some(200));
1817 assert_eq!(app.patchsets.items[2].date, None);
1818 }
1819
1820 #[test]
1821 fn sort_stability_equal_keys() {
1822 let mut app = App::new(Config::default());
1823 let mut ps1 = Patchset::fixture();
1824 ps1.id = 10;
1825 ps1.date = Some(100);
1826 let mut ps2 = Patchset::fixture();
1827 ps2.id = 20;
1828 ps2.date = Some(100); let mut ps3 = Patchset::fixture();
1830 ps3.id = 30;
1831 ps3.date = Some(100); app.patchsets.items = vec![ps1, ps2, ps3];
1833 app.sort_column = SortColumn::Date;
1834 app.sort_direction = SortDirection::Ascending;
1835 apply_sort(&mut app);
1836 assert_eq!(app.patchsets.items[0].id, 10);
1838 assert_eq!(app.patchsets.items[1].id, 20);
1839 assert_eq!(app.patchsets.items[2].id, 30);
1840 }
1841
1842 #[test]
1845 fn bookmark_filter_toggle_on() {
1846 let mut app = App::new(Config::default());
1847 assert!(!app.show_bookmarks_only);
1848 update(&mut app, Message::ToggleBookmarkFilter);
1849 assert!(app.show_bookmarks_only);
1850 }
1851
1852 #[test]
1853 fn bookmark_filter_toggle_off() {
1854 let mut app = App::new(Config::default());
1855 app.show_bookmarks_only = true;
1856 update(&mut app, Message::ToggleBookmarkFilter);
1857 assert!(!app.show_bookmarks_only);
1858 }
1859
1860 #[test]
1861 fn bookmark_filter_resets_selected_index() {
1862 let mut app = App::new(Config::default());
1863 app.selected_index = 4;
1864 update(&mut app, Message::ToggleBookmarkFilter);
1865 assert_eq!(app.selected_index, 0);
1866 }
1867
1868 #[test]
1869 fn bookmark_filter_returns_cmd_none() {
1870 let mut app = App::new(Config::default());
1871 let cmd = update(&mut app, Message::ToggleBookmarkFilter);
1872 assert!(matches!(cmd, Cmd::None));
1873 }
1874
1875 #[test]
1876 fn switch_remote_resets_bookmark_filter() {
1877 let mut config = Config::default();
1878 config
1879 .remotes
1880 .push(crate::config::RemoteConfig::fixture("r1"));
1881 config
1882 .remotes
1883 .push(crate::config::RemoteConfig::fixture("r2"));
1884 let mut app = App::new(config);
1885 app.show_bookmarks_only = true;
1886 update(&mut app, Message::NextMailbox);
1887 assert!(!app.show_bookmarks_only);
1888 }
1889
1890 #[test]
1893 fn next_page_advances_when_more_pages() {
1894 let mut app = App::new(Config::default());
1895 app.patchsets.total = 100;
1896 app.patchsets.per_page = 50;
1897 app.list_params.page = 1;
1898 app.selected_index = 5;
1899 let cmd = update(&mut app, Message::NextPage);
1900 assert_eq!(app.list_params.page, 2);
1901 assert_eq!(app.selected_index, 0);
1902 assert!(matches!(cmd, Cmd::FetchPatchsets(_)));
1903 }
1904
1905 #[test]
1906 fn next_page_noop_on_last_page() {
1907 let mut app = App::new(Config::default());
1908 app.patchsets.total = 50;
1909 app.patchsets.per_page = 50;
1910 app.list_params.page = 1;
1911 let cmd = update(&mut app, Message::NextPage);
1912 assert_eq!(app.list_params.page, 1);
1913 assert!(matches!(cmd, Cmd::None));
1914 }
1915
1916 #[test]
1917 fn prev_page_goes_back() {
1918 let mut app = App::new(Config::default());
1919 app.list_params.page = 3;
1920 app.selected_index = 5;
1921 let cmd = update(&mut app, Message::PrevPage);
1922 assert_eq!(app.list_params.page, 2);
1923 assert_eq!(app.selected_index, 0);
1924 assert!(matches!(cmd, Cmd::FetchPatchsets(_)));
1925 }
1926
1927 #[test]
1928 fn prev_page_noop_on_first_page() {
1929 let mut app = App::new(Config::default());
1930 app.list_params.page = 1;
1931 let cmd = update(&mut app, Message::PrevPage);
1932 assert_eq!(app.list_params.page, 1);
1933 assert!(matches!(cmd, Cmd::None));
1934 }
1935
1936 #[test]
1939 fn comment_nav_noop_without_detail() {
1940 let mut app = App::new(Config::default());
1941 let cmd = update(&mut app, Message::NextComment);
1942 assert!(matches!(cmd, Cmd::None));
1943 }
1944
1945 #[test]
1946 fn comment_nav_forward_from_none() {
1947 let mut app = App::new(Config::default());
1948 app.view_mode = crate::app::ViewMode::Detail;
1949 app.comment_positions = vec![0, 10, 20];
1950 app.detail_scroll_offset = 5;
1951 app.current_comment_index = None;
1952 update(&mut app, Message::NextComment);
1953 assert_eq!(app.current_comment_index, Some(1));
1955 assert_eq!(app.detail_scroll_offset, 10);
1956 }
1957
1958 #[test]
1959 fn comment_nav_backward_from_none() {
1960 let mut app = App::new(Config::default());
1961 app.view_mode = crate::app::ViewMode::Detail;
1962 app.comment_positions = vec![0, 10, 20];
1963 app.detail_scroll_offset = 15;
1964 app.current_comment_index = None;
1965 update(&mut app, Message::PrevComment);
1966 assert_eq!(app.current_comment_index, Some(1));
1968 assert_eq!(app.detail_scroll_offset, 10);
1969 }
1970
1971 #[test]
1972 fn comment_nav_forward_advances() {
1973 let mut app = App::new(Config::default());
1974 app.view_mode = crate::app::ViewMode::Detail;
1975 app.comment_positions = vec![0, 10, 20];
1976 app.current_comment_index = Some(0);
1977 update(&mut app, Message::NextComment);
1978 assert_eq!(app.current_comment_index, Some(1));
1979 assert_eq!(app.detail_scroll_offset, 10);
1980 }
1981
1982 #[test]
1983 fn comment_nav_forward_clamps_at_last() {
1984 let mut app = App::new(Config::default());
1985 app.view_mode = crate::app::ViewMode::Detail;
1986 app.comment_positions = vec![0, 10, 20];
1987 app.current_comment_index = Some(2);
1988 update(&mut app, Message::NextComment);
1989 assert_eq!(app.current_comment_index, Some(2));
1990 assert_eq!(app.detail_scroll_offset, 20);
1991 }
1992
1993 #[test]
1994 fn comment_nav_backward_clamps_at_first() {
1995 let mut app = App::new(Config::default());
1996 app.view_mode = crate::app::ViewMode::Detail;
1997 app.comment_positions = vec![0, 10, 20];
1998 app.current_comment_index = Some(0);
1999 update(&mut app, Message::PrevComment);
2000 assert_eq!(app.current_comment_index, Some(0));
2001 assert_eq!(app.detail_scroll_offset, 0);
2002 }
2003
2004 #[test]
2007 fn sidebar_scroll_down_through_remotes() {
2008 let mut config = Config::default();
2009 config
2010 .remotes
2011 .push(crate::config::RemoteConfig::fixture("r1"));
2012 config
2013 .remotes
2014 .push(crate::config::RemoteConfig::fixture("r2"));
2015 let mut app = App::new(config);
2016 app.focus = crate::app::FocusPanel::Sidebar;
2017 app.active_remote_index = 0;
2018 update(&mut app, Message::ScrollDown);
2019 assert_eq!(app.active_remote_index, 1);
2020 }
2021
2022 #[test]
2023 fn sidebar_scroll_down_transitions_to_mailing_lists() {
2024 let mut config = Config::default();
2025 config
2026 .remotes
2027 .push(crate::config::RemoteConfig::fixture("r1"));
2028 let mut app = App::new(config);
2029 app.focus = crate::app::FocusPanel::Sidebar;
2030 app.active_remote_index = 0;
2031 app.mailing_lists = vec![crate::models::MailingList::fixture()];
2032 update(&mut app, Message::ScrollDown);
2033 assert_eq!(
2034 app.sidebar_section,
2035 crate::app::SidebarSection::MailingLists
2036 );
2037 assert_eq!(app.sidebar_list_index, 0);
2038 }
2039
2040 #[test]
2041 fn sidebar_scroll_up_in_remotes_clamps() {
2042 let mut config = Config::default();
2043 config
2044 .remotes
2045 .push(crate::config::RemoteConfig::fixture("r1"));
2046 let mut app = App::new(config);
2047 app.focus = crate::app::FocusPanel::Sidebar;
2048 app.active_remote_index = 0;
2049 update(&mut app, Message::ScrollUp);
2050 assert_eq!(app.active_remote_index, 0);
2051 }
2052
2053 #[test]
2054 fn sidebar_scroll_down_in_mailing_lists() {
2055 let mut config = Config::default();
2056 config
2057 .remotes
2058 .push(crate::config::RemoteConfig::fixture("r1"));
2059 let mut app = App::new(config);
2060 app.focus = crate::app::FocusPanel::Sidebar;
2061 app.sidebar_section = crate::app::SidebarSection::MailingLists;
2062 app.mailing_lists = vec![crate::models::MailingList::fixture()];
2063 app.sidebar_list_index = 0;
2064 update(&mut app, Message::ScrollDown);
2065 assert_eq!(app.sidebar_list_index, 1);
2066 }
2067
2068 #[test]
2069 fn sidebar_scroll_up_transitions_back_to_remotes() {
2070 let mut config = Config::default();
2071 config
2072 .remotes
2073 .push(crate::config::RemoteConfig::fixture("r1"));
2074 let mut app = App::new(config);
2075 app.focus = crate::app::FocusPanel::Sidebar;
2076 app.sidebar_section = crate::app::SidebarSection::MailingLists;
2077 app.sidebar_list_index = 0;
2078 update(&mut app, Message::ScrollUp);
2079 assert_eq!(app.sidebar_section, crate::app::SidebarSection::Remotes);
2080 }
2081
2082 #[test]
2085 fn back_clears_detail_view() {
2086 let mut app = App::new(Config::default());
2087 app.view_mode = crate::app::ViewMode::Detail;
2088 app.selected_detail = Some(crate::models::PatchsetDetail::fixture());
2089 app.detail_scroll_offset = 10;
2090 app.comment_positions = vec![1, 2, 3];
2091 app.current_comment_index = Some(1);
2092 let cmd = update(&mut app, Message::Back);
2093 assert_eq!(app.view_mode, crate::app::ViewMode::List);
2094 assert!(app.selected_detail.is_none());
2095 assert_eq!(app.detail_scroll_offset, 0);
2096 assert!(app.comment_positions.is_empty());
2097 assert!(app.current_comment_index.is_none());
2098 assert!(matches!(cmd, Cmd::None));
2099 }
2100
2101 #[test]
2102 fn back_closes_help_overlay() {
2103 let mut app = App::new(Config::default());
2104 app.show_help = true;
2105 let cmd = update(&mut app, Message::Back);
2106 assert!(!app.show_help);
2107 assert!(matches!(cmd, Cmd::None));
2108 }
2109
2110 #[test]
2111 fn toggle_help_on_and_off() {
2112 let mut app = App::new(Config::default());
2113 update(&mut app, Message::ToggleHelp);
2114 assert!(app.show_help);
2115 update(&mut app, Message::ToggleHelp);
2116 assert!(!app.show_help);
2117 }
2118
2119 #[test]
2120 fn bookmarks_persisted_error_is_noop() {
2121 let mut app = App::new(Config::default());
2122 let cmd = update(
2123 &mut app,
2124 Message::BookmarksPersisted(Err("test error".to_string())),
2125 );
2126 assert!(matches!(cmd, Cmd::None));
2127 }
2128
2129 #[test]
2130 fn bookmarks_persisted_ok_is_noop() {
2131 let mut app = App::new(Config::default());
2132 let cmd = update(&mut app, Message::BookmarksPersisted(Ok(())));
2133 assert!(matches!(cmd, Cmd::None));
2134 }
2135
2136 #[test]
2139 fn refresh_in_messages_mode_fetches_messages() {
2140 let mut app = App::new(Config::default());
2141 app.list_content = ListContent::Messages;
2142 let cmd = update(&mut app, Message::Refresh);
2143 assert!(matches!(cmd, Cmd::ClearCacheAndBatch(_)));
2144 }
2145
2146 #[test]
2147 fn view_baseline_log_dispatches() {
2148 let mut app = App::new(Config::default());
2149 let cmd = update(&mut app, Message::ViewBaselineLog);
2150 assert!(matches!(cmd, Cmd::None));
2151 }
2152
2153 #[test]
2154 fn toggle_list_content_switches_mode() {
2155 let mut app = App::new(Config::default());
2156 assert_eq!(app.list_content, ListContent::Patchsets);
2157 let cmd = update(&mut app, Message::ToggleListContent);
2158 assert_eq!(app.list_content, ListContent::Messages);
2159 assert!(matches!(cmd, Cmd::FetchMessages(_)));
2160 }
2161
2162 #[test]
2163 fn messages_loaded_stores_messages() {
2164 let mut app = App::new(Config::default());
2165 let paginated = Paginated {
2166 items: vec![],
2167 total: 0,
2168 page: 1,
2169 per_page: 50,
2170 };
2171 let cmd = update(&mut app, Message::MessagesLoaded(Ok(paginated)));
2172 assert!(matches!(cmd, Cmd::None));
2173 }
2174
2175 #[test]
2176 fn messages_loaded_error_sets_error_state() {
2177 let mut app = App::new(Config::default());
2178 let err = crate::client::ApiError::HttpStatus {
2179 status: 500,
2180 body: None,
2181 remote: "test".to_string(),
2182 };
2183 let cmd = update(&mut app, Message::MessagesLoaded(Err(err)));
2184 assert!(matches!(cmd, Cmd::None));
2185 assert!(app.error_state.is_some());
2186 }
2187
2188 #[test]
2189 fn message_detail_loaded_stores_detail() {
2190 let mut app = App::new(Config::default());
2191 app.view_mode = crate::app::ViewMode::Loading;
2192 let msg = crate::models::EmailMessage::fixture();
2193 let cmd = update(&mut app, Message::MessageDetailLoaded(Box::new(Ok(msg))));
2194 assert!(matches!(cmd, Cmd::None));
2195 assert!(app.selected_message.is_some());
2196 }
2197
2198 #[test]
2199 fn bookmark_toggle_in_list_view() {
2200 let mut app = app_with_patchsets(3);
2201 app.selected_index = 0;
2202 let cmd = update(&mut app, Message::BookmarkToggle);
2203 assert!(
2205 matches!(cmd, Cmd::None | Cmd::PersistBookmarks { .. }),
2206 "unexpected cmd: {cmd:?}"
2207 );
2208 }
2209}