pmacs/tests/gui_zoom_acceptance.rs

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:?}"
);
}