AkurAI Build
Menu

Public repository / master

AkurAI-Framework / crates/ui/src/testing.rs

Latest change fee5b6c2cde89fa6bf8dc51068af7336b56a88b8 - akurai-ui: platform::open_url (hand a URL, e.g. a native shell's scheme, to the window) by Ólafur Búi Ólafsson

//! Test harness over the in-memory DOM (native builds only).
//!
//! Render a component, query the DOM with simple selectors, fire events and
//! answer the app's fetches — all synchronously inside `cargo test`.

use crate::callbacks;
use crate::dom::{mount, IntoView};
use crate::host::native::{with, Dom, FakeEvent, Kind};
use crate::host::{self, EventRef, Node};
use crate::reactive::{create_root, untrack};

pub use crate::host::native::{OpenStream, PendingFetch};

/// Reset the DOM to an empty `<body>`.
pub fn reset() {
    with(|d| *d = Dom::fresh());
}

/// Reset, then mount `component` into `<body>` inside a fresh (leaked) root.
pub fn render<V: IntoView>(component: impl FnOnce() -> V) {
    render_with(|| (component(), ()))
}

/// Like [`render`] but the closure also returns a handle (often a signal)
/// for the test to drive.
pub fn render_with<V: IntoView, R>(component: impl FnOnce() -> (V, R)) -> R {
    reset();
    create_root(|| {
        let (view, out) = untrack(component);
        mount(host::BODY, view.into_view(), None);
        out
    })
    .0
}

/// Serialised HTML of `<body>`'s children (comment markers omitted).
pub fn body_html() -> String {
    html(host::BODY)
}

/// Serialised inner HTML of `node`.
pub fn html(node: Node) -> String {
    with(|d| inner(d, node.0))
}

fn inner(d: &Dom, id: u32) -> String {
    let Some(n) = d.nodes.get(&id) else {
        return String::new();
    };
    n.children.iter().map(|c| outer(d, *c)).collect()
}

fn outer(d: &Dom, id: u32) -> String {
    let n = &d.nodes[&id];
    match &n.kind {
        Kind::Text => escape(&n.text),
        Kind::Comment | Kind::Root => String::new(),
        Kind::Element(tag) => {
            let attrs: String = n
                .attrs
                .iter()
                .map(|(k, v)| format!(r#" {k}="{}""#, escape(v)))
                .collect();
            format!("<{tag}{attrs}>{}</{tag}>", inner(d, id))
        }
    }
}

fn escape(s: &str) -> String {
    s.replace('&', "&amp;")
        .replace('<', "&lt;")
        .replace('"', "&quot;")
}

/// Text content of `node` and its descendants.
pub fn text(node: Node) -> String {
    with(|d| text_of(d, node.0))
}

fn text_of(d: &Dom, id: u32) -> String {
    let Some(n) = d.nodes.get(&id) else {
        return String::new();
    };
    match n.kind {
        Kind::Text => n.text.clone(),
        _ => n.children.iter().map(|c| text_of(d, *c)).collect(),
    }
}

/// An attribute's value.
pub fn attr(node: Node, name: &str) -> Option<String> {
    with(|d| {
        d.nodes
            .get(&node.0)
            .and_then(|n| n.attr(name).map(str::to_string))
    })
}

/// A property's value (as set by the app or by [`input`]).
pub fn prop(node: Node, name: &str) -> String {
    host::get_prop_str(node, name)
}

struct Selector {
    tag: Option<String>,
    id: Option<String>,
    classes: Vec<String>,
    attrs: Vec<(String, Option<String>)>,
    text: Option<String>,
}

/// Parse `tag#id.class[attr][attr=value]:text(Label)`.
fn parse_selector(sel: &str) -> Selector {
    let mut s = Selector {
        tag: None,
        id: None,
        classes: Vec::new(),
        attrs: Vec::new(),
        text: None,
    };
    let mut rest = sel;
    if let Some(i) = rest.find(":text(") {
        s.text = Some(rest[i + 6..rest.len() - 1].to_string());
        rest = &rest[..i];
    }
    let tag_end = rest.find(['#', '.', '[']).unwrap_or(rest.len());
    if tag_end > 0 {
        s.tag = Some(rest[..tag_end].to_string());
    }
    let mut rest = &rest[tag_end..];
    while !rest.is_empty() {
        let kind = rest.as_bytes()[0];
        if kind == b'[' {
            let close = rest.find(']').unwrap_or(rest.len() - 1);
            let body = &rest[1..close];
            match body.split_once('=') {
                Some((k, v)) => s.attrs.push((k.into(), Some(v.trim_matches('"').into()))),
                None => s.attrs.push((body.into(), None)),
            }
            rest = &rest[close + 1..];
        } else {
            let end = rest[1..]
                .find(['#', '.', '['])
                .map_or(rest.len(), |i| i + 1);
            let name = rest[1..end].to_string();
            if kind == b'#' {
                s.id = Some(name);
            } else {
                s.classes.push(name);
            }
            rest = &rest[end..];
        }
    }
    s
}

fn matches(d: &Dom, id: u32, s: &Selector) -> bool {
    let n = &d.nodes[&id];
    let Kind::Element(tag) = &n.kind else {
        return false;
    };
    if s.tag.as_ref().is_some_and(|t| t != tag) {
        return false;
    }
    if s.id.as_ref().is_some_and(|i| n.attr("id") != Some(i)) {
        return false;
    }
    let classes: Vec<&str> = n.attr("class").unwrap_or("").split_whitespace().collect();
    if !s.classes.iter().all(|c| classes.contains(&c.as_str())) {
        return false;
    }
    let attrs_ok = s.attrs.iter().all(|(k, v)| match (n.attr(k), v) {
        (Some(_), None) => true,
        (Some(actual), Some(want)) => actual == want,
        (None, _) => false,
    });
    attrs_ok && s.text.as_ref().is_none_or(|t| text_of(d, id).trim() == t)
}

fn collect(d: &Dom, id: u32, s: &Selector, out: &mut Vec<Node>) {
    let Some(n) = d.nodes.get(&id) else {
        return;
    };
    for c in &n.children {
        if matches(d, *c, s) {
            out.push(Node(*c));
        }
        collect(d, *c, s, out);
    }
}

/// Split `a b` on spaces outside `[...]` and `(...)`.
fn compounds(selector: &str) -> Vec<&str> {
    let mut parts = Vec::new();
    let (mut depth, mut start) = (0i32, 0);
    for (i, ch) in selector.char_indices() {
        match ch {
            '[' | '(' => depth += 1,
            ']' | ')' => depth -= 1,
            ' ' if depth == 0 => {
                if i > start {
                    parts.push(&selector[start..i]);
                }
                start = i + 1;
            }
            _ => {}
        }
    }
    if start < selector.len() {
        parts.push(&selector[start..]);
    }
    parts
}

/// All elements under `<body>` matching `selector`, in document order.
/// Supports `tag`, `#id`, `.class`, `[attr]`, `[attr=value]`, a trailing
/// `:text(Exact text)`, and descendant combinators (`[role=dialog] input`).
pub fn find_all(selector: &str) -> Vec<Node> {
    let mut scope = vec![Node(2)];
    for part in compounds(selector) {
        let s = parse_selector(part);
        let mut next: Vec<Node> = Vec::new();
        with(|d| {
            for root in &scope {
                let mut found = Vec::new();
                collect(d, root.0, &s, &mut found);
                for n in found {
                    if !next.contains(&n) {
                        next.push(n);
                    }
                }
            }
        });
        scope = next;
    }
    scope
}

/// The first element matching `selector`.
pub fn find(selector: &str) -> Option<Node> {
    find_all(selector).into_iter().next()
}

/// Like [`find`] but panics with the DOM dump when missing.
pub fn get(selector: &str) -> Node {
    find(selector).unwrap_or_else(|| panic!("no match for {selector:?} in:\n{}", body_html()))
}

/// Dispatch `event` at `node`, bubbling to its ancestors (and `document`/
/// `window` when attached). Returns the event record (prevented, stopped).
pub fn fire(node: Node, event: &str) {
    fire_with(node, event, FakeEvent::default());
}

fn fire_with(node: Node, event: &str, mut record: FakeEvent) {
    record.target = node.0;
    let ev = with(|d| {
        let id = d.next_event;
        d.next_event += 1;
        d.events.insert(id, record);
        id
    });
    let mut path = Vec::new();
    let mut cursor = Some(node.0);
    while let Some(id) = cursor {
        path.push(id);
        cursor = with(|d| d.nodes.get(&id).and_then(|n| n.parent));
    }
    if path.last() == Some(&2) {
        path.push(1);
    }
    for id in path {
        let listeners: Vec<u32> = with(|d| {
            d.nodes
                .get(&id)
                .map(|n| {
                    n.listeners
                        .iter()
                        .filter(|(e, _)| e == event)
                        .map(|(_, cb)| *cb)
                        .collect()
                })
                .unwrap_or_default()
        });
        for cb in listeners {
            callbacks::dispatch(cb, EventRef(ev));
        }
        if with(|d| d.events.get(&ev).is_some_and(|e| e.stopped)) {
            break;
        }
    }
    with(|d| d.events.remove(&ev));
}

/// Click `node`.
pub fn click(node: Node) {
    fire(node, "click");
}

/// Set a field's value and fire `input` (and `change`).
pub fn input(node: Node, value: &str) {
    host::set_prop_str(node, "value", value);
    fire(node, "input");
    fire(node, "change");
}

/// Toggle a checkbox and fire `change`.
pub fn check(node: Node, checked: bool) {
    host::set_prop_bool(node, "checked", checked);
    fire(node, "change");
}

/// Submit a form.
pub fn submit(form: Node) {
    fire(form, "submit");
}

/// Press `key` on `node` (fires `keydown`).
pub fn key(node: Node, key: &str) {
    fire_with(
        node,
        "keydown",
        FakeEvent {
            key: key.to_string(),
            ..FakeEvent::default()
        },
    );
}

/// The currently focused node.
pub fn focused() -> Option<Node> {
    host::active_element()
}

/// Requests the app has made and not yet been answered.
pub fn pending_fetches() -> Vec<PendingFetch> {
    with(|d| d.fetches.clone())
}

/// Answer the oldest pending request whose URL contains `url_part`.
/// Returns the request so tests can assert on method and body.
pub fn respond(url_part: &str, status: u16, body: &str) -> PendingFetch {
    let req = with(|d| {
        let i = d
            .fetches
            .iter()
            .position(|f| f.url.contains(url_part))
            .unwrap_or_else(|| panic!("no pending fetch matching {url_part:?}: {:?}", d.fetches));
        d.fetches.remove(i)
    });
    callbacks::complete(req.callback, status, body.to_string());
    req
}

/// Streams the app has opened with `http::event_source` and not closed.
pub fn open_streams() -> Vec<OpenStream> {
    with(|d| d.streams.clone())
}

/// Deliver a named server event to the open stream whose URL contains
/// `url_part`. Panics when no such stream is open.
pub fn push_event(url_part: &str, name: &str, data: &str) {
    let cb = stream_callback(url_part);
    callbacks::complete(cb, 1, format!("{name}\n{data}"));
}

/// Tell the app the stream whose URL contains `url_part` dropped.
pub fn stream_error(url_part: &str, reason: &str) {
    let cb = stream_callback(url_part);
    callbacks::complete(cb, 0, reason.to_string());
}

fn stream_callback(url_part: &str) -> u32 {
    with(|d| {
        d.streams
            .iter()
            .find(|s| s.url.contains(url_part))
            .map(|s| s.callback)
            .unwrap_or_else(|| panic!("no open stream matching {url_part:?}: {:?}", d.streams))
    })
}

/// Whether the app is recording from the (fake) microphone.
pub fn recording() -> bool {
    with(|d| d.recording.is_some())
}

/// Finish the clip a `record_stop` is waiting for, as `(mime, base64)`.
pub fn finish_recording(mime: &str, base64: &str) {
    let cb = with(|d| d.recording_cb.take()).expect("no record_stop waiting");
    callbacks::complete(cb, 1, format!("{mime}\n{base64}"));
}

/// Fail the clip a `record_stop` is waiting for (permission refused, …).
pub fn fail_recording(reason: &str) {
    let cb = with(|d| d.recording_cb.take()).expect("no record_stop waiting");
    callbacks::complete(cb, 0, reason.to_string());
}

/// Audio sources the app asked to play, in order.
pub fn played_audio() -> Vec<String> {
    with(|d| d.played.clone())
}

/// The audio stream `platform::stream_audio` left playing: (src, volume), or None.
pub fn audio_stream() -> Option<(String, f64)> {
    with(|d| d.stream.clone())
}

/// URLs handed to `platform::open_url`, in order.
pub fn opened_urls() -> Vec<String> {
    with(|d| d.opened.clone())
}

/// The fullscreen video `platform::stream_video` left showing: (src, volume), or None.
pub fn video_stream() -> Option<(String, f64)> {
    with(|d| d.video.clone())
}

/// Fire the timers queued so far, in order. Timers they schedule (e.g. a
/// self-rescheduling clock tick) wait for the next call, so this never loops.
pub fn run_timers() {
    let due = with(|d| std::mem::take(&mut d.timers));
    for (_, cb) in due {
        callbacks::complete(cb, 0, String::new());
    }
}

/// Set `location.hash` and fire `hashchange`.
pub fn set_hash(hash: &str) {
    host::set_location_hash(hash);
}

/// Current `location.hash` (without `#`).
pub fn hash() -> String {
    host::location_hash()
}

/// Set the test clock (milliseconds since the Unix epoch, UTC).
pub fn set_now(ms: f64) {
    with(|d| d.now = ms);
}

/// The document title.
pub fn title() -> String {
    with(|d| d.title.clone())
}

/// A `<html>` attribute set via the app (e.g. `data-theme`).
pub fn root_attr(name: &str) -> Option<String> {
    with(|d| d.root_attrs.get(name).cloned())
}