test(font): GPU acceptance for apply_font_facts + caret substrate

Twenty-one new headless tests covering the GPU-side acceptance items of
docs/gpu-set-font-framing.md (9-14, 16-19):

-  9: size route re-derives metrics; (None, None) reset reproduces
      the never-set frame byte-for-byte
- 10: 600/7200 bounds render; minibuffer dropdown windows its rows
      above the band at the derived row height; context menu keeps
      the clipped route with coherent hit geometry
- 11: wrapped caret survives 16->72->6 px; an optimistic insertion
      that wraps a new bottom row follows immediately and its
      confirming CursorByte needs no second repair; overscan caret
      never snaps the viewport; narrowing resize re-follows via the
      coarse + visual-run + fold pipeline; adornment projection
      shifts the caret past injected text with left gravity at the
      anchor; the CursorByte arm follows into a wrapped run as a
      normalized sub-line residual; explicit scrolls clear the
      residual (including wheel-up at the clamp edge); the
      minibuffer suspends the follow decision
-  4 (GPU half): BufferSnapshot resets the residual while the font
      preference/metrics survive -- the replacement buffer wraps
      too, so the EOF clamp cannot mask a leaked residual
- 12: unknown + proportional families fall back; the four-style
      gate rejects a proportional BOLD sibling the normal-only
      query would accept; a valid second monospace family resolves,
      changes the ink, and resets cleanly; the parameterized
      sanitizer removes exactly the same-family proportional
      collision
- 13: the "\0" sentinel defeats the status string-equality gates
- 14: a shrinking visible slice re-declares the scoped viewport
- 16: 0/599/7201/u32::MAX reject the whole message, state untouched
- 17: metrics + drawable dimensions atomic on all seven buffers,
      resize symmetric via Buffer::size()
- 18: popup rows never wrap (Wrap::None + unique line_i per run)
- 19: with line numbers and a context menu open over an EMPTY
      buffer, the measured probe advance drives gutter reservation
      and menu hit width (fixture ratio exactly 1.2 vs the bundled
      default); reset restores the exact geometry and frame

Family routing is hermetic: four generated fixture faces under
pmacs-gpu/fonts/test/ (second monospace at 720/1000, a proportional,
and a "Pmacs Test Family" whose NORMAL face is monospaced but whose
BOLD face is proportional), built by the committed generate.py --
provenance and license in LICENSE.txt beside them. fontdb requires a
PostScript name (nameID 6), which the generator sets explicitly.

assemble() now routes construction through sync_buffer_dimensions
too: shape_until_cursor and wrapping must use the painter's clip
from the first frame -- a v16 daemon never sends the FontFacts that
would sync them later (found by the CursorByte-follow test: the
caret parked in the status band at surface-height dims).

Every protection was bitten (temporarily reverted, its test observed
failing, then restored): wire validation, CursorByte visual-run
follow, painted-before gate, projection inversion, Wrap::None,
four-style gate, sanitizer, status sentinel, normalize fold,
scroll_by_lines residual clear, BufferSnapshot residual reset, the
all-seven dimension helper, and the optimistic-completion follow.
The snapshot-reset test was strengthened after its first bite did
NOT fail (a short replacement buffer let cosmic's EOF clamp zero the
residual anyway).

PMACS_REQUIRE_GPU suite 90/90; clippy -D warnings and fmt clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VoiEyuPjoBhvwACf8HAnLB
This commit is contained in:
Levi Neuwirth 2026-07-15 17:20:50 +01:00
parent 06a4b2e359
commit fe65fddae5
7 changed files with 1018 additions and 0 deletions

View File

