Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/facett

facett-filedialog/src/tests.rs

//! Inject-and-assert tests (LAW #1): feed a real injected listing + drive the
//! dialog by Actions, asserting the resolved outcome / cursor / picks — never by
//! simulating raw pixels. Deterministic (no disk).

use super::*;
// `Facet` for `state_json`/`caps`/`copy`/`ui`; the inherent methods resolve directly.
use facett_core::Facet;

fn listing() -> Vec<Entry> {
    vec![
        Entry::file("readme.md"),
        Entry::dir("src"),
        Entry::file("Cargo.toml"),
        Entry::dir("docs"),
        Entry::file("notes.txt"),
    ]
}

#[test]
fn entries_are_sorted_dirs_first_then_name() {
    let mut d = FileDialog::new(DialogMode::Open, "/proj");
    d.set_entries(listing());
    let s = d.state_json();
    let vis: Vec<String> = s["visible"].as_array().unwrap().iter().map(|v| v.as_str().unwrap().to_string()).collect();
    assert_eq!(vis, vec!["docs", "src", "Cargo.toml", "notes.txt", "readme.md"]);
}

#[test]
fn arrow_nav_then_confirm_opens_the_cursored_file() {
    let mut d = FileDialog::new(DialogMode::Open, "/proj");
    d.set_entries(listing());
    // dirs first: cursor 0 = docs, 1 = src, 2 = Cargo.toml
    d.move_cursor(2); // → Cargo.toml
    d.confirm();
    assert_eq!(d.outcome(), &Outcome::Confirmed(vec!["/proj/Cargo.toml".into()]));
}

#[test]
fn confirm_on_a_directory_navigates_into_it() {
    let mut d = FileDialog::new(DialogMode::Open, "/proj");
    d.set_entries(listing());
    d.move_cursor(0); // docs (a dir)
    d.confirm();
    assert_eq!(d.state_json()["cwd"], "/proj/docs");
    assert_eq!(d.outcome(), &Outcome::Pending, "entering a dir doesn't resolve the dialog");
}

#[test]
fn type_to_filter_narrows_the_listing() {
    let mut d = FileDialog::new(DialogMode::Open, "/proj");
    d.set_entries(listing());
    d.set_filter("to");
    let vis: Vec<String> =
        d.state_json()["visible"].as_array().unwrap().iter().map(|v| v.as_str().unwrap().to_string()).collect();
    // "Cargo.toml" and "notes.txt"? only those containing "to": Cargo.toml + notes? notes has no "to". → Cargo.toml only
    assert_eq!(vis, vec!["Cargo.toml"]);
}

#[test]
fn pick_folder_only_shows_dirs_and_confirms_a_folder() {
    let mut d = FileDialog::new(DialogMode::PickFolder, "/proj");
    d.set_entries(listing());
    let vis: Vec<String> =
        d.state_json()["visible"].as_array().unwrap().iter().map(|v| v.as_str().unwrap().to_string()).collect();
    assert_eq!(vis, vec!["docs", "src"], "folders only");
    d.move_cursor(1); // src
    d.confirm();
    assert_eq!(d.outcome(), &Outcome::Confirmed(vec!["/proj/src".into()]));
}

#[test]
fn open_many_toggles_picks_and_confirms_all() {
    let mut d = FileDialog::new(DialogMode::OpenMany, "/proj");
    d.set_entries(listing());
    d.move_cursor(2); // Cargo.toml (cursor 2)
    d.toggle_pick();
    d.move_cursor(1); // notes.txt (cursor 3)
    d.toggle_pick();
    assert_eq!(d.picks().len(), 2);
    d.confirm();
    match d.outcome() {
        Outcome::Confirmed(p) => {
            assert!(p.contains(&"/proj/Cargo.toml".to_string()));
            assert!(p.contains(&"/proj/notes.txt".to_string()));
        }
        o => panic!("expected confirmed, got {o:?}"),
    }
}

#[test]
fn save_mode_confirms_the_typed_name() {
    let mut d = FileDialog::new(DialogMode::Save, "/proj");
    d.set_entries(listing());
    d.confirm(); // empty name → no resolution
    assert_eq!(d.outcome(), &Outcome::Pending);
    d.set_save_name("report.csv");
    d.confirm();
    assert_eq!(d.outcome(), &Outcome::Confirmed(vec!["/proj/report.csv".into()]));
}

#[test]
fn cancel_resolves_cancelled() {
    let mut d = FileDialog::new(DialogMode::Open, "/proj");
    d.set_entries(listing());
    d.cancel();
    assert_eq!(d.outcome(), &Outcome::Cancelled);
}

