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,
);
}