Seregon/StratoSDK

StratoSDK is a framework with a declarative approach similar to Flutter/React, written and designed entirely for Rust.

Rust/27.3 KB/No license
crates/strato-ui-core/src/keymap_test.rs
StratoSDK / crates / strato-ui-core / src / keymap_test.rs
1use std::sync::atomic::AtomicBool;
2 
3use super::*;
4use crate::App;
5 
6#[test]
7fn test_keystroke_parse() -> anyhow::Result<()> {
8 assert_eq!(
9 Keystroke::parse("ctrl-p")?,
10 Keystroke {
11 key: "p".into(),
12 ctrl: true,
13 alt: false,
14 shift: false,
15 meta: false,
16 cmd: false,
17 }
18 );
19 
20 assert_eq!(
21 Keystroke::parse("alt-shift-down")?,
22 Keystroke {
23 key: "down".into(),
24 ctrl: false,
25 alt: true,
26 shift: true,
27 meta: false,
28 cmd: false,
29 }
30 );
31 
32 assert_eq!(
33 Keystroke::parse("shift-cmd--")?,
34 Keystroke {
35 key: "-".into(),
36 ctrl: false,
37 alt: false,
38 shift: true,
39 meta: false,
40 cmd: true,
41 }
42 );
43 
44 assert_eq!(
45 Keystroke::parse("shift-cmd-space")?,
46 Keystroke {
47 key: " ".into(),
48 ctrl: false,
49 alt: false,
50 shift: true,
51 meta: false,
52 cmd: true,
53 }
54 );
55 
56 assert_eq!(
57 Keystroke::parse("shift-cmd- ")?,
58 Keystroke {
59 key: " ".into(),
60 ctrl: false,
61 alt: false,
62 shift: true,
63 meta: false,
64 cmd: true,
65 }
66 );
67 
68 assert_eq!(
69 Keystroke::parse("enter")?,
70 Keystroke {
71 key: "enter".into(),
72 ctrl: false,
73 alt: false,
74 shift: false,
75 meta: false,
76 cmd: false,
77 }
78 );
79 
80 Ok(())
81}
82 
83#[test]
84fn test_keystroke_normalized() -> anyhow::Result<()> {
85 assert_eq!(Keystroke::parse("ctrl-p")?.normalized(), "ctrl-p");
86 assert_eq!(Keystroke::parse("cmd-p")?.normalized(), "cmd-p");
87 assert_eq!(Keystroke::parse("ctrl-alt-p")?.normalized(), "ctrl-alt-p");
88 assert_eq!(
89 Keystroke::parse("ctrl-alt-shift-P")?.normalized(),
90 "ctrl-alt-shift-P"
91 );
92 
93 assert_eq!(
94 Keystroke::parse("ctrl-shift-P")?.normalized(),
95 "ctrl-shift-P"
96 );
97 assert_eq!(
98 Keystroke::parse("shift-ctrl-P")?.normalized(),
99 "ctrl-shift-P"
100 );
101 
102 assert_eq!(
103 Keystroke::parse("shift-ctrl-space")?.normalized(),
104 "ctrl-shift-space"
105 );
106 
107 Ok(())
108}
109 
110#[test]
111#[should_panic]
112fn test_keystroke_invalid_shift_lowercase() {
113 let _ = Keystroke::parse("ctrl-shift-p");
114}
115 
116#[test]
117#[should_panic]
118fn test_keystroke_invalid_no_shift_uppercase() {
119 let _ = Keystroke::parse("ctrl-P");
120}
121 
122#[test]
123fn test_keymap_bindings_list() {
124 use crate::keymap::macros::*;
125 
126 #[derive(Debug)]
127 enum TypedAction {
128 Open,
129 Close,
130 Idle,
131 Copy,
132 Paste,
133 }
134 
135 let mut map = Keymap::default();
136 map.register_editable_bindings([EditableBinding::new(
137 "open",
138 "Typed action for Open",
139 TypedAction::Open,
140 )
141 .with_key_binding("cmd-o")]);
142 map.register_fixed_bindings([FixedBinding::new("cmd-c", TypedAction::Copy, always!())]);
143 map.register_editable_bindings([EditableBinding::new(
144 "close",
145 "Typed action for Close",
146 TypedAction::Close,
147 )
148 .with_key_binding("cmd-w")]);
149 map.register_fixed_bindings([FixedBinding::new("cmd-v", TypedAction::Paste, always!())]);
150 map.register_editable_bindings([EditableBinding::new(
151 "idle",
152 "Typed action for Idle",
153 TypedAction::Idle,
154 )]);
155 
156 let mut ordered = map.bindings();
157 
158 // All bindings should be returned.
159 // The precedence order for the bindings should be:
160 // 1. Editable bindings in the reverse order they were registered (LIFO)
161 // 2. Fixed bindings in the reverse order they were added
162 
163 // Given the above, we should have 5 items (in order): Idle, Close, Open, Paste, Copy
164 let first = ordered.next().unwrap();
165 assert_eq!(
166 first
167 .description
168 .unwrap()
169 .in_context(DescriptionContext::Default),
170 "Typed Action for Idle"
171 );
172 assert!(first.trigger == &Trigger::Empty);
173 let typed = first
174 .action
175 .as_ref()
176 .as_any()
177 .downcast_ref::<TypedAction>()
178 .unwrap();
179 assert!(matches!(typed, TypedAction::Idle));
180 
181 let second = ordered.next().unwrap();
182 assert_eq!(
183 second
184 .description
185 .unwrap()
186 .in_context(DescriptionContext::Default),
187 "Typed Action for Close"
188 );
189 assert!(second.trigger == &Trigger::Keystrokes(vec![Keystroke::parse("cmd-w").unwrap()]));
190 let typed = second
191 .action
192 .as_ref()
193 .as_any()
194 .downcast_ref::<TypedAction>()
195 .unwrap();
196 assert!(matches!(typed, TypedAction::Close));
197 
198 let third = ordered.next().unwrap();
199 assert_eq!(
200 third
201 .description
202 .unwrap()
203 .in_context(DescriptionContext::Default),
204 "Typed Action for Open"
205 );
206 assert!(third.trigger == &Trigger::Keystrokes(vec![Keystroke::parse("cmd-o").unwrap()]));
207 let typed = third
208 .action
209 .as_ref()
210 .as_any()
211 .downcast_ref::<TypedAction>()
212 .unwrap();
213 assert!(matches!(typed, TypedAction::Open));
214 
215 let fourth = ordered.next().unwrap();
216 assert!(fourth.description.is_none());
217 assert!(fourth.trigger == &Trigger::Keystrokes(vec![Keystroke::parse("cmd-v").unwrap()]));
218 let typed = fourth
219 .action
220 .as_ref()
221 .as_any()
222 .downcast_ref::<TypedAction>()
223 .unwrap();
224 assert!(matches!(typed, TypedAction::Paste));
225 
226 let fifth = ordered.next().unwrap();
227 assert!(fifth.description.is_none());
228 assert!(fifth.trigger == &Trigger::Keystrokes(vec![Keystroke::parse("cmd-c").unwrap()]));
229 let typed = fifth
230 .action
231 .as_ref()
232 .as_any()
233 .downcast_ref::<TypedAction>()
234 .unwrap();
235 assert!(matches!(typed, TypedAction::Copy));
236 
237 assert!(ordered.next().is_none());
238}
239 
240#[test]
241fn test_binding_description_preserves_case() {
242 let desc = BindingDescription::new_preserve_case("/add-mcp");
243 assert_eq!(desc.in_context(DescriptionContext::Default), "/add-mcp");
244 
245 let desc = BindingDescription::new_preserve_case("Add new MCP server");
246 assert_eq!(
247 desc.in_context(DescriptionContext::Default),
248 "Add new MCP server"
249 );
250}
251 
252#[test]
253fn test_custom_triggers() {
254 #[derive(Debug)]
255 enum TypedAction {
256 First,
257 Second,
258 }
259 
260 let mut map = Keymap::default();
261 let first_default_binding = Keystroke::parse("cmd-1").unwrap();
262 let second_default_binding = Keystroke::parse("cmd-2").unwrap();
263 let first_custom_trigger = Keystroke::parse("cmd-a").unwrap();
264 
265 map.register_editable_bindings([
266 EditableBinding::new("first", "First editable binding", TypedAction::First)
267 .with_key_binding("cmd-1"),
268 EditableBinding::new("second", "Second editable binding", TypedAction::Second)
269 .with_key_binding("cmd-2"),
270 ]);
271 
272 map.update_custom_trigger(
273 "first",
274 Some(Trigger::Keystrokes(vec![first_custom_trigger.clone()])),
275 );
276 
277 {
278 let mut bindings = map.bindings();
279 let second = bindings.next().unwrap();
280 let first = bindings.next().unwrap();
281 assert!(bindings.next().is_none());
282 
283 match first.trigger {
284 Trigger::Keystrokes(keystrokes) => {
285 assert_eq!(keystrokes, std::slice::from_ref(&first_custom_trigger));
286 }
287 _ => panic!("Expected keystroke trigger"),
288 }
289 
290 match second.trigger {
291 Trigger::Keystrokes(keystrokes) => {
292 assert_eq!(keystrokes, std::slice::from_ref(&second_default_binding));
293 }
294 _ => panic!("Expected keystroke trigger"),
295 }
296 }
297 
298 {
299 let mut editable_bindings = map.editable_bindings();
300 let second = editable_bindings.next().unwrap();
301 let first = editable_bindings.next().unwrap();
302 assert!(editable_bindings.next().is_none());
303 
304 match first.trigger {
305 Trigger::Keystrokes(keystrokes) => {
306 assert_eq!(keystrokes, &[first_custom_trigger]);
307 }
308 _ => panic!("Expected keystroke trigger"),
309 }
310 
311 match second.trigger {
312 Trigger::Keystrokes(keystrokes) => {
313 assert_eq!(keystrokes, std::slice::from_ref(&second_default_binding));
314 }
315 _ => panic!("Expected keystroke trigger"),
316 }
317 }
318 
319 map.update_custom_trigger("first", None);
320 
321 {
322 let mut bindings = map.bindings();
323 let second = bindings.next().unwrap();
324 let first = bindings.next().unwrap();
325 assert!(bindings.next().is_none());
326 
327 match first.trigger {
328 Trigger::Keystrokes(keystrokes) => {
329 assert_eq!(keystrokes, &[first_default_binding]);
330 }
331 _ => panic!("Expected keystroke trigger"),
332 }
333 
334 match second.trigger {
335 Trigger::Keystrokes(keystrokes) => {
336 assert_eq!(keystrokes, &[second_default_binding]);
337 }
338 _ => panic!("Expected keystroke trigger"),
339 }
340 }
341}
342 
343#[test]
344fn test_disabled_bindings() {
345 #[derive(Debug)]
346 enum TypedAction {
347 AlwaysAvailable,
348 Enableable,
349 }
350 
351 static TOGGLE: AtomicBool = AtomicBool::new(true);
352 
353 let mut map = Keymap::default();
354 
355 map.register_editable_bindings([
356 EditableBinding::new("always", "First Binding", TypedAction::AlwaysAvailable)
357 .with_key_binding("cmd-1"),
358 EditableBinding::new("toggle", "Second Binding", TypedAction::Enableable)
359 .with_key_binding("cmd-2")
360 .with_enabled(|| TOGGLE.load(Ordering::Relaxed)),
361 ]);
362 
363 // Since `TOGGLE` is `true`, both bindings should be listed.
364 {
365 let mut bindings = map.bindings();
366 let second = bindings.next().unwrap();
367 let first = bindings.next().unwrap();
368 
369 assert_eq!(
370 first
371 .description
372 .unwrap()
373 .in_context(DescriptionContext::Default),
374 "First Binding"
375 );
376 
377 assert_eq!(
378 second
379 .description
380 .unwrap()
381 .in_context(DescriptionContext::Default),
382 "Second Binding"
383 );
384 }
385 
386 // If the binding is toggled off, it should no longer be listed.
387 TOGGLE.store(false, Ordering::Relaxed);
388 
389 {
390 let mut bindings = map.bindings();
391 
392 let first = bindings.next().expect("First binding should exist");
393 assert_eq!(
394 first
395 .description
396 .unwrap()
397 .in_context(DescriptionContext::Default),
398 "First Binding"
399 );
400 
401 assert!(bindings.next().is_none());
402 }
403}
404 
405#[test]
406fn test_binding_description_has_dynamic_override() {
407 let plain = BindingDescription::new("static");
408 assert!(!plain.has_dynamic_override());
409 
410 let dynamic =
411 BindingDescription::new("static").with_dynamic_override(|_| Some("dynamic".into()));
412 assert!(dynamic.has_dynamic_override());
413}
414 
415#[test]
416fn test_binding_description_in_context_ignores_dynamic_override() {
417 let desc = BindingDescription::new("static").with_dynamic_override(|_| Some("dynamic".into()));
418 assert_eq!(desc.in_context(DescriptionContext::Default), "Static");
419}
420 
421#[test]
422fn test_binding_description_eq_ignores_dynamic_override() {
423 let plain = BindingDescription::new("static");
424 let with_dynamic_override =
425 BindingDescription::new("static").with_dynamic_override(|_| Some("dynamic".into()));
426 let with_different_override =
427 BindingDescription::new("static").with_dynamic_override(|_| Some("other".into()));
428 
429 assert_eq!(plain, with_dynamic_override);
430 assert_eq!(with_dynamic_override, with_different_override);
431 
432 let different_static = BindingDescription::new("different");
433 assert_ne!(plain, different_static);
434}
435 
436#[test]
437fn test_binding_description_resolve_static() {
438 App::test((), |app| async move {
439 let resolved = app.read(|ctx| {
440 BindingDescription::new("static")
441 .resolve(ctx, DescriptionContext::Default)
442 .into_owned()
443 });
444 assert_eq!(resolved, "Static");
445 });
446}
447 
448#[test]
449fn test_binding_description_resolve_dynamic_override() {
450 App::test((), |app| async move {
451 let resolved = app.read(|ctx| {
452 BindingDescription::new("static")
453 .with_dynamic_override(|_| Some("dynamic".into()))
454 .resolve(ctx, DescriptionContext::Default)
455 .into_owned()
456 });
457 assert_eq!(resolved, "Dynamic");
458 });
459}
460 
461#[test]
462fn test_binding_description_resolve_dynamic_override_falls_back_to_custom_context() {
463 App::test((), |app| async move {
464 let resolved = app.read(|ctx| {
465 BindingDescription::new("static")
466 .with_custom_description(DescriptionContext::Custom("menu"), "menu-static")
467 .with_dynamic_override(|_| None)
468 .resolve(ctx, DescriptionContext::Custom("menu"))
469 .into_owned()
470 });
471 assert_eq!(resolved, "menu-static");
472 });
473}
474