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