Prepare measured scenario labels and clear corridor evaluators

This commit is contained in:
2026-09-12 18:35:52 -07:00
parent e811879857
commit 9b56a337f8
18 changed files with 2956 additions and 0 deletions
@@ -0,0 +1,429 @@
//! Unwired single-line scenario label experiment. No renderer or schema is changed.
//!
//! Iced 0.14.0 WGPU `geometry::Frame::fill_text` shapes positive uniform-scale
//! canvas text at `size * zoom`; its `text::prepare` uses graphics text::Cache.
//! This adapter uses that same allocator/font system/attributes, not a character
//! width estimate. Canvas Text::max_width is wrapping, so it stays infinite.
//! Experimental parity requires settled fonts and a fresh renderer/cache after
//! font loading. Iced's WGPU text cache key omits FontSystem::Version, so merely
//! remeasuring here cannot repair its cached glyph buffers after dynamic loading.
//! A RenderSession is caller attestation of that lifecycle, not its enforcement.
//! No fonts may be loaded between measurement, guarded access and actual draw.
//!
//! Integration seam: after lifecycle/activation endpoint alignment, a caller
//! supplies a usable text width in world units and retains one cache per live
//! labelled occurrence across Scene rebuilds. Paint, hit testing, navigation and extents
//! consume the returned Label. Position/color do not invalidate measurement.
//! This does NOT choose a self-call, reversed-arrow or obstacle corridor policy.
//! It measures layout advances, not raster ink; overhang/clip/activation-border
//! acceptance still requires the real WGPU evaluator. The old 32-grapheme/225px
//! renderer is deliberately untouched for a later baseline comparison.
use iced::advanced::{
graphics::text as graphics_text,
text::{Alignment, LineHeight, Shaping},
};
use iced::{Color, Font, Pixels, Point, Rectangle, Size, widget::canvas::Text};
use std::{collections::VecDeque, ops::Range, sync::Arc};
use unicode_segmentation::UnicodeSegmentation;
const MAX_SOURCE_BYTES: usize = 64 * 1024;
const MAX_GRAPHEMES: usize = 4096;
const MAX_ENTRIES: usize = 2;
#[derive(Clone, Copy, Debug, PartialEq)]
pub(super) struct Style {
pub font: Font,
pub size: Pixels,
pub line_height: LineHeight,
pub shaping: Shaping,
}
impl Default for Style {
fn default() -> Self {
let text = Text::default();
Self {
font: text.font,
size: Pixels(12.),
line_height: text.line_height,
shaping: text.shaping,
}
}
}
impl Style {
fn scaled(self, zoom: f32) -> (f32, f32) {
let size = Pixels(self.size.0 * zoom);
let line_height = match self.line_height {
LineHeight::Absolute(value) => LineHeight::Absolute(Pixels(value.0 * zoom)),
LineHeight::Relative(value) => LineHeight::Relative(value),
};
(size.0, line_height.to_absolute(size).0)
}
}
/// Explicit experimental contract: the caller finishes loading fonts and starts
/// a fresh renderer/cache BEFORE capturing this session, then keeps fonts fixed.
/// This module cannot inspect or reset the actual renderer's text cache.
#[derive(Clone, Debug)]
pub(super) struct RenderSession {
version: graphics_text::Version,
identity: Arc<()>,
}
impl RenderSession {
pub fn after_fonts_settled_and_renderer_reset() -> Result<Self, Error> {
let fonts = graphics_text::font_system()
.read()
.map_err(|_| Error::FontSystemUnavailable)?;
Ok(Self {
version: fonts.version(),
identity: Arc::new(()),
})
}
}
#[derive(Clone, Debug)]
pub(super) struct Label {
source: String,
displayed: String,
/// Original-label bytes represented before any added ellipsis. Includes
/// collapsed whitespace, but `retains_any` never counts whitespace as text.
retained_source: Range<usize>,
/// World-space layout advances, at origin; not glyph-ink containment.
bounds: Rectangle,
style: Style,
measured_session: RenderSession,
measured_zoom: f32,
}
impl Label {
fn check(&self, session: &RenderSession, zoom: f32) -> Result<(), Error> {
if !Arc::ptr_eq(&self.measured_session.identity, &session.identity) {
return Err(Error::StaleRenderSession);
}
if !zoom.is_finite() || zoom != self.measured_zoom {
return Err(Error::StaleZoom);
}
let fonts = graphics_text::font_system()
.read()
.map_err(|_| Error::FontSystemUnavailable)?;
if fonts.version() != self.measured_session.version || fonts.version() != session.version {
return Err(Error::FontSetChanged);
}
Ok(())
}
pub fn displayed(&self, session: &RenderSession, zoom: f32) -> Result<&str, Error> {
self.check(session, zoom)?;
Ok(&self.displayed)
}
pub fn retained_source(
&self,
session: &RenderSession,
zoom: f32,
) -> Result<Range<usize>, Error> {
self.check(session, zoom)?;
Ok(self.retained_source.clone())
}
pub fn retains_any(
&self,
session: &RenderSession,
zoom: f32,
field: Range<usize>,
) -> Result<bool, Error> {
self.check(session, zoom)?;
if field.start > field.end || self.source.get(field.clone()).is_none() {
return Ok(false);
}
let end = field.end.min(self.retained_source.end);
Ok(field.start < end
&& self
.source
.get(field.start..end)
.is_some_and(|text| text.chars().any(|c| !c.is_whitespace())))
}
/// Validate finite world position and rectangle endpoints only. Camera
/// transforms and raster bounds remain a separate integration contract.
pub fn bounds_at(
&self,
session: &RenderSession,
zoom: f32,
position: Point,
) -> Result<Rectangle, Error> {
self.check(session, zoom)?;
translated_bounds(position, self.bounds.size())
}
/// Paint under the same positive uniform zoom used for `Cache::resolve`.
/// No per-label wrapping or implicit text maximum is introduced.
/// The returned Text must be consumed immediately in this session/zoom;
/// retaining raw Text past another font load/zoom change bypasses the guard.
pub fn canvas_text(
&self,
session: &RenderSession,
zoom: f32,
position: Point,
color: Color,
) -> Result<Text, Error> {
self.bounds_at(session, zoom, position)?;
Ok(Text {
content: self.displayed.clone(),
position,
color,
size: self.style.size,
line_height: self.style.line_height,
font: self.style.font,
shaping: self.style.shaping,
max_width: f32::INFINITY,
..Text::default()
})
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(super) enum Error {
InvalidMetrics,
SourceBudget,
GraphemeBudget,
FontSystemUnavailable,
InvalidMeasurement,
InvalidPosition,
StaleZoom,
StaleRenderSession,
FontSetChanged,
}
struct Entry {
source: String,
style: Style,
zoom: f32,
width: f32,
label: Arc<Label>,
}
#[derive(Default)]
/// Two recent resolutions for ONE live labelled occurrence. A global small LRU
/// would thrash when a larger Scene is rebuilt in order. The integration owner
/// must retain these by occurrence identity and prune on removal/document change.
pub(super) struct Cache {
version: Option<graphics_text::Version>,
session: Option<Arc<()>>,
entries: VecDeque<Entry>,
#[cfg(test)]
allocations: usize,
}
impl Cache {
/// `width` excludes any caller-chosen backplate padding. Zero is valid.
/// Cache ownership must survive redraws; constructing one per Scene defeats
/// reuse. Changed fonts invalidate the SESSION: the caller must establish
/// a fresh renderer and a new settled session, not just remeasure this cache.
/// Error handling/fallback for these explicit experiment budgets is an
/// integration decision; never silently paint the old truncated contract.
pub fn resolve(
&mut self,
session: &RenderSession,
source: &str,
width: f32,
zoom: f32,
style: Style,
) -> Result<Arc<Label>, Error> {
let (size, line_height) = style.scaled(zoom);
if !width.is_finite()
|| width < 0.
|| !zoom.is_finite()
|| zoom <= 0.
|| !size.is_finite()
|| size <= 0.
|| !line_height.is_finite()
|| line_height <= 0.
|| !(width * zoom).is_finite()
|| !(size / zoom).is_finite()
|| !(line_height / zoom).is_finite()
{
return Err(Error::InvalidMetrics);
}
if source.len() > MAX_SOURCE_BYTES {
return Err(Error::SourceBudget);
}
let mut fonts = graphics_text::font_system()
.write()
.map_err(|_| Error::FontSystemUnavailable)?;
if session.version != fonts.version() {
return Err(Error::FontSetChanged);
}
if self.version != Some(fonts.version())
|| self
.session
.as_ref()
.is_none_or(|identity| !Arc::ptr_eq(identity, &session.identity))
{
self.entries.clear();
self.version = Some(fonts.version());
self.session = Some(session.identity.clone());
}
if let Some(index) = self.entries.iter().position(|entry| {
entry.source == source
&& entry.style == style
&& entry.zoom == zoom
&& entry.width == width
}) {
let entry = self.entries.remove(index).unwrap();
let label = entry.label.clone();
self.entries.push_back(entry);
return Ok(label);
}
let normalized = Normalized::new(source);
let cuts: Vec<usize> = normalized
.text
.grapheme_indices(true)
.map(|(i, _)| i)
.chain(std::iter::once(normalized.text.len()))
.collect();
if cuts.len().saturating_sub(1) > MAX_GRAPHEMES {
return Err(Error::GraphemeBudget);
}
// Candidate buffers are temporary. Only the resolved strings/metrics
// survive, bounded by MAX_ENTRIES; renderer caching remains independent.
let mut shapes = graphics_text::Cache::new();
let mut measure = |content: &str| -> Result<Size, Error> {
if content.is_empty() {
return Ok(Size::ZERO);
}
#[cfg(test)]
{
self.allocations += 1;
}
let (_, entry) = shapes.allocate(
fonts.raw(),
graphics_text::cache::Key {
content,
size,
line_height,
font: style.font,
bounds: Size::INFINITE,
shaping: style.shaping,
align_x: Alignment::Default,
},
);
if entry.buffer.layout_runs().count() != 1
|| !entry.min_bounds.width.is_finite()
|| !entry.min_bounds.height.is_finite()
{
return Err(Error::InvalidMeasurement);
}
world_size(entry.min_bounds, zoom)
};
let full = measure(&normalized.text)?;
let (displayed, retained, measured) = if full.width <= width {
(normalized.text.clone(), normalized.text.len(), full)
} else {
let ellipsis = measure("…")?;
if ellipsis.width > width {
(String::new(), 0, Size::ZERO)
} else {
// Every accepted candidate is actually shaped and fits. Search
// is bounded/logarithmic; contextual shaping is not universally
// monotone, so this does NOT promise the globally longest prefix.
// Full content is always checked first and never needlessly cut.
let mut best = ("…".to_owned(), 0, ellipsis);
let (mut low, mut high) = (1, cuts.len().saturating_sub(1));
while low < high {
let middle = low + (high - low) / 2;
let candidate = format!("{}…", &normalized.text[..cuts[middle]]);
let measured = measure(&candidate)?;
if measured.width <= width {
best = (candidate, cuts[middle], measured);
low = middle + 1;
} else {
high = middle;
}
}
best
}
};
let label = Arc::new(Label {
source: source.into(),
displayed,
retained_source: 0..normalized.source_end(retained),
bounds: Rectangle::with_size(measured),
style,
measured_session: session.clone(),
measured_zoom: zoom,
});
if self.entries.len() == MAX_ENTRIES {
self.entries.pop_front();
}
self.entries.push_back(Entry {
source: source.into(),
style,
zoom,
width,
label: label.clone(),
});
Ok(label)
}
}
fn world_size(scaled: Size, zoom: f32) -> Result<Size, Error> {
let world = Size::new(scaled.width / zoom, scaled.height / zoom);
if !world.width.is_finite()
|| !world.height.is_finite()
|| world.width < 0.
|| world.height < 0.
{
return Err(Error::InvalidMeasurement);
}
Ok(world)
}
fn translated_bounds(position: Point, size: Size) -> Result<Rectangle, Error> {
if !position.x.is_finite()
|| !position.y.is_finite()
|| !(position.x + size.width).is_finite()
|| !(position.y + size.height).is_finite()
{
return Err(Error::InvalidPosition);
}
Ok(Rectangle::new(position, size))
}
struct Normalized {
text: String,
ends: Vec<(usize, usize)>,
}
impl Normalized {
fn new(source: &str) -> Self {
let mut result = Self {
text: String::new(),
ends: vec![(0, 0)],
};
let mut separator = false;
for (start, c) in source.char_indices() {
if c.is_whitespace() {
separator = !result.text.is_empty();
continue;
}
if separator {
result.text.push(' ');
result.ends.push((result.text.len(), start));
separator = false;
}
result.text.push(c);
result.ends.push((result.text.len(), start + c.len_utf8()));
}
result
}
fn source_end(&self, normalized_end: usize) -> usize {
self.ends
.binary_search_by_key(&normalized_end, |(end, _)| *end)
.map(|index| self.ends[index].1)
.expect("grapheme cuts are character boundaries")
}
}
#[cfg(test)]
mod tests;
@@ -0,0 +1,189 @@
//! Unwired corridor-policy candidate for the measured scenario-label experiment.
//!
//! The caller supplies a finite structural label band AFTER endpoint/activation
//! alignment, with padding already removed from its edges. Blockers include
//! padded activation/card/header bounds over the complete text band, including
//! stroke/ink clearance. This function does not infer a self-message corridor,
//! alter scene extents, change zoom, or establish raster-ink containment.
use iced::Rectangle;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) struct InvalidGeometry;
fn edges(rect: Rectangle) -> Result<(f32, f32, f32, f32), InvalidGeometry> {
let right = rect.x + rect.width;
let bottom = rect.y + rect.height;
if [rect.x, rect.y, rect.width, rect.height, right, bottom]
.into_iter()
.any(|value| !value.is_finite())
|| rect.width < 0.
|| rect.height < 0.
{
return Err(InvalidGeometry);
}
Ok((rect.x, right, rect.y, bottom))
}
/// Select one connected clear interval; never assemble text across blockers.
/// Prefer greater width, then proximity to the previous label anchor, then the
/// leftmost interval. Input order has no influence. A zero-area band has no
/// corridor. Touching the padded vertical band is conservatively obstructing;
/// a zero-area blocker has no area and must receive stroke padding upstream.
pub(super) fn widest_clear(
band: Rectangle,
blockers: &[Rectangle],
preferred_anchor: f32,
) -> Result<Option<Rectangle>, InvalidGeometry> {
let (left, right, top, bottom) = edges(band)?;
if !preferred_anchor.is_finite() {
return Err(InvalidGeometry);
}
let mut blocked = Vec::new();
for &blocker in blockers {
let (x0, x1, y0, y1) = edges(blocker)?;
if blocker.width > 0. && blocker.height > 0. && y1 >= top && y0 <= bottom {
let interval = (x0.max(left), x1.min(right));
if interval.0 < interval.1 {
blocked.push(interval);
}
}
}
if band.width == 0. || band.height == 0. {
return Ok(None);
}
blocked.sort_by(|a, b| a.0.total_cmp(&b.0).then(a.1.total_cmp(&b.1)));
let mut best: Option<Rectangle> = None;
let mut consider = |start: f32, end: f32| {
if start >= end {
return;
}
let candidate = Rectangle {
x: start,
width: end - start,
..band
};
// Use f64 only for comparison distance: subtracting two admitted f32
// world coordinates can overflow even though each rectangle is finite.
let distance = |x: f32| (f64::from(x) - f64::from(preferred_anchor)).abs();
if best.is_none_or(|old| {
candidate.width > old.width
|| (candidate.width == old.width
&& (distance(candidate.x) < distance(old.x)
|| (distance(candidate.x) == distance(old.x) && candidate.x < old.x)))
}) {
best = Some(candidate);
}
};
let mut cursor = left;
for (start, end) in blocked {
consider(cursor, start);
cursor = cursor.max(end);
}
consider(cursor, right);
Ok(best)
}
#[cfg(test)]
mod tests {
use super::*;
fn rect(x: f32, y: f32, width: f32, height: f32) -> Rectangle {
Rectangle {
x,
y,
width,
height,
}
}
#[test]
fn long_message_does_not_cross_an_intermediate_execution() {
let band = rect(20., 100., 700., 22.);
let blockers = [rect(250., 80., 16., 80.), rect(490., 90., 28., 140.)];
let result = widest_clear(band, &blockers, band.x).unwrap().unwrap();
assert_eq!(result, rect(20., 100., 230., 22.));
for blocker in blockers {
assert!(result.x + result.width <= blocker.x || result.x >= blocker.x + blocker.width);
}
assert_eq!(widest_clear(band, &[], band.x).unwrap(), Some(band));
}
#[test]
fn overlapping_unsorted_blockers_merge_and_widest_gap_wins() {
let band = rect(-100., 10., 600., 20.);
let blockers = [
rect(50., 0., 80., 60.),
rect(0., 5., 70., 30.),
rect(450., 0., 100., 60.),
];
let expected = Some(rect(130., 10., 320., 20.));
assert_eq!(widest_clear(band, &blockers, -100.).unwrap(), expected);
let mut reversed = blockers;
reversed.reverse();
assert_eq!(widest_clear(band, &reversed, -100.).unwrap(), expected);
}
#[test]
fn complete_padded_band_and_touching_boundaries_are_obstructed() {
let band = rect(0., 100., 200., 20.);
// The first blocker ends at the band top: checking only a baseline
// would miss it. The second starts at the band bottom.
let blockers = [rect(0., 70., 60., 30.), rect(150., 120., 50., 30.)];
assert_eq!(
widest_clear(band, &blockers, 0.).unwrap(),
Some(rect(60., 100., 90., 20.))
);
let outside = [rect(0., 70., 200., 29.), rect(0., 121., 200., 30.)];
assert_eq!(widest_clear(band, &outside, 0.).unwrap(), Some(band));
}
#[test]
fn ties_keep_the_nearest_anchor_then_choose_leftmost() {
let band = rect(0., 0., 300., 20.);
let blockers = [rect(100., 0., 100., 20.)];
assert_eq!(
widest_clear(band, &blockers, 190.).unwrap(),
Some(rect(200., 0., 100., 20.))
);
assert_eq!(
widest_clear(band, &blockers, 100.).unwrap(),
Some(rect(0., 0., 100., 20.))
);
}
#[test]
fn caller_defined_self_and_reversed_corridors_keep_their_geometry() {
// Direction and self-loop policy are explicit caller inputs. Neither
// a zero endpoint gap nor screen width can silently supply a corridor.
for band in [rect(420., 40., 180., 22.), rect(-400., 90., 350., 22.)] {
assert_eq!(widest_clear(band, &[], band.x).unwrap(), Some(band));
}
assert_eq!(
widest_clear(rect(420., 40., 0., 22.), &[], 420.).unwrap(),
None
);
assert_eq!(
widest_clear(rect(420., 40., 180., 0.), &[], 420.).unwrap(),
None
);
assert_eq!(
widest_clear(rect(0., 0., 100., 20.), &[rect(-10., -10., 200., 50.)], 0.).unwrap(),
None
);
}
#[test]
fn invalid_or_overflowing_geometry_never_enters_a_corridor() {
let band = rect(0., 0., 100., 20.);
for invalid in [
rect(f32::NAN, 0., 10., 20.),
rect(0., 0., -1., 20.),
rect(f32::MAX, 0., f32::MAX, 20.),
rect(0., f32::MAX, 10., f32::MAX),
] {
assert_eq!(widest_clear(invalid, &[], 0.), Err(InvalidGeometry));
assert_eq!(widest_clear(band, &[invalid], 0.), Err(InvalidGeometry));
}
assert_eq!(widest_clear(band, &[], f32::INFINITY), Err(InvalidGeometry));
}
}
@@ -0,0 +1,487 @@
//! Unexecuted evaluator candidates; the module is intentionally not wired yet.
use super::*;
use iced::advanced::{
graphics::text::Paragraph,
text::{Paragraph as _, Text as ParagraphText, Wrapping},
};
// Independent paragraph construction: no candidate cache, normalization, search,
// or character-width estimate is used by this measurement oracle.
fn paragraph_size(content: &str, zoom: f32, style: Style) -> Size {
let size = Pixels(style.size.0 * zoom);
let line_height = match style.line_height {
LineHeight::Relative(factor) => LineHeight::Relative(factor),
LineHeight::Absolute(pixels) => LineHeight::Absolute(Pixels(pixels.0 * zoom)),
};
let paragraph = Paragraph::with_text(ParagraphText {
content,
bounds: Size::INFINITE,
size,
line_height,
font: style.font,
align_x: Alignment::Default,
align_y: iced::alignment::Vertical::Top,
shaping: style.shaping,
wrapping: Wrapping::default(),
});
assert_eq!(paragraph.buffer().layout_runs().count(), 1);
Size::new(paragraph.min_width() / zoom, paragraph.min_height() / zoom)
}
#[test]
fn adequate_corridor_reveals_full_realistic_names_and_distinguishing_suffixes() {
let mut cache = Cache::default();
let session = RenderSession::after_fonts_settled_and_renderer_reset().unwrap();
let style = Style::default();
for text in [
"1. call · Request valve repair · Technician service conversation",
"2. reply · Return pressure after repair · Technician service conversation",
"1. call · Request redundant hydraulic pressure calibration channel A",
"1. call · Request redundant hydraulic pressure calibration channel B",
] {
let wide = cache.resolve(&session, text, 1600., 1., style).unwrap();
assert_eq!(wide.displayed(&session, 1.).unwrap(), text);
assert_eq!(wide.retained_source(&session, 1.).unwrap(), 0..text.len());
assert_eq!(wide.bounds.size(), paragraph_size(text, 1., style));
let narrow = cache.resolve(&session, text, 80., 1., style).unwrap();
assert_ne!(narrow.displayed, text);
assert!(narrow.bounds.width <= 80.);
assert_eq!(
narrow.bounds.size(),
paragraph_size(&narrow.displayed, 1., style)
);
}
}
#[test]
fn normalized_visible_prefix_maps_to_original_bytes_without_claiming_hidden_operation() {
let raw = " \t1. call · Pump\r\n · Operation: Need\u{301}le ";
let expected = "1. call · Pump · Operation: Need\u{301}le";
let n = Normalized::new(raw);
assert_eq!(n.text, expected);
let prefix = "1. call · Pump ";
assert_eq!(n.source_end(prefix.len()), raw.find("· Operation").unwrap());
let mut cache = Cache::default();
let session = RenderSession::after_fonts_settled_and_renderer_reset().unwrap();
let wide = cache
.resolve(&session, raw, 1600., 1., Style::default())
.unwrap();
assert_eq!(wide.displayed, expected);
assert_eq!(wide.retained_source.end, raw.trim_end().len());
let field = raw.find("Need").unwrap()..raw.trim_end().len();
assert!(wide.retains_any(&session, 1., field.clone()).unwrap());
assert!(
!wide.retains_any(&session, 1., 0..2).unwrap(),
"normalized whitespace is not a visible field"
);
let small = cache
.resolve(&session, raw, 70., 1., Style::default())
.unwrap();
assert!(!small.retains_any(&session, 1., field).unwrap());
assert!(small.retained_source.end < raw.find("Operation").unwrap());
}
#[test]
fn grapheme_prefixes_and_zoom_match_actual_paragraph_layout() {
let text = "call · 戻り値 圧力 e\u{301} 👩‍🔬 safety valve verification";
let mut cache = Cache::default();
let session = RenderSession::after_fonts_settled_and_renderer_reset().unwrap();
for zoom in [0.66, 1., 1.75] {
let label = cache
.resolve(&session, text, 105., zoom, Style::default())
.unwrap();
assert!(label.bounds.width <= 105.);
assert_eq!(
label.bounds.size(),
paragraph_size(&label.displayed, zoom, Style::default())
);
assert!(
text.grapheme_indices(true)
.any(|(i, _)| i == label.retained_source.end)
|| label.retained_source.end == text.len()
);
let painted = label
.canvas_text(&session, zoom, Point::new(400., 90.), Color::BLACK)
.unwrap();
assert_eq!(painted.content, label.displayed);
assert!(painted.max_width.is_infinite());
assert_eq!(
label
.bounds_at(&session, zoom, painted.position)
.unwrap()
.size(),
label.bounds.size()
);
}
}
#[test]
fn empty_and_too_narrow_labels_never_claim_source_visibility() {
let mut cache = Cache::default();
let session = RenderSession::after_fonts_settled_and_renderer_reset().unwrap();
for text in ["", " \t\n", "Operation: Pump"] {
let label = cache
.resolve(&session, text, 0., 1., Style::default())
.unwrap();
assert_eq!(label.displayed, "");
assert_eq!(label.retained_source, 0..0);
assert_eq!(label.bounds.size(), Size::ZERO);
assert!(!label.retains_any(&session, 1., 0..text.len()).unwrap());
}
}
#[test]
fn redraws_reuse_resolution_but_width_zoom_and_style_are_dependencies() {
let mut cache = Cache::default();
let session = RenderSession::after_fonts_settled_and_renderer_reset().unwrap();
let text = "call · Pressure verification with a descriptive operation label";
let style = Style::default();
let a = cache.resolve(&session, text, 100., 1., style).unwrap();
let measured = cache.allocations;
let b = cache.resolve(&session, text, 100., 1., style).unwrap();
assert!(Arc::ptr_eq(&a, &b));
assert_eq!(cache.allocations, measured);
for (width, zoom, style) in [
(120., 1., style),
(100., 0.66, style),
(
100.,
1.,
Style {
size: Pixels(14.),
..style
},
),
] {
let other = cache.resolve(&session, text, width, zoom, style).unwrap();
assert!(!Arc::ptr_eq(&a, &other));
}
}
#[test]
fn resident_entry_is_remeasured_for_a_fresh_settled_render_session() {
let mut cache = Cache::default();
let old_session = RenderSession::after_fonts_settled_and_renderer_reset().unwrap();
let text = "call · Pressure verification";
let a = cache
.resolve(&old_session, text, 100., 1., Style::default())
.unwrap();
assert_eq!(cache.entries.len(), 1);
assert!(Arc::ptr_eq(&cache.entries.front().unwrap().label, &a));
let measured = cache.allocations;
assert!(measured > 0);
assert!(Arc::ptr_eq(
&a,
&cache
.resolve(&old_session, text, 100., 1., Style::default())
.unwrap()
));
assert_eq!(cache.allocations, measured);
// A fresh renderer session invalidates a still-resident entry. This does
// not mutate global fonts or purport to test dynamic WGPU font loading.
let new_session = RenderSession::after_fonts_settled_and_renderer_reset().unwrap();
let refreshed = cache
.resolve(&new_session, text, 100., 1., Style::default())
.unwrap();
assert!(!Arc::ptr_eq(&a, &refreshed));
assert!(cache.allocations > measured);
assert_eq!(cache.entries.len(), 1);
assert!(Arc::ptr_eq(
&cache.entries.front().unwrap().label,
&refreshed
));
}
#[test]
fn resident_entry_remeasures_after_local_font_epoch_invalidation() {
let mut cache = Cache::default();
let session = RenderSession::after_fonts_settled_and_renderer_reset().unwrap();
let text = "call · Resident pressure label";
let a = cache
.resolve(&session, text, 100., 1., Style::default())
.unwrap();
assert_eq!(cache.entries.len(), 1);
assert!(Arc::ptr_eq(&cache.entries.front().unwrap().label, &a));
let measured = cache.allocations;
assert!(measured > 0);
// Exercise the local memo epoch branch while a is still resident. No
// intervening width/zoom/style resolution can cause incidental eviction.
// This is not an actual font-load or renderer-cache acceptance test.
cache.version = None;
let refreshed = cache
.resolve(&session, text, 100., 1., Style::default())
.unwrap();
assert!(!Arc::ptr_eq(&a, &refreshed));
assert!(cache.allocations > measured);
assert_eq!(cache.entries.len(), 1);
assert!(Arc::ptr_eq(
&cache.entries.front().unwrap().label,
&refreshed
));
}
#[test]
fn retained_artifact_rejects_other_zoom_and_render_session_for_every_consumer() {
let mut cache = Cache::default();
let session = RenderSession::after_fonts_settled_and_renderer_reset().unwrap();
let label = cache
.resolve(&session, "Operation: Pressure", 200., 1., Style::default())
.unwrap();
let other = RenderSession::after_fonts_settled_and_renderer_reset().unwrap();
for (context, zoom, expected) in [
(&session, 0.66, Error::StaleZoom),
(&other, 1., Error::StaleRenderSession),
] {
assert_eq!(label.displayed(context, zoom).unwrap_err(), expected);
assert_eq!(label.retained_source(context, zoom).unwrap_err(), expected);
assert_eq!(
label.retains_any(context, zoom, 0..9).unwrap_err(),
expected
);
assert_eq!(
label.bounds_at(context, zoom, Point::ORIGIN).unwrap_err(),
expected
);
assert_eq!(
label
.canvas_text(context, zoom, Point::ORIGIN, Color::BLACK)
.unwrap_err(),
expected
);
}
assert!(
label
.canvas_text(&session, 1., Point::ORIGIN, Color::BLACK)
.is_ok()
);
}
#[test]
fn finite_scaled_metrics_cannot_escape_as_infinite_world_dimensions() {
let mut cache = Cache::default();
let session = RenderSession::after_fonts_settled_and_renderer_reset().unwrap();
let style = Style {
size: Pixels(2.),
line_height: LineHeight::Relative(f32::MAX),
..Style::default()
};
// At zoom 0.5 the scaled size=1 and line height=MAX are finite. The
// required world line height is not; reject before passing it to shaping.
let scaled = style.scaled(0.5);
assert!(scaled.0.is_finite() && scaled.1.is_finite());
assert_eq!(
cache
.resolve(&session, "call", 100., 0.5, style)
.unwrap_err(),
Error::InvalidMetrics
);
assert_eq!(cache.allocations, 0);
assert!(cache.entries.is_empty());
// Independently exercise validation after division for actual measured
// dimensions: finite scaled width/height do not imply finite world values.
assert_eq!(
world_size(Size::new(f32::MAX, 1.), 0.5),
Err(Error::InvalidMeasurement)
);
assert_eq!(
world_size(Size::new(1., f32::MAX), 0.5),
Err(Error::InvalidMeasurement)
);
assert_eq!(
world_size(Size::new(20., 8.), 0.5).unwrap(),
Size::new(40., 16.)
);
}
#[test]
fn malformed_metrics_and_explicit_experiment_budgets_fail_without_a_cached_candidate() {
let mut cache = Cache::default();
let session = RenderSession::after_fonts_settled_and_renderer_reset().unwrap();
for (width, zoom) in [(f32::NAN, 1.), (-1., 1.), (100., 0.), (100., f32::INFINITY)] {
assert_eq!(
cache
.resolve(&session, "call", width, zoom, Style::default())
.unwrap_err(),
Error::InvalidMetrics
);
}
assert_eq!(
cache
.resolve(
&session,
&"x".repeat(MAX_SOURCE_BYTES + 1),
100.,
1.,
Style::default()
)
.unwrap_err(),
Error::SourceBudget
);
assert_eq!(
cache
.resolve(
&session,
&"x".repeat(MAX_GRAPHEMES + 1),
100.,
1.,
Style::default()
)
.unwrap_err(),
Error::GraphemeBudget
);
assert!(cache.entries.is_empty());
}
#[test]
fn bounds_translation_rejects_nonfinite_world_positions_and_endpoints() {
let mut cache = Cache::default();
let session = RenderSession::after_fonts_settled_and_renderer_reset().unwrap();
let label = cache
.resolve(&session, "call", 100., 1., Style::default())
.unwrap();
for position in [Point::new(f32::NAN, 0.), Point::new(0., f32::INFINITY)] {
assert_eq!(
label.bounds_at(&session, 1., position).unwrap_err(),
Error::InvalidPosition
);
assert_eq!(
label
.canvas_text(&session, 1., position, Color::BLACK)
.unwrap_err(),
Error::InvalidPosition
);
}
assert_eq!(
translated_bounds(Point::new(f32::MAX, 0.), Size::new(f32::MAX, 1.)),
Err(Error::InvalidPosition)
);
assert_eq!(
translated_bounds(Point::new(0., f32::MAX), Size::new(1., f32::MAX)),
Err(Error::InvalidPosition)
);
assert_eq!(
label
.bounds_at(&session, 1., Point::new(-20., -10.))
.unwrap()
.position(),
Point::new(-20., -10.)
);
}
/// Must run as the ONLY selected test in a dedicated, fresh test process.
/// The ignored marker keeps this global-font mutation out of normal parallel
/// desktop tests. See the evidence directory's exact executable runner.
#[test]
#[ignore = "mutates the global font database; run only this exact test in a fresh process"]
fn actual_font_load_rejects_retained_label_and_old_session() {
use graphics_text::cosmic_text::fontdb;
use std::borrow::Cow;
let mut cache = Cache::default();
let session = RenderSession::after_fonts_settled_and_renderer_reset().unwrap();
let source = "Operation: Pressure";
let label = cache
.resolve(&session, source, 200., 1., Style::default())
.unwrap();
assert_eq!(label.displayed(&session, 1.).unwrap(), source);
assert!(
label
.canvas_text(&session, 1., Point::ORIGIN, Color::BLACK)
.is_ok()
);
let measured = cache.allocations;
assert!(measured > 0);
assert!(Arc::ptr_eq(&cache.entries.front().unwrap().label, &label));
let (version_before, version_after, faces_before, faces_after) = {
let mut fonts = graphics_text::font_system().write().unwrap();
let face = fonts
.raw()
.db()
.faces()
.find(|face| {
face.families
.iter()
.any(|(family, _)| family == "Iced-Icons")
})
.expect("Iced 0.14 embeds and loads its known Iced-Icons.ttf face")
.id;
let bytes = fonts
.raw()
.db()
.with_face_data(face, |data, _| data.to_vec())
.expect("the built-in binary font has accessible bytes");
assert!(!bytes.is_empty());
// load_font increments Version even for invalid bytes. Independently
// establish actual face admission so this cannot pass with invalid data.
let mut probe = fontdb::Database::new();
probe.load_font_data(bytes.clone());
let admitted = probe
.faces()
.filter(|face| {
face.families
.iter()
.any(|(family, _)| family == "Iced-Icons")
})
.count();
assert!(
admitted > 0,
"the extracted bytes parse as the expected real font"
);
let before = fonts.version();
assert_eq!(before, session.version);
let count = fonts.raw().db().faces().count();
// Owned bytes intentionally bypass Borrowed-address deduplication.
// This is an actual valid duplicate-face load, not a claim the chosen
// face or rendered metrics change; the stale-epoch rule is the oracle.
fonts.load_font(Cow::Owned(bytes));
(
before,
fonts.version(),
count,
fonts.raw().db().faces().count(),
)
};
assert_ne!(version_before, version_after);
assert!(
faces_after > faces_before,
"actual font admission must accompany the version increment"
);
assert_eq!(
label.displayed(&session, 1.).unwrap_err(),
Error::FontSetChanged
);
assert_eq!(
label.retained_source(&session, 1.).unwrap_err(),
Error::FontSetChanged
);
assert_eq!(
label.retains_any(&session, 1., 0..9).unwrap_err(),
Error::FontSetChanged
);
assert_eq!(
label.bounds_at(&session, 1., Point::ORIGIN).unwrap_err(),
Error::FontSetChanged
);
assert_eq!(
label
.canvas_text(&session, 1., Point::ORIGIN, Color::BLACK)
.unwrap_err(),
Error::FontSetChanged
);
assert_eq!(
cache
.resolve(&session, source, 200., 1., Style::default())
.unwrap_err(),
Error::FontSetChanged
);
assert_eq!(
cache.allocations, measured,
"an invalid session must not remeasure and imply renderer repair"
);
println!(
"Valid Iced-Icons load: faces {faces_before}->{faces_after}, font Version {version_before:?}->{version_after:?}; old label and session rejected"
);
// Do not create a fresh session or claim dynamic WGPU cache recovery here.
// Process exit is the isolation boundary; no other test may run afterward.
}