Skip to content

Commit d549c10

Browse files
author
Test User
committed
refactor: implement UserInterface abstraction layer for testability
1 parent fe74339 commit d549c10

File tree

10 files changed

+1784
-630
lines changed

10 files changed

+1784
-630
lines changed

docs/coverage-improvement-plan.md

Lines changed: 808 additions & 0 deletions
Large diffs are not rendered by default.

src/commands.rs

Lines changed: 80 additions & 122 deletions
Large diffs are not rendered by default.

src/input_esc_raw.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,7 @@ pub fn input_with_esc_support_raw(prompt: &str, default: Option<&str>) -> Option
154154
/// None => println!("Operation cancelled"),
155155
/// }
156156
/// ```
157+
#[allow(dead_code)]
157158
pub fn input_esc_raw(prompt: &str) -> Option<String> {
158159
input_with_esc_support_raw(prompt, None)
159160
}

src/lib.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
//! - [`repository_info`] - Repository context detection
2929
//! - [`utils`] - Utility functions for terminal output
3030
//! - [`input_esc_raw`] - Custom input handling with ESC key support
31+
//! - [`ui`] - User interface abstraction layer for testability
3132
//!
3233
//! # Usage Example
3334
//!
@@ -52,4 +53,5 @@ pub mod hooks;
5253
pub mod input_esc_raw;
5354
pub mod menu;
5455
pub mod repository_info;
56+
pub mod ui;
5557
pub mod utils;

src/main.rs

Lines changed: 6 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,6 @@ use anyhow::Result;
4040
use clap::Parser;
4141
use colored::*;
4242
use console::Term;
43-
use dialoguer::Select;
4443
use std::env;
4544
use std::io::{self, Write};
4645

@@ -53,12 +52,13 @@ mod hooks;
5352
mod input_esc_raw;
5453
mod menu;
5554
mod repository_info;
55+
mod ui;
5656
mod utils;
5757

5858
use constants::header_separator;
5959
use menu::MenuItem;
6060
use repository_info::get_repository_info;
61-
use utils::get_theme;
61+
use ui::{DialoguerUI, UserInterface};
6262

