Skip to main content

rusticity_term/ui/
mod.rs

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