//! 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, bounds: Rectangle, } #[derive(Debug, Clone)] struct ParagraphPaint { content: String, glyphs: Vec, clip: Rectangle, } #[derive(Debug)] struct EditorPaint { glyphs: Vec, clip: Rectangle, } #[derive(Debug)] struct QuadPaint { bounds: Rectangle, clip: Rectangle, layer_depth: usize, background: Background, } #[derive(Debug, Default)] struct Frame { editors: Vec, paragraphs: Vec, quads: Vec, } 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::>().join("\n") } fn decoration_quads(&self) -> impl Iterator { 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, clips: Vec, } 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 { 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::>().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) { 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) + 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 = ::ICON_FONT; const CHECKMARK_ICON: char = ::CHECKMARK_ICON; const ARROW_DOWN_ICON: char = ::ARROW_DOWN_ICON; const SCROLL_UP_ICON: char = ::SCROLL_UP_ICON; const SCROLL_DOWN_ICON: char = ::SCROLL_DOWN_ICON; const SCROLL_LEFT_ICON: char = ::SCROLL_LEFT_ICON; const SCROLL_RIGHT_ICON: char = ::SCROLL_RIGHT_ICON; const ICED_LOGO: char = ::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, 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, source: Arc, path: String, diagnostics: Vec, publication: Option, hover_enabled: bool, tree: core_widget::Tree, clock: Arc, width: f32, height: f32, viewport: Option, } 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(&mut self, visit: impl FnOnce( &mut dyn Widget, &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::(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, 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 { struct Focus { set: Option, found: Option } 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, cursor: text_editor::Cursor, scroll: (usize, f32), focused: bool, clock: u64, } fn passive(mounted: &mut Mounted, point: Option) -> 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"); } }