apace-dom 0.3.0-beta.4

Apace DOM implementation
Documentation
use crate::profile::{Phase, Profile};
use crate::{Attribute, BaseDocument, DocumentConfig, UserAgentStylesheet, qual_name};
use apace_traits::node_id::NodeId;
use apace_traits::shell::Viewport;

fn fixture(incremental: bool) -> (BaseDocument, [NodeId; 5], Profile) {
    let mut doc = BaseDocument::new(DocumentConfig {
        viewport: Some(Viewport::new(400, 300, 1., Default::default())),
        incremental: Some(incremental),
        ..Default::default()
    });
    doc.add_user_agent_stylesheet(UserAgentStylesheet::parse(
        "html,body{margin:0;padding:0}.root{position:relative}.row{position:absolute;top:0;left:0;width:160px;height:32px;font:16px/20px monospace}.peer{position:absolute;left:200px;top:0;font:16px/20px monospace}",
    ));
    let root = doc.root_node().id;
    let mut m = doc.mutate();
    let html = m.create_element(qual_name!("html", html), vec![]);
    let body = m.create_element(
        qual_name!("body", html),
        vec![Attribute {
            name: qual_name!("class"),
            value: "root".into(),
        }],
    );
    let row = m.create_element(
        qual_name!("div", html),
        vec![Attribute {
            name: qual_name!("class"),
            value: "row".into(),
        }],
    );
    let span = m.create_element(qual_name!("span", html), vec![]);
    let peer = m.create_element(
        qual_name!("div", html),
        vec![Attribute {
            name: qual_name!("class"),
            value: "peer".into(),
        }],
    );
    let text = m.create_text_node("alpha beta gamma delta");
    let other = m.create_text_node("peer text");
    m.append_children(root, &[html]);
    m.append_children(html, &[body]);
    m.append_children(body, &[row, peer]);
    m.append_children(row, &[span]);
    m.append_children(span, &[text]);
    m.append_children(peer, &[other]);
    drop(m);
    let profile = Profile::default();
    doc.set_profile(Some(profile.clone()));
    doc.resolve(0.);
    let text_box = doc.get_client_bounding_rect(span).unwrap();
    assert!(
        text_box.width > 0. && text_box.height > 0.,
        "real font required"
    );
    assert!(profile.snapshot().sample(Phase::TextShape).calls > 0);
    assert!(profile.reset());
    (doc, [html, body, row, span, peer], profile)
}

#[test]
fn inline_offset_and_height_changes_reuse_text_then_clear_to_stylesheet() {
    let (mut doc, nodes, profile) = fixture(true);
    let row = nodes[2];
    doc.mutate()
        .set_attribute(row, qual_name!("style"), "top:90px;height:48px");
    doc.resolve(0.);
    let rect = doc.get_client_bounding_rect(row).unwrap();
    assert_eq!((rect.y, rect.height), (90., 48.));
    assert_eq!(profile.snapshot().sample(Phase::TextShape).calls, 0);
    assert!(profile.reset());
    doc.mutate().clear_attribute(row, qual_name!("style"));
    doc.resolve(0.);
    let rect = doc.get_client_bounding_rect(row).unwrap();
    assert_eq!((rect.y, rect.height), (0., 32.));
    assert_eq!(profile.snapshot().sample(Phase::TextShape).calls, 0);
}

#[test]
fn equivalent_inline_declarations_do_not_shape_or_break_cached_text() {
    let (mut doc, nodes, profile) = fixture(true);
    doc.mutate()
        .set_attribute(nodes[2], qual_name!("style"), "top:0px;height:32px");
    doc.resolve(0.);
    assert_eq!(profile.snapshot().sample(Phase::TextShape).calls, 0);
    assert_eq!(profile.snapshot().sample(Phase::TextBreak).calls, 0);
}

#[test]
fn inline_style_differences_match_full_reconstruction_across_layout_and_text_changes() {
    let (mut incremental, ids, _) = fixture(true);
    let (mut complete, full_ids, _) = fixture(false);
    for value in [
        "top:91px;left:7px;height:48px",
        "width:72px;padding:3px;border:2px solid red",
        "font:16px/20px monospace;width:72px",
        "font:16px/30px monospace;width:72px",
        "font:16px/30px monospace;width:72px;text-align:right",
        "font:16px/30px monospace;width:72px;text-align:center",
        "font-size:24px;line-height:30px",
        "font-weight:700;font-family:serif",
        "white-space:nowrap;letter-spacing:2px",
        "direction:rtl;text-transform:uppercase",
        "color:blue;opacity:0.6;transform:translateY(8px)",
        "display:none",
        "display:flex;align-items:center;justify-content:end",
        "position:relative;top:4px;width:80px;height:auto",
        "display:contents",
        "",
    ] {
        incremental
            .mutate()
            .set_attribute(ids[2], qual_name!("style"), value);
        complete
            .mutate()
            .set_attribute(full_ids[2], qual_name!("style"), value);
        incremental.resolve(0.);
        complete.resolve(0.);
        for (&id, &full) in ids.iter().zip(&full_ids) {
            let a = incremental.get_client_bounding_rect(id);
            let b = complete.get_client_bounding_rect(full);
            assert_eq!(a.is_some(), b.is_some(), "{value}: {id:?}");
            if let (Some(a), Some(b)) = (a, b) {
                for (actual, expected) in [
                    (a.x, b.x),
                    (a.y, b.y),
                    (a.width, b.width),
                    (a.height, b.height),
                ] {
                    assert!(
                        (actual - expected).abs() < 0.01,
                        "{value}: {id:?}: {a:?} != {b:?}"
                    );
                }
            }
        }
    }
}

