//! GUI zoom acceptance (`QoL` Stage 2, `docs/gui-zoom-framing.md`). //! //! Zoom drives the font preference that already existed: `set_font` //! writes it, `semantic_render` relays it as `FontFacts` at v17, and the //! GPU frontend owns every pixel consequence. Nothing here knows a //! metric — the no-pixels invariant holds through the whole feature. //! //! Each test gets its **own** bootstrap roots. `iso::roots()` is a pure //! function of the build environment by design, so every suite sharing //! it shares one state directory — fine when nobody writes, wrong here, //! where the state file *is* the subject and parallel tests would //! overwrite each other's fixture. use std::path::{Path, PathBuf}; use pmacs::bootstrap::BootstrapRoots; use pmacs::editor::EditorState; /// Per-test roots plus the path zoom's state file will occupy /// (`/pmacs/gpu-zoom`, per `src/state.rs`). fn roots_for(name: &str) -> (BootstrapRoots, PathBuf) { let base = Path::new(env!("CARGO_TARGET_TMPDIR")) .join("gui-zoom") .join(name); let _ = std::fs::remove_dir_all(&base); let roots = BootstrapRoots::isolated_under(&base); for (_, dir) in roots.child_env() { std::fs::create_dir_all(&dir).expect("create controlled root"); } let dir = roots.state_dir().expect("isolated roots have a state dir"); std::fs::create_dir_all(&dir).expect("create state dir"); (roots, dir.join("gpu-zoom")) } /// A session whose state dirs are installed — which is also what runs /// `pmacs.zoom.restore`, so anything planted at `path` beforehand is /// visible to it. fn session(roots: &BootstrapRoots) -> EditorState { let state = EditorState::new_with_roots(roots); state.install_state_dirs(); state } fn eval(s: &EditorState, src: &str) -> T { s.lua_host.lua().load(src.to_string()).eval().unwrap() } fn exec(s: &EditorState, src: &str) { s.lua_host.lua().load(src.to_string()).exec().unwrap(); } /// Current preference size in logical px, or `None` when unset — the /// real "frontend's own default" state, never inferred from silence. fn size(s: &EditorState) -> Option { eval(s, "return pmacs.gpu.font().size") } fn family(s: &EditorState) -> Option { eval(s, "return pmacs.gpu.font().family") } // --------------------------------------------------------------------------- // The starting point (Q#Z1 = c) // --------------------------------------------------------------------------- /// The first step starts from the CONFIGURED base, not a hardcoded /// 16.0 — which is the whole reason (c) was chosen over (a). The daemon /// never learns what the frontend resolved; it reads a user-facing /// preference about zooming. #[test] fn the_first_step_starts_from_the_configured_base_not_a_constant() { let (roots, _) = roots_for("first_step_base"); let s = session(&roots); assert_eq!(size(&s), None, "premise: untouched means unset"); exec(&s, r#"pmacs.config.set("ui.gpu-font-size-base", 20.0)"#); exec(&s, "pmacs.zoom.increase()"); assert_eq!( size(&s), Some(21.0), "20.0 base + 1.0 step. A hardcoded origin would give 17.0" ); } /// Until a zoom happens the preference stays unset, so the frontend's /// own default query still runs. This is what option (b) would have /// destroyed for every user who never zooms. #[test] fn defining_the_settings_does_not_itself_set_a_size() { let (roots, _) = roots_for("no_implicit_size"); let s = session(&roots); assert_eq!(size(&s), None); assert_eq!(family(&s), None); } // --------------------------------------------------------------------------- // Step arithmetic (Q#Z2 = additive) // --------------------------------------------------------------------------- /// n steps in, then n steps out, returns to EXACTLY the starting value. /// True because the step is centi-pixel representable and addition is /// exact in that domain — the property multiplicative stepping loses. #[test] fn n_steps_in_then_n_out_returns_exactly() { let (roots, _) = roots_for("round_trip"); let s = session(&roots); exec(&s, r#"pmacs.config.set("ui.gpu-zoom-step", 0.37)"#); exec(&s, "pmacs.zoom.increase()"); let start = size(&s).expect("a size exists after the first step"); for _ in 0..7 { exec(&s, "pmacs.zoom.increase()"); } for _ in 0..7 { exec(&s, "pmacs.zoom.decrease()"); } assert_eq!( size(&s), Some(start), "exact return, not approximately — 0.37 is centi-pixel \ representable and the domain is quantized" ); } /// An out-of-range step leaves the preference **unmutated** rather than /// pinning it to the boundary. Pinning would silently break the round /// trip precisely at the edges, where a user steps back and forth most. #[test] fn a_step_past_the_boundary_changes_nothing_and_says_so() { let (roots, _) = roots_for("boundary"); let s = session(&roots); exec(&s, "pmacs.gpu.set_font { size = 71.5 }"); exec(&s, r#"pmacs.config.set("ui.gpu-zoom-step", 2.0)"#); let why: Option = eval(&s, "local _, why = pmacs.zoom.increase() return why"); assert_eq!( size(&s), Some(71.5), "the preference is untouched, not pinned to 72.0" ); assert!( why.unwrap_or_default().contains("unchanged"), "and the caller is told why" ); } // --------------------------------------------------------------------------- // Config bounds (§3.1) // --------------------------------------------------------------------------- /// A zero, negative, or sub-centi-pixel step is refused by the REGISTRY, /// not discovered later as a zoom that does nothing or runs backwards. /// /// The negative case is the sharp one: it would invert the commands, and /// `gpu.zoom-in` shrinking is not a malfunction a user can diagnose from /// the outside — the command still does something coherent. #[test] fn an_unusable_step_is_refused_at_the_registry() { let (roots, _) = roots_for("step_bounds"); let s = session(&roots); for bad in ["0.0", "-1.0", "0.001"] { let ok: bool = eval( &s, &format!(r#"return pcall(pmacs.config.set, "ui.gpu-zoom-step", {bad})"#), ); assert!(!ok, "a step of {bad} must be refused"); } // …and the smallest representable step is allowed. let ok: bool = eval( &s, r#"return pcall(pmacs.config.set, "ui.gpu-zoom-step", 0.01)"#, ); assert!(ok, "0.01 is one centi-pixel — the smallest real step"); } /// The base is bounded to the wire range. A base outside it could never /// be sent, so the first step would fail from a value the registry had /// allowed the user to set. #[test] fn the_base_is_bounded_to_the_wire_range() { let (roots, _) = roots_for("base_bounds"); let s = session(&roots); for bad in ["5.99", "72.01"] { let ok: bool = eval( &s, &format!(r#"return pcall(pmacs.config.set, "ui.gpu-font-size-base", {bad})"#), ); assert!(!ok, "a base of {bad} is outside 6.00-72.00"); } } // --------------------------------------------------------------------------- // The family clobber (§5a) // --------------------------------------------------------------------------- /// `set_font` replaces BOTH fields unconditionally, so a size-only write /// would clear a family the user configured — and they would get it back /// only by restarting. Every zoom write must carry the family through. #[test] fn a_zoom_preserves_a_configured_family() { let (roots, _) = roots_for("family_kept"); let s = session(&roots); exec( &s, r#"pmacs.gpu.set_font { family = "Iosevka", size = 18.0 }"#, ); exec(&s, "pmacs.zoom.increase()"); assert_eq!(size(&s), Some(19.0)); assert_eq!( family(&s).as_deref(), Some("Iosevka"), "the family survives a zoom — set_font replaces both fields, so \ a size-only write would silently drop it" ); exec(&s, "pmacs.zoom.decrease()"); assert_eq!( family(&s).as_deref(), Some("Iosevka"), "and every step after" ); } // --------------------------------------------------------------------------- // Reset (§5b) // --------------------------------------------------------------------------- /// Reset returns the size to UNSET — the frontend's own default — not to /// the configured base. Resetting to the base would ship an explicit /// size that merely happens to equal the default, making the untouched /// state unreachable once a user has ever zoomed. #[test] fn reset_returns_to_unset_not_to_the_base() { let (roots, path) = roots_for("reset_unsets"); let s = session(&roots); exec(&s, r#"pmacs.gpu.set_font { family = "Iosevka" }"#); exec(&s, "pmacs.zoom.increase()"); assert!(size(&s).is_some(), "premise: a size is set"); assert!(path.exists(), "premise: the zoom was saved"); exec(&s, "pmacs.zoom.reset()"); assert_eq!(size(&s), None, "unset, not the base value"); assert_eq!( family(&s).as_deref(), Some("Iosevka"), "reset is about size; a configured family is not collateral" ); // Cleared, so it does not resurrect on the next launch — "reset // until restart" is not what the word says. let saved = std::fs::read_to_string(&path).unwrap_or_default(); assert!( saved.trim().is_empty(), "reset clears the saved zoom, found {saved:?}" ); } // --------------------------------------------------------------------------- // Persistence and its parser (§5a1) // --------------------------------------------------------------------------- /// The exact bytes the writer produces round-trip. This is the case the /// framing's first parser (`^(%d+)$`) would have REJECTED — it anchors /// to end-of-subject, so it fails on the trailing newline — which is why /// it is asserted rather than assumed. #[test] fn a_valid_newline_terminated_file_restores() { let (roots, path) = roots_for("restore_valid"); std::fs::write(&path, "1800\n").expect("plant state"); let s = session(&roots); assert_eq!( size(&s), Some(18.0), "restored at the seam, from the bytes the writer emits" ); } /// What zoom writes is what zoom reads. Pins the two halves against each /// other so a format change cannot land in one alone. #[test] fn the_written_format_is_the_format_that_parses() { let (roots, path) = roots_for("format_round_trip"); { let s = session(&roots); exec(&s, "pmacs.gpu.set_font { size = 23.5 }"); exec(&s, "pmacs.zoom.increase()"); assert_eq!(size(&s), Some(24.5)); } let raw = std::fs::read_to_string(&path).expect("state written"); assert_eq!(raw, "2450\n", "centi-pixels, newline-terminated"); let s2 = session(&roots); assert_eq!(size(&s2), Some(24.5), "and a fresh session reads it back"); } /// Malformed, out-of-range, and shape-wrong state all behave as absent, /// and the file is left intact for inspection rather than truncated. /// /// The multi-line case is the one that matters most: `saveplace`'s /// `gmatch("([^\n]+)")` line iterator would happily accept its FIRST /// line. That is right for `recentf`, where a line is one independent /// entry, and wrong here, where the file IS the value. #[test] fn unparseable_or_out_of_range_state_behaves_as_absent() { for (label, contents) in [ ("multi-line", "1800\n1900\n"), ("no trailing newline", "1800"), ("decimal", "18.0\n"), ("leading space", " 1800\n"), ("empty", ""), ("bare newline", "\n"), ("non-numeric", "big\n"), ("above the wire range", "9999\n"), ("below the wire range", "100\n"), ] { let (roots, path) = roots_for(&format!("bad_{}", label.replace(' ', "_"))); std::fs::write(&path, contents).expect("plant state"); let s = session(&roots); assert_eq!( size(&s), None, "{label}: {contents:?} must behave exactly as no saved state" ); assert_eq!( std::fs::read_to_string(&path).expect("still there"), contents, "{label}: the file is left intact — truncating it on read \ would destroy the only evidence of whatever wrote it" ); } } /// Precedence: a valid saved zoom beats a size set during startup, and /// never touches a configured family. /// /// The saved value is a later, deliberate user action; the init value is /// the standing default it was chosen against — the same precedence /// `saveplace` already applies to a remembered position. #[test] fn saved_state_beats_a_startup_size_but_never_the_family() { let (roots, path) = roots_for("precedence"); std::fs::write(&path, "2600\n").expect("plant state"); let s = EditorState::new_with_roots(&roots); // Stand in for init.lua, which runs before state is installed. exec( &s, r#"pmacs.gpu.set_font { family = "Iosevka", size = 12.0 }"#, ); s.install_state_dirs(); assert_eq!(size(&s), Some(26.0), "the remembered zoom wins on SIZE"); assert_eq!( family(&s).as_deref(), Some("Iosevka"), "and never on family — restored state does not carry one" ); } // --------------------------------------------------------------------------- // No keybindings (Q#Z3 = C) // --------------------------------------------------------------------------- /// This stage ships commands and NO default bindings. /// /// The keymap has no way to say "GPU frontends only" — `Scope` is /// `Buffer | Mode | Global` and carries no frontend identity — so a /// global binding would capture the chord in the TUI and take away the /// terminal's own zoom, which is the very thing the user is pressing it /// for. Better an unbound key than one that answers with an apology. #[test] fn the_commands_exist_and_nothing_is_bound() { let (roots, _) = roots_for("no_bindings"); let s = session(&roots); // `pmacs.command.list()` returns plain names, as `help.lua` reads it. let names: Vec = eval(&s, "return pmacs.command.list()"); for want in ["gpu.zoom-in", "gpu.zoom-out", "gpu.zoom-reset"] { assert!(names.iter().any(|n| n == want), "{want} is discoverable"); } let bound: Vec = eval( &s, r#"local out = {} for _, b in ipairs(pmacs.keymap.list()) do if b.command and b.command:match("^gpu%.zoom") then out[#out + 1] = string.format("%s -> %s (%s)", b.sequence, b.command, b.scope) end end return out"#, ); assert!( bound.is_empty(), "no zoom keybinding may be installed by default, found {bound:?}" ); }