397 lines
15 KiB
Rust
397 lines
15 KiB
Rust
//! 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
|
|
/// (`<state>/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<T: mlua::FromLuaMulti>(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<f64> {
|
|
eval(s, "return pmacs.gpu.font().size")
|
|
}
|
|
|
|
fn family(s: &EditorState) -> Option<String> {
|
|
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<String> = 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<String> = 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<String> = 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:?}"
|
|
);
|
|
}
|