pmacs/tests/gpu_font_acceptance.rs

493 lines
17 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// gpu_font_acceptance.rs --- gpu-set-font Arc 4 stage 2 acceptance
// (docs/gpu-set-font-framing.md, acceptance items 27; item 1's pins
// live in src/protocol.rs, item 8's TUI drop arm is a unit in
// src/frontend.rs, items 914 and 1619 are GPU routes in pmacs-gpu's
// headless suite, and item 15 is the docs themselves).
//! The `pmacs.gpu.set_font` preference + the `FontFacts` wire channel
//! (protocol v17).
//!
//! Wire claims drive a `SemanticRenderState` frame by frame (the
//! `ThemeFacts` discipline: authoritative per attachment, epoch-gated,
//! silent when unchanged); the Lua contract is exercised against the
//! real `pmacs.gpu` module installed by `EditorState::new`; the
//! version gate exercises a real daemon; init.lua reachability goes
//! through the real `load_user_config_at`.
use pmacs::editor::EditorState;
use pmacs::protocol::{ByteRange, FrontendId, InstanceMessage};
use pmacs::semantic_render::SemanticRenderState;
#[cfg(feature = "crdt")]
mod common;
// ---------------------------------------------------------------------------
// Harness (theme_faces_acceptance conventions)
// ---------------------------------------------------------------------------
fn exec(s: &EditorState, src: &str) {
s.lua_host.lua().load(src.to_string()).exec().unwrap();
}
fn exec_err(s: &EditorState, src: &str) -> mlua::Error {
s.lua_host
.lua()
.load(src.to_string())
.exec()
.expect_err("chunk must error")
}
fn eval<T: mlua::FromLuaMulti>(s: &EditorState, src: &str) -> T {
s.lua_host.lua().load(src.to_string()).eval().unwrap()
}
/// Fresh editor with LSP spawning disabled.
fn editor() -> EditorState {
let s = EditorState::new();
exec(&s, "pmacs.lsp.config = {}");
s
}
fn active_buffer(state: &EditorState) -> pmacs::buffer::BufferId {
state.core.borrow().active_window().buffer_id
}
fn semantic(state: &EditorState) -> SemanticRenderState {
let buffer_id = active_buffer(state);
let mut s = SemanticRenderState::new(FrontendId::LOCAL);
s.set_viewport(
buffer_id,
ByteRange {
start: 0,
end: 1 << 20,
},
0,
);
s
}
fn font_facts_of(msgs: &[InstanceMessage]) -> Option<(Option<String>, Option<u32>)> {
msgs.iter().find_map(|m| match m {
InstanceMessage::FontFacts {
family,
size_centi_px,
} => Some((family.clone(), *size_centi_px)),
_ => None,
})
}
fn font_facts_count(msgs: &[InstanceMessage]) -> usize {
msgs.iter()
.filter(|m| matches!(m, InstanceMessage::FontFacts { .. }))
.count()
}
/// The daemon-side preference triple `(family, size_centi_px, epoch)`.
fn pref_of(state: &EditorState) -> (Option<String>, Option<u32>, u64) {
let pref = state.font_pref.lock().expect("font pref lock");
(pref.family.clone(), pref.size_centi_px, pref.epoch)
}
// ---------------------------------------------------------------------------
// 2 — authoritative default per attachment; unchanged ticks silent;
// late joiner receives the current preference
// ---------------------------------------------------------------------------
#[test]
fn first_frame_ships_the_authoritative_default_and_then_stays_silent() {
let state = editor();
let mut sem = semantic(&state);
let first = sem.render_frame(&state);
assert_eq!(
font_facts_of(&first),
Some((None, None)),
"a fresh attachment's first frame carries the REAL (None, None) default — \
never inferred from silence"
);
for tick in 0..3 {
let next = sem.render_frame(&state);
assert_eq!(
font_facts_count(&next),
0,
"unchanged tick {tick} must be FontFacts-silent"
);
}
// `state` untouched since the set below — a late-joining second
// session receives the current preference without any mutation
// post-attach.
exec(
&state,
r#"pmacs.gpu.set_font { family = "Iosevka", size = 18 }"#,
);
let mut late = semantic(&state);
let joined = late.render_frame(&state);
assert_eq!(
font_facts_of(&joined),
Some((Some("Iosevka".to_owned()), Some(1800))),
"a late joiner's first frame carries the current preference"
);
}
// ---------------------------------------------------------------------------
// 3 — live re-ship: one FontFacts on the next frame; an identical
// re-set advances the epoch without emitting
// ---------------------------------------------------------------------------
#[test]
fn set_font_reships_once_and_identical_resets_advance_the_epoch_silently() {
let state = editor();
let mut sem = semantic(&state);
let _ = sem.render_frame(&state);
exec(&state, "pmacs.gpu.set_font { size = 18 }");
let next = sem.render_frame(&state);
assert_eq!(
font_facts_count(&next),
1,
"a mid-session set emits exactly one FontFacts on the next frame"
);
assert_eq!(font_facts_of(&next), Some((None, Some(1800))));
let (_, _, epoch_before) = pref_of(&state);
exec(&state, "pmacs.gpu.set_font { size = 18 }");
let (_, _, epoch_after) = pref_of(&state);
assert_eq!(
epoch_after,
epoch_before + 1,
"an identical re-set still advances the epoch (caches advance on \
computation, Q#TH6)"
);
let silent = sem.render_frame(&state);
assert_eq!(
font_facts_count(&silent),
0,
"an identical payload does not re-emit"
);
}
// ---------------------------------------------------------------------------
// 4 — snapshot survival, producer side: the buffer-baseline reset
// re-ships buffer facts but never the bufferless FontFacts
// ---------------------------------------------------------------------------
#[test]
fn buffer_snapshot_reset_never_reships_font_facts() {
let state = editor();
exec(&state, "pmacs.gpu.set_font { size = 20 }");
let buffer_id = active_buffer(&state);
let mut sem = semantic(&state);
let first = sem.render_frame(&state);
assert_eq!(
font_facts_of(&first),
Some((None, Some(2000))),
"precondition: the preference shipped on the first frame"
);
let has_status = |msgs: &[InstanceMessage]| {
msgs.iter()
.any(|m| matches!(m, InstanceMessage::StatusFacts { .. }))
};
assert!(has_status(&first), "precondition: buffer facts shipped too");
// The daemon wrote a BufferSnapshot for this buffer (an A → B → A
// revisit): every BUFFER-scoped baseline resets…
sem.on_buffer_snapshot_sent(buffer_id);
let after = sem.render_frame(&state);
assert!(
has_status(&after),
"the reset re-ships the buffer's facts on the next frame"
);
// …but the global font baseline survives: the new buffer shapes
// under the same preference without a redundant global fact.
assert_eq!(
font_facts_count(&after),
0,
"FontFacts is bufferless — the snapshot reset must not touch it"
);
}
// ---------------------------------------------------------------------------
// 5 — the daemon version gate (v16 peer never receives FontFacts)
// ---------------------------------------------------------------------------
#[cfg(feature = "crdt")]
#[test]
fn v16_peer_never_receives_font_facts_and_v17_does() {
use common::daemon::{TestDaemon, build_default_caps};
use pmacs::cell::CellSize;
use pmacs::protocol::{AttachRequest, FrontendCapabilities, FrontendEvent, Hello};
use pmacs::transport::{read_message, write_message};
use std::time::{Duration, Instant};
fn semantic_caps() -> FrontendCapabilities {
FrontendCapabilities {
multi_frontend: true,
crdt_replica: true,
semantic_render: true,
..build_default_caps()
}
}
/// Attach a semantic session at `version`, declare a viewport,
/// and report `(saw_font_facts, saw_style_spans)` within the
/// deadline.
fn probe(daemon: &TestDaemon, version: u32) -> (bool, bool) {
let mut stream = daemon.connect();
stream
.set_read_timeout(Some(Duration::from_millis(250)))
.unwrap();
let hello: Hello = read_message(&mut stream).expect("read Hello");
let fid = hello.assigned_frontend_id;
write_message(
&mut stream,
&AttachRequest {
protocol_version: version,
frontend_capabilities: semantic_caps(),
initial_size: CellSize::new(24, 80),
},
)
.expect("write AttachRequest");
let mut buf = None;
let learn_by = Instant::now() + Duration::from_secs(2);
while Instant::now() < learn_by && buf.is_none() {
if let Ok(InstanceMessage::BufferSnapshot { buffer_id, .. }) =
read_message::<InstanceMessage>(&mut stream)
{
buf = Some(buffer_id);
}
}
let buffer_id = buf.expect("received a BufferSnapshot");
write_message(
&mut stream,
&FrontendEvent::Viewport {
frontend_id: fid,
buffer_id,
visible: ByteRange {
start: 0,
end: 4096,
},
generation: 0,
},
)
.expect("write Viewport");
let deadline = Instant::now() + Duration::from_secs(3);
let (mut saw_facts, mut saw_spans) = (false, false);
while Instant::now() < deadline && !(saw_facts && saw_spans) {
match read_message::<InstanceMessage>(&mut stream) {
Ok(InstanceMessage::FontFacts { .. }) => saw_facts = true,
Ok(InstanceMessage::StyleSpans { .. }) => saw_spans = true,
Ok(_) | Err(_) => {}
}
}
(saw_facts, saw_spans)
}
let daemon = TestDaemon::spawn();
let (v17_facts, v17_spans) = probe(&daemon, 17);
assert!(v17_spans, "a v17 semantic session receives StyleSpans");
assert!(
v17_facts,
"a v17 semantic session receives the authoritative FontFacts"
);
let (v16_facts, v16_spans) = probe(&daemon, 16);
assert!(v16_spans, "a v16 peer still receives StyleSpans");
assert!(
!v16_facts,
"the daemon skip arm must keep FontFacts off a v16 wire"
);
}
// ---------------------------------------------------------------------------
// 6 — the Lua contract: strict plain data, all-or-nothing
// ---------------------------------------------------------------------------
#[test]
fn set_font_rejects_bad_sizes_naming_the_field_and_nothing_lands() {
let state = editor();
let mut sem = semantic(&state);
let _ = sem.render_frame(&state);
let before = pref_of(&state);
for bad in [
"pmacs.gpu.set_font { size = 5.999 }", // must error, not round into range
"pmacs.gpu.set_font { size = 72.01 }",
"pmacs.gpu.set_font { size = 0 }",
"pmacs.gpu.set_font { size = -16 }",
"pmacs.gpu.set_font { size = 0/0 }", // NaN
"pmacs.gpu.set_font { size = 1/0 }", // +inf
"pmacs.gpu.set_font { size = '16' }", // non-number
] {
let err = exec_err(&state, bad);
assert!(
err.to_string().contains("`size`"),
"{bad}: the error names the offending field, got: {err}"
);
}
assert_eq!(pref_of(&state), before, "no failed set may land");
let silent = sem.render_frame(&state);
assert_eq!(
font_facts_count(&silent),
0,
"no failed set may emit on the wire"
);
}
#[test]
fn set_font_rejects_bad_families_and_unknown_keys_by_name() {
let state = editor();
let err = exec_err(&state, "pmacs.gpu.set_font { family = '' }");
assert!(
err.to_string().contains("`family`"),
"empty family names the field: {err}"
);
let err = exec_err(&state, "pmacs.gpu.set_font { family = 12 }");
assert!(
err.to_string().contains("`family`"),
"non-string family names the field: {err}"
);
let err = exec_err(&state, "pmacs.gpu.set_font { size = 18, sise = 20 }");
assert!(
err.to_string().contains("`sise`"),
"an unknown key is rejected by NAME: {err}"
);
assert_eq!(
pref_of(&state),
(None, None, 0),
"every rejected shape leaves the preference untouched"
);
}
#[test]
fn set_font_never_consults_metatables() {
let state = editor();
// A hostile `__index` that answers every lookup: raw reads must
// never see its values, and raw iteration must never invoke it.
exec(
&state,
r#"
_G.__mt_hits = 0
local spec = setmetatable({ size = 18 }, {
__index = function(_, _)
_G.__mt_hits = _G.__mt_hits + 1
return "Injected Family"
end,
__pairs = function()
_G.__mt_hits = _G.__mt_hits + 1
return function() return nil end
end,
})
pmacs.gpu.set_font(spec)
"#,
);
let hits: i64 = eval(&state, "return _G.__mt_hits");
assert_eq!(hits, 0, "metatables are never invoked");
assert_eq!(
pref_of(&state),
(None, Some(1800), 1),
"the metatable's `family` answer must NOT be injected — only the \
raw `size` landed"
);
}
#[test]
fn set_font_quantizes_both_sides_of_a_hundredth_and_empty_resets() {
let state = editor();
exec(&state, "pmacs.gpu.set_font { size = 15.994 }");
assert_eq!(pref_of(&state).1, Some(1599), "15.994 rounds DOWN");
exec(&state, "pmacs.gpu.set_font { size = 15.996 }");
assert_eq!(pref_of(&state).1, Some(1600), "15.996 rounds UP");
// Boundary values are in range and quantize exactly.
exec(&state, "pmacs.gpu.set_font { size = 6 }");
assert_eq!(pref_of(&state).1, Some(600));
exec(&state, "pmacs.gpu.set_font { size = 72 }");
assert_eq!(pref_of(&state).1, Some(7200));
exec(
&state,
r#"pmacs.gpu.set_font { family = "Iosevka", size = 18 }"#,
);
exec(&state, "pmacs.gpu.set_font {}");
let (family, size, _) = pref_of(&state);
assert_eq!(
(family, size),
(None, None),
"set_font {{}} resets BOTH axes to the frontend default"
);
}
#[test]
fn font_getter_returns_a_fresh_quantized_plain_table() {
let state = editor();
exec(&state, "pmacs.gpu.set_font { size = 15.996 }");
let (size, fresh, no_family): (f64, bool, bool) = eval(
&state,
r"
local a = pmacs.gpu.font()
local b = pmacs.gpu.font()
a.size = 999 -- scribbling on the returned table…
local c = pmacs.gpu.font()
return c.size, rawequal(a, b) == false, c.family == nil
",
);
assert!(
(size - 16.0).abs() < f64::EPSILON,
"the getter reports the QUANTIZED value (1600 → 16.0), got {size}"
);
assert!(
fresh,
"each call returns a fresh table, never a stored handle"
);
assert!(
no_family,
"an unset axis is absent, and scribbles don't stick"
);
}
// ---------------------------------------------------------------------------
// 7 — init.lua reachability: the module installs BEFORE user config
// ---------------------------------------------------------------------------
#[test]
fn init_lua_set_font_lands_in_the_preference_the_first_frame_reads() {
use pmacs::config::load_user_config_at;
use pmacs::lua::LuaHost;
let dir = tempfile::TempDir::new().expect("tempdir");
std::fs::write(
dir.path().join("init.lua"),
r#"pmacs.gpu.set_font { family = "Iosevka", size = 18 }"#,
)
.expect("write init.lua");
let mut host = LuaHost::new().expect("LuaHost::new");
// Mirror `EditorState::new`'s ordering: the pmacs.gpu module
// installs BEFORE user config runs (src/editor.rs), so an
// init.lua set_font lands in the same state the first
// attachment's producer reads.
let handle = pmacs::lua_bindings::make_font_pref(host.lua()).expect("install pmacs.gpu");
load_user_config_at(&mut host, dir.path());
host.set_init_complete();
assert!(
host.errors().is_empty(),
"init.lua produced errors: {:?}",
host.errors()
);
let pref = handle.lock().expect("font pref lock");
assert_eq!(pref.family.as_deref(), Some("Iosevka"));
assert_eq!(pref.size_centi_px, Some(1800));
assert_eq!(pref.epoch, 1, "exactly the init.lua set landed");
}
/// The producer half of item 7: a preference already in place before
/// the first attachment (the init.lua timing) ships on that
/// attachment's FIRST frame.
#[test]
fn preference_set_before_attach_ships_on_the_first_frame() {
let state = editor();
exec(
&state,
r#"pmacs.gpu.set_font { family = "Iosevka", size = 18 }"#,
);
let mut sem = semantic(&state);
let first = sem.render_frame(&state);
assert_eq!(
font_facts_of(&first),
Some((Some("Iosevka".to_owned()), Some(1800))),
"the first frame ships the pre-attach preference"
);
}