@ -0,0 +1,6 @@
The Pmacs Test* font fixtures in this directory are trivial synthetic
faces (rectangle glyphs over space, digits, and a-z) generated for the
pmacs-gpu test suite by the accompanying generate.py script. They are
original to the pmacs project, contain no third-party outlines or
data, and are released under the same license as the pmacs source
tree. They are test fixtures, not usable typefaces.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -0,0 +1,138 @@
#!/usr/bin/env python3
"""Generate the four hermetic test faces for the pmacs-gpu font tests.
The gpu-set-font acceptance suite (docs/gpu-set-font-framing.md) needs
family-routing tests that cannot depend on whatever fonts the host has
installed, so these tiny fixture faces are generated and committed:
PmacsTestMonoTwo-Regular.ttf "Pmacs Test Mono Two" monospaced,
advance 720/1000 (JetBrains Mono is
600/1000, so the measured advance
ratio is exactly 1.2)
PmacsTestProportional-Regular.ttf "Pmacs Test Proportional" varying
advances, not monospaced
PmacsTestFamily-Regular.ttf "Pmacs Test Family" monospaced
normal face, advance 800/1000
PmacsTestFamily-Bold.ttf "Pmacs Test Family" BOLD and
proportional -- the same-family
collision the sanitizer removes and
the four-style monospace gate must
reject
Every glyph is a plain rectangle (ink for frame-diff tests); coverage
is space, the digits (the ADVANCE_PROBE string), and a-z. fontdb's
`monospaced` flag reads the post table's isFixedPitch, so that is the
one bit that decides mono vs proportional here.
Run from this directory: python3 generate.py
Requires fontTools (any recent version).
"""
from fontTools.fontBuilder import FontBuilder
from fontTools.pens.ttGlyphPen import TTGlyphPen
UPM = 1000
CHARS = " 0123456789abcdefghijklmnopqrstuvwxyz"
ASCENT = 800
DESCENT = -200
def glyph_name(char):
return "uni%04X" % ord(char)
def rect_glyph(advance):
"""A filled rectangle spanning most of the advance width."""
pen = TTGlyphPen(None)
left = 60
right = max(left + 40, advance - 60)
pen.moveTo((left, 0))
pen.lineTo((right, 0))
pen.lineTo((right, 700))
pen.lineTo((left, 700))
pen.closePath()
return pen.glyph()
def empty_glyph():
return TTGlyphPen(None).glyph()
def build(path, family, style, weight, bold, fixed_pitch, advance_for):
order = [".notdef"] + [glyph_name(c) for c in CHARS]
fb = FontBuilder(UPM, isTTF=True)
fb.setupGlyphOrder(order)
fb.setupCharacterMap({ord(c): glyph_name(c) for c in CHARS})
glyphs = {".notdef": rect_glyph(600)}
metrics = {".notdef": (600, 60)}
for c in CHARS:
adv = advance_for(c)
name = glyph_name(c)
glyphs[name] = empty_glyph() if c == " " else rect_glyph(adv)
metrics[name] = (adv, 0 if c == " " else 60)
fb.setupGlyf(glyphs)
fb.setupHorizontalMetrics(metrics)
fb.setupHorizontalHeader(ascent=ASCENT, descent=DESCENT)
# fontdb refuses faces without a PostScript name (nameID 6).
ps_name = (family + "-" + style).replace(" ", "")
fb.setupNameTable({"familyName": family, "styleName": style, "psName": ps_name})
fb.setupOS2(
sTypoAscender=ASCENT,
sTypoDescender=DESCENT,
usWinAscent=ASCENT,
usWinDescent=-DESCENT,
usWeightClass=weight,
fsSelection=0x20 if bold else 0x40, # BOLD else REGULAR
)
fb.setupPost(isFixedPitch=1 if fixed_pitch else 0)
if bold:
fb.font["head"].macStyle = 0x01
fb.save(path)
print("wrote", path)
def proportional_advance(c):
if c == " ":
return 250
if c.isdigit():
return 500
# A spread of widths so no two adjacent letters share one.
return 300 + (ord(c) - ord("a")) * 15
build(
"PmacsTestMonoTwo-Regular.ttf",
"Pmacs Test Mono Two",
"Regular",
400,
bold=False,
fixed_pitch=True,
advance_for=lambda c: 720,
)
build(
"PmacsTestProportional-Regular.ttf",
"Pmacs Test Proportional",
"Regular",
400,
bold=False,
fixed_pitch=False,
advance_for=proportional_advance,
)
build(
"PmacsTestFamily-Regular.ttf",
"Pmacs Test Family",
"Regular",
400,
bold=False,
fixed_pitch=True,
advance_for=lambda c: 800,
)
build(
"PmacsTestFamily-Bold.ttf",
"Pmacs Test Family",
"Bold",
700,
bold=True,
fixed_pitch=False,
advance_for=proportional_advance,
)

View File

