Skip to main content

a11y_dom/
lib.rs

1//! Dokumentmodell-Abstraktion für Accessibility-Regeln.
2//!
3//! Regeln werden einmal geschrieben und laufen über drei sehr verschiedene
4//! Substrate: statisches HTML aus einem Build, ein per CDP ferngesteuerter
5//! Chrome, und der DOM einer laufenden Seite. Dieses Crate definiert, was diese
6//! drei gemeinsam haben — und, wichtiger, wie sie sich unterscheiden.
7//!
8//! # Der Baum ist DOM-förmig
9//!
10//! [`Node`] bildet Tags, Attribute, Text und Hierarchie ab, nicht Rollen und
11//! Accessible Names. Das ist bewusst: Die Mehrzahl der Regeln braucht
12//! Attribute (`tabindex`, `id`, `role`, `alt`, `for`), und der native
13//! Accessibility-Tree des Browsers gibt die gar nicht her — `tabindex` etwa
14//! taucht dort nicht auf. Rolle und Name kommen als eigene Fähigkeit obendrauf.
15//!
16//! # Fähigkeiten statt Optionen
17//!
18//! Die drei Substrate unterscheiden sich nicht in der Darstellung derselben
19//! Daten, sondern darin, welche Daten überhaupt existieren. Ein flaches Trait
20//! mit `Option`-Rückgaben würde dazu führen, dass Regeln je nach Host
21//! stillschweigend nicht laufen. Stattdessen gibt es [`Semantics`],
22//! [`Rendering`] und [`Interaction`] als eigene Traits, die ein Host
23//! implementiert oder eben nicht — und eine Regel, deren [`Tier`] nicht erfüllt
24//! ist, meldet `UNTESTED` statt zu schweigen.
25//!
26//! ```
27//! use a11y_dom::{elements, subtree_text, Arena, Document, Node};
28//!
29//! let doc = Arena::builder()
30//!     .open("html").attr("lang", "de")
31//!         .open("body")
32//!             .open("h1").text("Bericht").close()
33//!             .open("img").attr("src", "logo.png").close()
34//!         .close()
35//!     .close()
36//!     .build();
37//!
38//! let img = elements(&doc).find(|n| n.local_name() == "img").unwrap();
39//! assert!(img.attr("alt").is_none());
40//!
41//! let h1 = elements(&doc).find(|n| n.local_name() == "h1").unwrap();
42//! assert_eq!(subtree_text(h1), "Bericht");
43//! ```
44
45#![forbid(unsafe_code)]
46
47mod arena;
48mod tiers;
49mod tree;
50
51pub use arena::{Arena, ArenaBuilder, ArenaNode};
52pub use tiers::{
53    Caps, Color, ComputedStyle, Interaction, NameSource, Rect, Rendering, Semantics, Tier,
54};
55pub use tree::{
56    ancestors, closest, descendants, elements, has_text, self_and_descendants, subtree_text,
57    Document, Node, NodeId, NodeKind,
58};
59
60#[cfg(test)]
61mod tests {
62    use super::*;
63
64    fn doc() -> Arena {
65        Arena::builder()
66            .open("html")
67            .attr("lang", "de")
68            .open("head")
69            .open("title")
70            .text("Seite")
71            .close()
72            .close()
73            .open("body")
74            .open("main")
75            .open("h1")
76            .text("Titel")
77            .close()
78            .open("p")
79            .text("Ein ")
80            .open("a")
81            .attr("href", "/x")
82            .text("Link")
83            .close()
84            .text(".")
85            .close()
86            .close()
87            .close()
88            .close()
89            .build()
90    }
91
92    #[test]
93    fn wurzel_und_attribute() {
94        let d = doc();
95        assert_eq!(d.root().local_name(), "html");
96        assert_eq!(d.root().attr("lang"), Some("de"));
97        assert!(d.root().has_attr("lang"));
98        assert!(!d.root().has_attr("dir"));
99    }
100
101    #[test]
102    fn nachfahren_kommen_in_dokumentreihenfolge() {
103        let d = doc();
104        let namen: Vec<&str> = elements(&d).map(|n| n.local_name()).collect();
105        assert_eq!(
106            namen,
107            vec!["html", "head", "title", "body", "main", "h1", "p", "a"]
108        );
109    }
110
111    #[test]
112    fn subtree_text_sammelt_ueber_verschachtelung() {
113        let d = doc();
114        let p = elements(&d).find(|n| n.local_name() == "p").unwrap();
115        assert_eq!(subtree_text(p), "Ein Link.");
116        assert!(has_text(p));
117    }
118
119    #[test]
120    fn eigener_text_ist_nicht_teilbaumtext() {
121        let d = doc();
122        let p = elements(&d).find(|n| n.local_name() == "p").unwrap();
123        // Ein Element traegt selbst keinen Text; der steckt in seinen Textkindern.
124        assert_eq!(p.text(), "");
125        assert_eq!(subtree_text(p), "Ein Link.");
126    }
127
128    #[test]
129    fn vorfahren_und_closest() {
130        let d = doc();
131        let a = elements(&d).find(|n| n.local_name() == "a").unwrap();
132        let kette: Vec<&str> = ancestors(a).map(|n| n.local_name()).collect();
133        assert_eq!(kette, vec!["p", "main", "body", "html"]);
134        assert_eq!(closest(a, "main").map(|n| n.local_name()), Some("main"));
135        // closest schliesst den Knoten selbst ein
136        assert_eq!(closest(a, "a").map(|n| n.local_name()), Some("a"));
137        assert!(closest(a, "form").is_none());
138    }
139
140    #[test]
141    fn knoten_lassen_sich_ueber_die_kennung_zurueckholen() {
142        let d = doc();
143        let h1 = elements(&d).find(|n| n.local_name() == "h1").unwrap();
144        let wieder = d.get(h1.id()).unwrap();
145        assert_eq!(wieder, h1);
146        assert!(d.get(NodeId(9999)).is_none());
147    }
148
149    #[test]
150    fn caps_kennt_struktur_immer() {
151        let nur_struktur = Caps::STRUCTURE_ONLY;
152        assert!(nur_struktur.has(Tier::Structure));
153        assert!(!nur_struktur.has(Tier::Semantics));
154
155        let voll = Caps::default()
156            .with_semantics()
157            .with_rendering()
158            .with_interaction();
159        assert!(voll.has(Tier::Rendering));
160        assert!(voll.has(Tier::Interaction));
161    }
162
163    #[test]
164    fn node_count_ist_optional_aber_hier_bekannt() {
165        let d = doc();
166        assert_eq!(d.node_count(), Some(d.len()));
167    }
168}