codewhale-tui 0.9.3

Terminal UI for open-source and open-weight coding models
use super::*;

fn distance(a: Color, b: Color) -> u16 {
    let (ar, ag, ab) = rgb(a).expect("RGB color");
    let (br, bg, bb) = rgb(b).expect("RGB color");
    ar.abs_diff(br) as u16 + ag.abs_diff(bg) as u16 + ab.abs_diff(bb) as u16
}

fn relative_luminance(value: Color) -> f64 {
    let (r, g, b) = rgb(value).expect("contrast colors must be RGB");
    let linearize = |component: u8| {
        let srgb = f64::from(component) / 255.0;
        if srgb <= 0.04045 {
            srgb / 12.92
        } else {
            ((srgb + 0.055) / 1.055).powf(2.4)
        }
    };
    0.2126 * linearize(r) + 0.7152 * linearize(g) + 0.0722 * linearize(b)
}

fn contrast_ratio(foreground: Color, background: Color) -> f64 {
    let foreground = relative_luminance(foreground);
    let background = relative_luminance(background);
    let (lighter, darker) = if foreground >= background {
        (foreground, background)
    } else {
        (background, foreground)
    };
    (lighter + 0.05) / (darker + 0.05)
}

#[test]
fn whale_ramp_is_perceptibly_deep_not_merely_non_equal() {
    let ramp = OceanRamp::for_theme(&crate::palette::UI_THEME).expect("RGB theme");
    assert_eq!(ramp.surface, Color::Rgb(0x0e, 0x17, 0x29));
    assert_eq!(ramp.middle, Color::Rgb(0x08, 0x11, 0x1c));
    assert_eq!(ramp.deep, Color::Rgb(0x03, 0x07, 0x0d));
    assert!(
        distance(ramp.surface, ramp.deep) >= 32,
        "the selected underwater treatment must read at a glance"
    );
    assert_ne!(ramp.color_at(0, 20), ramp.color_at(19, 20));
}

#[test]
fn light_theme_stays_light_enough_for_light_theme_text() {
    let ramp = OceanRamp::for_theme(&crate::palette::LIGHT_UI_THEME).expect("RGB theme");
    assert_eq!(ramp.surface, Color::Rgb(0xff, 0xfd, 0xf8));
    assert_eq!(ramp.middle, Color::Rgb(0xf4, 0xf7, 0xfb));
    assert_eq!(ramp.deep, Color::Rgb(0xf0, 0xf4, 0xf9));
    let (r, g, b) = rgb(ramp.deep).expect("RGB color");
    assert!(u16::from(r) + u16::from(g) + u16::from(b) > 420);
}

#[test]
fn light_ocean_and_selection_keep_text_and_semantic_roles_readable() {
    let theme = crate::palette::LIGHT_UI_THEME;
    let ramp = OceanRamp::for_theme(&theme).expect("RGB theme");
    let foregrounds = [
        ("body", theme.text_body),
        ("soft", theme.text_soft),
        ("muted", theme.text_muted),
        ("hint", theme.text_hint),
        ("action", theme.accent_primary),
        ("live", theme.status_working),
        ("human", theme.accent_action),
        ("warning", theme.warning),
        ("danger", theme.error_fg),
        ("act mode", theme.mode_agent),
        ("plan mode", theme.mode_plan),
        ("operate", theme.mode_operate),
        ("full-access mode", theme.mode_yolo),
        ("success", theme.success),
        ("user", crate::palette::LIGHT_USER_BODY),
    ];
    let backgrounds = [
        ("ocean surface", ramp.surface),
        ("ocean middle", ramp.middle),
        ("ocean deep", ramp.deep),
        ("selection", theme.selection_bg),
    ];

    for (background_name, background) in backgrounds {
        for (foreground_name, foreground) in foregrounds {
            let ratio = contrast_ratio(foreground, background);
            assert!(
                ratio >= 4.5,
                "light {foreground_name} on {background_name} contrast {ratio:.2} is below 4.50"
            );
        }
    }
}

#[test]
fn whale_custom_background_uses_the_configured_surface() {
    let custom = Color::Rgb(0x12, 0x1a, 0x2d);
    let theme = crate::palette::UI_THEME.with_background_color(custom);
    let ramp = OceanRamp::for_theme(&theme).expect("custom backgrounds retain ombre");

    assert_ne!(ramp.surface, Color::Rgb(0x0e, 0x17, 0x29));
    assert_ne!(ramp.surface, ramp.deep);
}

#[test]
fn inherited_terminal_background_reports_no_ramp() {
    let mut theme = crate::palette::UI_THEME;
    theme.surface_bg = Color::Reset;
    assert_eq!(OceanRamp::for_theme(&theme), None);
}

#[test]
fn solarized_light_preserves_its_canonical_base3_background() {
    let theme = crate::palette::SOLARIZED_LIGHT_UI_THEME;

    assert_eq!(theme.surface_bg, Color::Rgb(0xfd, 0xf6, 0xe3));
    assert_eq!(OceanRamp::for_theme(&theme), None);
}

