From 57be2bacd13548b4c3a641f3a5cac8fede9b4fbc Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Wed, 29 Jul 2026 18:51:55 -0400 Subject: [PATCH] test(bottom-panel): acceptance for the band, the activation, and the flip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Placed by what each assertion needs: the band's pixel geometry, the epoch latch, the probe-derived columns, and the three-boundary contrast assertion live in pmacs-gpu's own tests because they need a real State and a real surface; the handshake, the negotiation, and the capability flip live in bottom_panel_stage2b_gpu_acceptance because they need a real daemon. Every acceptance runs both directions in one fixture. The activation test uses ONE daemon for all three halves — a shipped v20 client reaching its initial grid, a v21 counter-offer receiving a Present band, and a v20 semantic session that is never sent a panel frame and keeps a live document window. Two daemons could each pass their own half while the same build was incapable of serving both, which is the only property that matters. Two real defects the new tests caught in my own implementation: * edge_scroll_direction has no upper bound, so moving its boundary was necessary but not sufficient — a pixel inside the band still read as 'further down the document' and armed the document's auto-scroll. That is the exact named symptom of leaving that consumer on the old bottom. The falsification keeps both answers: the probe pixel is inside the unmoved boundary's own edge strip, so the two genuinely differ, and a third assertion proves the feature is not simply switched off. * apply_panel_payload ignored the exhaustion latch, so a latched session kept storing frames and reporting 'changed'. That left presented() as the only thing between a disowned declaration and a painted band, and spent a reshape on every arriving frame for the rest of the session. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Lv428Fth9LRtffwJSsqH7T --- pmacs-gpu/src/main.rs | 514 ++++++++++++++++++- tests/bottom_panel_stage2b_gpu_acceptance.rs | 396 ++++++++++++++ 2 files changed, 909 insertions(+), 1 deletion(-) create mode 100644 tests/bottom_panel_stage2b_gpu_acceptance.rs 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" + ); +}