use super::*;
use crate::accessibility::AccessibilityDetails;
const RECORD_FIELDS: usize = 41;
const FIELD_KINDS: &[u8; RECORD_FIELDS] = b"NNNNNNFFNTTTTNNNANNNFFFNNNNNNNNNTTNNTNNNN";
struct PayloadReader<'a> {
bytes: &'a [u8],
at: usize,
}
impl PayloadReader<'_> {
fn take(&mut self, length: usize) -> &[u8] {
let taken = &self.bytes[self.at..self.at + length];
self.at += length;
taken
}
fn word(&mut self) -> [u8; 4] {
self.take(4).try_into().expect("four bytes")
}
fn text(&mut self) -> String {
let length = u32::from_le_bytes(self.word()) as usize;
String::from_utf8(self.take(length).to_vec()).expect("UTF-8 text")
}
}
fn fraction(value: f32) -> String {
if value.fract() == 0.0 && value.abs() < 16_777_216.0 {
(value as i32).to_string()
} else {
value.to_string()
}
}
fn fields(payload: &[u8]) -> Vec<Vec<String>> {
let mut reader = PayloadReader {
bytes: payload,
at: 0,
};
let mut records = Vec::new();
while reader.at < payload.len() {
let record = FIELD_KINDS
.iter()
.map(|kind| match kind {
b'N' => i32::from_le_bytes(reader.word()).to_string(),
b'F' => fraction(f32::from_le_bytes(reader.word())),
b'T' => reader.text(),
_ => {
let count = u32::from_le_bytes(reader.word());
(0..count)
.map(|_| reader.text())
.collect::<Vec<_>>()
.join("\u{1f}")
}
})
.collect();
records.push(record);
}
records
}
fn as_text(payload: &[u8]) -> String {
fields(payload)
.iter()
.map(|record| record.join("\t"))
.collect::<Vec<_>>()
.join("\n")
}
fn encode_elements(elements: &[AccessibilityElement], density: f32) -> String {
as_text(&encode_payload(&[], elements, density))
}
fn encode_payload(
before: &[AccessibilityElement],
elements: &[AccessibilityElement],
density: f32,
) -> Vec<u8> {
let mut wire = AccessibilityWire::default();
let mut snapshot = AccessibilitySnapshot::default();
wire.publish(&mut snapshot, before.to_vec(), density)
.expect("unique identities");
wire.publish(&mut snapshot, elements.to_vec(), density)
.expect("unique identities")
.records
}
fn encode_after(
before: &[AccessibilityElement],
elements: &[AccessibilityElement],
density: f32,
) -> String {
as_text(&encode_payload(before, elements, density))
}
fn said_before(element: &AccessibilityElement) -> AccessibilityElement {
AccessibilityElement {
label: format!("Not {}", element.label),
..element.clone()
}
}
fn published(elements: &[AccessibilityElement]) -> (AccessibilityWire, AccessibilitySnapshot) {
let mut wire = AccessibilityWire::default();
let mut snapshot = AccessibilitySnapshot::default();
wire.publish(&mut snapshot, elements.to_vec(), 2.0)
.expect("unique identities");
(wire, snapshot)
}
fn record_ids(update: &AccessibilityUpdate) -> Vec<i32> {
fields(&update.records)
.iter()
.map(|record| record[0].parse().expect("a record starts with its id"))
.collect()
}
fn labelled(node_id: cranpose_core::NodeId, label: &str, y: f32) -> AccessibilityElement {
AccessibilityElement {
node_id,
label: label.into(),
bounds: AccessibilityRect::new(0.0, y, 100.0, 20.0),
role: AccessibilityRole::Button,
clickable: true,
..AccessibilityElement::default()
}
}
#[test]
fn the_first_update_sends_every_control_in_order() {
let elements = [labelled(1, "One", 0.0), labelled(2, "Two", 30.0)];
let mut snapshot = AccessibilitySnapshot::default();
let update = AccessibilityWire::default()
.publish(&mut snapshot, elements.to_vec(), 2.0)
.expect("unique identities");
assert_eq!(update.order, snapshot.ids);
assert_eq!(record_ids(&update), snapshot.ids);
assert!(update.moves.is_empty());
assert!(!update.is_empty());
}
#[test]
fn an_unchanged_snapshot_leaves_the_host_alone() {
let elements = [labelled(1, "One", 0.0), labelled(2, "Two", 30.0)];
let (mut wire, mut snapshot) = published(&elements);
let update = wire
.publish(&mut snapshot, elements.to_vec(), 2.0)
.expect("unique identities");
assert!(update.is_empty(), "{update:?}");
}
#[test]
fn a_control_that_only_moved_is_sent_as_its_new_bounds() {
let (mut wire, mut snapshot) = published(&[labelled(1, "One", 0.0), labelled(2, "Two", 30.0)]);
let ids = snapshot.ids.clone();
let update = wire
.publish(
&mut snapshot,
vec![labelled(1, "One", 0.0), labelled(2, "Two", 40.0)],
2.0,
)
.expect("unique identities");
assert!(update.records.is_empty(), "a move resends no record");
assert_eq!(update.moves, vec![ids[1], 0, 80, 200, 120]);
assert_eq!(update.order, ids);
assert!(!update.is_empty());
}
#[test]
fn a_move_smaller_than_a_pixel_leaves_the_host_alone() {
let (mut wire, mut snapshot) = published(&[labelled(1, "One", 0.0)]);
let update = wire
.publish(&mut snapshot, vec![labelled(1, "One", 0.1)], 2.0)
.expect("unique identities");
assert!(update.is_empty(), "{update:?}");
}
#[test]
fn a_control_that_says_something_else_is_resent_in_full() {
let (mut wire, mut snapshot) = published(&[labelled(1, "One", 0.0), labelled(2, "Two", 30.0)]);
let ids = snapshot.ids.clone();
let update = wire
.publish(
&mut snapshot,
vec![labelled(1, "One", 0.0), labelled(2, "Three", 40.0)],
2.0,
)
.expect("unique identities");
assert_eq!(record_ids(&update), vec![ids[1]]);
assert_eq!(fields(&update.records)[0][9], "Three");
assert!(
update.moves.is_empty(),
"the record carries the new bounds itself"
);
}
#[test]
fn a_spoken_change_is_resent_with_its_flag() {
let (mut wire, mut snapshot) = published(&[labelled(1, "One", 0.0), labelled(2, "Two", 30.0)]);
let ids = snapshot.ids.clone();
let update = wire
.publish(
&mut snapshot,
vec![labelled(1, "One", 0.0), labelled(2, "Two now", 30.0)],
2.0,
)
.expect("unique identities");
assert_eq!(record_ids(&update), vec![ids[1]]);
assert_eq!(fields(&update.records)[0][29], "1");
}
#[test]
fn a_new_control_is_sent_and_the_order_names_it() {
let (mut wire, mut snapshot) = published(&[labelled(1, "One", 0.0)]);
let update = wire
.publish(
&mut snapshot,
vec![labelled(3, "New", 0.0), labelled(1, "One", 30.0)],
2.0,
)
.expect("unique identities");
assert_eq!(update.order, snapshot.ids);
assert_eq!(record_ids(&update), vec![snapshot.ids[0]]);
assert_eq!(update.moves, vec![snapshot.ids[1], 0, 60, 200, 100]);
}
#[test]
fn a_removed_control_leaves_the_order() {
let (mut wire, mut snapshot) = published(&[labelled(1, "One", 0.0), labelled(2, "Two", 30.0)]);
let kept = snapshot.ids[0];
let update = wire
.publish(&mut snapshot, vec![labelled(1, "One", 0.0)], 2.0)
.expect("unique identities");
assert_eq!(update.order, vec![kept]);
assert!(update.records.is_empty());
assert!(!update.is_empty(), "the host must drop the removed control");
}
#[test]
fn a_row_whose_list_was_replaced_is_resent() {
let list = |generation| AccessibilityElement {
node_generation: generation,
..list_scrolled_to(6, 0.0)
};
let row = AccessibilityElement {
scroll_parent: Some(6),
..labelled(9, "Milk", 10.0)
};
let (mut wire, mut snapshot) = published(&[list(0), row.clone()]);
let update = wire
.publish(&mut snapshot, vec![list(1), row], 2.0)
.expect("unique identities");
assert_eq!(
record_ids(&update),
snapshot.ids,
"the row names its new list's id, so both are resent"
);
}
#[test]
fn a_new_density_resends_every_control() {
let elements = [labelled(1, "One", 0.0), labelled(2, "Two", 30.0)];
let (mut wire, mut snapshot) = published(&elements);
let update = wire
.publish(&mut snapshot, elements.to_vec(), 3.0)
.expect("unique identities");
assert_eq!(record_ids(&update), snapshot.ids);
}
#[test]
fn a_forgotten_host_is_sent_every_control() {
let elements = [labelled(1, "One", 0.0), labelled(2, "Two", 30.0)];
let (mut wire, mut snapshot) = published(&elements);
wire.forget();
let update = wire
.publish(&mut snapshot, elements.to_vec(), 2.0)
.expect("unique identities");
assert_eq!(record_ids(&update), snapshot.ids);
assert_eq!(update.order, snapshot.ids);
}
use crate::accessibility::{AccessibilityRect, AccessibilityRole, element_with};
fn strings(fields: &[&str]) -> Vec<String> {
fields.iter().map(|field| (*field).to_string()).collect()
}
#[test]
fn records_carry_every_field_java_reads() {
let elements = vec![
AccessibilityElement {
node_id: 4,
label: "Tab\tand%".into(),
value: Some("line\nnext\r".into()),
details: Some(Box::new(AccessibilityDetails {
state_description: Some("On".into()),
click_label: Some("Toggle".into()),
custom_actions: vec!["Pause".into(), "Re\u{1f}sume".into()],
pane_title: Some("Pane".into()),
error: Some("Bad%".into()),
long_click_label: Some("Hold".into()),
expanded: Some(false),
..Default::default()
})),
bounds: AccessibilityRect::new(1.25, 2.5, 30.0, 40.75),
role: AccessibilityRole::Switch,
clickable: true,
toggled: Some(true),
..AccessibilityElement::default()
},
element_with(5, Some(1)),
];
let payload = encode_payload(&[said_before(&elements[0])], &elements, 1.5);
assert_eq!(
fields(&payload),
vec![
strings(&[
"1",
"5",
"2",
"4",
"47",
"65",
"16.25",
"22.875",
"1",
"Tab\tand%",
"line\nnext\r",
"On",
"Toggle",
"-1",
"1",
"1",
"Pause\u{1f}Re\u{1f}sume",
"0",
"0",
"0",
"0",
"0",
"0",
"0",
"0",
"0",
"-1",
"0",
"0",
"1",
"-1",
"-1",
"Pane",
"Bad%",
"0",
"0",
"Hold",
"0",
"0",
"-1",
"-1",
]),
strings(&[
"2", "2", "0", "0", "15", "15", "5", "5", "0", "Row", "", "", "", "-1", "-1", "1",
"", "0", "0", "0", "0", "0", "0", "0", "0", "0", "-1", "0", "0", "0", "-1", "-1",
"", "", "0", "-1", "", "0", "0", "-1", "-1",
]),
]
);
}
#[test]
fn tristate_distinguishes_unset_from_off() {
assert_eq!(tristate(None), -1);
assert_eq!(tristate(Some(false)), 0);
assert_eq!(tristate(Some(true)), 1);
}
#[test]
fn every_encoded_record_carries_the_fields_java_parses() {
let elements = vec![
AccessibilityElement {
node_id: 4,
label: "Haptics".into(),
details: Some(Box::new(AccessibilityDetails {
state_description: Some("On".into()),
click_label: Some("Toggle".into()),
custom_actions: vec!["Pause".into(), "Resume".into()],
..Default::default()
})),
bounds: AccessibilityRect::new(1.0, 2.0, 30.0, 40.0),
role: AccessibilityRole::Switch,
clickable: true,
toggled: Some(true),
..AccessibilityElement::default()
},
element_with(5, Some(1)),
];
let payload = encode_elements(&elements, 2.0);
let records: Vec<_> = payload.split('\n').collect();
assert_eq!(records.len(), 2);
for record in &records {
assert_eq!(
record.split('\t').count(),
RECORD_FIELDS,
"record: {record}"
);
}
let fields: Vec<_> = records[0].split('\t').collect();
assert_eq!(fields[1], "5", "Switch should encode as role 5");
assert_eq!(&fields[2..6], ["2", "4", "62", "84"]);
assert_eq!(fields[9], "Haptics");
assert_eq!(fields[11], "On");
assert_eq!(fields[12], "Toggle");
assert_eq!(fields[13], "-1", "selected was never set");
assert_eq!(fields[14], "1", "toggled on");
assert_eq!(fields[15], "1", "enabled by default");
assert_eq!(fields[16], "Pause\u{1f}Resume");
assert_eq!(fields[17], "0", "this element registered no focus target");
assert_eq!(fields[18], "0", "and focus does not sit on it");
}
#[test]
fn the_record_carries_a_field_selection_in_utf16_units_and_nothing_for_the_rest() {
let elements = vec![
AccessibilityElement {
node_id: 4,
label: "Note".into(),
value: Some("añb😀c".into()),
details: Some(Box::new(AccessibilityDetails {
text_selection: Some((8, 3)),
..Default::default()
})),
bounds: AccessibilityRect::new(1.0, 2.0, 30.0, 40.0),
role: AccessibilityRole::TextField,
..AccessibilityElement::default()
},
element_with(5, None),
];
let payload = encode_elements(&elements, 1.0);
let records: Vec<Vec<_>> = payload
.split('\n')
.map(|record| record.split('\t').collect())
.collect();
assert_eq!(&records[0][39..41], ["5", "2"]);
assert_eq!(&records[1][39..41], ["-1", "-1"]);
}
#[test]
fn the_record_names_what_a_long_press_does() {
let elements = vec![
AccessibilityElement {
node_id: 4,
label: "Milk".into(),
details: Some(Box::new(AccessibilityDetails {
long_click_label: Some("Remove receipt".into()),
..Default::default()
})),
bounds: AccessibilityRect::new(0.0, 0.0, 30.0, 40.0),
role: AccessibilityRole::Button,
clickable: true,
..AccessibilityElement::default()
},
save_button(5),
];
let payload = encode_elements(&elements, 1.0);
let records: Vec<_> = payload.split('\n').collect();
let with_long_press: Vec<_> = records[0].split('\t').collect();
let plain: Vec<_> = records[1].split('\t').collect();
assert_eq!(with_long_press[36], "Remove receipt");
assert_eq!(plain[36], "", "a control with no long press says nothing");
}
#[test]
fn the_record_carries_whether_focus_can_land_on_a_control_and_whether_it_has() {
let elements = vec![
AccessibilityElement {
node_id: 4,
label: "Name".into(),
bounds: AccessibilityRect::new(0.0, 0.0, 30.0, 40.0),
role: AccessibilityRole::TextField,
focusable: true,
focused: true,
..AccessibilityElement::default()
},
AccessibilityElement {
node_id: 5,
label: "Save".into(),
bounds: AccessibilityRect::new(0.0, 50.0, 30.0, 40.0),
role: AccessibilityRole::Button,
clickable: true,
focusable: true,
..AccessibilityElement::default()
},
];
let payload = encode_elements(&elements, 1.0);
let records: Vec<_> = payload.split('\n').collect();
let focused: Vec<_> = records[0].split('\t').collect();
let other: Vec<_> = records[1].split('\t').collect();
assert_eq!(focused[17], "1");
assert_eq!(focused[18], "1", "TalkBack follows the app onto this one");
assert_eq!(other[17], "1", "the button takes focus too");
assert_eq!(other[18], "0", "but focus does not sit on it");
}
fn list_scrolled_to(node_id: cranpose_core::NodeId, offset: f32) -> AccessibilityElement {
AccessibilityElement {
node_id,
bounds: AccessibilityRect::new(0.0, 0.0, 400.0, 600.0),
details: Some(Box::new(AccessibilityDetails {
vertical_scroll: Some(cranpose_ui::ScrollAxisRange::new(offset, 900.0, false)),
..Default::default()
})),
..AccessibilityElement::default()
}
}
fn save_button(node_id: cranpose_core::NodeId) -> AccessibilityElement {
AccessibilityElement {
node_id,
label: "Save".into(),
bounds: AccessibilityRect::new(0.0, 50.0, 30.0, 40.0),
role: AccessibilityRole::Button,
clickable: true,
..AccessibilityElement::default()
}
}
#[test]
fn the_record_names_the_list_above_a_row() {
let elements = vec![
list_scrolled_to(6, 0.0),
AccessibilityElement {
node_id: 9,
label: "Milk".into(),
bounds: AccessibilityRect::new(0.0, 10.0, 400.0, 40.0),
scroll_parent: Some(6),
..AccessibilityElement::default()
},
save_button(8),
];
let payload = encode_elements(&elements, 1.0);
let records: Vec<_> = payload.split('\n').collect();
let list: Vec<_> = records[0].split('\t').collect();
let row: Vec<_> = records[1].split('\t').collect();
let button: Vec<_> = records[2].split('\t').collect();
assert_eq!(list[26], "-1", "the list sits under the host");
assert_eq!(row[26], list[0], "the row names its list by virtual id");
assert_eq!(
button[26], "-1",
"a button outside any list sits under the host"
);
}
#[test]
fn the_record_says_which_way_a_list_can_still_page() {
let elements = vec![
list_scrolled_to(6, 0.0),
list_scrolled_to(7, 900.0),
save_button(8),
];
let payload = encode_elements(&elements, 1.0);
let records: Vec<_> = payload.split('\n').collect();
let top: Vec<_> = records[0].split('\t').collect();
let bottom: Vec<_> = records[1].split('\t').collect();
let button: Vec<_> = records[2].split('\t').collect();
assert_eq!(top[23], "1", "a list is scrollable");
assert_eq!(top[24], "1", "at the top it pages forward");
assert_eq!(top[25], "0", "and not back");
assert_eq!(bottom[24], "0", "at the end it pages back only");
assert_eq!(bottom[25], "1");
assert_eq!(button[23], "0", "a button does not scroll");
}
#[test]
fn the_record_carries_the_range_of_an_adjustable_control() {
let elements = vec![
AccessibilityElement {
node_id: 4,
label: "Volume".into(),
bounds: AccessibilityRect::new(0.0, 0.0, 200.0, 40.0),
details: Some(Box::new(AccessibilityDetails {
progress: Some(cranpose_ui::ProgressBarRangeInfo::new(0.25, 0.0, 1.0, 0)),
adjustable: true,
..Default::default()
})),
..AccessibilityElement::default()
},
save_button(5),
];
let payload = encode_elements(&elements, 1.0);
let records: Vec<_> = payload.split('\n').collect();
let slider: Vec<_> = records[0].split('\t').collect();
let button: Vec<_> = records[1].split('\t').collect();
assert_eq!(slider[19], "1", "TalkBack may move this one");
assert_eq!(slider[20], "0.25");
assert_eq!(slider[21], "0");
assert_eq!(slider[22], "1");
assert_eq!(button[19], "0", "a button holds no range");
}
#[test]
fn the_record_says_how_many_rows_a_list_holds() {
let mut list = list_scrolled_to(6, 0.0);
list.update_details(|details| {
details.collection = Some(cranpose_ui::CollectionInfo {
rows: 12,
columns: 1,
});
});
let payload = encode_elements(&[list, save_button(8)], 1.0);
let records: Vec<_> = payload.split('\n').collect();
let list: Vec<_> = records[0].split('\t').collect();
let button: Vec<_> = records[1].split('\t').collect();
assert_eq!(
(list[27], list[28]),
("12", "1"),
"the list says its rows and columns"
);
assert_eq!((button[27], button[28]), ("0", "0"), "a button is no list");
}
#[test]
fn the_record_flags_a_control_that_says_something_new() {
let flagged = encode_after(&[said_before(&save_button(8))], &[save_button(8)], 1.0);
let quiet = encode_elements(&[save_button(8)], 1.0);
assert_eq!(
flagged.split('\t').nth(29),
Some("1"),
"the button says something new"
);
assert_eq!(
quiet.split('\t').nth(29),
Some("0"),
"the button reads as before"
);
}
#[test]
fn the_record_places_a_tab_in_its_group() {
let mut tab = save_button(8);
tab.selected = Some(true);
tab.collection_item = Some(CollectionItem {
position: 2,
count: 5,
horizontal: true,
});
let payload = encode_elements(&[tab, save_button(9)], 1.0);
let records: Vec<_> = payload.split('\n').collect();
let placed: Vec<_> = records[0].split('\t').collect();
let loose: Vec<_> = records[1].split('\t').collect();
assert_eq!(
(placed[30], placed[31]),
("0", "1"),
"the second tab of a row is column one"
);
assert_eq!(
(loose[30], loose[31]),
("-1", "-1"),
"a button outside a group has no place"
);
}
#[test]
fn the_record_carries_the_title_of_a_pane() {
let mut screen = AccessibilityElement {
node_id: 1,
bounds: AccessibilityRect::new(0.0, 0.0, 300.0, 600.0),
..AccessibilityElement::default()
};
screen.update_details(|details| details.pane_title = Some("Library".into()));
let payload = encode_elements(&[screen, save_button(8)], 1.0);
let records: Vec<_> = payload.split('\n').collect();
assert_eq!(records[0].split('\t').nth(32), Some("Library"));
assert_eq!(
records[1].split('\t').nth(32),
Some(""),
"a button names no pane"
);
}
#[test]
fn the_record_says_why_a_field_is_wrong() {
let mut field = save_button(8);
field.update_details(|details| details.error = Some("needs a number".into()));
let payload = encode_elements(&[field, save_button(9)], 1.0);
let records: Vec<_> = payload.split('\n').collect();
assert_eq!(records[0].split('\t').nth(33), Some("needs a number"));
assert_eq!(
records[1].split('\t').nth(33),
Some(""),
"a sound control names no error"
);
}
#[test]
fn the_record_marks_a_field_that_holds_a_secret() {
let mut field = save_button(8);
field.update_details(|details| details.password = true);
let payload = encode_elements(&[field, save_button(9)], 1.0);
let records: Vec<_> = payload.split('\n').collect();
assert_eq!(records[0].split('\t').nth(34), Some("1"));
assert_eq!(records[1].split('\t').nth(34), Some("0"));
}
#[test]
fn the_record_says_whether_a_control_is_open() {
let mut open = save_button(8);
open.update_details(|details| details.expanded = Some(true));
let mut closed = save_button(9);
closed.update_details(|details| details.expanded = Some(false));
let payload = encode_elements(&[open, closed, save_button(10)], 1.0);
let records: Vec<_> = payload.split('\n').collect();
assert_eq!(records[0].split('\t').nth(35), Some("1"));
assert_eq!(records[1].split('\t').nth(35), Some("0"));
assert_eq!(
records[2].split('\t').nth(35),
Some("-1"),
"a plain button opens nothing"
);
}
#[test]
fn the_record_says_whether_a_control_has_a_way_out() {
let mut row = save_button(8);
row.update_details(|details| details.dismissable = true);
let payload = encode_elements(&[row, save_button(9)], 1.0);
let records: Vec<_> = payload.split('\n').collect();
assert_eq!(records[0].split('\t').nth(37), Some("1"));
assert_eq!(
records[1].split('\t').nth(37),
Some("0"),
"a plain button says nothing about a way out"
);
}
#[test]
fn the_record_says_whether_a_list_takes_a_row_number() {
let mut list = save_button(8);
list.update_details(|details| details.scroll_to_index = true);
let payload = encode_elements(&[list, save_button(9)], 1.0);
let records: Vec<_> = payload.split('\n').collect();
assert_eq!(records[0].split('\t').nth(38), Some("1"));
assert_eq!(
records[1].split('\t').nth(38),
Some("0"),
"a plain button takes no row number"
);
}
#[test]
fn fields_are_written_little_endian_and_text_after_its_length() {
let mut out = Vec::new();
let mut record = RecordWriter(&mut out);
record.number(-2);
record.float(12.5);
record.text("é");
record.actions(&["a".into(), String::new()]);
let mut expected = Vec::new();
expected.extend_from_slice(&(-2i32).to_le_bytes());
expected.extend_from_slice(&12.5f32.to_le_bytes());
expected.extend_from_slice(&[2, 0, 0, 0, 0xc3, 0xa9]);
expected.extend_from_slice(&[2, 0, 0, 0, 1, 0, 0, 0, b'a', 0, 0, 0, 0]);
assert_eq!(out, expected);
assert_eq!(count(usize::MAX), i32::MAX);
assert_eq!(count(12), 12);
}