#[test]
fn solarized_light_custom_background_preserves_ombre() {
    let custom = Color::Rgb(0x1a, 0x1b, 0x26);
    let theme = crate::palette::SOLARIZED_LIGHT_UI_THEME.with_background_color(custom);
    let ramp = OceanRamp::for_theme(&theme).expect("custom backgrounds retain ombre");

    assert_ne!(ramp.surface, custom);
    assert_ne!(ramp.surface, ramp.deep);
}

#[test]
fn every_shipped_theme_has_an_intentional_ocean_treatment() {
    use crate::palette::{SELECTABLE_THEMES, ThemeId};

    for id in SELECTABLE_THEMES {
        let ramp = OceanRamp::for_theme(&id.ui_theme());
        if matches!(id, ThemeId::Terminal | ThemeId::SolarizedLight) {
            assert_eq!(
                ramp,
                None,
                "{} must keep its canonical background",
                id.name()
            );
        } else {
            let ramp = ramp.unwrap_or_else(|| panic!("{} has no ocean ramp", id.name()));
            assert_ne!(
                ramp.surface,
                ramp.deep,
                "{} lost underwater depth",
                id.name()
            );
        }
    }
}

#[test]
fn treatment_parses_saved_values_and_defaults_to_ombre() {
    assert_eq!(OceanTreatment::parse("flat"), OceanTreatment::Flat);
    assert_eq!(OceanTreatment::parse(" FLAT "), OceanTreatment::Flat);
    assert_eq!(OceanTreatment::parse("classic"), OceanTreatment::Classic);
    assert_eq!(OceanTreatment::parse("ombre"), OceanTreatment::Ombre);
    assert_eq!(OceanTreatment::parse("kelp"), OceanTreatment::Ombre);
    assert_eq!(OceanTreatment::parse(""), OceanTreatment::Ombre);
}

#[test]
fn every_underwater_treatment_keeps_ambient_life() {
    assert!(OceanTreatment::Ombre.supports_ambient_life());
    assert!(OceanTreatment::Flat.supports_ambient_life());
    assert!(!OceanTreatment::Classic.supports_ambient_life());
}

#[test]
fn ambient_ink_matches_sunk_sky_shades_and_survives_reset_surfaces() {
    // RGB themes: fish wear two sunk sky shades; seafoam remains live-work ink.
    let theme = crate::palette::UI_THEME;
    let ramp = OceanRamp::for_theme(&theme).expect("RGB theme");
    let (primary, secondary) = ambient_inks(&theme);
    assert_ne!(primary, ramp.ambient);
    assert_ne!(primary, secondary);
    assert_ne!(primary, theme.accent_secondary);

    // Terminal-owned surfaces have no RGB base; the raw secondary accent
    // lets the terminal's own palette color the life.
    let terminal = crate::palette::TERMINAL_UI_THEME;
    assert_eq!(ambient_inks(&terminal), (terminal.info, terminal.info));
}

#[test]
fn shimmer_is_subtle_and_concentrated_near_the_surface() {
    let ramp = OceanRamp::for_theme(&crate::palette::UI_THEME).expect("RGB theme");
    let surface_a = ramp.color_at_phase(0, 20, 0, ShellPhase::Idle);
    let surface_b = ramp.color_at_phase(0, 20, 3_000, ShellPhase::Idle);
    let deep_a = ramp.color_at_phase(19, 20, 0, ShellPhase::Idle);
    let deep_b = ramp.color_at_phase(19, 20, 3_000, ShellPhase::Idle);

    let surface_shift = distance(surface_a, surface_b);
    assert!(
        (1..=8).contains(&surface_shift),
        "surface shift was {surface_shift}"
    );
    assert_eq!(
        deep_a, deep_b,
        "the floor should stay perceptually anchored"
    );
}

#[test]
fn attention_phases_are_still_and_work_phases_have_distinct_depth_bias() {
    let ramp = OceanRamp::for_theme(&crate::palette::UI_THEME).expect("RGB theme");
    for phase in [
        ShellPhase::Waiting,
        ShellPhase::Approval,
        ShellPhase::Failed,
    ] {
        assert_eq!(
            ramp.color_at_phase(4, 20, 0, phase),
            ramp.color_at_phase(4, 20, 45_000, phase)
        );
    }
    assert_ne!(
        ramp.color_at_phase(10, 20, 22_500, ShellPhase::Working),
        ramp.color_at_phase(10, 20, 22_500, ShellPhase::Verifying)
    );
}

#[test]
fn completion_breath_peaks_once_then_settles() {
    let ramp = OceanRamp::for_theme(&crate::palette::UI_THEME).expect("RGB theme");
    let start = ramp.color_at_completion(0, 20, 0);
    let peak = ramp.color_at_completion(0, 20, 320);
    let settled = ramp.color_at_completion(0, 20, 800);
    assert_ne!(start, peak);
    assert_ne!(peak, settled);
    assert_eq!(settled, ramp.color_at(0, 20));
}

