tests.rs
raw
//! What the parser makes of a document, and what survives being laid out.
use guiduck_core::text::TextContext;
use guiduck_scene::geom::{Point, Size};
use super::*;
const VIEW_WIDTH: f64 = 200.0;
/// A view laid out at `width`, in the sample font, exactly as the tree would
/// lay it out.
fn view(source: &str, width: f64) -> (MarkdownView, TextContext) {
let mut text_cx = guiduck_samples::sample_text_context();
let mut view = MarkdownView::new(source);
let _ = view.take_dirt();
view.finalize_layout(&mut text_cx, Size::new(width, 0.0), Size::ZERO);
(view, text_cx)
}
fn kinds(view: &MarkdownView) -> Vec<BlockKind> {
view.blocks().iter().map(|block| block.kind).collect()
}
const DOC: &str = "\
# Title
Some prose with a [link](home) in it.
## Sub *head*
```
fn main() {}
```
- one
- two
1. first
2. second
> quoted prose
> still quoted
---
Last.
";
#[test]
fn every_block_kind_in_scope_parses() {
let (view, _cx) = view(DOC, VIEW_WIDTH);
assert_eq!(
kinds(&view),
vec![
BlockKind::Heading(1),
BlockKind::Paragraph,
BlockKind::Heading(2),
BlockKind::Code,
BlockKind::ListItem,
BlockKind::ListItem,
BlockKind::ListItem,
BlockKind::ListItem,
BlockKind::Paragraph,
BlockKind::Rule,
BlockKind::Paragraph,
],
);
}
#[test]
fn a_heading_carries_its_level_and_its_size() {
let (view, _cx) = view(DOC, VIEW_WIDTH);
let blocks = view.blocks();
let h1 = blocks[0].body.as_ref().expect("a heading has text");
let h2 = blocks[2].body.as_ref().expect("a heading has text");
assert_eq!(h1.text(), "Title");
assert_eq!(h2.text(), "Sub head", "the emphasis is a span, not a glyph");
assert!(
h1.font_size() > h2.font_size(),
"the type scale came from the tokens, in level order",
);
assert!(
h2.font_size() > blocks[1].body.as_ref().unwrap().font_size(),
"and a sub-head is still bigger than body prose",
);
}
#[test]
fn a_code_block_keeps_its_lines_and_its_own_font() {
let (view, _cx) = view(DOC, VIEW_WIDTH);
let code = view.blocks()[3].body.as_ref().expect("code has text");
assert_eq!(code.text(), "fn main() {}", "the fence is not the program");
}
#[test]
fn a_list_numbers_itself_and_hangs_its_markers() {
let (view, _cx) = view(DOC, VIEW_WIDTH);
let blocks = view.blocks();
let markers: Vec<&str> = blocks
.iter()
.filter(|block| block.kind == BlockKind::ListItem)
.map(|block| block.marker.as_ref().expect("an item has a marker").text())
.collect();
assert_eq!(markers, vec!["\u{2022}", "\u{2022}", "1.", "2."]);
let item = blocks
.iter()
.find(|block| block.kind == BlockKind::ListItem)
.unwrap();
assert_eq!(item.list_depth, 1);
assert!(
item.marker_origin.x < item.body_origin.x,
"the marker sits in the gutter the body's indent opened",
);
}
#[test]
fn a_block_quote_carries_its_depth_and_indents_for_it() {
let (view, _cx) = view(DOC, VIEW_WIDTH);
let quote = view.blocks()[8].body.as_ref().expect("quoted prose");
assert_eq!(
quote.text(),
"quoted prose still quoted",
"a soft break inside a quote is a space, as it is anywhere else",
);
assert_eq!(view.blocks()[8].quote_depth, 1);
assert!(view.blocks()[8].indent > 0.0);
assert_eq!(view.blocks()[1].quote_depth, 0, "prose is not quoted");
}
#[test]
fn nested_quotes_and_lists_count_their_levels() {
let (view, _cx) = view("> > deep\n\n- a\n - b\n", VIEW_WIDTH);
let blocks = view.blocks();
assert_eq!(blocks[0].quote_depth, 2);
assert_eq!(blocks[1].list_depth, 1, "the outer item");
assert_eq!(blocks[2].list_depth, 2, "the nested one");
assert!(blocks[2].indent > blocks[1].indent);
}
#[test]
fn a_thematic_break_has_no_text_at_all() {
let (view, _cx) = view(DOC, VIEW_WIDTH);
let rule = &view.blocks()[9];
assert_eq!(rule.kind, BlockKind::Rule);
assert!(rule.body.is_none(), "a break has nothing to say");
assert!(rule.height > 0.0, "but it is still on the page");
}
#[test]
fn blocks_stack_without_overlapping() {
let (view, _cx) = view(DOC, VIEW_WIDTH);
for pair in view.blocks().windows(2) {
assert!(
pair[1].top >= pair[0].top + pair[0].height,
"{:?} overlaps {:?}",
pair[1].kind,
pair[0].kind,
);
}
}
const PROSE: &str = "Read the [documentation for widgets](guide) before you \
start, or ask on the [forum](forum).";
/// The claim the whole design rests on: an inline link is a byte range of the
/// block's prose, a line break does not move a byte, and so a link the wrap
/// splits is still one link — hittable on both of the lines it landed on.
#[test]
fn a_links_byte_range_survives_wrapping() {
// Narrow enough that "documentation for widgets" cannot fit on one line.
let (view, _cx) = view(PROSE, 120.0);
let block = &view.blocks()[0];
let body = block.body.as_ref().expect("prose");
assert!(body.line_count() > 1, "the paragraph wrapped");
let text = body.text();
let start = text.find("documentation").expect("the link's first word");
let end = text.find("widgets").expect("the link's last word") + "widgets".len();
let point_on = |byte: usize| {
body.byte_bounds(byte)
.expect("the byte is laid out")
.center()
+ block.body_origin.to_vec2()
};
let first = point_on(start);
let last = point_on(end - 1);
assert_ne!(
first.y, last.y,
"the link's own words landed on different lines",
);
assert_eq!(view.link_at(first), Some("guide"), "the first line's half");
assert_eq!(view.link_at(last), Some("guide"), "the second line's half");
// A second link in the same paragraph is its own answer, and prose between
// them is nobody's.
let forum = text.find("forum").expect("the second link");
assert_eq!(view.link_at(point_on(forum)), Some("forum"));
assert_eq!(view.link_at(point_on(0)), None, "\"Read\" is not a link");
}
/// A link in a *later* block is found in that block's coordinates, which is the
/// one thing stacking paragraphs adds over a `Text`.
#[test]
fn a_link_in_a_later_block_is_hit_in_its_own_coordinates() {
let (view, _cx) = view("# Title\n\nSee the [guide](guide).\n", 400.0);
let block = &view.blocks()[1];
assert!(block.top > 0.0, "the paragraph is below the heading");
let body = block.body.as_ref().unwrap();
let byte = body.text().find("guide").unwrap();
let point = body.byte_bounds(byte).unwrap().center() + block.body_origin.to_vec2();
assert_eq!(view.link_at(point), Some("guide"));
assert_eq!(
view.link_at(Point::new(point.x, 0.0)),
None,
"the same x in the heading's row is not the link",
);
}
#[test]
fn clicking_a_link_reports_its_target_as_on_link() {
let (mut view, mut text_cx) = view(PROSE, 400.0);
let block = &view.blocks()[0];
let body = block.body.as_ref().unwrap();
let byte = body.text().find("forum").unwrap();
let at = body.byte_bounds(byte).unwrap().center() + block.body_origin.to_vec2();
let event = PointerEvent {
window: at,
local: at,
button: Some(PointerButton::Left),
click_count: 1,
};
// A link follows on the *confirmed* single click (the settled burst), not
// the immediate one — so a double-click on it does not navigate.
let consumed = view.on_pointer(
EventKind::CountedClick,
&event,
&mut text_cx,
&mut guiduck_core::NoClipboard,
);
assert!(consumed, "a confirmed single click on a link is the link's");
assert_eq!(
view.take_emitted(),
vec![EventData::User {
name: "on-link".to_owned(),
payload: Some(UserValue::Str("forum".to_owned())),
}],
);
assert_eq!(
view.cursor(at),
guiduck_core::CursorShape::Pointer,
"and the hand said so first",
);
}
/// Every link is a node an assistive technology can find, name, and click —
/// across blocks, each in its own place on the page.
#[test]
fn every_link_is_announced_and_actionable() {
let (mut view, mut text_cx) = view(
"# T\n\nSee the [guide](guide) or the [forum](forum).",
400.0,
);
let mut node = accesskit::Node::new(accesskit::Role::Document);
let mut update = accesskit::TreeUpdate {
nodes: Vec::new(),
tree: None,
tree_id: accesskit::TreeId::ROOT,
focus: accesskit::NodeId(0),
};
let mut counter = 1_u64;
let mut next_id = || {
let id = accesskit::NodeId(counter);
counter += 1;
id
};
view.accessibility_extended(
&mut node,
&mut update,
&mut next_id,
Point::ORIGIN,
&mut text_cx,
);
assert_eq!(node.children().len(), 2, "a node per link");
let (id, link) = &update.nodes[0];
assert_eq!(link.role(), accesskit::Role::Link);
assert_eq!(link.label(), Some("guide"));
assert_eq!(link.url(), Some("guide"));
let bounds = link.bounds().expect("a link is somewhere on screen");
assert!(
bounds.y0 > 0.0,
"and its place is the block's, not the paragraph's own origin",
);
view.accessibility_action(*id, accesskit::Action::Click);
assert_eq!(
view.take_emitted(),
vec![EventData::User {
name: "on-link".to_owned(),
payload: Some(UserValue::Str("guide".to_owned())),
}],
);
}
const TABLE: &str = "\
| Name | Score |
| :--- | -----: |
| Alice | 10 |
| Bob | [profile](bob) |
";
#[test]
fn a_table_parses_into_an_aligned_grid() {
let (view, _cx) = view(TABLE, 300.0);
let blocks = view.blocks();
assert_eq!(blocks.len(), 1);
assert_eq!(blocks[0].kind, BlockKind::Table);
let table = blocks[0].table.as_ref().expect("a table block has a grid");
assert_eq!(table.columns(), 2);
assert_eq!(table.rows.len(), 3, "the header row plus two body rows");
assert_eq!(table.header_rows, 1);
assert_eq!(table.rows[0][0].body.text(), "Name");
assert_eq!(table.rows[0][1].body.text(), "Score");
assert_eq!(table.rows[1][0].body.text(), "Alice");
assert_eq!(
table.aligns,
vec![CellAlign::Start, CellAlign::End],
"the delimiter row's colons set the column alignment",
);
}
#[test]
fn a_table_places_a_non_overlapping_grid() {
let (view, _cx) = view(TABLE, 300.0);
let table = view.blocks()[0].table.as_ref().unwrap();
assert_eq!(table.col_edges.len(), 3, "two columns are three boundaries");
assert_eq!(table.row_edges.len(), 4, "three rows are four boundaries");
assert!(
table.col_edges.windows(2).all(|edge| edge[1] > edge[0]),
"columns advance left to right",
);
assert!(
table.row_edges.windows(2).all(|edge| edge[1] > edge[0]),
"rows advance top to bottom",
);
for (r, row) in table.rows.iter().enumerate() {
for (c, cell) in row.iter().enumerate() {
assert!(
cell.origin.x >= table.col_edges[c] && cell.origin.x < table.col_edges[c + 1],
"cell ({r},{c}) sits inside its column",
);
assert!(
cell.origin.y >= table.row_edges[r] && cell.origin.y < table.row_edges[r + 1],
"cell ({r},{c}) sits inside its row",
);
}
}
}
#[test]
fn an_end_aligned_cell_is_pushed_right() {
// The Score column is right-aligned, and "10" is narrower than the column,
// so it sits past a start-aligned cell would.
let (view, _cx) = view(TABLE, 300.0);
let table = view.blocks()[0].table.as_ref().unwrap();
let start_cell = &table.rows[1][0]; // "Alice", left-aligned
let end_cell = &table.rows[1][1]; // "10", right-aligned
let end_col_left = table.col_edges[1];
assert!(
end_cell.origin.x - end_col_left > start_cell.origin.x - table.col_edges[0],
"the right-aligned cell's text is inset further into its column",
);
}
#[test]
fn a_link_in_a_cell_is_hittable() {
let (view, _cx) = view(TABLE, 300.0);
let table = view.blocks()[0].table.as_ref().unwrap();
let cell = &table.rows[2][1];
assert_eq!(cell.body.text(), "profile");
// A point on the cell's link, in the widget's coordinates: the same
// link_at a whole paragraph answers, reached through the grid.
let local = cell
.body
.byte_bounds(0)
.expect("the link's first byte is laid out")
.center()
+ cell.origin.to_vec2();
assert_eq!(view.link_at(local), Some("bob"));
}
#[test]
fn plain_text_reads_a_table_by_rows() {
let (view, _cx) = view(TABLE, 300.0);
let text = view.plain_text();
assert!(
text.contains("Name\tScore"),
"header, tab-separated: {text:?}"
);
assert!(text.contains("Alice\t10"));
assert!(text.contains("Bob\tprofile"));
}
#[test]
fn a_table_does_not_overlap_its_neighbours() {
let (view, _cx) = view("before\n\n| a | b |\n|---|---|\n| 1 | 2 |\n\nafter", 300.0);
let kinds = kinds(&view);
assert_eq!(
kinds,
vec![BlockKind::Paragraph, BlockKind::Table, BlockKind::Paragraph],
);
for pair in view.blocks().windows(2) {
assert!(
pair[1].top >= pair[0].top + pair[0].height,
"{:?} overlaps {:?}",
pair[1].kind,
pair[0].kind,
);
}
}
/// A solid `width`×`height` image, so a test resolver can hand the widget real
/// pixels without a file on disk.
fn solid_image(width: u32, height: u32) -> guiduck_scene::paint::ImageBrush {
use guiduck_scene::paint::{Blob, ImageAlphaType, ImageBrush, ImageData, ImageFormat};
ImageBrush::from(ImageData {
data: Blob::new(std::sync::Arc::new(vec![
0xff_u8;
(width * height * 4) as usize
])),
format: ImageFormat::Rgba8,
alpha_type: ImageAlphaType::Alpha,
width,
height,
})
}
/// A resolved image of the given size.
fn resolved(width: u32, height: u32) -> ImageResult {
ImageResult::Graphic(Graphic::Image(solid_image(width, height)))
}
/// Build and lay out a view with an image resolver set before layout.
fn view_with_images(
source: &str,
width: f64,
resolver: impl Fn(&str) -> ImageResult + 'static,
) -> (MarkdownView, TextContext) {
let mut text_cx = guiduck_samples::sample_text_context();
let mut view = MarkdownView::new(source);
view.set_image_resolver(resolver);
let _ = view.take_dirt();
view.finalize_layout(&mut text_cx, Size::new(width, 0.0), Size::ZERO);
(view, text_cx)
}
#[test]
fn a_lone_image_is_a_block_the_app_resolves() {
let (view, _cx) = view_with_images("", 200.0, |src| {
if src == "diagram.png" {
resolved(40, 20)
} else {
ImageResult::None
}
});
let blocks = view.blocks();
assert_eq!(blocks.len(), 1);
assert_eq!(blocks[0].kind, BlockKind::Image);
let image = blocks[0]
.image
.as_ref()
.expect("an image block has content");
assert_eq!(image.src, "diagram.png");
assert!(image.graphic.is_some(), "the resolver supplied the pixels");
assert!(
(blocks[0].height - 20.0).abs() < 1.0,
"the block is the image's natural height, got {}",
blocks[0].height,
);
}
#[test]
fn a_wide_image_is_scaled_to_the_width() {
// 400×200 (2:1) in a 100px view → 100 wide, 50 tall, aspect kept.
let (view, _cx) = view_with_images("", 100.0, |_| resolved(400, 200));
assert!(
(view.blocks()[0].height - 50.0).abs() < 1.0,
"got {}",
view.blocks()[0].height,
);
}
#[test]
fn an_unresolved_image_falls_back_to_its_alt() {
// No resolver at all.
let (view, _cx) = view("", 200.0);
let blocks = view.blocks();
assert_eq!(blocks[0].kind, BlockKind::Image);
let image = blocks[0].image.as_ref().unwrap();
assert!(image.graphic.is_none());
assert_eq!(image.alt.text(), "the alt text");
assert!(blocks[0].height > 0.0, "the alt text still takes space");
assert!(
view.plain_text().contains("the alt text"),
"and is read out"
);
}
#[test]
fn a_declined_image_keeps_its_alt() {
let (view, _cx) = view_with_images("", 200.0, |_| ImageResult::None);
let image = view.blocks()[0].image.as_ref().unwrap();
assert!(
image.graphic.is_none(),
"the resolver declining leaves the alt"
);
assert!(!image.deferred, "None is final, not pending");
assert_eq!(image.alt.text(), "alt");
}
#[test]
fn a_deferred_image_is_polled_until_it_arrives() {
use std::cell::Cell;
use std::rc::Rc;
// The resolver reports the image as loading until the app's "fetch"
// completes, then hands it over.
let ready = Rc::new(Cell::new(false));
let seen = ready.clone();
let (mut view, _cx) = view_with_images("", 200.0, move |_| {
if seen.get() {
resolved(30, 20)
} else {
ImageResult::DeferredImage
}
});
let image = view.blocks()[0].image.as_ref().unwrap();
assert!(image.deferred, "the alt stands in while it loads");
assert!(image.graphic.is_none());
assert!(
guiduck_core::Widget::take_wake(&mut view).is_some(),
"a deferred image schedules a re-ask",
);
// The fetch completes; the next poll picks it up and stops asking.
ready.set(true);
view.on_timer();
let image = view.blocks()[0].image.as_ref().unwrap();
assert!(image.graphic.is_some(), "the deferred image resolved");
assert!(!image.deferred);
assert!(
guiduck_core::Widget::take_wake(&mut view).is_none(),
"and the poll stops once nothing is loading",
);
}
#[test]
fn an_inline_image_renders_as_its_alt_text() {
// An image mixed with prose is not a block image; its alt stands in the
// sentence.
let (view, _cx) = view("see  here", 300.0);
let blocks = view.blocks();
assert_eq!(blocks.len(), 1);
assert_eq!(
blocks[0].kind,
BlockKind::Paragraph,
"not lifted to a block"
);
assert_eq!(blocks[0].body.as_ref().unwrap().text(), "see icon here");
}