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("![a diagram](diagram.png)", 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("![big](big.png)", 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("![the alt text](missing.png)", 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("![alt](x)", 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("![loading](slow)", 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 ![icon](i.png) 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");
}