#[test]
fn cache_fingerprint_changes_when_only_ramp_colors_change() {
    let viewport = Rect::new(3, 5, 80, 24);
    let first_ramp = OceanRamp {
        surface: Color::Rgb(1, 2, 3),
        middle: Color::Rgb(4, 5, 6),
        deep: Color::Rgb(7, 8, 9),
        ambient: Color::Rgb(10, 11, 12),
    };
    let second_ramp = OceanRamp {
        surface: Color::Rgb(21, 22, 23),
        middle: Color::Rgb(24, 25, 26),
        deep: Color::Rgb(27, 28, 29),
        ambient: Color::Rgb(30, 31, 32),
    };
    let first = OceanColumn::new(
        first_ramp,
        viewport,
        22_500,
        None,
        ShellPhase::Working,
        true,
    );
    let second = OceanColumn::new(
        second_ramp,
        viewport,
        22_500,
        None,
        ShellPhase::Working,
        true,
    );

    assert_ne!(first.color_at_y(viewport.y), second.color_at_y(viewport.y));
    assert_ne!(
        first.ramp_fingerprint(),
        second.ramp_fingerprint(),
        "visibly different palettes must not reuse the same row-color cache"
    );
}

#[test]
fn each_ramp_color_participates_in_the_typed_cache_identity() {
    let viewport = Rect::new(3, 5, 80, 24);
    let ramp = OceanRamp {
        surface: Color::Rgb(1, 2, 3),
        middle: Color::Rgb(4, 5, 6),
        deep: Color::Rgb(7, 8, 9),
        ambient: Color::Rgb(10, 11, 12),
    };
    let baseline = OceanColumn::new(ramp, viewport, 22_500, None, ShellPhase::Working, true);
    let alternatives = [
        OceanRamp {
            surface: Color::Rgb(101, 2, 3),
            ..ramp
        },
        OceanRamp {
            middle: Color::Rgb(104, 5, 6),
            ..ramp
        },
        OceanRamp {
            deep: Color::Rgb(107, 8, 9),
            ..ramp
        },
        OceanRamp {
            ambient: Color::Rgb(110, 11, 12),
            ..ramp
        },
    ];

    for alternative in alternatives {
        let changed = OceanColumn::new(
            alternative,
            viewport,
            22_500,
            None,
            ShellPhase::Working,
            true,
        );
        assert_ne!(
            baseline.ramp_cache_identity(),
            changed.ramp_cache_identity()
        );
        assert_ne!(baseline.ramp_fingerprint(), changed.ramp_fingerprint());
    }
}

#[test]
fn identical_semantic_cache_inputs_have_identical_identity() {
    let ramp = OceanRamp::for_theme(&crate::palette::UI_THEME).expect("RGB theme");
    let viewport = Rect::new(3, 5, 80, 24);
    let first = OceanColumn::new(ramp, viewport, 22_500, None, ShellPhase::Working, true);
    let second = OceanColumn::new(ramp, viewport, 22_500, None, ShellPhase::Working, true);

    assert_eq!(first.ramp_cache_identity(), second.ramp_cache_identity());
    assert_eq!(first.ramp_fingerprint(), second.ramp_fingerprint());
}

#[test]
fn split_shell_surfaces_share_one_absolute_row_column() {
    let theme = crate::palette::UI_THEME;
    let ramp = OceanRamp::for_theme(&theme).expect("RGB theme");
    let viewport = Rect::new(0, 0, 12, 12);
    let header = Rect::new(0, 0, 12, 2);
    let composer = Rect::new(0, 10, 12, 2);
    let mut buf = Buffer::empty(viewport);
    for y in header.top()..header.bottom() {
        for x in header.left()..header.right() {
            buf[(x, y)].set_bg(theme.header_bg);
        }
    }
    for y in composer.top()..composer.bottom() {
        for x in composer.left()..composer.right() {
            buf[(x, y)].set_bg(theme.composer_bg);
        }
    }
    buf[(4, 10)].set_bg(theme.selection_bg);

    let column = OceanColumn::new(ramp, viewport, 0, None, ShellPhase::Idle, false);
    column.paint_matching(header, &mut buf, theme.header_bg);
    column.paint_matching(composer, &mut buf, theme.composer_bg);

    assert_eq!(buf[(0, 0)].bg, ramp.color_at(0, 12));
    assert_eq!(buf[(0, 11)].bg, ramp.color_at(11, 12));
    assert_ne!(buf[(0, 1)].bg, buf[(0, 10)].bg);
    assert_eq!(
        buf[(4, 10)].bg,
        theme.selection_bg,
        "semantic surfaces must survive the shell ombre pass"
    );
}