@ -2281,6 +2281,11 @@ impl State {
gutter_buffer,
gutter_text_renderer,
};
// Real drawable dimensions from construction (framing Q#F6):
// wrapping and `shape_until_cursor` must use the same clip the
// painter uses, and a v16 daemon never sends the FontFacts
// that would sync them later.
state.sync_buffer_dimensions();
// Shape the initial text through the shared chunk path so the
// `buffer.lines` ↔ `line_chunk_cache` invariant holds from
// construction — the caret/anchor projection (framing Q#F6)
@ -9811,4 +9816,873 @@ mod tests {
"a parent-only byte keeps the parent color"
);
}
// -----------------------------------------------------------------
// Arc 4 stage 2 (gpu-set-font framing Q#F6/Q#F7): apply_font_facts
// and the visual-run caret/scroll substrate. Family routing is
// hermetic: the four generated fixture faces load beside the
// bundled font (fonts/test/, provenance in LICENSE.txt there).
// -----------------------------------------------------------------
const TEST_MONO_TWO: &[u8] = include_bytes!("../fonts/test/PmacsTestMonoTwo-Regular.ttf");
const TEST_PROPORTIONAL: &[u8] =
include_bytes!("../fonts/test/PmacsTestProportional-Regular.ttf");
const TEST_FAMILY_REGULAR: &[u8] = include_bytes!("../fonts/test/PmacsTestFamily-Regular.ttf");
const TEST_FAMILY_BOLD: &[u8] = include_bytes!("../fonts/test/PmacsTestFamily-Bold.ttf");
fn load_fixture_faces(state: &mut State) {
for bytes in [
TEST_MONO_TWO,
TEST_PROPORTIONAL,
TEST_FAMILY_REGULAR,
TEST_FAMILY_BOLD,
] {
state.font_system.db_mut().load_font_data(bytes.to_vec());
}
}
fn font_facts(family: Option<&str>, size_centi_px: Option<u32>) -> InstanceMessage {
InstanceMessage::FontFacts {
family: family.map(str::to_owned),
size_centi_px,
}
}
/// Acceptance 16 — wire validation fails closed: an out-of-range
/// size rejects the WHOLE message (family included), keeps every
/// piece of current state, and re-declares nothing.
#[test]
#[allow(clippy::float_cmp)] // exact: assigned constants, not computed sums
fn font_facts_out_of_range_sizes_fail_closed() {
let Some(mut state) = headless_or_skip(320, 240, "fn main() {}") else {
return;
};
let baseline = state.render_offscreen();
for size in [0u32, 599, 7201, u32::MAX] {
let vp =
state.apply_attach_message(font_facts(Some("Pmacs Test Mono Two"), Some(size)));
assert!(vp.is_none(), "a rejected FontFacts must not re-declare");
assert_eq!(state.fm.scale, 1.0, "scale untouched at size {size}");
assert_eq!(
state.resolved_family, DEFAULT_FONT_FAMILY,
"family untouched at size {size} — the whole message is rejected"
);
assert_eq!(
state.render_offscreen(),
baseline,
"frame byte-identical at size {size}"
);
}
}
/// Acceptance 9 — the size route re-derives every metric, and the
/// `(None, None)` reset reproduces the never-set frame
/// byte-for-byte within the process.
#[test]
#[allow(clippy::float_cmp)] // exact: assigned constants, not computed sums
fn font_size_applies_and_reset_is_byte_identical() {
let Some(mut state) = headless_or_skip(320, 240, "fn main() {}") else {
return;
};
let never_set = state.render_offscreen();
let advance_before = state.mono_advance();
let visible_before = estimated_visible_lines(state.config.height, state.fm);
state.apply_attach_message(font_facts(None, Some(2400)));
assert!(
(state.fm.scale - 1.5).abs() < f32::EPSILON,
"2400 centi-px / 16 = 1.5"
);
assert!(
state.mono_advance() > advance_before,
"a larger size widens the measured advance (gutter geometry)"
);
assert!(
estimated_visible_lines(state.config.height, state.fm) < visible_before,
"a larger size fits fewer source lines"
);
let big = state.render_offscreen();
assert_ne!(big, never_set, "a 24px preference must change the ink");
state.apply_attach_message(font_facts(None, None));
assert_eq!(state.fm.scale, 1.0);
assert_eq!(state.fm.advance_ratio, 1.0);
assert_eq!(
state.render_offscreen(),
never_set,
"the (None, None) reset must reproduce the never-set frame byte-for-byte"
);
}
/// Acceptance 17 — metrics and REAL drawable dimensions change
/// atomically on all seven buffers, and `resize()` keeps every
/// buffer in step through the same helper (`Buffer::size()` is
/// the witness).
#[test]
fn all_seven_buffers_track_metrics_and_dimensions() {
fn assert_buffer_dims(state: &State) {
let fm = state.fm;
let width = state.config.width as f32;
let height = state.config.height as f32;
let code_metrics = Metrics::new(fm.code_font_size(), fm.code_line_height());
let code_width = (state.text_bounds_right() as f32 - state.text_left()).max(0.0);
let code_height = (text_area_bottom(state.config.height, fm) - TEXT_TOP).max(0.0);
assert_eq!(state.buffer.metrics(), code_metrics);
assert_eq!(state.buffer.size(), (Some(code_width), Some(code_height)));
assert_eq!(state.gutter_buffer.metrics(), code_metrics);
assert_eq!(state.gutter_buffer.size(), (Some(width), Some(code_height)));
let status_metrics = Metrics::new(fm.status_font_size(), fm.status_line_height());
for buffer in [&state.status_buffer, &state.status_left_buffer] {
assert_eq!(buffer.metrics(), status_metrics);
assert_eq!(buffer.size(), (Some(width), Some(fm.status_band_height())));
}
assert_eq!(
state.menu_buffer.metrics(),
Metrics::new(fm.menu_font_size(), fm.menu_line_height())
);
assert_eq!(
state.menu_buffer.size(),
(Some(MENU_MAX_WIDTH), Some(height))
);
let drop_metrics = Metrics::new(fm.mb_drop_font_size(), fm.mb_drop_line_height());
for buffer in [&state.mb_buffer, &state.completion_buffer] {
assert_eq!(buffer.metrics(), drop_metrics);
assert_eq!(buffer.size(), (Some(MB_DROP_MAX_WIDTH), Some(height)));
}
}
let Some(mut state) = headless_or_skip(320, 240, "one\ntwo\nthree\n") else {
return;
};
state.apply_attach_message(font_facts(None, Some(2400)));
assert_buffer_dims(&state);
state.resize(500, 400);
assert_buffer_dims(&state);
}
/// Acceptance 18 — rows stay rows: after a large size lands, no
/// popup label may wrap onto a second visual run that would
/// hit-test as the following item.
#[test]
fn popup_rows_never_wrap_after_a_font_change() {
let Some(mut state) = headless_or_skip(320, 600, "text") else {
return;
};
state.apply_attach_message(font_facts(None, Some(7200)));
let long = "a very long label ".repeat(8);
state.completion = Some(CompletionLocal {
buffer_id: BufferId::next(),
anchor: 0,
prefix_len: 0,
rows: vec![
CompletionPopupRow {
label: long.clone(),
kind: 3,
detail: Some(long.clone()),
},
CompletionPopupRow {
label: long.clone(),
kind: 3,
detail: None,
},
],
selected: Some(0),
total: 2,
});
state.refresh_completion_buffer();
state.minibuffer = Some(MinibufferLocal {
prompt: "P: ".into(),
input: String::new(),
cursor: 0,
candidates: vec![long.clone(), long.clone()],
selected: Some(0),
total: 2,
});
state.refresh_mb_buffer();
state.menu = Some(MenuLocal {
rows: vec![
MenuPromptRow {
label: long.clone(),
separator: false,
},
MenuPromptRow {
label: long,
separator: false,
},
],
active: Some(0),
anchor_px: (10.0, 10.0),
});
state.refresh_menu_buffer();
for (name, buffer) in [
("completion", &state.completion_buffer),
("minibuffer dropdown", &state.mb_buffer),
("context menu", &state.menu_buffer),
] {
assert_eq!(buffer.wrap(), Wrap::None, "{name} buffer must not wrap");
let mut seen = std::collections::HashSet::new();
for run in buffer.layout_runs() {
assert!(
seen.insert(run.line_i),
"{name} row {} wrapped onto a second visual run",
run.line_i
);
}
}
}
/// Acceptance 13 — the two string-equality status caches drop on
/// a font change, so an unchanged composed status re-shapes with
/// the new attrs on the next frame.
#[test]
#[allow(clippy::float_cmp)] // exact: assigned constants, not computed sums
fn font_change_invalidates_the_status_shaping_caches() {
let Some(mut state) = headless_or_skip(320, 240, "text") else {
return;
};
let _ = state.render_offscreen();
let composed_before = state.status_text.clone();
assert!(
!composed_before.is_empty(),
"precondition: a frame composed the status readout"
);
state.apply_attach_message(font_facts(None, Some(3200)));
assert_eq!(
state.status_text, "\0",
"the sentinel must defeat the string-equality gate"
);
assert_eq!(state.status_left_text, "\0");
let _ = state.render_offscreen();
assert_eq!(
state.status_buffer.metrics().font_size,
state.fm.status_font_size(),
"the re-shaped band must carry the derived metrics"
);
assert_eq!(
state.status_text, composed_before,
"same composed text, re-shaped anyway"
);
}
/// Acceptance 14 — a size that shrinks the visible line count
/// re-declares the scoped viewport from the final normalized
/// origin.
#[test]
fn font_change_redeclares_a_shrunken_viewport() {
let text = "line of text\n".repeat(40);
let Some(mut state) = headless_or_skip(320, 400, &text) else {
return;
};
let bid = BufferId::next();
state.current_buffer_id = Some(bid);
state.reshape();
let declared = state.view_range;
state.last_viewport_sent = Some(declared);
let vp = state
.apply_attach_message(font_facts(None, Some(7200)))
.expect("fewer lines fit at 72px — the viewport must be re-declared");
assert_eq!(vp.buffer_id, bid);
assert!(
vp.visible.end < declared.1,
"the re-declared range must shrink ({} vs {})",
vp.visible.end,
declared.1
);
}
/// Acceptance 10 — both preference bounds render without panic,
/// the minibuffer dropdown windows its rows above the band at the
/// derived row height, and the context menu keeps its raw-anchor
/// clipped route with coherent hit geometry.
#[test]
fn extreme_sizes_render_with_contained_popups() {
let text = "line of text\n".repeat(20);
let Some(mut state) = headless_or_skip(320, 400, &text) else {
return;
};
state.apply_attach_message(font_facts(None, Some(600)));
let _ = state.render_offscreen();
state.apply_attach_message(font_facts(None, Some(7200)));
state.minibuffer = Some(MinibufferLocal {
prompt: "M-x ".into(),
input: String::new(),
cursor: 0,
candidates: (0..30).map(|i| format!("candidate-{i}")).collect(),
selected: Some(0),
total: 30,
});
let (_, count) = state
.mb_visible_window()
.expect("at least one 90px row fits above the band");
let band_top = text_area_bottom(state.config.height, state.fm);
assert!(
count >= 1 && count as f32 * state.fm.mb_drop_row_height() <= band_top,
"{count} visible rows must fit above the band at the derived row height"
);
state.menu = Some(MenuLocal {
rows: (0..20)
.map(|i| MenuPromptRow {
label: format!("menu entry {i} with a fairly long label attached"),
separator: false,
})
.collect(),
active: Some(0),
anchor_px: (200.0, 300.0),
});
let px = state.render_offscreen();
assert_eq!(
px.len(),
320 * 400 * 4,
"the clipped route renders a full frame"
);
assert_eq!(
state.menu_hit(205.0, 305.0),
Some((0, true)),
"hit geometry stays coherent on the clipped popup"
);
}
/// Acceptance 11 — a caret painted on a wrapped visual run
/// survives 16px → 72px → 6px re-wraps, with the normalized
/// scroll invariant intact throughout.
#[test]
#[allow(clippy::float_cmp)] // exact: assigned constants, not computed sums
fn wrapped_caret_survives_size_changes() {
let long = "x".repeat(180);
let text = format!("{long}\nsecond\nthird\n");
let Some(mut state) = headless_or_skip(320, 400, &text) else {
return;
};
let bid = BufferId::next();
state.current_buffer_id = Some(bid);
state.own_cursor = Some(OwnCursor {
buffer_id: bid,
byte: 180,
});
state.reshape();
state.ensure_caret_painted();
assert!(
state.caret_painted_in_code_clip(),
"precondition: the caret paints at the default size"
);
state.apply_attach_message(font_facts(None, Some(7200)));
assert!(
state.caret_painted_in_code_clip(),
"the caret must survive the 16px → 72px re-wrap"
);
assert_eq!(
state.buffer.scroll().line,
0,
"normalized: slice-local line 0"
);
assert_eq!(
state.buffer.scroll().horizontal,
0.0,
"horizontal is discarded"
);
state.apply_attach_message(font_facts(None, Some(600)));
assert!(
state.caret_painted_in_code_clip(),
"and the reverse 72px → 6px"
);
assert_eq!(state.buffer.scroll().line, 0);
}
/// Acceptance 11 — an overscan-only caret (shaped but below the
/// drawable window) is NOT painted, and a font change must not
/// snap the viewport back to it.
#[test]
fn overscan_caret_never_snaps_the_viewport() {
let text = "line of text\n".repeat(40);
let Some(mut state) = headless_or_skip(320, 400, &text) else {
return;
};
let bid = BufferId::next();
state.current_buffer_id = Some(bid);
state.reshape();
let visible = estimated_visible_lines(state.config.height, state.fm);
let overscan_line = visible + 1;
state.own_cursor = Some(OwnCursor {
buffer_id: bid,
byte: state.current_line_starts[overscan_line],
});
assert!(
!state.caret_painted_in_code_clip(),
"precondition: the overscan caret is not painted"
);
let top_before = state.scroll_top;
state.apply_attach_message(font_facts(None, Some(2400)));
assert_eq!(
state.scroll_top, top_before,
"an unpainted caret must never snap the viewport"
);
}
/// Acceptance 11 — `resize()` applies the same painted-before
/// policy: shrinking the window re-follows a painted caret
/// through the coarse + visual-run + fold pipeline.
#[test]
#[allow(clippy::float_cmp)] // exact: assigned constants, not computed sums
fn narrowing_resize_keeps_a_wrapped_caret_painted() {
let mut text = "short\n".repeat(10);
let long = "y".repeat(100);
text.push_str(&long);
let Some(mut state) = headless_or_skip(640, 400, &text) else {
return;
};
let bid = BufferId::next();
state.current_buffer_id = Some(bid);
state.own_cursor = Some(OwnCursor {
buffer_id: bid,
byte: text.len() as u64,
});
state.reshape();
assert!(
state.caret_painted_in_code_clip(),
"precondition: everything fits at 640x400"
);
state.resize(320, 150);
assert!(
state.caret_painted_in_code_clip(),
"the shrunken window must re-follow the caret into its wrapped run"
);
assert_eq!(state.buffer.scroll().line, 0);
assert_eq!(state.buffer.scroll().horizontal, 0.0);
}
/// Acceptance 11 — the caret projection inverts the chunk cache:
/// injected adornment text shifts a caret past the anchor by the
/// projected width, while a caret AT the anchor keeps left
/// gravity (before the injected text).
#[test]
fn caret_projection_accounts_for_inline_adornments() {
let Some(mut state) = headless_or_skip(320, 240, "ab\ncd") else {
return;
};
let bid = BufferId::next();
state.current_buffer_id = Some(bid);
state.reshape();
state.own_cursor = Some(OwnCursor {
buffer_id: bid,
byte: 2,
});
let x_plain = state.caret_rect().expect("caret on line 0").x;
state.own_cursor = Some(OwnCursor {
buffer_id: bid,
byte: 1,
});
let x1_plain = state.caret_rect().expect("caret at byte 1").x;
state.current_adornments = vec![adornment(1, AdornmentPlacement::AtOffset, "hint")];
state.reshape();
state.own_cursor = Some(OwnCursor {
buffer_id: bid,
byte: 2,
});
let x_projected = state.caret_rect().expect("caret past the adornment").x;
assert!(
x_projected > x_plain + 3.0 * 9.0,
"the caret must sit past the injected text ({x_projected} vs {x_plain})"
);
state.own_cursor = Some(OwnCursor {
buffer_id: bid,
byte: 1,
});
let x_anchor = state.caret_rect().expect("caret at the anchor").x;
assert!(
(x_anchor - x1_plain).abs() < 0.5,
"an anchor byte keeps left gravity — before the adornment \
({x_anchor} vs {x1_plain})"
);
}
/// Acceptance 11 — the `CursorByte` arm follows into a wrapped
/// continuation run (the pre-existing source-line-only hole): the
/// follow lands as a sub-line residual, normalized to slice-local
/// line 0.
#[test]
#[allow(clippy::float_cmp)] // exact: assigned constants, not computed sums
fn cursor_byte_follows_into_a_wrapped_run() {
let long = "z".repeat(400);
let Some(mut state) = headless_or_skip(320, 240, &long) else {
return;
};
let bid = BufferId::next();
state.current_buffer_id = Some(bid);
state.reshape();
let _ = state.apply_attach_message(InstanceMessage::CursorByte {
buffer_id: bid,
byte_pos: 400,
});
assert!(
state.caret_painted_in_code_clip(),
"the CursorByte arm must follow into the wrapped run"
);
assert!(
state.code_scroll_residual > 0.0,
"the follow is a sub-line residual, not a source-line scroll"
);
assert_eq!(state.buffer.scroll().line, 0);
assert_eq!(state.buffer.scroll().horizontal, 0.0);
}
/// Acceptance 11 — an optimistic insertion that creates a new
/// bottom-edge wrap follows immediately (waiting a round trip
/// reads as a hitch), and the identical confirming `CursorByte`
/// needs no second repair.
#[test]
#[allow(clippy::float_cmp)] // exact: comparing a value to itself across a no-op
fn optimistic_insertion_follows_a_new_bottom_edge_wrap() {
// 320 wide → 304px drawable → 31 glyphs per row at the 9.6px
// advance; 240 high → 198px drawable → 9 rows. Exactly 9 full
// rows of text puts the caret at the painted bottom edge, and
// one more glyph starts row 10 below the clip.
let text = "q".repeat(31 * 9);
let Some(mut state) = headless_or_skip(320, 240, "") else {
return;
};
let bid = BufferId::next();
let doc = loro::LoroDoc::new();
doc.get_text(LORO_TEXT_CONTAINER)
.insert(0, &text)
.expect("insert snapshot text");
let _ = state.apply_attach_message(InstanceMessage::BufferSnapshot {
buffer_id: bid,
crdt_snapshot: doc.export(loro::ExportMode::Snapshot).expect("export"),
});
state.set_frontend_id(FrontendId(9001));
state.dispatch_idle = true;
let _ = state.apply_attach_message(InstanceMessage::CursorByte {
buffer_id: bid,
byte_pos: text.len() as u64,
});
assert!(
state.caret_painted_in_code_clip(),
"precondition: the end caret paints on the last full row"
);
let send = state
.optimistic_crdt_insert(ProtocolKey::Char('q'), Modifiers::NONE)
.expect("the optimistic insert path is eligible");
assert_eq!(send.buffer_id, bid);
assert!(
state.caret_painted_in_code_clip(),
"the insertion wrapped a new bottom row — the caret must follow NOW"
);
assert!(
state.code_scroll_residual > 0.0,
"the follow is a visual-run residual"
);
let residual = state.code_scroll_residual;
let top = state.scroll_top;
// The daemon confirms the predicted byte: `moved == false`, so
// no second repair may disturb the settled scroll.
let _ = state.apply_attach_message(InstanceMessage::CursorByte {
buffer_id: bid,
byte_pos: text.len() as u64 + 1,
});
assert_eq!(state.code_scroll_residual, residual);
assert_eq!(state.scroll_top, top);
}
/// Acceptance 11 — an explicit scroll clears the caret-follow
/// residual, including a wheel-up at the top clamp whose only
/// remaining motion IS the residual.
#[test]
#[allow(clippy::float_cmp)] // exact: assigned constants, not computed sums
fn explicit_scroll_clears_the_caret_follow_residual() {
let long = "w".repeat(400);
let Some(mut state) = headless_or_skip(320, 240, &long) else {
return;
};
let bid = BufferId::next();
state.current_buffer_id = Some(bid);
state.reshape();
let _ = state.apply_attach_message(InstanceMessage::CursorByte {
buffer_id: bid,
byte_pos: 400,
});
assert!(
state.code_scroll_residual > 0.0,
"precondition: residual armed"
);
assert_eq!(
state.scroll_top, 0,
"single source line: the residual IS the scroll"
);
let _ = state.scroll_by_lines(-1);
assert_eq!(
state.code_scroll_residual, 0.0,
"wheel-up at the clamp edge must scroll the residual away"
);
assert!(
!state.caret_painted_in_code_clip(),
"the deep wrapped caret is off-screen again — the user owns the viewport"
);
}
/// Acceptance 4 (GPU half) — the caret-follow residual is
/// buffer-scoped view state: a `BufferSnapshot` resets it with
/// the rest of the view, while the font preference and derived
/// metrics survive the switch.
#[test]
#[allow(clippy::float_cmp)] // exact: assigned constants, not computed sums
fn buffer_snapshot_resets_the_residual_but_not_the_font() {
let long = "v".repeat(400);
let Some(mut state) = headless_or_skip(320, 240, &long) else {
return;
};
let bid = BufferId::next();
state.current_buffer_id = Some(bid);
state.reshape();
state.apply_attach_message(font_facts(None, Some(2400)));
let _ = state.apply_attach_message(InstanceMessage::CursorByte {
buffer_id: bid,
byte_pos: 400,
});
assert!(
state.code_scroll_residual > 0.0,
"precondition: residual armed"
);
// The replacement buffer wraps too: a leaked residual would
// NOT be zeroed by the EOF clamp, so the assertion below can
// only pass through the snapshot arm's explicit reset.
let doc = loro::LoroDoc::new();
doc.get_text(LORO_TEXT_CONTAINER)
.insert(0, &"u".repeat(400))
.expect("insert snapshot text");
let _ = state.apply_attach_message(InstanceMessage::BufferSnapshot {
buffer_id: BufferId::next(),
crdt_snapshot: doc.export(loro::ExportMode::Snapshot).expect("export"),
});
assert_eq!(
state.code_scroll_residual, 0.0,
"the residual is buffer-scoped view state"
);
assert!(
(state.fm.scale - 1.5).abs() < f32::EPSILON,
"the global font preference survives the switch"
);
assert_eq!(state.resolved_family, DEFAULT_FONT_FAMILY);
}
/// The follow decision is suspended while the minibuffer is open:
/// its caret lives in the band, not the code area.
#[test]
fn open_minibuffer_suppresses_the_caret_follow_decision() {
let Some(mut state) = headless_or_skip(320, 240, "hello") else {
return;
};
let bid = BufferId::next();
state.current_buffer_id = Some(bid);
state.own_cursor = Some(OwnCursor {
buffer_id: bid,
byte: 2,
});
state.reshape();
assert!(state.caret_painted_in_code_clip());
state.minibuffer = Some(MinibufferLocal {
prompt: ":".into(),
input: String::new(),
cursor: 0,
candidates: Vec::new(),
selected: None,
total: 0,
});
assert!(
!state.caret_painted_in_code_clip(),
"an open minibuffer owns the caret — the code-area decision is false"
);
}
/// Acceptance 19 — family-dependent geometry on an EMPTY document:
/// the measured probe advance (not a shaped code glyph) drives the
/// gutter fallback and menu char width through the advance ratio.
#[test]
#[allow(clippy::float_cmp)] // exact: the reset re-measures the identical face
fn family_advance_ratio_scales_empty_document_geometry() {
let Some(mut state) = headless_or_skip(320, 240, "") else {
return;
};
// Line numbers + a context menu over the EMPTY buffer: no code
// glyph has ever shaped, so any advance the geometry uses must
// come from the probe.
state.line_numbers = LineNumberMode::Absolute;
state.menu = Some(MenuLocal {
rows: vec![MenuPromptRow {
// Long enough that the estimated width sits BETWEEN
// the min/max clamps at both ratios — a short label
// pins to MENU_MIN_WIDTH and hides the scaling.
label: "a context menu entry armed here".into(),
separator: false,
}],
active: Some(0),
anchor_px: (40.0, 40.0),
});
load_fixture_faces(&mut state);
let gutter_before = state.gutter_width_px();
let menu_w_before = State::menu_width_px(state.menu.as_ref().unwrap(), state.fm);
let frame_before = state.render_offscreen();
state.apply_attach_message(font_facts(Some("Pmacs Test Mono Two"), None));
assert_eq!(state.resolved_family, "Pmacs Test Mono Two");
// Fixture advance 720/1000 vs JetBrains Mono 600/1000 → 1.2.
assert!(
(state.fm.advance_ratio - 1.2).abs() < 0.01,
"measured selected/default ratio, got {}",
state.fm.advance_ratio
);
assert!(
(state.mono_advance() - 11.52).abs() < 0.1,
"the measured NORMAL-face advance is authoritative on an empty \
document, got {}",
state.mono_advance()
);
assert!(
(state.fm.menu_char_w() - BASE_MENU_CHAR_W * 1.2).abs() < 0.05,
"menu hit width follows the ratio, got {}",
state.fm.menu_char_w()
);
assert!(
state.gutter_width_px() > gutter_before,
"the gutter reservation follows the measured advance"
);
assert!(
State::menu_width_px(state.menu.as_ref().unwrap(), state.fm) > menu_w_before,
"the menu hit width follows the measured advance"
);
state.apply_attach_message(font_facts(None, None));
assert_eq!(
state.gutter_width_px(),
gutter_before,
"reset: exact gutter"
);
assert_eq!(
State::menu_width_px(state.menu.as_ref().unwrap(), state.fm),
menu_w_before,
"reset: exact menu width"
);
assert_eq!(
state.render_offscreen(),
frame_before,
"reset restores the original frame"
);
}
/// Acceptance 12 — unresolvable and proportional families both
/// take the total fallback to the sanitized default.
#[test]
#[allow(clippy::float_cmp)] // exact: assigned constants, not computed sums
fn unresolvable_and_proportional_families_fall_back() {
let Some(mut state) = headless_or_skip(320, 240, "text") else {
return;
};
load_fixture_faces(&mut state);
let never_set = state.render_offscreen();
state.apply_attach_message(font_facts(Some("No Such Family Zzz"), None));
assert_eq!(state.resolved_family, DEFAULT_FONT_FAMILY);
assert_eq!(
state.render_offscreen(),
never_set,
"the unresolvable route renders byte-identically to never-set"
);
state.apply_attach_message(font_facts(Some("Pmacs Test Proportional"), None));
assert_eq!(
state.resolved_family, DEFAULT_FONT_FAMILY,
"a proportional family must fall back"
);
assert_eq!(
state.fm.advance_ratio, 1.0,
"the fallback is the default: ratio 1"
);
assert_eq!(
state.render_offscreen(),
never_set,
"the rejected route renders byte-identically to never-set"
);
}
/// Acceptance 12 — the four-style gate rejects a family whose
/// BOLD sibling is proportional, exactly what a normal-only
/// query check would wave through.
#[test]
fn four_style_gate_rejects_a_proportional_bold_sibling() {
let Some(mut state) = headless_or_skip(320, 240, "text") else {
return;
};
load_fixture_faces(&mut state);
assert!(
query_normal_face(state.font_system.db(), "Pmacs Test Family")
.and_then(|id| state.font_system.db().face(id))
.is_some_and(|face| face.monospaced),
"precondition: the NORMAL face alone looks monospaced"
);
assert!(state.family_is_monospace_everywhere("Pmacs Test Mono Two"));
assert!(
!state.family_is_monospace_everywhere("Pmacs Test Family"),
"the proportional BOLD sibling must fail the four-style gate"
);
state.apply_attach_message(font_facts(Some("Pmacs Test Family"), None));
assert_eq!(state.resolved_family, DEFAULT_FONT_FAMILY);
}
/// Acceptance 12 — a valid second monospace family resolves,
/// changes the ink, and the `(None, None)` reset restores the
/// default frame byte-for-byte.
#[test]
fn second_monospace_family_changes_the_frame_and_reset_restores() {
let Some(mut state) = headless_or_skip(320, 240, "0123456789") else {
return;
};
let default_frame = state.render_offscreen();
load_fixture_faces(&mut state);
state.apply_attach_message(font_facts(Some("Pmacs Test Mono Two"), None));
assert_eq!(state.resolved_family, "Pmacs Test Mono Two");
assert_ne!(
state.render_offscreen(),
default_frame,
"different outlines/advances must change the ink"
);
state.apply_attach_message(font_facts(None, None));
assert_eq!(state.resolved_family, DEFAULT_FONT_FAMILY);
assert_eq!(
state.render_offscreen(),
default_frame,
"the reset restores the default frame byte-for-byte"
);
}
/// Acceptance 12 — the sanitizer (parameterized, the production
/// path) removes exactly the non-monospace same-family
/// collisions: the monospaced default and unrelated families
/// survive.
#[test]
fn sanitizer_removes_only_same_family_proportional_collisions() {
let mut db = fontdb::Database::new();
let bold_id = *db
.load_font_source(fontdb::Source::Binary(std::sync::Arc::new(
TEST_FAMILY_BOLD,
)))
.first()
.expect("bold fixture loads");
let regular_id = *db
.load_font_source(fontdb::Source::Binary(std::sync::Arc::new(
TEST_FAMILY_REGULAR,
)))
.first()
.expect("regular fixture loads");
let unrelated_id = *db
.load_font_source(fontdb::Source::Binary(std::sync::Arc::new(
TEST_PROPORTIONAL,
)))
.first()
.expect("proportional fixture loads");
sanitize_font_database(&mut db, "Pmacs Test Family", regular_id);
assert!(
db.face(bold_id).is_none(),
"the proportional same-family BOLD collision is removed"
);
assert!(
db.face(regular_id).is_some(),
"the monospaced default survives"
);
assert!(
db.face(unrelated_id).is_some(),
"an unrelated proportional family is untouched"
);
}
}