Preserve raw include diagnostic provenance and derive bands from the existing shaped editor. Retain deferred tooltip paragraphs through rendering. Include source, regression, failure and native-trial evidence; corrected native acceptance remains pending.
539 lines
25 KiB
Rust
539 lines
25 KiB
Rust
//! PRIVATE prepared tests. Parent may copy to source_annotations/widget/tests.rs
|
|
//! and register `#[cfg(all(test, debug_assertions))] mod tests;` in widget.rs.
|
|
//! No build or execution was performed while preparing this file.
|
|
//!
|
|
//! Actual Decorations + TextEditor + existing raw-input observer, one retained
|
|
//! Tree, graphics Editor/Paragraph, ID-based focus Operation and emitted editor
|
|
//! actions. The recorder observes actual fill_editor clusters, renderer layers,
|
|
//! and shaped tooltip paragraphs. No private editor-state indexing or fake
|
|
//! character-width metrics. Double/triple click events are batched through the
|
|
//! actual widget, then their emitted actions are applied in order to Content.
|
|
//!
|
|
//! Limits: CPU layout/advance coverage, not raster ink/GPU/native/IME, real App
|
|
//! publication admission, menu controller, or the production sensor/view tree.
|
|
//! Snapshot and hover_enabled are explicit widget-boundary inputs. Word/Line
|
|
//! insertion compares a hovered path with an actual direct-typing control.
|
|
//! Footer expectations target the parent's measured-height/remaining-count fix.
|
|
#![cfg(debug_assertions)]
|
|
|
|
use super::{Decorations, Snapshot};
|
|
use crate::views::source_tests::FontLayout;
|
|
use iced::advanced::{
|
|
Layout, Shell, Widget, graphics, layout, renderer,
|
|
text::{self as shaped, Paragraph as _},
|
|
widget as core_widget,
|
|
};
|
|
use iced::widget::text_editor::{self, Action, Content};
|
|
use iced::{
|
|
Background, Color, Element, Event, Font, Padding, Pixels, Point, Rectangle, Size, Theme,
|
|
Transformation, keyboard, mouse, window,
|
|
};
|
|
use oa_core::{
|
|
Id,
|
|
language::{Diagnostic, Span},
|
|
source_project::SourceDiagnostic,
|
|
validation::Severity,
|
|
};
|
|
use std::{
|
|
collections::BTreeSet,
|
|
ops::Range,
|
|
sync::{Arc, atomic::{AtomicU64, Ordering}},
|
|
};
|
|
|
|
const ORIGIN: Point = Point::new(23., 31.);
|
|
const PAD: f32 = 16.;
|
|
const EDITOR_ID: &str = "annotation-wrapper-editor";
|
|
const ROW: &str = "alpha beta gamma\n";
|
|
|
|
#[derive(Debug, Clone)]
|
|
struct Glyph {
|
|
line: usize,
|
|
bytes: Range<usize>,
|
|
bounds: Rectangle,
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
struct ParagraphPaint {
|
|
content: String,
|
|
glyphs: Vec<Glyph>,
|
|
clip: Rectangle,
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
struct EditorPaint {
|
|
glyphs: Vec<Glyph>,
|
|
clip: Rectangle,
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
struct QuadPaint {
|
|
bounds: Rectangle,
|
|
clip: Rectangle,
|
|
layer_depth: usize,
|
|
background: Background,
|
|
}
|
|
|
|
#[derive(Debug, Default)]
|
|
struct Frame {
|
|
editors: Vec<EditorPaint>,
|
|
paragraphs: Vec<ParagraphPaint>,
|
|
quads: Vec<QuadPaint>,
|
|
}
|
|
|
|
impl Frame {
|
|
fn editor(&self) -> &EditorPaint {
|
|
assert_eq!(self.editors.len(), 1, "one actual fill_editor per frame");
|
|
&self.editors[0]
|
|
}
|
|
fn point(&self, line: usize, byte: usize) -> Point {
|
|
let editor = self.editor();
|
|
let glyph = editor.glyphs.iter().find(|g| {
|
|
g.line == line && g.bytes.contains(&byte) && inside(g.bounds, editor.clip)
|
|
}).expect("requested fixture cluster must be genuinely visible");
|
|
Point::new(
|
|
glyph.bounds.x + glyph.bounds.width / 2.,
|
|
glyph.bounds.y + glyph.bounds.height / 2.,
|
|
)
|
|
}
|
|
fn tooltip(&self) -> String {
|
|
self.paragraphs.iter().map(|p| p.content.as_str()).collect::<Vec<_>>().join("\n")
|
|
}
|
|
fn decoration_quads(&self) -> impl Iterator<Item = &QuadPaint> {
|
|
self.quads.iter().filter(|q| q.layer_depth > 0)
|
|
}
|
|
}
|
|
|
|
fn inside(bounds: Rectangle, clip: Rectangle) -> bool {
|
|
const EPS: f32 = 0.05;
|
|
bounds.x >= clip.x - EPS && bounds.y >= clip.y - EPS
|
|
&& bounds.x + bounds.width <= clip.x + clip.width + EPS
|
|
&& bounds.y + bounds.height <= clip.y + clip.height + EPS
|
|
}
|
|
|
|
struct Recorder {
|
|
frame: Frame,
|
|
transforms: Vec<Transformation>,
|
|
clips: Vec<Rectangle>,
|
|
}
|
|
|
|
impl Recorder {
|
|
fn new(viewport: Rectangle) -> Self {
|
|
Self { frame: Frame::default(), transforms: vec![Transformation::IDENTITY], clips: vec![viewport] }
|
|
}
|
|
fn transform(&self) -> Transformation { *self.transforms.last().unwrap() }
|
|
fn clip(&self, bounds: Rectangle) -> Rectangle {
|
|
self.clips.last().unwrap().intersection(&(bounds * self.transform()))
|
|
.unwrap_or(Rectangle::with_size(Size::ZERO))
|
|
}
|
|
fn glyphs(&self, buffer: &graphics::text::cosmic_text::Buffer, origin: Point) -> Vec<Glyph> {
|
|
buffer.layout_runs().flat_map(|run| {
|
|
run.glyphs.iter().map(move |glyph| Glyph {
|
|
line: run.line_i,
|
|
bytes: glyph.start..glyph.end,
|
|
// Actual shaped advances and line bands, not estimated ink.
|
|
bounds: Rectangle::new(
|
|
Point::new(origin.x + glyph.x, origin.y + run.line_top),
|
|
Size::new(glyph.w, run.line_height),
|
|
) * self.transform(),
|
|
})
|
|
}).collect()
|
|
}
|
|
fn paragraph(&mut self, paragraph: &graphics::text::Paragraph, origin: Point, clip: Rectangle) {
|
|
let buffer = paragraph.buffer();
|
|
self.frame.paragraphs.push(ParagraphPaint {
|
|
content: buffer.lines.iter().map(|line| line.text()).collect::<Vec<_>>().join("\n"),
|
|
glyphs: self.glyphs(buffer, origin),
|
|
clip: self.clip(clip),
|
|
});
|
|
}
|
|
}
|
|
|
|
impl renderer::Renderer for Recorder {
|
|
fn start_layer(&mut self, bounds: Rectangle) { self.clips.push(self.clip(bounds)); }
|
|
fn end_layer(&mut self) { assert!(self.clips.len() > 1); self.clips.pop(); }
|
|
fn start_transformation(&mut self, value: Transformation) {
|
|
self.transforms.push(self.transform() * value);
|
|
}
|
|
fn end_transformation(&mut self) { assert!(self.transforms.len() > 1); self.transforms.pop(); }
|
|
fn reset(&mut self, bounds: Rectangle) { *self = Self::new(bounds); }
|
|
fn fill_quad(&mut self, quad: renderer::Quad, background: impl Into<Background>) {
|
|
self.frame.quads.push(QuadPaint {
|
|
bounds: quad.bounds * self.transform(),
|
|
clip: *self.clips.last().unwrap(),
|
|
layer_depth: self.clips.len() - 1,
|
|
background: background.into(),
|
|
});
|
|
}
|
|
fn allocate_image(
|
|
&mut self, handle: &iced::advanced::image::Handle,
|
|
callback: impl FnOnce(Result<iced::advanced::image::Allocation, iced::advanced::image::Error>) + Send + 'static,
|
|
) { renderer::Renderer::allocate_image(&mut FontLayout, handle, callback); }
|
|
}
|
|
|
|
impl shaped::Renderer for Recorder {
|
|
type Font = Font;
|
|
type Paragraph = graphics::text::Paragraph;
|
|
type Editor = graphics::text::Editor;
|
|
const ICON_FONT: Font = <FontLayout as shaped::Renderer>::ICON_FONT;
|
|
const CHECKMARK_ICON: char = <FontLayout as shaped::Renderer>::CHECKMARK_ICON;
|
|
const ARROW_DOWN_ICON: char = <FontLayout as shaped::Renderer>::ARROW_DOWN_ICON;
|
|
const SCROLL_UP_ICON: char = <FontLayout as shaped::Renderer>::SCROLL_UP_ICON;
|
|
const SCROLL_DOWN_ICON: char = <FontLayout as shaped::Renderer>::SCROLL_DOWN_ICON;
|
|
const SCROLL_LEFT_ICON: char = <FontLayout as shaped::Renderer>::SCROLL_LEFT_ICON;
|
|
const SCROLL_RIGHT_ICON: char = <FontLayout as shaped::Renderer>::SCROLL_RIGHT_ICON;
|
|
const ICED_LOGO: char = <FontLayout as shaped::Renderer>::ICED_LOGO;
|
|
fn default_font(&self) -> Font { shaped::Renderer::default_font(&FontLayout) }
|
|
fn default_size(&self) -> Pixels { shaped::Renderer::default_size(&FontLayout) }
|
|
fn fill_paragraph(&mut self, paragraph: &Self::Paragraph, origin: Point, _: Color, clip: Rectangle) {
|
|
self.paragraph(paragraph, origin, clip);
|
|
}
|
|
fn fill_editor(&mut self, editor: &Self::Editor, origin: Point, _: Color, clip: Rectangle) {
|
|
self.frame.editors.push(EditorPaint { glyphs: self.glyphs(editor.buffer(), origin), clip: self.clip(clip) });
|
|
}
|
|
fn fill_text(&mut self, value: shaped::Text, origin: Point, _: Color, clip: Rectangle) {
|
|
let paragraph = graphics::text::Paragraph::with_text(value.as_ref());
|
|
self.paragraph(¶graph, origin, clip);
|
|
}
|
|
}
|
|
|
|
fn diagnostic(path: &str, bytes: Range<usize>, code: &str, severity: Severity) -> SourceDiagnostic {
|
|
SourceDiagnostic {
|
|
path: path.into(),
|
|
diagnostic: Diagnostic {
|
|
severity, code: code.into(), message: format!("Detail {code} 雨"),
|
|
object_id: None, field: None, line: 1, column: 1,
|
|
span: Span { start: bytes.start, end: bytes.end },
|
|
},
|
|
related: Vec::new(),
|
|
}
|
|
}
|
|
|
|
struct Mounted {
|
|
content: Content<Recorder>,
|
|
source: Arc<str>,
|
|
path: String,
|
|
diagnostics: Vec<SourceDiagnostic>,
|
|
publication: Option<Id>,
|
|
hover_enabled: bool,
|
|
tree: core_widget::Tree,
|
|
clock: Arc<AtomicU64>,
|
|
width: f32,
|
|
height: f32,
|
|
viewport: Option<Rectangle>,
|
|
}
|
|
|
|
impl Mounted {
|
|
fn new(source: &str) -> Self {
|
|
Self {
|
|
content: Content::with_text(source), source: Arc::from(source), path: "a".into(),
|
|
diagnostics: vec![diagnostic("a", 0..5, "A", Severity::Warning), diagnostic("a", 6..10, "B", Severity::Error)],
|
|
publication: Some(Id::new_v4()), hover_enabled: true, tree: core_widget::Tree::empty(),
|
|
clock: Arc::new(AtomicU64::new(0)), width: 552., height: 392., viewport: None,
|
|
}
|
|
}
|
|
fn with_widget<T>(&mut self, visit: impl FnOnce(
|
|
&mut dyn Widget<Action, Theme, Recorder>, &mut core_widget::Tree,
|
|
Layout<'_>, &mut Recorder, &Rectangle,
|
|
) -> T) -> T {
|
|
let snapshot = self.publication.map(|publication| Snapshot {
|
|
publication, source: self.source.clone(), path: &self.path, diagnostics: &self.diagnostics,
|
|
});
|
|
let editor = iced::widget::text_editor(&self.content)
|
|
.id(EDITOR_ID).on_action(|action| action).font(Font::MONOSPACE).size(14)
|
|
.padding(PAD).width(self.width).height(self.height)
|
|
.highlight_with::<crate::highlight::ArcadiaHighlighter>(self.source.clone(), crate::highlight::format);
|
|
let decorated = Decorations::new(editor, &self.content, snapshot, Padding::new(PAD), self.hover_enabled);
|
|
let mut view: Element<'_, Action, Theme, Recorder> =
|
|
crate::source_navigation::interaction::observe(decorated.into(), self.clock.clone());
|
|
self.tree.diff(view.as_widget());
|
|
let bounds = Rectangle::new(ORIGIN, Size::new(self.width, self.height));
|
|
let viewport = self.viewport.unwrap_or(bounds);
|
|
let mut recorder = Recorder::new(viewport);
|
|
let node = view.as_widget_mut().layout(&mut self.tree, &recorder,
|
|
&layout::Limits::new(Size::ZERO, bounds.size())).move_to(ORIGIN);
|
|
assert_eq!(Layout::new(&node).bounds(), bounds);
|
|
visit(view.as_widget_mut(), &mut self.tree, Layout::new(&node), &mut recorder, &viewport)
|
|
}
|
|
fn draw(&mut self, cursor: mouse::Cursor) -> Frame {
|
|
self.with_widget(|widget, tree, layout, recorder, viewport| {
|
|
widget.draw(tree, recorder, &Theme::Light, &renderer::Style::default(), layout, cursor, viewport);
|
|
assert_eq!(recorder.clips.len(), 1, "balanced renderer layers");
|
|
assert_eq!(recorder.transforms.len(), 1, "balanced renderer transformations");
|
|
std::mem::take(&mut recorder.frame)
|
|
})
|
|
}
|
|
fn event(&mut self, event: &Event, cursor: mouse::Cursor) -> (Vec<Action>, bool, window::RedrawRequest) {
|
|
self.with_widget(|widget, tree, layout, renderer, viewport| {
|
|
let mut messages = Vec::new();
|
|
let (captured, redraw) = {
|
|
let mut shell = Shell::new(&mut messages);
|
|
widget.update(tree, event, layout, cursor, renderer, &mut iced::advanced::clipboard::Null, &mut shell, viewport);
|
|
(shell.is_event_captured(), shell.redraw_request())
|
|
};
|
|
(messages, captured, redraw)
|
|
})
|
|
}
|
|
fn inspect_focus(&mut self, set: Option<bool>) -> bool {
|
|
struct Focus { set: Option<bool>, found: Option<bool> }
|
|
impl core_widget::Operation for Focus {
|
|
fn traverse(&mut self, visit: &mut dyn FnMut(&mut dyn core_widget::Operation)) { visit(self); }
|
|
fn focusable(&mut self, id: Option<&core_widget::Id>, _: Rectangle, state: &mut dyn core_widget::operation::Focusable) {
|
|
if id == Some(&core_widget::Id::new(EDITOR_ID)) {
|
|
assert!(self.found.is_none(), "duplicate editor ID");
|
|
if let Some(focus) = self.set {
|
|
if focus { state.focus(); } else { state.unfocus(); }
|
|
}
|
|
self.found = Some(state.is_focused());
|
|
}
|
|
}
|
|
}
|
|
let mut operation = Focus { set, found: None };
|
|
self.with_widget(|widget, tree, layout, renderer, _| widget.operate(tree, layout, renderer, &mut operation));
|
|
operation.found.expect("actual editor is reachable by ID through all wrappers")
|
|
}
|
|
fn state(&mut self) -> ContentState {
|
|
let focused = self.inspect_focus(None);
|
|
ContentState {
|
|
text: self.content.text(), selected: self.content.selection(), cursor: self.content.cursor(),
|
|
scroll: self.content.with_editor(|e| (e.buffer().scroll().line, e.buffer().scroll().vertical)),
|
|
focused, clock: self.clock.load(Ordering::SeqCst),
|
|
}
|
|
}
|
|
fn gesture(&mut self, point: Point, clicks: usize) {
|
|
let messages = self.with_widget(|widget, tree, layout, renderer, viewport| {
|
|
let mut messages = Vec::new();
|
|
for _ in 0..clicks {
|
|
for event in [Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)), Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left))] {
|
|
let mut shell = Shell::new(&mut messages);
|
|
widget.update(tree, &event, layout, mouse::Cursor::Available(point), renderer,
|
|
&mut iced::advanced::clipboard::Null, &mut shell, viewport);
|
|
}
|
|
}
|
|
messages
|
|
});
|
|
assert_eq!(messages.len(), clicks);
|
|
assert!(matches!(messages[0], Action::Click(_)));
|
|
assert!(matches!(messages[1], Action::SelectWord));
|
|
if clicks == 3 { assert!(matches!(messages[2], Action::SelectLine)); }
|
|
for message in messages { self.content.perform(message); }
|
|
assert!(self.inspect_focus(None));
|
|
}
|
|
fn type_x(&mut self) {
|
|
let event = Event::Keyboard(keyboard::Event::KeyPressed {
|
|
key: keyboard::Key::Character("x".into()), modified_key: keyboard::Key::Character("x".into()),
|
|
physical_key: keyboard::key::Physical::Code(keyboard::key::Code::KeyX),
|
|
location: keyboard::Location::Standard, modifiers: keyboard::Modifiers::empty(),
|
|
text: Some("X".into()), repeat: false,
|
|
});
|
|
let (messages, captured, _) = self.event(&event, mouse::Cursor::Unavailable);
|
|
assert!(captured);
|
|
assert_eq!(messages.len(), 1);
|
|
assert!(matches!(messages[0], Action::Edit(text_editor::Edit::Insert('X'))));
|
|
for action in messages { self.content.perform(action); }
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, PartialEq)]
|
|
struct ContentState {
|
|
text: String,
|
|
selected: Option<String>,
|
|
cursor: text_editor::Cursor,
|
|
scroll: (usize, f32),
|
|
focused: bool,
|
|
clock: u64,
|
|
}
|
|
|
|
fn passive(mounted: &mut Mounted, point: Option<Point>) -> Frame {
|
|
let before = mounted.state();
|
|
let (event, cursor) = match point {
|
|
Some(point) => (Event::Mouse(mouse::Event::CursorMoved { position: point }), mouse::Cursor::Available(point)),
|
|
None => (Event::Mouse(mouse::Event::CursorLeft), mouse::Cursor::Unavailable),
|
|
};
|
|
let (messages, captured, redraw) = mounted.event(&event, cursor);
|
|
assert!(messages.is_empty(), "hover must not synthesize editor actions");
|
|
assert!(!captured, "hover must not consume ordinary pointer movement");
|
|
assert_eq!(redraw, window::RedrawRequest::NextFrame);
|
|
let frame = mounted.draw(cursor);
|
|
assert_eq!(mounted.state(), before, "hover must preserve exact content, hidden selection, focus, scroll and raw clock");
|
|
frame
|
|
}
|
|
|
|
#[test]
|
|
fn pointer_motion_between_bands_blank_and_leave_is_passive_and_always_redraws() {
|
|
let mut mounted = Mounted::new(&ROW.repeat(24));
|
|
mounted.diagnostics = (0..24).flat_map(|line| {
|
|
let start = line * ROW.len();
|
|
[diagnostic("a", start..start + 5, "A", Severity::Warning), diagnostic("a", start + 6..start + 10, "B", Severity::Error)]
|
|
}).collect();
|
|
assert!(mounted.inspect_focus(Some(true)));
|
|
mounted.draw(mouse::Cursor::Unavailable);
|
|
mounted.content.perform(Action::Scroll { lines: 2 });
|
|
let frame = mounted.draw(mouse::Cursor::Unavailable);
|
|
let line = frame.editor().glyphs.iter().find(|g| g.bytes.contains(&2) && inside(g.bounds, frame.editor().clip)).unwrap().line;
|
|
assert!(mounted.state().scroll != (0, 0.));
|
|
let a = frame.point(line, 2);
|
|
let b = frame.point(line, 7);
|
|
let clip = frame.editor().clip;
|
|
let blank = Point::new(clip.x + clip.width - 3., a.y);
|
|
let frame_a = passive(&mut mounted, Some(a));
|
|
assert!(frame_a.tooltip().contains("Detail A 雨"));
|
|
assert!(!frame_a.tooltip().contains("Detail B 雨"));
|
|
let frame_b = passive(&mut mounted, Some(b));
|
|
assert!(frame_b.tooltip().contains("Detail B 雨"));
|
|
assert!(!frame_b.tooltip().contains("Detail A 雨"));
|
|
assert!(passive(&mut mounted, Some(blank)).paragraphs.is_empty());
|
|
assert!(passive(&mut mounted, None).paragraphs.is_empty());
|
|
// Unfocused motion also requests repaint, without depending on caret blink.
|
|
assert!(!mounted.inspect_focus(Some(false)));
|
|
assert!(passive(&mut mounted, Some(a)).tooltip().contains("Detail A 雨"));
|
|
}
|
|
|
|
#[test]
|
|
fn actual_word_and_line_selection_then_typing_survive_hover_and_publication_changes() {
|
|
for clicks in [2, 3] {
|
|
let run = |hover: bool| {
|
|
let mut mounted = Mounted::new("alpha beta gamma\r\nsecond line 雨\r\n");
|
|
let frame = mounted.draw(mouse::Cursor::Unavailable);
|
|
let point = frame.point(0, 2);
|
|
mounted.gesture(point, clicks);
|
|
let selected = if clicks == 2 { "alpha" } else { "alpha beta gamma" };
|
|
assert_eq!(mounted.content.selection().as_deref(), Some(selected));
|
|
mounted.draw(mouse::Cursor::Unavailable);
|
|
let before = mounted.state();
|
|
if hover {
|
|
passive(&mut mounted, Some(point));
|
|
mounted.publication = None;
|
|
assert!(mounted.draw(mouse::Cursor::Available(point)).paragraphs.is_empty());
|
|
mounted.publication = Some(Id::new_v4());
|
|
assert!(mounted.draw(mouse::Cursor::Available(point)).tooltip().contains("Detail A"));
|
|
passive(&mut mounted, None);
|
|
assert_eq!(mounted.state(), before);
|
|
}
|
|
mounted.type_x();
|
|
mounted.content.text()
|
|
};
|
|
let control = run(false);
|
|
let hovered = run(true);
|
|
let expected = if clicks == 2 { "X beta gamma\r\nsecond line 雨\r\n" } else { "X\r\nsecond line 雨\r\n" };
|
|
assert_eq!(control, expected, "actual direct-typing control");
|
|
assert_eq!(hovered, expected, "hover must preserve the effective selected bytes, not only raw cursor endpoints");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn stationary_pointer_replacement_absence_and_path_switch_use_only_current_index() {
|
|
let mut mounted = Mounted::new(ROW);
|
|
mounted.diagnostics = vec![diagnostic("a", 0..5, "A", Severity::Warning), diagnostic("b", 0..5, "OTHER_PATH", Severity::Error)];
|
|
assert!(mounted.inspect_focus(Some(true)));
|
|
let frame = mounted.draw(mouse::Cursor::Unavailable);
|
|
let alpha = frame.point(0, 2);
|
|
let beta = frame.point(0, 7);
|
|
let before = mounted.state();
|
|
assert!(mounted.draw(mouse::Cursor::Available(alpha)).tooltip().contains("Detail A"));
|
|
// Same publication and same source Arc; only the active logical path changes.
|
|
mounted.path = "b".into();
|
|
let other = mounted.draw(mouse::Cursor::Available(alpha));
|
|
assert!(other.tooltip().contains("OTHER_PATH"));
|
|
assert!(!other.tooltip().contains("Detail A"));
|
|
mounted.path = "a".into();
|
|
assert!(mounted.draw(mouse::Cursor::Available(alpha)).tooltip().contains("Detail A"));
|
|
// Same path/source Arc but a different publication moves the diagnostic.
|
|
mounted.publication = Some(Id::new_v4());
|
|
mounted.diagnostics = vec![diagnostic("a", 6..10, "NEW_BETA", Severity::Error)];
|
|
assert!(mounted.draw(mouse::Cursor::Available(alpha)).paragraphs.is_empty());
|
|
assert!(mounted.draw(mouse::Cursor::Available(beta)).tooltip().contains("NEW_BETA"));
|
|
mounted.publication = None;
|
|
let empty = mounted.draw(mouse::Cursor::Available(beta));
|
|
assert!(empty.paragraphs.is_empty());
|
|
assert_eq!(empty.decoration_quads().count(), 0);
|
|
mounted.publication = Some(Id::new_v4());
|
|
assert!(mounted.draw(mouse::Cursor::Available(beta)).tooltip().contains("NEW_BETA"));
|
|
assert_eq!(mounted.state(), before);
|
|
}
|
|
|
|
#[test]
|
|
fn disabled_hover_and_unavailable_cursor_hide_details_without_removing_current_marks() {
|
|
let mut mounted = Mounted::new(ROW);
|
|
let frame = mounted.draw(mouse::Cursor::Unavailable);
|
|
let point = frame.point(0, 2);
|
|
assert!(mounted.draw(mouse::Cursor::Available(point)).tooltip().contains("Detail A"));
|
|
let before = mounted.state();
|
|
mounted.hover_enabled = false;
|
|
let disabled = mounted.draw(mouse::Cursor::Available(point));
|
|
assert!(disabled.paragraphs.is_empty());
|
|
assert!(disabled.decoration_quads().count() > 0);
|
|
mounted.hover_enabled = true;
|
|
let unavailable = mounted.draw(mouse::Cursor::Unavailable);
|
|
assert!(unavailable.paragraphs.is_empty());
|
|
assert!(unavailable.decoration_quads().count() > 0);
|
|
assert_eq!(mounted.state(), before);
|
|
}
|
|
|
|
fn paragraph_complete(paint: &ParagraphPaint, clip: Rectangle) -> bool {
|
|
let lines: Vec<_> = paint.content.split('\n').collect();
|
|
let wanted: BTreeSet<_> = lines.iter().enumerate().flat_map(|(line, text)| {
|
|
text.char_indices().filter(|(_, c)| !c.is_whitespace()).map(move |(byte, _)| (line, byte))
|
|
}).collect();
|
|
let mut covered = BTreeSet::new();
|
|
for glyph in &paint.glyphs {
|
|
let Some(text) = lines.get(glyph.line) else { return false };
|
|
let Some(cluster) = text.get(glyph.bytes.clone()) else { return false };
|
|
if cluster.chars().all(char::is_whitespace) { continue; }
|
|
if !inside(glyph.bounds, clip) { return false; }
|
|
for (byte, character) in text.char_indices() {
|
|
if !character.is_whitespace() && glyph.bytes.contains(&byte) { covered.insert((glyph.line, byte)); }
|
|
}
|
|
}
|
|
!wanted.is_empty() && covered == wanted
|
|
}
|
|
|
|
#[test]
|
|
fn measured_footer_and_omitted_count_fit_actual_shaped_widths_and_ancestor_clip() {
|
|
for width in [100., 180., 520.] {
|
|
let mut mounted = Mounted::new(ROW);
|
|
mounted.width = width + PAD * 2.;
|
|
mounted.height = 432.;
|
|
mounted.diagnostics = (0..6).map(|n| {
|
|
let mut value = diagnostic("a", 0..5, &format!("OVERLAP_{n}"), if n == 0 { Severity::Warning } else { Severity::Error });
|
|
value.diagnostic.message = format!("Long 雨 message {}", "bounded details ".repeat(90));
|
|
value
|
|
}).collect();
|
|
let first = mounted.draw(mouse::Cursor::Unavailable);
|
|
let point = first.point(0, 2);
|
|
let frame = mounted.draw(mouse::Cursor::Available(point));
|
|
let footer = frame.paragraphs.iter().find(|p| p.content.contains("4 of 6 shown")).expect("overlap footer must exist at admitted test viewport");
|
|
assert!(footer.content.contains("2 more"));
|
|
assert!(footer.content.contains("Actions → Source problems"));
|
|
assert!(paragraph_complete(footer, footer.clip), "all shaped footer clusters must fit at width {width}: {footer:?}");
|
|
let body = frame.paragraphs.iter().find(|p| p.content.contains("OVERLAP_")).unwrap();
|
|
assert!(body.content.starts_with("Error [OVERLAP_1]"), "errors precede warning even when warning was published first");
|
|
assert!(!body.content.contains("OVERLAP_0"));
|
|
for quad in frame.decoration_quads() { assert!(inside(quad.bounds, quad.clip), "decoration outside ancestor layer: {quad:?}"); }
|
|
let warning = Background::Color(Theme::Light.palette().warning);
|
|
let error = Background::Color(Theme::Light.palette().danger);
|
|
let colors: Vec<_> = frame.decoration_quads().filter(|q| q.background == warning || q.background == error).map(|q| q.background).collect();
|
|
assert_eq!(colors.len(), 6);
|
|
assert_eq!(colors[0], warning, "warnings painted below overlapping errors");
|
|
assert!(colors[1..].iter().all(|color| *color == error));
|
|
if width == 100. {
|
|
// In-process rejected old budget: same actual shaped footer clipped
|
|
// to the old 26px allowance must fail our completeness evaluator.
|
|
assert!(footer.clip.height > 26.);
|
|
assert!(!paragraph_complete(footer, Rectangle { height: 26., ..footer.clip }));
|
|
}
|
|
// A genuinely narrower ancestor viewport, not a nominal child width.
|
|
let inset = if width > 100. { 9. } else { 0. };
|
|
mounted.viewport = Some(Rectangle::new(
|
|
Point::new(ORIGIN.x + PAD + inset, ORIGIN.y + PAD), Size::new(width - inset, 220.),
|
|
));
|
|
let clipped = mounted.draw(mouse::Cursor::Available(point));
|
|
for quad in clipped.decoration_quads() { assert!(inside(quad.bounds, quad.clip)); }
|
|
let footer = clipped.paragraphs.iter().find(|p| p.content.contains("4 of 6 shown")).expect("footer must remain usable at 220px effective height");
|
|
assert!(paragraph_complete(footer, footer.clip));
|
|
mounted.viewport = Some(Rectangle::new(
|
|
Point::new(ORIGIN.x + PAD, ORIGIN.y + PAD), Size::new(width, 60.),
|
|
));
|
|
assert!(mounted.draw(mouse::Cursor::Available(point)).paragraphs.is_empty(), "insufficient tooltip height is suppressed safely");
|
|
}
|
|
}
|