// gpu_font_acceptance.rs --- gpu-set-font Arc 4 stage 2 acceptance // (docs/gpu-set-font-framing.md, acceptance items 2–7; item 1's pins // live in src/protocol.rs, item 8's TUI drop arm is a unit in // src/frontend.rs, items 9–14 and 16–19 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; // Ungated: `common::iso` (isolated bootstrap roots) is needed in every // build, not only the CRDT one. 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(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_with_roots(&crate::iso::roots()); 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, Option)> { 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, Option, 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::(&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::(&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" ); } // Isolated bootstrap storage roots (see the module docs): an // integration test is compiled without `cfg(test)`, so a raw // `EditorState::new()` would read the developer's real `init.lua` and // write into their real data root. Re-exported rather than re-declared // with `#[path]` — this file already pulls in `common`, and loading one // source file as two modules is `clippy::duplicate_mod`. use common::iso;