use std::path::PathBuf;
use mermaid_cli::app::Config;
use mermaid_cli::domain::{
Cmd, CompactionRecord, CompactionResult, CompactionTrigger, ContextUsageSnapshot, Msg,
PendingToolCall, PromptTokenBreakdown, SlashCmd, State, ToolCallId, ToolOutcome, TurnId,
TurnState, start_executing_tools, start_generating, update,
};
use mermaid_cli::models::tool_call::{FunctionCall, ToolCall as ModelToolCall};
use mermaid_cli::models::{ChatMessage, ChatMessageKind, MessageRole};
fn fresh() -> State {
State::new(
Config::default(),
PathBuf::from("/tmp/flow"),
"ollama/test".to_string(),
chrono::Local::now(),
)
}
fn user_submit(state: State, text: &str) -> (State, Vec<Cmd>) {
update(
state,
Msg::SubmitPrompt {
text: text.to_string(),
attachment_ids: vec![],
},
)
}
#[test]
fn happy_path_turn_ends_idle_with_assistant_message() {
let (state, cmds) = user_submit(fresh(), "hello");
assert!(cmds.iter().any(|c| matches!(c, Cmd::CallModel { .. })));
let id = state.current_turn_id().expect("turn id");
let (state, _) = update(
state,
Msg::StreamText {
turn: id,
chunk: "hello back".to_string(),
},
);
let (state, cmds) = update(
state,
Msg::StreamDone {
turn: id,
usage: None,
provider_continuation: None,
stop_reason: None,
},
);
assert!(matches!(state.turn, TurnState::Idle));
assert_eq!(state.session.messages().len(), 3);
let assistant = &state.session.messages()[1];
assert_eq!(assistant.role, MessageRole::Assistant);
assert_eq!(assistant.content, "hello back");
let summary = state.session.messages().last().unwrap();
assert_eq!(summary.kind, ChatMessageKind::RunSummary);
assert!(summary.content.contains("Worked for"));
assert!(cmds.iter().any(|c| matches!(c, Cmd::SaveConversation(_))));
}
#[test]
fn stale_stream_chunks_cannot_corrupt_current_turn() {
let (state, _) = user_submit(fresh(), "first");
let turn_a = state.current_turn_id().unwrap();
let (state, _) = update(
state,
Msg::StreamText {
turn: turn_a,
chunk: "from A".to_string(),
},
);
let (state, _) = update(state, Msg::CancelTurn);
let (state, _) = update(
state,
Msg::StreamDone {
turn: turn_a,
usage: None,
provider_continuation: None,
stop_reason: None,
},
);
assert_eq!(
state
.session
.messages()
.iter()
.filter(|m| m.content.contains("from A"))
.count(),
0,
"stale content from cancelled turn must not reach committed history"
);
}
#[test]
fn stream_text_from_prior_turn_is_ignored() {
let mut state = fresh();
state.turn = start_generating(TurnId(10), std::time::SystemTime::now());
let (state, _) = update(
state,
Msg::StreamText {
turn: TurnId(9), chunk: "stale".to_string(),
},
);
match &state.turn {
TurnState::Generating { partial_text, .. } => assert!(partial_text.is_empty()),
_ => panic!("should still be Generating"),
}
}
#[test]
fn tool_outcomes_must_all_land_before_followup_call() {
let mut state = fresh();
let calls = vec![
PendingToolCall {
call_id: ToolCallId(1),
source: ModelToolCall {
id: Some("a".to_string()),
function: FunctionCall {
name: "read_file".to_string(),
arguments: serde_json::json!({}),
},
},
},
PendingToolCall {
call_id: ToolCallId(2),
source: ModelToolCall {
id: Some("b".to_string()),
function: FunctionCall {
name: "write_file".to_string(),
arguments: serde_json::json!({}),
},
},
},
];
state.turn = start_executing_tools(TurnId(1), calls, std::time::SystemTime::now());
state.session.append(
mermaid_cli::models::ChatMessage::assistant("ok let me call tools"),
state.now,
);
let (state, cmds) = update(
state,
Msg::ToolFinished {
turn: TurnId(1),
call_id: ToolCallId(1),
outcome: ToolOutcome::success("first done", "first done", 0.1),
},
);
assert!(matches!(state.turn, TurnState::ExecutingTools { .. }));
assert!(cmds.is_empty(), "no follow-up until all tools finish");
let (state, cmds) = update(
state,
Msg::ToolFinished {
turn: TurnId(1),
call_id: ToolCallId(2),
outcome: ToolOutcome::success("second done", "second done", 0.1),
},
);
assert!(matches!(state.turn, TurnState::Generating { .. }));
assert!(cmds.iter().any(|c| matches!(c, Cmd::CallModel { .. })));
let tool_msgs = state
.session
.messages()
.iter()
.filter(|m| m.role == MessageRole::Tool)
.count();
assert_eq!(tool_msgs, 2);
}
#[test]
fn cancelled_tool_produces_placeholder_in_history() {
let mut state = fresh();
let call = PendingToolCall {
call_id: ToolCallId(1),
source: ModelToolCall {
id: None,
function: FunctionCall {
name: "read_file".to_string(),
arguments: serde_json::json!({}),
},
},
};
state.turn = start_executing_tools(TurnId(3), vec![call], std::time::SystemTime::now());
state.session.append(
mermaid_cli::models::ChatMessage::assistant("calling tool"),
state.now,
);
let (state, _) = update(
state,
Msg::ToolFinished {
turn: TurnId(3),
call_id: ToolCallId(1),
outcome: ToolOutcome::cancelled(),
},
);
let last = state.session.messages().last().unwrap();
assert_eq!(last.role, MessageRole::Tool);
assert!(last.content.contains("cancelled"));
}
#[test]
fn cancel_emits_scope_cancel_and_transitions_cancelling() {
let mut state = fresh();
state.turn = start_generating(TurnId(7), std::time::SystemTime::now());
let (state, cmds) = update(state, Msg::CancelTurn);
assert!(
matches!(state.turn, TurnState::Cancelling { id: TurnId(7), .. }),
"active turn must be in Cancelling"
);
assert!(
cmds.iter()
.any(|c| matches!(c, Cmd::CancelScope(TurnId(7)))),
"reducer must emit Cmd::CancelScope so the runner tears down"
);
}
#[test]
fn cancel_on_idle_is_noop() {
let (state, cmds) = update(fresh(), Msg::CancelTurn);
assert!(matches!(state.turn, TurnState::Idle));
assert!(cmds.is_empty());
}
#[test]
fn double_cancel_does_not_emit_a_second_cancel_scope() {
let mut state = fresh();
state.turn = TurnState::Cancelling {
id: TurnId(1),
since: std::time::SystemTime::now(),
};
let (_state, cmds) = update(state, Msg::CancelTurn);
assert!(cmds.iter().all(|c| !matches!(c, Cmd::CancelScope(_))));
}
#[test]
fn cancel_then_turn_cancelled_returns_to_idle() {
let (state, _) = user_submit(fresh(), "will be cancelled");
let id = state.current_turn_id().expect("turn in flight");
let (state, cmds) = update(state, Msg::CancelTurn);
assert!(matches!(state.turn, TurnState::Cancelling { .. }));
assert!(cmds.iter().any(|c| matches!(c, Cmd::CancelScope(_))));
let (state, _) = update(state, Msg::TurnCancelled(id));
assert!(
matches!(state.turn, TurnState::Idle),
"TurnCancelled should clear Cancelling; got {:?}",
state.turn,
);
}
#[test]
fn stale_turn_cancelled_does_not_mutate_state() {
let (state, _) = user_submit(fresh(), "active turn");
let live = state.current_turn_id().unwrap();
let stale = TurnId(live.0 + 100);
let (state, cmds) = update(state, Msg::TurnCancelled(stale));
assert!(matches!(state.turn, TurnState::Generating { .. }));
assert!(cmds.is_empty());
}
#[test]
fn upstream_error_ends_turn_exactly_once() {
let mut state = fresh();
state.turn = start_generating(TurnId(4), std::time::SystemTime::now());
let err = mermaid_cli::models::UserFacingError {
summary: "Server error".to_string(),
message: "500 internal".to_string(),
suggestion: "try again".to_string(),
category: mermaid_cli::models::ErrorCategory::Temporary,
recoverable: true,
};
let (state, _) = update(
state,
Msg::UpstreamError {
turn: TurnId(4),
error: err,
},
);
assert!(matches!(state.turn, TurnState::Idle));
assert_eq!(state.session.messages().len(), 1);
let last = state.session.messages().last().expect("error message");
assert_eq!(
last.actions.len(),
1,
"error surfaced through one ActionDisplay"
);
}
#[test]
fn upstream_error_from_stale_turn_is_dropped() {
let mut state = fresh();
state.turn = start_generating(TurnId(8), std::time::SystemTime::now());
let err = mermaid_cli::models::UserFacingError {
summary: "late".to_string(),
message: "".to_string(),
suggestion: "".to_string(),
category: mermaid_cli::models::ErrorCategory::Temporary,
recoverable: false,
};
let (state, _) = update(
state,
Msg::UpstreamError {
turn: TurnId(7), error: err,
},
);
assert!(matches!(state.turn, TurnState::Generating { .. }));
assert!(state.session.messages().is_empty());
}
#[test]
fn slash_clear_requires_confirmation_before_wiping() {
let mut state = fresh();
state.session.append(
mermaid_cli::models::ChatMessage::user("priceless"),
state.now,
);
let (state, _) = update(state, Msg::Slash(SlashCmd::Clear));
assert!(state.confirm.is_some());
assert_eq!(state.session.messages().len(), 1);
let (state, _) = update(state, Msg::ConfirmAccepted);
assert!(state.session.messages().is_empty());
assert!(state.confirm.is_none());
}
#[test]
fn slash_save_emits_save_conversation() {
let (_state, cmds) = update(fresh(), Msg::Slash(SlashCmd::Save(None)));
assert!(cmds.iter().any(|c| matches!(c, Cmd::SaveConversation(_))));
}
#[test]
fn slash_compact_emits_compaction_command() {
let mut state = fresh();
state
.session
.append(ChatMessage::user("old prompt"), state.now);
state
.session
.append(ChatMessage::assistant("old answer"), state.now);
state
.session
.append(ChatMessage::user("new prompt"), state.now);
let (state, cmds) = update(
state,
Msg::Slash(SlashCmd::Compact(Some("focus on tests".to_string()))),
);
assert!(matches!(state.turn, TurnState::Compacting { .. }));
assert!(cmds.iter().any(|cmd| {
matches!(
cmd,
Cmd::CompactConversation { request, .. }
if request.instructions.as_deref() == Some("focus on tests")
)
}));
}
#[test]
fn compaction_finished_replaces_history_and_archives_head() {
let mut state = fresh();
state
.session
.append(ChatMessage::user("old prompt"), state.now);
state
.session
.append(ChatMessage::assistant("old answer"), state.now);
state
.session
.append(ChatMessage::user("new prompt"), state.now);
let (state, cmds) = update(state, Msg::Slash(SlashCmd::Compact(None)));
let turn = state.turn.id().expect("compaction turn");
assert!(
cmds.iter()
.any(|cmd| matches!(cmd, Cmd::CompactConversation { .. }))
);
let before = ContextUsageSnapshot::from_estimate(
PromptTokenBreakdown {
system_tokens: 10,
instructions_tokens: 0,
message_tokens: 90,
tool_schema_tokens: 0,
image_count: 0,
message_count: 3,
tool_count: 0,
},
Some(1_000),
);
let after = ContextUsageSnapshot::from_estimate(
PromptTokenBreakdown {
system_tokens: 10,
instructions_tokens: 0,
message_tokens: 20,
tool_schema_tokens: 0,
image_count: 0,
message_count: 3,
tool_count: 0,
},
Some(1_000),
);
let mut checkpoint = ChatMessage::user("MERMAID CONTEXT CHECKPOINT\n## Goal\n- continue");
checkpoint.kind = ChatMessageKind::ContextCheckpoint;
let replacement = vec![
checkpoint,
ChatMessage::assistant("Context compacted: 100 -> 30 tokens."),
ChatMessage::user("new prompt"),
];
let result = CompactionResult {
record: CompactionRecord {
id: "compact_test".to_string(),
trigger: CompactionTrigger::Manual,
created_at: chrono::Local::now(),
before_tokens: 100,
after_tokens: 30,
archived_message_count: 2,
preserved_message_count: 1,
preserved_turn_count: 1,
summary_tokens: 10,
duration_secs: 0.5,
review_status: mermaid_cli::domain::CompactionReviewStatus::Reviewed,
review_error: None,
focus: None,
archive_path: None,
},
replacement_messages: replacement,
archived_messages: vec![
ChatMessage::user("old prompt"),
ChatMessage::assistant("old answer"),
],
before_snapshot: before,
after_snapshot: after,
usage: None,
source_boundaries: Vec::new(),
};
let (state, cmds) = update(state, Msg::CompactionFinished { turn, result });
assert!(matches!(state.turn, TurnState::Idle));
assert_eq!(state.session.messages().len(), 3);
assert_eq!(
state.session.messages()[0].kind,
ChatMessageKind::ContextCheckpoint
);
assert_eq!(state.session.conversation.compactions.len(), 1);
let archive_cmd = cmds
.iter()
.find(|cmd| matches!(cmd, Cmd::SaveCompactionArchive { .. }));
assert!(archive_cmd.is_some());
if let Some(Cmd::SaveCompactionArchive { conversation, .. }) = archive_cmd {
assert_eq!(conversation.compactions.len(), 1);
}
}
#[test]
fn truncation_note_skipped_when_tool_calls_pending() {
let (state, _) = user_submit(fresh(), "do a thing");
let id = state.current_turn_id().unwrap();
let (state, _) = update(
state,
Msg::StreamToolCall {
turn: id,
call: ModelToolCall {
id: Some("call_1".to_string()),
function: FunctionCall {
name: "read_file".to_string(),
arguments: serde_json::json!({"path": "x"}),
},
},
},
);
let (state, _) = update(
state,
Msg::StreamDone {
turn: id,
usage: None,
provider_continuation: None,
stop_reason: Some(mermaid_cli::models::FinishReason::Length),
},
);
assert!(
matches!(state.turn, TurnState::ExecutingTools { .. }),
"expected ExecutingTools; got {:?}",
state.turn
);
assert!(
!state
.session
.messages()
.iter()
.any(|m| m.role == MessageRole::System && m.content.contains("truncated")),
"truncation note must not be inserted between tool_calls and their results"
);
}
#[test]
fn truncation_note_shown_when_no_tool_calls() {
let (state, _) = user_submit(fresh(), "summarize");
let id = state.current_turn_id().unwrap();
let (state, _) = update(
state,
Msg::StreamDone {
turn: id,
usage: None,
provider_continuation: None,
stop_reason: Some(mermaid_cli::models::FinishReason::Length),
},
);
assert!(matches!(state.turn, TurnState::Idle));
assert!(
state
.session
.messages()
.iter()
.any(|m| m.role == MessageRole::System && m.content.contains("truncated")),
"a Length stop with no tool calls should surface the truncation note"
);
}
#[test]
fn manual_compaction_finish_drains_queued_message() {
let mut state = fresh();
state
.session
.append(ChatMessage::user("old prompt"), state.now);
state
.session
.append(ChatMessage::assistant("old answer"), state.now);
state
.session
.append(ChatMessage::user("new prompt"), state.now);
let (state, _) = update(state, Msg::Slash(SlashCmd::Compact(None)));
let turn = state.turn.id().expect("compaction turn");
let (state, _) = update(
state,
Msg::SubmitPrompt {
text: "while you were compacting".to_string(),
attachment_ids: vec![],
},
);
assert_eq!(
state.ui.queued_messages.len(),
1,
"message should queue during compaction"
);
let snap = |msg_tokens| {
ContextUsageSnapshot::from_estimate(
PromptTokenBreakdown {
system_tokens: 10,
instructions_tokens: 0,
message_tokens: msg_tokens,
tool_schema_tokens: 0,
image_count: 0,
message_count: 3,
tool_count: 0,
},
Some(1_000),
)
};
let mut checkpoint = ChatMessage::user("MERMAID CONTEXT CHECKPOINT\n## Goal\n- continue");
checkpoint.kind = ChatMessageKind::ContextCheckpoint;
let result = CompactionResult {
record: CompactionRecord {
id: "compact_test".to_string(),
trigger: CompactionTrigger::Manual,
created_at: chrono::Local::now(),
before_tokens: 100,
after_tokens: 30,
archived_message_count: 2,
preserved_message_count: 1,
preserved_turn_count: 1,
summary_tokens: 10,
duration_secs: 0.5,
review_status: mermaid_cli::domain::CompactionReviewStatus::Reviewed,
review_error: None,
focus: None,
archive_path: None,
},
replacement_messages: vec![checkpoint, ChatMessage::user("new prompt")],
archived_messages: vec![
ChatMessage::user("old prompt"),
ChatMessage::assistant("old answer"),
],
before_snapshot: snap(90),
after_snapshot: snap(20),
usage: None,
source_boundaries: Vec::new(),
};
let (state, cmds) = update(state, Msg::CompactionFinished { turn, result });
assert!(
state.ui.queued_messages.is_empty(),
"queued message must drain on manual compaction finish (#73)"
);
assert!(
cmds.iter().any(|c| matches!(c, Cmd::CallModel { .. })),
"draining the queued message should auto-submit it (CallModel emitted)"
);
assert!(matches!(state.turn, TurnState::Generating { .. }));
}
#[test]
fn slash_unknown_posts_to_transcript() {
let (state, cmds) = update(fresh(), Msg::Slash(SlashCmd::Unknown("nope".to_string())));
assert!(
state
.session
.messages()
.last()
.is_some_and(|m| m.content.contains("Unknown command: /nope")),
"unknown command posts a note to the chat transcript"
);
assert!(cmds.iter().any(|c| matches!(c, Cmd::SaveConversation(_))));
}
#[test]
fn quit_saves_and_sets_exit() {
let (state, cmds) = update(fresh(), Msg::Quit);
assert!(state.should_exit);
assert!(cmds.iter().any(|c| matches!(c, Cmd::SaveConversation(_))));
assert!(cmds.iter().any(|c| matches!(c, Cmd::Exit)));
}
#[test]
fn stream_tool_call_buffers_then_stream_done_transitions_to_executing_tools() {
let (state, _) = user_submit(fresh(), "do a thing");
let id = state.current_turn_id().unwrap();
let (state, _) = update(
state,
Msg::StreamToolCall {
turn: id,
call: ModelToolCall {
id: Some("call_1".to_string()),
function: FunctionCall {
name: "read_file".to_string(),
arguments: serde_json::json!({"path": "x"}),
},
},
},
);
let (state, cmds) = update(
state,
Msg::StreamDone {
turn: id,
usage: None,
provider_continuation: None,
stop_reason: None,
},
);
assert!(
matches!(state.turn, TurnState::ExecutingTools { .. }),
"expected ExecutingTools; got {:?}",
state.turn
);
assert!(
cmds.iter().any(|c| matches!(c, Cmd::ExecuteTool { .. })),
"expected Cmd::ExecuteTool; got {:?}",
cmds.iter().map(|c| c.tag()).collect::<Vec<_>>()
);
}
#[test]
fn tool_progress_artifact_routes_image_to_assistant_message() {
use mermaid_cli::providers::ProgressEvent;
let (state, _) = user_submit(fresh(), "take a screenshot");
let id = state.current_turn_id().unwrap();
let mut state = state;
state.session.append(
mermaid_cli::models::ChatMessage {
role: MessageRole::Assistant,
content: String::new(),
timestamp: chrono::Local::now(),
kind: mermaid_cli::models::ChatMessageKind::Normal,
metadata: None,
actions: vec![],
thinking: None,
images: None,
image_numbers: None,
tool_calls: None,
tool_call_id: None,
tool_name: None,
provider_continuation: None,
},
state.now,
);
state.turn = start_executing_tools(
id,
vec![PendingToolCall {
call_id: ToolCallId(1),
source: ModelToolCall {
id: Some("c1".to_string()),
function: FunctionCall {
name: "screenshot".to_string(),
arguments: serde_json::json!({}),
},
},
}],
std::time::SystemTime::now(),
);
let data = vec![0x89, 0x50, 0x4E, 0x47]; let (state, _) = update(
state,
Msg::ToolProgress {
turn: id,
call_id: ToolCallId(1),
event: ProgressEvent::Artifact {
mime: "image/png".to_string(),
data: data.clone(),
caption: Some("preview".to_string()),
},
},
);
let last = state.session.messages().last().expect("last msg");
assert_eq!(last.role, MessageRole::Assistant);
let imgs = last.images.as_ref().expect("images attached");
assert_eq!(imgs.len(), 1, "one artifact appended");
use base64::{Engine as _, engine::general_purpose};
let decoded = general_purpose::STANDARD.decode(&imgs[0]).unwrap();
assert_eq!(decoded, data);
}
#[test]
fn configured_mcp_servers_seed_state_and_ready_updates() {
use mermaid_cli::app::{Config as AppConfig, McpServerConfig};
use mermaid_cli::domain::{McpServerStatus, McpToolSpec};
let mut cfg = AppConfig::default();
cfg.mcp_servers.insert(
"context7".to_string(),
McpServerConfig {
command: "npx".to_string(),
args: vec!["-y".to_string(), "@upstash/context7-mcp".to_string()],
env: std::collections::HashMap::new(),
..Default::default()
},
);
let state = State::new(
cfg,
PathBuf::from("/tmp/p"),
"ollama/test".to_string(),
chrono::Local::now(),
);
let entry = state
.mcp
.servers
.get("context7")
.expect("configured server must be seeded");
assert_eq!(entry.status, McpServerStatus::Starting);
assert!(entry.tools.is_empty());
let (state, _) = update(
state,
Msg::McpServerReady {
name: "context7".to_string(),
tools: vec![McpToolSpec {
name: "mcp__context7__resolve-library-id".to_string(),
raw_name: "resolve-library-id".to_string(),
description: "Resolve a library name".to_string(),
input_schema: serde_json::json!({"type": "object"}),
}],
},
);
let entry = &state.mcp.servers["context7"];
assert_eq!(entry.status, McpServerStatus::Ready);
assert_eq!(entry.tools.len(), 1);
assert_eq!(entry.tools[0].name, "mcp__context7__resolve-library-id");
}
#[test]
fn tick_never_mutates_visible_state() {
let before = fresh();
let before_msg_count = before.session.messages().len();
let (after, cmds) = update(before, Msg::Tick);
assert!(cmds.is_empty());
assert_eq!(after.session.messages().len(), before_msg_count);
assert!(matches!(after.turn, TurnState::Idle));
}
use mermaid_cli::domain::{
Key, KeyCode, KeyMods, PendingQuestionSet, Question, QuestionKind, QuestionOption,
QuestionResolution, TextValidate,
};
fn opt(label: &str) -> QuestionOption {
QuestionOption {
label: label.to_string(),
description: Some("desc".to_string()),
recommended: false,
preview: None,
}
}
fn question(header: &str, text: &str, multi: bool, labels: &[&str]) -> Question {
Question {
header: header.to_string(),
question: text.to_string(),
kind: if multi {
QuestionKind::MultiSelect
} else {
QuestionKind::Select
},
options: labels.iter().map(|l| opt(l)).collect(),
memory_key: None,
}
}
fn seed_questions(questions: Vec<Question>) -> State {
let mut s = fresh();
s.pending_question
.push_back(PendingQuestionSet::new(TurnId(1), ToolCallId(1), questions));
s
}
fn press(state: State, code: KeyCode) -> (State, Vec<Cmd>) {
update(
state,
Msg::Key(Key {
code,
modifiers: KeyMods::NONE,
}),
)
}
fn resolution(cmds: &[Cmd]) -> Option<&QuestionResolution> {
cmds.iter().find_map(|c| match c {
Cmd::ResolveQuestion { resolution, .. } => Some(resolution),
_ => None,
})
}
fn assert_answered(cmds: &[Cmd], expected: &[&[&str]]) {
match resolution(cmds) {
Some(QuestionResolution::Answered { answers: ans, .. }) => {
assert_eq!(ans.len(), expected.len(), "answer count");
for (a, exp) in ans.iter().zip(expected) {
let got: Vec<&str> = a.selected.iter().map(|s| s.as_str()).collect();
assert_eq!(&got, exp, "selected labels for {:?}", a.question);
}
},
other => panic!("expected Answered, got {other:?}"),
}
}
#[test]
fn single_select_number_key_resolves_immediately() {
let state = seed_questions(vec![question(
"DB",
"Which database?",
false,
&["PostgreSQL", "SQLite"],
)]);
let (state, cmds) = press(state, KeyCode::Char('1'));
assert!(state.pending_question.is_empty(), "resolved and popped");
assert_answered(&cmds, &[&["PostgreSQL"]]);
}
#[test]
fn single_select_arrow_then_enter_picks_second() {
let state = seed_questions(vec![question("DB", "Which?", false, &["A", "B"])]);
let (state, _) = press(state, KeyCode::Down);
let (state, cmds) = press(state, KeyCode::Enter);
assert!(state.pending_question.is_empty());
assert_answered(&cmds, &[&["B"]]);
}
#[test]
fn multi_select_toggles_then_submits_via_review() {
let state = seed_questions(vec![question(
"Feat",
"Which features?",
true,
&["Auth", "API", "Jobs"],
)]);
let (state, _) = press(state, KeyCode::Char('1')); let (state, _) = press(state, KeyCode::Char('3')); let (state, _) = press(state, KeyCode::Down); let (state, _) = press(state, KeyCode::Down); let (state, _) = press(state, KeyCode::Enter); assert!(
!state.pending_question.is_empty(),
"review screen, not resolved"
);
let (state, cmds) = press(state, KeyCode::Enter); assert!(state.pending_question.is_empty());
assert_answered(&cmds, &[&["Auth", "Jobs"]]);
}
#[test]
fn batched_questions_advance_then_review_submit() {
let state = seed_questions(vec![
question("Runtime", "Which runtime?", false, &["Node", "Python"]),
question("Style", "Which styling?", false, &["Tailwind", "CSS"]),
]);
let (state, _) = press(state, KeyCode::Enter); let (state, _) = press(state, KeyCode::Char('2')); assert!(!state.pending_question.is_empty(), "review screen");
let (state, cmds) = press(state, KeyCode::Enter); assert!(state.pending_question.is_empty());
assert_answered(&cmds, &[&["Node"], &["CSS"]]);
}
#[test]
fn esc_dismisses_the_question() {
let state = seed_questions(vec![question("DB", "Which?", false, &["A", "B"])]);
let (state, cmds) = press(state, KeyCode::Escape);
assert!(state.pending_question.is_empty());
assert!(matches!(
resolution(&cmds),
Some(QuestionResolution::Dismissed)
));
}
#[test]
fn other_free_text_becomes_the_answer() {
let state = seed_questions(vec![question("DB", "Which?", false, &["A", "B"])]);
let (state, _) = press(state, KeyCode::Down); let (mut state, _) = press(state, KeyCode::Down); for ch in "duck".chars() {
let (s, _) = press(state, KeyCode::Char(ch));
state = s;
}
let (state, cmds) = press(state, KeyCode::Enter);
assert!(state.pending_question.is_empty());
assert_answered(&cmds, &[&["duck"]]);
}
#[test]
fn note_rides_back_with_the_answer() {
let state = seed_questions(vec![question("DB", "Which?", false, &["A", "B"])]);
let (mut state, _) = press(state, KeyCode::Char('n')); for ch in "prefer A".chars() {
let (s, _) = press(state, KeyCode::Char(ch));
state = s;
}
let (state, _) = press(state, KeyCode::Enter); let (state, cmds) = press(state, KeyCode::Char('1')); assert!(state.pending_question.is_empty());
match resolution(&cmds) {
Some(QuestionResolution::Answered { answers: ans, .. }) => {
assert_eq!(ans[0].selected, vec!["A".to_string()]);
assert_eq!(ans[0].note.as_deref(), Some("prefer A"));
},
other => panic!("expected Answered, got {other:?}"),
}
}
fn input_q(kind: QuestionKind) -> Question {
Question {
header: "H".to_string(),
question: "Q?".to_string(),
kind,
options: vec![],
memory_key: None,
}
}
fn rank_q(labels: &[&str]) -> Question {
Question {
header: "H".to_string(),
question: "Q?".to_string(),
kind: QuestionKind::Rank,
options: labels.iter().map(|l| opt(l)).collect(),
memory_key: None,
}
}
#[test]
fn text_input_value_rides_back() {
let state = seed_questions(vec![input_q(QuestionKind::Text {
validate: TextValidate::Any,
})]);
let mut state = state;
for ch in "hello".chars() {
let (s, _) = press(state, KeyCode::Char(ch));
state = s;
}
let (state, cmds) = press(state, KeyCode::Enter);
assert!(state.pending_question.is_empty());
assert_answered(&cmds, &[&["hello"]]);
}
#[test]
fn number_steps_with_up_arrow() {
let state = seed_questions(vec![input_q(QuestionKind::Number {
min: Some(0.0),
max: Some(10.0),
step: Some(2.0),
slider: false,
})]);
let (state, _) = press(state, KeyCode::Up); let (state, _) = press(state, KeyCode::Up); let (state, cmds) = press(state, KeyCode::Enter);
assert!(state.pending_question.is_empty());
assert_answered(&cmds, &[&["4"]]);
}
#[test]
fn invalid_number_blocks_submit() {
let state = seed_questions(vec![input_q(QuestionKind::Text {
validate: TextValidate::Number,
})]);
let mut state = state;
for ch in "abc".chars() {
let (s, _) = press(state, KeyCode::Char(ch));
state = s;
}
let (state, cmds) = press(state, KeyCode::Enter);
assert!(
!state.pending_question.is_empty(),
"invalid value must not submit"
);
assert!(resolution(&cmds).is_none());
}
#[test]
fn rank_reorder_moves_item_and_submits() {
let state = seed_questions(vec![rank_q(&["A", "B", "C"])]);
let (state, _) = press(state, KeyCode::Char(' ')); let (state, _) = press(state, KeyCode::Down); let (state, _) = press(state, KeyCode::Down); let (state, _) = press(state, KeyCode::Char(' ')); let (state, cmds) = press(state, KeyCode::Enter);
assert!(state.pending_question.is_empty());
assert_answered(&cmds, &[&["B", "C", "A"]]);
}
#[test]
fn chat_about_this_reformulates() {
let state = seed_questions(vec![question("DB", "Which?", false, &["A", "B"])]);
let (state, cmds) = press(state, KeyCode::Char('c'));
assert!(state.pending_question.is_empty());
assert!(matches!(
resolution(&cmds),
Some(QuestionResolution::Reformulate)
));
}
#[test]
fn r_toggles_remember_flag() {
let state = seed_questions(vec![question("DB", "Which?", false, &["A", "B"])]);
let (state, _) = press(state, KeyCode::Char('r')); let (_state, cmds) = press(state, KeyCode::Char('1')); match resolution(&cmds) {
Some(QuestionResolution::Answered { remember, .. }) => assert!(*remember),
other => panic!("expected Answered, got {other:?}"),
}
}