#[test]
fn copy_yields_the_cursored_path() {
    let mut d = FileDialog::new(DialogMode::Open, "/proj");
    d.set_entries(listing());
    d.move_cursor(1); // src
    assert_eq!(d.copy(), Some("/proj/src".to_string()));
}

#[test]
fn caps_and_serde_round_trip() {
    let mut d = FileDialog::new(DialogMode::OpenMany, "/proj");
    d.set_entries(listing());
    let caps = d.caps();
    assert!(caps.themeable && caps.searchable && caps.copyable && caps.selectable);
    let json = serde_json::to_string(&d).unwrap();
    let back: FileDialog = serde_json::from_str(&json).unwrap();
    assert_eq!(back.state_json(), d.state_json());
}

#[test]
fn picturable_headless_with_injected_listing() {
    use facett_core::harness::headless_render;
    let mut d = FileDialog::new(DialogMode::Open, "/proj");
    d.set_entries(listing());
    let r = headless_render(&mut d);
    assert!(r.drew(), "dialog renders a non-empty frame headlessly (FILE-3)");
    assert_eq!(r.state["cwd"], "/proj");
}

#[test]
fn keymap_arrow_down_moves_cursor_in_the_ui_path() {
    use facett_core::look::{Action, KeyMap, publish_keymap};
    let mut d = FileDialog::new(DialogMode::Open, "/proj");
    d.set_entries(listing());
    let ctx = egui::Context::default();
    let sc = KeyMap::windows().shortcut(Action::Down).unwrap();
    let input = egui::RawInput {
        screen_rect: Some(egui::Rect::from_min_size(egui::pos2(0.0, 0.0), egui::vec2(600.0, 400.0))),
        events: vec![egui::Event::ModifiersChanged(sc.modifiers), egui::Event::Key { key: sc.logical_key, physical_key: None, pressed: true, repeat: false, modifiers: sc.modifiers }],
        ..Default::default()
    };
    ctx.run_ui(input, |ui| {
        publish_keymap(ui.ctx(), KeyMap::windows());
        egui::CentralPanel::default().show(ui, |ui| d.ui(ui));
    }).textures_delta.clear();
    assert_eq!(d.cursor(), 1, "ArrowDown via KeyMap advanced the cursor");
}

#[test]
fn typed_paste_sets_the_save_name_only_in_save_mode() {
    use facett_core::clip::{ClipKind, ClipPayload, PasteTarget};
    // Save mode: a pasted text (first line) becomes the filename.
    let mut d = FileDialog::new(DialogMode::Save, "/proj");
    assert!(d.accepts(ClipKind::Text));
    d.paste_payload(&ClipPayload::Rows("out.csv\tignored".into()));
    assert_eq!(d.state_json()["save_name"], "out.csv");
    d.confirm();
    assert_eq!(d.outcome(), &Outcome::Confirmed(vec!["/proj/out.csv".into()]));
    // Open mode: paste is rejected (no editable filename field).
    let mut o = FileDialog::new(DialogMode::Open, "/proj");
    assert!(!o.accepts(ClipKind::Text));
    assert!(!<FileDialog as facett_core::Facet>::paste(&mut o, "x.txt"));
}

// ── FC-2 → FC-3 as a *headless* property: feed a `Vec<Msg>` through the core
//    harness and assert the resulting `FileDialogState` — no egui, no GPU. ────────

#[test]
fn harness_drives_msgs_and_snapshots_state() {
    use facett_core::harness;
    let mut d = FileDialog::new(DialogMode::Open, "/proj");
    // Injection is itself a Msg; then arrow-nav + confirm the cursored file — the
    // exact transitions the keyboard/click path drives, but with zero device.
    let snap = harness::snapshot(
        &mut d,
        [Msg::SetEntries(listing()), Msg::MoveCursor(2), Msg::Confirm], // dirs first: 0=docs,1=src,2=Cargo.toml
    );
    assert_eq!(snap.cursor, 2, "cursor is observable headlessly");
    assert_eq!(snap.outcome, Outcome::Confirmed(vec!["/proj/Cargo.toml".into()]));
    assert_eq!(&snap, d.state(), "the snapshot is a clone of the live state");
}

#[test]
fn harness_confirm_on_a_directory_navigates_via_msg() {
    use facett_core::harness;
    let mut d = FileDialog::new(DialogMode::Open, "/proj");
    // MoveCursor(0) keeps cursor on `docs` (a dir); Confirm navigates into it.
    let snap = harness::snapshot(&mut d, [Msg::SetEntries(listing()), Msg::Confirm]);
    assert_eq!(snap.cwd, "/proj/docs");
    assert_eq!(snap.outcome, Outcome::Pending, "entering a dir doesn't resolve the dialog");
    assert!(snap.entries.is_empty(), "the old listing is cleared pending re-injection");
}

