use std::fs;
use std::path::Path;
use crate::error::{AuditError, Result};
use crate::screen_reader::SrAuditReport;
pub fn export_sr_audit(report: &SrAuditReport, path: &Path) -> Result<()> {
if let Some(parent) = path
.parent()
.filter(|parent| !parent.as_os_str().is_empty())
{
fs::create_dir_all(parent).map_err(|e| AuditError::FileError {
path: parent.to_path_buf(),
reason: e.to_string(),
})?;
}
let json = serde_json::to_string_pretty(report).map_err(|e| AuditError::OutputError {
reason: e.to_string(),
})?;
fs::write(path, json).map_err(|e| AuditError::FileError {
path: path.to_path_buf(),
reason: e.to_string(),
})
}
#[cfg(test)]
mod tests {
use crate::accessibility::{AXNode, AXTree};
use crate::screen_reader::build_sr_audit_report;
use super::export_sr_audit;
fn sort_json_keys(value: serde_json::Value) -> serde_json::Value {
match value {
serde_json::Value::Object(map) => {
let mut entries: Vec<(String, serde_json::Value)> = map
.into_iter()
.map(|(k, v)| (k, sort_json_keys(v)))
.collect();
entries.sort_by(|a, b| a.0.cmp(&b.0));
serde_json::Value::Object(entries.into_iter().collect())
}
serde_json::Value::Array(arr) => {
serde_json::Value::Array(arr.into_iter().map(sort_json_keys).collect())
}
other => other,
}
}
fn node(id: &str, role: &str, name: Option<&str>, child_ids: Vec<&str>) -> AXNode {
AXNode {
node_id: id.to_string(),
ignored: false,
ignored_reasons: vec![],
role: Some(role.to_string()),
name: name.map(String::from),
name_source: None,
description: None,
value: None,
properties: vec![],
child_ids: child_ids.into_iter().map(String::from).collect(),
parent_id: None,
backend_dom_node_id: None,
}
}
#[test]
fn exports_valid_screen_reader_json() {
let tree = AXTree::from_nodes(vec![
node("1", "WebArea", Some("Example"), vec!["2", "3"]),
node("2", "main", Some("Inhalt"), vec![]),
node("3", "link", Some("Hier"), vec![]),
]);
let report = build_sr_audit_report(
"https://example.com",
chrono::DateTime::<chrono::Utc>::UNIX_EPOCH,
&tree,
"de",
None,
);
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("sr-audit.json");
export_sr_audit(&report, &path).expect("export succeeds");
let raw = std::fs::read_to_string(path).expect("read output");
let mut json: serde_json::Value = serde_json::from_str(&raw).expect("valid json");
json["timestamp"] = serde_json::Value::String("[timestamp]".into());
json["tool_version"] = serde_json::Value::String("[version]".into());
let json = sort_json_keys(json);
insta::assert_json_snapshot!(json, @r###"
{
"bfsg_compliance": {
"passed_criteria": [
"1.3.1",
"2.4.1",
"2.4.6",
"3.3.2",
"4.1.2"
],
"verdict": "non_compliant",
"violations": [
{
"affected_node_ids": [
"3"
],
"bfsg_reference": "§ 12 Nr. 3 BFSGV",
"en_301_549_clause": "9.2.4.4",
"fix_required": true,
"wcag_criterion": "2.4.4"
}
]
},
"issues": [
{
"affected_node_ids": [
"3"
],
"message": "Interactive name \"Hier\" is not meaningful without context.",
"severity": "medium",
"wcag_criterion": "2.4.4"
},
{
"affected_node_ids": [],
"message": "No header area (banner landmark) present. Screen readers cannot fully navigate the page structure.",
"severity": "medium",
"wcag_criterion": "1.3.6"
},
{
"affected_node_ids": [],
"message": "No navigation landmark present. Keyboard users cannot jump directly to the navigation.",
"severity": "medium",
"wcag_criterion": "1.3.6"
},
{
"affected_node_ids": [],
"message": "No footer landmark (contentinfo) present. The page structure is incomplete for screen readers.",
"severity": "medium",
"wcag_criterion": "1.3.6"
}
],
"navigation_views": {
"form_controls": [],
"headings": [],
"landmarks": [
{
"name": "Inhalt",
"node_id": "2",
"quality": "ok",
"role": "main",
"seq": 1
}
],
"links": [
{
"count": 1,
"node_ids": [
"3"
],
"quality": "non_descriptive",
"seq_positions": [
2
],
"text": "Hier"
}
],
"tables": []
},
"reading_sequence": [
{
"announcement": "Example, WebArea",
"depth": 0,
"description": null,
"name": "Example",
"node_id": "1",
"role": "WebArea",
"seq": 0,
"states": [],
"tab_stop": false,
"value": null
},
{
"announcement": "Inhalt, main",
"depth": 1,
"description": null,
"name": "Inhalt",
"node_id": "2",
"role": "main",
"seq": 1,
"states": [],
"tab_stop": false,
"value": null
},
{
"announcement": "Hier, link",
"depth": 1,
"description": null,
"name": "Hier",
"node_id": "3",
"role": "link",
"seq": 2,
"states": [],
"tab_stop": false,
"value": null
}
],
"report_type": "screen_reader_audit",
"schema_version": "1.0",
"summary": {
"audit_quality": "ok",
"bfsg_violations": 1,
"heading_quality_score": 100,
"landmark_quality_score": 25,
"name_quality_score": 0,
"quality_score_context": {
"higher_is_better": true,
"maximum": 100,
"meaning": "Structural quality of detected accessible names, landmarks, or headings; announced nodes and tab stops are counts, not quality scores.",
"minimum": 0
},
"tab_stops": 0,
"total_announced_nodes": 3
},
"timestamp": "[timestamp]",
"tool_version": "[version]",
"url": "https://example.com"
}
"###);
}
}