diff --git a/pmacs-gpu/src/main.rs b/pmacs-gpu/src/main.rs index 63d4ed7..8f65cc1 100644 --- a/pmacs-gpu/src/main.rs +++ b/pmacs-gpu/src/main.rs @@ -5198,6 +5198,15 @@ impl State { /// exactly as it was, because `PanelFrame::validate` is pure and runs /// before any state is touched. fn apply_panel_payload(&mut self, payload: PanelFramePayload) -> bool { + if self.panel.exhausted { + // A latched session has disowned its geometry for good, so a + // payload answering it describes nothing this frontend can + // present. Retaining the frame anyway would leave `presented()` + // as the only thing standing between a disowned declaration and + // a painted band, and would spend a reshape on every arriving + // frame for the rest of the session. + return false; + } match payload { PanelFramePayload::Absent => { // Authoritative removal, and always safe. Note this does @@ -9617,9 +9626,21 @@ fn edge_scroll_direction( fm: FontMetrics, band: PanelBandInset, ) -> Option { + let bottom = document_text_bottom(surface_height, fm, band); if y < TEXT_TOP + EDGE_SCROLL_BAND { Some(-1) - } else if y > document_text_bottom(surface_height, fm, band) - EDGE_SCROLL_BAND { + } else if band.px() > 0.0 && y >= bottom { + // Moving the boundary is necessary but NOT sufficient: this arm has + // no upper bound, so a pixel far below the text area still reads as + // "further down the document". With a band installed that pixel is + // on ANOTHER SURFACE, and letting it arm the document's auto-scroll + // is the named symptom of leaving this consumer on the old bottom — + // the document scrolls while the pointer is inside the panel. + // + // Gated on an installed band so a bandless surface keeps its exact + // previous behavior, including over the status band. + None + } else if y > bottom - EDGE_SCROLL_BAND { Some(1) } else { None @@ -16265,6 +16286,497 @@ mod tests { // --- Vterm Stage 3: terminal mode ----------------------------------- + // =================================================================== + // Bottom panel Stage 2B-3 — the GPU band + // =================================================================== + + fn panel_frame_of(rows: u32, cols: u32, geometry_epoch: u64, panel_epoch: u64) -> PanelFrame { + let cells = (0..(rows as usize * cols as usize)) + .map(|_| terminal_cell(pmacs_protocol::Glyph::Char('x'), CellStyle::default())) + .collect(); + PanelFrame { + buffer_id: BufferId::from_raw(77), + panel_epoch, + geometry_epoch, + size: CellSize::new(rows, cols), + cells, + cursor: None, + focused: true, + } + } + + /// Bring a `State` to the point a real v21 session reaches: panel wire + /// on, one geometry declaration made, one `Present` frame installed. + fn present_panel(state: &mut State, rows: u32) -> PanelFrame { + state.set_panel_wire(PANEL_MIN_VERSION); + let (epoch, total) = state + .next_geometry_declaration(GeometryTrigger::Surface) + .expect("a panel-wire session declares its first geometry"); + let frame = panel_frame_of(rows, total.cols.max(1), epoch, 1); + assert!( + state.apply_panel_payload(PanelFramePayload::Present(frame.clone())), + "installing a first frame changes the band" + ); + frame + } + + /// A2B-4 (contrast assertion) — installing a panel moves every + /// document-owned boundary by exactly the band's pixel height while + /// every status-owned boundary stays pixel-identical. + /// + /// Both halves in one scenario on purpose: a uniformly wrong + /// implementation that subtracted the band from `status_band_top` too + /// would pass the "everything moved" half by itself. + #[test] + fn installing_a_panel_moves_the_document_bottom_and_not_the_status_band() { + let Some(mut state) = headless_or_skip(800, 600, "alpha\nbeta\ngamma\n") else { + return; + }; + let fm = state.fm; + let status_before = status_band_top(state.config.height, fm); + let document_before = document_text_bottom(state.config.height, fm, state.band_inset()); + assert_eq!( + state.band_inset(), + PanelBandInset::ABSENT, + "no panel yet, so the two boundaries coincide" + ); + assert!((status_before - document_before).abs() < f32::EPSILON); + + let rows = 4; + present_panel(&mut state, rows); + + let band = state.band_inset(); + assert_eq!( + band, + PanelBandInset::installed(rows, fm), + "the inset is the panel's rows plus its divider" + ); + assert!(band.px() > 0.0); + assert_eq!( + status_band_top(state.config.height, fm), + status_before, + "the status band must stay pixel-identical at the physical window bottom" + ); + assert!( + (document_text_bottom(state.config.height, fm, band) - (document_before - band.px())) + .abs() + < f32::EPSILON, + "the document bottom must move by exactly the band height" + ); + // Every document-owned consumer follows the moved boundary, and the + // three easiest to misclassify are named because each has its own + // visible symptom. + assert!( + estimated_visible_lines(state.config.height, fm, band) + < estimated_visible_lines(state.config.height, fm, PanelBandInset::ABSENT), + "the visible-line estimate must shrink with the document area" + ); + assert!( + minimap_height(state.config.height, fm, band) + < minimap_height(state.config.height, fm, PanelBandInset::ABSENT), + "the minimap is document-owned" + ); + // Edge scrolling (the row a plausible implementation leaves on the + // old bottom, which then auto-scrolls from INSIDE the panel). The + // probe pixel sits near the BOTTOM of the band, inside the unmoved + // boundary's own edge strip, so the two answers genuinely differ. + let deep_in_band = status_before - 2.0; + assert!( + deep_in_band > document_text_bottom(state.config.height, fm, band), + "fixture must place the probe inside the band" + ); + assert_eq!( + edge_scroll_direction(deep_in_band, state.config.height, fm, band), + None, + "a pixel inside the band must not arm document edge scrolling" + ); + assert_eq!( + edge_scroll_direction( + deep_in_band, + state.config.height, + fm, + PanelBandInset::ABSENT + ), + Some(1), + "and it WOULD have, on the unmoved boundary — so this pins the move, \ + not merely the absence of a trigger" + ); + // And the feature is not simply switched off: the moved boundary has + // its own edge strip, which still arms. + assert_eq!( + edge_scroll_direction( + document_text_bottom(state.config.height, fm, band) - 2.0, + state.config.height, + fm, + band + ), + Some(1), + "the document's own bottom edge strip still auto-scrolls" + ); + } + + /// The geometry declaration reserves the divider while the panel is + /// absent, and the document loses no pixels until a `Present` is + /// painted. That asymmetry is what breaks the first-open cycle. + #[test] + fn geometry_capacity_reserves_the_divider_while_the_document_does_not() { + let fm = FontMetrics::default(); + let height = 600; + assert!(fm.divider_height() > 0.0); + assert!( + (status_band_top(height, fm) + - geometry_capacity_bottom(height, fm) + - fm.divider_height()) + .abs() + < f32::EPSILON, + "capacity always reserves the divider" + ); + assert_eq!( + document_text_bottom(height, fm, PanelBandInset::ABSENT), + status_band_top(height, fm), + "while an absent panel costs the document nothing" + ); + } + + /// All three boundaries clamp at zero on a surface shorter than its own + /// chrome, preserving the pre-split `.max(0.0)` behavior. + #[test] + fn every_boundary_clamps_at_zero_on_a_surface_shorter_than_its_chrome() { + let fm = FontMetrics::default(); + for height in [0, 1, 4, 10] { + assert!(status_band_top(height, fm) >= 0.0); + assert!(geometry_capacity_bottom(height, fm) >= 0.0); + assert!(document_text_bottom(height, fm, PanelBandInset::installed(9, fm)) >= 0.0); + } + } + + /// A2B-3 — panel columns come from the stable normal-face probe, so two + /// frontends with identical metrics and different documents derive + /// identical totals. + #[test] + fn panel_columns_do_not_depend_on_the_document() { + let Some(mut one) = headless_or_skip(800, 600, "iiiiiiiiiiiiiiii") else { + return; + }; + let Some(mut two) = headless_or_skip(800, 600, "WWWWWWWWWWWWWWWW") else { + return; + }; + assert_eq!( + one.declared_cell_total(), + two.declared_cell_total(), + "the declaration must not sample the document's first glyph" + ); + } + + /// The pixel→cell conversion, including the daemon's virtual status row + /// and floor rounding (parent 41, GPU half). + #[test] + fn panel_cell_capacity_floors_and_carries_the_virtual_status_row() { + // 100px of usable height at a 22px line height is 4 whole rows, plus + // the virtual status row the daemon subtracts back off. + let size = crate::terminal::panel_cell_capacity(100.0, 100.0, 10.0, 22.0) + .expect("a 100x100 rectangle admits cells"); + assert_eq!(size.rows, 5, "4 document rows + 1 virtual status row"); + assert_eq!(size.cols, 10); + // Fractional widths floor rather than round. + let fractional = crate::terminal::panel_cell_capacity(99.9, 43.9, 10.0, 22.0) + .expect("fractional inputs still admit cells"); + assert_eq!((fractional.rows, fractional.cols), (2, 9)); + // A panel may legitimately be wider than a PTY: no 512 cap. + let wide = crate::terminal::panel_cell_capacity(6000.0, 44.0, 1.0, 22.0) + .expect("a wide surface admits a wide panel"); + assert_eq!( + wide.cols, 6000, + "the panel does not inherit the terminal's 512-column PTY cap" + ); + assert!( + crate::terminal::cell_viewport(6000.0, 44.0, 1.0, 22.0) + .expect("terminal viewport") + .cols + <= u32::from(pmacs_protocol::MAX_TERMINAL_COLS), + "while the terminal projection still clamps" + ); + } + + /// Zero, non-finite, and non-positive metric inputs fail closed to zero + /// usable geometry rather than an absurd row count (parent 41). + #[test] + fn degenerate_metrics_declare_zero_usable_geometry() { + for (w, h, a, l) in [ + (0.0, 100.0, 10.0, 22.0), + (100.0, 0.0, 10.0, 22.0), + (100.0, 100.0, 0.0, 22.0), + (100.0, 100.0, 10.0, 0.0), + (f32::NAN, 100.0, 10.0, 22.0), + (100.0, f32::INFINITY, 10.0, 22.0), + (100.0, 100.0, -10.0, 22.0), + ] { + assert_eq!( + crate::terminal::panel_cell_capacity(w, h, a, l), + None, + "degenerate input ({w}, {h}, {a}, {l}) must fail closed" + ); + } + } + + /// A2B-2 — a font or scale change that leaves `CellSize` IDENTICAL still + /// produces a new `geometry_epoch`, and the older retained frame neither + /// paints nor hit-tests until a matching `Present` arrives. + #[test] + fn identical_cell_totals_still_advance_the_epoch_on_a_metrics_change() { + let Some(mut state) = headless_or_skip(800, 600, "alpha") else { + return; + }; + let frame = present_panel(&mut state, 3); + let first_epoch = frame.geometry_epoch; + assert!(state.band_inset().px() > 0.0); + + // The `Surface` trigger dedups an unchanged total. + assert_eq!( + state.next_geometry_declaration(GeometryTrigger::Surface), + None, + "an identical cell total is not re-declared on a resize" + ); + assert_eq!(state.panel.geometry_epoch, first_epoch); + + // The `Metrics` trigger must not. + let (second_epoch, second_total) = state + .next_geometry_declaration(GeometryTrigger::Metrics) + .expect("a metrics change always re-declares"); + assert_eq!( + second_total, + state.panel.declared.expect("declared"), + "the total is genuinely unchanged — which is the whole point" + ); + assert!(second_epoch > first_epoch); + assert!( + state.panel.presented().is_none(), + "the retained frame answers a superseded declaration, so it must \ + neither paint nor hit-test" + ); + assert_eq!(state.band_inset(), PanelBandInset::ABSENT); + assert!(state.panel_hit_test(TEXT_LEFT + 1.0, 400.0).is_none()); + + // Only a matching `Present` brings it back. + let matching = panel_frame_of(3, second_total.cols.max(1), second_epoch, 2); + assert!(state.apply_panel_payload(PanelFramePayload::Present(matching))); + assert!(state.panel.presented().is_some()); + } + + /// A2B-1's frontend half — exhaustion latches, and the latch survives a + /// retained `Present` whose epoch still matches. + #[test] + fn an_exhausted_frontend_latches_and_no_retained_frame_revives_the_band() { + let Some(mut state) = headless_or_skip(800, 600, "alpha") else { + return; + }; + state.set_panel_wire(PANEL_MIN_VERSION); + state.panel.geometry_epoch = u64::MAX; + state.panel.declared = Some(CellSize::new(1, 1)); + let stale = panel_frame_of(3, 8, u64::MAX, 1); + assert!(state.apply_panel_payload(PanelFramePayload::Present(stale.clone()))); + assert!( + state.panel.presented().is_some(), + "before exhaustion the matching frame is presentable" + ); + + assert_eq!( + state.next_geometry_declaration(GeometryTrigger::Metrics), + None, + "checked allocation refuses to wrap" + ); + assert!(state.panel.exhausted, "and latches for the session"); + assert!(state.panel.frame.is_none()); + assert_eq!(state.band_inset(), PanelBandInset::ABSENT); + + // An old matching `Present` must not resurrect the band, and no + // further declaration is sent however the surface changes. + assert!(!state.apply_panel_payload(PanelFramePayload::Present(stale))); + assert!(state.panel.presented().is_none()); + assert_eq!( + state.next_geometry_declaration(GeometryTrigger::Surface), + None + ); + } + + /// `Absent` is authoritative; silence retains; an invalid frame is + /// rejected whole; a duplicate does no work. + #[test] + fn absent_clears_while_silence_retains_and_a_bad_frame_keeps_the_old_one() { + let Some(mut state) = headless_or_skip(800, 600, "alpha") else { + return; + }; + let good = present_panel(&mut state, 3); + assert!( + !state.apply_panel_payload(PanelFramePayload::Present(good.clone())), + "a duplicate valid frame does no work" + ); + assert!(state.panel.presented().is_some(), "silence retains"); + + // A frame whose cell count disagrees with its declared area. + let mut bad = good.clone(); + bad.cells.pop(); + assert!(!state.apply_panel_payload(PanelFramePayload::Present(bad))); + assert_eq!( + state.panel.frame.as_ref(), + Some(&good), + "rejection is atomic: the previous valid frame is retained" + ); + + let declared_before = state.panel.declared; + assert!(state.apply_panel_payload(PanelFramePayload::Absent)); + assert!(state.panel.presented().is_none()); + assert_eq!(state.band_inset(), PanelBandInset::ABSENT); + assert_eq!( + state.panel.declared, declared_before, + "an Absent panel does not invalidate the frame-capacity declaration" + ); + assert!( + !state.apply_panel_payload(PanelFramePayload::Absent), + "a duplicate Absent does no work either" + ); + } + + /// Criterion 48 / 47 — the band hit-tests to cells, the divider strip is + /// the painted rect, and a drag names ROWS. + #[test] + fn the_band_hit_tests_cells_and_the_divider_strip_drags_rows() { + let Some(mut state) = headless_or_skip(800, 600, "alpha") else { + return; + }; + let frame = present_panel(&mut state, 4); + let (ox, oy, w, h) = state + .panel_content_rect() + .expect("a presented band has a rect"); + + // Cells inside; nothing outside. + assert_eq!( + state.panel_hit_test(ox + 0.5, oy + 0.5), + Some(pmacs_protocol::CellCoord::new(0, 0)) + ); + assert!(state.panel_hit_test(ox - 1.0, oy + 0.5).is_none()); + assert!( + state.panel_hit_test(ox + 0.5, oy - 1.0).is_none(), + "a pixel above the band belongs to the document" + ); + assert!(state.panel_hit_test(ox + 0.5, oy + h + 1.0).is_none()); + assert!(state.panel_hit_test(ox + w + 1.0, oy + 0.5).is_none()); + + // The divider strip sits directly above the cells, and its painted + // rect IS its hit rect. + let (dx, dy, dw, dh) = state.panel_divider_rect().expect("divider rect"); + assert!((dy + dh - oy).abs() < f32::EPSILON, "strip abuts the cells"); + assert!((dh - state.fm.divider_height()).abs() < f32::EPSILON); + assert!(state.panel_divider_contains(dx + dw / 2.0, dy + dh / 2.0)); + assert!(!state.panel_divider_contains(dx + dw / 2.0, dy - 1.0)); + assert!(!state.panel_divider_contains(dx + dw / 2.0, dy + dh)); + + // A drag UP grows the panel; the request names rows and repeats are + // suppressed. + assert!(state.begin_panel_drag(dx + 1.0, dy + 1.0)); + let line = state.fm.code_line_height(); + let request = state + .panel_drag_request(dy + 1.0 - 2.0 * line) + .expect("two lines up is a two-row request"); + assert_eq!(request.rows, frame.size.rows + 2); + assert_eq!(request.geometry_epoch, frame.geometry_epoch); + assert_eq!(request.panel_epoch, frame.panel_epoch); + state.note_panel_drag_sent(request.rows); + assert_eq!( + state.panel_drag_request(dy + 1.0 - 2.0 * line), + None, + "re-crossing the same row boundary does not re-send" + ); + assert!(state.end_panel_drag()); + assert_eq!(state.panel_drag_request(dy + 1.0 - 3.0 * line), None); + } + + /// A drag whose presentation has been replaced under it is dropped, not + /// applied to the successor. + #[test] + fn a_drag_that_outlives_its_panel_is_dropped() { + let Some(mut state) = headless_or_skip(800, 600, "alpha") else { + return; + }; + let frame = present_panel(&mut state, 4); + let (dx, dy, _, dh) = state.panel_divider_rect().expect("divider rect"); + assert!(state.begin_panel_drag(dx + 1.0, dy + dh / 2.0)); + + // A new presentation of the SAME buffer under a new panel epoch. + let replaced = PanelFrame { + panel_epoch: frame.panel_epoch + 1, + ..frame.clone() + }; + assert!(state.apply_panel_payload(PanelFramePayload::Present(replaced))); + assert_eq!( + state.panel_drag_request(dy - 3.0 * state.fm.code_line_height()), + None, + "the gesture addressed a presentation that is gone" + ); + assert!( + state.panel.drag.is_none(), + "and the stale drag is discarded rather than left armed" + ); + } + + /// The divider hover bit drives the `RowResize` icon and reports only on + /// change, so the cursor is not reset on every pixel of motion. + #[test] + fn divider_hover_reports_only_on_change() { + let Some(mut state) = headless_or_skip(800, 600, "alpha") else { + return; + }; + present_panel(&mut state, 3); + assert!(state.set_panel_divider_hover(true)); + assert!(!state.set_panel_divider_hover(true)); + assert!(state.set_panel_divider_hover(false)); + assert!(!state.set_panel_divider_hover(false)); + } + + /// A session that did not negotiate the panel wire declares nothing and + /// can never present a band. + #[test] + fn a_pre_panel_session_declares_no_geometry() { + let Some(mut state) = headless_or_skip(800, 600, "alpha") else { + return; + }; + state.set_panel_wire(PANEL_MIN_VERSION - 1); + assert_eq!( + state.next_geometry_declaration(GeometryTrigger::Surface), + None + ); + assert_eq!(state.panel.geometry_epoch, 0, "0 means never declared"); + assert_eq!(state.band_inset(), PanelBandInset::ABSENT); + } + + /// Criterion 46 — the band and divider really do take pixels off the + /// painted document, and the status band's own pixels are unchanged. + #[test] + fn the_painted_band_takes_pixels_from_the_document_and_not_the_status_band() { + let Some(mut state) = headless_or_skip(400, 300, "alpha\nbeta\ngamma\ndelta\n") else { + return; + }; + let before = state.render_offscreen(); + let fm = state.fm; + let status_top = status_band_top(state.config.height, fm).floor() as u32; + + present_panel(&mut state, 3); + state.sync_buffer_dimensions(); + let after = state.render_offscreen(); + + let bounds = frame_diff_bounds(&before, &after, state.config.width); + let (_, min_y, _, max_y) = bounds.expect("installing a band changes pixels"); + let band_top = document_text_bottom(state.config.height, fm, state.band_inset()).floor(); + assert!( + min_y >= band_top as u32, + "no pixel above the band's own top may change: {min_y} < {band_top}" + ); + assert!( + max_y < status_top, + "and the status band stays pixel-identical: {max_y} >= {status_top}" + ); + } + fn terminal_cell(glyph: pmacs_protocol::Glyph, style: CellStyle) -> pmacs_protocol::Cell { pmacs_protocol::Cell { glyph, diff --git a/tests/bottom_panel_stage2b_gpu_acceptance.rs b/tests/bottom_panel_stage2b_gpu_acceptance.rs new file mode 100644 index 0000000..47b9f52 --- /dev/null +++ b/tests/bottom_panel_stage2b_gpu_acceptance.rs @@ -0,0 +1,396 @@ +// bottom_panel_stage2b_gpu_acceptance.rs --- bottom-panel Stage 2B-3 +// (docs/bottom-panel-stage2-framing.md §7.2.3; A2B-5 and the production +// re-assertion of 42/43/44/45/51/52 through the real capability flip). + +//! Compatible v21 activation and the negotiated `panel_capable` flip. +//! +//! The band's own pixel geometry, the epoch latch, the probe-derived +//! column count, and the three-boundary contrast assertion live in +//! `pmacs-gpu`'s own tests, because they need a real `State` and a real +//! surface. What lives here is everything that needs a **real daemon**: +//! the handshake, the negotiation, and what the daemon does with a +//! session's negotiated version. +//! +//! The discipline this suite is built around: **every acceptance runs +//! both directions in the same fixture.** A test that only proved "a v21 +//! frontend gets a panel" would pass with the compatibility half broken, +//! and a test that only proved "a v20 frontend still attaches" would pass +//! with the activation missing entirely. Neither half is meaningful +//! alone, so neither appears alone. + +mod common; + +use std::io::Read; +use std::os::unix::net::UnixStream; +use std::time::{Duration, Instant}; + +use pmacs_protocol::cell::CellSize; +use pmacs_protocol::message::{ + AttachRequest, FrontendCapabilities, FrontendEvent, GoodbyeReason, Hello, InstanceMessage, Key, + KeyEvent, Modifiers, SessionBootstrapRequest, +}; +use pmacs_protocol::panel::{PANEL_MIN_VERSION, PanelFramePayload}; +use pmacs_protocol::transport::{read_message, write_message}; +use pmacs_protocol::{ + ADVERTISED_PROTOCOL_VERSION, PROTOCOL_VERSION, SUPPORTED_PROTOCOL_VERSIONS, + is_supported_protocol_version, negotiated_session_version, requested_protocol_version, +}; + +use common::daemon::{TestDaemon, build_default_caps}; + +/// Opens a bottom panel in whichever frontend pressed the key. +/// +/// A real adopter path rather than a Lua eval hook: `pmacs.window.display` +/// with an explicit `side` is exactly what a Stage 1 adopter does, and the +/// acting frontend is the one that sent the key — so whether this produces +/// a side window is precisely the `panel_capable` question. +const PANEL_ON_KEY: &str = r#" +pmacs.command.define { + name = "bp-probe.panel", + description = "Open the Stage 2B-3 acceptance panel.", + fn = function() + pmacs.window.display(pmacs.buffer.create("*bp-probe*"), { side = "bottom", height = 4 }) + end, +} +pmacs.keymap.bind { scope = "global", sequence = "C-M-p", command = "bp-probe.panel" } +"#; + +fn semantic_caps() -> FrontendCapabilities { + FrontendCapabilities { + multi_frontend: true, + crdt_replica: true, + semantic_render: true, + ..build_default_caps() + } +} + +/// One attached session, with the version it actually offered. +struct Session { + stream: UnixStream, + offered: u32, +} + +/// Attach a semantic frontend that offers exactly `offer`. +/// +/// The `Hello` baseline is asserted here rather than in one dedicated +/// test, because every fixture in this file depends on it: if the daemon +/// ever advertised something above the baseline, a shipped frontend would +/// reject before reaching any of these code paths, and the tests below +/// would still pass while the product was broken. +fn attach_semantic(daemon: &TestDaemon, offer: u32) -> Session { + let mut stream = daemon.connect(); + stream + .set_read_timeout(Some(Duration::from_secs(10))) + .expect("set read timeout"); + let hello: Hello = read_message(&mut stream).expect("read daemon Hello"); + assert_eq!( + hello.protocol_version, ADVERTISED_PROTOCOL_VERSION, + "the server-first Hello must stay at the compatibility baseline" + ); + write_message( + &mut stream, + &AttachRequest { + protocol_version: offer, + frontend_capabilities: semantic_caps(), + initial_size: CellSize::new(24, 80), + }, + ) + .expect("write AttachRequest"); + // A v20-or-later semantic session sends the bootstrap envelope; the + // daemon reads it unconditionally for those, so skipping it would + // desynchronize the stream rather than merely omit a target. + if offer >= 20 { + write_message( + &mut stream, + &SessionBootstrapRequest { + initial_target: None, + }, + ) + .expect("write bootstrap"); + } + Session { + stream, + offered: offer, + } +} + +/// Read messages until `want` returns `Some`, or the deadline passes. +fn drain_until( + stream: &mut UnixStream, + label: &str, + mut want: impl FnMut(&InstanceMessage) -> Option, +) -> Option { + let deadline = Instant::now() + Duration::from_secs(10); + while Instant::now() < deadline { + match read_message::(stream) { + Ok(message) => { + if let Some(found) = want(&message) { + return Some(found); + } + } + Err(error) => { + eprintln!("{label}: read stopped: {error}"); + return None; + } + } + } + None +} + +/// Press `C-M-p`, then report whether a `Present` panel frame arrived. +fn press_and_await_panel(session: &mut Session) -> bool { + // The declaration first: the daemon needs columns before it can paint + // a first panel frame, and it is valid without a side window for + // exactly that reason. + if session.offered >= PANEL_MIN_VERSION { + write_message( + &mut session.stream, + &FrontendEvent::FrontendCellGeometry { + frontend_id: pmacs_protocol::FrontendId(0), + geometry_epoch: 1, + total: CellSize::new(40, 120), + }, + ) + .expect("write geometry declaration"); + } + write_message( + &mut session.stream, + &FrontendEvent::Key(KeyEvent { + frontend_id: pmacs_protocol::FrontendId(0), + key: Key::Char('p'), + mods: Modifiers::CTRL | Modifiers::ALT, + timestamp_ns: 0, + }), + ) + .expect("write panel-open key"); + drain_until(&mut session.stream, "panel", |message| match message { + InstanceMessage::PanelFrame(PanelFramePayload::Present(frame)) => Some(frame.size), + _ => None, + }) + .is_some() +} + +// --------------------------------------------------------------------------- +// A2B-5 — the activation mechanism, both directions in one fixture +// --------------------------------------------------------------------------- + +/// The whole mechanism, on one live daemon: the v21 frontend gets a band, +/// the v20 frontend still attaches and reaches its initial grid, and the +/// daemon's advertised version never moves. +/// +/// One daemon rather than two, deliberately. Two daemons could each pass +/// their own half while the *same* build was incapable of serving both, +/// which is the only property that matters here. +#[cfg(feature = "crdt")] +#[test] +fn one_daemon_serves_a_v21_panel_session_and_a_shipped_v20_client() { + let daemon = TestDaemon::spawn_with_config(PANEL_ON_KEY); + + // Half 1 — the shipped v20 client. This runs FIRST on purpose: it is + // the half a broken activation destroys, and running it first means a + // regression fails here rather than after the interesting half passed. + let mut legacy = daemon.connect(); + legacy + .set_read_timeout(Some(Duration::from_secs(10))) + .expect("set legacy timeout"); + let hello: Hello = read_message(&mut legacy).expect("read Hello"); + let shipped_v20_range = 6..=20; + assert!( + shipped_v20_range.contains(&hello.protocol_version), + "a shipped v20 client rejects the server-first Hello before it can \ + send AttachRequest, so this is the rejection point: {}", + hello.protocol_version + ); + write_message( + &mut legacy, + &AttachRequest { + protocol_version: hello.protocol_version, + frontend_capabilities: build_default_caps(), + initial_size: CellSize::new(24, 80), + }, + ) + .expect("write v20 AttachRequest"); + assert!( + drain_until(&mut legacy, "legacy", |message| matches!( + message, + InstanceMessage::CellDelta { + full_grid: true, + .. + } + ) + .then_some(())) + .is_some(), + "the v20 session must reach its initial grid, not merely receive an \ + acceptable Hello" + ); + + // Half 2 — the current frontend counter-offers and gets the band. + let mut current = attach_semantic(&daemon, requested_protocol_version(hello.protocol_version)); + assert_eq!( + current.offered, PROTOCOL_VERSION, + "the counter-offer is this binary's own wire" + ); + assert!( + press_and_await_panel(&mut current), + "a v21-negotiated semantic session must be panel-capable and receive \ + a Present panel frame" + ); + + // Half 3 — a semantic session that echoed the baseline is NOT + // panel-capable, and the gate is on PLACEMENT rather than only on + // transport: it keeps a working document window instead of an + // invisible side one. + let mut pre_panel = attach_semantic(&daemon, ADVERTISED_PROTOCOL_VERSION); + let document = drain_until( + &mut pre_panel.stream, + "pre-panel snapshot", + |message| match message { + InstanceMessage::BufferSnapshot { buffer_id, .. } => Some(*buffer_id), + _ => None, + }, + ) + .expect("a semantic attach receives a buffer snapshot"); + // A real semantic frontend declares a viewport; the daemon produces no + // styling until it does, so without this the "document still works" + // half below would be unobservable rather than false. + write_message( + &mut pre_panel.stream, + &FrontendEvent::Viewport { + frontend_id: pmacs_protocol::FrontendId(0), + buffer_id: document, + visible: pmacs_protocol::ByteRange { start: 0, end: 0 }, + generation: 0, + }, + ) + .expect("declare a viewport"); + // The same panel-open key the v21 session used. One drain, classifying + // both outcomes: a `PanelFrame` fails immediately, and the document's + // own semantic traffic is what proves the fallback window is live. + write_message( + &mut pre_panel.stream, + &FrontendEvent::Key(KeyEvent { + frontend_id: pmacs_protocol::FrontendId(0), + key: Key::Char('p'), + mods: Modifiers::CTRL | Modifiers::ALT, + timestamp_ns: 0, + }), + ) + .expect("write panel-open key"); + let document_still_driven = drain_until(&mut pre_panel.stream, "fallback", |message| { + assert!( + !matches!(message, InstanceMessage::PanelFrame(_)), + "a v20 semantic session must never be sent a panel frame: {message:?}" + ); + match message { + InstanceMessage::StyleSpans { buffer_id, .. } + | InstanceMessage::Decorations { buffer_id, .. } + if *buffer_id == document => + { + Some(()) + } + _ => None, + } + }); + assert!( + document_still_driven.is_some(), + "the pre-panel session must keep being driven as a DOCUMENT — a \ + frontend placed in a side window it cannot render would have an \ + invisible window, so the daemon must take the Stage 1 fallback" + ); +} + +// --------------------------------------------------------------------------- +// The negotiation rules themselves +// --------------------------------------------------------------------------- + +/// The advertised baseline is a compatibility floor that does NOT move, +/// and the counter-offer is what reaches the current wire. +#[test] +fn the_baseline_stays_and_the_counter_offer_activates() { + assert_eq!(PROTOCOL_VERSION, 21); + assert_eq!( + ADVERTISED_PROTOCOL_VERSION, 20, + "moving this is the incompatible act the mechanism exists to avoid" + ); + assert!(PROTOCOL_VERSION > ADVERTISED_PROTOCOL_VERSION); + assert_eq!(PANEL_MIN_VERSION, PROTOCOL_VERSION); + + // The current baseline is answered with this binary's own version. + assert_eq!( + requested_protocol_version(ADVERTISED_PROTOCOL_VERSION), + PROTOCOL_VERSION + ); + // Anything older is echoed VERBATIM, so a genuinely older daemon takes + // byte-for-byte the pre-activation path. + for older in 6..ADVERTISED_PROTOCOL_VERSION { + assert_eq!( + requested_protocol_version(older), + older, + "a daemon advertising v{older} must be echoed, not counter-offered" + ); + } + // The offer is never below the baseline: a frontend that supported less + // would already have rejected the Hello. + for baseline in SUPPORTED_PROTOCOL_VERSIONS { + assert!(requested_protocol_version(*baseline) >= *baseline); + } +} + +/// The session speaks the lower of the two ceilings. +#[test] +fn the_daemon_negotiates_the_lower_of_the_two_ceilings() { + for offer in SUPPORTED_PROTOCOL_VERSIONS { + assert_eq!( + negotiated_session_version(*offer), + *offer, + "every supported offer is adopted as-is" + ); + } + // An offer above this binary's own wire is clamped rather than + // recorded. It cannot arrive today — the membership test rejects it + // first — which is exactly why the rule is written down instead of + // left implicit in that test. + assert_eq!( + negotiated_session_version(PROTOCOL_VERSION + 1), + PROTOCOL_VERSION + ); + assert!(!is_supported_protocol_version(PROTOCOL_VERSION + 1)); +} + +/// An offer outside the supported set is still refused with an explicit +/// `VersionMismatch` naming both versions, so the one-way window the +/// counter-offer leaves open is visible rather than a silent hang. +#[cfg(feature = "crdt")] +#[test] +fn an_unsupported_offer_is_refused_by_name() { + let daemon = TestDaemon::spawn(); + let mut stream = daemon.connect(); + stream + .set_read_timeout(Some(Duration::from_secs(10))) + .expect("set timeout"); + let hello: Hello = read_message(&mut stream).expect("read Hello"); + write_message( + &mut stream, + &AttachRequest { + protocol_version: PROTOCOL_VERSION + 7, + frontend_capabilities: semantic_caps(), + initial_size: CellSize::new(24, 80), + }, + ) + .expect("write over-offer"); + let message: InstanceMessage = read_message(&mut stream).expect("read refusal"); + match message { + InstanceMessage::Goodbye(GoodbyeReason::VersionMismatch { server, client }) => { + assert_eq!(server, ADVERTISED_PROTOCOL_VERSION); + assert_eq!(client, PROTOCOL_VERSION + 7); + } + other => panic!("expected a named VersionMismatch, got {other:?}"), + } + assert_eq!(hello.protocol_version, ADVERTISED_PROTOCOL_VERSION); + // And the connection closes rather than lingering half-open. + let mut sink = [0u8; 1]; + assert!( + matches!(stream.read(&mut sink), Ok(0) | Err(_)), + "a refused attach must close" + ); +}