romm-tui 1.5.0

Terminal UI for the RomM API
Documentation
use std::sync::{Arc, Mutex};

use ratatui_image::picker::ProtocolType;

use super::cover::detect_cover_protocol_from_env;
use super::saves::save_lines;
use super::types::{
    CoverState, DetailTab, GameDetailPrevious, GameDetailScreen, SaveListState,
    COVER_PANEL_WIDTH_DEFAULT,
};
use crate::tui::screens::SearchScreen;
use romm_api::types::SaveMetadata;

#[test]
fn detect_cover_protocol_prefers_kitty_hint() {
    let protocol = detect_cover_protocol_from_env(
        Some("iTerm.app".into()),
        Some("xterm-256color".into()),
        Some("123".into()),
    );
    assert_eq!(protocol, Some(ProtocolType::Kitty));
}

#[test]
fn detect_cover_protocol_supports_sixel_term() {
    let protocol =
        detect_cover_protocol_from_env(None, Some("xterm+sixel".into()), Some(String::new()));
    assert_eq!(protocol, Some(ProtocolType::Sixel));
}

#[test]
fn missing_protocol_still_requests_cover_load() {
    let rom = romm_api::types::Rom {
        id: 5,
        platform_id: 1,
        platform_slug: None,
        platform_fs_slug: None,
        platform_custom_name: None,
        platform_display_name: None,
        fs_name: "game.zip".to_string(),
        fs_name_no_tags: "game".to_string(),
        fs_name_no_ext: "game".to_string(),
        fs_extension: "zip".to_string(),
        fs_path: "/game.zip".to_string(),
        fs_size_bytes: 10,
        name: "game".to_string(),
        slug: None,
        summary: None,
        path_cover_small: None,
        path_cover_large: None,
        url_cover: Some("http://example.com/cover.png".to_string()),
        has_manual: false,
        path_manual: None,
        url_manual: None,
        is_unidentified: false,
        is_identified: true,
        files: Vec::new(),
        ra_id: None,
        merged_ra_metadata: None,
    };
    let previous = GameDetailPrevious::Search(SearchScreen::new());
    let downloads = Arc::new(Mutex::new(Vec::new()));
    let mut detail = GameDetailScreen::new(
        rom,
        Vec::new(),
        previous,
        downloads,
        COVER_PANEL_WIDTH_DEFAULT,
    );
    detail.cover_protocol = None;
    assert!(detail.should_request_cover_load());
    detail.set_cover_loading();
    assert_eq!(detail.cover_state, CoverState::Loading);
}

#[test]
fn has_any_extras_false_when_no_assets() {
    let rom = test_rom(1, None);
    let detail = new_detail(rom);
    assert!(!detail.has_any_extras());
}

#[test]
fn footer_info_tab_shows_match_for_unidentified() {
    let mut rom = test_rom(1, None);
    rom.is_identified = false;
    let detail = new_detail(rom);
    assert_eq!(detail.active_tab, DetailTab::Info);
    let entries = detail.footer_help_entries();
    assert!(entries
        .iter()
        .any(|entry| entry.key == "m" && entry.label == "Match"));
    assert!(entries
        .iter()
        .any(|entry| entry.key == "Ctrl+←/→" && entry.label == "Resize cover"));
    assert!(entries
        .iter()
        .any(|entry| entry.key == "1-5" && entry.label == "Tabs"));
}

#[test]
fn footer_info_tab_shows_unmatch_for_identified() {
    let detail = new_detail(test_rom(1, None));
    assert_eq!(detail.active_tab, DetailTab::Info);
    let entries = detail.footer_help_entries();
    assert!(entries
        .iter()
        .any(|entry| entry.key == "m" && entry.label == "Unmatch"));
}

#[test]
fn footer_extras_tab_hints() {
    let mut detail = new_detail(test_rom(1, None));
    detail.select_tab(DetailTab::Extras);
    let entries = detail.footer_help_entries();
    assert!(entries
        .iter()
        .any(|entry| entry.key == "Space" && entry.label == "Toggle"));
    assert!(entries
        .iter()
        .any(|entry| entry.key == "Enter" && entry.label == "Download"));
}

#[test]
fn footer_technical_tab_has_match_unmatch() {
    let detail = new_detail(test_rom(1, None));
    let mut d = detail;
    d.select_tab(DetailTab::Technical);
    let entries = d.footer_help_entries();
    assert!(entries
        .iter()
        .any(|entry| entry.key == "m" && entry.label == "Unmatch"));
}

#[test]
fn footer_saves_tab_shows_upload_download() {
    let mut detail = new_detail(test_rom(1, None));
    detail.select_tab(DetailTab::Saves);
    let entries = detail.footer_help_entries();
    assert!(entries
        .iter()
        .any(|entry| entry.key == "u" && entry.label == "Upload save"));
    assert!(entries
        .iter()
        .any(|entry| entry.key == "D" && entry.label == "Download save"));
}

#[test]
fn tab_switching_changes_active_tab() {
    let mut detail = new_detail(test_rom(1, None));
    assert_eq!(detail.active_tab, DetailTab::Info);
    detail.select_tab(DetailTab::Extras);
    assert_eq!(detail.active_tab, DetailTab::Extras);
    detail.select_tab(DetailTab::Saves);
    assert_eq!(detail.active_tab, DetailTab::Saves);
    detail.select_tab(DetailTab::Achievements);
    assert_eq!(detail.active_tab, DetailTab::Achievements);
    detail.select_tab(DetailTab::Technical);
    assert_eq!(detail.active_tab, DetailTab::Technical);
    detail.select_tab(DetailTab::Info);
    assert_eq!(detail.active_tab, DetailTab::Info);
}

