use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
use quorum_core::memory::{
Dismissal, DismissalId, DismissalReason, FindingIdentityHash, PromotionState,
StateTransitionRow,
};
use quorum_core::review::Finding;
#[derive(Debug, Clone, PartialEq)]
pub enum Command {
None,
Quit,
Dismiss {
finding_index: usize,
reason: DismissalReason,
note: Option<String>,
},
Undo,
OpenHistory,
LoadTransitions(FindingIdentityHash),
Promote {
hash: FindingIdentityHash,
body: String,
},
Demote {
hash: FindingIdentityHash,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Modal {
None,
DismissReason,
DismissNote, Help,
Error,
PromoteText,
DemoteConfirm,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum View {
Main,
History,
}
#[derive(Debug, Clone)]
pub struct UndoEntry {
pub dismissal_id: DismissalId,
pub original_index: usize,
pub finding: Finding,
}
pub struct AppState {
pub findings: Vec<Finding>,
pub selected: usize,
pub body_scroll: u16,
pub modal: Modal,
pub note_buf: String,
pub modal_target_index: Option<usize>,
pub help_visible: bool,
pub status_message: Option<String>,
pub undo_stack: Vec<UndoEntry>,
pub no_expire: bool,
pub session_dismissed_count: u32,
pub view: View,
pub history_rows: Vec<Dismissal>,
pub history_selected: usize,
pub history_body_scroll: u16,
pub history_transitions: Vec<StateTransitionRow>,
pub promote_text_buf: String,
}
impl AppState {
pub fn new(findings: Vec<Finding>, no_expire: bool) -> Self {
Self {
findings,
selected: 0,
body_scroll: 0,
modal: Modal::None,
note_buf: String::new(),
modal_target_index: None,
help_visible: false,
status_message: None,
undo_stack: Vec::new(),
no_expire,
session_dismissed_count: 0,
view: View::Main,
history_rows: Vec::new(),
history_selected: 0,
history_body_scroll: 0,
history_transitions: Vec::new(),
promote_text_buf: String::new(),
}
}
pub fn selected_finding(&self) -> Option<&Finding> {
self.findings.get(self.selected)
}
pub fn on_key(&mut self, key: KeyEvent) -> Command {
if key.kind == KeyEventKind::Release {
return Command::None;
}
match self.modal {
Modal::None => match self.view {
View::Main => self.on_key_list(key),
View::History => self.on_key_history(key),
},
Modal::Help => self.on_key_help(key),
Modal::DismissReason => self.on_key_dismiss_reason(key),
Modal::DismissNote => self.on_key_dismiss_note(key),
Modal::Error => self.on_key_error(key),
Modal::PromoteText => self.on_key_promote_text(key),
Modal::DemoteConfirm => self.on_key_demote_confirm(key),
}
}
fn on_key_list(&mut self, key: KeyEvent) -> Command {
if matches!(key.code, KeyCode::Char('q')) || matches!(key.code, KeyCode::Esc) {
return Command::Quit;
}
if matches!(key.code, KeyCode::Char('c')) && key.modifiers.contains(KeyModifiers::CONTROL) {
return Command::Quit;
}
match key.code {
KeyCode::Char('j') | KeyCode::Down
if !self.findings.is_empty() && self.selected + 1 < self.findings.len() =>
{
self.selected += 1;
self.body_scroll = 0;
}
KeyCode::Char('k') | KeyCode::Up if self.selected > 0 => {
self.selected -= 1;
self.body_scroll = 0;
}
KeyCode::Char('g') => {
self.selected = 0;
self.body_scroll = 0;
}
KeyCode::Char('G') if !self.findings.is_empty() => {
self.selected = self.findings.len() - 1;
self.body_scroll = 0;
}
KeyCode::PageDown => {
self.body_scroll = self.body_scroll.saturating_add(BODY_HALF_PAGE);
}
KeyCode::PageUp => {
self.body_scroll = self.body_scroll.saturating_sub(BODY_HALF_PAGE);
}
KeyCode::Char('d') | KeyCode::Enter if !self.findings.is_empty() => {
self.modal = Modal::DismissReason;
self.modal_target_index = Some(self.selected);
self.status_message = None;
}
KeyCode::Char('u') => {
if !self.undo_stack.is_empty() {
return Command::Undo;
}
self.status_message = Some("nothing to undo".into());
}
KeyCode::Char('?') => {
self.modal = Modal::Help;
self.help_visible = true;
}
KeyCode::Char('H') => {
self.status_message = None;
return Command::OpenHistory;
}
_ => {}
}
Command::None
}
fn on_key_history(&mut self, key: KeyEvent) -> Command {
if matches!(key.code, KeyCode::Char('q')) {
return Command::Quit;
}
if matches!(key.code, KeyCode::Char('c')) && key.modifiers.contains(KeyModifiers::CONTROL) {
return Command::Quit;
}
if matches!(key.code, KeyCode::Char('H')) || matches!(key.code, KeyCode::Esc) {
self.view = View::Main;
self.history_body_scroll = 0;
self.status_message = None;
return Command::None;
}
match key.code {
KeyCode::Char('j') | KeyCode::Down
if !self.history_rows.is_empty()
&& self.history_selected + 1 < self.history_rows.len() =>
{
self.history_selected += 1;
self.history_body_scroll = 0;
if let Some(row) = self.history_rows.get(self.history_selected) {
return Command::LoadTransitions(row.finding_identity_hash);
}
}
KeyCode::Char('k') | KeyCode::Up if self.history_selected > 0 => {
self.history_selected -= 1;
self.history_body_scroll = 0;
if let Some(row) = self.history_rows.get(self.history_selected) {
return Command::LoadTransitions(row.finding_identity_hash);
}
}
KeyCode::Char('g') => {
self.history_selected = 0;
self.history_body_scroll = 0;
if let Some(row) = self.history_rows.first() {
return Command::LoadTransitions(row.finding_identity_hash);
}
}
KeyCode::Char('G') if !self.history_rows.is_empty() => {
self.history_selected = self.history_rows.len() - 1;
self.history_body_scroll = 0;
if let Some(row) = self.history_rows.get(self.history_selected) {
return Command::LoadTransitions(row.finding_identity_hash);
}
}
KeyCode::PageDown => {
self.history_body_scroll = self.history_body_scroll.saturating_add(BODY_HALF_PAGE);
}
KeyCode::PageUp => {
self.history_body_scroll = self.history_body_scroll.saturating_sub(BODY_HALF_PAGE);
}
KeyCode::Char('p') => {
let Some(row) = self.history_rows.get(self.history_selected) else {
return Command::None;
};
match row.promotion_state {
PromotionState::LocalOnly => {
self.promote_text_buf = row.title_snapshot.clone();
self.modal = Modal::PromoteText;
self.status_message = None;
}
PromotionState::Candidate => {
self.status_message = Some(
"promote requires local_only state (this row is candidate; dismiss \
it more or wait for auto-promote)"
.into(),
);
}
PromotionState::PromotedConvention => {
self.status_message =
Some("already a promoted_convention; demote first to update".into());
}
}
}
KeyCode::Char('D') => {
let Some(row) = self.history_rows.get(self.history_selected) else {
return Command::None;
};
match row.promotion_state {
PromotionState::PromotedConvention => {
self.modal = Modal::DemoteConfirm;
self.status_message = None;
}
PromotionState::Candidate | PromotionState::LocalOnly => {
self.status_message = Some(
"demote requires promoted_convention state (use 'p' to promote a \
local_only row first)"
.into(),
);
}
}
}
KeyCode::Char('?') => {
self.modal = Modal::Help;
self.help_visible = true;
}
_ => {}
}
Command::None
}
fn on_key_help(&mut self, _key: KeyEvent) -> Command {
self.modal = Modal::None;
self.help_visible = false;
Command::None
}
fn on_key_dismiss_reason(&mut self, key: KeyEvent) -> Command {
if matches!(key.code, KeyCode::Esc) {
self.modal = Modal::None;
self.modal_target_index = None;
return Command::None;
}
let reason = match key.code {
KeyCode::Char('f') => DismissalReason::FalsePositive,
KeyCode::Char('i') => DismissalReason::Intentional,
KeyCode::Char('s') => DismissalReason::OutOfScope,
KeyCode::Char('w') => DismissalReason::WontFix,
KeyCode::Char('o') => {
self.modal = Modal::DismissNote;
self.note_buf.clear();
return Command::None;
}
_ => return Command::None,
};
let idx = self
.modal_target_index
.expect("modal_target_index set when DismissReason is open");
self.modal = Modal::None;
self.modal_target_index = None;
Command::Dismiss {
finding_index: idx,
reason,
note: None,
}
}
fn on_key_dismiss_note(&mut self, key: KeyEvent) -> Command {
match key.code {
KeyCode::Esc => {
self.modal = Modal::DismissReason; self.note_buf.clear();
Command::None
}
KeyCode::Backspace => {
self.note_buf.pop();
Command::None
}
KeyCode::Enter => self.try_commit_note(),
KeyCode::Char(c) => {
self.append_note_char(c);
Command::None
}
_ => Command::None,
}
}
fn on_key_error(&mut self, _key: KeyEvent) -> Command {
self.modal = Modal::None;
self.status_message = None;
Command::None
}
fn on_key_promote_text(&mut self, key: KeyEvent) -> Command {
match key.code {
KeyCode::Esc => {
self.modal = Modal::None;
self.promote_text_buf.clear();
Command::None
}
KeyCode::Backspace => {
self.promote_text_buf.pop();
Command::None
}
KeyCode::Enter => {
let Some(row) = self.history_rows.get(self.history_selected) else {
self.modal = Modal::None;
self.promote_text_buf.clear();
return Command::None;
};
if row.promotion_state != PromotionState::LocalOnly {
self.status_message = Some(
"promote aborted: row is no longer local_only (snapshot stale; quit \
and re-invoke to refresh)"
.into(),
);
self.modal = Modal::Error;
self.promote_text_buf.clear();
return Command::None;
}
let hash = row.finding_identity_hash;
let body = std::mem::take(&mut self.promote_text_buf)
.trim_end()
.to_string();
self.modal = Modal::None;
Command::Promote { hash, body }
}
KeyCode::Char(c) => {
if c == '\n' || c == '\r' {
return Command::None;
}
if (c as u32) < 0x20 && c != '\t' {
return Command::None;
}
let ch = if c == '\t' { ' ' } else { c };
if self.promote_text_buf.len() + ch.len_utf8()
> quorum_core::memory::BODY_SNAPSHOT_MAX_BYTES
{
return Command::None;
}
self.promote_text_buf.push(ch);
Command::None
}
_ => Command::None,
}
}
fn on_key_demote_confirm(&mut self, key: KeyEvent) -> Command {
match key.code {
KeyCode::Char('Y') => {
let Some(row) = self.history_rows.get(self.history_selected) else {
self.modal = Modal::None;
return Command::None;
};
if row.promotion_state != PromotionState::PromotedConvention {
self.status_message = Some(
"demote aborted: row is no longer promoted_convention (snapshot stale; \
quit and re-invoke to refresh)"
.into(),
);
self.modal = Modal::Error;
return Command::None;
}
let hash = row.finding_identity_hash;
self.modal = Modal::None;
Command::Demote { hash }
}
_ => {
self.modal = Modal::None;
Command::None
}
}
}
fn append_note_char(&mut self, c: char) {
if c == '\n' || c == '\r' {
self.status_message = Some(NOTE_NEWLINE_REJECTED.into());
self.modal = Modal::Error;
return;
}
if (c as u32) < 0x20 && c != '\t' {
return;
}
let ch = if c == '\t' { ' ' } else { c };
if self.note_buf.len() + ch.len_utf8() > NOTE_MAX_BYTES {
self.status_message = Some(NOTE_TOO_LONG.into());
self.modal = Modal::Error;
return;
}
self.note_buf.push(ch);
}
fn try_commit_note(&mut self) -> Command {
let trimmed = self.note_buf.trim().to_string();
if trimmed.is_empty() {
self.status_message = Some(NOTE_REQUIRED.into());
self.modal = Modal::Error;
return Command::None;
}
if trimmed.len() > NOTE_MAX_BYTES {
self.status_message = Some(NOTE_TOO_LONG.into());
self.modal = Modal::Error;
return Command::None;
}
let idx = self
.modal_target_index
.expect("modal_target_index set when DismissNote is open");
self.modal = Modal::None;
self.modal_target_index = None;
let note = std::mem::take(&mut self.note_buf);
Command::Dismiss {
finding_index: idx,
reason: DismissalReason::Other,
note: Some(note),
}
}
pub fn apply_committed_dismissal(&mut self, finding_index: usize, dismissal_id: DismissalId) {
if finding_index >= self.findings.len() {
return;
}
let removed = self.findings.remove(finding_index);
self.undo_stack.push(UndoEntry {
dismissal_id,
original_index: finding_index,
finding: removed,
});
self.session_dismissed_count += 1;
if self.findings.is_empty() {
self.selected = 0;
} else if self.selected >= self.findings.len() {
self.selected = self.findings.len() - 1;
}
self.body_scroll = 0;
}
pub fn apply_undo(&mut self, entry: UndoEntry) {
let pos = entry.original_index.min(self.findings.len());
self.findings.insert(pos, entry.finding);
self.selected = pos;
self.session_dismissed_count = self.session_dismissed_count.saturating_sub(1);
self.body_scroll = 0;
}
pub fn apply_history_loaded(&mut self, rows: Vec<Dismissal>) -> Command {
self.history_rows = rows;
self.history_selected = 0;
self.history_body_scroll = 0;
self.history_transitions.clear();
self.view = View::History;
self.status_message = None;
if let Some(row) = self.history_rows.first() {
Command::LoadTransitions(row.finding_identity_hash)
} else {
Command::None
}
}
pub fn apply_transitions_loaded(&mut self, rows: Vec<StateTransitionRow>) {
self.history_transitions = rows;
}
pub fn apply_promote_committed(
&mut self,
hash: FindingIdentityHash,
short_hash: &str,
title: &str,
) {
for row in self.history_rows.iter_mut() {
if row.finding_identity_hash == hash {
row.promotion_state = PromotionState::PromotedConvention;
break;
}
}
self.status_message = Some(format!(
"promoted {short_hash}: {title} — commit .quorum/conventions.md to apply"
));
}
pub fn apply_demote_committed(&mut self, hash: FindingIdentityHash, short_hash: &str) {
for row in self.history_rows.iter_mut() {
if row.finding_identity_hash == hash {
row.promotion_state = PromotionState::LocalOnly;
break;
}
}
self.status_message = Some(format!("demoted {short_hash} (now local_only)"));
}
pub fn apply_write_failed(&mut self, msg: String) {
self.status_message = Some(msg);
self.modal = Modal::Error;
}
pub fn apply_already_dismissed(&mut self, finding_index: usize) {
if finding_index < self.findings.len() {
self.findings.remove(finding_index);
}
if self.selected >= self.findings.len() && !self.findings.is_empty() {
self.selected = self.findings.len() - 1;
}
self.status_message = Some(ALREADY_DISMISSED.into());
self.modal = Modal::Error;
}
}
pub const NOTE_MAX_BYTES: usize = quorum_core::memory::NOTE_MAX_BYTES;
const BODY_HALF_PAGE: u16 = 10;
pub const NOTE_REQUIRED: &str = "note required for \"other\" reason";
pub const NOTE_TOO_LONG: &str = "note too long; 2KB max";
pub const NOTE_NEWLINE_REJECTED: &str = "note must be single-line; newline rejected";
pub const ALREADY_DISMISSED: &str = "already dismissed; press any key to continue";
#[cfg(test)]
mod tests {
use super::*;
use quorum_core::review::{FindingSource, Severity};
use time::OffsetDateTime;
fn k(code: KeyCode) -> KeyEvent {
KeyEvent::new(code, KeyModifiers::NONE)
}
fn ctrl(c: char) -> KeyEvent {
KeyEvent::new(KeyCode::Char(c), KeyModifiers::CONTROL)
}
fn dismissal(byte: u8, title: &str, state: PromotionState) -> Dismissal {
Dismissal {
id: DismissalId(byte as i64),
finding_identity_hash: FindingIdentityHash([byte; 32]),
title_snapshot: title.to_string(),
body_snapshot: Some(format!("body snapshot for {title}")),
source_type_snapshot: "divergence".into(),
models_snapshot: vec!["m1".into(), "m2".into()],
branch_snapshot: "main".into(),
reason: DismissalReason::FalsePositive,
note: None,
dismissed_at: OffsetDateTime::UNIX_EPOCH,
last_seen_at: OffsetDateTime::UNIX_EPOCH,
last_seen_session_id: None,
recurrence_count: 3,
expires_at: None,
repo_head_sha_first: "abcdef0123".into(),
promotion_state: state,
}
}
fn fixture() -> AppState {
let mk = |t: &str, sev: Severity| Finding {
severity: sev,
title: t.to_string(),
body: format!("body of {t}"),
source: FindingSource::Divergence,
supported_by: vec!["m1".into(), "m2".into()],
confidence: Some(0.9),
};
AppState::new(
vec![
mk("alpha", Severity::High),
mk("beta", Severity::High),
mk("gamma", Severity::Medium),
mk("delta", Severity::Low),
],
false,
)
}
#[test]
fn j_k_navigate_with_no_wrap() {
let mut s = fixture();
assert_eq!(s.selected, 0);
s.on_key(k(KeyCode::Char('j')));
assert_eq!(s.selected, 1);
s.on_key(k(KeyCode::Char('j')));
s.on_key(k(KeyCode::Char('j')));
s.on_key(k(KeyCode::Char('j'))); assert_eq!(s.selected, 3);
s.on_key(k(KeyCode::Char('k')));
assert_eq!(s.selected, 2);
for _ in 0..10 {
s.on_key(k(KeyCode::Char('k')));
}
assert_eq!(s.selected, 0, "k clamps at zero, no wrap");
}
#[test]
fn g_and_g_upper_jump_to_first_and_last() {
let mut s = fixture();
s.on_key(k(KeyCode::Char('G')));
assert_eq!(s.selected, 3);
s.on_key(k(KeyCode::Char('g')));
assert_eq!(s.selected, 0);
}
#[test]
fn pgdn_pgup_advance_body_scroll() {
let mut s = fixture();
assert_eq!(s.body_scroll, 0);
s.on_key(k(KeyCode::PageDown));
assert!(s.body_scroll > 0);
let high = s.body_scroll;
s.on_key(k(KeyCode::PageUp));
assert!(s.body_scroll < high);
}
#[test]
fn d_opens_reason_modal_then_letters_commit() {
let mut s = fixture();
let cmd = s.on_key(k(KeyCode::Char('d')));
assert_eq!(cmd, Command::None);
assert_eq!(s.modal, Modal::DismissReason);
let cmd = s.on_key(k(KeyCode::Char('w')));
match cmd {
Command::Dismiss {
reason: DismissalReason::WontFix,
note: None,
finding_index: 0,
} => {}
other => panic!("unexpected {other:?}"),
}
assert_eq!(s.modal, Modal::None);
}
#[test]
fn enter_is_alias_for_d_in_list_mode() {
let mut s = fixture();
s.on_key(k(KeyCode::Enter));
assert_eq!(s.modal, Modal::DismissReason);
}
#[test]
fn o_opens_note_input_and_validates_rules() {
let mut s = fixture();
s.on_key(k(KeyCode::Char('d')));
s.on_key(k(KeyCode::Char('o')));
assert_eq!(s.modal, Modal::DismissNote);
let cmd = s.on_key(k(KeyCode::Enter));
assert_eq!(cmd, Command::None);
assert_eq!(s.modal, Modal::Error);
assert_eq!(s.status_message.as_deref(), Some(NOTE_REQUIRED));
s.on_key(k(KeyCode::Char('x')));
assert_eq!(s.modal, Modal::None);
s.on_key(k(KeyCode::Char('d')));
s.on_key(k(KeyCode::Char('o')));
s.on_key(k(KeyCode::Char('a')));
s.on_key(k(KeyCode::Char('\n')));
assert_eq!(s.modal, Modal::Error);
assert_eq!(s.status_message.as_deref(), Some(NOTE_NEWLINE_REJECTED));
}
#[test]
fn o_with_valid_note_commits_as_other() {
let mut s = fixture();
s.on_key(k(KeyCode::Char('d')));
s.on_key(k(KeyCode::Char('o')));
for c in "looks intentional".chars() {
s.on_key(k(KeyCode::Char(c)));
}
let cmd = s.on_key(k(KeyCode::Enter));
match cmd {
Command::Dismiss {
reason: DismissalReason::Other,
note: Some(n),
finding_index: 0,
} => assert_eq!(n, "looks intentional"),
other => panic!("unexpected {other:?}"),
}
}
#[test]
fn control_chars_stripped_tab_collapsed_to_space() {
let mut s = fixture();
s.on_key(k(KeyCode::Char('d')));
s.on_key(k(KeyCode::Char('o')));
s.on_key(k(KeyCode::Char('a')));
s.on_key(k(KeyCode::Char('\t'))); s.on_key(k(KeyCode::Char('\u{0007}'))); s.on_key(k(KeyCode::Char('b')));
let cmd = s.on_key(k(KeyCode::Enter));
match cmd {
Command::Dismiss { note: Some(n), .. } => assert_eq!(n, "a b"),
other => panic!("unexpected {other:?}"),
}
}
#[test]
fn note_2kb_cap_enforced_on_input() {
let mut s = fixture();
s.on_key(k(KeyCode::Char('d')));
s.on_key(k(KeyCode::Char('o')));
for _ in 0..NOTE_MAX_BYTES {
s.on_key(k(KeyCode::Char('x')));
}
s.on_key(k(KeyCode::Char('y')));
assert_eq!(s.modal, Modal::Error);
assert_eq!(s.status_message.as_deref(), Some(NOTE_TOO_LONG));
}
#[test]
fn esc_from_reason_modal_returns_to_list() {
let mut s = fixture();
s.on_key(k(KeyCode::Char('d')));
s.on_key(k(KeyCode::Esc));
assert_eq!(s.modal, Modal::None);
assert!(s.modal_target_index.is_none());
}
#[test]
fn esc_from_note_modal_returns_to_reason() {
let mut s = fixture();
s.on_key(k(KeyCode::Char('d')));
s.on_key(k(KeyCode::Char('o')));
s.on_key(k(KeyCode::Char('a')));
s.on_key(k(KeyCode::Esc));
assert_eq!(s.modal, Modal::DismissReason);
assert!(s.note_buf.is_empty());
}
#[test]
fn q_from_list_quits() {
let mut s = fixture();
let cmd = s.on_key(k(KeyCode::Char('q')));
assert_eq!(cmd, Command::Quit);
}
#[test]
fn esc_from_list_quits() {
let mut s = fixture();
let cmd = s.on_key(k(KeyCode::Esc));
assert_eq!(cmd, Command::Quit);
}
#[test]
fn ctrl_c_quits_through_restoration_path() {
let mut s = fixture();
let cmd = s.on_key(ctrl('c'));
assert_eq!(cmd, Command::Quit);
}
#[test]
fn help_toggles_then_any_key_dismisses() {
let mut s = fixture();
s.on_key(k(KeyCode::Char('?')));
assert_eq!(s.modal, Modal::Help);
assert!(s.help_visible);
s.on_key(k(KeyCode::Char('x')));
assert_eq!(s.modal, Modal::None);
assert!(!s.help_visible);
}
#[test]
fn undo_with_empty_stack_is_a_no_op_with_status() {
let mut s = fixture();
let cmd = s.on_key(k(KeyCode::Char('u')));
assert_eq!(cmd, Command::None);
assert_eq!(s.status_message.as_deref(), Some("nothing to undo"));
}
#[test]
fn apply_committed_dismissal_removes_and_advances_no_wrap() {
let mut s = fixture();
s.selected = 1; let removed_title = s.findings[1].title.clone();
s.apply_committed_dismissal(1, DismissalId(42));
assert!(!s.findings.iter().any(|f| f.title == removed_title));
assert_eq!(s.selected, 1, "next finding ('gamma') becomes selected");
assert_eq!(s.undo_stack.len(), 1);
assert_eq!(s.session_dismissed_count, 1);
}
#[test]
fn apply_undo_restores_at_original_index() {
let mut s = fixture();
s.selected = 1;
s.apply_committed_dismissal(1, DismissalId(42));
let popped = s.undo_stack.pop().unwrap();
s.apply_undo(popped);
assert_eq!(s.findings[1].title, "beta");
assert_eq!(s.selected, 1);
assert_eq!(s.session_dismissed_count, 0);
}
#[test]
fn undo_stack_unbounded_no_eviction() {
let mut s = fixture();
for i in 0..50u32 {
s.undo_stack.push(UndoEntry {
dismissal_id: DismissalId(i as i64),
original_index: 0,
finding: s.findings[0].clone(),
});
}
assert_eq!(s.undo_stack.len(), 50);
}
#[test]
fn key_release_events_are_ignored() {
let mut s = fixture();
let mut press = KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE);
press.kind = KeyEventKind::Release;
s.on_key(press);
assert_eq!(s.selected, 0, "Release events do not advance selection");
}
fn shift(c: char) -> KeyEvent {
KeyEvent::new(KeyCode::Char(c), KeyModifiers::SHIFT)
}
fn history_fixture() -> AppState {
let mut s = fixture();
let rows = vec![
dismissal(0x11, "alpha rule", PromotionState::LocalOnly),
dismissal(0x22, "beta candidate", PromotionState::Candidate),
dismissal(0x33, "gamma promoted", PromotionState::PromotedConvention),
];
let cmd = s.apply_history_loaded(rows);
match cmd {
Command::LoadTransitions(_) => {}
other => panic!("expected LoadTransitions after history load, got {other:?}"),
}
s
}
#[test]
fn capital_h_from_main_emits_open_history_without_view_flip() {
let mut s = fixture();
let cmd = s.on_key(shift('H'));
assert_eq!(cmd, Command::OpenHistory);
assert_eq!(s.view, View::Main, "view flips inside apply_history_loaded");
}
#[test]
fn apply_history_loaded_flips_view_and_requests_transitions() {
let mut s = fixture();
let rows = vec![dismissal(1, "row a", PromotionState::LocalOnly)];
let cmd = s.apply_history_loaded(rows);
assert_eq!(s.view, View::History);
assert_eq!(s.history_selected, 0);
match cmd {
Command::LoadTransitions(h) => assert_eq!(h, FindingIdentityHash([1; 32])),
other => panic!("expected LoadTransitions, got {other:?}"),
}
}
#[test]
fn apply_history_loaded_empty_emits_no_command() {
let mut s = fixture();
let cmd = s.apply_history_loaded(Vec::new());
assert_eq!(s.view, View::History);
assert!(s.history_rows.is_empty());
assert_eq!(cmd, Command::None);
}
#[test]
fn capital_h_from_history_returns_to_main() {
let mut s = history_fixture();
assert_eq!(s.view, View::History);
let cmd = s.on_key(shift('H'));
assert_eq!(cmd, Command::None);
assert_eq!(s.view, View::Main);
}
#[test]
fn esc_from_history_returns_to_main_does_not_quit() {
let mut s = history_fixture();
let cmd = s.on_key(k(KeyCode::Esc));
assert_eq!(cmd, Command::None);
assert_eq!(s.view, View::Main);
}
#[test]
fn q_from_history_quits_tui() {
let mut s = history_fixture();
let cmd = s.on_key(k(KeyCode::Char('q')));
assert_eq!(cmd, Command::Quit);
}
#[test]
fn ctrl_c_from_history_quits_tui() {
let mut s = history_fixture();
let cmd = s.on_key(ctrl('c'));
assert_eq!(cmd, Command::Quit);
}
#[test]
fn j_k_in_history_emit_load_transitions_each_move() {
let mut s = history_fixture();
assert_eq!(s.history_selected, 0);
let cmd = s.on_key(k(KeyCode::Char('j')));
assert_eq!(s.history_selected, 1);
match cmd {
Command::LoadTransitions(h) => assert_eq!(h, FindingIdentityHash([0x22; 32])),
other => panic!("unexpected {other:?}"),
}
let cmd = s.on_key(k(KeyCode::Char('k')));
assert_eq!(s.history_selected, 0);
match cmd {
Command::LoadTransitions(h) => assert_eq!(h, FindingIdentityHash([0x11; 32])),
other => panic!("unexpected {other:?}"),
}
}
#[test]
fn p_on_local_only_opens_promote_modal_with_title_seed() {
let mut s = history_fixture();
assert_eq!(s.history_selected, 0);
let cmd = s.on_key(k(KeyCode::Char('p')));
assert_eq!(cmd, Command::None);
assert_eq!(s.modal, Modal::PromoteText);
assert_eq!(s.promote_text_buf, "alpha rule");
}
#[test]
fn p_on_candidate_is_rejected_no_modal_opens() {
let mut s = history_fixture();
s.on_key(k(KeyCode::Char('j'))); let cmd = s.on_key(k(KeyCode::Char('p')));
assert_eq!(cmd, Command::None);
assert_eq!(s.modal, Modal::None);
assert!(s
.status_message
.as_deref()
.unwrap_or("")
.starts_with("promote requires local_only"));
}
#[test]
fn p_on_promoted_convention_is_rejected_no_modal_opens() {
let mut s = history_fixture();
s.on_key(k(KeyCode::Char('j')));
s.on_key(k(KeyCode::Char('j'))); let cmd = s.on_key(k(KeyCode::Char('p')));
assert_eq!(cmd, Command::None);
assert_eq!(s.modal, Modal::None);
assert!(s
.status_message
.as_deref()
.unwrap_or("")
.starts_with("already a promoted_convention"));
}
#[test]
fn promote_modal_enter_with_title_seed_commits_title_only() {
let mut s = history_fixture();
s.on_key(k(KeyCode::Char('p')));
let cmd = s.on_key(k(KeyCode::Enter));
match cmd {
Command::Promote { hash, body } => {
assert_eq!(hash, FindingIdentityHash([0x11; 32]));
assert_eq!(body, "alpha rule");
}
other => panic!("unexpected {other:?}"),
}
assert_eq!(s.modal, Modal::None);
}
#[test]
fn promote_modal_clear_and_enter_commits_empty_body() {
let mut s = history_fixture();
s.on_key(k(KeyCode::Char('p')));
for _ in 0..s.promote_text_buf.len() + 1 {
s.on_key(k(KeyCode::Backspace));
}
let cmd = s.on_key(k(KeyCode::Enter));
match cmd {
Command::Promote { body, .. } => assert_eq!(body, ""),
other => panic!("unexpected {other:?}"),
}
}
#[test]
fn promote_modal_user_edits_then_enter_commits_custom_body() {
let mut s = history_fixture();
s.on_key(k(KeyCode::Char('p')));
s.promote_text_buf.clear();
for c in "do not block on stylistic ABC".chars() {
s.on_key(k(KeyCode::Char(c)));
}
let cmd = s.on_key(k(KeyCode::Enter));
match cmd {
Command::Promote { body, .. } => assert_eq!(body, "do not block on stylistic ABC"),
other => panic!("unexpected {other:?}"),
}
}
#[test]
fn promote_modal_esc_cancels_and_clears_buffer() {
let mut s = history_fixture();
s.on_key(k(KeyCode::Char('p')));
assert!(!s.promote_text_buf.is_empty());
let cmd = s.on_key(k(KeyCode::Esc));
assert_eq!(cmd, Command::None);
assert_eq!(s.modal, Modal::None);
assert!(s.promote_text_buf.is_empty());
}
#[test]
fn promote_modal_rejects_embedded_newlines_silently() {
let mut s = history_fixture();
s.on_key(k(KeyCode::Char('p')));
let before = s.promote_text_buf.len();
let cmd = s.on_key(k(KeyCode::Char('\n')));
assert_eq!(cmd, Command::None);
assert_eq!(s.promote_text_buf.len(), before);
assert_eq!(s.modal, Modal::PromoteText);
}
#[test]
fn capital_d_on_promoted_convention_opens_confirm_modal() {
let mut s = history_fixture();
s.on_key(k(KeyCode::Char('j')));
s.on_key(k(KeyCode::Char('j'))); let cmd = s.on_key(shift('D'));
assert_eq!(cmd, Command::None);
assert_eq!(s.modal, Modal::DemoteConfirm);
}
#[test]
fn capital_d_on_local_only_is_rejected_no_modal_opens() {
let mut s = history_fixture();
let cmd = s.on_key(shift('D'));
assert_eq!(cmd, Command::None);
assert_eq!(s.modal, Modal::None);
assert!(s
.status_message
.as_deref()
.unwrap_or("")
.starts_with("demote requires promoted_convention"));
}
#[test]
fn capital_d_on_candidate_is_rejected_no_modal_opens() {
let mut s = history_fixture();
s.on_key(k(KeyCode::Char('j'))); let cmd = s.on_key(shift('D'));
assert_eq!(cmd, Command::None);
assert_eq!(s.modal, Modal::None);
}
#[test]
fn demote_confirm_y_uppercase_commits() {
let mut s = history_fixture();
s.on_key(k(KeyCode::Char('j')));
s.on_key(k(KeyCode::Char('j')));
s.on_key(shift('D'));
let cmd = s.on_key(shift('Y'));
match cmd {
Command::Demote { hash } => assert_eq!(hash, FindingIdentityHash([0x33; 32])),
other => panic!("unexpected {other:?}"),
}
assert_eq!(s.modal, Modal::None);
}
#[test]
fn demote_confirm_lowercase_y_cancels() {
let mut s = history_fixture();
s.on_key(k(KeyCode::Char('j')));
s.on_key(k(KeyCode::Char('j')));
s.on_key(shift('D'));
let cmd = s.on_key(k(KeyCode::Char('y')));
assert_eq!(cmd, Command::None);
assert_eq!(s.modal, Modal::None);
}
#[test]
fn demote_confirm_n_or_esc_cancels() {
for cancel in [KeyCode::Char('n'), KeyCode::Esc] {
let mut s = history_fixture();
s.on_key(k(KeyCode::Char('j')));
s.on_key(k(KeyCode::Char('j')));
s.on_key(shift('D'));
let cmd = s.on_key(k(cancel));
assert_eq!(cmd, Command::None);
assert_eq!(s.modal, Modal::None);
}
}
#[test]
fn d_lowercase_in_history_is_a_no_op_not_dismiss() {
let mut s = history_fixture();
s.on_key(k(KeyCode::Char('j')));
s.on_key(k(KeyCode::Char('j')));
let cmd = s.on_key(k(KeyCode::Char('d')));
assert_eq!(cmd, Command::None);
assert_eq!(s.modal, Modal::None, "lowercase d is unbound in history");
}
#[test]
fn apply_promote_committed_flips_local_only_to_promoted_in_snapshot() {
let mut s = history_fixture();
s.apply_promote_committed(
FindingIdentityHash([0x11; 32]),
"111111111111",
"alpha rule",
);
assert_eq!(
s.history_rows[0].promotion_state,
PromotionState::PromotedConvention
);
assert!(s.status_message.is_some());
}
#[test]
fn apply_demote_committed_flips_promoted_to_local_only_in_snapshot() {
let mut s = history_fixture();
s.apply_demote_committed(FindingIdentityHash([0x33; 32]), "333333333333");
assert_eq!(s.history_rows[2].promotion_state, PromotionState::LocalOnly);
}
#[test]
fn apply_write_failed_opens_error_modal() {
let mut s = history_fixture();
s.apply_write_failed("commit_promote: state drifted".into());
assert_eq!(s.modal, Modal::Error);
assert_eq!(
s.status_message.as_deref(),
Some("commit_promote: state drifted")
);
}
#[test]
fn history_g_capital_g_jump_to_first_last_emit_transitions() {
let mut s = history_fixture();
let cmd = s.on_key(shift('G'));
assert_eq!(s.history_selected, 2);
assert!(matches!(cmd, Command::LoadTransitions(_)));
let cmd = s.on_key(k(KeyCode::Char('g')));
assert_eq!(s.history_selected, 0);
assert!(matches!(cmd, Command::LoadTransitions(_)));
}
#[test]
fn empty_history_p_and_capital_d_are_safe_no_ops() {
let mut s = fixture();
let _ = s.apply_history_loaded(Vec::new());
let cmd = s.on_key(k(KeyCode::Char('p')));
assert_eq!(cmd, Command::None);
assert_eq!(s.modal, Modal::None);
let cmd = s.on_key(shift('D'));
assert_eq!(cmd, Command::None);
assert_eq!(s.modal, Modal::None);
}
}