#[test]
fn harness_open_many_toggles_picks_and_confirms_all() {
    use facett_core::harness;
    let mut d = FileDialog::new(DialogMode::OpenMany, "/proj");
    let snap = harness::snapshot(
        &mut d,
        [
            Msg::SetEntries(listing()),
            Msg::MoveCursor(2), // Cargo.toml
            Msg::TogglePick,
            Msg::MoveCursor(1), // notes.txt
            Msg::TogglePick,
            Msg::Confirm,
        ],
    );
    assert_eq!(snap.picks.len(), 2, "two stable-name picks (FC-5)");
    match &snap.outcome {
        Outcome::Confirmed(p) => {
            assert!(p.contains(&"/proj/Cargo.toml".to_string()));
            assert!(p.contains(&"/proj/notes.txt".to_string()));
        }
        o => panic!("expected confirmed, got {o:?}"),
    }
}

#[test]
fn drive_returns_no_effects_and_state_round_trips() {
    use facett_core::harness;
    let mut d = FileDialog::new(DialogMode::Save, "/proj");
    // FC-8: the dialog does no I/O, so the effect stream is always empty.
    let effects = harness::drive(&mut d, [Msg::SetSaveName("report.csv".into()), Msg::Confirm]);
    assert!(effects.is_empty(), "FC-8: filedialog emits no Effects");
    // FC-3: the observable Model round-trips through serde.
    let json = serde_json::to_value(d.state()).unwrap();
    let back: FileDialogState = serde_json::from_value(json).unwrap();
    assert_eq!(&back, d.state(), "serde(state) -> state round-trips");
}

#[test]
fn setscale_msg_clamps_and_is_observable() {
    use facett_core::harness;
    let mut d = FileDialog::new(DialogMode::Open, "/proj");
    let snap = harness::snapshot(&mut d, [Msg::SetScale(99.0)]);
    assert_eq!(snap.scale, 4.0, "scale clamps to the 0.25..=4.0 band");
    // The Facet scale surface reads the same Model field.
    assert_eq!(Facet::scale(&d), 4.0);
}

#[test]
fn state_json_keys_are_preserved_byte_for_byte() {
    // The custom (form-3) state_json must expose exactly the keys facett-demo reads.
    let mut d = FileDialog::new(DialogMode::OpenMany, "/proj");
    d.set_entries(listing());
    d.move_cursor(2);
    d.toggle_pick();
    let j = d.state_json();
    let obj = j.as_object().unwrap();
    let mut keys: Vec<&str> = obj.keys().map(|k| k.as_str()).collect();
    keys.sort_unstable();
    assert_eq!(keys, vec!["cursor", "cwd", "filter", "mode", "outcome", "picks", "save_name", "visible"]);
    assert_eq!(j["mode"], "OpenMany", "mode is the Debug-formatted variant");
    assert_eq!(j["outcome"], "pending");
    assert!(j["visible"].is_array(), "visible is the filtered listing");
    assert_eq!(j["picks"][0], "Cargo.toml");
}

/// **RED-when-broken: the uniform scale knob reaches the PIXELS.**
///
/// Everything else here is a state round-trip — set a value, read it back, see it in
/// `state_json`. Measured: deleting the scale application from the painter outright
/// left this whole crate's suite green, and `RenderReport::drew()` cannot see it
/// either (a glyph is four vertices at 6pt and at 60pt). So this asserts APPLIED
/// geometry: the area of the glyph triangles the painter really emitted, which must
/// grow roughly 4x when the scale doubles.
#[test]
fn scale_is_applied_to_the_drawn_rows_not_just_stored() {
    use facett_core::harness;
    let ink = |s: f32| {
        let mut v = { let mut d = FileDialog::new(DialogMode::Open, "/proj"); d.set_entries(listing()); d };
        <_ as facett_core::Facet>::set_scale(&mut v, s);
        harness::headless_render(&mut v).textured_ink_area
    };
    let (small, big) = (ink(1.0), ink(2.0));
    assert!(small > 0.0, "the facet draws text at 1.0x ({small})");
    assert!(
        big > small * 2.0,
        "doubling the scale must roughly QUADRUPLE the glyph ink, got {small:.0} -> \
         {big:.0} pt^2 ({:.2}x). A ratio near 1.0 means the knob is stored, clamped and \
         serialised but never applied to anything drawn.",
        big / small,
    );
}