6363
/// Command-line arguments for Git Workers
6464
///
@@ -160,14 +160,10 @@ fn main() -> Result<()> {
160160
let display_items: Vec<String> = menu_items.iter().map(|item| item.to_string()).collect();
161161

162162
// Show menu with List worktrees as default selection
163-
let selection = match Select::with_theme(&get_theme())
164-
.with_prompt(constants::PROMPT_ACTION)
165-
.items(&display_items)
166-
.default(0) // Set List worktrees (index 0) as default
167-
.interact_opt()?
168-
{
169-
Some(selection) => selection,
170-
None => {
163+
let ui = DialoguerUI;
164+
let selection = match ui.select(constants::PROMPT_ACTION, &display_items) {
165+
Ok(selection) => selection,
166+
Err(_) => {
171167
// User pressed ESC - exit cleanly
172168
clear_screen(&term);
173169
let exit_msg = constants::INFO_EXITING.bright_black();

src/ui.rs

Lines changed: 321 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,321 @@
1+
//! User Interface abstraction layer
2+
//!
3+
//! This module provides an abstraction over user interface interactions,
4+
//! allowing for testable code by separating business logic from UI dependencies.
5+
6+
use anyhow::Result;
7+
use dialoguer::{Confirm, FuzzySelect, Input, MultiSelect, Select};
8+
use std::collections::VecDeque;
9+
10+
/// Trait for user interface interactions
11+
///
12+
/// This trait abstracts all user input operations, making the code testable
13+
/// by allowing mock implementations for testing and real implementations for production.
14+
pub trait UserInterface {
15+
/// Display a selection menu and return the selected index
16+
fn select(&self, prompt: &str, items: &[String]) -> Result<usize>;
17+
18+
/// Display a fuzzy-searchable selection menu and return the selected index
19+
fn fuzzy_select(&self, prompt: &str, items: &[String]) -> Result<usize>;
20+
21+
/// Get text input from user
22+
fn input(&self, prompt: &str) -> Result<String>;
23+
24+
/// Get text input with a default value
25+
fn input_with_default(&self, prompt: &str, default: &str) -> Result<String>;
26+
27+
/// Ask for user confirmation (yes/no)
28+
#[allow(dead_code)]
29+
fn confirm(&self, prompt: &str) -> Result<bool>;
30+
31+
/// Ask for user confirmation with a default value
32+
fn confirm_with_default(&self, prompt: &str, default: bool) -> Result<bool>;
33+
34+
/// Display a multi-selection menu and return selected indices
35+
#[allow(dead_code)]
36+
fn multiselect(&self, prompt: &str, items: &[String]) -> Result<Vec<usize>>;
37+
}
38+
39+
/// Production implementation using dialoguer
40+
pub struct DialoguerUI;
41+
42+
impl UserInterface for DialoguerUI {
43+
fn select(&self, prompt: &str, items: &[String]) -> Result<usize> {
44+
let selection = Select::new()
45+
.with_prompt(prompt)
46+
.items(items)
47+
.interact_opt()?;
48+
selection.ok_or_else(|| anyhow::anyhow!("User cancelled selection"))
49+
}
50+
51+
fn fuzzy_select(&self, prompt: &str, items: &[String]) -> Result<usize> {
52+
let selection = FuzzySelect::new()
53+
.with_prompt(prompt)
54+
.items(items)
55+
.interact_opt()?;
56+
selection.ok_or_else(|| anyhow::anyhow!("User cancelled fuzzy selection"))
57+
}
58+
59+
fn input(&self, prompt: &str) -> Result<String> {
60+
let input = Input::<String>::new().with_prompt(prompt).interact_text()?;
61+
Ok(input)
62+
}
63+
64+
fn input_with_default(&self, prompt: &str, default: &str) -> Result<String> {
65+
let input = Input::<String>::new()
66+
.with_prompt(prompt)
67+
.default(default.to_string())
68+
.interact_text()?;
69+
Ok(input)
70+
}
71+
72+
fn confirm(&self, prompt: &str) -> Result<bool> {
73+
let confirmed = Confirm::new().with_prompt(prompt).interact_opt()?;
74+
confirmed.ok_or_else(|| anyhow::anyhow!("User cancelled confirmation"))
75+
}
76+
77+
fn confirm_with_default(&self, prompt: &str, default: bool) -> Result<bool> {
78+
let confirmed = Confirm::new()
79+
.with_prompt(prompt)
80+
.default(default)
81+
.interact_opt()?;
82+
confirmed.ok_or_else(|| anyhow::anyhow!("User cancelled confirmation"))
83+
}
84+
85+
fn multiselect(&self, prompt: &str, items: &[String]) -> Result<Vec<usize>> {
86+
let selections = MultiSelect::new()
87+
.with_prompt(prompt)
88+
.items(items)
89+
.interact_opt()?;
90+
selections.ok_or_else(|| anyhow::anyhow!("User cancelled multiselection"))
91+
}
92+
}
93+
94+
/// Mock implementation for testing
95+
///
96+
/// Uses interior mutability to allow mutable access through immutable references,
97+
/// enabling testable UI interactions in the UserInterface trait.
98+
pub struct MockUI {
99+
selections: std::cell::RefCell<VecDeque<usize>>,
100+
inputs: std::cell::RefCell<VecDeque<String>>,
101+
confirms: std::cell::RefCell<VecDeque<bool>>,
102+
multiselects: std::cell::RefCell<VecDeque<Vec<usize>>>,
103+
}
104+
105+
impl Default for MockUI {
106+
fn default() -> Self {
107+
Self::new()
108+
}
109+
}
110+
111+
impl MockUI {
112+
/// Create a new MockUI instance
113+
pub fn new() -> Self {
114+
Self {
115+
selections: std::cell::RefCell::new(VecDeque::new()),
116+
inputs: std::cell::RefCell::new(VecDeque::new()),
117+
confirms: std::cell::RefCell::new(VecDeque::new()),
118+
multiselects: std::cell::RefCell::new(VecDeque::new()),
119+
}
120+
}
121+
122+
/// Add a selection response (for select() calls)
123+
#[allow(dead_code)]
124+
pub fn with_selection(self, selection: usize) -> Self {
125+
self.selections.borrow_mut().push_back(selection);
126+
self
127+
}
128+
129+
/// Add an input response (for input() calls)
130+
#[allow(dead_code)]
131+
pub fn with_input(self, input: impl Into<String>) -> Self {
132+
self.inputs.borrow_mut().push_back(input.into());
133+
self
134+
}
135+
136+
/// Add a confirmation response (for confirm() calls)
137+
#[allow(dead_code)]
138+
pub fn with_confirm(self, confirm: bool) -> Self {
139+
self.confirms.borrow_mut().push_back(confirm);
140+
self
141+
}
142+
143+
/// Add a multiselect response (for multiselect() calls)
144+
#[allow(dead_code)]
145+
pub fn with_multiselect(self, selections: Vec<usize>) -> Self {
146+
self.multiselects.borrow_mut().push_back(selections);
147+
self
148+
}
149+
150+
/// Check if all configured responses have been consumed
151+
#[allow(dead_code)]
152+
pub fn is_exhausted(&self) -> bool {
153+
self.selections.borrow().is_empty()
154+
&& self.inputs.borrow().is_empty()
155+
&& self.confirms.borrow().is_empty()
156+
&& self.multiselects.borrow().is_empty()
157+
}
158+
}
159+
160+
impl UserInterface for MockUI {
161+
fn select(&self, _prompt: &str, _items: &[String]) -> Result<usize> {
162+
self.selections
163+
.borrow_mut()
164+
.pop_front()
165+
.ok_or_else(|| anyhow::anyhow!("No more selections configured for MockUI"))
166+
}
167+
168+
fn fuzzy_select(&self, _prompt: &str, _items: &[String]) -> Result<usize> {
169+
// For testing, fuzzy select behaves the same as regular select
170+
self.selections
171+
.borrow_mut()
172+
.pop_front()
173+
.ok_or_else(|| anyhow::anyhow!("No more selections configured for MockUI"))
174+
}
175+
176+
fn input(&self, _prompt: &str) -> Result<String> {
177+
self.inputs
178+
.borrow_mut()
179+
.pop_front()
180+
.ok_or_else(|| anyhow::anyhow!("No more inputs configured for MockUI"))
181+
}
182+
183+
fn input_with_default(&self, _prompt: &str, default: &str) -> Result<String> {
184+
// Try to get configured input, fall back to default
185+
if let Some(input) = self.inputs.borrow_mut().pop_front() {
186+
Ok(input)
187+
} else {
188+
Ok(default.to_string())
189+
}
190+
}
191+
192+
fn confirm(&self, _prompt: &str) -> Result<bool> {
193+
self.confirms
194+
.borrow_mut()
195+
.pop_front()
196+
.ok_or_else(|| anyhow::anyhow!("No more confirmations configured for MockUI"))
197+
}
198+
199+
fn confirm_with_default(&self, _prompt: &str, default: bool) -> Result<bool> {
200+
// Try to get configured confirmation, fall back to default
201+
if let Some(confirm) = self.confirms.borrow_mut().pop_front() {
202+
Ok(confirm)
203+
} else {
204+
Ok(default)
205+
}
206+
}
207+
208+
fn multiselect(&self, _prompt: &str, _items: &[String]) -> Result<Vec<usize>> {
209+
self.multiselects
210+
.borrow_mut()
211+
.pop_front()
212+
.ok_or_else(|| anyhow::anyhow!("No more multiselects configured for MockUI"))
213+
}
214+
}
215+
216+
#[cfg(test)]
217+
mod tests {
218+
use super::*;
219+
220+
#[test]
221+
fn test_mock_ui_creation() {
222+
let mock_ui = MockUI::new()
223+
.with_selection(1)
224+
.with_input("test-branch")
225+
.with_confirm(true)
226+
.with_multiselect(vec![0, 2]);
227+
228+
// MockUI should be created successfully
229+
assert_eq!(mock_ui.selections.borrow().len(), 1);
230+
assert_eq!(mock_ui.inputs.borrow().len(), 1);
231+
assert_eq!(mock_ui.confirms.borrow().len(), 1);
232+
assert_eq!(mock_ui.multiselects.borrow().len(), 1);
233+
}
234+
235+
#[test]
236+
fn test_mock_ui_exhaustion_check() {
237+
let mock_ui = MockUI::new();
238+
assert!(mock_ui.is_exhausted());
239+
240+
let mock_ui = MockUI::new().with_selection(0);
241+
assert!(!mock_ui.is_exhausted());
242+
}
243+
244+
#[test]
245+
fn test_dialoguer_ui_trait_implementation() {
246+
let _ui = DialoguerUI;
247+
// DialoguerUI should implement UserInterface trait
248+
// This test just verifies the struct can be instantiated
249+
}
250+
251+
#[test]
252+
fn test_mock_ui_functional_behavior() -> Result<()> {
253+
let mock_ui = MockUI::new()
254+
.with_selection(2)
255+
.with_selection(3) // For fuzzy_select
256+
.with_input("feature-branch")
257+
.with_confirm(false)
258+
.with_confirm(true) // For confirm_with_default fallback
259+
.with_multiselect(vec![1, 3]);
260+
261+
// Test that the methods return configured values
262+
assert_eq!(
263+
mock_ui.select("test", &["a".to_string(), "b".to_string()])?,
264+
2
265+
);
266+
assert_eq!(
267+
mock_ui.fuzzy_select("test", &["a".to_string(), "b".to_string()])?,
268+
3
269+
);
270+
assert_eq!(mock_ui.input("test")?, "feature-branch");
271+
assert!(!mock_ui.confirm("test")?);
272+
assert!(mock_ui.confirm_with_default("test", false)?);
273+
assert_eq!(mock_ui.multiselect("test", &["a".to_string()])?, vec![1, 3]);
274+
275+
// Now the mock should be exhausted
276+
assert!(mock_ui.is_exhausted());
277+
278+
Ok(())
279+
}
280+
281+
#[test]
282+
fn test_mock_ui_input_with_default() -> Result<()> {
283+
let mock_ui = MockUI::new().with_input("custom-input");
284+
285+
// Should return configured input
286+
assert_eq!(
287+
mock_ui.input_with_default("test", "default")?,
288+
"custom-input"
289+
);
290+
291+
// Should now fall back to default since no more inputs configured
292+
assert_eq!(mock_ui.input_with_default("test", "fallback")?, "fallback");
293+
294+
Ok(())
295+
}
296+
297+
#[test]
298+
fn test_mock_ui_confirm_with_default() -> Result<()> {
299+
let mock_ui = MockUI::new().with_confirm(false);
300+
301+
// Should return configured confirmation
302+
assert!(!mock_ui.confirm_with_default("test", true)?);
303+
304+
// Should now fall back to default since no more confirmations configured
305+
assert!(mock_ui.confirm_with_default("test", true)?);
306+
307+
Ok(())
308+
}
309+
310+
#[test]
311+
fn test_mock_ui_error_on_exhaustion() {
312+
let mock_ui = MockUI::new();
313+
314+
// Should error when no responses are configured
315+
assert!(mock_ui.select("test", &["a".to_string()]).is_err());
316+
assert!(mock_ui.fuzzy_select("test", &["a".to_string()]).is_err());
317+
assert!(mock_ui.input("test").is_err());
318+
assert!(mock_ui.confirm("test").is_err());
319+
assert!(mock_ui.multiselect("test", &["a".to_string()]).is_err());
320+
}
321+
}

0 commit comments

Comments
 (0)