#[test]
fn cover_panel_width_defaults_to_forty_two() {
    let detail = new_detail(test_rom(1, None));
    assert_eq!(detail.cover_panel_width, 42);
}

#[test]
fn adjust_cover_panel_width_clamps_at_bounds() {
    let mut detail = new_detail(test_rom(1, None));
    detail.adjust_cover_panel_width(-100);
    assert_eq!(detail.cover_panel_width, 20);
    detail.adjust_cover_panel_width(100);
    assert_eq!(detail.cover_panel_width, 60);
}

#[test]
fn cover_state_transitions_to_ready_and_error() {
    let mut detail = new_detail(test_rom(1, Some("http://example.com/cover.png".into())));
    detail.set_cover_loading();
    assert_eq!(detail.cover_state, CoverState::Loading);

    detail.apply_cover_image(image::DynamicImage::new_rgba8(4, 4));
    assert_eq!(detail.cover_state, CoverState::Ready);
    assert!(detail.cover_image.is_some());

    detail.apply_cover_error("oops".to_string());
    assert_eq!(detail.cover_state, CoverState::Failed("oops".to_string()));
}

#[test]
fn save_list_formatting_handles_states() {
    assert!(save_lines(&SaveListState::Loading, 0)[0]
        .to_string()
        .contains("Loading"));
    assert!(save_lines(&SaveListState::Failed("boom".into()), 0)[0]
        .to_string()
        .contains("Error"));
    assert!(save_lines(&SaveListState::Loaded(vec![]), 0)[0]
        .to_string()
        .contains("No remote saves"));

    let rows = vec![SaveMetadata {
        id: 7,
        file_name: "game.sav".into(),
        emulator: Some("duckstation".into()),
        slot: Some("1".into()),
        updated_at: Some("2026-05-12T00:00:00Z".into()),
        hash: Some("abcdef1234567890".into()),
        size_bytes: Some(1024),
        device_id: Some("dev1".into()),
        device_name: None,
        screenshot: None,
    }];
    let line = save_lines(&SaveListState::Loaded(rows), 0)[0].to_string();
    assert!(line.contains("> game.sav"));
    assert!(line.contains("emu=duckstation"));
    assert!(line.contains("slot=1"));
}

fn test_rom(id: u64, url_cover: Option<String>) -> romm_api::types::Rom {
    romm_api::types::Rom {
        id,
        platform_id: 1,
        platform_slug: None,
        platform_fs_slug: None,
        platform_custom_name: None,
        platform_display_name: None,
        fs_name: "game.zip".to_string(),
        fs_name_no_tags: "game".to_string(),
        fs_name_no_ext: "game".to_string(),
        fs_extension: "zip".to_string(),
        fs_path: "/game.zip".to_string(),
        fs_size_bytes: 10,
        name: "game".to_string(),
        slug: None,
        summary: None,
        path_cover_small: None,
        path_cover_large: None,
        url_cover,
        has_manual: false,
        path_manual: None,
        url_manual: None,
        is_unidentified: false,
        is_identified: true,
        files: Vec::new(),
        ra_id: None,
        merged_ra_metadata: None,
    }
}

#[test]
fn achievement_selection_nav_clamps() {
    let mut detail = new_detail(test_rom(1, None));
    use super::types::AchievementListState;
    detail.achievements_state = AchievementListState::Loaded {
        rows: vec![
            romm_api::types::AchievementRow {
                title: "A".into(),
                description: Some("Desc A".into()),
                points: Some(5),
                earned: false,
                earned_at: None,
            },
            romm_api::types::AchievementRow {
                title: "B".into(),
                description: None,
                points: Some(10),
                earned: true,
                earned_at: Some("2024-01-01".into()),
            },
        ],
        summary: (1, 2),
    };
    assert_eq!(detail.selected_achievement_index, 0);
    detail.achievement_selection_next();
    assert_eq!(detail.selected_achievement_index, 1);
    detail.achievement_selection_next();
    assert_eq!(detail.selected_achievement_index, 1);
    detail.achievement_selection_previous();
    assert_eq!(detail.selected_achievement_index, 0);
    detail.achievement_selection_previous();
    assert_eq!(detail.selected_achievement_index, 0);

    let sel = detail.selected_achievement().unwrap();
    assert_eq!(sel.title, "A");
    assert_eq!(sel.description.as_deref(), Some("Desc A"));
}

#[test]
fn save_screenshot_state_transitions() {
    let mut detail = new_detail(test_rom(1, None));
    assert_eq!(detail.save_screenshot_state, CoverState::Idle);
    assert!(detail.save_screenshot_image.is_none());

    detail.apply_save_screenshot_image(image::DynamicImage::new_rgba8(4, 4));
    assert_eq!(detail.save_screenshot_state, CoverState::Ready);
    assert!(detail.save_screenshot_image.is_some());

    detail.apply_save_screenshot_error("bad".to_string());
    assert_eq!(
        detail.save_screenshot_state,
        CoverState::Failed("bad".to_string())
    );
    assert!(detail.save_screenshot_image.is_none());
}

fn new_detail(rom: romm_api::types::Rom) -> GameDetailScreen {
    GameDetailScreen::new(
        rom,
        Vec::new(),
        GameDetailPrevious::Search(SearchScreen::new()),
        Arc::new(Mutex::new(Vec::new())),
        COVER_PANEL_WIDTH_DEFAULT,
    )
}