rusticity_term/ui/
mod.rs

1pub mod cfn;
2pub mod cw;
3pub mod ecr;
4mod expanded_view;
5pub mod filter;
6pub mod iam;
7pub mod lambda;
8mod pagination;
9pub mod prefs;
10mod query_editor;
11pub mod s3;
12mod status;
13pub mod styles;
14pub mod table;
15
16use styles::highlight;
17
18pub use cw::insights::{DateRangeType, TimeUnit};
19pub use cw::{
20    CloudWatchLogGroupsState, DetailTab, EventColumn, EventFilterFocus, LogGroupColumn,
21    StreamColumn, StreamSort,
22};
23pub use expanded_view::{format_expansion_text, format_fields};
24pub use pagination::{render_paginated_filter, PaginatedFilterConfig};
25pub use prefs::Preferences;
26pub use query_editor::{render_query_editor, QueryEditorConfig};
27pub use status::{hint, hint_last, SPINNER_FRAMES};
28pub use table::{format_expandable, CURSOR_COLLAPSED, CURSOR_EXPANDED};
29
30use crate::app::{AlarmViewMode, App, Service, ViewMode};
31use crate::common::{render_pagination_text, PageSize};
32use crate::keymap::Mode;
33use ratatui::style::{Modifier, Style};
34use ratatui::text::{Line, Span};
35
36pub fn labeled_field(label: &str, value: impl Into<String>) -> Line<'static> {
37    let val = value.into();
38    let display = if val.is_empty() { "-".to_string() } else { val };
39    Line::from(vec![
40        Span::styled(
41            format!("{}: ", label),
42            Style::default().add_modifier(Modifier::BOLD),
43        ),
44        Span::raw(display),
45    ])
46}
47
48pub fn section_header(text: &str, width: u16) -> Line<'static> {
49    let text_len = text.len() as u16;
50    // Format: " Section Name ─────────────────────"
51    // space + text + space + dashes = width
52    let remaining = width.saturating_sub(text_len + 2);
53    let dashes = "─".repeat(remaining as usize);
54    Line::from(vec![
55        Span::raw(" "),
56        Span::raw(text.to_string()),
57        Span::raw(format!(" {}", dashes)),
58    ])
59}
60
61pub fn tab_style(selected: bool) -> Style {
62    if selected {
63        highlight()
64    } else {
65        Style::default()
66    }
67}
68
69pub fn service_tab_style(selected: bool) -> Style {
70    if selected {
71        Style::default().bg(Color::Green).fg(Color::Black)
72    } else {
73        Style::default()
74    }
75}
76
77pub fn render_tab_spans<'a>(tabs: &[(&'a str, bool)]) -> Vec<Span<'a>> {
78    let mut spans = Vec::new();
79    for (i, (name, selected)) in tabs.iter().enumerate() {
80        if i > 0 {
81            spans.push(Span::raw(" ⋮ "));
82        }
83        spans.push(Span::styled(*name, service_tab_style(*selected)));
84    }
85    spans
86}
87
88pub fn render_inner_tab_spans<'a>(tabs: &[(&'a str, bool)]) -> Vec<Span<'a>> {
89    let mut spans = Vec::new();
90    for (i, (name, selected)) in tabs.iter().enumerate() {
91        if i > 0 {
92            spans.push(Span::raw(" ⋮ "));
93        }
94        spans.push(Span::styled(*name, tab_style(*selected)));
95    }
96    spans
97}
98
99use ratatui::{prelude::*, widgets::*};
100
101// Common UI constants
102pub const SEARCH_ICON: &str = " 🔍 ";
103pub const PREFERENCES_TITLE: &str = " Preferences ";
104
105// Common style helpers
106pub fn active_border() -> Style {
107    Style::default().fg(Color::Green)
108}
109
110pub fn bold_style() -> Style {
111    Style::default().add_modifier(Modifier::BOLD)
112}
113
114pub fn cyan_bold() -> Style {
115    Style::default()
116        .fg(Color::Cyan)
117        .add_modifier(Modifier::BOLD)
118}
119
120pub fn red_text() -> Style {
121    Style::default().fg(Color::Rgb(255, 165, 0))
122}
123
124pub fn yellow_text() -> Style {
125    Style::default().fg(Color::Yellow)
126}
127
128pub fn get_cursor(active: bool) -> &'static str {
129    if active {
130        "█"
131    } else {
132        ""
133    }
134}
135
136pub fn render_search_filter(
137    frame: &mut Frame,
138    area: Rect,
139    filter_text: &str,
140    is_active: bool,
141    selected: usize,
142    total_items: usize,
143    page_size: usize,
144) {
145    let cursor = get_cursor(is_active);
146    let total_pages = total_items.div_ceil(page_size);
147    let current_page = selected / page_size;
148    let pagination = render_pagination_text(current_page, total_pages);
149
150    let controls_text = format!(" {}", pagination);
151    let filter_width = (area.width as usize).saturating_sub(4);
152    let content_len = filter_text.len() + if is_active { cursor.len() } else { 0 };
153    let available_space = filter_width.saturating_sub(controls_text.len() + 1);
154
155    let mut spans = vec![];
156    if filter_text.is_empty() && !is_active {
157        spans.push(Span::styled("Search", Style::default().fg(Color::DarkGray)));
158    } else {
159        spans.push(Span::raw(filter_text));
160    }
161    if is_active {
162        spans.push(Span::styled(cursor, Style::default().fg(Color::Yellow)));
163    }
164    if content_len < available_space {
165        spans.push(Span::raw(
166            " ".repeat(available_space.saturating_sub(content_len)),
167        ));
168    }
169    spans.push(Span::styled(
170        controls_text,
171        if is_active {
172            Style::default()
173        } else {
174            Style::default().fg(Color::Green)
175        },
176    ));
177
178    frame.render_widget(
179        Paragraph::new(Line::from(spans)).block(block_with_style(
180            " 🔍 ",
181            if is_active {
182                Style::default().fg(Color::Yellow)
183            } else {
184                Style::default()
185            },
186        )),
187        area,
188    );
189}
190
191fn render_toggle(is_on: bool) -> Vec<Span<'static>> {
192    if is_on {
193        vec![
194            Span::styled("◼", Style::default().fg(Color::Blue)),
195            Span::raw("⬜"),
196        ]
197    } else {
198        vec![
199            Span::raw("⬜"),
200            Span::styled("◼", Style::default().fg(Color::Black)),
201        ]
202    }
203}
204
205fn render_radio(is_selected: bool) -> (String, Style) {
206    if is_selected {
207        ("●".to_string(), Style::default().fg(Color::Blue))
208    } else {
209        ("○".to_string(), Style::default())
210    }
211}
212
213// Common UI constants
214
215// Common style helpers
216pub fn vertical(
217    constraints: impl IntoIterator<Item = Constraint>,
218    area: Rect,
219) -> std::rc::Rc<[Rect]> {
220    Layout::default()
221        .direction(Direction::Vertical)
222        .constraints(constraints)
223        .split(area)
224}
225
226pub fn horizontal(
227    constraints: impl IntoIterator<Item = Constraint>,
228    area: Rect,
229) -> std::rc::Rc<[Rect]> {
230    Layout::default()
231        .direction(Direction::Horizontal)
232        .constraints(constraints)
233        .split(area)
234}
235
236// Block helpers
237pub fn block(title: &str) -> Block<'_> {
238    Block::default().title(title).borders(Borders::ALL)
239}
240
241pub fn block_with_style(title: &str, style: Style) -> Block<'_> {
242    block(title).border_style(style)
243}
244
245// Render a summary section with labeled fields
246pub fn render_summary(frame: &mut Frame, area: Rect, title: &str, fields: &[(&str, String)]) {
247    let summary_block = block(title).border_type(BorderType::Plain);
248    let inner = summary_block.inner(area);
249    frame.render_widget(summary_block, area);
250
251    let lines: Vec<Line> = fields
252        .iter()
253        .map(|(label, value)| {
254            Line::from(vec![
255                Span::styled(*label, Style::default().add_modifier(Modifier::BOLD)),
256                Span::raw(value),
257            ])
258        })
259        .collect();
260
261    frame.render_widget(Paragraph::new(lines), inner);
262}
263
264// Render tabs with selection highlighting
265pub fn render_tabs<T: PartialEq>(frame: &mut Frame, area: Rect, tabs: &[(&str, T)], selected: &T) {
266    let spans: Vec<Span> = tabs
267        .iter()
268        .enumerate()
269        .flat_map(|(i, (name, tab))| {
270            let mut result = Vec::new();
271            if i > 0 {
272                result.push(Span::raw(" ⋮ "));
273            }
274            if tab == selected {
275                result.push(Span::styled(*name, tab_style(true)));
276            } else {
277                result.push(Span::raw(*name));
278            }
279            result
280        })
281        .collect();
282
283    frame.render_widget(Paragraph::new(Line::from(spans)), area);
284}
285
286pub fn format_duration(seconds: u64) -> String {
287    const MINUTE: u64 = 60;
288    const HOUR: u64 = 60 * MINUTE;
289    const DAY: u64 = 24 * HOUR;
290    const WEEK: u64 = 7 * DAY;
291    const YEAR: u64 = 365 * DAY;
292
293    if seconds >= YEAR {
294        let years = seconds / YEAR;
295        let remainder = seconds % YEAR;
296        if remainder == 0 {
297            format!("{} year{}", years, if years == 1 { "" } else { "s" })
298        } else {
299            let weeks = remainder / WEEK;
300            format!(
301                "{} year{} {} week{}",
302                years,
303                if years == 1 { "" } else { "s" },
304                weeks,
305                if weeks == 1 { "" } else { "s" }
306            )
307        }
308    } else if seconds >= WEEK {
309        let weeks = seconds / WEEK;
310        let remainder = seconds % WEEK;
311        if remainder == 0 {
312            format!("{} week{}", weeks, if weeks == 1 { "" } else { "s" })
313        } else {
314            let days = remainder / DAY;
315            format!(
316                "{} week{} {} day{}",
317                weeks,
318                if weeks == 1 { "" } else { "s" },
319                days,
320                if days == 1 { "" } else { "s" }
321            )
322        }
323    } else if seconds >= DAY {
324        let days = seconds / DAY;
325        let remainder = seconds % DAY;
326        if remainder == 0 {
327            format!("{} day{}", days, if days == 1 { "" } else { "s" })
328        } else {
329            let hours = remainder / HOUR;
330            format!(
331                "{} day{} {} hour{}",
332                days,
333                if days == 1 { "" } else { "s" },
334                hours,
335                if hours == 1 { "" } else { "s" }
336            )
337        }
338    } else if seconds >= HOUR {
339        let hours = seconds / HOUR;
340        let remainder = seconds % HOUR;
341        if remainder == 0 {
342            format!("{} hour{}", hours, if hours == 1 { "" } else { "s" })
343        } else {
344            let minutes = remainder / MINUTE;
345            format!(
346                "{} hour{} {} minute{}",
347                hours,
348                if hours == 1 { "" } else { "s" },
349                minutes,
350                if minutes == 1 { "" } else { "s" }
351            )
352        }
353    } else if seconds >= MINUTE {
354        let minutes = seconds / MINUTE;
355        format!("{} minute{}", minutes, if minutes == 1 { "" } else { "s" })
356    } else {
357        format!("{} second{}", seconds, if seconds == 1 { "" } else { "s" })
358    }
359}
360
361// Dummy type for wrap lines option to work with ColumnTrait
362struct WrapLinesOption;
363
364impl crate::common::ColumnTrait for WrapLinesOption {
365    fn name(&self) -> &'static str {
366        "Wrap lines"
367    }
368}
369
370// Helper to render a column toggle item
371fn render_column_toggle_item<T>(
372    col: &T,
373    is_visible: bool,
374    _indent: bool,
375) -> (ListItem<'static>, usize)
376where
377    T: crate::common::ColumnTrait,
378{
379    let spans = vec![];
380    let mut spans = spans;
381    spans.extend(render_toggle(is_visible));
382    spans.push(Span::raw(format!(" {}", col.name())));
383    let text_len = 2 + 1 + col.name().len();
384    (ListItem::new(Line::from(spans)), text_len)
385}
386
387// Helper to render a section header
388fn render_section_header(title: &str) -> (ListItem<'static>, usize) {
389    let len = title.len();
390    (
391        ListItem::new(Line::from(Span::styled(
392            title.to_string(),
393            Style::default()
394                .fg(Color::Cyan)
395                .add_modifier(Modifier::BOLD),
396        ))),
397        len,
398    )
399}
400
401// Helper to render a radio button item
402fn render_radio_item(label: &str, is_selected: bool, indent: bool) -> (ListItem<'static>, usize) {
403    let (radio, style) = render_radio(is_selected);
404    let text_len = (if indent { 2 } else { 0 }) + radio.chars().count() + 1 + label.len();
405    let mut spans = if indent {
406        vec![Span::raw("  ")]
407    } else {
408        vec![]
409    };
410    spans.push(Span::styled(radio, style));
411    spans.push(Span::raw(format!(" {}", label)));
412    (ListItem::new(Line::from(spans)), text_len)
413}
414
415// Helper to render page size options
416fn render_page_size_section(
417    current_size: PageSize,
418    sizes: &[(PageSize, &str)],
419) -> (Vec<ListItem<'static>>, usize) {
420    let mut items = Vec::new();
421    let mut max_len = 0;
422
423    let (header, header_len) = render_section_header("Page size");
424    items.push(header);
425    max_len = max_len.max(header_len);
426
427    for (size, label) in sizes {
428        let is_selected = current_size == *size;
429        let (item, len) = render_radio_item(label, is_selected, false);
430        items.push(item);
431        max_len = max_len.max(len);
432    }
433
434    (items, max_len)
435}
436
437pub fn render(frame: &mut Frame, app: &App) {
438    let area = frame.area();
439
440    // Always show tabs row (with profile info), optionally show top bar for breadcrumbs
441    let has_tabs = !app.tabs.is_empty();
442    let show_breadcrumbs = has_tabs && app.service_selected && {
443        // Only show breadcrumbs if we're deeper than the root level
444        match app.current_service {
445            Service::CloudWatchLogGroups => app.view_mode != ViewMode::List,
446            Service::S3Buckets => app.s3_state.current_bucket.is_some(),
447            _ => false,
448        }
449    };
450
451    let chunks = if show_breadcrumbs {
452        Layout::default()
453            .direction(Direction::Vertical)
454            .constraints([
455                Constraint::Length(2), // Tabs row (profile info + tabs)
456                Constraint::Length(1), // Top bar (breadcrumbs)
457                Constraint::Min(0),    // Content
458                Constraint::Length(1), // Bottom bar
459            ])
460            .split(area)
461    } else {
462        Layout::default()
463            .direction(Direction::Vertical)
464            .constraints([
465                Constraint::Length(2), // Tabs row (profile info + tabs)
466                Constraint::Min(0),    // Content
467                Constraint::Length(1), // Bottom bar
468            ])
469            .split(area)
470    };
471
472    // Always render tabs row (shows profile info)
473    render_tabs_row(frame, app, chunks[0]);
474
475    if show_breadcrumbs {
476        render_top_bar(frame, app, chunks[1]);
477    }
478
479    let content_idx = if show_breadcrumbs { 2 } else { 1 };
480    let bottom_idx = if show_breadcrumbs { 3 } else { 2 };
481
482    if !app.service_selected && app.tabs.is_empty() && app.mode == Mode::Normal {
483        // Empty screen with message
484        let message = vec![
485            Line::from(""),
486            Line::from(""),
487            Line::from(vec![
488                Span::raw("Press "),
489                Span::styled("␣", Style::default().fg(Color::Red)),
490                Span::raw(" to open Menu"),
491            ]),
492        ];
493        let paragraph = Paragraph::new(message).alignment(Alignment::Center);
494        frame.render_widget(paragraph, chunks[content_idx]);
495        render_bottom_bar(frame, app, chunks[bottom_idx]);
496    } else if !app.service_selected && app.mode != Mode::SpaceMenu {
497        render_service_picker(frame, app, chunks[content_idx]);
498        render_bottom_bar(frame, app, chunks[bottom_idx]);
499    } else if app.service_selected {
500        render_service(frame, app, chunks[content_idx]);
501        render_bottom_bar(frame, app, chunks[bottom_idx]);
502    } else {
503        // SpaceMenu with no service selected - just render bottom bar
504        render_bottom_bar(frame, app, chunks[bottom_idx]);
505    }
506
507    // Render modals on top
508    match app.mode {
509        Mode::SpaceMenu => render_space_menu(frame, area),
510        Mode::ServicePicker => render_service_picker(frame, app, area),
511        Mode::ColumnSelector => render_column_selector(frame, app, area),
512        Mode::ErrorModal => render_error_modal(frame, app, area),
513        Mode::HelpModal => render_help_modal(frame, area),
514        Mode::RegionPicker => render_region_selector(frame, app, area),
515        Mode::ProfilePicker => render_profile_picker(frame, app, area),
516        Mode::CalendarPicker => render_calendar_picker(frame, app, area),
517        Mode::TabPicker => render_tab_picker(frame, app, area),
518        Mode::SessionPicker => render_session_picker(frame, app, area),
519        _ => {}
520    }
521}
522
523fn render_tabs_row(frame: &mut Frame, app: &App, area: Rect) {
524    // Split into 2 lines: profile info on top, tabs below
525    let chunks = Layout::default()
526        .direction(Direction::Vertical)
527        .constraints([Constraint::Length(1), Constraint::Length(1)])
528        .split(area);
529
530    // Profile info line (highlighted)
531    let now = chrono::Utc::now();
532    let timestamp = now.format("%Y-%m-%d %H:%M:%S").to_string();
533
534    let (identity_label, identity_value) = if app.config.role_arn.is_empty() {
535        ("Identity:", "N/A".to_string())
536    } else if let Some(role_part) = app.config.role_arn.split("assumed-role/").nth(1) {
537        (
538            "Role:",
539            role_part.split('/').next().unwrap_or("N/A").to_string(),
540        )
541    } else if let Some(user_part) = app.config.role_arn.split(":user/").nth(1) {
542        ("User:", user_part.to_string())
543    } else {
544        ("Identity:", "N/A".to_string())
545    };
546
547    let region_display = if app.config.region_auto_detected {
548        format!(" {} ⚡ ⋮ ", app.config.region)
549    } else {
550        format!(" {} ⋮ ", app.config.region)
551    };
552
553    let info_spans = vec![
554        Span::styled(
555            "Profile:",
556            Style::default()
557                .fg(Color::White)
558                .add_modifier(Modifier::BOLD),
559        ),
560        Span::styled(
561            format!(" {} ⋮ ", app.profile),
562            Style::default().fg(Color::White),
563        ),
564        Span::styled(
565            "Account:",
566            Style::default()
567                .fg(Color::White)
568                .add_modifier(Modifier::BOLD),
569        ),
570        Span::styled(
571            format!(" {} ⋮ ", app.config.account_id),
572            Style::default().fg(Color::White),
573        ),
574        Span::styled(
575            "Region:",
576            Style::default()
577                .fg(Color::White)
578                .add_modifier(Modifier::BOLD),
579        ),
580        Span::styled(region_display, Style::default().fg(Color::White)),
581        Span::styled(
582            identity_label,
583            Style::default()
584                .fg(Color::White)
585                .add_modifier(Modifier::BOLD),
586        ),
587        Span::styled(
588            format!(" {} ⋮ ", identity_value),
589            Style::default().fg(Color::White),
590        ),
591        Span::styled(
592            "Timestamp:",
593            Style::default()
594                .fg(Color::White)
595                .add_modifier(Modifier::BOLD),
596        ),
597        Span::styled(
598            format!(" {} (UTC)", timestamp),
599            Style::default().fg(Color::White),
600        ),
601    ];
602
603    let info_widget = Paragraph::new(Line::from(info_spans))
604        .alignment(Alignment::Right)
605        .style(Style::default().bg(Color::DarkGray).fg(Color::White));
606    frame.render_widget(info_widget, chunks[0]);
607
608    // Tabs line
609    let tab_data: Vec<(&str, bool)> = app
610        .tabs
611        .iter()
612        .enumerate()
613        .map(|(i, tab)| (tab.title.as_str(), i == app.current_tab))
614        .collect();
615    let spans = render_tab_spans(&tab_data);
616
617    let tabs_widget = Paragraph::new(Line::from(spans));
618    frame.render_widget(tabs_widget, chunks[1]);
619}
620
621fn render_top_bar(frame: &mut Frame, app: &App, area: Rect) {
622    let breadcrumbs_str = app.breadcrumbs();
623
624    // For S3 with prefix, highlight the last part (prefix)
625    let breadcrumb_line = if app.current_service == Service::S3Buckets
626        && app.s3_state.current_bucket.is_some()
627        && !app.s3_state.prefix_stack.is_empty()
628    {
629        let parts: Vec<&str> = breadcrumbs_str.split(" > ").collect();
630        let mut spans = Vec::new();
631        for (i, part) in parts.iter().enumerate() {
632            if i > 0 {
633                spans.push(Span::raw(" > "));
634            }
635            if i == parts.len() - 1 {
636                // Last part (prefix) - highlight in cyan
637                spans.push(Span::styled(
638                    *part,
639                    Style::default()
640                        .fg(Color::Cyan)
641                        .add_modifier(Modifier::BOLD),
642                ));
643            } else {
644                spans.push(Span::raw(*part));
645            }
646        }
647        Line::from(spans)
648    } else {
649        Line::from(breadcrumbs_str)
650    };
651
652    let breadcrumb_widget =
653        Paragraph::new(breadcrumb_line).style(Style::default().fg(Color::White));
654
655    frame.render_widget(breadcrumb_widget, area);
656}
657fn render_bottom_bar(frame: &mut Frame, app: &App, area: Rect) {
658    status::render_bottom_bar(frame, app, area);
659}
660
661fn render_service(frame: &mut Frame, app: &App, area: Rect) {
662    match app.current_service {
663        Service::CloudWatchLogGroups => {
664            if app.view_mode == ViewMode::Events {
665                cw::logs::render_events(frame, app, area);
666            } else if app.view_mode == ViewMode::Detail {
667                cw::logs::render_group_detail(frame, app, area);
668            } else {
669                cw::logs::render_groups_list(frame, app, area);
670            }
671        }
672        Service::CloudWatchInsights => cw::render_insights(frame, app, area),
673        Service::CloudWatchAlarms => cw::render_alarms(frame, app, area),
674        Service::EcrRepositories => ecr::render_repositories(frame, app, area),
675        Service::LambdaFunctions => lambda::render_functions(frame, app, area),
676        Service::LambdaApplications => lambda::render_applications(frame, app, area),
677        Service::S3Buckets => s3::render_buckets(frame, app, area),
678        Service::CloudFormationStacks => cfn::render_stacks(frame, app, area),
679        Service::IamUsers => iam::render_users(frame, app, area),
680        Service::IamRoles => iam::render_roles(frame, app, area),
681        Service::IamUserGroups => iam::render_user_groups(frame, app, area),
682    }
683}
684
685fn render_column_selector(frame: &mut Frame, app: &App, area: Rect) {
686    let (items, title, max_text_len) =
687        if app.current_service == Service::S3Buckets && app.s3_state.current_bucket.is_none() {
688            let mut max_len = 0;
689            let items: Vec<ListItem> = app
690                .all_bucket_columns
691                .iter()
692                .map(|col| {
693                    let is_visible = app.visible_bucket_columns.contains(col);
694                    let (item, len) = render_column_toggle_item(col, is_visible, false);
695                    max_len = max_len.max(len);
696                    item
697                })
698                .collect();
699            (items, " Preferences ", max_len)
700        } else if app.current_service == Service::CloudWatchAlarms {
701            let mut all_items: Vec<ListItem> = Vec::new();
702            let mut max_len = 0;
703
704            // Columns section
705            let (header, header_len) = render_section_header("Columns");
706            all_items.push(header);
707            max_len = max_len.max(header_len);
708
709            for col in &app.all_alarm_columns {
710                let is_visible = app.visible_alarm_columns.contains(col);
711                let (item, len) = render_column_toggle_item(col, is_visible, true);
712                all_items.push(item);
713                max_len = max_len.max(len);
714            }
715
716            // View As section
717            all_items.push(ListItem::new(""));
718            let (header, header_len) = render_section_header("View as");
719            all_items.push(header);
720            max_len = max_len.max(header_len);
721
722            let (item, len) = render_radio_item(
723                "Table",
724                app.alarms_state.view_as == AlarmViewMode::Table,
725                true,
726            );
727            all_items.push(item);
728            max_len = max_len.max(len);
729
730            let (item, len) = render_radio_item(
731                "Cards",
732                app.alarms_state.view_as == AlarmViewMode::Cards,
733                true,
734            );
735            all_items.push(item);
736            max_len = max_len.max(len);
737
738            // Page Size section
739            all_items.push(ListItem::new(""));
740            let (page_items, page_len) = render_page_size_section(
741                app.alarms_state.table.page_size,
742                &[
743                    (PageSize::Ten, "10"),
744                    (PageSize::TwentyFive, "25"),
745                    (PageSize::Fifty, "50"),
746                    (PageSize::OneHundred, "100"),
747                ],
748            );
749            all_items.extend(page_items);
750            max_len = max_len.max(page_len);
751
752            // Wrap Lines section
753            all_items.push(ListItem::new(""));
754            let (header, header_len) = render_section_header("Wrap lines");
755            all_items.push(header);
756            max_len = max_len.max(header_len);
757
758            let (item, len) =
759                render_column_toggle_item(&WrapLinesOption, app.alarms_state.wrap_lines, true);
760            all_items.push(item);
761            max_len = max_len.max(len);
762
763            (all_items, " Preferences ", max_len)
764        } else if app.view_mode == ViewMode::Events {
765            let mut max_len = 0;
766            let items: Vec<ListItem> = app
767                .all_event_columns
768                .iter()
769                .map(|col| {
770                    let is_visible = app.visible_event_columns.contains(col);
771                    let (item, len) = render_column_toggle_item(col, is_visible, false);
772                    max_len = max_len.max(len);
773                    item
774                })
775                .collect();
776            (items, " Select visible columns (Space to toggle) ", max_len)
777        } else if app.view_mode == ViewMode::Detail {
778            let mut max_len = 0;
779            let items: Vec<ListItem> = app
780                .all_stream_columns
781                .iter()
782                .map(|col| {
783                    let is_visible = app.visible_stream_columns.contains(col);
784                    let (item, len) = render_column_toggle_item(col, is_visible, false);
785                    max_len = max_len.max(len);
786                    item
787                })
788                .collect();
789            (items, " Preferences ", max_len)
790        } else if app.current_service == Service::CloudWatchLogGroups {
791            let mut max_len = 0;
792            let items: Vec<ListItem> = app
793                .all_columns
794                .iter()
795                .map(|col| {
796                    let is_visible = app.visible_columns.contains(col);
797                    let (item, len) = render_column_toggle_item(col, is_visible, false);
798                    max_len = max_len.max(len);
799                    item
800                })
801                .collect();
802            (items, " Preferences ", max_len)
803        } else if app.current_service == Service::EcrRepositories {
804            let mut max_len = 0;
805            let items: Vec<ListItem> = if app.ecr_state.current_repository.is_some() {
806                // ECR images columns
807                app.all_ecr_image_columns
808                    .iter()
809                    .map(|col| {
810                        let is_visible = app.visible_ecr_image_columns.contains(col);
811                        let (item, len) = render_column_toggle_item(col, is_visible, false);
812                        max_len = max_len.max(len);
813                        item
814                    })
815                    .collect()
816            } else {
817                // ECR repository columns
818                app.all_ecr_columns
819                    .iter()
820                    .map(|col| {
821                        let is_visible = app.visible_ecr_columns.contains(col);
822                        let (item, len) = render_column_toggle_item(col, is_visible, false);
823                        max_len = max_len.max(len);
824                        item
825                    })
826                    .collect()
827            };
828            (items, " Preferences ", max_len)
829        } else if app.current_service == Service::LambdaFunctions {
830            let mut all_items: Vec<ListItem> = Vec::new();
831            let mut max_len = 0;
832
833            let (header, header_len) = render_section_header("Columns");
834            all_items.push(header);
835            max_len = max_len.max(header_len);
836
837            // Show appropriate columns based on current tab
838            if app.lambda_state.current_function.is_some()
839                && app.lambda_state.detail_tab == crate::app::LambdaDetailTab::Code
840            {
841                // Show layer columns for Code tab
842                for col in &app.lambda_state.all_layer_columns {
843                    let is_visible = app.lambda_state.visible_layer_columns.contains(col);
844                    let (item, len) = render_column_toggle_item(col, is_visible, true);
845                    all_items.push(item);
846                    max_len = max_len.max(len);
847                }
848            } else if app.lambda_state.detail_tab == crate::app::LambdaDetailTab::Versions {
849                for col in &app.lambda_state.all_version_columns {
850                    let is_visible = app.lambda_state.visible_version_columns.contains(col);
851                    let (item, len) = render_column_toggle_item(col, is_visible, true);
852                    all_items.push(item);
853                    max_len = max_len.max(len);
854                }
855            } else if app.lambda_state.detail_tab == crate::app::LambdaDetailTab::Aliases {
856                for col in &app.lambda_state.all_alias_columns {
857                    let is_visible = app.lambda_state.visible_alias_columns.contains(col);
858                    let (item, len) = render_column_toggle_item(col, is_visible, true);
859                    all_items.push(item);
860                    max_len = max_len.max(len);
861                }
862            } else {
863                for col in &app.lambda_state.all_columns {
864                    let is_visible = app.lambda_state.visible_columns.contains(col);
865                    let (item, len) = render_column_toggle_item(col, is_visible, true);
866                    all_items.push(item);
867                    max_len = max_len.max(len);
868                }
869            }
870
871            all_items.push(ListItem::new(""));
872
873            let (page_items, page_len) = render_page_size_section(
874                if app.lambda_state.detail_tab == crate::app::LambdaDetailTab::Versions {
875                    app.lambda_state.version_table.page_size
876                } else {
877                    app.lambda_state.table.page_size
878                },
879                &[
880                    (PageSize::Ten, "10"),
881                    (PageSize::TwentyFive, "25"),
882                    (PageSize::Fifty, "50"),
883                    (PageSize::OneHundred, "100"),
884                ],
885            );
886            all_items.extend(page_items);
887            max_len = max_len.max(page_len);
888
889            (all_items, " Preferences ", max_len)
890        } else if app.current_service == Service::LambdaApplications {
891            let mut all_items: Vec<ListItem> = Vec::new();
892            let mut max_len = 0;
893
894            let (header, header_len) = render_section_header("Columns");
895            all_items.push(header);
896            max_len = max_len.max(header_len);
897
898            // Show different columns based on current view
899            if app.lambda_application_state.current_application.is_some() {
900                use crate::ui::lambda::ApplicationDetailTab;
901                if app.lambda_application_state.detail_tab == ApplicationDetailTab::Overview {
902                    // Resources columns
903                    for col in &app.all_resource_columns {
904                        let is_visible = app.visible_resource_columns.contains(col);
905                        let (item, len) = render_column_toggle_item(col, is_visible, true);
906                        all_items.push(item);
907                        max_len = max_len.max(len);
908                    }
909
910                    all_items.push(ListItem::new(""));
911                    let (page_items, page_len) = render_page_size_section(
912                        app.lambda_application_state.resources.page_size,
913                        &[
914                            (PageSize::Ten, "10"),
915                            (PageSize::TwentyFive, "25"),
916                            (PageSize::Fifty, "50"),
917                        ],
918                    );
919                    all_items.extend(page_items);
920                    max_len = max_len.max(page_len);
921                } else {
922                    // Deployments columns
923                    for col in &app.all_deployment_columns {
924                        let is_visible = app.visible_deployment_columns.contains(col);
925                        let (item, len) = render_column_toggle_item(col, is_visible, true);
926                        all_items.push(item);
927                        max_len = max_len.max(len);
928                    }
929
930                    all_items.push(ListItem::new(""));
931                    let (page_items, page_len) = render_page_size_section(
932                        app.lambda_application_state.deployments.page_size,
933                        &[
934                            (PageSize::Ten, "10"),
935                            (PageSize::TwentyFive, "25"),
936                            (PageSize::Fifty, "50"),
937                        ],
938                    );
939                    all_items.extend(page_items);
940                    max_len = max_len.max(page_len);
941                }
942            } else {
943                // Application list columns
944                for col in &app.all_lambda_application_columns {
945                    let is_visible = app.visible_lambda_application_columns.contains(col);
946                    let (item, len) = render_column_toggle_item(col, is_visible, true);
947                    all_items.push(item);
948                    max_len = max_len.max(len);
949                }
950
951                all_items.push(ListItem::new(""));
952                let (page_items, page_len) = render_page_size_section(
953                    app.lambda_application_state.table.page_size,
954                    &[
955                        (PageSize::Ten, "10"),
956                        (PageSize::TwentyFive, "25"),
957                        (PageSize::Fifty, "50"),
958                    ],
959                );
960                all_items.extend(page_items);
961                max_len = max_len.max(page_len);
962            }
963
964            (all_items, " Preferences ", max_len)
965        } else if app.current_service == Service::CloudFormationStacks {
966            let mut all_items: Vec<ListItem> = Vec::new();
967            let mut max_len = 0;
968
969            let (header, header_len) = render_section_header("Columns");
970            all_items.push(header);
971            max_len = max_len.max(header_len);
972
973            for col in &app.all_cfn_columns {
974                let is_visible = app.visible_cfn_columns.contains(col);
975                let (item, len) = render_column_toggle_item(col, is_visible, true);
976                all_items.push(item);
977                max_len = max_len.max(len);
978            }
979
980            all_items.push(ListItem::new(""));
981            let (page_items, page_len) = render_page_size_section(
982                app.cfn_state.table.page_size,
983                &[
984                    (PageSize::Ten, "10"),
985                    (PageSize::TwentyFive, "25"),
986                    (PageSize::Fifty, "50"),
987                    (PageSize::OneHundred, "100"),
988                ],
989            );
990            all_items.extend(page_items);
991            max_len = max_len.max(page_len);
992
993            (all_items, " Preferences ", max_len)
994        } else if app.current_service == Service::IamUsers {
995            let mut all_items: Vec<ListItem> = Vec::new();
996            let mut max_len = 0;
997
998            // Show policy columns only for Permissions tab in user detail view
999            if app.iam_state.current_user.is_some()
1000                && app.iam_state.user_tab == crate::ui::iam::UserTab::Permissions
1001            {
1002                let (header, header_len) = render_section_header("Columns");
1003                all_items.push(header);
1004                max_len = max_len.max(header_len);
1005
1006                for col in &app.all_policy_columns {
1007                    let is_visible = app.visible_policy_columns.contains(col);
1008                    let mut spans = vec![];
1009                    spans.extend(render_toggle(is_visible));
1010                    spans.push(Span::raw(" "));
1011                    spans.push(Span::raw(col.clone()));
1012                    let text_len = 4 + col.len();
1013                    all_items.push(ListItem::new(Line::from(spans)));
1014                    max_len = max_len.max(text_len);
1015                }
1016
1017                all_items.push(ListItem::new(""));
1018                let (page_items, page_len) = render_page_size_section(
1019                    app.iam_state.policies.page_size,
1020                    &[
1021                        (PageSize::Ten, "10"),
1022                        (PageSize::TwentyFive, "25"),
1023                        (PageSize::Fifty, "50"),
1024                    ],
1025                );
1026                all_items.extend(page_items);
1027                max_len = max_len.max(page_len);
1028            } else if app.iam_state.current_user.is_none() {
1029                let (header, header_len) = render_section_header("Columns");
1030                all_items.push(header);
1031                max_len = max_len.max(header_len);
1032
1033                for col in &app.all_iam_columns {
1034                    let is_visible = app.visible_iam_columns.contains(col);
1035                    let mut spans = vec![];
1036                    spans.extend(render_toggle(is_visible));
1037                    spans.push(Span::raw(" "));
1038                    spans.push(Span::raw(col.clone()));
1039                    let text_len = 4 + col.len();
1040                    all_items.push(ListItem::new(Line::from(spans)));
1041                    max_len = max_len.max(text_len);
1042                }
1043
1044                all_items.push(ListItem::new(""));
1045                let (page_items, page_len) = render_page_size_section(
1046                    app.iam_state.users.page_size,
1047                    &[
1048                        (PageSize::Ten, "10"),
1049                        (PageSize::TwentyFive, "25"),
1050                        (PageSize::Fifty, "50"),
1051                    ],
1052                );
1053                all_items.extend(page_items);
1054                max_len = max_len.max(page_len);
1055            }
1056
1057            (all_items, " Preferences ", max_len)
1058        } else if app.current_service == Service::IamRoles {
1059            let mut all_items: Vec<ListItem> = Vec::new();
1060            let mut max_len = 0;
1061
1062            let (header, header_len) = render_section_header("Columns");
1063            all_items.push(header);
1064            max_len = max_len.max(header_len);
1065
1066            for col in &app.all_role_columns {
1067                let is_visible = app.visible_role_columns.contains(col);
1068                let mut spans = vec![];
1069                spans.extend(render_toggle(is_visible));
1070                spans.push(Span::raw(" "));
1071                spans.push(Span::raw(col.clone()));
1072                let text_len = 4 + col.len();
1073                all_items.push(ListItem::new(Line::from(spans)));
1074                max_len = max_len.max(text_len);
1075            }
1076
1077            all_items.push(ListItem::new(""));
1078            let (page_items, page_len) = render_page_size_section(
1079                app.iam_state.roles.page_size,
1080                &[
1081                    (PageSize::Ten, "10"),
1082                    (PageSize::TwentyFive, "25"),
1083                    (PageSize::Fifty, "50"),
1084                ],
1085            );
1086            all_items.extend(page_items);
1087            max_len = max_len.max(page_len);
1088
1089            (all_items, " Preferences ", max_len)
1090        } else if app.current_service == Service::IamUserGroups {
1091            let mut all_items: Vec<ListItem> = Vec::new();
1092            let mut max_len = 0;
1093
1094            let (header, header_len) = render_section_header("Columns");
1095            all_items.push(header);
1096            max_len = max_len.max(header_len);
1097
1098            for col in &app.all_group_columns {
1099                let is_visible = app.visible_group_columns.contains(col);
1100                let mut spans = vec![];
1101                spans.extend(render_toggle(is_visible));
1102                spans.push(Span::raw(" "));
1103                spans.push(Span::raw(col.clone()));
1104                let text_len = 4 + col.len();
1105                all_items.push(ListItem::new(Line::from(spans)));
1106                max_len = max_len.max(text_len);
1107            }
1108
1109            all_items.push(ListItem::new(""));
1110            let (page_items, page_len) = render_page_size_section(
1111                app.iam_state.groups.page_size,
1112                &[
1113                    (PageSize::Ten, "10"),
1114                    (PageSize::TwentyFive, "25"),
1115                    (PageSize::Fifty, "50"),
1116                ],
1117            );
1118            all_items.extend(page_items);
1119            max_len = max_len.max(page_len);
1120
1121            (all_items, " Preferences ", max_len)
1122        } else {
1123            // Fallback for unknown services
1124            (vec![], " Preferences ", 0)
1125        };
1126
1127    // Calculate popup size based on content
1128    let item_count = items.len();
1129
1130    // Width: based on content + padding
1131    let width = (max_text_len + 10).clamp(30, 100) as u16; // +10 for padding, min 30, max 100
1132
1133    // Height: fit all items if possible, otherwise use max available and show scrollbar
1134    let height = (item_count as u16 + 2).max(8); // +2 for borders, min 8
1135    let max_height = area.height.saturating_sub(4);
1136    let actual_height = height.min(max_height);
1137    let popup_area = centered_rect_absolute(width, actual_height, area);
1138
1139    // Check if scrollbar is needed
1140    let needs_scrollbar = height > max_height;
1141
1142    // Preferences should always have green border (active state)
1143    let border_color = Color::Green;
1144
1145    let list = List::new(items)
1146        .block(
1147            Block::default()
1148                .title(title)
1149                .borders(Borders::ALL)
1150                .border_style(Style::default().fg(border_color)),
1151        )
1152        .highlight_style(Style::default().bg(Color::DarkGray))
1153        .highlight_symbol("► ");
1154
1155    let mut state = ListState::default();
1156    state.select(Some(app.column_selector_index));
1157
1158    frame.render_widget(Clear, popup_area);
1159    frame.render_stateful_widget(list, popup_area, &mut state);
1160
1161    // Render scrollbar only if content doesn't fit
1162    if needs_scrollbar {
1163        crate::common::render_scrollbar(
1164            frame,
1165            popup_area.inner(Margin {
1166                vertical: 1,
1167                horizontal: 0,
1168            }),
1169            item_count,
1170            app.column_selector_index,
1171        );
1172    }
1173}
1174
1175fn render_error_modal(frame: &mut Frame, app: &App, area: Rect) {
1176    let popup_area = centered_rect(70, 40, area);
1177
1178    let error_text = app.error_message.as_deref().unwrap_or("Unknown error");
1179
1180    let lines = vec![
1181        Line::from(""),
1182        Line::from(vec![Span::styled(
1183            "AWS Error",
1184            crate::ui::red_text().add_modifier(Modifier::BOLD),
1185        )]),
1186        Line::from(""),
1187        Line::from(error_text),
1188        Line::from(""),
1189        Line::from("This may be due to:"),
1190        Line::from("  • Expired AWS credentials/token"),
1191        Line::from("  • Invalid AWS profile configuration"),
1192        Line::from("  • Network connectivity issues"),
1193        Line::from(""),
1194        Line::from(""),
1195        Line::from(vec![
1196            Span::styled("Press ", Style::default()),
1197            Span::styled("^r", crate::ui::red_text()),
1198            Span::styled(" to retry", Style::default()),
1199        ]),
1200        Line::from(vec![
1201            Span::styled("Press ", Style::default()),
1202            Span::styled("y", crate::ui::red_text()),
1203            Span::styled(" to copy error", Style::default()),
1204        ]),
1205        Line::from(vec![
1206            Span::styled("Press ", Style::default()),
1207            Span::styled("q", crate::ui::red_text()),
1208            Span::styled(" or ", Style::default()),
1209            Span::styled("esc", crate::ui::red_text()),
1210            Span::styled(" to quit", Style::default()),
1211        ]),
1212    ];
1213
1214    let paragraph = Paragraph::new(lines)
1215        .block(
1216            Block::default()
1217                .title(" Connection Error ")
1218                .borders(Borders::ALL)
1219                .border_style(crate::ui::red_text()),
1220        )
1221        .alignment(Alignment::Center)
1222        .style(Style::default().bg(Color::Black));
1223
1224    frame.render_widget(Clear, popup_area);
1225    frame.render_widget(paragraph, popup_area);
1226}
1227
1228fn render_space_menu(frame: &mut Frame, area: Rect) {
1229    let items = vec![
1230        Line::from(vec![
1231            Span::styled("o", Style::default().fg(Color::Yellow)),
1232            Span::raw(" services"),
1233        ]),
1234        Line::from(vec![
1235            Span::styled("t", Style::default().fg(Color::Yellow)),
1236            Span::raw(" tabs"),
1237        ]),
1238        Line::from(vec![
1239            Span::styled("c", Style::default().fg(Color::Yellow)),
1240            Span::raw(" close"),
1241        ]),
1242        Line::from(vec![
1243            Span::styled("r", Style::default().fg(Color::Yellow)),
1244            Span::raw(" regions"),
1245        ]),
1246        Line::from(vec![
1247            Span::styled("s", Style::default().fg(Color::Yellow)),
1248            Span::raw(" sessions"),
1249        ]),
1250        Line::from(vec![
1251            Span::styled("h", Style::default().fg(Color::Yellow)),
1252            Span::raw(" help"),
1253        ]),
1254    ];
1255
1256    let menu_height = items.len() as u16 + 2; // +2 for borders
1257    let menu_area = bottom_right_rect(30, menu_height, area);
1258
1259    let paragraph = Paragraph::new(items)
1260        .block(
1261            Block::default()
1262                .title(" Menu ")
1263                .borders(Borders::ALL)
1264                .border_type(BorderType::Rounded)
1265                .border_style(Style::default().fg(Color::Cyan)),
1266        )
1267        .style(Style::default().bg(Color::Black));
1268
1269    frame.render_widget(Clear, menu_area);
1270    frame.render_widget(paragraph, menu_area);
1271}
1272
1273fn render_service_picker(frame: &mut Frame, app: &App, area: Rect) {
1274    let popup_area = centered_rect(60, 60, area);
1275
1276    let chunks = Layout::default()
1277        .direction(Direction::Vertical)
1278        .constraints([Constraint::Length(3), Constraint::Min(0)])
1279        .split(popup_area);
1280
1281    let is_active = app.mode == Mode::ServicePicker;
1282    let cursor = get_cursor(is_active);
1283    let active_color = Color::Green;
1284    let inactive_color = Color::Cyan;
1285    let filter = Paragraph::new(Line::from(vec![
1286        Span::raw(&app.service_picker.filter),
1287        Span::styled(cursor, Style::default().fg(active_color)),
1288    ]))
1289    .block(
1290        Block::default()
1291            .title(" 🔍 ")
1292            .borders(Borders::ALL)
1293            .border_style(Style::default().fg(if is_active {
1294                active_color
1295            } else {
1296                inactive_color
1297            })),
1298    )
1299    .style(Style::default());
1300
1301    let filtered = app.filtered_services();
1302    let items: Vec<ListItem> = filtered.iter().map(|s| ListItem::new(*s)).collect();
1303
1304    let list = List::new(items)
1305        .block(
1306            Block::default()
1307                .title(" AWS Services ")
1308                .borders(Borders::ALL)
1309                .border_style(if is_active {
1310                    active_border()
1311                } else {
1312                    Style::default().fg(Color::Cyan)
1313                }),
1314        )
1315        .highlight_style(Style::default().bg(Color::DarkGray))
1316        .highlight_symbol("► ");
1317
1318    let mut state = ListState::default();
1319    state.select(Some(app.service_picker.selected));
1320
1321    frame.render_widget(Clear, popup_area);
1322    frame.render_widget(filter, chunks[0]);
1323    frame.render_stateful_widget(list, chunks[1], &mut state);
1324}
1325
1326fn render_tab_picker(frame: &mut Frame, app: &App, area: Rect) {
1327    let popup_area = centered_rect(80, 60, area);
1328
1329    // Split into filter, list and preview
1330    let main_chunks = Layout::default()
1331        .direction(Direction::Vertical)
1332        .constraints([Constraint::Length(3), Constraint::Min(0)])
1333        .split(popup_area);
1334
1335    // Filter input
1336    let filter_text = if app.tab_filter.is_empty() {
1337        "Type to filter tabs...".to_string()
1338    } else {
1339        app.tab_filter.clone()
1340    };
1341    let filter_style = if app.tab_filter.is_empty() {
1342        Style::default().fg(Color::DarkGray)
1343    } else {
1344        Style::default()
1345    };
1346    let filter = Paragraph::new(filter_text).style(filter_style).block(
1347        Block::default()
1348            .title(" 🔍 ")
1349            .borders(Borders::ALL)
1350            .border_style(Style::default().fg(Color::Yellow)),
1351    );
1352    frame.render_widget(Clear, main_chunks[0]);
1353    frame.render_widget(filter, main_chunks[0]);
1354
1355    let chunks = Layout::default()
1356        .direction(Direction::Horizontal)
1357        .constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
1358        .split(main_chunks[1]);
1359
1360    // Tab list - use filtered tabs
1361    let filtered_tabs = app.get_filtered_tabs();
1362    let items: Vec<ListItem> = filtered_tabs
1363        .iter()
1364        .map(|(_, tab)| ListItem::new(tab.breadcrumb.clone()))
1365        .collect();
1366
1367    let list = List::new(items)
1368        .block(
1369            Block::default()
1370                .title(format!(
1371                    " Tabs ({}/{}) ",
1372                    filtered_tabs.len(),
1373                    app.tabs.len()
1374                ))
1375                .borders(Borders::ALL)
1376                .border_type(BorderType::Plain)
1377                .border_style(crate::ui::active_border()),
1378        )
1379        .highlight_style(Style::default().bg(Color::DarkGray))
1380        .highlight_symbol("► ");
1381
1382    let mut state = ListState::default();
1383    state.select(Some(app.tab_picker_selected));
1384
1385    frame.render_widget(Clear, chunks[0]);
1386    frame.render_stateful_widget(list, chunks[0], &mut state);
1387
1388    // Preview pane
1389    frame.render_widget(Clear, chunks[1]);
1390
1391    let preview_block = Block::default()
1392        .title(" Preview ")
1393        .borders(Borders::ALL)
1394        .border_style(Style::default().fg(Color::Cyan));
1395
1396    let preview_inner = preview_block.inner(chunks[1]);
1397    frame.render_widget(preview_block, chunks[1]);
1398
1399    if let Some(&(_, tab)) = filtered_tabs.get(app.tab_picker_selected) {
1400        // Render preview using the tab's service context
1401        // Note: This may show stale state if the tab's service differs from current_service
1402        render_service_preview(frame, app, tab.service, preview_inner);
1403    }
1404}
1405
1406fn render_service_preview(frame: &mut Frame, app: &App, service: Service, area: Rect) {
1407    match service {
1408        Service::CloudWatchLogGroups => {
1409            if app.view_mode == ViewMode::Events {
1410                cw::logs::render_events(frame, app, area);
1411            } else if app.view_mode == ViewMode::Detail {
1412                cw::logs::render_group_detail(frame, app, area);
1413            } else {
1414                cw::logs::render_groups_list(frame, app, area);
1415            }
1416        }
1417        Service::CloudWatchInsights => cw::render_insights(frame, app, area),
1418        Service::CloudWatchAlarms => cw::render_alarms(frame, app, area),
1419        Service::EcrRepositories => ecr::render_repositories(frame, app, area),
1420        Service::LambdaFunctions => lambda::render_functions(frame, app, area),
1421        Service::LambdaApplications => lambda::render_applications(frame, app, area),
1422        Service::S3Buckets => s3::render_buckets(frame, app, area),
1423        Service::CloudFormationStacks => cfn::render_stacks(frame, app, area),
1424        Service::IamUsers => iam::render_users(frame, app, area),
1425        Service::IamRoles => iam::render_roles(frame, app, area),
1426        Service::IamUserGroups => iam::render_user_groups(frame, app, area),
1427    }
1428}
1429
1430fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect {
1431    let popup_layout = Layout::default()
1432        .direction(Direction::Vertical)
1433        .constraints([
1434            Constraint::Percentage((100 - percent_y) / 2),
1435            Constraint::Percentage(percent_y),
1436            Constraint::Percentage((100 - percent_y) / 2),
1437        ])
1438        .split(r);
1439
1440    Layout::default()
1441        .direction(Direction::Horizontal)
1442        .constraints([
1443            Constraint::Percentage((100 - percent_x) / 2),
1444            Constraint::Percentage(percent_x),
1445            Constraint::Percentage((100 - percent_x) / 2),
1446        ])
1447        .split(popup_layout[1])[1]
1448}
1449
1450fn centered_rect_absolute(width: u16, height: u16, r: Rect) -> Rect {
1451    let x = (r.width.saturating_sub(width)) / 2;
1452    let y = (r.height.saturating_sub(height)) / 2;
1453    Rect {
1454        x: r.x + x,
1455        y: r.y + y,
1456        width: width.min(r.width),
1457        height: height.min(r.height),
1458    }
1459}
1460
1461fn bottom_right_rect(width: u16, height: u16, r: Rect) -> Rect {
1462    let x = r.width.saturating_sub(width + 1);
1463    let y = r.height.saturating_sub(height + 1);
1464    Rect {
1465        x: r.x + x,
1466        y: r.y + y,
1467        width: width.min(r.width),
1468        height: height.min(r.height),
1469    }
1470}
1471
1472fn render_help_modal(frame: &mut Frame, area: Rect) {
1473    let help_text = vec![
1474        Line::from(vec![
1475            Span::styled("⎋  ", crate::ui::red_text()),
1476            Span::raw("  Escape"),
1477        ]),
1478        Line::from(vec![
1479            Span::styled("⏎  ", crate::ui::red_text()),
1480            Span::raw("  Enter/Return"),
1481        ]),
1482        Line::from(vec![
1483            Span::styled("⇤⇥ ", crate::ui::red_text()),
1484            Span::raw("  Tab"),
1485        ]),
1486        Line::from(vec![
1487            Span::styled("␣  ", crate::ui::red_text()),
1488            Span::raw("  Space"),
1489        ]),
1490        Line::from(vec![
1491            Span::styled("^r ", crate::ui::red_text()),
1492            Span::raw("  Ctrl+r"),
1493        ]),
1494        Line::from(vec![
1495            Span::styled("^w ", crate::ui::red_text()),
1496            Span::raw("  Ctrl+w"),
1497        ]),
1498        Line::from(vec![
1499            Span::styled("^o ", crate::ui::red_text()),
1500            Span::raw("  Ctrl+o"),
1501        ]),
1502        Line::from(vec![
1503            Span::styled("^p ", crate::ui::red_text()),
1504            Span::raw("  Ctrl+p"),
1505        ]),
1506        Line::from(vec![
1507            Span::styled("^u ", crate::ui::red_text()),
1508            Span::raw("  Ctrl+u (page up)"),
1509        ]),
1510        Line::from(vec![
1511            Span::styled("^d ", crate::ui::red_text()),
1512            Span::raw("  Ctrl+d (page down)"),
1513        ]),
1514        Line::from(vec![
1515            Span::styled("[] ", crate::ui::red_text()),
1516            Span::raw("  [ and ] (switch tabs)"),
1517        ]),
1518        Line::from(vec![
1519            Span::styled("↑↓ ", crate::ui::red_text()),
1520            Span::raw("  Arrow up/down"),
1521        ]),
1522        Line::from(vec![
1523            Span::styled("←→ ", crate::ui::red_text()),
1524            Span::raw("  Arrow left/right"),
1525        ]),
1526        Line::from(""),
1527        Line::from(vec![
1528            Span::styled("Press ", Style::default()),
1529            Span::styled("⎋", crate::ui::red_text()),
1530            Span::styled(" or ", Style::default()),
1531            Span::styled("⏎", crate::ui::red_text()),
1532            Span::styled(" to close", Style::default()),
1533        ]),
1534    ];
1535
1536    // Find max line width
1537    let max_width = help_text
1538        .iter()
1539        .map(|line| {
1540            line.spans
1541                .iter()
1542                .map(|span| span.content.len())
1543                .sum::<usize>()
1544        })
1545        .max()
1546        .unwrap_or(80) as u16;
1547
1548    // Content dimensions + borders + padding
1549    let content_width = max_width + 6; // +6 for borders and 1 char padding on each side
1550    let content_height = help_text.len() as u16 + 2; // +2 for borders
1551
1552    // Center the dialog
1553    let popup_width = content_width.min(area.width.saturating_sub(4));
1554    let popup_height = content_height.min(area.height.saturating_sub(4));
1555
1556    let popup_area = Rect {
1557        x: area.x + (area.width.saturating_sub(popup_width)) / 2,
1558        y: area.y + (area.height.saturating_sub(popup_height)) / 2,
1559        width: popup_width,
1560        height: popup_height,
1561    };
1562
1563    let paragraph = Paragraph::new(help_text)
1564        .block(
1565            Block::default()
1566                .title(Span::styled(
1567                    " Help ",
1568                    Style::default().add_modifier(Modifier::BOLD),
1569                ))
1570                .borders(Borders::ALL)
1571                .border_style(crate::ui::active_border())
1572                .padding(Padding::horizontal(1)),
1573        )
1574        .wrap(Wrap { trim: false });
1575
1576    frame.render_widget(Clear, popup_area);
1577    frame.render_widget(paragraph, popup_area);
1578}
1579
1580fn render_region_selector(frame: &mut Frame, app: &App, area: Rect) {
1581    let popup_area = centered_rect(60, 60, area);
1582
1583    let chunks = Layout::default()
1584        .direction(Direction::Vertical)
1585        .constraints([Constraint::Length(3), Constraint::Min(0)])
1586        .split(popup_area);
1587
1588    // Filter input at top
1589    let cursor = "█";
1590    let filter_text = format!("{}{}", app.region_filter, cursor);
1591    let filter = Paragraph::new(filter_text)
1592        .block(
1593            Block::default()
1594                .title(" 🔍 ")
1595                .borders(Borders::ALL)
1596                .border_style(crate::ui::active_border()),
1597        )
1598        .style(Style::default());
1599
1600    // Filtered list below
1601    let filtered = app.get_filtered_regions();
1602    let items: Vec<ListItem> = filtered
1603        .iter()
1604        .map(|r| {
1605            let latency_str = match r.latency_ms {
1606                Some(ms) => format!("({}ms)", ms),
1607                None => "(>1s)".to_string(),
1608            };
1609            let opt_in = if r.opt_in { "[opt-in] " } else { "" };
1610            let display = format!(
1611                "{} > {} > {} {}{}",
1612                r.group, r.name, r.code, opt_in, latency_str
1613            );
1614            ListItem::new(display)
1615        })
1616        .collect();
1617
1618    let list = List::new(items)
1619        .block(
1620            Block::default()
1621                .title(" Regions ")
1622                .borders(Borders::ALL)
1623                .border_style(crate::ui::active_border()),
1624        )
1625        .highlight_style(Style::default().bg(Color::DarkGray).fg(Color::White))
1626        .highlight_symbol("▶ ");
1627
1628    frame.render_widget(Clear, popup_area);
1629    frame.render_widget(filter, chunks[0]);
1630    frame.render_stateful_widget(
1631        list,
1632        chunks[1],
1633        &mut ratatui::widgets::ListState::default().with_selected(Some(app.region_picker_selected)),
1634    );
1635}
1636
1637fn render_profile_picker(frame: &mut Frame, app: &App, area: Rect) {
1638    crate::aws::render_profile_picker(frame, app, area, centered_rect);
1639}
1640
1641fn render_session_picker(frame: &mut Frame, app: &App, area: Rect) {
1642    crate::session::render_session_picker(frame, app, area, centered_rect);
1643}
1644
1645fn render_calendar_picker(frame: &mut Frame, app: &App, area: Rect) {
1646    use ratatui::widgets::calendar::{CalendarEventStore, Monthly};
1647
1648    let popup_area = centered_rect(50, 50, area);
1649
1650    let date = app
1651        .calendar_date
1652        .unwrap_or_else(|| time::OffsetDateTime::now_utc().date());
1653
1654    let field_name = match app.calendar_selecting {
1655        crate::app::CalendarField::StartDate => "Start Date",
1656        crate::app::CalendarField::EndDate => "End Date",
1657    };
1658
1659    let events = CalendarEventStore::today(
1660        Style::default()
1661            .add_modifier(Modifier::BOLD)
1662            .bg(Color::Blue),
1663    );
1664
1665    let calendar = Monthly::new(date, events)
1666        .block(
1667            Block::default()
1668                .title(format!(" Select {} ", field_name))
1669                .borders(Borders::ALL)
1670                .border_style(crate::ui::active_border()),
1671        )
1672        .show_weekdays_header(Style::new().bold().yellow())
1673        .show_month_header(Style::new().bold().green());
1674
1675    frame.render_widget(Clear, popup_area);
1676    frame.render_widget(calendar, popup_area);
1677}
1678
1679// Render JSON content with syntax highlighting and scrollbar
1680pub fn render_json_highlighted(
1681    frame: &mut Frame,
1682    area: Rect,
1683    json_text: &str,
1684    scroll_offset: usize,
1685    title: &str,
1686) {
1687    let lines: Vec<Line> = json_text
1688        .lines()
1689        .skip(scroll_offset)
1690        .map(|line| {
1691            let mut spans = Vec::new();
1692            let trimmed = line.trim_start();
1693            let indent = line.len() - trimmed.len();
1694
1695            if indent > 0 {
1696                spans.push(Span::raw(" ".repeat(indent)));
1697            }
1698
1699            if trimmed.starts_with('"') && trimmed.contains(':') {
1700                if let Some(colon_pos) = trimmed.find(':') {
1701                    spans.push(Span::styled(
1702                        &trimmed[..colon_pos],
1703                        Style::default().fg(Color::Blue),
1704                    ));
1705                    spans.push(Span::raw(&trimmed[colon_pos..]));
1706                } else {
1707                    spans.push(Span::raw(trimmed));
1708                }
1709            } else if trimmed.starts_with('"') {
1710                spans.push(Span::styled(trimmed, Style::default().fg(Color::Green)));
1711            } else if trimmed.starts_with("true") || trimmed.starts_with("false") {
1712                spans.push(Span::styled(trimmed, Style::default().fg(Color::Yellow)));
1713            } else if trimmed.chars().next().is_some_and(|c| c.is_ascii_digit()) {
1714                spans.push(Span::styled(trimmed, Style::default().fg(Color::Magenta)));
1715            } else {
1716                spans.push(Span::raw(trimmed));
1717            }
1718
1719            Line::from(spans)
1720        })
1721        .collect();
1722
1723    frame.render_widget(
1724        Paragraph::new(lines).block(
1725            Block::default()
1726                .title(title)
1727                .borders(Borders::ALL)
1728                .border_style(crate::ui::active_border()),
1729        ),
1730        area,
1731    );
1732
1733    let total_lines = json_text.lines().count();
1734    if total_lines > 0 {
1735        crate::common::render_scrollbar(
1736            frame,
1737            area.inner(Margin {
1738                vertical: 1,
1739                horizontal: 0,
1740            }),
1741            total_lines,
1742            scroll_offset,
1743        );
1744    }
1745}
1746
1747// Render a tags tab with description and table
1748pub fn render_tags_section<F>(frame: &mut Frame, area: Rect, render_table: F)
1749where
1750    F: FnOnce(&mut Frame, Rect),
1751{
1752    let chunks = vertical([Constraint::Length(1), Constraint::Min(0)], area);
1753
1754    frame.render_widget(
1755        Paragraph::new(
1756            "Tags are key-value pairs that you can add to AWS resources to help identify, organize, or search for resources.",
1757        ),
1758        chunks[0],
1759    );
1760
1761    render_table(frame, chunks[1]);
1762}
1763
1764// Render a permissions tab with description and policies table
1765pub fn render_permissions_section<F>(
1766    frame: &mut Frame,
1767    area: Rect,
1768    description: &str,
1769    render_table: F,
1770) where
1771    F: FnOnce(&mut Frame, Rect),
1772{
1773    let chunks = vertical([Constraint::Length(1), Constraint::Min(0)], area);
1774
1775    frame.render_widget(Paragraph::new(description), chunks[0]);
1776
1777    render_table(frame, chunks[1]);
1778}
1779
1780// Render a last accessed tab with description, note, and table
1781pub fn render_last_accessed_section<F>(
1782    frame: &mut Frame,
1783    area: Rect,
1784    description: &str,
1785    note: &str,
1786    render_table: F,
1787) where
1788    F: FnOnce(&mut Frame, Rect),
1789{
1790    let chunks = vertical(
1791        [
1792            Constraint::Length(1),
1793            Constraint::Length(1),
1794            Constraint::Min(0),
1795        ],
1796        area,
1797    );
1798
1799    frame.render_widget(Paragraph::new(description), chunks[0]);
1800    frame.render_widget(Paragraph::new(note), chunks[1]);
1801
1802    render_table(frame, chunks[2]);
1803}
1804
1805#[cfg(test)]
1806mod tests {
1807    use super::*;
1808    use crate::ecr::image::Image as EcrImage;
1809    use crate::ecr::repo::Repository as EcrRepository;
1810    use crate::keymap::Action;
1811    use crate::ui::table::Column;
1812
1813    fn test_app() -> App {
1814        App::new_without_client("test".to_string(), Some("us-east-1".to_string()))
1815    }
1816
1817    fn test_app_no_region() -> App {
1818        App::new_without_client("test".to_string(), None)
1819    }
1820
1821    #[test]
1822    fn test_expanded_content_wrapping_marks_continuation_lines() {
1823        // Simulate the wrapping logic
1824        let max_width = 50;
1825        let col_name = "Message: ";
1826        let value = "This is a very long message that will definitely exceed the maximum width and need to be wrapped";
1827        let full_line = format!("{}{}", col_name, value);
1828
1829        let mut lines = Vec::new();
1830
1831        if full_line.len() <= max_width {
1832            lines.push((full_line, true));
1833        } else {
1834            let first_chunk_len = max_width.min(full_line.len());
1835            lines.push((full_line[..first_chunk_len].to_string(), true));
1836
1837            let mut remaining = &full_line[first_chunk_len..];
1838            while !remaining.is_empty() {
1839                let take = max_width.min(remaining.len());
1840                lines.push((remaining[..take].to_string(), false));
1841                remaining = &remaining[take..];
1842            }
1843        }
1844
1845        // First line should be marked as first (true)
1846        assert!(lines[0].1);
1847        // Continuation lines should be marked as continuation (false)
1848        assert!(!lines[1].1);
1849        assert!(lines.len() > 1);
1850    }
1851
1852    #[test]
1853    fn test_expanded_content_short_line_not_wrapped() {
1854        let max_width = 100;
1855        let col_name = "Timestamp: ";
1856        let value = "2025-03-13 19:49:30 (UTC)";
1857        let full_line = format!("{}{}", col_name, value);
1858
1859        let mut lines = Vec::new();
1860
1861        if full_line.len() <= max_width {
1862            lines.push((full_line.clone(), true));
1863        } else {
1864            let first_chunk_len = max_width.min(full_line.len());
1865            lines.push((full_line[..first_chunk_len].to_string(), true));
1866
1867            let mut remaining = &full_line[first_chunk_len..];
1868            while !remaining.is_empty() {
1869                let take = max_width.min(remaining.len());
1870                lines.push((remaining[..take].to_string(), false));
1871                remaining = &remaining[take..];
1872            }
1873        }
1874
1875        // Should only have one line
1876        assert_eq!(lines.len(), 1);
1877        assert!(lines[0].1);
1878        assert_eq!(lines[0].0, full_line);
1879    }
1880
1881    #[test]
1882    fn test_tabs_display_with_separator() {
1883        // Test that tabs are formatted with ⋮ separator
1884        let tabs = [
1885            crate::app::Tab {
1886                service: crate::app::Service::CloudWatchLogGroups,
1887                title: "CloudWatch > Log Groups".to_string(),
1888                breadcrumb: "CloudWatch > Log Groups".to_string(),
1889            },
1890            crate::app::Tab {
1891                service: crate::app::Service::CloudWatchInsights,
1892                title: "CloudWatch > Logs Insights".to_string(),
1893                breadcrumb: "CloudWatch > Logs Insights".to_string(),
1894            },
1895        ];
1896
1897        let mut spans = Vec::new();
1898        for (i, tab) in tabs.iter().enumerate() {
1899            if i > 0 {
1900                spans.push(Span::raw(" ⋮ "));
1901            }
1902            spans.push(Span::raw(tab.title.clone()));
1903        }
1904
1905        // Should have 3 spans: Tab1, separator, Tab2
1906        assert_eq!(spans.len(), 3);
1907        assert_eq!(spans[1].content, " ⋮ ");
1908    }
1909
1910    #[test]
1911    fn test_current_tab_highlighted() {
1912        let tabs = [
1913            crate::app::Tab {
1914                service: crate::app::Service::CloudWatchLogGroups,
1915                title: "CloudWatch > Log Groups".to_string(),
1916                breadcrumb: "CloudWatch > Log Groups".to_string(),
1917            },
1918            crate::app::Tab {
1919                service: crate::app::Service::CloudWatchInsights,
1920                title: "CloudWatch > Logs Insights".to_string(),
1921                breadcrumb: "CloudWatch > Logs Insights".to_string(),
1922            },
1923        ];
1924        let current_tab = 1;
1925
1926        let mut spans = Vec::new();
1927        for (i, tab) in tabs.iter().enumerate() {
1928            if i > 0 {
1929                spans.push(Span::raw(" ⋮ "));
1930            }
1931            if i == current_tab {
1932                spans.push(Span::styled(
1933                    tab.title.clone(),
1934                    Style::default()
1935                        .fg(Color::Yellow)
1936                        .add_modifier(Modifier::BOLD),
1937                ));
1938            } else {
1939                spans.push(Span::raw(tab.title.clone()));
1940            }
1941        }
1942
1943        // Current tab (index 2 in spans) should have yellow color
1944        assert_eq!(spans[2].style.fg, Some(Color::Yellow));
1945        assert!(spans[2].style.add_modifier.contains(Modifier::BOLD));
1946        // First tab should have no styling
1947        assert_eq!(spans[0].style.fg, None);
1948    }
1949
1950    #[test]
1951    fn test_lambda_application_update_complete_shows_green_checkmark() {
1952        let app = crate::lambda::Application {
1953            name: "test-stack".to_string(),
1954            arn: "arn:aws:cloudformation:us-east-1:123456789012:stack/test-stack/abc123"
1955                .to_string(),
1956            description: "Test stack".to_string(),
1957            status: "UPDATE_COMPLETE".to_string(),
1958            last_modified: "2025-10-31 12:00:00 (UTC)".to_string(),
1959        };
1960
1961        struct AppStatusColumn;
1962        impl crate::ui::table::Column<crate::lambda::Application> for AppStatusColumn {
1963            fn name(&self) -> &str {
1964                "Status"
1965            }
1966            fn width(&self) -> u16 {
1967                20
1968            }
1969            fn render(&self, item: &crate::lambda::Application) -> (String, Style) {
1970                let status_upper = item.status.to_uppercase();
1971                if status_upper.contains("UPDATE_COMPLETE") {
1972                    (
1973                        "✅ Update complete".to_string(),
1974                        Style::default().fg(Color::Green),
1975                    )
1976                } else if status_upper.contains("CREATE_COMPLETE") {
1977                    (
1978                        "✅ Create complete".to_string(),
1979                        Style::default().fg(Color::Green),
1980                    )
1981                } else {
1982                    (item.status.clone(), Style::default())
1983                }
1984            }
1985        }
1986
1987        let col = AppStatusColumn;
1988        let (text, style) = col.render(&app);
1989        assert_eq!(text, "✅ Update complete");
1990        assert_eq!(style.fg, Some(Color::Green));
1991    }
1992
1993    #[test]
1994    fn test_lambda_application_create_complete_shows_green_checkmark() {
1995        let app = crate::lambda::Application {
1996            name: "test-stack".to_string(),
1997            arn: "arn:aws:cloudformation:us-east-1:123456789012:stack/test-stack/abc123"
1998                .to_string(),
1999            description: "Test stack".to_string(),
2000            status: "CREATE_COMPLETE".to_string(),
2001            last_modified: "2025-10-31 12:00:00 (UTC)".to_string(),
2002        };
2003
2004        struct AppStatusColumn;
2005        impl crate::ui::table::Column<crate::lambda::Application> for AppStatusColumn {
2006            fn name(&self) -> &str {
2007                "Status"
2008            }
2009            fn width(&self) -> u16 {
2010                20
2011            }
2012            fn render(&self, item: &crate::lambda::Application) -> (String, Style) {
2013                let status_upper = item.status.to_uppercase();
2014                if status_upper.contains("UPDATE_COMPLETE") {
2015                    (
2016                        "✅ Update complete".to_string(),
2017                        Style::default().fg(Color::Green),
2018                    )
2019                } else if status_upper.contains("CREATE_COMPLETE") {
2020                    (
2021                        "✅ Create complete".to_string(),
2022                        Style::default().fg(Color::Green),
2023                    )
2024                } else {
2025                    (item.status.clone(), Style::default())
2026                }
2027            }
2028        }
2029
2030        let col = AppStatusColumn;
2031        let (text, style) = col.render(&app);
2032        assert_eq!(text, "✅ Create complete");
2033        assert_eq!(style.fg, Some(Color::Green));
2034    }
2035
2036    #[test]
2037    fn test_lambda_application_other_status_shows_default() {
2038        let app = crate::lambda::Application {
2039            name: "test-stack".to_string(),
2040            arn: "arn:aws:cloudformation:us-east-1:123456789012:stack/test-stack/abc123"
2041                .to_string(),
2042            description: "Test stack".to_string(),
2043            status: "UPDATE_IN_PROGRESS".to_string(),
2044            last_modified: "2025-10-31 12:00:00 (UTC)".to_string(),
2045        };
2046
2047        struct AppStatusColumn;
2048        impl crate::ui::table::Column<crate::lambda::Application> for AppStatusColumn {
2049            fn name(&self) -> &str {
2050                "Status"
2051            }
2052            fn width(&self) -> u16 {
2053                20
2054            }
2055            fn render(&self, item: &crate::lambda::Application) -> (String, Style) {
2056                let status_upper = item.status.to_uppercase();
2057                if status_upper.contains("UPDATE_COMPLETE") {
2058                    (
2059                        "✅ Update complete".to_string(),
2060                        Style::default().fg(Color::Green),
2061                    )
2062                } else if status_upper.contains("CREATE_COMPLETE") {
2063                    (
2064                        "✅ Create complete".to_string(),
2065                        Style::default().fg(Color::Green),
2066                    )
2067                } else {
2068                    (item.status.clone(), Style::default())
2069                }
2070            }
2071        }
2072
2073        let col = AppStatusColumn;
2074        let (text, style) = col.render(&app);
2075        assert_eq!(text, "UPDATE_IN_PROGRESS");
2076        assert_eq!(style.fg, None);
2077    }
2078
2079    #[test]
2080    fn test_tab_picker_shows_breadcrumb_and_preview() {
2081        let tabs = [
2082            crate::app::Tab {
2083                service: crate::app::Service::CloudWatchLogGroups,
2084                title: "CloudWatch > Log Groups".to_string(),
2085                breadcrumb: "CloudWatch > Log Groups".to_string(),
2086            },
2087            crate::app::Tab {
2088                service: crate::app::Service::CloudWatchAlarms,
2089                title: "CloudWatch > Alarms".to_string(),
2090                breadcrumb: "CloudWatch > Alarms".to_string(),
2091            },
2092        ];
2093
2094        // Tab picker should show breadcrumb in list
2095        let selected_idx = 1;
2096        let selected_tab = &tabs[selected_idx];
2097        assert_eq!(selected_tab.breadcrumb, "CloudWatch > Alarms");
2098        assert_eq!(selected_tab.title, "CloudWatch > Alarms");
2099
2100        // Preview should show both service and tab name
2101        assert!(selected_tab.breadcrumb.contains("CloudWatch"));
2102        assert!(selected_tab.breadcrumb.contains("Alarms"));
2103    }
2104
2105    #[test]
2106    fn test_tab_picker_has_active_border() {
2107        // Tab picker should have green border like other active controls
2108        let border_style = Style::default().fg(Color::Green);
2109        let border_type = BorderType::Plain;
2110
2111        // Verify green color is used
2112        assert_eq!(border_style.fg, Some(Color::Green));
2113        // Verify plain border type
2114        assert_eq!(border_type, BorderType::Plain);
2115    }
2116
2117    #[test]
2118    fn test_tab_picker_title_is_tabs() {
2119        // Tab picker should be titled "Tabs" not "Open Tabs"
2120        let title = " Tabs ";
2121        assert_eq!(title.trim(), "Tabs");
2122        assert!(!title.contains("Open"));
2123    }
2124
2125    #[test]
2126    fn test_s3_bucket_tabs_no_count_in_tabs() {
2127        // S3 bucket type tabs should not show counts (only in table title)
2128        let general_purpose_tab = "General purpose buckets (All AWS Regions)";
2129        let directory_tab = "Directory buckets";
2130
2131        // Verify no count in tab labels
2132        assert!(!general_purpose_tab.contains("(0)"));
2133        assert!(!general_purpose_tab.contains("(1)"));
2134        assert!(!directory_tab.contains("(0)"));
2135        assert!(!directory_tab.contains("(1)"));
2136
2137        // Count should only appear in table title
2138        let table_title = " General purpose buckets (42) ";
2139        assert!(table_title.contains("(42)"));
2140    }
2141
2142    #[test]
2143    fn test_s3_bucket_column_preferences_shows_bucket_columns() {
2144        use crate::app::S3BucketColumn;
2145
2146        let app = test_app();
2147
2148        // Should have 3 bucket columns (Name, Region, CreationDate)
2149        assert_eq!(app.all_bucket_columns.len(), 3);
2150        assert_eq!(app.visible_bucket_columns.len(), 3);
2151
2152        // Verify column names
2153        assert_eq!(S3BucketColumn::Name.name(), "Name");
2154        assert_eq!(S3BucketColumn::Region.name(), "Region");
2155        assert_eq!(S3BucketColumn::CreationDate.name(), "Creation date");
2156    }
2157
2158    #[test]
2159    fn test_s3_bucket_columns_not_cloudwatch_columns() {
2160        let app = test_app();
2161
2162        // S3 bucket columns should be different from CloudWatch log group columns
2163        let bucket_col_names: Vec<&str> = app.all_bucket_columns.iter().map(|c| c.name()).collect();
2164        let log_col_names: Vec<&str> = app.all_columns.iter().map(|c| c.name()).collect();
2165
2166        // Verify they're different
2167        assert_ne!(bucket_col_names, log_col_names);
2168
2169        // Verify S3 columns don't contain CloudWatch-specific terms
2170        assert!(!bucket_col_names.contains(&"Log group"));
2171        assert!(!bucket_col_names.contains(&"Stored bytes"));
2172
2173        // Verify S3 columns contain S3-specific terms
2174        assert!(bucket_col_names.contains(&"Creation date"));
2175
2176        // Region should NOT be in bucket columns (shown only when expanded)
2177        assert!(!bucket_col_names.contains(&"AWS Region"));
2178    }
2179
2180    #[test]
2181    fn test_s3_bucket_column_toggle() {
2182        use crate::app::{S3BucketColumn, Service};
2183
2184        let mut app = test_app();
2185        app.current_service = Service::S3Buckets;
2186
2187        // Initially 3 columns visible
2188        assert_eq!(app.visible_bucket_columns.len(), 3);
2189
2190        // Simulate toggling off the Region column (index 1)
2191        let col = app.all_bucket_columns[1];
2192        if let Some(pos) = app.visible_bucket_columns.iter().position(|&c| c == col) {
2193            app.visible_bucket_columns.remove(pos);
2194        }
2195
2196        assert_eq!(app.visible_bucket_columns.len(), 2);
2197        assert!(!app.visible_bucket_columns.contains(&S3BucketColumn::Region));
2198
2199        // Toggle it back on
2200        app.visible_bucket_columns.push(col);
2201        assert_eq!(app.visible_bucket_columns.len(), 3);
2202        assert!(app
2203            .visible_bucket_columns
2204            .contains(&S3BucketColumn::CreationDate));
2205    }
2206
2207    #[test]
2208    fn test_s3_preferences_dialog_title() {
2209        // S3 bucket preferences should be titled "Preferences" without hints
2210        let title = " Preferences ";
2211        assert_eq!(title.trim(), "Preferences");
2212        assert!(!title.contains("Space"));
2213        assert!(!title.contains("toggle"));
2214    }
2215
2216    #[test]
2217    fn test_column_selector_mode_has_hotkey_hints() {
2218        // ColumnSelector mode should show hotkey hints in status bar
2219        let help = " ↑↓: scroll | ␣: toggle | esc: close ";
2220
2221        // Verify key hints are present
2222        assert!(help.contains("␣: toggle"));
2223        assert!(help.contains("↑↓: scroll"));
2224        assert!(help.contains("esc: close"));
2225
2226        // Should NOT contain unavailable keys
2227        assert!(!help.contains("⏎"));
2228        assert!(!help.contains("^w"));
2229    }
2230
2231    #[test]
2232    fn test_date_range_title_no_hints() {
2233        // Date range title should not contain hints
2234        let title = " Date range ";
2235
2236        // Should NOT contain hints
2237        assert!(!title.contains("Tab to switch"));
2238        assert!(!title.contains("Space to change"));
2239        assert!(!title.contains("("));
2240        assert!(!title.contains(")"));
2241    }
2242
2243    #[test]
2244    fn test_event_filter_mode_has_hints_in_status_bar() {
2245        // EventFilterInput mode should show hints in status bar
2246        let help = " tab: switch | ␣: change unit | enter: apply | esc: cancel | ctrl+w: close ";
2247
2248        // Verify key hints are present
2249        assert!(help.contains("tab: switch"));
2250        assert!(help.contains("␣: change unit"));
2251        assert!(help.contains("enter: apply"));
2252        assert!(help.contains("esc: cancel"));
2253    }
2254
2255    #[test]
2256    fn test_s3_preferences_shows_all_columns() {
2257        let app = test_app();
2258
2259        // Should have 3 bucket columns (Name, Region, CreationDate)
2260        assert_eq!(app.all_bucket_columns.len(), 3);
2261
2262        // All should be visible by default
2263        assert_eq!(app.visible_bucket_columns.len(), 3);
2264
2265        // Verify all column names
2266        let names: Vec<&str> = app.all_bucket_columns.iter().map(|c| c.name()).collect();
2267        assert_eq!(names, vec!["Name", "Region", "Creation date"]);
2268    }
2269
2270    #[test]
2271    fn test_s3_preferences_has_active_border() {
2272        use ratatui::style::Color;
2273
2274        // S3 preferences should have green border (active state)
2275        let border_color = Color::Green;
2276        assert_eq!(border_color, Color::Green);
2277
2278        // Not cyan (inactive)
2279        assert_ne!(border_color, Color::Cyan);
2280    }
2281
2282    #[test]
2283    fn test_s3_table_loses_focus_when_preferences_shown() {
2284        use crate::app::Service;
2285        use crate::keymap::Mode;
2286        use ratatui::style::Color;
2287
2288        let mut app = test_app();
2289        app.current_service = Service::S3Buckets;
2290
2291        // When in Normal mode, table should be active (green)
2292        app.mode = Mode::Normal;
2293        let is_active = app.mode != Mode::ColumnSelector;
2294        let border_color = if is_active {
2295            Color::Green
2296        } else {
2297            Color::White
2298        };
2299        assert_eq!(border_color, Color::Green);
2300
2301        // When in ColumnSelector mode, table should be inactive (white)
2302        app.mode = Mode::ColumnSelector;
2303        let is_active = app.mode != Mode::ColumnSelector;
2304        let border_color = if is_active {
2305            Color::Green
2306        } else {
2307            Color::White
2308        };
2309        assert_eq!(border_color, Color::White);
2310    }
2311
2312    #[test]
2313    fn test_s3_object_tabs_cleared_before_render() {
2314        // Tabs should be cleared before rendering to prevent artifacts
2315        // This is verified by the Clear widget being rendered before tabs
2316    }
2317
2318    #[test]
2319    fn test_s3_properties_tab_shows_bucket_info() {
2320        use crate::app::{S3ObjectTab, Service};
2321
2322        let mut app = test_app();
2323        app.current_service = Service::S3Buckets;
2324        app.s3_state.current_bucket = Some("test-bucket".to_string());
2325        app.s3_state.object_tab = S3ObjectTab::Properties;
2326
2327        // Properties tab should be selectable
2328        assert_eq!(app.s3_state.object_tab, S3ObjectTab::Properties);
2329
2330        // Properties scroll should start at 0
2331        assert_eq!(app.s3_state.properties_scroll, 0);
2332    }
2333
2334    #[test]
2335    fn test_s3_properties_scrolling() {
2336        use crate::app::{S3ObjectTab, Service};
2337
2338        let mut app = test_app();
2339        app.current_service = Service::S3Buckets;
2340        app.s3_state.current_bucket = Some("test-bucket".to_string());
2341        app.s3_state.object_tab = S3ObjectTab::Properties;
2342
2343        // Initial scroll should be 0
2344        assert_eq!(app.s3_state.properties_scroll, 0);
2345
2346        // Scroll down
2347        app.s3_state.properties_scroll = app.s3_state.properties_scroll.saturating_add(1);
2348        assert_eq!(app.s3_state.properties_scroll, 1);
2349
2350        app.s3_state.properties_scroll = app.s3_state.properties_scroll.saturating_add(1);
2351        assert_eq!(app.s3_state.properties_scroll, 2);
2352
2353        // Scroll up
2354        app.s3_state.properties_scroll = app.s3_state.properties_scroll.saturating_sub(1);
2355        assert_eq!(app.s3_state.properties_scroll, 1);
2356
2357        app.s3_state.properties_scroll = app.s3_state.properties_scroll.saturating_sub(1);
2358        assert_eq!(app.s3_state.properties_scroll, 0);
2359
2360        // Should not go below 0
2361        app.s3_state.properties_scroll = app.s3_state.properties_scroll.saturating_sub(1);
2362        assert_eq!(app.s3_state.properties_scroll, 0);
2363    }
2364
2365    #[test]
2366    fn test_s3_parent_prefix_cleared_before_render() {
2367        // Parent prefix area should be cleared to prevent artifacts
2368        // Verified by Clear widget being rendered before parent text
2369    }
2370
2371    #[test]
2372    fn test_s3_empty_region_defaults_to_us_east_1() {
2373        let _app = App::new_without_client("test".to_string(), Some("us-east-1".to_string()));
2374
2375        // When bucket region is empty, should default to us-east-1
2376        let empty_region = "";
2377        let bucket_region = if empty_region.is_empty() {
2378            "us-east-1"
2379        } else {
2380            empty_region
2381        };
2382        assert_eq!(bucket_region, "us-east-1");
2383
2384        // When bucket region is set, should use it
2385        let set_region = "us-west-2";
2386        let bucket_region = if set_region.is_empty() {
2387            "us-east-1"
2388        } else {
2389            set_region
2390        };
2391        assert_eq!(bucket_region, "us-west-2");
2392    }
2393
2394    #[test]
2395    fn test_s3_properties_has_multiple_blocks() {
2396        // Properties tab should have 12 separate blocks
2397        let block_count = 12;
2398        assert_eq!(block_count, 12);
2399
2400        // Blocks: Bucket overview, Tags, Default encryption, Intelligent-Tiering,
2401        // Server access logging, CloudTrail, Event notifications, EventBridge,
2402        // Transfer acceleration, Object Lock, Requester pays, Static website hosting
2403    }
2404
2405    #[test]
2406    fn test_s3_properties_tables_use_common_component() {
2407        // Tables should use ratatui Table widget
2408        // Tags table: Key, Value columns
2409        let tags_columns = ["Key", "Value"];
2410        assert_eq!(tags_columns.len(), 2);
2411
2412        // Intelligent-Tiering table: 5 columns
2413        let tiering_columns = [
2414            "Name",
2415            "Status",
2416            "Scope",
2417            "Days to Archive",
2418            "Days to Deep Archive",
2419        ];
2420        assert_eq!(tiering_columns.len(), 5);
2421
2422        // Event notifications table: 5 columns
2423        let events_columns = [
2424            "Name",
2425            "Event types",
2426            "Filters",
2427            "Destination type",
2428            "Destination",
2429        ];
2430        assert_eq!(events_columns.len(), 5);
2431    }
2432
2433    #[test]
2434    fn test_s3_properties_field_format() {
2435        // Each field should have bold label followed by value
2436        use ratatui::style::{Modifier, Style};
2437        use ratatui::text::{Line, Span};
2438
2439        let label = Line::from(vec![Span::styled(
2440            "AWS Region",
2441            Style::default().add_modifier(Modifier::BOLD),
2442        )]);
2443        let value = Line::from("us-east-1");
2444
2445        // Verify label is bold
2446        assert!(label.spans[0].style.add_modifier.contains(Modifier::BOLD));
2447
2448        // Verify value is plain text
2449        assert!(!value.spans[0].style.add_modifier.contains(Modifier::BOLD));
2450    }
2451
2452    #[test]
2453    fn test_s3_properties_has_scrollbar() {
2454        // Properties tab should have vertical scrollbar
2455        let total_height = 7 + 5 + 6 + 5 + 4 + 4 + 5 + 4 + 4 + 4 + 4 + 4;
2456        assert_eq!(total_height, 56);
2457
2458        // If total height exceeds area, scrollbar should be shown
2459        let area_height = 40;
2460        assert!(total_height > area_height);
2461    }
2462
2463    #[test]
2464    fn test_s3_bucket_region_fetched_on_open() {
2465        // When bucket region is empty, it should be fetched before loading objects
2466        // This prevents PermanentRedirect errors
2467
2468        // Simulate empty region
2469        let empty_region = "";
2470        assert!(empty_region.is_empty());
2471
2472        // After fetch, region should be populated
2473        let fetched_region = "us-west-2";
2474        assert!(!fetched_region.is_empty());
2475    }
2476
2477    #[test]
2478    fn test_s3_filter_space_used_when_hidden() {
2479        // When filter is hidden (non-Objects tabs), its space should be used by content
2480        // Objects tab: 4 chunks (prefix, tabs, filter, content)
2481        // Other tabs: 3 chunks (prefix, tabs, content)
2482
2483        let objects_chunks = 4;
2484        let other_chunks = 3;
2485
2486        assert_eq!(objects_chunks, 4);
2487        assert_eq!(other_chunks, 3);
2488        assert!(other_chunks < objects_chunks);
2489    }
2490
2491    #[test]
2492    fn test_s3_properties_scrollable() {
2493        let mut app = test_app();
2494
2495        // Properties should be scrollable
2496        assert_eq!(app.s3_state.properties_scroll, 0);
2497
2498        // Scroll down
2499        app.s3_state.properties_scroll += 1;
2500        assert_eq!(app.s3_state.properties_scroll, 1);
2501
2502        // Scroll up
2503        app.s3_state.properties_scroll = app.s3_state.properties_scroll.saturating_sub(1);
2504        assert_eq!(app.s3_state.properties_scroll, 0);
2505    }
2506
2507    #[test]
2508    fn test_s3_properties_scrollbar_conditional() {
2509        // Scrollbar should only show when content exceeds viewport
2510        let content_height = 40;
2511        let small_viewport = 20;
2512        let large_viewport = 50;
2513
2514        // Should show scrollbar
2515        assert!(content_height > small_viewport);
2516
2517        // Should not show scrollbar
2518        assert!(content_height < large_viewport);
2519    }
2520
2521    #[test]
2522    fn test_s3_tabs_visible_with_styling() {
2523        use ratatui::style::{Color, Modifier, Style};
2524        use ratatui::text::Span;
2525
2526        // Active tab should be yellow, bold, and underlined
2527        let active_style = Style::default()
2528            .fg(Color::Yellow)
2529            .add_modifier(Modifier::BOLD | Modifier::UNDERLINED);
2530        let active_tab = Span::styled("Objects", active_style);
2531        assert_eq!(active_tab.style.fg, Some(Color::Yellow));
2532        assert!(active_tab.style.add_modifier.contains(Modifier::BOLD));
2533        assert!(active_tab.style.add_modifier.contains(Modifier::UNDERLINED));
2534
2535        // Inactive tab should be gray
2536        let inactive_style = Style::default().fg(Color::Gray);
2537        let inactive_tab = Span::styled("Properties", inactive_style);
2538        assert_eq!(inactive_tab.style.fg, Some(Color::Gray));
2539    }
2540
2541    #[test]
2542    fn test_s3_properties_field_labels_bold() {
2543        use ratatui::style::{Modifier, Style};
2544        use ratatui::text::{Line, Span};
2545
2546        // Field labels should be bold, values should not
2547        let label = Span::styled(
2548            "AWS Region: ",
2549            Style::default().add_modifier(Modifier::BOLD),
2550        );
2551        let value = Span::raw("us-east-1");
2552        let line = Line::from(vec![label.clone(), value.clone()]);
2553
2554        // Verify label is bold
2555        assert!(label.style.add_modifier.contains(Modifier::BOLD));
2556
2557        // Verify value is not bold
2558        assert!(!value.style.add_modifier.contains(Modifier::BOLD));
2559
2560        // Verify line has both parts
2561        assert_eq!(line.spans.len(), 2);
2562    }
2563
2564    #[test]
2565    fn test_session_picker_dialog_opaque() {
2566        // Session picker dialog should use Clear widget to be opaque
2567        // This prevents background content from showing through
2568    }
2569
2570    #[test]
2571    fn test_status_bar_hotkey_format() {
2572        // Status bar should use ⋮ separator, ^ for ctrl, uppercase for shift+char, and highlight keys in red
2573
2574        // Test separator
2575        let separator = " ⋮ ";
2576        assert_eq!(separator, " ⋮ ");
2577
2578        // Test ctrl format
2579        let ctrl_key = "^r";
2580        assert!(ctrl_key.starts_with("^"));
2581        assert!(!ctrl_key.contains("ctrl+"));
2582        assert!(!ctrl_key.contains("ctrl-"));
2583
2584        // Test shift+char format (uppercase)
2585        let shift_key = "^R";
2586        assert!(shift_key.contains("^R"));
2587        assert!(!shift_key.contains("shift+"));
2588        assert!(!shift_key.contains("shift-"));
2589
2590        // Test that old formats are not used
2591        let old_separator = " | ";
2592        assert_ne!(separator, old_separator);
2593    }
2594
2595    #[test]
2596    fn test_space_key_uses_unicode_symbol() {
2597        // Space key should use ␣ (U+2423 OPEN BOX) symbol, not "space" text
2598        let space_symbol = "␣";
2599        assert_eq!(space_symbol, "␣");
2600        assert_eq!(space_symbol.len(), 3); // UTF-8 bytes
2601
2602        // Should not use text "space"
2603        assert_ne!(space_symbol, "space");
2604        assert_ne!(space_symbol, "SPC");
2605    }
2606
2607    #[test]
2608    fn test_region_hotkey_uses_space_menu() {
2609        // Region should use ␣→r (space menu), not ^R (Ctrl+Shift+R)
2610        let region_hotkey = "␣→r";
2611        assert_eq!(region_hotkey, "␣→r");
2612
2613        // Should not use ^R for region
2614        assert_ne!(region_hotkey, "^R");
2615        assert_ne!(region_hotkey, "ctrl+shift+r");
2616    }
2617
2618    #[test]
2619    fn test_no_incorrect_hotkey_patterns_in_ui() {
2620        // This test validates that common hotkey mistakes are not present in the UI code
2621        let source = include_str!("mod.rs");
2622
2623        // Split at #[cfg(test)] to only check non-test code
2624        let ui_code = if let Some(pos) = source.find("#[cfg(test)]") {
2625            &source[..pos]
2626        } else {
2627            source
2628        };
2629
2630        // Check for "space" text instead of ␣ symbol in hotkeys
2631        let space_text_pattern = r#"Span::styled("space""#;
2632        assert!(
2633            !ui_code.contains(space_text_pattern),
2634            "Found 'space' text in hotkey - should use ␣ symbol instead"
2635        );
2636
2637        // Check for ^R followed by region (should be ␣→r)
2638        let lines_with_ctrl_shift_r: Vec<_> = ui_code
2639            .lines()
2640            .enumerate()
2641            .filter(|(_, line)| {
2642                line.contains(r#"Span::styled("^R""#) && line.contains("Color::Red")
2643            })
2644            .collect();
2645
2646        assert!(
2647            lines_with_ctrl_shift_r.is_empty(),
2648            "Found ^R in hotkeys (should use ␣→r for region): {:?}",
2649            lines_with_ctrl_shift_r
2650        );
2651    }
2652
2653    #[test]
2654    fn test_region_only_in_space_menu_not_status_bar() {
2655        // Region switching should ONLY be in Space menu, NOT in status bar hotkeys
2656        let source = include_str!("mod.rs");
2657
2658        // Find the space menu section
2659        let space_menu_start = source
2660            .find("fn render_space_menu")
2661            .expect("render_space_menu function not found");
2662        let space_menu_end = space_menu_start
2663            + source[space_menu_start..]
2664                .find("fn render_service_picker")
2665                .expect("render_service_picker not found");
2666        let space_menu_code = &source[space_menu_start..space_menu_end];
2667
2668        // Verify region IS in space menu
2669        assert!(
2670            space_menu_code.contains(r#"Span::raw(" regions")"#),
2671            "Region must be in Space menu"
2672        );
2673
2674        // Find status bar section (render_bottom_bar)
2675        let status_bar_start = source
2676            .find("fn render_bottom_bar")
2677            .expect("render_bottom_bar function not found");
2678        let status_bar_end = status_bar_start
2679            + source[status_bar_start..]
2680                .find("\nfn render_")
2681                .expect("Next function not found");
2682        let status_bar_code = &source[status_bar_start..status_bar_end];
2683
2684        // Verify region is NOT in status bar
2685        assert!(
2686            !status_bar_code.contains(" region ⋮ "),
2687            "Region hotkey must NOT be in status bar - it's only in Space menu!"
2688        );
2689        assert!(
2690            !status_bar_code.contains("␣→r"),
2691            "Region hotkey (␣→r) must NOT be in status bar - it's only in Space menu!"
2692        );
2693        assert!(
2694            !status_bar_code.contains("^R"),
2695            "Region hotkey (^R) must NOT be in status bar - it's only in Space menu!"
2696        );
2697    }
2698
2699    #[test]
2700    fn test_s3_bucket_preview_permanent_redirect_handled() {
2701        // PermanentRedirect errors should be silently handled
2702        // Empty preview should be inserted to prevent retry
2703        let error_msg = "PermanentRedirect";
2704        assert!(error_msg.contains("PermanentRedirect"));
2705
2706        // Verify empty preview prevents retry
2707        let mut preview_map: std::collections::HashMap<String, Vec<crate::app::S3Object>> =
2708            std::collections::HashMap::new();
2709        preview_map.insert("bucket".to_string(), vec![]);
2710        assert!(preview_map.contains_key("bucket"));
2711    }
2712
2713    #[test]
2714    fn test_s3_objects_hint_is_open() {
2715        // Hint should say "open" not "open folder" or "drill down"
2716        let hint = "open";
2717        assert_eq!(hint, "open");
2718        assert_ne!(hint, "drill down");
2719        assert_ne!(hint, "open folder");
2720    }
2721
2722    #[test]
2723    fn test_s3_service_tabs_use_cyan() {
2724        // Service tabs should use cyan color when active
2725        let active_color = Color::Cyan;
2726        assert_eq!(active_color, Color::Cyan);
2727        assert_ne!(active_color, Color::Yellow);
2728    }
2729
2730    #[test]
2731    fn test_s3_column_names_use_orange() {
2732        // Column names should use orange (LightRed) color
2733        let column_color = Color::LightRed;
2734        assert_eq!(column_color, Color::LightRed);
2735    }
2736
2737    #[test]
2738    fn test_s3_bucket_errors_shown_in_expanded_rows() {
2739        // Bucket errors should be stored and displayed in expanded rows
2740        let mut errors: std::collections::HashMap<String, String> =
2741            std::collections::HashMap::new();
2742        errors.insert("bucket".to_string(), "Error message".to_string());
2743        assert!(errors.contains_key("bucket"));
2744        assert_eq!(errors.get("bucket").unwrap(), "Error message");
2745    }
2746
2747    #[test]
2748    fn test_cloudwatch_alarms_page_input() {
2749        // Page input should work for CloudWatch alarms
2750        let mut app = test_app();
2751        app.current_service = Service::CloudWatchAlarms;
2752        app.page_input = "2".to_string();
2753
2754        // Verify page input is set
2755        assert_eq!(app.page_input, "2");
2756    }
2757
2758    #[test]
2759    fn test_tabs_row_shows_profile_info() {
2760        // Tabs row should show profile, account, region, identity, and timestamp
2761        let profile = "default";
2762        let account = "123456789012";
2763        let region = "us-west-2";
2764        let identity = "role:/MyRole";
2765
2766        let info = format!(
2767            "Profile: {} ⋮ Account: {} ⋮ Region: {} ⋮ Identity: {}",
2768            profile, account, region, identity
2769        );
2770        assert!(info.contains("Profile:"));
2771        assert!(info.contains("Account:"));
2772        assert!(info.contains("Region:"));
2773        assert!(info.contains("Identity:"));
2774        assert!(info.contains("⋮"));
2775    }
2776
2777    #[test]
2778    fn test_tabs_row_profile_labels_are_bold() {
2779        // Profile info labels should use bold modifier
2780        let label_style = Style::default()
2781            .fg(Color::White)
2782            .add_modifier(Modifier::BOLD);
2783        assert!(label_style.add_modifier.contains(Modifier::BOLD));
2784    }
2785
2786    #[test]
2787    fn test_profile_info_not_duplicated() {
2788        // Profile info should only appear once (in tabs row, not in top bar)
2789        // Top bar should only show breadcrumbs
2790        let breadcrumbs = "CloudWatch > Alarms";
2791        assert!(!breadcrumbs.contains("Profile:"));
2792        assert!(!breadcrumbs.contains("Account:"));
2793    }
2794
2795    #[test]
2796    fn test_s3_column_headers_are_cyan() {
2797        // All table column headers should use Cyan color
2798        let header_style = Style::default()
2799            .fg(Color::Cyan)
2800            .add_modifier(Modifier::BOLD);
2801        assert_eq!(header_style.fg, Some(Color::Cyan));
2802        assert!(header_style.add_modifier.contains(Modifier::BOLD));
2803    }
2804
2805    #[test]
2806    fn test_s3_nested_objects_can_be_expanded() {
2807        // Nested objects (second level folders) should be expandable
2808        // Visual index should map to actual object including nested items
2809        let mut app = test_app();
2810        app.current_service = Service::S3Buckets;
2811        app.s3_state.current_bucket = Some("bucket".to_string());
2812
2813        // Add a top-level folder
2814        app.s3_state.objects.push(crate::app::S3Object {
2815            key: "folder1/".to_string(),
2816            size: 0,
2817            last_modified: String::new(),
2818            is_prefix: true,
2819            storage_class: String::new(),
2820        });
2821
2822        // Expand it
2823        app.s3_state
2824            .expanded_prefixes
2825            .insert("folder1/".to_string());
2826
2827        // Add nested folder in preview
2828        let nested = vec![crate::app::S3Object {
2829            key: "folder1/subfolder/".to_string(),
2830            size: 0,
2831            last_modified: String::new(),
2832            is_prefix: true,
2833            storage_class: String::new(),
2834        }];
2835        app.s3_state
2836            .prefix_preview
2837            .insert("folder1/".to_string(), nested);
2838
2839        // Visual index 1 should be the nested folder
2840        app.s3_state.selected_object = 1;
2841
2842        // Should be able to expand nested folder
2843        assert!(app.s3_state.current_bucket.is_some());
2844    }
2845
2846    #[test]
2847    fn test_s3_nested_folder_shows_expand_indicator() {
2848        use crate::app::{S3Object, Service};
2849
2850        let mut app = test_app();
2851        app.current_service = Service::S3Buckets;
2852        app.s3_state.current_bucket = Some("test-bucket".to_string());
2853
2854        // Add parent folder
2855        app.s3_state.objects = vec![S3Object {
2856            key: "parent/".to_string(),
2857            size: 0,
2858            last_modified: "2024-01-01T00:00:00Z".to_string(),
2859            is_prefix: true,
2860            storage_class: String::new(),
2861        }];
2862
2863        // Expand parent and add nested folder
2864        app.s3_state.expanded_prefixes.insert("parent/".to_string());
2865        app.s3_state.prefix_preview.insert(
2866            "parent/".to_string(),
2867            vec![S3Object {
2868                key: "parent/child/".to_string(),
2869                size: 0,
2870                last_modified: "2024-01-01T00:00:00Z".to_string(),
2871                is_prefix: true,
2872                storage_class: String::new(),
2873            }],
2874        );
2875
2876        // Nested folder should show ▶ when collapsed
2877        let child = &app.s3_state.prefix_preview.get("parent/").unwrap()[0];
2878        let is_expanded = app.s3_state.expanded_prefixes.contains(&child.key);
2879        let indicator = if is_expanded { "▼ " } else { "▶ " };
2880        assert_eq!(indicator, "▶ ");
2881
2882        // After expanding, should show ▼
2883        app.s3_state
2884            .expanded_prefixes
2885            .insert("parent/child/".to_string());
2886        let is_expanded = app.s3_state.expanded_prefixes.contains(&child.key);
2887        let indicator = if is_expanded { "▼ " } else { "▶ " };
2888        assert_eq!(indicator, "▼ ");
2889    }
2890
2891    #[test]
2892    fn test_tabs_row_always_visible() {
2893        // Tabs row should always be visible (shows profile info)
2894        // Even when on service picker
2895        let app = test_app();
2896        assert!(!app.service_selected); // On service picker
2897                                        // Tabs row should still render with profile info
2898    }
2899
2900    #[test]
2901    fn test_no_duplicate_breadcrumbs_at_root() {
2902        // When at root level (e.g., CloudWatch > Alarms), don't show duplicate breadcrumb
2903        let mut app = test_app();
2904        app.current_service = Service::CloudWatchAlarms;
2905        app.service_selected = true;
2906        app.tabs.push(crate::app::Tab {
2907            service: Service::CloudWatchAlarms,
2908            title: "CloudWatch > Alarms".to_string(),
2909            breadcrumb: "CloudWatch > Alarms".to_string(),
2910        });
2911
2912        // At root level, breadcrumb should not be shown separately
2913        // (it's already in the tab)
2914        assert_eq!(app.breadcrumbs(), "CloudWatch > Alarms");
2915    }
2916
2917    #[test]
2918    fn test_preferences_headers_use_cyan_underline() {
2919        // Preferences section headers should use cyan with underline, not box drawing
2920        let header_style = Style::default()
2921            .fg(Color::Cyan)
2922            .add_modifier(Modifier::BOLD | Modifier::UNDERLINED);
2923        assert_eq!(header_style.fg, Some(Color::Cyan));
2924        assert!(header_style.add_modifier.contains(Modifier::BOLD));
2925        assert!(header_style.add_modifier.contains(Modifier::UNDERLINED));
2926
2927        // Should not use box drawing characters
2928        let header_text = "Columns";
2929        assert!(!header_text.contains("═"));
2930    }
2931
2932    #[test]
2933    fn test_alarm_pagination_shows_actual_pages() {
2934        // Pagination should show "Page X of Y", not page size selector
2935        let page_size = 10;
2936        let total_items = 25;
2937        let total_pages = (total_items + page_size - 1) / page_size;
2938        let current_page = 1;
2939
2940        let pagination = format!("Page {} of {}", current_page, total_pages);
2941        assert_eq!(pagination, "Page 1 of 3");
2942        assert!(!pagination.contains("[1]"));
2943        assert!(!pagination.contains("[2]"));
2944    }
2945
2946    #[test]
2947    fn test_mode_indicator_uses_insert_not_input() {
2948        // Mode indicator should say "INSERT" not "INPUT"
2949        let mode_text = " INSERT ";
2950        assert_eq!(mode_text, " INSERT ");
2951        assert_ne!(mode_text, " INPUT ");
2952    }
2953
2954    #[test]
2955    fn test_service_picker_shows_insert_mode_when_typing() {
2956        // Service picker should show INSERT mode when filter is not empty
2957        let mut app = test_app();
2958        app.mode = Mode::ServicePicker;
2959        app.service_picker.filter = "cloud".to_string();
2960
2961        // Should show INSERT mode
2962        assert!(!app.service_picker.filter.is_empty());
2963    }
2964
2965    #[test]
2966    fn test_log_events_no_horizontal_scrollbar() {
2967        // Log events should not show horizontal scrollbar
2968        // Only vertical scrollbar for navigating events
2969        // Message column truncates with ellipsis, expand to see full content
2970        let app = test_app();
2971
2972        // Log events only have 2 columns: Timestamp and Message
2973        // No horizontal scrolling needed - message truncates
2974        assert_eq!(app.visible_event_columns.len(), 2);
2975
2976        // Horizontal scroll offset should not be used for events
2977        assert_eq!(app.log_groups_state.event_horizontal_scroll, 0);
2978    }
2979
2980    #[test]
2981    fn test_log_events_expansion_stays_visible_when_scrolling() {
2982        // Expanded log event should stay visible when scrolling to other events
2983        // Same behavior as CloudWatch Alarms
2984        let mut app = test_app();
2985
2986        // Expand event at index 0
2987        app.log_groups_state.expanded_event = Some(0);
2988        app.log_groups_state.event_scroll_offset = 0;
2989
2990        // Scroll to event 1
2991        app.log_groups_state.event_scroll_offset = 1;
2992
2993        // Expanded event should still be set and visible
2994        assert_eq!(app.log_groups_state.expanded_event, Some(0));
2995    }
2996
2997    #[test]
2998    fn test_log_events_right_arrow_expands() {
2999        let mut app = test_app();
3000        app.current_service = Service::CloudWatchLogGroups;
3001        app.service_selected = true;
3002        app.view_mode = ViewMode::Events;
3003
3004        app.log_groups_state.log_events = vec![rusticity_core::LogEvent {
3005            timestamp: chrono::Utc::now(),
3006            message: "Test log message".to_string(),
3007        }];
3008        app.log_groups_state.event_scroll_offset = 0;
3009
3010        assert_eq!(app.log_groups_state.expanded_event, None);
3011
3012        // Right arrow - should expand
3013        app.handle_action(Action::NextPane);
3014        assert_eq!(app.log_groups_state.expanded_event, Some(0));
3015    }
3016
3017    #[test]
3018    fn test_log_events_left_arrow_collapses() {
3019        let mut app = test_app();
3020        app.current_service = Service::CloudWatchLogGroups;
3021        app.service_selected = true;
3022        app.view_mode = ViewMode::Events;
3023
3024        app.log_groups_state.log_events = vec![rusticity_core::LogEvent {
3025            timestamp: chrono::Utc::now(),
3026            message: "Test log message".to_string(),
3027        }];
3028        app.log_groups_state.event_scroll_offset = 0;
3029        app.log_groups_state.expanded_event = Some(0);
3030
3031        // Left arrow - should collapse
3032        app.handle_action(Action::PrevPane);
3033        assert_eq!(app.log_groups_state.expanded_event, None);
3034    }
3035
3036    #[test]
3037    fn test_log_events_expanded_content_replaces_tabs() {
3038        // Expanded content should replace tabs with spaces to avoid rendering artifacts
3039        let message_with_tabs = "[INFO]\t2025-10-22T13:41:37.601Z\tb2227e1c";
3040        let cleaned = message_with_tabs.replace('\t', "    ");
3041
3042        assert!(!cleaned.contains('\t'));
3043        assert!(cleaned.contains("    "));
3044        assert_eq!(cleaned, "[INFO]    2025-10-22T13:41:37.601Z    b2227e1c");
3045    }
3046
3047    #[test]
3048    fn test_log_events_navigation_skips_expanded_overlay() {
3049        // When navigating down from an expanded event, selection should skip to next event
3050        // Empty rows are added to table to reserve space, but navigation uses event indices
3051        let mut app = test_app();
3052
3053        // Expand event at index 0
3054        app.log_groups_state.expanded_event = Some(0);
3055        app.log_groups_state.event_scroll_offset = 0;
3056
3057        // Navigate down - should go to event 1, not expanded overlay lines
3058        app.log_groups_state.event_scroll_offset = 1;
3059
3060        // Selection is now on event 1
3061        assert_eq!(app.log_groups_state.event_scroll_offset, 1);
3062
3063        // Expanded event 0 is still expanded
3064        assert_eq!(app.log_groups_state.expanded_event, Some(0));
3065    }
3066
3067    #[test]
3068    fn test_log_events_empty_rows_reserve_space_for_overlay() {
3069        // Empty rows are added to table for expanded content to prevent overlay from covering next events
3070        // This ensures selection highlight is visible on the correct row
3071        let message = "Long message that will wrap across multiple lines when expanded";
3072        let max_width = 50;
3073
3074        // Calculate how many lines this would take
3075        let full_line = format!("Message: {}", message);
3076        let line_count = full_line.len().div_ceil(max_width);
3077
3078        // Should be at least 2 lines for this message
3079        assert!(line_count >= 2);
3080
3081        // Empty rows equal to line_count should be added to reserve space
3082        // This prevents the overlay from covering the next event's selection highlight
3083    }
3084
3085    #[test]
3086    fn test_preferences_title_no_hints() {
3087        // All preferences dialogs should have clean titles without hints
3088        // Hints should be in status bar instead
3089        let s3_title = " Preferences ";
3090        let events_title = " Preferences ";
3091        let alarms_title = " Preferences ";
3092
3093        assert_eq!(s3_title.trim(), "Preferences");
3094        assert_eq!(events_title.trim(), "Preferences");
3095        assert_eq!(alarms_title.trim(), "Preferences");
3096
3097        // No hints in titles
3098        assert!(!s3_title.contains("Space"));
3099        assert!(!events_title.contains("Space"));
3100        assert!(!alarms_title.contains("Tab"));
3101    }
3102
3103    #[test]
3104    fn test_page_navigation_works_for_events() {
3105        // Page navigation (e.g., "2P") should work for log events
3106        let mut app = test_app();
3107        app.view_mode = ViewMode::Events;
3108
3109        // Simulate having 50 events
3110        app.log_groups_state.event_scroll_offset = 0;
3111
3112        // Navigate to page 2 (page_size = 20, so target_index = 20)
3113        let page = 2;
3114        let page_size = 20;
3115        let target_index = (page - 1) * page_size;
3116
3117        assert_eq!(target_index, 20);
3118
3119        // After navigation, page_input should be cleared
3120        app.page_input.clear();
3121        assert!(app.page_input.is_empty());
3122    }
3123
3124    #[test]
3125    fn test_status_bar_shows_tab_hint_for_alarms_preferences() {
3126        // Alarms preferences should show Tab hint in status bar (has multiple sections)
3127        // Other preferences don't need Tab hint
3128        let app = test_app();
3129
3130        // Alarms has sections: Columns, View As, Page Size, Wrap Lines
3131        // So it needs Tab navigation hint
3132        assert_eq!(app.current_service, Service::CloudWatchLogGroups);
3133
3134        // When current_service is CloudWatchAlarms, Tab hint should be shown
3135        // This is checked in the status bar rendering logic
3136    }
3137
3138    #[test]
3139    fn test_column_selector_shows_correct_columns_per_service() {
3140        use crate::app::Service;
3141
3142        // S3 Buckets should show bucket columns
3143        let mut app = test_app();
3144        app.current_service = Service::S3Buckets;
3145        let bucket_col_names: Vec<&str> = app.all_bucket_columns.iter().map(|c| c.name()).collect();
3146        assert_eq!(bucket_col_names, vec!["Name", "Region", "Creation date"]);
3147
3148        // CloudWatch Log Groups should show log group columns
3149        app.current_service = Service::CloudWatchLogGroups;
3150        let log_col_names: Vec<&str> = app.all_columns.iter().map(|c| c.name()).collect();
3151        assert_eq!(
3152            log_col_names,
3153            vec![
3154                "Log group",
3155                "Log class",
3156                "Retention",
3157                "Stored bytes",
3158                "Creation time",
3159                "ARN"
3160            ]
3161        );
3162
3163        // CloudWatch Alarms should show alarm columns
3164        app.current_service = Service::CloudWatchAlarms;
3165        assert!(!app.all_alarm_columns.is_empty());
3166        assert!(
3167            app.all_alarm_columns[0].name().contains("Name")
3168                || app.all_alarm_columns[0].name().contains("Alarm")
3169        );
3170    }
3171
3172    #[test]
3173    fn test_log_groups_preferences_shows_all_six_columns() {
3174        use crate::app::Service;
3175
3176        let mut app = test_app();
3177        app.current_service = Service::CloudWatchLogGroups;
3178
3179        // Verify all 6 columns exist
3180        assert_eq!(app.all_columns.len(), 6);
3181
3182        // Verify each column by name
3183        let col_names: Vec<&str> = app.all_columns.iter().map(|c| c.name()).collect();
3184        assert!(col_names.contains(&"Log group"));
3185        assert!(col_names.contains(&"Log class"));
3186        assert!(col_names.contains(&"Retention"));
3187        assert!(col_names.contains(&"Stored bytes"));
3188        assert!(col_names.contains(&"Creation time"));
3189        assert!(col_names.contains(&"ARN"));
3190    }
3191
3192    #[test]
3193    fn test_stream_preferences_shows_all_columns() {
3194        use crate::app::ViewMode;
3195
3196        let mut app = test_app();
3197        app.view_mode = ViewMode::Detail;
3198
3199        // Verify stream columns exist
3200        assert!(!app.all_stream_columns.is_empty());
3201        assert_eq!(app.all_stream_columns.len(), 7);
3202    }
3203
3204    #[test]
3205    fn test_event_preferences_shows_all_columns() {
3206        use crate::app::ViewMode;
3207
3208        let mut app = test_app();
3209        app.view_mode = ViewMode::Events;
3210
3211        // Verify event columns exist
3212        assert!(!app.all_event_columns.is_empty());
3213        assert_eq!(app.all_event_columns.len(), 5);
3214    }
3215
3216    #[test]
3217    fn test_alarm_preferences_shows_all_columns() {
3218        use crate::app::Service;
3219
3220        let mut app = test_app();
3221        app.current_service = Service::CloudWatchAlarms;
3222
3223        // Verify alarm columns exist
3224        assert!(!app.all_alarm_columns.is_empty());
3225        assert_eq!(app.all_alarm_columns.len(), 16);
3226    }
3227
3228    #[test]
3229    fn test_column_selector_has_scrollbar() {
3230        // Column selector should have scrollbar when items don't fit
3231        // This is rendered in render_column_selector after the list widget
3232        let item_count = 6; // Log groups has 6 columns
3233        assert!(item_count > 0);
3234
3235        // Scrollbar should be rendered with vertical right orientation
3236        // with up/down arrows
3237    }
3238
3239    #[test]
3240    fn test_preferences_scrollbar_only_when_needed() {
3241        // Scrollbar should only appear when content exceeds available height
3242        let item_count = 6;
3243        let height = (item_count as u16 + 2).max(8); // +2 for borders
3244        let max_height_fits = 20; // Large enough to fit all items
3245        let max_height_doesnt_fit = 5; // Too small to fit all items
3246
3247        // When content fits, no scrollbar needed
3248        let needs_scrollbar_fits = height > max_height_fits;
3249        assert!(!needs_scrollbar_fits);
3250
3251        // When content doesn't fit, scrollbar needed
3252        let needs_scrollbar_doesnt_fit = height > max_height_doesnt_fit;
3253        assert!(needs_scrollbar_doesnt_fit);
3254    }
3255
3256    #[test]
3257    fn test_preferences_height_no_extra_padding() {
3258        // Height should be item_count + 2 (for borders), not + 4
3259        let item_count = 6;
3260        let height = (item_count as u16 + 2).max(8);
3261        assert_eq!(height, 8); // 6 + 2 = 8
3262
3263        // Should not have extra empty lines
3264        assert_ne!(height, 10); // Not 6 + 4
3265    }
3266
3267    #[test]
3268    fn test_preferences_uses_absolute_sizing() {
3269        // Preferences should use centered_rect_absolute, not centered_rect (percentages)
3270        // This ensures width/height are in characters, not percentages
3271        let width = 50u16; // 50 characters
3272        let height = 10u16; // 10 lines
3273
3274        // These are absolute values, not percentages
3275        assert!(width <= 100); // Reasonable character width
3276        assert!(height <= 50); // Reasonable line height
3277    }
3278
3279    #[test]
3280    fn test_profile_picker_shows_sort_indicator() {
3281        // Profile picker should show sort on Profile column ascending
3282        let sort_column = "Profile";
3283        let sort_direction = "ASC";
3284
3285        assert_eq!(sort_column, "Profile");
3286        assert_eq!(sort_direction, "ASC");
3287
3288        // Verify arrow would be added
3289        let arrow = if sort_direction == "ASC" {
3290            " ↑"
3291        } else {
3292            " ↓"
3293        };
3294        assert_eq!(arrow, " ↑");
3295    }
3296
3297    #[test]
3298    fn test_session_picker_shows_sort_indicator() {
3299        // Session picker should show sort on Timestamp column descending
3300        let sort_column = "Timestamp";
3301        let sort_direction = "DESC";
3302
3303        assert_eq!(sort_column, "Timestamp");
3304        assert_eq!(sort_direction, "DESC");
3305
3306        // Verify arrow would be added
3307        let arrow = if sort_direction == "ASC" {
3308            " ↑"
3309        } else {
3310            " ↓"
3311        };
3312        assert_eq!(arrow, " ↓");
3313    }
3314
3315    #[test]
3316    fn test_profile_picker_sorted_ascending() {
3317        let mut app = test_app_no_region();
3318        app.available_profiles = vec![
3319            crate::app::AwsProfile {
3320                name: "zebra".to_string(),
3321                region: None,
3322                account: None,
3323                role_arn: None,
3324                source_profile: None,
3325            },
3326            crate::app::AwsProfile {
3327                name: "alpha".to_string(),
3328                region: None,
3329                account: None,
3330                role_arn: None,
3331                source_profile: None,
3332            },
3333        ];
3334
3335        let filtered = app.get_filtered_profiles();
3336        assert_eq!(filtered[0].name, "alpha");
3337        assert_eq!(filtered[1].name, "zebra");
3338    }
3339
3340    #[test]
3341    fn test_session_picker_sorted_descending() {
3342        let mut app = test_app_no_region();
3343        // Sessions should be added in descending timestamp order (newest first)
3344        app.sessions = vec![
3345            crate::session::Session {
3346                id: "2".to_string(),
3347                timestamp: "2024-01-02 10:00:00 UTC".to_string(),
3348                profile: "new".to_string(),
3349                region: "us-east-1".to_string(),
3350                account_id: "123".to_string(),
3351                role_arn: String::new(),
3352                tabs: vec![],
3353            },
3354            crate::session::Session {
3355                id: "1".to_string(),
3356                timestamp: "2024-01-01 10:00:00 UTC".to_string(),
3357                profile: "old".to_string(),
3358                region: "us-east-1".to_string(),
3359                account_id: "123".to_string(),
3360                role_arn: String::new(),
3361                tabs: vec![],
3362            },
3363        ];
3364
3365        let filtered = app.get_filtered_sessions();
3366        // Sessions are already sorted descending by timestamp (newest first)
3367        assert_eq!(filtered[0].profile, "new");
3368        assert_eq!(filtered[1].profile, "old");
3369    }
3370
3371    #[test]
3372    fn test_ecr_encryption_type_aes256_renders_as_aes_dash_256() {
3373        let repo = EcrRepository {
3374            name: "test-repo".to_string(),
3375            uri: "123456789012.dkr.ecr.us-east-1.amazonaws.com/test-repo".to_string(),
3376            created_at: "2024-01-01".to_string(),
3377            tag_immutability: "MUTABLE".to_string(),
3378            encryption_type: "AES256".to_string(),
3379        };
3380
3381        let formatted = match repo.encryption_type.as_str() {
3382            "AES256" => "AES-256".to_string(),
3383            "KMS" => "KMS".to_string(),
3384            other => other.to_string(),
3385        };
3386
3387        assert_eq!(formatted, "AES-256");
3388    }
3389
3390    #[test]
3391    fn test_ecr_encryption_type_kms_unchanged() {
3392        let repo = EcrRepository {
3393            name: "test-repo".to_string(),
3394            uri: "123456789012.dkr.ecr.us-east-1.amazonaws.com/test-repo".to_string(),
3395            created_at: "2024-01-01".to_string(),
3396            tag_immutability: "MUTABLE".to_string(),
3397            encryption_type: "KMS".to_string(),
3398        };
3399
3400        let formatted = match repo.encryption_type.as_str() {
3401            "AES256" => "AES-256".to_string(),
3402            "KMS" => "KMS".to_string(),
3403            other => other.to_string(),
3404        };
3405
3406        assert_eq!(formatted, "KMS");
3407    }
3408
3409    #[test]
3410    fn test_ecr_repo_filter_active_removes_table_focus() {
3411        let mut app = test_app_no_region();
3412        app.current_service = Service::EcrRepositories;
3413        app.mode = Mode::FilterInput;
3414        app.ecr_state.repositories.items = vec![EcrRepository {
3415            name: "test-repo".to_string(),
3416            uri: "123456789012.dkr.ecr.us-east-1.amazonaws.com/test-repo".to_string(),
3417            created_at: "2024-01-01".to_string(),
3418            tag_immutability: "MUTABLE".to_string(),
3419            encryption_type: "AES256".to_string(),
3420        }];
3421
3422        // When in FilterInput mode, table should not be active
3423        assert_eq!(app.mode, Mode::FilterInput);
3424        // This would be checked in render logic: is_active: app.mode != Mode::FilterInput
3425    }
3426
3427    #[test]
3428    fn test_ecr_image_filter_active_removes_table_focus() {
3429        let mut app = test_app_no_region();
3430        app.current_service = Service::EcrRepositories;
3431        app.ecr_state.current_repository = Some("test-repo".to_string());
3432        app.mode = Mode::FilterInput;
3433        app.ecr_state.images.items = vec![EcrImage {
3434            tag: "v1.0.0".to_string(),
3435            artifact_type: "application/vnd.docker.container.image.v1+json".to_string(),
3436            pushed_at: "2024-01-01".to_string(),
3437            size_bytes: 104857600,
3438            uri: "123456789012.dkr.ecr.us-east-1.amazonaws.com/test-repo:v1.0.0".to_string(),
3439            digest: "sha256:abc123".to_string(),
3440            last_pull_time: "2024-01-02".to_string(),
3441        }];
3442
3443        // When in FilterInput mode, table should not be active
3444        assert_eq!(app.mode, Mode::FilterInput);
3445        // This would be checked in render logic: is_active: app.mode != Mode::FilterInput
3446    }
3447
3448    #[test]
3449    fn test_ecr_filter_escape_returns_to_normal_mode() {
3450        let mut app = test_app_no_region();
3451        app.current_service = Service::EcrRepositories;
3452        app.mode = Mode::FilterInput;
3453        app.ecr_state.repositories.filter = "test".to_string();
3454
3455        // Simulate Escape key (CloseMenu action)
3456        app.handle_action(crate::keymap::Action::CloseMenu);
3457
3458        assert_eq!(app.mode, Mode::Normal);
3459    }
3460
3461    #[test]
3462    fn test_ecr_repos_no_scrollbar_when_all_fit() {
3463        // ECR repos table should not show scrollbar when all paginated items fit
3464        let mut app = test_app_no_region();
3465        app.current_service = Service::EcrRepositories;
3466        app.ecr_state.repositories.items = (0..50)
3467            .map(|i| EcrRepository {
3468                name: format!("repo{}", i),
3469                uri: format!("123456789012.dkr.ecr.us-east-1.amazonaws.com/repo{}", i),
3470                created_at: "2024-01-01".to_string(),
3471                tag_immutability: "MUTABLE".to_string(),
3472                encryption_type: "AES256".to_string(),
3473            })
3474            .collect();
3475
3476        // With 50 repos on page and typical terminal height, scrollbar should not appear
3477        // Scrollbar logic: row_count > (area_height - 3)
3478        let row_count = 50;
3479        let typical_area_height: u16 = 60;
3480        let available_height = typical_area_height.saturating_sub(3);
3481
3482        assert!(
3483            row_count <= available_height as usize,
3484            "50 repos should fit without scrollbar"
3485        );
3486    }
3487
3488    #[test]
3489    fn test_lambda_default_columns() {
3490        let app = test_app_no_region();
3491
3492        assert_eq!(app.lambda_state.visible_columns.len(), 6);
3493        assert_eq!(
3494            app.lambda_state.visible_columns[0],
3495            crate::app::LambdaColumn::Name
3496        );
3497        assert_eq!(
3498            app.lambda_state.visible_columns[1],
3499            crate::app::LambdaColumn::Runtime
3500        );
3501        assert_eq!(
3502            app.lambda_state.visible_columns[2],
3503            crate::app::LambdaColumn::CodeSize
3504        );
3505        assert_eq!(
3506            app.lambda_state.visible_columns[3],
3507            crate::app::LambdaColumn::MemoryMb
3508        );
3509        assert_eq!(
3510            app.lambda_state.visible_columns[4],
3511            crate::app::LambdaColumn::TimeoutSeconds
3512        );
3513        assert_eq!(
3514            app.lambda_state.visible_columns[5],
3515            crate::app::LambdaColumn::LastModified
3516        );
3517    }
3518
3519    #[test]
3520    fn test_lambda_all_columns_available() {
3521        let all_columns = crate::app::LambdaColumn::all();
3522
3523        assert_eq!(all_columns.len(), 9);
3524        assert!(all_columns.contains(&crate::app::LambdaColumn::Name));
3525        assert!(all_columns.contains(&crate::app::LambdaColumn::Description));
3526        assert!(all_columns.contains(&crate::app::LambdaColumn::PackageType));
3527        assert!(all_columns.contains(&crate::app::LambdaColumn::Runtime));
3528        assert!(all_columns.contains(&crate::app::LambdaColumn::Architecture));
3529        assert!(all_columns.contains(&crate::app::LambdaColumn::CodeSize));
3530        assert!(all_columns.contains(&crate::app::LambdaColumn::MemoryMb));
3531        assert!(all_columns.contains(&crate::app::LambdaColumn::TimeoutSeconds));
3532        assert!(all_columns.contains(&crate::app::LambdaColumn::LastModified));
3533    }
3534
3535    #[test]
3536    fn test_lambda_filter_active_removes_table_focus() {
3537        let mut app = test_app_no_region();
3538        app.current_service = Service::LambdaFunctions;
3539        app.mode = Mode::FilterInput;
3540        app.lambda_state.table.items = vec![crate::app::LambdaFunction {
3541            arn: "arn:aws:lambda:us-east-1:123456789012:function:test".to_string(),
3542            application: None,
3543            name: "test-function".to_string(),
3544            description: "Test function".to_string(),
3545            package_type: "Zip".to_string(),
3546            runtime: "python3.12".to_string(),
3547            architecture: "x86_64".to_string(),
3548            code_size: 1024,
3549            code_sha256: "test-sha256".to_string(),
3550            memory_mb: 128,
3551            timeout_seconds: 3,
3552            last_modified: "2024-01-01T00:00:00.000+0000".to_string(),
3553            layers: vec![],
3554        }];
3555
3556        assert_eq!(app.mode, Mode::FilterInput);
3557    }
3558
3559    #[test]
3560    fn test_lambda_default_page_size() {
3561        let app = test_app_no_region();
3562
3563        assert_eq!(app.lambda_state.table.page_size, PageSize::Fifty);
3564        assert_eq!(app.lambda_state.table.page_size.value(), 50);
3565    }
3566
3567    #[test]
3568    fn test_lambda_pagination() {
3569        let mut app = test_app_no_region();
3570        app.current_service = Service::LambdaFunctions;
3571        app.lambda_state.table.page_size = PageSize::Ten;
3572        app.lambda_state.table.items = (0..25)
3573            .map(|i| crate::app::LambdaFunction {
3574                arn: "arn:aws:lambda:us-east-1:123456789012:function:test".to_string(),
3575                application: None,
3576                name: format!("function-{}", i),
3577                description: format!("Function {}", i),
3578                package_type: "Zip".to_string(),
3579                runtime: "python3.12".to_string(),
3580                architecture: "x86_64".to_string(),
3581                code_size: 1024,
3582                code_sha256: "test-sha256".to_string(),
3583                memory_mb: 128,
3584                timeout_seconds: 3,
3585                last_modified: "2024-01-01T00:00:00.000+0000".to_string(),
3586                layers: vec![],
3587            })
3588            .collect();
3589
3590        let page_size = app.lambda_state.table.page_size.value();
3591        let total_pages = app.lambda_state.table.items.len().div_ceil(page_size);
3592
3593        assert_eq!(page_size, 10);
3594        assert_eq!(total_pages, 3);
3595    }
3596
3597    #[test]
3598    fn test_lambda_filter_by_name() {
3599        let mut app = test_app_no_region();
3600        app.lambda_state.table.items = vec![
3601            crate::app::LambdaFunction {
3602                arn: "arn:aws:lambda:us-east-1:123456789012:function:test".to_string(),
3603                application: None,
3604                name: "api-handler".to_string(),
3605                description: "API handler".to_string(),
3606                package_type: "Zip".to_string(),
3607                runtime: "python3.12".to_string(),
3608                architecture: "x86_64".to_string(),
3609                code_size: 1024,
3610                code_sha256: "test-sha256".to_string(),
3611                memory_mb: 128,
3612                timeout_seconds: 3,
3613                last_modified: "2024-01-01T00:00:00.000+0000".to_string(),
3614                layers: vec![],
3615            },
3616            crate::app::LambdaFunction {
3617                arn: "arn:aws:lambda:us-east-1:123456789012:function:test".to_string(),
3618                application: None,
3619                name: "data-processor".to_string(),
3620                description: "Data processor".to_string(),
3621                package_type: "Zip".to_string(),
3622                runtime: "nodejs20.x".to_string(),
3623                architecture: "arm64".to_string(),
3624                code_size: 2048,
3625                code_sha256: "test-sha256".to_string(),
3626                memory_mb: 256,
3627                timeout_seconds: 30,
3628                last_modified: "2024-01-02T00:00:00.000+0000".to_string(),
3629                layers: vec![],
3630            },
3631        ];
3632        app.lambda_state.table.filter = "api".to_string();
3633
3634        let filtered: Vec<_> = app
3635            .lambda_state
3636            .table
3637            .items
3638            .iter()
3639            .filter(|f| {
3640                app.lambda_state.table.filter.is_empty()
3641                    || f.name
3642                        .to_lowercase()
3643                        .contains(&app.lambda_state.table.filter.to_lowercase())
3644                    || f.description
3645                        .to_lowercase()
3646                        .contains(&app.lambda_state.table.filter.to_lowercase())
3647                    || f.runtime
3648                        .to_lowercase()
3649                        .contains(&app.lambda_state.table.filter.to_lowercase())
3650            })
3651            .collect();
3652
3653        assert_eq!(filtered.len(), 1);
3654        assert_eq!(filtered[0].name, "api-handler");
3655    }
3656
3657    #[test]
3658    fn test_lambda_filter_by_runtime() {
3659        let mut app = test_app_no_region();
3660        app.lambda_state.table.items = vec![
3661            crate::app::LambdaFunction {
3662                arn: "arn:aws:lambda:us-east-1:123456789012:function:test".to_string(),
3663                application: None,
3664                name: "python-func".to_string(),
3665                description: "Python function".to_string(),
3666                package_type: "Zip".to_string(),
3667                runtime: "python3.12".to_string(),
3668                architecture: "x86_64".to_string(),
3669                code_size: 1024,
3670                code_sha256: "test-sha256".to_string(),
3671                memory_mb: 128,
3672                timeout_seconds: 3,
3673                last_modified: "2024-01-01T00:00:00.000+0000".to_string(),
3674                layers: vec![],
3675            },
3676            crate::app::LambdaFunction {
3677                arn: "arn:aws:lambda:us-east-1:123456789012:function:test".to_string(),
3678                application: None,
3679                name: "node-func".to_string(),
3680                description: "Node function".to_string(),
3681                package_type: "Zip".to_string(),
3682                runtime: "nodejs20.x".to_string(),
3683                architecture: "arm64".to_string(),
3684                code_size: 2048,
3685                code_sha256: "test-sha256".to_string(),
3686                memory_mb: 256,
3687                timeout_seconds: 30,
3688                last_modified: "2024-01-02T00:00:00.000+0000".to_string(),
3689                layers: vec![],
3690            },
3691        ];
3692        app.lambda_state.table.filter = "python".to_string();
3693
3694        let filtered: Vec<_> = app
3695            .lambda_state
3696            .table
3697            .items
3698            .iter()
3699            .filter(|f| {
3700                app.lambda_state.table.filter.is_empty()
3701                    || f.name
3702                        .to_lowercase()
3703                        .contains(&app.lambda_state.table.filter.to_lowercase())
3704                    || f.description
3705                        .to_lowercase()
3706                        .contains(&app.lambda_state.table.filter.to_lowercase())
3707                    || f.runtime
3708                        .to_lowercase()
3709                        .contains(&app.lambda_state.table.filter.to_lowercase())
3710            })
3711            .collect();
3712
3713        assert_eq!(filtered.len(), 1);
3714        assert_eq!(filtered[0].runtime, "python3.12");
3715    }
3716
3717    #[test]
3718    fn test_lambda_page_size_changes_in_preferences() {
3719        let mut app = test_app_no_region();
3720        app.current_service = Service::LambdaFunctions;
3721        app.lambda_state.table.page_size = PageSize::Fifty;
3722
3723        // Simulate opening preferences and changing page size
3724        app.mode = Mode::ColumnSelector;
3725        // Index for page size options: 0=Columns header, 1-9=columns, 10=empty, 11=PageSize header, 12=10, 13=25, 14=50, 15=100
3726        app.column_selector_index = 12; // 10 resources
3727        app.handle_action(crate::keymap::Action::ToggleColumn);
3728
3729        assert_eq!(app.lambda_state.table.page_size, PageSize::Ten);
3730    }
3731
3732    #[test]
3733    fn test_lambda_preferences_shows_page_sizes() {
3734        let app = test_app_no_region();
3735        let mut app = app;
3736        app.current_service = Service::LambdaFunctions;
3737
3738        // Verify all page sizes are available
3739        let page_sizes = vec![
3740            PageSize::Ten,
3741            PageSize::TwentyFive,
3742            PageSize::Fifty,
3743            PageSize::OneHundred,
3744        ];
3745
3746        for size in page_sizes {
3747            app.lambda_state.table.page_size = size;
3748            assert_eq!(app.lambda_state.table.page_size, size);
3749        }
3750    }
3751
3752    #[test]
3753    fn test_lambda_pagination_respects_page_size() {
3754        let mut app = test_app_no_region();
3755        app.current_service = Service::LambdaFunctions;
3756        app.lambda_state.table.items = (0..100)
3757            .map(|i| crate::app::LambdaFunction {
3758                arn: "arn:aws:lambda:us-east-1:123456789012:function:test".to_string(),
3759                application: None,
3760                name: format!("function-{}", i),
3761                description: format!("Function {}", i),
3762                package_type: "Zip".to_string(),
3763                runtime: "python3.12".to_string(),
3764                architecture: "x86_64".to_string(),
3765                code_size: 1024,
3766                code_sha256: "test-sha256".to_string(),
3767                memory_mb: 128,
3768                timeout_seconds: 3,
3769                last_modified: "2024-01-01T00:00:00.000+0000".to_string(),
3770                layers: vec![],
3771            })
3772            .collect();
3773
3774        // Test with page size 10
3775        app.lambda_state.table.page_size = PageSize::Ten;
3776        let page_size = app.lambda_state.table.page_size.value();
3777        let total_pages = app.lambda_state.table.items.len().div_ceil(page_size);
3778        assert_eq!(page_size, 10);
3779        assert_eq!(total_pages, 10);
3780
3781        // Test with page size 25
3782        app.lambda_state.table.page_size = PageSize::TwentyFive;
3783        let page_size = app.lambda_state.table.page_size.value();
3784        let total_pages = app.lambda_state.table.items.len().div_ceil(page_size);
3785        assert_eq!(page_size, 25);
3786        assert_eq!(total_pages, 4);
3787
3788        // Test with page size 50
3789        app.lambda_state.table.page_size = PageSize::Fifty;
3790        let page_size = app.lambda_state.table.page_size.value();
3791        let total_pages = app.lambda_state.table.items.len().div_ceil(page_size);
3792        assert_eq!(page_size, 50);
3793        assert_eq!(total_pages, 2);
3794    }
3795
3796    #[test]
3797    fn test_lambda_next_preferences_cycles_sections() {
3798        let mut app = test_app_no_region();
3799        app.current_service = Service::LambdaFunctions;
3800        app.mode = Mode::ColumnSelector;
3801
3802        // Start at columns section
3803        app.column_selector_index = 0;
3804        app.handle_action(crate::keymap::Action::NextPreferences);
3805
3806        // Should jump to page size section (9 columns + 1 empty + 1 header = 11)
3807        assert_eq!(app.column_selector_index, 11);
3808
3809        // Next should cycle back to columns
3810        app.handle_action(crate::keymap::Action::NextPreferences);
3811        assert_eq!(app.column_selector_index, 0);
3812    }
3813
3814    #[test]
3815    fn test_lambda_drill_down_on_enter() {
3816        let mut app = test_app_no_region();
3817        app.current_service = Service::LambdaFunctions;
3818        app.service_selected = true;
3819        app.mode = Mode::Normal;
3820        app.lambda_state.table.items = vec![crate::app::LambdaFunction {
3821            arn: "arn:aws:lambda:us-east-1:123456789012:function:test".to_string(),
3822            application: None,
3823            name: "test-function".to_string(),
3824            description: "Test function".to_string(),
3825            package_type: "Zip".to_string(),
3826            runtime: "python3.12".to_string(),
3827            architecture: "x86_64".to_string(),
3828            code_size: 1024,
3829            code_sha256: "test-sha256".to_string(),
3830            memory_mb: 128,
3831            timeout_seconds: 3,
3832            last_modified: "2024-01-01 00:00:00 (UTC)".to_string(),
3833            layers: vec![],
3834        }];
3835        app.lambda_state.table.selected = 0;
3836
3837        // Drill down into function
3838        app.handle_action(crate::keymap::Action::Select);
3839
3840        assert_eq!(
3841            app.lambda_state.current_function,
3842            Some("test-function".to_string())
3843        );
3844        assert_eq!(
3845            app.lambda_state.detail_tab,
3846            crate::app::LambdaDetailTab::Code
3847        );
3848    }
3849
3850    #[test]
3851    fn test_lambda_go_back_from_detail() {
3852        let mut app = test_app_no_region();
3853        app.current_service = Service::LambdaFunctions;
3854        app.lambda_state.current_function = Some("test-function".to_string());
3855
3856        app.handle_action(crate::keymap::Action::GoBack);
3857
3858        assert_eq!(app.lambda_state.current_function, None);
3859    }
3860
3861    #[test]
3862    fn test_lambda_detail_tab_cycling() {
3863        let mut app = test_app_no_region();
3864        app.current_service = Service::LambdaFunctions;
3865        app.lambda_state.current_function = Some("test-function".to_string());
3866        app.lambda_state.detail_tab = crate::app::LambdaDetailTab::Code;
3867
3868        app.handle_action(crate::keymap::Action::NextDetailTab);
3869        assert_eq!(
3870            app.lambda_state.detail_tab,
3871            crate::app::LambdaDetailTab::Configuration
3872        );
3873
3874        app.handle_action(crate::keymap::Action::NextDetailTab);
3875        assert_eq!(
3876            app.lambda_state.detail_tab,
3877            crate::app::LambdaDetailTab::Aliases
3878        );
3879
3880        app.handle_action(crate::keymap::Action::NextDetailTab);
3881        assert_eq!(
3882            app.lambda_state.detail_tab,
3883            crate::app::LambdaDetailTab::Versions
3884        );
3885
3886        app.handle_action(crate::keymap::Action::NextDetailTab);
3887        assert_eq!(
3888            app.lambda_state.detail_tab,
3889            crate::app::LambdaDetailTab::Code
3890        );
3891    }
3892
3893    #[test]
3894    fn test_lambda_breadcrumbs_with_function_name() {
3895        let mut app = test_app_no_region();
3896        app.current_service = Service::LambdaFunctions;
3897        app.service_selected = true;
3898
3899        // List view
3900        let breadcrumb = app.breadcrumbs();
3901        assert_eq!(breadcrumb, "Lambda > Functions");
3902
3903        // Detail view
3904        app.lambda_state.current_function = Some("my-function".to_string());
3905        let breadcrumb = app.breadcrumbs();
3906        assert_eq!(breadcrumb, "Lambda > my-function");
3907    }
3908
3909    #[test]
3910    fn test_lambda_console_url() {
3911        let mut app = test_app_no_region();
3912        app.current_service = Service::LambdaFunctions;
3913        app.config.region = "us-east-1".to_string();
3914
3915        // List view
3916        let url = app.get_console_url();
3917        assert_eq!(
3918            url,
3919            "https://us-east-1.console.aws.amazon.com/lambda/home?region=us-east-1#/functions"
3920        );
3921
3922        // Detail view
3923        app.lambda_state.current_function = Some("my-function".to_string());
3924        let url = app.get_console_url();
3925        assert_eq!(
3926            url,
3927            "https://us-east-1.console.aws.amazon.com/lambda/home?region=us-east-1#/functions/my-function"
3928        );
3929    }
3930
3931    #[test]
3932    fn test_lambda_last_modified_format() {
3933        let func = crate::app::LambdaFunction {
3934            arn: "arn:aws:lambda:us-east-1:123456789012:function:test".to_string(),
3935            application: None,
3936            name: "test-function".to_string(),
3937            description: "Test function".to_string(),
3938            package_type: "Zip".to_string(),
3939            runtime: "python3.12".to_string(),
3940            architecture: "x86_64".to_string(),
3941            code_size: 1024,
3942            code_sha256: "test-sha256".to_string(),
3943            memory_mb: 128,
3944            timeout_seconds: 3,
3945            last_modified: "2024-01-01 12:30:45 (UTC)".to_string(),
3946            layers: vec![],
3947        };
3948
3949        // Verify format matches our (UTC) pattern
3950        assert!(func.last_modified.contains("(UTC)"));
3951        assert!(func.last_modified.contains("2024-01-01"));
3952    }
3953
3954    #[test]
3955    fn test_lambda_expand_on_right_arrow() {
3956        let mut app = test_app_no_region();
3957        app.current_service = Service::LambdaFunctions;
3958        app.service_selected = true;
3959        app.mode = Mode::Normal;
3960        app.lambda_state.table.items = vec![crate::app::LambdaFunction {
3961            arn: "arn:aws:lambda:us-east-1:123456789012:function:test".to_string(),
3962            application: None,
3963            name: "test-function".to_string(),
3964            description: "Test function".to_string(),
3965            package_type: "Zip".to_string(),
3966            runtime: "python3.12".to_string(),
3967            architecture: "x86_64".to_string(),
3968            code_size: 1024,
3969            code_sha256: "test-sha256".to_string(),
3970            memory_mb: 128,
3971            timeout_seconds: 3,
3972            last_modified: "2024-01-01 00:00:00 (UTC)".to_string(),
3973            layers: vec![],
3974        }];
3975        app.lambda_state.table.selected = 0;
3976
3977        app.handle_action(crate::keymap::Action::NextPane);
3978
3979        assert_eq!(app.lambda_state.table.expanded_item, Some(0));
3980    }
3981
3982    #[test]
3983    fn test_lambda_collapse_on_left_arrow() {
3984        let mut app = test_app_no_region();
3985        app.current_service = Service::LambdaFunctions;
3986        app.service_selected = true;
3987        app.mode = Mode::Normal;
3988        app.lambda_state.current_function = None; // In list view
3989        app.lambda_state.table.expanded_item = Some(0);
3990
3991        app.handle_action(crate::keymap::Action::PrevPane);
3992
3993        assert_eq!(app.lambda_state.table.expanded_item, None);
3994    }
3995
3996    #[test]
3997    fn test_lambda_filter_activation() {
3998        let mut app = test_app_no_region();
3999        app.current_service = Service::LambdaFunctions;
4000        app.service_selected = true;
4001        app.mode = Mode::Normal;
4002
4003        app.handle_action(crate::keymap::Action::StartFilter);
4004
4005        assert_eq!(app.mode, Mode::FilterInput);
4006    }
4007
4008    #[test]
4009    fn test_lambda_filter_backspace() {
4010        let mut app = test_app_no_region();
4011        app.current_service = Service::LambdaFunctions;
4012        app.mode = Mode::FilterInput;
4013        app.lambda_state.table.filter = "test".to_string();
4014
4015        app.handle_action(crate::keymap::Action::FilterBackspace);
4016
4017        assert_eq!(app.lambda_state.table.filter, "tes");
4018    }
4019
4020    #[test]
4021    fn test_lambda_sorted_by_last_modified_desc() {
4022        let func1 = crate::app::LambdaFunction {
4023            arn: "arn:aws:lambda:us-east-1:123456789012:function:test".to_string(),
4024            application: None,
4025            name: "func1".to_string(),
4026            description: String::new(),
4027            package_type: "Zip".to_string(),
4028            runtime: "python3.12".to_string(),
4029            architecture: "x86_64".to_string(),
4030            code_size: 1024,
4031            code_sha256: "test-sha256".to_string(),
4032            memory_mb: 128,
4033            timeout_seconds: 3,
4034            last_modified: "2024-01-01 00:00:00 (UTC)".to_string(),
4035            layers: vec![],
4036        };
4037        let func2 = crate::app::LambdaFunction {
4038            arn: "arn:aws:lambda:us-east-1:123456789012:function:test".to_string(),
4039            application: None,
4040            name: "func2".to_string(),
4041            description: String::new(),
4042            package_type: "Zip".to_string(),
4043            runtime: "python3.12".to_string(),
4044            architecture: "x86_64".to_string(),
4045            code_size: 1024,
4046            code_sha256: "test-sha256".to_string(),
4047            memory_mb: 128,
4048            timeout_seconds: 3,
4049            last_modified: "2024-12-31 00:00:00 (UTC)".to_string(),
4050            layers: vec![],
4051        };
4052
4053        let mut functions = [func1.clone(), func2.clone()].to_vec();
4054        functions.sort_by(|a, b| b.last_modified.cmp(&a.last_modified));
4055
4056        // func2 should be first (newer)
4057        assert_eq!(functions[0].name, "func2");
4058        assert_eq!(functions[1].name, "func1");
4059    }
4060
4061    #[test]
4062    fn test_lambda_code_properties_has_sha256() {
4063        let func = crate::app::LambdaFunction {
4064            arn: "arn:aws:lambda:us-east-1:123456789012:function:test".to_string(),
4065            application: None,
4066            name: "test-function".to_string(),
4067            description: "Test".to_string(),
4068            package_type: "Zip".to_string(),
4069            runtime: "python3.12".to_string(),
4070            architecture: "x86_64".to_string(),
4071            code_size: 2600,
4072            code_sha256: "HHn6CTPhEnmSfX9I/dozcFFLQXUTDFapBAkzjVj9UxE=".to_string(),
4073            memory_mb: 128,
4074            timeout_seconds: 3,
4075            last_modified: "2024-01-01 00:00:00 (UTC)".to_string(),
4076            layers: vec![],
4077        };
4078
4079        assert!(!func.code_sha256.is_empty());
4080        assert_eq!(
4081            func.code_sha256,
4082            "HHn6CTPhEnmSfX9I/dozcFFLQXUTDFapBAkzjVj9UxE="
4083        );
4084    }
4085
4086    #[test]
4087    fn test_lambda_name_column_has_expand_symbol() {
4088        let func = crate::app::LambdaFunction {
4089            arn: "arn:aws:lambda:us-east-1:123456789012:function:test".to_string(),
4090            application: None,
4091            name: "test-function".to_string(),
4092            description: "Test".to_string(),
4093            package_type: "Zip".to_string(),
4094            runtime: "python3.12".to_string(),
4095            architecture: "x86_64".to_string(),
4096            code_size: 1024,
4097            code_sha256: "test-sha256".to_string(),
4098            memory_mb: 128,
4099            timeout_seconds: 3,
4100            last_modified: "2024-01-01 00:00:00 (UTC)".to_string(),
4101            layers: vec![],
4102        };
4103
4104        // Test collapsed state
4105        let symbol_collapsed = crate::ui::table::CURSOR_COLLAPSED;
4106        let rendered_collapsed = format!("{} {}", symbol_collapsed, func.name);
4107        assert!(rendered_collapsed.contains(symbol_collapsed));
4108        assert!(rendered_collapsed.contains("test-function"));
4109
4110        // Test expanded state
4111        let symbol_expanded = crate::ui::table::CURSOR_EXPANDED;
4112        let rendered_expanded = format!("{} {}", symbol_expanded, func.name);
4113        assert!(rendered_expanded.contains(symbol_expanded));
4114        assert!(rendered_expanded.contains("test-function"));
4115
4116        // Verify symbols are different
4117        assert_ne!(symbol_collapsed, symbol_expanded);
4118    }
4119
4120    #[test]
4121    fn test_lambda_last_modified_column_width() {
4122        // Verify width is sufficient for "2025-10-31 08:37:46 (UTC)" (25 chars)
4123        let timestamp = "2025-10-31 08:37:46 (UTC)";
4124        assert_eq!(timestamp.len(), 25);
4125
4126        // Column width should be at least 27 to have some padding
4127        let width = 27u16;
4128        assert!(width >= timestamp.len() as u16);
4129    }
4130
4131    #[test]
4132    fn test_lambda_code_properties_has_info_and_kms_sections() {
4133        let mut app = test_app_no_region();
4134        app.current_service = Service::LambdaFunctions;
4135        app.lambda_state.current_function = Some("test-function".to_string());
4136        app.lambda_state.detail_tab = crate::app::LambdaDetailTab::Code;
4137        app.lambda_state.table.items = vec![crate::app::LambdaFunction {
4138            arn: "arn:aws:lambda:us-east-1:123456789012:function:test".to_string(),
4139            application: None,
4140            name: "test-function".to_string(),
4141            description: "Test".to_string(),
4142            package_type: "Zip".to_string(),
4143            runtime: "python3.12".to_string(),
4144            architecture: "x86_64".to_string(),
4145            code_size: 2600,
4146            code_sha256: "HHn6CTPhEnmSfX9I/dozcFFLQXUTDFapBAkzjVj9UxE=".to_string(),
4147            memory_mb: 128,
4148            timeout_seconds: 3,
4149            last_modified: "2024-01-01 00:00:00 (UTC)".to_string(),
4150            layers: vec![],
4151        }];
4152
4153        // Verify we're in Code tab
4154        assert_eq!(
4155            app.lambda_state.detail_tab,
4156            crate::app::LambdaDetailTab::Code
4157        );
4158
4159        // Verify function exists
4160        assert!(app.lambda_state.current_function.is_some());
4161        assert_eq!(app.lambda_state.table.items.len(), 1);
4162
4163        // Info section should have: Package size, SHA256 hash, Last modified
4164        let func = &app.lambda_state.table.items[0];
4165        assert!(!func.code_sha256.is_empty());
4166        assert!(!func.last_modified.is_empty());
4167        assert!(func.code_size > 0);
4168    }
4169
4170    #[test]
4171    fn test_lambda_pagination_navigation() {
4172        let mut app = test_app_no_region();
4173        app.current_service = Service::LambdaFunctions;
4174        app.service_selected = true;
4175        app.mode = Mode::Normal;
4176        app.lambda_state.table.page_size = PageSize::Ten;
4177
4178        // Create 25 functions
4179        app.lambda_state.table.items = (0..25)
4180            .map(|i| crate::app::LambdaFunction {
4181                arn: "arn:aws:lambda:us-east-1:123456789012:function:test".to_string(),
4182                application: None,
4183                name: format!("function-{}", i),
4184                description: "Test".to_string(),
4185                package_type: "Zip".to_string(),
4186                runtime: "python3.12".to_string(),
4187                architecture: "x86_64".to_string(),
4188                code_size: 1024,
4189                code_sha256: "test-sha256".to_string(),
4190                memory_mb: 128,
4191                timeout_seconds: 3,
4192                last_modified: "2024-01-01 00:00:00 (UTC)".to_string(),
4193                layers: vec![],
4194            })
4195            .collect();
4196
4197        // Start at index 0 (page 0)
4198        app.lambda_state.table.selected = 0;
4199        let page_size = app.lambda_state.table.page_size.value();
4200        let current_page = app.lambda_state.table.selected / page_size;
4201        assert_eq!(current_page, 0);
4202        assert_eq!(app.lambda_state.table.selected % page_size, 0);
4203
4204        // Navigate to index 10 (page 1)
4205        app.lambda_state.table.selected = 10;
4206        let current_page = app.lambda_state.table.selected / page_size;
4207        assert_eq!(current_page, 1);
4208        assert_eq!(app.lambda_state.table.selected % page_size, 0);
4209
4210        // Navigate to index 15 (page 1, item 5)
4211        app.lambda_state.table.selected = 15;
4212        let current_page = app.lambda_state.table.selected / page_size;
4213        assert_eq!(current_page, 1);
4214        assert_eq!(app.lambda_state.table.selected % page_size, 5);
4215    }
4216
4217    #[test]
4218    fn test_lambda_pagination_with_100_functions() {
4219        let mut app = test_app_no_region();
4220        app.current_service = Service::LambdaFunctions;
4221        app.service_selected = true;
4222        app.mode = Mode::Normal;
4223        app.lambda_state.table.page_size = PageSize::Fifty;
4224
4225        // Create 100 functions (simulating real scenario)
4226        app.lambda_state.table.items = (0..100)
4227            .map(|i| crate::app::LambdaFunction {
4228                arn: format!("arn:aws:lambda:us-east-1:123456789012:function:func-{}", i),
4229                application: None,
4230                name: format!("function-{:03}", i),
4231                description: format!("Function {}", i),
4232                package_type: "Zip".to_string(),
4233                runtime: "python3.12".to_string(),
4234                architecture: "x86_64".to_string(),
4235                code_size: 1024 + i,
4236                code_sha256: format!("sha256-{}", i),
4237                memory_mb: 128,
4238                timeout_seconds: 3,
4239                last_modified: "2024-01-01 00:00:00 (UTC)".to_string(),
4240                layers: vec![],
4241            })
4242            .collect();
4243
4244        let page_size = app.lambda_state.table.page_size.value();
4245        assert_eq!(page_size, 50);
4246
4247        // Page 0: items 0-49
4248        app.lambda_state.table.selected = 0;
4249        let current_page = app.lambda_state.table.selected / page_size;
4250        assert_eq!(current_page, 0);
4251
4252        app.lambda_state.table.selected = 49;
4253        let current_page = app.lambda_state.table.selected / page_size;
4254        assert_eq!(current_page, 0);
4255
4256        // Page 1: items 50-99
4257        app.lambda_state.table.selected = 50;
4258        let current_page = app.lambda_state.table.selected / page_size;
4259        assert_eq!(current_page, 1);
4260
4261        app.lambda_state.table.selected = 99;
4262        let current_page = app.lambda_state.table.selected / page_size;
4263        assert_eq!(current_page, 1);
4264
4265        // Verify pagination text
4266        let filtered_count = app.lambda_state.table.items.len();
4267        let total_pages = filtered_count.div_ceil(page_size);
4268        assert_eq!(total_pages, 2);
4269    }
4270
4271    #[test]
4272    fn test_pagination_color_matches_border_color() {
4273        use ratatui::style::{Color, Style};
4274
4275        // When active (not in FilterInput mode), pagination should be green, border white
4276        let is_filter_input = false;
4277        let pagination_style = if is_filter_input {
4278            Style::default()
4279        } else {
4280            Style::default().fg(Color::Green)
4281        };
4282        let border_style = if is_filter_input {
4283            Style::default().fg(Color::Yellow)
4284        } else {
4285            Style::default()
4286        };
4287        assert_eq!(pagination_style.fg, Some(Color::Green));
4288        assert_eq!(border_style.fg, None); // White (default)
4289
4290        // When in FilterInput mode, pagination should be white (default), border yellow
4291        let is_filter_input = true;
4292        let pagination_style = if is_filter_input {
4293            Style::default()
4294        } else {
4295            Style::default().fg(Color::Green)
4296        };
4297        let border_style = if is_filter_input {
4298            Style::default().fg(Color::Yellow)
4299        } else {
4300            Style::default()
4301        };
4302        assert_eq!(pagination_style.fg, None); // White (default)
4303        assert_eq!(border_style.fg, Some(Color::Yellow));
4304    }
4305
4306    #[test]
4307    fn test_lambda_application_expansion_indicator() {
4308        // Lambda applications should show expansion indicator like ECR repos
4309        let app_name = "my-application";
4310
4311        // Collapsed state
4312        let collapsed = crate::ui::table::format_expandable(app_name, false);
4313        assert!(collapsed.contains(crate::ui::table::CURSOR_COLLAPSED));
4314        assert!(collapsed.contains(app_name));
4315
4316        // Expanded state
4317        let expanded = crate::ui::table::format_expandable(app_name, true);
4318        assert!(expanded.contains(crate::ui::table::CURSOR_EXPANDED));
4319        assert!(expanded.contains(app_name));
4320    }
4321
4322    #[test]
4323    fn test_ecr_repository_selection_uses_table_state_page_size() {
4324        // ECR should use TableState's page_size, not hardcoded value
4325        let mut app = test_app_no_region();
4326        app.current_service = Service::EcrRepositories;
4327
4328        // Create 100 repositories
4329        app.ecr_state.repositories.items = (0..100)
4330            .map(|i| crate::ecr::repo::Repository {
4331                name: format!("repo{}", i),
4332                uri: format!("123456789012.dkr.ecr.us-east-1.amazonaws.com/repo{}", i),
4333                created_at: "2024-01-01".to_string(),
4334                tag_immutability: "MUTABLE".to_string(),
4335                encryption_type: "AES256".to_string(),
4336            })
4337            .collect();
4338
4339        // Set page size to 25
4340        app.ecr_state.repositories.page_size = crate::common::PageSize::TwentyFive;
4341
4342        // Select item 30 (should be on page 1, index 5 within page)
4343        app.ecr_state.repositories.selected = 30;
4344
4345        let page_size = app.ecr_state.repositories.page_size.value();
4346        let selected_index = app.ecr_state.repositories.selected % page_size;
4347
4348        assert_eq!(page_size, 25);
4349        assert_eq!(selected_index, 5); // 30 % 25 = 5
4350    }
4351
4352    #[test]
4353    fn test_ecr_repository_selection_indicator_visible() {
4354        // Verify selection indicator calculation matches table rendering
4355        let mut app = test_app_no_region();
4356        app.current_service = Service::EcrRepositories;
4357        app.mode = crate::keymap::Mode::Normal;
4358
4359        app.ecr_state.repositories.items = vec![
4360            crate::ecr::repo::Repository {
4361                name: "repo1".to_string(),
4362                uri: "123456789012.dkr.ecr.us-east-1.amazonaws.com/repo1".to_string(),
4363                created_at: "2024-01-01".to_string(),
4364                tag_immutability: "MUTABLE".to_string(),
4365                encryption_type: "AES256".to_string(),
4366            },
4367            crate::ecr::repo::Repository {
4368                name: "repo2".to_string(),
4369                uri: "123456789012.dkr.ecr.us-east-1.amazonaws.com/repo2".to_string(),
4370                created_at: "2024-01-02".to_string(),
4371                tag_immutability: "IMMUTABLE".to_string(),
4372                encryption_type: "KMS".to_string(),
4373            },
4374        ];
4375
4376        app.ecr_state.repositories.selected = 1;
4377
4378        let page_size = app.ecr_state.repositories.page_size.value();
4379        let selected_index = app.ecr_state.repositories.selected % page_size;
4380
4381        // Should be active (not in FilterInput mode)
4382        let is_active = app.mode != crate::keymap::Mode::FilterInput;
4383
4384        assert_eq!(selected_index, 1);
4385        assert!(is_active);
4386    }
4387
4388    #[test]
4389    fn test_ecr_repository_shows_expandable_indicator() {
4390        // ECR repository name column should use format_expandable to show ► indicator
4391        let repo = crate::ecr::repo::Repository {
4392            name: "test-repo".to_string(),
4393            uri: "123456789012.dkr.ecr.us-east-1.amazonaws.com/test-repo".to_string(),
4394            created_at: "2024-01-01".to_string(),
4395            tag_immutability: "MUTABLE".to_string(),
4396            encryption_type: "AES256".to_string(),
4397        };
4398
4399        // Collapsed state should show ►
4400        let collapsed = crate::ui::table::format_expandable(&repo.name, false);
4401        assert!(collapsed.contains(crate::ui::table::CURSOR_COLLAPSED));
4402        assert!(collapsed.contains("test-repo"));
4403
4404        // Expanded state should show ▼
4405        let expanded = crate::ui::table::format_expandable(&repo.name, true);
4406        assert!(expanded.contains(crate::ui::table::CURSOR_EXPANDED));
4407        assert!(expanded.contains("test-repo"));
4408    }
4409
4410    #[test]
4411    fn test_lambda_application_expanded_status_formatting() {
4412        // Status in expanded content should show emoji for complete states
4413        let app = crate::lambda::Application {
4414            name: "test-app".to_string(),
4415            arn: "arn:aws:cloudformation:us-east-1:123456789012:stack/test-app/abc123".to_string(),
4416            description: "Test application".to_string(),
4417            status: "UpdateComplete".to_string(),
4418            last_modified: "2024-01-01 00:00:00 (UTC)".to_string(),
4419        };
4420
4421        let status_upper = app.status.to_uppercase();
4422        let formatted = if status_upper.contains("UPDATECOMPLETE")
4423            || status_upper.contains("UPDATE_COMPLETE")
4424        {
4425            "✅ Update complete"
4426        } else if status_upper.contains("CREATECOMPLETE")
4427            || status_upper.contains("CREATE_COMPLETE")
4428        {
4429            "✅ Create complete"
4430        } else {
4431            &app.status
4432        };
4433
4434        assert_eq!(formatted, "✅ Update complete");
4435
4436        // Test CREATE_COMPLETE
4437        let app2 = crate::lambda::Application {
4438            status: "CreateComplete".to_string(),
4439            ..app
4440        };
4441        let status_upper = app2.status.to_uppercase();
4442        let formatted = if status_upper.contains("UPDATECOMPLETE")
4443            || status_upper.contains("UPDATE_COMPLETE")
4444        {
4445            "✅ Update complete"
4446        } else if status_upper.contains("CREATECOMPLETE")
4447            || status_upper.contains("CREATE_COMPLETE")
4448        {
4449            "✅ Create complete"
4450        } else {
4451            &app2.status
4452        };
4453        assert_eq!(formatted, "✅ Create complete");
4454    }
4455
4456    #[test]
4457    fn test_pagination_shows_1_when_empty() {
4458        let result = render_pagination_text(0, 0);
4459        assert_eq!(result, "[1]");
4460    }
4461
4462    #[test]
4463    fn test_pagination_shows_current_page() {
4464        let result = render_pagination_text(0, 3);
4465        assert_eq!(result, "[1] 2 3");
4466
4467        let result = render_pagination_text(1, 3);
4468        assert_eq!(result, "1 [2] 3");
4469    }
4470
4471    #[test]
4472    fn test_cloudformation_section_heights_match_content() {
4473        // Test that section heights are calculated based on content, not fixed
4474        // Overview: 14 fields + 2 borders = 16
4475        let overview_fields = 14;
4476        let overview_height = overview_fields + 2;
4477        assert_eq!(overview_height, 16);
4478
4479        // Tags (empty): 4 lines + 2 borders = 6
4480        let tags_empty_lines = 4;
4481        let tags_empty_height = tags_empty_lines + 2;
4482        assert_eq!(tags_empty_height, 6);
4483
4484        // Stack policy (empty): 5 lines + 2 borders = 7
4485        let policy_empty_lines = 5;
4486        let policy_empty_height = policy_empty_lines + 2;
4487        assert_eq!(policy_empty_height, 7);
4488
4489        // Rollback (empty): 6 lines + 2 borders = 8
4490        let rollback_empty_lines = 6;
4491        let rollback_empty_height = rollback_empty_lines + 2;
4492        assert_eq!(rollback_empty_height, 8);
4493
4494        // Notifications (empty): 4 lines + 2 borders = 6
4495        let notifications_empty_lines = 4;
4496        let notifications_empty_height = notifications_empty_lines + 2;
4497        assert_eq!(notifications_empty_height, 6);
4498    }
4499
4500    #[test]
4501    fn test_log_groups_uses_table_state() {
4502        let mut app = test_app_no_region();
4503        app.current_service = Service::CloudWatchLogGroups;
4504
4505        // Verify log_groups uses TableState
4506        assert_eq!(app.log_groups_state.log_groups.items.len(), 0);
4507        assert_eq!(app.log_groups_state.log_groups.selected, 0);
4508        assert_eq!(app.log_groups_state.log_groups.filter, "");
4509        assert_eq!(
4510            app.log_groups_state.log_groups.page_size,
4511            crate::common::PageSize::Fifty
4512        );
4513    }
4514
4515    #[test]
4516    fn test_log_groups_filter_and_pagination() {
4517        let mut app = test_app_no_region();
4518        app.current_service = Service::CloudWatchLogGroups;
4519
4520        // Add test log groups
4521        app.log_groups_state.log_groups.items = vec![
4522            rusticity_core::LogGroup {
4523                name: "/aws/lambda/function1".to_string(),
4524                creation_time: None,
4525                stored_bytes: Some(1024),
4526                retention_days: None,
4527                log_class: None,
4528                arn: None,
4529            },
4530            rusticity_core::LogGroup {
4531                name: "/aws/lambda/function2".to_string(),
4532                creation_time: None,
4533                stored_bytes: Some(2048),
4534                retention_days: None,
4535                log_class: None,
4536                arn: None,
4537            },
4538            rusticity_core::LogGroup {
4539                name: "/aws/ecs/service1".to_string(),
4540                creation_time: None,
4541                stored_bytes: Some(4096),
4542                retention_days: None,
4543                log_class: None,
4544                arn: None,
4545            },
4546        ];
4547
4548        // Test filtering
4549        app.log_groups_state.log_groups.filter = "lambda".to_string();
4550        let filtered = app.filtered_log_groups();
4551        assert_eq!(filtered.len(), 2);
4552
4553        // Test pagination
4554        let page_size = app.log_groups_state.log_groups.page_size.value();
4555        assert_eq!(page_size, 50);
4556    }
4557
4558    #[test]
4559    fn test_log_groups_expandable_indicators() {
4560        let group = rusticity_core::LogGroup {
4561            name: "/aws/lambda/test".to_string(),
4562            creation_time: None,
4563            stored_bytes: Some(1024),
4564            retention_days: None,
4565            log_class: None,
4566            arn: None,
4567        };
4568
4569        // Test collapsed state (►)
4570        let collapsed = crate::ui::table::format_expandable(&group.name, false);
4571        assert!(collapsed.starts_with("► "));
4572        assert!(collapsed.contains("/aws/lambda/test"));
4573
4574        // Test expanded state (▼)
4575        let expanded = crate::ui::table::format_expandable(&group.name, true);
4576        assert!(expanded.starts_with("▼ "));
4577        assert!(expanded.contains("/aws/lambda/test"));
4578    }
4579
4580    #[test]
4581    fn test_log_groups_visual_boundaries() {
4582        // Verify visual boundary constants exist
4583        assert_eq!(crate::ui::table::CURSOR_COLLAPSED, "►");
4584        assert_eq!(crate::ui::table::CURSOR_EXPANDED, "▼");
4585
4586        // The visual boundaries │ and ╰ are rendered in render_table()
4587        // They are added as prefixes to expanded content lines
4588        let continuation = "│ ";
4589        let last_line = "╰ ";
4590
4591        assert_eq!(continuation, "│ ");
4592        assert_eq!(last_line, "╰ ");
4593    }
4594
4595    #[test]
4596    fn test_log_groups_right_arrow_expands() {
4597        let mut app = test_app();
4598        app.current_service = Service::CloudWatchLogGroups;
4599        app.service_selected = true;
4600        app.view_mode = ViewMode::List;
4601
4602        app.log_groups_state.log_groups.items = vec![rusticity_core::LogGroup {
4603            name: "/aws/lambda/test".to_string(),
4604            creation_time: None,
4605            stored_bytes: Some(1024),
4606            retention_days: None,
4607            log_class: None,
4608            arn: None,
4609        }];
4610        app.log_groups_state.log_groups.selected = 0;
4611
4612        assert_eq!(app.log_groups_state.log_groups.expanded_item, None);
4613
4614        // Right arrow - should expand
4615        app.handle_action(Action::NextPane);
4616        assert_eq!(app.log_groups_state.log_groups.expanded_item, Some(0));
4617
4618        // Left arrow - should collapse
4619        app.handle_action(Action::PrevPane);
4620        assert_eq!(app.log_groups_state.log_groups.expanded_item, None);
4621    }
4622
4623    #[test]
4624    fn test_log_streams_right_arrow_expands() {
4625        let mut app = test_app();
4626        app.current_service = Service::CloudWatchLogGroups;
4627        app.service_selected = true;
4628        app.view_mode = ViewMode::Detail;
4629
4630        app.log_groups_state.log_streams = vec![rusticity_core::LogStream {
4631            name: "stream-1".to_string(),
4632            creation_time: None,
4633            last_event_time: None,
4634        }];
4635        app.log_groups_state.selected_stream = 0;
4636
4637        assert_eq!(app.log_groups_state.expanded_stream, None);
4638
4639        // Right arrow - should expand
4640        app.handle_action(Action::NextPane);
4641        assert_eq!(app.log_groups_state.expanded_stream, Some(0));
4642
4643        // Left arrow - should collapse
4644        app.handle_action(Action::PrevPane);
4645        assert_eq!(app.log_groups_state.expanded_stream, None);
4646    }
4647
4648    #[test]
4649    fn test_log_events_border_style_no_double_border() {
4650        // Verify that log events don't use BorderType::Double
4651        // The new style only uses Green fg color for active state
4652        let mut app = test_app();
4653        app.current_service = Service::CloudWatchLogGroups;
4654        app.service_selected = true;
4655        app.view_mode = ViewMode::Events;
4656
4657        // Border style should only be Green fg when active, not Double border type
4658        // This is a regression test to ensure we don't reintroduce Double borders
4659        assert_eq!(app.view_mode, ViewMode::Events);
4660    }
4661
4662    #[test]
4663    fn test_log_group_detail_border_style_no_double_border() {
4664        // Verify that log group detail doesn't use BorderType::Double
4665        let mut app = test_app();
4666        app.current_service = Service::CloudWatchLogGroups;
4667        app.service_selected = true;
4668        app.view_mode = ViewMode::Detail;
4669
4670        // Border style should only be Green fg when active, not Double border type
4671        assert_eq!(app.view_mode, ViewMode::Detail);
4672    }
4673
4674    #[test]
4675    fn test_expansion_uses_intermediate_field_indicator() {
4676        // Verify that expanded content uses ├ for intermediate fields
4677        // This is tested by checking the constants exist
4678        // The actual rendering logic uses:
4679        // - ├ for field starts (lines with ": ")
4680        // - │ for continuation lines
4681        // - ╰ for the last line
4682
4683        let intermediate = "├ ";
4684        let continuation = "│ ";
4685        let last = "╰ ";
4686
4687        assert_eq!(intermediate, "├ ");
4688        assert_eq!(continuation, "│ ");
4689        assert_eq!(last, "╰ ");
4690    }
4691
4692    #[test]
4693    fn test_log_streams_expansion_renders() {
4694        let mut app = test_app();
4695        app.current_service = Service::CloudWatchLogGroups;
4696        app.service_selected = true;
4697        app.view_mode = ViewMode::Detail;
4698
4699        app.log_groups_state.log_streams = vec![rusticity_core::LogStream {
4700            name: "test-stream".to_string(),
4701            creation_time: None,
4702            last_event_time: None,
4703        }];
4704        app.log_groups_state.selected_stream = 0;
4705        app.log_groups_state.expanded_stream = Some(0);
4706
4707        // Verify expansion is set
4708        assert_eq!(app.log_groups_state.expanded_stream, Some(0));
4709
4710        // Verify stream exists
4711        assert_eq!(app.log_groups_state.log_streams.len(), 1);
4712        assert_eq!(app.log_groups_state.log_streams[0].name, "test-stream");
4713    }
4714
4715    #[test]
4716    fn test_log_streams_filter_layout_single_line() {
4717        // Verify that filter, exact match, and show expired are on the same line
4718        // This is a visual test - we verify the constraint is Length(3) not Length(4)
4719        let _app = App::new_without_client("test".to_string(), Some("us-east-1".to_string()));
4720
4721        // Filter area should be 3 lines (1 for content + 2 for borders)
4722        // not 4 lines (2 for content + 2 for borders)
4723        let expected_filter_height = 3;
4724        assert_eq!(expected_filter_height, 3);
4725    }
4726
4727    #[test]
4728    fn test_table_navigation_at_page_boundary() {
4729        let mut app = test_app();
4730        app.current_service = Service::CloudWatchLogGroups;
4731        app.service_selected = true;
4732        app.view_mode = ViewMode::List;
4733        app.mode = Mode::Normal;
4734
4735        // Create 100 log groups
4736        for i in 0..100 {
4737            app.log_groups_state
4738                .log_groups
4739                .items
4740                .push(rusticity_core::LogGroup {
4741                    name: format!("/aws/lambda/function{}", i),
4742                    creation_time: None,
4743                    stored_bytes: Some(1024),
4744                    retention_days: None,
4745                    log_class: None,
4746                    arn: None,
4747                });
4748        }
4749
4750        // Set page size to 50
4751        app.log_groups_state.log_groups.page_size = crate::common::PageSize::Fifty;
4752
4753        // Go to item 49 (last on page 1, 0-indexed)
4754        app.log_groups_state.log_groups.selected = 49;
4755
4756        // Press down - should go to item 50 (first on page 2)
4757        app.handle_action(Action::NextItem);
4758        assert_eq!(app.log_groups_state.log_groups.selected, 50);
4759
4760        // Press up - should go back to item 49
4761        app.handle_action(Action::PrevItem);
4762        assert_eq!(app.log_groups_state.log_groups.selected, 49);
4763
4764        // Go to item 50 again
4765        app.handle_action(Action::NextItem);
4766        assert_eq!(app.log_groups_state.log_groups.selected, 50);
4767
4768        // Press up again - should still go to 49
4769        app.handle_action(Action::PrevItem);
4770        assert_eq!(app.log_groups_state.log_groups.selected, 49);
4771    }
4772
4773    #[test]
4774    fn test_table_navigation_at_end() {
4775        let mut app = test_app();
4776        app.current_service = Service::CloudWatchLogGroups;
4777        app.service_selected = true;
4778        app.view_mode = ViewMode::List;
4779        app.mode = Mode::Normal;
4780
4781        // Create 100 log groups
4782        for i in 0..100 {
4783            app.log_groups_state
4784                .log_groups
4785                .items
4786                .push(rusticity_core::LogGroup {
4787                    name: format!("/aws/lambda/function{}", i),
4788                    creation_time: None,
4789                    stored_bytes: Some(1024),
4790                    retention_days: None,
4791                    log_class: None,
4792                    arn: None,
4793                });
4794        }
4795
4796        // Go to last item (99)
4797        app.log_groups_state.log_groups.selected = 99;
4798
4799        // Press down - should stay at 99
4800        app.handle_action(Action::NextItem);
4801        assert_eq!(app.log_groups_state.log_groups.selected, 99);
4802
4803        // Press up - should go to 98
4804        app.handle_action(Action::PrevItem);
4805        assert_eq!(app.log_groups_state.log_groups.selected, 98);
4806    }
4807
4808    #[test]
4809    fn test_table_viewport_scrolling() {
4810        let mut app = test_app();
4811        app.current_service = Service::CloudWatchLogGroups;
4812        app.service_selected = true;
4813        app.view_mode = ViewMode::List;
4814        app.mode = Mode::Normal;
4815
4816        // Create 100 log groups
4817        for i in 0..100 {
4818            app.log_groups_state
4819                .log_groups
4820                .items
4821                .push(rusticity_core::LogGroup {
4822                    name: format!("/aws/lambda/function{}", i),
4823                    creation_time: None,
4824                    stored_bytes: Some(1024),
4825                    retention_days: None,
4826                    log_class: None,
4827                    arn: None,
4828                });
4829        }
4830
4831        // Set page size to 50
4832        app.log_groups_state.log_groups.page_size = crate::common::PageSize::Fifty;
4833
4834        // Start at item 49 (last visible on first viewport)
4835        app.log_groups_state.log_groups.selected = 49;
4836        app.log_groups_state.log_groups.scroll_offset = 0;
4837
4838        // Press down - should go to item 50 and scroll viewport
4839        app.handle_action(Action::NextItem);
4840        assert_eq!(app.log_groups_state.log_groups.selected, 50);
4841        assert_eq!(app.log_groups_state.log_groups.scroll_offset, 1); // Scrolled by 1
4842
4843        // Press up - should go back to item 49 WITHOUT scrolling back
4844        app.handle_action(Action::PrevItem);
4845        assert_eq!(app.log_groups_state.log_groups.selected, 49);
4846        assert_eq!(app.log_groups_state.log_groups.scroll_offset, 1); // Still at 1, not 0
4847
4848        // Press up again - should go to 48, still no scroll
4849        app.handle_action(Action::PrevItem);
4850        assert_eq!(app.log_groups_state.log_groups.selected, 48);
4851        assert_eq!(app.log_groups_state.log_groups.scroll_offset, 1); // Still at 1
4852
4853        // Keep going up until we hit the top of viewport (item 1)
4854        for _ in 0..47 {
4855            app.handle_action(Action::PrevItem);
4856        }
4857        assert_eq!(app.log_groups_state.log_groups.selected, 1);
4858        assert_eq!(app.log_groups_state.log_groups.scroll_offset, 1); // Still at 1
4859
4860        // One more up - should scroll viewport up
4861        app.handle_action(Action::PrevItem);
4862        assert_eq!(app.log_groups_state.log_groups.selected, 0);
4863        assert_eq!(app.log_groups_state.log_groups.scroll_offset, 0); // Scrolled to 0
4864    }
4865
4866    #[test]
4867    fn test_table_up_from_last_row() {
4868        let mut app = test_app();
4869        app.current_service = Service::CloudWatchLogGroups;
4870        app.service_selected = true;
4871        app.view_mode = ViewMode::List;
4872        app.mode = Mode::Normal;
4873
4874        // Create 100 log groups
4875        for i in 0..100 {
4876            app.log_groups_state
4877                .log_groups
4878                .items
4879                .push(rusticity_core::LogGroup {
4880                    name: format!("/aws/lambda/function{}", i),
4881                    creation_time: None,
4882                    stored_bytes: Some(1024),
4883                    retention_days: None,
4884                    log_class: None,
4885                    arn: None,
4886                });
4887        }
4888
4889        // Set page size to 50
4890        app.log_groups_state.log_groups.page_size = crate::common::PageSize::Fifty;
4891
4892        // Go to last row (99) with scroll showing last page
4893        app.log_groups_state.log_groups.selected = 99;
4894        app.log_groups_state.log_groups.scroll_offset = 50; // Showing items 50-99
4895
4896        // Press up - should go to item 98 WITHOUT scrolling
4897        app.handle_action(Action::PrevItem);
4898        assert_eq!(app.log_groups_state.log_groups.selected, 98);
4899        assert_eq!(app.log_groups_state.log_groups.scroll_offset, 50); // Should NOT scroll
4900
4901        // Press up again - should go to 97, still no scroll
4902        app.handle_action(Action::PrevItem);
4903        assert_eq!(app.log_groups_state.log_groups.selected, 97);
4904        assert_eq!(app.log_groups_state.log_groups.scroll_offset, 50); // Should NOT scroll
4905    }
4906
4907    #[test]
4908    fn test_table_up_from_last_visible_row() {
4909        let mut app = test_app();
4910        app.current_service = Service::CloudWatchLogGroups;
4911        app.service_selected = true;
4912        app.view_mode = ViewMode::List;
4913        app.mode = Mode::Normal;
4914
4915        // Create 100 log groups
4916        for i in 0..100 {
4917            app.log_groups_state
4918                .log_groups
4919                .items
4920                .push(rusticity_core::LogGroup {
4921                    name: format!("/aws/lambda/function{}", i),
4922                    creation_time: None,
4923                    stored_bytes: Some(1024),
4924                    retention_days: None,
4925                    log_class: None,
4926                    arn: None,
4927                });
4928        }
4929
4930        // Set page size to 50
4931        app.log_groups_state.log_groups.page_size = crate::common::PageSize::Fifty;
4932
4933        // Simulate: at item 49 (last visible), press down to get to item 50
4934        app.log_groups_state.log_groups.selected = 49;
4935        app.log_groups_state.log_groups.scroll_offset = 0;
4936        app.handle_action(Action::NextItem);
4937
4938        // Now at item 50, scroll_offset = 1 (showing items 1-50)
4939        assert_eq!(app.log_groups_state.log_groups.selected, 50);
4940        assert_eq!(app.log_groups_state.log_groups.scroll_offset, 1);
4941
4942        // Item 50 is now the last visible row
4943        // Press up - should move to item 49 WITHOUT scrolling
4944        app.handle_action(Action::PrevItem);
4945        assert_eq!(
4946            app.log_groups_state.log_groups.selected, 49,
4947            "Selection should move to 49"
4948        );
4949        assert_eq!(
4950            app.log_groups_state.log_groups.scroll_offset, 1,
4951            "Should NOT scroll up"
4952        );
4953    }
4954
4955    #[test]
4956    fn test_cloudformation_up_from_last_visible_row() {
4957        let mut app = test_app();
4958        app.current_service = Service::CloudFormationStacks;
4959        app.service_selected = true;
4960        app.mode = Mode::Normal;
4961
4962        // Create 100 stacks
4963        for i in 0..100 {
4964            app.cfn_state.table.items.push(crate::cfn::Stack {
4965                name: format!("Stack{}", i),
4966                stack_id: format!("id{}", i),
4967                status: "CREATE_COMPLETE".to_string(),
4968                created_time: "2024-01-01 00:00:00 (UTC)".to_string(),
4969                updated_time: "2024-01-01 00:00:00 (UTC)".to_string(),
4970                deleted_time: String::new(),
4971                description: "Test".to_string(),
4972                drift_status: "NOT_CHECKED".to_string(),
4973                last_drift_check_time: "-".to_string(),
4974                status_reason: String::new(),
4975                detailed_status: "CREATE_COMPLETE".to_string(),
4976                root_stack: String::new(),
4977                parent_stack: String::new(),
4978                termination_protection: false,
4979                iam_role: String::new(),
4980                tags: Vec::new(),
4981                stack_policy: String::new(),
4982                rollback_monitoring_time: String::new(),
4983                rollback_alarms: Vec::new(),
4984                notification_arns: Vec::new(),
4985            });
4986        }
4987
4988        // Set page size to 50
4989        app.cfn_state.table.page_size = crate::common::PageSize::Fifty;
4990
4991        // Simulate: at item 49 (last visible), press down to get to item 50
4992        app.cfn_state.table.selected = 49;
4993        app.cfn_state.table.scroll_offset = 0;
4994        app.handle_action(Action::NextItem);
4995
4996        // Now at item 50, scroll_offset should be 1
4997        assert_eq!(app.cfn_state.table.selected, 50);
4998        assert_eq!(app.cfn_state.table.scroll_offset, 1);
4999
5000        // Press up - should move to item 49 WITHOUT scrolling
5001        app.handle_action(Action::PrevItem);
5002        assert_eq!(
5003            app.cfn_state.table.selected, 49,
5004            "Selection should move to 49"
5005        );
5006        assert_eq!(
5007            app.cfn_state.table.scroll_offset, 1,
5008            "Should NOT scroll up - this is the bug!"
5009        );
5010    }
5011
5012    #[test]
5013    fn test_cloudformation_up_from_actual_last_row() {
5014        let mut app = test_app();
5015        app.current_service = Service::CloudFormationStacks;
5016        app.service_selected = true;
5017        app.mode = Mode::Normal;
5018
5019        // Create 88 stacks (like in the user's screenshot)
5020        for i in 0..88 {
5021            app.cfn_state.table.items.push(crate::cfn::Stack {
5022                name: format!("Stack{}", i),
5023                stack_id: format!("id{}", i),
5024                status: "CREATE_COMPLETE".to_string(),
5025                created_time: "2024-01-01 00:00:00 (UTC)".to_string(),
5026                updated_time: "2024-01-01 00:00:00 (UTC)".to_string(),
5027                deleted_time: String::new(),
5028                description: "Test".to_string(),
5029                drift_status: "NOT_CHECKED".to_string(),
5030                last_drift_check_time: "-".to_string(),
5031                status_reason: String::new(),
5032                detailed_status: "CREATE_COMPLETE".to_string(),
5033                root_stack: String::new(),
5034                parent_stack: String::new(),
5035                termination_protection: false,
5036                iam_role: String::new(),
5037                tags: Vec::new(),
5038                stack_policy: String::new(),
5039                rollback_monitoring_time: String::new(),
5040                rollback_alarms: Vec::new(),
5041                notification_arns: Vec::new(),
5042            });
5043        }
5044
5045        // Set page size to 50
5046        app.cfn_state.table.page_size = crate::common::PageSize::Fifty;
5047
5048        // Simulate being on page 2 (showing items 38-87, which is the last page)
5049        // User is at item 87 (the actual last row)
5050        app.cfn_state.table.selected = 87;
5051        app.cfn_state.table.scroll_offset = 38; // Showing last 50 items
5052
5053        // Press up - should move to item 86 WITHOUT scrolling
5054        app.handle_action(Action::PrevItem);
5055        assert_eq!(
5056            app.cfn_state.table.selected, 86,
5057            "Selection should move to 86"
5058        );
5059        assert_eq!(
5060            app.cfn_state.table.scroll_offset, 38,
5061            "Should NOT scroll - scroll_offset should stay at 38"
5062        );
5063    }
5064
5065    #[test]
5066    fn test_iam_users_default_columns() {
5067        let app = test_app();
5068        assert_eq!(app.visible_iam_columns.len(), 11);
5069        assert!(app.visible_iam_columns.contains(&"User name".to_string()));
5070        assert!(app.visible_iam_columns.contains(&"Path".to_string()));
5071        assert!(app.visible_iam_columns.contains(&"ARN".to_string()));
5072    }
5073
5074    #[test]
5075    fn test_iam_users_all_columns() {
5076        let app = test_app();
5077        assert_eq!(app.all_iam_columns.len(), 14);
5078        assert!(app.all_iam_columns.contains(&"Creation time".to_string()));
5079        assert!(app.all_iam_columns.contains(&"Console access".to_string()));
5080        assert!(app.all_iam_columns.contains(&"Signing certs".to_string()));
5081    }
5082
5083    #[test]
5084    fn test_iam_users_filter() {
5085        let mut app = test_app();
5086        app.current_service = Service::IamUsers;
5087
5088        // Add test users
5089        app.iam_state.users.items = vec![
5090            crate::iam::IamUser {
5091                user_name: "alice".to_string(),
5092                path: "/".to_string(),
5093                groups: "admins".to_string(),
5094                last_activity: "2024-01-01".to_string(),
5095                mfa: "Enabled".to_string(),
5096                password_age: "30 days".to_string(),
5097                console_last_sign_in: "2024-01-01".to_string(),
5098                access_key_id: "AKIA...".to_string(),
5099                active_key_age: "60 days".to_string(),
5100                access_key_last_used: "2024-01-01".to_string(),
5101                arn: "arn:aws:iam::123456789012:user/alice".to_string(),
5102                creation_time: "2023-01-01".to_string(),
5103                console_access: "Enabled".to_string(),
5104                signing_certs: "0".to_string(),
5105            },
5106            crate::iam::IamUser {
5107                user_name: "bob".to_string(),
5108                path: "/".to_string(),
5109                groups: "developers".to_string(),
5110                last_activity: "2024-01-02".to_string(),
5111                mfa: "Disabled".to_string(),
5112                password_age: "45 days".to_string(),
5113                console_last_sign_in: "2024-01-02".to_string(),
5114                access_key_id: "AKIA...".to_string(),
5115                active_key_age: "90 days".to_string(),
5116                access_key_last_used: "2024-01-02".to_string(),
5117                arn: "arn:aws:iam::123456789012:user/bob".to_string(),
5118                creation_time: "2023-02-01".to_string(),
5119                console_access: "Enabled".to_string(),
5120                signing_certs: "1".to_string(),
5121            },
5122        ];
5123
5124        // No filter - should return all users
5125        let filtered = crate::ui::iam::filtered_iam_users(&app);
5126        assert_eq!(filtered.len(), 2);
5127
5128        // Filter by name
5129        app.iam_state.users.filter = "alice".to_string();
5130        let filtered = crate::ui::iam::filtered_iam_users(&app);
5131        assert_eq!(filtered.len(), 1);
5132        assert_eq!(filtered[0].user_name, "alice");
5133
5134        // Case insensitive filter
5135        app.iam_state.users.filter = "BOB".to_string();
5136        let filtered = crate::ui::iam::filtered_iam_users(&app);
5137        assert_eq!(filtered.len(), 1);
5138        assert_eq!(filtered[0].user_name, "bob");
5139    }
5140
5141    #[test]
5142    fn test_iam_users_pagination() {
5143        let mut app = test_app();
5144        app.current_service = Service::IamUsers;
5145
5146        // Add 30 test users
5147        for i in 0..30 {
5148            app.iam_state.users.items.push(crate::iam::IamUser {
5149                user_name: format!("user{}", i),
5150                path: "/".to_string(),
5151                groups: String::new(),
5152                last_activity: "-".to_string(),
5153                mfa: "Disabled".to_string(),
5154                password_age: "-".to_string(),
5155                console_last_sign_in: "-".to_string(),
5156                access_key_id: "-".to_string(),
5157                active_key_age: "-".to_string(),
5158                access_key_last_used: "-".to_string(),
5159                arn: format!("arn:aws:iam::123456789012:user/user{}", i),
5160                creation_time: "2023-01-01".to_string(),
5161                console_access: "Disabled".to_string(),
5162                signing_certs: "0".to_string(),
5163            });
5164        }
5165
5166        // Default page size is 25
5167        app.iam_state.users.page_size = crate::common::PageSize::TwentyFive;
5168
5169        let filtered = crate::ui::iam::filtered_iam_users(&app);
5170        assert_eq!(filtered.len(), 30);
5171
5172        // Pagination should work
5173        let page_size = app.iam_state.users.page_size.value();
5174        assert_eq!(page_size, 25);
5175    }
5176
5177    #[test]
5178    fn test_iam_users_expansion() {
5179        let mut app = test_app();
5180        app.current_service = Service::IamUsers;
5181        app.service_selected = true;
5182        app.mode = Mode::Normal;
5183
5184        app.iam_state.users.items = vec![crate::iam::IamUser {
5185            user_name: "testuser".to_string(),
5186            path: "/admin/".to_string(),
5187            groups: "admins,developers".to_string(),
5188            last_activity: "2024-01-01".to_string(),
5189            mfa: "Enabled".to_string(),
5190            password_age: "30 days".to_string(),
5191            console_last_sign_in: "2024-01-01 10:00:00".to_string(),
5192            access_key_id: "AKIAIOSFODNN7EXAMPLE".to_string(),
5193            active_key_age: "60 days".to_string(),
5194            access_key_last_used: "2024-01-01 09:00:00".to_string(),
5195            arn: "arn:aws:iam::123456789012:user/admin/testuser".to_string(),
5196            creation_time: "2023-01-01 00:00:00".to_string(),
5197            console_access: "Enabled".to_string(),
5198            signing_certs: "2".to_string(),
5199        }];
5200
5201        // Expand first item
5202        app.handle_action(Action::NextPane);
5203        assert_eq!(app.iam_state.users.expanded_item, Some(0));
5204
5205        // Collapse
5206        app.handle_action(Action::PrevPane);
5207        assert_eq!(app.iam_state.users.expanded_item, None);
5208    }
5209
5210    #[test]
5211    fn test_iam_users_in_service_picker() {
5212        let app = test_app();
5213        assert!(app.service_picker.services.contains(&"IAM > Users"));
5214    }
5215
5216    #[test]
5217    fn test_iam_users_service_selection() {
5218        let mut app = test_app();
5219        app.mode = Mode::ServicePicker;
5220        let filtered = app.filtered_services();
5221        let selected_idx = filtered.iter().position(|&s| s == "IAM > Users").unwrap();
5222        app.service_picker.selected = selected_idx;
5223
5224        app.handle_action(Action::Select);
5225
5226        assert_eq!(app.current_service, Service::IamUsers);
5227        assert!(app.service_selected);
5228        assert_eq!(app.tabs.len(), 1);
5229        assert_eq!(app.tabs[0].service, Service::IamUsers);
5230        assert_eq!(app.tabs[0].title, "IAM > Users");
5231    }
5232
5233    #[test]
5234    fn test_format_duration_seconds() {
5235        assert_eq!(format_duration(1), "1 second");
5236        assert_eq!(format_duration(30), "30 seconds");
5237    }
5238
5239    #[test]
5240    fn test_format_duration_minutes() {
5241        assert_eq!(format_duration(60), "1 minute");
5242        assert_eq!(format_duration(120), "2 minutes");
5243        assert_eq!(format_duration(3600 - 1), "59 minutes");
5244    }
5245
5246    #[test]
5247    fn test_format_duration_hours() {
5248        assert_eq!(format_duration(3600), "1 hour");
5249        assert_eq!(format_duration(7200), "2 hours");
5250        assert_eq!(format_duration(3600 + 1800), "1 hour 30 minutes");
5251        assert_eq!(format_duration(7200 + 60), "2 hours 1 minute");
5252    }
5253
5254    #[test]
5255    fn test_format_duration_days() {
5256        assert_eq!(format_duration(86400), "1 day");
5257        assert_eq!(format_duration(172800), "2 days");
5258        assert_eq!(format_duration(86400 + 3600), "1 day 1 hour");
5259        assert_eq!(format_duration(172800 + 7200), "2 days 2 hours");
5260    }
5261
5262    #[test]
5263    fn test_format_duration_weeks() {
5264        assert_eq!(format_duration(604800), "1 week");
5265        assert_eq!(format_duration(1209600), "2 weeks");
5266        assert_eq!(format_duration(604800 + 86400), "1 week 1 day");
5267        assert_eq!(format_duration(1209600 + 172800), "2 weeks 2 days");
5268    }
5269
5270    #[test]
5271    fn test_format_duration_years() {
5272        assert_eq!(format_duration(31536000), "1 year");
5273        assert_eq!(format_duration(63072000), "2 years");
5274        assert_eq!(format_duration(31536000 + 604800), "1 year 1 week");
5275        assert_eq!(format_duration(63072000 + 1209600), "2 years 2 weeks");
5276    }
5277
5278    #[test]
5279    fn test_tab_style_selected() {
5280        let style = tab_style(true);
5281        assert_eq!(style, highlight());
5282    }
5283
5284    #[test]
5285    fn test_tab_style_not_selected() {
5286        let style = tab_style(false);
5287        assert_eq!(style, Style::default());
5288    }
5289
5290    #[test]
5291    fn test_render_tab_spans_single_tab() {
5292        let tabs = [("Tab1", true)];
5293        let spans = render_tab_spans(&tabs);
5294        assert_eq!(spans.len(), 1);
5295        assert_eq!(spans[0].content, "Tab1");
5296        assert_eq!(spans[0].style, service_tab_style(true));
5297    }
5298
5299    #[test]
5300    fn test_render_tab_spans_multiple_tabs() {
5301        let tabs = [("Tab1", true), ("Tab2", false), ("Tab3", false)];
5302        let spans = render_tab_spans(&tabs);
5303        assert_eq!(spans.len(), 5); // Tab1, separator, Tab2, separator, Tab3
5304        assert_eq!(spans[0].content, "Tab1");
5305        assert_eq!(spans[0].style, service_tab_style(true));
5306        assert_eq!(spans[1].content, " ⋮ ");
5307        assert_eq!(spans[2].content, "Tab2");
5308        assert_eq!(spans[2].style, Style::default());
5309        assert_eq!(spans[3].content, " ⋮ ");
5310        assert_eq!(spans[4].content, "Tab3");
5311        assert_eq!(spans[4].style, Style::default());
5312    }
5313
5314    #[test]
5315    fn test_render_tab_spans_no_separator_for_first() {
5316        let tabs = [("First", false), ("Second", true)];
5317        let spans = render_tab_spans(&tabs);
5318        assert_eq!(spans.len(), 3); // First, separator, Second
5319        assert_eq!(spans[0].content, "First");
5320        assert_eq!(spans[1].content, " ⋮ ");
5321        assert_eq!(spans[2].content, "Second");
5322        assert_eq!(spans[2].style, service_tab_style(true));
5323    }
5324}