#[test]
fn style_attribute_selectors_still_restyle_related_text() {
    let (mut doc, nodes, profile) = fixture(true);
    doc.add_user_agent_stylesheet(UserAgentStylesheet::parse(
        ".row[style*=\"99\"] + .peer{font-size:26px}",
    ));
    doc.resolve(0.);
    let before = doc.get_client_bounding_rect(nodes[4]).unwrap().width;
    assert!(profile.reset());
    doc.mutate()
        .set_attribute(nodes[2], qual_name!("style"), "top:99px");
    doc.resolve(0.);
    let after = doc.get_client_bounding_rect(nodes[4]).unwrap().width;
    assert!(
        after > before * 1.2,
        "selector restyle lost: {before} -> {after}"
    );
    assert!(profile.snapshot().sample(Phase::TextShape).calls > 0);
}

#[test]
fn hidden_parent_disposal_retires_cached_layout_children_before_slot_reuse() {
    for incremental in [true, false] {
        for resolve_hidden_first in [false, true] {
            for remove_all in [false, true] {
                let mut doc = BaseDocument::new(DocumentConfig {
                    viewport: Some(Viewport::new(400, 300, 1., Default::default())),
                    incremental: Some(incremental),
                    ..Default::default()
                });
                doc.add_user_agent_stylesheet(UserAgentStylesheet::parse(
                    "html,body{margin:0;padding:0}[data-gly-hidden]{display:none!important}",
                ));
                let root = doc.root_node().id;
                let mut m = doc.mutate();
                let html = m.create_element(qual_name!("html", html), vec![]);
                let body = m.create_element(qual_name!("body", html), vec![]);
                let outer = m.create_element(qual_name!("div", html), vec![]);
                let anchor = m.create_element(qual_name!("a", html), vec![]);
                let label = m.create_text_node("Parent");
                let trigger = m.create_element(qual_name!("button", html), vec![]);
                let trigger_text = m.create_text_node("Expand");
                let panel = m.create_element(
                    qual_name!("div", html),
                    vec![Attribute {
                        name: qual_name!("style"),
                        value: "padding:12px".into(),
                    }],
                );
                let control = m.create_element(qual_name!("input", html), vec![]);
                m.append_children(root, &[html]);
                m.append_children(html, &[body]);
                m.append_children(body, &[outer]);
                m.append_children(outer, &[anchor, trigger, panel]);
                m.append_children(anchor, &[label]);
                m.append_children(trigger, &[trigger_text]);
                let start = m.create_comment_node("group start");
                let end = m.create_comment_node("group end");
                m.append_children(panel, &[start, control, end]);
                drop(m);
                doc.resolve(0.);
                let expected = doc.get_client_bounding_rect(control).unwrap();
                assert!(expected.width > 0. && expected.height > 0.);
                let mut current = control;
                for _ in 0..8 {
                    if resolve_hidden_first {
                        doc.mutate().set_attribute(
                            panel,
                            crate::QualName::new(None, "".into(), "data-gly-hidden".into()),
                            "",
                        );
                        doc.resolve(0.);
                    }
                    if remove_all {
                        doc.mutate().remove_and_drop_all_children(panel);
                    } else {
                        doc.mutate().remove_and_drop_node(current);
                    }
                    if !resolve_hidden_first {
                        doc.mutate().set_attribute(
                            panel,
                            crate::QualName::new(None, "".into(), "data-gly-hidden".into()),
                            "",
                        );
                    }
                    doc.resolve(0.);
                    assert!(doc.get_node(current).is_none());
                    for (_, node) in doc.nodes.iter() {
                        if let Some(children) = node.layout_children.borrow().as_ref() {
                            assert!(children.iter().all(|id| doc.nodes.contains_key(*id)));
                        }
                    }
                    let mut m = doc.mutate();
                    let replacement = m.create_element(qual_name!("input", html), vec![]);
                    m.append_children(panel, &[replacement]);
                    m.clear_attribute(
                        panel,
                        crate::QualName::new(None, "".into(), "data-gly-hidden".into()),
                    );
                    drop(m);
                    assert_ne!(replacement, current);
                    doc.resolve(0.);
                    let actual = doc.get_client_bounding_rect(replacement).unwrap();
                    assert!((actual.width - expected.width).abs() < 0.01);
                    assert!((actual.height - expected.height).abs() < 0.01);
                    current = replacement;
                }
            }
        }
    }
}