use std::fs;
use crate::app::{Action, App};
use crate::config::Config;
use crate::ui::commandbar;
use crate::ui::nav::TopMode;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use tempfile::TempDir;
fn empty_app() -> (TempDir, App) {
let tmp = TempDir::new().unwrap();
let issues = tmp.path().join("issues");
fs::create_dir_all(&issues).unwrap();
let config_path = tmp.path().join("tix.toml");
let mut config = Config {
issues_dir: issues,
..Config::starter()
};
config.layout.move_on_change = false;
config.save(&config_path).unwrap();
let config = Config::load_with_warning(&config_path, |_| {}).unwrap();
let app = App::new(&config).unwrap();
(tmp, app)
}
fn rendered(app: &App) -> String {
use ratatui::Terminal;
use ratatui::backend::TestBackend;
let mut terminal = Terminal::new(TestBackend::new(100, 32)).unwrap();
terminal
.draw(|frame| crate::app::render(app, frame, frame.area()))
.unwrap();
format!("{:?}", terminal.backend().buffer())
}
fn rendered_text(app: &App) -> String {
use ratatui::Terminal;
use ratatui::backend::TestBackend;
let mut terminal = Terminal::new(TestBackend::new(100, 32)).unwrap();
terminal
.draw(|frame| crate::app::render(app, frame, frame.area()))
.unwrap();
let buffer = terminal.backend().buffer();
(0..buffer.area.height)
.map(|y| {
(0..buffer.area.width)
.map(|x| buffer[(x, y)].symbol())
.collect::<String>()
})
.collect::<Vec<_>>()
.join("\n")
}
#[test]
fn every_advertised_action_has_a_visible_outcome_when_nothing_is_eligible() {
let mut silent = Vec::new();
for mode in TopMode::ALL {
let context = commandbar::hint_context_for_mode(mode);
let actions = {
let (tmp, app) = empty_app();
let actions = app.bindings.bound_actions(context);
drop(tmp);
actions
};
for action in actions {
let (_tmp, mut app) = empty_app();
app.switch_top_mode(mode).unwrap();
let expected_refusal = app.action_refusal(context, action).is_some();
let before = rendered(&app);
let key = app.bindings.canonical_event(context, action);
let result = app.handle_key(key).unwrap();
let after = rendered(&app);
if expected_refusal {
if let Some(feedback) = app.action_feedback.as_ref() {
assert_ne!(feedback.reason, "");
assert_ne!(feedback.remedy, "");
let text = rendered_text(&app);
assert!(
text.contains("unavailable:") && text.contains("remedy:"),
"{mode:?} / {} did not render both halves:\n{text}",
action.as_str()
);
} else {
assert_ne!(
before,
after,
"{mode:?} / {} bypassed the refusal without a visible outcome",
action.as_str()
);
}
}
if before == after && matches!(result, Action::None) {
silent.push(format!(
"{:?} / {} ({})",
mode,
action.as_str(),
app.bindings.label(context, action)
));
}
}
}
assert!(
silent.is_empty(),
"these advertised actions produced no visible outcome in an empty fixture:\n {}",
silent.join("\n ")
);
}
#[test]
fn a_refusal_is_consumed_by_the_next_key_and_does_not_leak_across_modes() {
let (_tmp, mut app) = empty_app();
let action = app.handle_key(press_enter()).unwrap();
assert!(matches!(action, Action::None));
assert!(
app.action_feedback
.as_ref()
.is_some_and(|feedback| feedback.remedy.contains("move the cursor"))
);
app.handle_key(press_char('?')).unwrap();
assert!(
app.action_feedback.is_none(),
"a new accepted key must consume the old refusal"
);
assert!(
!rendered_text(&app).contains("unavailable:"),
"the old refusal must not leak into Help"
);
app.switch_top_mode(TopMode::Library).unwrap();
assert!(app.action_feedback.is_none());
}
#[test]
fn advertised_raw_keys_also_have_a_visible_empty_state_outcome() {
fn cycle_tab(app: &mut App) {
app.schedule.as_mut().unwrap().tab = crate::ui::schedule::ScheduleTab::Cycles;
}
fn roadmap_tab(app: &mut App) {
app.portfolio.as_mut().unwrap().tab = crate::ui::portfolio::PortfolioTab::Roadmap;
app.portfolio_roadmap = None;
}
type RawKeyCase = (TopMode, fn(&mut App), &'static [(KeyCode, KeyModifiers)]);
let cases: &[RawKeyCase] = &[
(
TopMode::Triage,
|_| {},
&[
(KeyCode::Char(' '), KeyModifiers::NONE),
(KeyCode::Char('A'), KeyModifiers::NONE),
(KeyCode::Char('a'), KeyModifiers::NONE),
(KeyCode::Char('d'), KeyModifiers::NONE),
(KeyCode::Char('u'), KeyModifiers::NONE),
(KeyCode::Char('f'), KeyModifiers::NONE),
(KeyCode::Char('p'), KeyModifiers::NONE),
(KeyCode::Char('s'), KeyModifiers::NONE),
(KeyCode::Char('@'), KeyModifiers::NONE),
],
),
(
TopMode::Schedule,
cycle_tab,
&[
(KeyCode::Tab, KeyModifiers::NONE),
(KeyCode::Char('n'), KeyModifiers::NONE),
(KeyCode::Char('E'), KeyModifiers::NONE),
(KeyCode::Char('c'), KeyModifiers::NONE),
(KeyCode::Char('R'), KeyModifiers::NONE),
(KeyCode::Char('e'), KeyModifiers::NONE),
(KeyCode::Char('s'), KeyModifiers::NONE),
(KeyCode::Char('a'), KeyModifiers::NONE),
],
),
(
TopMode::Portfolio,
roadmap_tab,
&[
(KeyCode::Char('c'), KeyModifiers::NONE),
(KeyCode::Char('e'), KeyModifiers::NONE),
(KeyCode::Char('a'), KeyModifiers::NONE),
(KeyCode::Char('x'), KeyModifiers::NONE),
(KeyCode::Char('f'), KeyModifiers::NONE),
(KeyCode::Char('r'), KeyModifiers::NONE),
],
),
];
let mut silent = Vec::new();
for (mode, prepare, keys) in cases {
for (code, modifiers) in *keys {
let (_tmp, mut app) = empty_app();
app.switch_top_mode(*mode).unwrap();
prepare(&mut app);
let before = rendered(&app);
app.handle_key(KeyEvent::new(*code, *modifiers)).unwrap();
if app.action_feedback.is_some() {
let text = rendered_text(&app);
assert!(
text.contains("unavailable:") && text.contains("remedy:"),
"{mode:?} / {code:?} did not render both refusal halves:\n{text}"
);
}
if before == rendered(&app) {
silent.push(format!("{mode:?} / {code:?}"));
}
}
}
assert!(
silent.is_empty(),
"these advertised raw keys had no visible empty-state outcome:\n {}",
silent.join("\n ")
);
}
fn press_enter() -> KeyEvent {
KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)
}
fn press_char(c: char) -> KeyEvent {
KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE)
}