mod common;
use std::cell::RefCell;
use std::rc::Rc;
use common::{Leaf, Stack};
use proptest::prelude::*;
use teksilo_canvas::{Point, SizeProposal};
use teksilo_core::event::{EventResponse, PointerButton, WidgetEvent};
use teksilo_core::gesture::MemberState;
use teksilo_core::pointer::touch_action::{PanClaim, TouchAction};
use teksilo_core::pointer::{CancelReason, PointerId};
use teksilo_core::widget_builder::WidgetBuilder;
use teksilo_core::widget_id::WidgetId;
use teksilo_core::widget_tree::WidgetTree;
use teksilo_tokens::{DragActivation, PenKind, PointerKind};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
struct NodeSpec {
tap: bool,
drag: bool,
captures: bool,
previews: bool,
claims_pan: bool,
action: TouchAction,
activation: DragActivation,
dead_zone: bool,
}
#[derive(Clone, Debug)]
struct Script {
kind: PointerKind,
steps: Vec<(f32, f32)>,
cancelled: bool,
}
fn arb_node() -> impl Strategy<Value = NodeSpec> {
(
any::<bool>(),
any::<bool>(),
any::<bool>(),
any::<bool>(),
any::<bool>(),
arb_action(),
arb_activation(),
prop::sample::select(vec![false, false, false, false, false, false, false, true]),
)
.prop_map(
|(tap, drag, captures, previews, claims_pan, action, activation, dead_zone)| NodeSpec {
tap,
drag,
captures,
previews,
claims_pan,
action,
activation,
dead_zone,
},
)
}
fn arb_action() -> impl Strategy<Value = TouchAction> {
prop::sample::select(vec![
TouchAction::AUTO,
TouchAction::AUTO,
TouchAction::AUTO,
TouchAction::NONE,
TouchAction::PAN,
TouchAction::PAN_X,
TouchAction::PAN_Y,
TouchAction::MANIPULATION,
])
}
fn arb_activation() -> impl Strategy<Value = DragActivation> {
prop::sample::select(vec![
DragActivation::Auto,
DragActivation::Auto,
DragActivation::Immediate,
DragActivation::AfterLongPress,
])
}
fn arb_kind() -> impl Strategy<Value = PointerKind> {
prop::sample::select(vec![
PointerKind::Mouse,
PointerKind::Touch,
PointerKind::Pen(PenKind::Pen),
])
}
fn arb_stack() -> impl Strategy<Value = Vec<NodeSpec>> {
prop::collection::vec(arb_node(), 1..=5)
}
fn arb_script() -> impl Strategy<Value = Script> {
(
arb_kind(),
prop::collection::vec((-120.0f32..120.0, -120.0f32..120.0), 0..=14),
any::<bool>(),
)
.prop_map(|(kind, steps, cancelled)| Script {
kind,
steps,
cancelled,
})
}
fn arb_stack_and_script() -> impl Strategy<Value = (Vec<NodeSpec>, Script)> {
(arb_stack(), arb_script())
}
struct Run {
tree: WidgetTree,
pointer: PointerId,
ids: Vec<WidgetId>,
cancels: Vec<(WidgetId, CancelReason)>,
winners: Vec<Option<WidgetId>>,
states: Vec<Vec<(WidgetId, MemberState)>>,
}
type CancelLog = Rc<RefCell<Vec<(WidgetId, CancelReason)>>>;
fn build(stack: &[NodeSpec]) -> (WidgetTree, Vec<WidgetId>, CancelLog) {
let log: CancelLog = Rc::new(RefCell::new(Vec::new()));
let mut tree = WidgetTree::new();
let mut ids: Vec<WidgetId> = Vec::new();
for (depth, spec) in stack.iter().enumerate().rev() {
let slot = Rc::new(std::cell::Cell::new(None::<WidgetId>));
let (log_for_node, slot_for_node) = (log.clone(), slot.clone());
let base = Stack::new();
let base = match ids.last() {
Some(&child) => base.add_child(child),
None => base,
};
let _ = depth;
let mut w = base
.on_pointer_cancel(move |_p, reason, _c| {
if let Some(id) = slot_for_node.get() {
log_for_node.borrow_mut().push((id, reason));
}
})
.touch_action(spec.action)
.drag_activation(spec.activation);
if spec.tap {
w = w.on_tap(|_e, _c| {});
}
if spec.drag {
w = w.on_drag(|_p, _c| {});
}
if spec.captures {
w = w.on_pointer_event(|event, ctx| {
if matches!(event, WidgetEvent::PointerDown { .. }) {
ctx.capture_pointer();
return EventResponse::Handled;
}
EventResponse::Ignored
});
} else if spec.previews {
w = w.on_pointer_event(|event, _ctx| {
if matches!(event, WidgetEvent::PointerDown { .. }) {
return EventResponse::Handled;
}
EventResponse::Ignored
});
}
if spec.claims_pan {
w = w.pan_claim(PanClaim::both());
}
if spec.dead_zone {
w = w.gesture_dead_zone(true);
}
let id = tree.add(w);
slot.set(Some(id));
ids.push(id);
}
let leaf = tree.add(Leaf::new());
let _ = leaf;
ids.reverse();
tree.layout(SizeProposal::exact(400.0, 400.0));
(tree, ids, log)
}
fn run_script(stack: &[NodeSpec], script: &Script) -> Run {
let (mut tree, ids, log) = build(stack);
let at = Point::new(200.0, 200.0);
let pointer = match script.kind {
PointerKind::Touch => {
let id = tree.new_contact();
tree.touch_down(id, at);
id
}
PointerKind::Pen(_) => {
tree.pen_down(at, 0.5, (0.0, 0.0));
tree.live_pointers()
.find(|p| matches!(p.kind, PointerKind::Pen(_)))
.map(|p| p.id)
.expect("the pen was admitted")
}
_ => {
tree.pointer_down_button(at, PointerButton::Primary);
PointerId::MOUSE
}
};
let snapshot = |tree: &WidgetTree| -> Vec<(WidgetId, MemberState)> {
tree.sequence_members(pointer)
.into_iter()
.map(|(id, _, state)| (id, state))
.collect()
};
let mut winners = vec![tree.sequence_winner(pointer)];
let mut states = vec![snapshot(&tree)];
let mut last = at;
for &(dx, dy) in &script.steps {
last = Point::new(at.x + dx, at.y + dy);
match script.kind {
PointerKind::Touch => tree.touch_move(pointer, last),
PointerKind::Pen(_) => tree.pen_move(last, 0.5, (0.0, 0.0)),
_ => tree.pointer_move(last),
}
winners.push(tree.sequence_winner(pointer));
states.push(snapshot(&tree));
}
match (script.kind, script.cancelled) {
(PointerKind::Touch, true) => tree.touch_cancel(pointer, last),
(PointerKind::Touch, false) => tree.touch_up(pointer, last),
(_, true) => {
let mut noop = teksilo_core::window::NoopWindowOps;
tree.cancel_pointer(pointer, CancelReason::Platform, &mut noop);
if !script.kind.hovers() {
tree.pointer_up_button(last, PointerButton::Primary);
}
}
(PointerKind::Pen(_), false) => tree.pen_up(last, 0.0, (0.0, 0.0)),
(_, false) => tree.pointer_up_button(last, PointerButton::Primary),
}
let cancels = log.borrow().clone();
Run {
tree,
pointer,
ids,
cancels,
winners,
states,
}
}
proptest! {
#[test]
fn a_sequence_never_changes_its_winner((stack, script) in arb_stack_and_script()) {
let run = run_script(&stack, &script);
let mut settled: Option<WidgetId> = None;
for (step, winner) in run.winners.iter().enumerate() {
match (settled, winner) {
(None, Some(w)) => settled = Some(*w),
(Some(had), Some(now)) => prop_assert_eq!(
had, *now,
"step {}: the sequence changed owner from {:?} to {:?} \
(stack={:?}, script={:?}, winners={:?})",
step, had, now, stack, script, run.winners
),
(Some(had), None) => prop_assert!(
false,
"step {}: the sequence un-decided after {:?} had won \
(stack={:?}, script={:?}, winners={:?})",
step, had, stack, script, run.winners
),
(None, None) => {}
}
}
}
}
proptest! {
#[test]
fn no_node_is_peer_claimed_twice((stack, script) in arb_stack_and_script()) {
let run = run_script(&stack, &script);
for &id in &run.ids {
let claims = run
.cancels
.iter()
.filter(|(who, reason)| *who == id && *reason == CancelReason::PeerClaimed)
.count();
prop_assert!(
claims <= 1,
"{:?} was told PeerClaimed {} times (stack={:?}, script={:?}, cancels={:?})",
id, claims, stack, script, run.cancels
);
}
}
}
proptest! {
#[test]
fn a_peer_claim_only_ever_reaches_a_rejected_member(
(stack, script) in arb_stack_and_script()
) {
let run = run_script(&stack, &script);
let last = run.states.last().cloned().unwrap_or_default();
for (who, reason) in &run.cancels {
if *reason != CancelReason::PeerClaimed {
continue;
}
let state = last.iter().find(|(id, _)| id == who).map(|(_, s)| *s);
prop_assert_eq!(
state,
Some(MemberState::Rejected),
"{:?} was told PeerClaimed but its final member state was {:?} \
(stack={:?}, script={:?}, members={:?})",
who, state, stack, script, last
);
}
}
}
proptest! {
#[test]
fn no_sequence_outlives_its_pointer((stack, script) in arb_stack_and_script()) {
let run = run_script(&stack, &script);
prop_assert!(
run.tree.sequence_members(run.pointer).is_empty(),
"the sequence outlived the pointer: {:?} (stack={:?}, script={:?})",
run.tree.sequence_members(run.pointer), stack, script
);
prop_assert_eq!(
run.tree.sequence_winner(run.pointer),
None,
"a winner outlived the pointer (stack={:?}, script={:?})",
stack, script
);
}
}
proptest! {
#[test]
fn every_script_leaves_no_pointer_state_behind(
(stack, script) in arb_stack_and_script()
) {
let run = run_script(&stack, &script);
run.tree.assert_no_leaked_pointer_state();
}
}
proptest! {
#[test]
fn a_member_state_never_goes_backwards((stack, script) in arb_stack_and_script()) {
fn rank(state: MemberState) -> u8 {
match state {
MemberState::Possible => 0,
MemberState::Held => 1,
MemberState::Won | MemberState::Rejected => 2,
_ => 0,
}
}
let run = run_script(&stack, &script);
for &id in &run.ids {
let mut seen: Option<MemberState> = None;
for (step, snapshot) in run.states.iter().enumerate() {
let Some((_, state)) = snapshot.iter().find(|(other, _)| *other == id) else {
continue;
};
if let Some(before) = seen {
prop_assert!(
rank(*state) >= rank(before),
"{:?} went from {:?} back to {:?} at step {} \
(stack={:?}, script={:?})",
id, before, state, step, stack, script
);
if rank(before) == 2 {
prop_assert_eq!(
*state, before,
"{:?} left the terminal state {:?} for {:?} at step {} \
(stack={:?}, script={:?})",
id, before, state, step, stack, script
);
}
}
seen = Some(*state);
}
}
}
}
#[test]
fn a_tap_streak_escalates_and_then_expires_on_the_virtual_clock() {
use std::cell::Cell;
let singles = Rc::new(Cell::new(0));
let doubles = Rc::new(Cell::new(0));
let triples = Rc::new(Cell::new(0));
let (s, d, t) = (singles.clone(), doubles.clone(), triples.clone());
let mut tree = WidgetTree::new();
tree.add(
Leaf::new()
.on_tap(move |_e, _c| s.set(s.get() + 1))
.on_double_tap(move |_e, _c| d.set(d.get() + 1))
.on_triple_tap(move |_e, _c| t.set(t.get() + 1)),
);
tree.layout(SizeProposal::exact(100.0, 100.0));
let at = Point::new(50.0, 50.0);
for _ in 0..3 {
tree.tap_with(PointerKind::Mouse, at);
}
assert_eq!(doubles.get(), 1, "click 2 fires DoubleTap");
assert_eq!(triples.get(), 1, "click 3 fires TripleTap");
let interval = teksilo_tokens::GestureProfile::MOUSE.multi_tap_interval;
tree.advance_input_time(interval + std::time::Duration::from_millis(1));
let doubles_before = doubles.get();
let triples_before = triples.get();
tree.tap_with(PointerKind::Mouse, at);
assert_eq!(
doubles.get(),
doubles_before,
"the first click after the interval starts a new streak"
);
tree.tap_with(PointerKind::Mouse, at);
assert_eq!(doubles.get(), doubles_before + 1, "and the second doubles");
assert_eq!(
triples.get(),
triples_before,
"…without escalating straight to a triple"
);
tree.assert_no_leaked_pointer_state();
}