use chrono::{DateTime, Utc};
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use rite_model::{Prompt, StepFact};
use rite_runtime::{
ExecEvent, PromptId, Response, UiCommand, UiSignal, fact_summary, signal_summary,
};
use crate::model::{
DEVIATION_INPUT_MAX, DeviationView, EnteredRow, LogLine, Model, PendingPrompt, RevealPhase,
RowEntry, RowNotice, RunningState, Screen, StepTab, StepView,
};
use crate::msg::{Cmd, Msg};
const LOG_DRIP_TICKS: u64 = 1;
pub fn update(model: &mut Model, msg: Msg) -> Vec<Cmd> {
match msg {
Msg::Tick => {
model.tick = model.tick.wrapping_add(1);
model.now = chrono::Local::now();
if drip_due(model)
&& let Some(event) = model.pending_events.pop_front()
{
return handle_exec_event(model, event);
}
Vec::new()
}
Msg::Key(key) => handle_key(model, key),
Msg::Exec(event) => {
model.pending_events.push_back(*event);
Vec::new()
}
Msg::Quit => {
while let Some(event) = model.pending_events.pop_front() {
handle_exec_event(model, event);
}
if model.screen.is_terminal() {
Vec::new()
} else {
vec![Cmd::Quit]
}
}
Msg::Resize { .. } | Msg::Mouse(_) => Vec::new(),
}
}
fn drip_due(model: &Model) -> bool {
if model.pending_events.is_empty() {
return false;
}
LOG_DRIP_TICKS == 0 || model.tick.is_multiple_of(LOG_DRIP_TICKS)
}
fn handle_key(model: &mut Model, key: KeyEvent) -> Vec<Cmd> {
if key.modifiers.contains(KeyModifiers::CONTROL) && matches!(key.code, KeyCode::Char('c')) {
return vec![Cmd::Quit];
}
if model.screen.is_terminal() {
return handle_terminal_key(key);
}
match &model.screen {
Screen::DeviationModal { .. } => return handle_deviation_modal_key(model, key),
Screen::AbortConfirm => return handle_abort_confirm_key(model, key),
_ => {}
}
if matches!(key.code, KeyCode::Tab)
&& let Screen::Step { tab } = &mut model.screen
{
*tab = match *tab {
StepTab::Overview => StepTab::Ceremony,
StepTab::Ceremony => StepTab::Deviations,
StepTab::Deviations => StepTab::System,
StepTab::System => StepTab::Overview,
};
return Vec::new();
}
if matches!(
&model.screen,
Screen::Step {
tab: StepTab::Ceremony
}
) && handle_log_scroll(model, key.code)
{
return Vec::new();
}
if model.pending_prompt.is_some()
&& matches!(
&model.screen,
Screen::Step {
tab: StepTab::Ceremony
}
)
{
return handle_prompt_key(model, key);
}
match key.code {
KeyCode::Esc => {
model.open_modal(Screen::AbortConfirm);
Vec::new()
}
KeyCode::Char('d')
if matches!(
&model.screen,
Screen::Step {
tab: StepTab::Deviations
}
) =>
{
model.open_modal(Screen::DeviationModal {
input: String::new(),
});
Vec::new()
}
_ => Vec::new(),
}
}
const LOG_PAGE_STEP: usize = 10;
fn handle_log_scroll(model: &mut Model, code: KeyCode) -> bool {
match code {
KeyCode::Up => {
let max = model.log.len();
model.log_scroll = model.log_scroll.saturating_add(1).min(max);
true
}
KeyCode::Down => {
model.log_scroll = model.log_scroll.saturating_sub(1);
true
}
KeyCode::PageUp => {
let max = model.log.len();
model.log_scroll = model.log_scroll.saturating_add(LOG_PAGE_STEP).min(max);
true
}
KeyCode::PageDown => {
model.log_scroll = model.log_scroll.saturating_sub(LOG_PAGE_STEP);
true
}
KeyCode::Home => {
model.log_scroll = model.log.len();
true
}
KeyCode::End => {
model.log_scroll = 0;
true
}
_ => false,
}
}
fn handle_terminal_key(key: KeyEvent) -> Vec<Cmd> {
match key.code {
KeyCode::Enter | KeyCode::Esc => vec![Cmd::Quit],
_ => Vec::new(),
}
}
fn handle_deviation_modal_key(model: &mut Model, key: KeyEvent) -> Vec<Cmd> {
let Screen::DeviationModal { input } = &mut model.screen else {
return Vec::new();
};
match key.code {
KeyCode::Esc => {
model.close_modal();
Vec::new()
}
KeyCode::Char(c) => {
if input.len() < DEVIATION_INPUT_MAX {
input.push(c);
}
Vec::new()
}
KeyCode::Backspace => {
input.pop();
Vec::new()
}
KeyCode::Enter => {
if input.trim().is_empty() {
return Vec::new();
}
let text = std::mem::take(input);
model.close_modal();
vec![Cmd::SendCommand(UiCommand::LogDeviation { text })]
}
_ => Vec::new(),
}
}
fn handle_abort_confirm_key(model: &mut Model, key: KeyEvent) -> Vec<Cmd> {
match key.code {
KeyCode::Char('y' | 'Y') => {
model.running = RunningState::Aborting;
model.close_modal();
vec![Cmd::SendCommand(UiCommand::Abort)]
}
KeyCode::Char('n' | 'N') | KeyCode::Esc => {
model.close_modal();
Vec::new()
}
_ => Vec::new(),
}
}
#[derive(Clone, Copy)]
enum PromptAction {
SubmitBool(bool),
SubmitText,
SubmitSecret,
SubmitAcknowledge,
Reveal(RevealPhase),
EnterRow {
format: rite_model::RevealFormat,
expected: Option<usize>,
},
RowBackspace,
PushChar(char),
Backspace,
Ignore,
}
fn handle_prompt_key(model: &mut Model, key: KeyEvent) -> Vec<Cmd> {
use PromptAction as Action;
if matches!(key.code, KeyCode::Esc) {
model.open_modal(Screen::AbortConfirm);
return Vec::new();
}
let action = {
let Some(pending) = model.pending_prompt.as_ref() else {
return Vec::new();
};
match &pending.prompt {
Prompt::Confirm { default, .. } => match key.code {
KeyCode::Char('y' | 'Y') => Action::SubmitBool(true),
KeyCode::Char('n' | 'N') => Action::SubmitBool(false),
KeyCode::Enter => Action::SubmitBool(default.unwrap_or(true)),
_ => Action::Ignore,
},
Prompt::Continue { .. } => match key.code {
KeyCode::Enter | KeyCode::Char(' ') => Action::SubmitAcknowledge,
_ => Action::Ignore,
},
Prompt::Reveal { .. } => match (pending.reveal, key.code) {
(RevealPhase::Ready, KeyCode::Enter) => Action::Reveal(RevealPhase::Shown),
(RevealPhase::Shown, KeyCode::Enter) => Action::Reveal(RevealPhase::Confirm),
(RevealPhase::Confirm, KeyCode::Char('y' | 'Y')) => Action::SubmitAcknowledge,
(RevealPhase::Confirm, KeyCode::Char('n' | 'N')) => {
Action::Reveal(RevealPhase::Shown)
}
_ => Action::Ignore,
},
Prompt::Text { .. } | Prompt::Literal { .. } => match key.code {
KeyCode::Char(c) => Action::PushChar(c),
KeyCode::Backspace => Action::Backspace,
KeyCode::Enter => Action::SubmitText,
_ => Action::Ignore,
},
Prompt::EnterRows { format, rows, .. } => match key.code {
KeyCode::Char(c) => Action::PushChar(c),
KeyCode::Backspace => Action::RowBackspace,
KeyCode::Enter => Action::EnterRow {
format: *format,
expected: *rows,
},
_ => Action::Ignore,
},
Prompt::Secret { .. } => match key.code {
KeyCode::Char(c) => Action::PushChar(c),
KeyCode::Backspace => Action::Backspace,
KeyCode::Enter => Action::SubmitSecret,
_ => Action::Ignore,
},
_ => Action::Ignore,
}
};
apply_prompt_action(model, action)
}
fn apply_prompt_action(model: &mut Model, action: PromptAction) -> Vec<Cmd> {
use PromptAction as Action;
match action {
Action::SubmitBool(b) => send_response(model, Response::Bool(b)),
Action::SubmitAcknowledge => send_response(model, Response::Acknowledge),
Action::Reveal(phase) => {
if let Some(pending) = model.pending_prompt.as_mut() {
pending.reveal = phase;
}
Vec::new()
}
Action::SubmitText => {
let value = model
.pending_prompt
.as_mut()
.map(|p| std::mem::take(&mut p.input))
.unwrap_or_default();
send_response(model, Response::Text(value))
}
Action::SubmitSecret => {
let value = model
.pending_prompt
.as_mut()
.map(|p| std::mem::take(&mut p.input))
.unwrap_or_default();
send_response(model, Response::Secret(secrecy::SecretString::from(value)))
}
Action::PushChar(c) => {
if let Some(pending) = model.pending_prompt.as_mut() {
pending.input.push(c);
}
Vec::new()
}
Action::Backspace => {
if let Some(pending) = model.pending_prompt.as_mut() {
pending.input.pop();
}
Vec::new()
}
Action::EnterRow { format, expected } => enter_row(model, format, expected),
Action::RowBackspace => {
if let Some(pending) = model.pending_prompt.as_mut() {
if pending.input.is_empty() {
if let Some(row) = pending.entry.rows.pop() {
pending.input = row.typed.to_string();
}
pending.entry.notice = None;
} else {
pending.input.pop();
}
}
Vec::new()
}
Action::Ignore => Vec::new(),
}
}
fn enter_row(
model: &mut Model,
format: rite_model::RevealFormat,
expected: Option<usize>,
) -> Vec<Cmd> {
let Some(pending) = model.pending_prompt.as_mut() else {
return Vec::new();
};
let done = if pending.input.trim().is_empty() {
expected.is_none() && !pending.entry.rows.is_empty()
} else {
let number = pending.entry.rows.len().saturating_add(1);
let more = expected.is_some_and(|rows| number < rows);
match format.read_row(&pending.input, number, more) {
Ok(read) => {
pending.entry.rows.push(EnteredRow {
typed: zeroize::Zeroizing::new(std::mem::take(&mut pending.input)),
reads: read.text,
});
pending.entry.notice = read.repair.map(RowNotice::Repaired);
!read.full || expected == Some(pending.entry.rows.len())
}
Err(reason) => {
pending.entry.notice = Some(RowNotice::Refused(reason));
false
}
}
};
if !done {
return Vec::new();
}
let rows = std::mem::take(&mut pending.entry.rows);
let text: Vec<&str> = rows.iter().map(|row| row.typed.as_str()).collect();
send_response(
model,
Response::Secret(secrecy::SecretString::from(text.join("\n"))),
)
}
fn send_response(model: &mut Model, response: Response) -> Vec<Cmd> {
let Some(pending) = model.pending_prompt.take() else {
return Vec::new();
};
vec![Cmd::SendCommand(UiCommand::PromptResponse {
prompt_id: pending.prompt_id,
response,
})]
}
fn handle_exec_event(model: &mut Model, event: ExecEvent) -> Vec<Cmd> {
match event {
ExecEvent::Fact { at, fact } => handle_fact(model, at, &fact),
ExecEvent::Signal(signal) => handle_signal(model, &signal),
ExecEvent::AwaitPrompt {
prompt_id,
prompt,
previous_attempt_rejected_because,
..
} => {
install_prompt(model, prompt_id, prompt, previous_attempt_rejected_because);
Vec::new()
}
ExecEvent::Finalized { fingerprint } => {
if let Screen::Completed {
fingerprint: fp, ..
} = &mut model.screen
{
*fp = Some(fingerprint);
}
Vec::new()
}
}
}
fn install_prompt(
model: &mut Model,
prompt_id: PromptId,
prompt: Prompt,
rejection: Option<String>,
) {
model.pending_prompt = Some(PendingPrompt {
prompt_id,
prompt,
input: String::new(),
rejection,
reveal: RevealPhase::Ready,
entry: RowEntry::default(),
});
}
fn handle_fact(model: &mut Model, at: DateTime<Utc>, fact: &StepFact) -> Vec<Cmd> {
let at_local = at.with_timezone(&chrono::Local);
match fact {
StepFact::CeremonyStarted { name, .. } => {
model.ceremony_name = Some(name.clone());
}
StepFact::ActStarted { label, .. } => {
model.push_log(LogLine::ActDivider {
label: label.clone(),
});
}
StepFact::RoleDeclared { role, name } => {
model.role_names.insert(role.clone(), name.clone());
}
StepFact::StepStarted { id, label, role } => {
let role_name = model
.role_names
.get(role)
.cloned()
.unwrap_or_else(|| role.as_str().to_string());
let is_first_step = model.current_step.is_none();
model.push_log(LogLine::StepDivider {
label: label.clone(),
role_name: role_name.clone(),
});
model.current_step = Some(StepView {
id: id.clone(),
label: label.clone(),
role_name: role_name.clone(),
});
model.pending_prompt = None;
if is_first_step
&& let Screen::Step {
tab: tab @ StepTab::Overview,
} = &mut model.screen
{
*tab = StepTab::Ceremony;
}
}
StepFact::StepCompleted { .. } => {
model.pending_prompt = None;
}
StepFact::DeviationRecorded { step, text } => {
model.deviations.push(DeviationView {
step: step.clone(),
text: text.clone(),
at: at_local,
});
}
StepFact::CeremonyCompleted { .. } => {
model.screen = Screen::Completed { fingerprint: None };
model.running = RunningState::Done;
}
StepFact::CeremonyFailed { error, .. } => {
model.screen = match error.class {
rite_model::ErrorClass::Abort => Screen::Aborted,
_ => Screen::Failed {
reason: error.message.clone(),
},
};
model.running = RunningState::Done;
}
_ => {}
}
if !matches!(
fact,
StepFact::StepStarted { .. } | StepFact::ActStarted { .. }
) && let Some((icon, text)) = fact_summary(fact)
{
model.push_entry_at(icon, text, at_local);
}
Vec::new()
}
fn handle_signal(model: &mut Model, signal: &UiSignal) -> Vec<Cmd> {
match signal {
UiSignal::CeremonyOverview {
description,
materials,
step_count,
} => {
model.ceremony_description.clone_from(description);
model.ceremony_materials.clone_from(materials);
model.ceremony_step_count = Some(*step_count);
}
UiSignal::SystemInfo(info) => model.system_info = Some((**info).clone()),
UiSignal::Environment(env) => model.environment = Some(env.clone()),
UiSignal::LogLine { .. } | UiSignal::Progress { .. } => {
if let Some((icon, text)) = signal_summary(signal) {
model.push_entry(icon, text);
}
}
}
Vec::new()
}
#[cfg(test)]
mod tests {
use rite_model::StepId;
use rite_runtime::test_support::fact_event;
use super::*;
fn key(code: KeyCode) -> KeyEvent {
KeyEvent::new(code, KeyModifiers::empty())
}
fn apply_exec(model: &mut Model, event: ExecEvent) -> Vec<Cmd> {
let _ = update(model, Msg::Exec(Box::new(event)));
update(model, Msg::Tick)
}
#[test]
fn tick_advances_frame_counter() {
let mut model = Model::new();
let cmds = update(&mut model, Msg::Tick);
assert!(cmds.is_empty());
assert_eq!(model.tick, 1);
}
#[test]
fn ctrl_c_returns_cmd_quit() {
let mut model = Model::new();
let event = KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL);
let cmds = update(&mut model, Msg::Key(event));
assert!(matches!(cmds.as_slice(), [Cmd::Quit]));
}
#[test]
fn esc_opens_abort_confirm_modal_without_aborting_yet() {
let mut model = Model::new();
let cmds = update(&mut model, Msg::Key(key(KeyCode::Esc)));
assert!(cmds.is_empty());
assert!(matches!(model.screen, Screen::AbortConfirm));
assert!(matches!(model.running, RunningState::Active));
}
#[test]
fn abort_confirm_yes_sends_abort_and_closes_modal() {
let mut model = Model::new();
let _ = update(&mut model, Msg::Key(key(KeyCode::Esc)));
let cmds = update(&mut model, Msg::Key(key(KeyCode::Char('y'))));
assert!(matches!(model.running, RunningState::Aborting));
assert!(matches!(
cmds.as_slice(),
[Cmd::SendCommand(UiCommand::Abort)]
));
assert!(matches!(model.screen, Screen::Step { .. }));
}
#[test]
fn abort_confirm_no_cancels() {
let mut model = Model::new();
let _ = update(&mut model, Msg::Key(key(KeyCode::Esc)));
let cmds = update(&mut model, Msg::Key(key(KeyCode::Char('n'))));
assert!(cmds.is_empty());
assert!(matches!(model.screen, Screen::Step { .. }));
assert!(matches!(model.running, RunningState::Active));
}
#[test]
fn deviation_modal_collects_input_then_submits_on_enter() {
let mut model = Model::new();
model.screen = Screen::Step {
tab: StepTab::Deviations,
};
model.current_step = Some(StepView {
id: StepId::new("step1"),
label: "L".to_string(),
role_name: "Operator".to_string(),
});
let _ = update(&mut model, Msg::Key(key(KeyCode::Char('d'))));
assert!(matches!(model.screen, Screen::DeviationModal { .. }));
for c in "phone rang".chars() {
let _ = update(&mut model, Msg::Key(key(KeyCode::Char(c))));
}
let cmds = update(&mut model, Msg::Key(key(KeyCode::Enter)));
match cmds.as_slice() {
[Cmd::SendCommand(UiCommand::LogDeviation { text })] => {
assert_eq!(text, "phone rang");
}
other => panic!("expected LogDeviation, got {other:?}"),
}
assert!(matches!(model.screen, Screen::Step { .. }));
assert!(model.deviations.is_empty());
}
#[test]
fn deviation_modal_escape_cancels_without_emitting() {
let mut model = Model::new();
model.screen = Screen::Step {
tab: StepTab::Deviations,
};
let _ = update(&mut model, Msg::Key(key(KeyCode::Char('d'))));
let _ = update(&mut model, Msg::Key(key(KeyCode::Char('x'))));
let cmds = update(&mut model, Msg::Key(key(KeyCode::Esc)));
assert!(cmds.is_empty());
assert!(matches!(model.screen, Screen::Step { .. }));
assert!(model.deviations.is_empty());
}
#[test]
fn deviation_key_is_ignored_outside_deviations_tab() {
for tab in [StepTab::Overview, StepTab::Ceremony, StepTab::System] {
let mut model = Model::new();
model.screen = Screen::Step { tab };
let cmds = update(&mut model, Msg::Key(key(KeyCode::Char('d'))));
assert!(cmds.is_empty(), "d on {tab:?} must be a no-op");
assert!(
matches!(model.screen, Screen::Step { tab: t } if t == tab),
"d on {tab:?} must not open the modal",
);
}
}
#[test]
fn deviation_modal_empty_input_does_not_submit() {
let mut model = Model::new();
model.screen = Screen::Step {
tab: StepTab::Deviations,
};
let _ = update(&mut model, Msg::Key(key(KeyCode::Char('d'))));
let cmds = update(&mut model, Msg::Key(key(KeyCode::Enter)));
assert!(cmds.is_empty());
assert!(matches!(model.screen, Screen::DeviationModal { .. }));
}
#[test]
fn step_started_pushes_a_divider_into_the_log_feed() {
let mut model = Model::new();
let _ = apply_exec(
&mut model,
fact_event(StepFact::RoleDeclared {
role: rite_model::RoleId::new("crypto_officer"),
name: "Crypto Officer".to_string(),
}),
);
let _ = apply_exec(
&mut model,
fact_event(StepFact::StepStarted {
id: StepId::new("s1"),
label: "2.1".to_string(),
role: rite_model::RoleId::new("crypto_officer"),
}),
);
assert!(matches!(model.screen, Screen::Step { .. }));
match model.log.front() {
Some(LogLine::StepDivider { label, role_name }) => {
assert_eq!(label, "2.1");
assert_eq!(role_name, "Crypto Officer");
}
other => panic!("expected StepDivider, got {other:?}"),
}
}
#[test]
fn tab_cycles_overview_ceremony_deviations_system() {
let mut model = Model::new();
assert!(matches!(
model.screen,
Screen::Step {
tab: StepTab::Overview
}
));
let _ = update(&mut model, Msg::Key(key(KeyCode::Tab)));
assert!(matches!(
model.screen,
Screen::Step {
tab: StepTab::Ceremony
}
));
let _ = update(&mut model, Msg::Key(key(KeyCode::Tab)));
assert!(matches!(
model.screen,
Screen::Step {
tab: StepTab::Deviations
}
));
let _ = update(&mut model, Msg::Key(key(KeyCode::Tab)));
assert!(matches!(
model.screen,
Screen::Step {
tab: StepTab::System
}
));
let _ = update(&mut model, Msg::Key(key(KeyCode::Tab)));
assert!(matches!(
model.screen,
Screen::Step {
tab: StepTab::Overview
}
));
}
#[test]
fn first_step_auto_switches_overview_to_ceremony() {
let mut model = Model::new();
let _ = apply_exec(
&mut model,
fact_event(StepFact::StepStarted {
id: StepId::new("s1"),
label: "1".to_string(),
role: rite_model::RoleId::new("op"),
}),
);
assert!(matches!(
model.screen,
Screen::Step {
tab: StepTab::Ceremony
}
));
}
#[test]
fn second_step_does_not_override_manual_tab_choice() {
let mut model = Model::new();
let _ = apply_exec(
&mut model,
fact_event(StepFact::StepStarted {
id: StepId::new("s1"),
label: "1".to_string(),
role: rite_model::RoleId::new("op"),
}),
);
let _ = update(&mut model, Msg::Key(key(KeyCode::Tab))); let _ = update(&mut model, Msg::Key(key(KeyCode::Tab))); let _ = update(&mut model, Msg::Key(key(KeyCode::Tab))); assert!(matches!(
model.screen,
Screen::Step {
tab: StepTab::Overview
}
));
let _ = apply_exec(
&mut model,
fact_event(StepFact::StepStarted {
id: StepId::new("s2"),
label: "2".to_string(),
role: rite_model::RoleId::new("op"),
}),
);
assert!(matches!(
model.screen,
Screen::Step {
tab: StepTab::Overview
}
));
}
#[test]
fn ceremony_started_fact_populates_only_the_name() {
let mut model = Model::new();
let _ = apply_exec(
&mut model,
fact_event(rite_runtime::test_support::ceremony_started("Root CA")),
);
assert_eq!(model.ceremony_name.as_deref(), Some("Root CA"));
assert!(model.ceremony_description.is_none());
assert!(model.ceremony_materials.is_empty());
assert!(model.ceremony_step_count.is_none());
}
#[test]
fn ceremony_overview_signal_populates_metadata() {
let mut model = Model::new();
let _ = apply_exec(
&mut model,
ExecEvent::Signal(UiSignal::CeremonyOverview {
description: Some("Generate offline root.".to_string()),
materials: vec![rite_runtime::MaterialOverview {
id: rite_model::MaterialId::new("yk"),
title: Some("YubiKey".to_string()),
description: None,
kind: rite_runtime::MaterialOverviewKind::Digital,
}],
step_count: 5,
}),
);
assert_eq!(
model.ceremony_description.as_deref(),
Some("Generate offline root."),
);
assert_eq!(model.ceremony_step_count, Some(5));
assert_eq!(model.ceremony_materials.len(), 1);
assert_eq!(
model
.ceremony_materials
.first()
.expect("material populated")
.display_title(),
"YubiKey",
);
}
#[test]
fn step_started_installs_current_step() {
let mut model = Model::new();
let fact = StepFact::StepStarted {
id: StepId::new("s1"),
label: "Step One".to_string(),
role: rite_model::RoleId::new("op"),
};
let _ = apply_exec(&mut model, fact_event(fact));
let s = model.current_step.expect("current step");
assert_eq!(s.id, StepId::new("s1"));
assert_eq!(s.label, "Step One");
}
#[test]
fn confirm_y_responds_with_true() {
let mut model = Model::new();
model.screen = Screen::Step {
tab: StepTab::Ceremony,
};
install_prompt(
&mut model,
PromptId::new(1),
Prompt::Confirm {
question: "ok?".to_string(),
default: None,
},
None,
);
let cmds = update(&mut model, Msg::Key(key(KeyCode::Char('y'))));
match cmds.as_slice() {
[
Cmd::SendCommand(UiCommand::PromptResponse {
prompt_id,
response: Response::Bool(true),
}),
] => assert_eq!(*prompt_id, PromptId::new(1)),
_ => panic!("expected Bool(true) response"),
}
assert!(model.pending_prompt.is_none());
}
#[test]
fn a_reveal_is_announced_shown_then_taken_away_only_on_yes() {
let mut model = Model::new();
model.screen = Screen::Step {
tab: StepTab::Ceremony,
};
install_prompt(
&mut model,
PromptId::new(3),
Prompt::Reveal {
label: "share".to_string(),
note: None,
shown: rite_model::Shown::new(
"A".repeat(32),
rite_model::RevealFormat::Paper32.layout(),
),
},
None,
);
let phase = |model: &Model| model.pending_prompt.as_ref().map(|p| p.reveal);
let press = |model: &mut Model, code| update(model, Msg::Key(key(code)));
assert!(press(&mut model, KeyCode::Char(' ')).is_empty());
assert_eq!(phase(&model), Some(RevealPhase::Ready));
assert!(press(&mut model, KeyCode::Enter).is_empty());
assert_eq!(phase(&model), Some(RevealPhase::Shown));
assert!(press(&mut model, KeyCode::Char(' ')).is_empty());
assert!(press(&mut model, KeyCode::Enter).is_empty());
assert_eq!(phase(&model), Some(RevealPhase::Confirm));
assert!(press(&mut model, KeyCode::Enter).is_empty());
assert!(press(&mut model, KeyCode::Char('n')).is_empty());
assert_eq!(phase(&model), Some(RevealPhase::Shown));
assert!(press(&mut model, KeyCode::Enter).is_empty());
let cmds = press(&mut model, KeyCode::Char('y'));
assert!(matches!(
cmds.as_slice(),
[Cmd::SendCommand(UiCommand::PromptResponse {
response: Response::Acknowledge,
..
})]
));
}
#[test]
fn rows_are_taken_one_at_a_time_and_sent_with_the_last() {
use secrecy::ExposeSecret;
let text = rite_model::paper32::encode(&[0x5A; 35]);
let (first, second) = text.split_at(32);
let mut model = Model::new();
model.screen = Screen::Step {
tab: StepTab::Ceremony,
};
install_prompt(
&mut model,
PromptId::new(5),
Prompt::EnterRows {
label: "share".to_string(),
note: None,
format: rite_model::RevealFormat::Paper32,
rows: Some(2),
validator: None,
},
None,
);
let typing = |model: &mut Model, text: &str| {
for c in text.chars() {
let _ = update(model, Msg::Key(key(KeyCode::Char(c))));
}
update(model, Msg::Key(key(KeyCode::Enter)))
};
let taken = |model: &Model| model.pending_prompt.as_ref().map(|p| p.entry.rows.len());
assert!(typing(&mut model, "ABCD").is_empty());
assert_eq!(taken(&model), Some(0));
assert!(matches!(
model
.pending_prompt
.as_ref()
.and_then(|p| p.entry.notice.clone()),
Some(RowNotice::Refused(_))
));
for _ in 0..4 {
let _ = update(&mut model, Msg::Key(key(KeyCode::Backspace)));
}
assert!(typing(&mut model, first).is_empty());
assert_eq!(taken(&model), Some(1));
let _ = update(&mut model, Msg::Key(key(KeyCode::Backspace)));
assert_eq!(taken(&model), Some(0));
assert!(update(&mut model, Msg::Key(key(KeyCode::Enter))).is_empty());
assert_eq!(taken(&model), Some(1));
let cmds = typing(&mut model, second);
match cmds.as_slice() {
[
Cmd::SendCommand(UiCommand::PromptResponse {
response: Response::Secret(rows),
..
}),
] => assert_eq!(rows.expose_secret(), format!("{first}\n{second}")),
_ => panic!("expected the rows as a secret"),
}
assert!(model.pending_prompt.is_none());
}
#[test]
fn a_short_row_ends_an_entry_of_no_set_length() {
let text = rite_model::paper32::encode(&[0x5A; 30]);
let (first, second) = text.split_at(32);
let mut model = Model::new();
model.screen = Screen::Step {
tab: StepTab::Ceremony,
};
install_prompt(
&mut model,
PromptId::new(6),
Prompt::EnterRows {
label: "share".to_string(),
note: None,
format: rite_model::RevealFormat::Paper32,
rows: None,
validator: None,
},
None,
);
let typing = |model: &mut Model, text: &str| {
for c in text.chars() {
let _ = update(model, Msg::Key(key(KeyCode::Char(c))));
}
update(model, Msg::Key(key(KeyCode::Enter)))
};
assert!(typing(&mut model, first).is_empty());
assert!(matches!(
typing(&mut model, second).as_slice(),
[Cmd::SendCommand(UiCommand::PromptResponse { .. })]
));
}
#[test]
fn text_prompt_collects_input_then_sends_on_enter() {
let mut model = Model::new();
model.screen = Screen::Step {
tab: StepTab::Ceremony,
};
install_prompt(
&mut model,
PromptId::new(2),
Prompt::Text {
label: "name".to_string(),
validator: rite_model::ValidatorSpec::NonEmpty,
},
None,
);
for c in "Alice".chars() {
let _ = update(&mut model, Msg::Key(key(KeyCode::Char(c))));
}
let cmds = update(&mut model, Msg::Key(key(KeyCode::Enter)));
match cmds.as_slice() {
[
Cmd::SendCommand(UiCommand::PromptResponse {
response: Response::Text(t),
..
}),
] => assert_eq!(t, "Alice"),
_ => panic!("expected Text response"),
}
}
#[test]
fn ceremony_completed_then_finalized_populates_fingerprint() {
let mut model = Model::new();
let fact = StepFact::CeremonyCompleted {};
let _ = apply_exec(&mut model, fact_event(fact));
assert!(matches!(
model.screen,
Screen::Completed {
fingerprint: None,
..
}
));
assert!(matches!(model.running, RunningState::Done));
let _ = apply_exec(
&mut model,
ExecEvent::Finalized {
fingerprint: "sha256:abc".to_string(),
},
);
match &model.screen {
Screen::Completed {
fingerprint: Some(fp),
..
} => assert_eq!(fp, "sha256:abc"),
other => panic!("expected fingerprinted Completed, got {other:?}"),
}
}
#[test]
fn quit_flushes_queued_terminal_fact_so_completion_screen_shows() {
let mut model = Model::new();
let _ = update(
&mut model,
Msg::Exec(Box::new(fact_event(StepFact::CeremonyCompleted {}))),
);
let _ = update(
&mut model,
Msg::Exec(Box::new(ExecEvent::Finalized {
fingerprint: "sha256:abc".to_string(),
})),
);
assert_eq!(model.pending_events.len(), 2);
assert!(!model.screen.is_terminal());
let cmds = update(&mut model, Msg::Quit);
assert!(
cmds.is_empty(),
"Quit must not tear down before the completion screen is shown",
);
assert!(model.pending_events.is_empty());
match &model.screen {
Screen::Completed {
fingerprint: Some(fp),
..
} => assert_eq!(fp, "sha256:abc"),
other => panic!("expected fingerprinted Completed, got {other:?}"),
}
}
#[test]
fn quit_mid_ceremony_still_tears_down() {
let mut model = Model::new();
let cmds = update(&mut model, Msg::Quit);
assert!(matches!(cmds.as_slice(), [Cmd::Quit]));
}
#[test]
fn exec_event_queues_until_next_tick() {
let mut model = Model::new();
let signal = UiSignal::LogLine {
step: None,
icon: rite_runtime::Icon::Info,
text: "queued".to_string(),
};
let _ = update(&mut model, Msg::Exec(Box::new(ExecEvent::Signal(signal))));
assert!(model.log.is_empty());
assert_eq!(model.pending_events.len(), 1);
let _ = update(&mut model, Msg::Tick);
assert!(model.pending_events.is_empty());
assert_eq!(model.log.len(), 1);
}
#[test]
fn log_signal_pushes_log_line() {
let mut model = Model::new();
let signal = UiSignal::LogLine {
step: None,
icon: rite_runtime::Icon::Info,
text: "hi".to_string(),
};
let _ = apply_exec(&mut model, ExecEvent::Signal(signal));
assert_eq!(model.log.len(), 1);
match model.log.front() {
Some(LogLine::Entry { text, .. }) => assert_eq!(text, "hi"),
other => panic!("expected Entry, got {other:?}"),
}
}
}