//! Partial and completed captions stay ordered and bounded through voice shutdown. use super::*; use pretty_assertions::assert_eq; fn seed_interleaved_partials(chat: &mut ChatWidget) { chat.on_realtime_transcript_delta("assistant".into(), "First ".into()); chat.on_realtime_transcript_delta("user".into(), "Correction".into()); chat.on_realtime_transcript_delta("assistant".into(), "answer".into()); } #[tokio::test] async fn interleaved_transcripts_keep_interrupted_speech_suppressed() { let (mut chat, _sender, _events, _ops) = make_chatwidget_manual_with_sender().await; activate_voice(&mut chat); chat.on_realtime_transcript_delta("assistant".into(), "The long answer".into()); chat.on_realtime_transcript_delta("user".into(), "Actually, ".into()); let generation = chat.realtime_conversation.input_generation; chat.on_realtime_transcript_delta("assistant".into(), " keeps going".into()); assert_eq!( chat.realtime_conversation.speaker_suppression_generation, Some(generation) ); chat.on_realtime_transcript_done("user".into(), "Actually, make it short".into()); chat.on_realtime_transcript_delta("assistant".into(), " and going".into()); chat.on_realtime_transcript_done("assistant".into(), "The long answer keeps going".into()); assert_eq!( chat.realtime_conversation.speaker_suppression_generation, Some(generation) ); chat.on_realtime_transcript_delta("assistant".into(), "Short answer".into()); assert!( chat.realtime_conversation .speaker_suppression_generation .is_none() ); } #[tokio::test] async fn delayed_user_transcript_suppresses_old_audio_after_microphone_mute() { let (mut chat, _sender, _events, _ops) = make_chatwidget_manual_with_sender().await; activate_voice(&mut chat); chat.on_realtime_transcript_delta("assistant".into(), "Old answer".into()); chat.realtime_conversation.microphone_muted = true; chat.on_realtime_transcript_delta("user".into(), "Actually".into()); assert_eq!( ( chat.realtime_conversation.microphone_muted, chat.realtime_conversation.speaker_suppression_generation, ), (true, Some(chat.realtime_conversation.input_generation)) ); } #[tokio::test] async fn interleaved_transcripts_do_not_discard_the_interrupting_request() { let (mut chat, _sender, _events, mut ops) = make_chatwidget_manual_with_sender().await; let thread_id = activate_voice(&mut chat); chat.on_realtime_transcript_delta("assistant".into(), "The long answer".into()); chat.on_realtime_transcript_delta("user".into(), "Actually, ".into()); let generation = chat.realtime_conversation.input_generation; let turn_id = "interruption"; start_item( &mut chat, thread_id, turn_id, user_item("make it short"), ); chat.on_realtime_transcript_delta("assistant".into(), " keeps going".into()); chat.on_realtime_transcript_delta("user".into(), "make it short".into()); chat.on_realtime_transcript_done("assistant".into(), "The long answer keeps going".into()); chat.on_realtime_transcript_done("user".into(), "Actually, make it short".into()); let answer = agent_item( "short-answer", "Short answer", Some(MessagePhase::FinalAnswer), ); start_item(&mut chat, thread_id, turn_id, answer.clone()); complete_item(&mut chat, thread_id, turn_id, answer.clone()); finish_turn( &mut chat, thread_id, turn_id, vec![answer], TurnStatus::Completed, ); assert!(matches!( ops.try_recv(), Ok(AppCommand::RealtimeConversationSpeech { input_generation, text, .. }) if input_generation == generation && text.as_str() == "Short answer" )); assert!(ops.try_recv().is_err()); } #[tokio::test] async fn completed_user_transcript_keeps_the_old_assistant_turn_suppressed() { let (mut chat, _sender, _events, _ops) = make_chatwidget_manual_with_sender().await; activate_voice(&mut chat); chat.on_realtime_transcript_delta("assistant".into(), "Old answer".into()); chat.on_realtime_transcript_done("user".into(), "Stop, I have a correction".into()); let generation = chat.realtime_conversation.input_generation; chat.on_realtime_transcript_done("assistant".into(), "Old answer".into()); assert_eq!( chat.realtime_conversation.speaker_suppression_generation, Some(generation) ); chat.on_realtime_transcript_delta("assistant".into(), "Go".into()); chat.on_realtime_transcript_done("assistant".into(), "Go ahead".into()); assert!( chat.realtime_conversation .speaker_suppression_generation .is_none() ); } #[tokio::test] async fn final_only_assistant_transcript_cannot_release_an_interruption() { let (mut chat, _sender, _events, _ops) = make_chatwidget_manual_with_sender().await; activate_voice(&mut chat); chat.on_realtime_transcript_delta("user".into(), "Stop".into()); let generation = chat.realtime_conversation.input_generation; chat.on_realtime_transcript_done("user".into(), "Stop".into()); chat.on_realtime_transcript_done("assistant".into(), "Late old answer".into()); assert_eq!( chat.realtime_conversation.speaker_suppression_generation, Some(generation) ); } #[tokio::test] async fn transcript_deltas_track_the_current_speaker() { let (mut chat, _sender, _events, _ops) = make_chatwidget_manual_with_sender().await; chat.realtime_conversation.phase = RealtimeConversationPhase::Active; let owner = |chat: &ChatWidget| chat.realtime_conversation.speaker_suppression_generation; chat.on_realtime_transcript_delta("user".to_string(), "Hello ".to_string()); chat.on_realtime_transcript_delta("user".to_string(), "there".to_string()); assert_eq!( ( chat.realtime_conversation.transcript_role.as_deref(), chat.realtime_conversation.transcript.as_str() ), (Some("user"), "Hello there") ); chat.on_realtime_transcript_done("assistant".to_string(), "Old".to_string()); assert_eq!( chat.realtime_conversation.speaker_suppression_generation, Some(chat.realtime_conversation.input_generation) ); chat.on_realtime_transcript_done("user".to_string(), "Hello there".to_string()); chat.on_realtime_transcript_delta("assistant".to_string(), "Hi".to_string()); assert!(owner(&chat).is_none()); chat.on_realtime_transcript_done("assistant".to_string(), "Hi".to_string()); chat.on_realtime_transcript_delta("user".to_string(), "Again".to_string()); chat.on_realtime_transcript_done("user".to_string(), "Again".to_string()); assert!(owner(&chat).is_some()); chat.on_realtime_transcript_delta("assistant".to_string(), "".to_string()); assert!(owner(&chat).is_some()); chat.on_realtime_transcript_delta("assistant".to_string(), "Hi".to_string()); assert!(owner(&chat).is_none()); assert_eq!( ( chat.realtime_conversation.transcript_role.as_deref(), chat.realtime_conversation.transcript.as_str() ), (Some("assistant"), "Hi") ); chat.on_realtime_transcript_done("user".to_string(), "Again".to_string()); let generation = chat.realtime_conversation.input_generation; assert_eq!(owner(&chat), Some(generation)); chat.on_realtime_transcript_done("user".to_string(), " ".to_string()); assert_eq!(owner(&chat), Some(generation)); chat.on_realtime_transcript_done("assistant".to_string(), "".to_string()); assert_eq!(owner(&chat), Some(generation)); chat.on_realtime_transcript_done("assistant".to_string(), "Hi".to_string()); assert_eq!(owner(&chat), Some(generation)); chat.on_realtime_transcript_delta("user".to_string(), "noise".to_string()); chat.on_realtime_transcript_done("user".to_string(), "".to_string()); assert!(owner(&chat).is_none()); } #[tokio::test] async fn voice_transcripts_stream_in_the_conversation_instead_of_the_footer() { let (mut chat, _sender, mut events, _ops) = make_chatwidget_manual_with_sender().await; chat.config.animations = false; activate_voice(&mut chat); chat.update_realtime_footer(); chat.on_realtime_transcript_delta("user".to_string(), "pick a ".to_string()); chat.on_realtime_transcript_delta("user".to_string(), "number".to_string()); let live = chat .active_cell_transcript_lines(/*width*/ 80) .unwrap_or_default() .into_iter() .map(|line| line.to_string()) .collect::>() .join("\n"); assert!(live.contains("pick a number")); let footer = render_bottom_popup(&chat, /*width*/ 80); assert!(footer.contains("voice ● listening")); let status_line = footer .lines() .find(|line| line.contains("voice ● listening")) .unwrap_or_default(); assert!(!status_line.contains("pick a number")); assert!(events.try_recv().is_err()); chat.on_realtime_transcript_done("user".to_string(), "pick a number".to_string()); assert!(chat.active_cell_transcript_lines(/*width*/ 80).is_none()); let Ok(AppEvent::InsertHistoryCell(cell)) = events.try_recv() else { panic!("the completed voice transcript should become one normal user turn"); }; assert!( cell.display_lines(/*width*/ 80) .iter() .any(|line| line.to_string().contains("pick a number")) ); assert!(events.try_recv().is_err()); } #[tokio::test] async fn unexpected_voice_close_preserves_partial_transcripts_once() { for (role, text) in [ ("user", "Part of my request"), ("assistant", "Part of the answer"), ] { let (mut chat, _sender, mut events, _ops) = make_chatwidget_manual_with_sender().await; activate_voice(&mut chat); chat.on_realtime_transcript_delta(role.to_string(), text.to_string()); chat.on_realtime_conversation_closed(/*reason*/ None); chat.on_realtime_transcript_done(role.to_string(), text.to_string()); let mut appearances = 0; while let Ok(event) = events.try_recv() { if let AppEvent::InsertHistoryCell(cell) = event { appearances += cell.display_lines(/*width*/ 80) .iter() .any(|line| line.to_string().contains(text)) as usize; } } assert_eq!(appearances, 1, "{role} transcript should be preserved once"); assert!(chat.active_cell_transcript_lines(/*width*/ 80).is_none()); assert_eq!( chat.realtime_conversation.phase, RealtimeConversationPhase::Inactive ); } } #[tokio::test] async fn interleaved_partial_transcripts_survive_voice_close() { let (mut chat, _sender, mut events, _ops) = make_chatwidget_manual_with_sender().await; activate_voice(&mut chat); seed_interleaved_partials(&mut chat); chat.on_realtime_conversation_closed(/*reason*/ None); chat.on_realtime_transcript_done("user".into(), "Correction".into()); chat.on_realtime_transcript_done("assistant".into(), "First answer".into()); let rendered = std::iter::from_fn(|| events.try_recv().ok()) .filter_map(|event| match event { AppEvent::InsertHistoryCell(cell) => Some( cell.display_lines(/*width*/ 80) .into_iter() .map(|line| line.to_string()) .collect::>() .join("\n"), ), _ => None, }) .collect::>() .join("\n"); assert_eq!(rendered.matches("Correction").count(), 1); assert_eq!(rendered.matches("First answer").count(), 1); insta::assert_snapshot!("interleaved_partials_on_close", rendered); } #[tokio::test] async fn interleaved_partials_transfer_once_on_thread_switch() { let (mut chat, _sender, _events, _ops) = make_chatwidget_manual_with_sender().await; activate_voice(&mut chat); seed_interleaved_partials(&mut chat); let cells = chat.take_realtime_transcript_cells_for_replay(); let rendered = cells .iter() .map(|record| record.text.as_str()) .collect::(); assert_eq!(cells.len(), 2); assert_eq!(rendered.matches("Correction").count(), 1); assert_eq!(rendered.matches("First answer").count(), 1); assert!(chat.take_realtime_transcript_cells_for_replay().is_empty()); } #[tokio::test] async fn intentional_stop_preserves_both_interleaved_partials_once() { let (mut chat, _sender, mut events, _ops) = make_chatwidget_manual_with_sender().await; activate_voice(&mut chat); seed_interleaved_partials(&mut chat); chat.stop_realtime_conversation(); chat.on_realtime_conversation_closed(/*reason*/ None); let rendered = std::iter::from_fn(|| events.try_recv().ok()) .filter_map(|event| match event { AppEvent::InsertHistoryCell(cell) => Some( cell.display_lines(/*width*/ 80) .into_iter() .map(|line| line.to_string()) .collect::>() .join("\n"), ), _ => None, }) .collect::>() .join("\n"); assert_eq!(rendered.matches("Correction").count(), 1); assert_eq!(rendered.matches("First answer").count(), 1); insta::assert_snapshot!("interleaved_partials_on_stop", rendered); } #[tokio::test] async fn stopping_voice_keeps_the_complete_late_final_caption() { let (mut chat, _sender, mut events, mut ops) = make_chatwidget_manual_with_sender().await; activate_voice(&mut chat); chat.on_realtime_transcript_delta("assistant".into(), "First ".into()); chat.finish_realtime_partial_transcripts(); chat.realtime_conversation.phase = RealtimeConversationPhase::Stopping; chat.on_realtime_transcript_delta("assistant".into(), "and last".into()); chat.on_realtime_transcript_done("assistant".into(), "First and last".into()); chat.on_realtime_conversation_closed(Some("requested".into())); let rendered = std::iter::from_fn(|| events.try_recv().ok()) .filter_map(|event| match event { AppEvent::InsertHistoryCell(cell) => Some( cell.display_lines(/*width*/ 80) .into_iter() .map(|line| line.to_string()) .collect::>() .join("\n"), ), _ => None, }) .collect::>() .join("\n"); assert_eq!(rendered.matches("First").count(), 2); assert_eq!(rendered.matches("and last").count(), 1); assert!(ops.try_recv().is_err()); insta::assert_snapshot!("voice_stop_late_transcript", rendered); } #[tokio::test] async fn separate_late_finals_with_a_shared_prefix_keep_both_full_captions() { let (mut chat, _sender, mut events, _ops) = make_chatwidget_manual_with_sender().await; activate_voice(&mut chat); chat.realtime_conversation.phase = RealtimeConversationPhase::Stopping; chat.on_realtime_transcript_done("assistant".into(), "Hello".into()); chat.on_realtime_transcript_done("assistant".into(), "Hello again".into()); let captions = std::iter::from_fn(|| events.try_recv().ok()) .filter_map(|event| match event { AppEvent::InsertHistoryCell(cell) => Some( cell.display_lines(/*width*/ 80) .into_iter() .map(|line| line.to_string()) .collect::>() .join("\n"), ), _ => None, }) .collect::>(); assert_eq!(captions, vec!["• Hello", "• Hello again"]); } #[tokio::test] async fn direct_reset_preserves_both_partial_speakers_for_replay() { let (mut chat, _sender, mut events, _ops) = make_chatwidget_manual_with_sender().await; activate_voice(&mut chat); chat.on_realtime_transcript_delta("assistant".into(), "First ".into()); chat.on_realtime_transcript_delta("user".into(), "Correction".into()); chat.on_realtime_transcript_delta("assistant".into(), "answer".into()); chat.reset_realtime_conversation(); let records = chat.take_realtime_transcript_cells_for_replay(); assert_eq!( records .into_iter() .map(|record| (record.role, record.text)) .collect::>(), vec![ ("user".to_string(), "Correction".to_string()), ("assistant".to_string(), "First answer".to_string()) ] ); let rendered = std::iter::from_fn(|| events.try_recv().ok()) .filter_map(|event| match event { AppEvent::InsertHistoryCell(cell) => Some( cell.display_lines(/*width*/ 80) .into_iter() .map(|line| line.to_string()) .collect::(), ), _ => None, }) .collect::(); assert_eq!(rendered.matches("Correction").count(), 1); assert_eq!(rendered.matches("First answer").count(), 1); } #[tokio::test] async fn stopping_voice_preserves_the_live_transcript_once() { for command in ["/voice", "/voice stop"] { let (mut chat, _sender, mut events, _ops) = make_chatwidget_manual_with_sender().await; chat.config.animations = true; activate_voice(&mut chat); chat.on_realtime_transcript_done("user".to_string(), "Earlier question".to_string()); chat.on_realtime_transcript_done("assistant".to_string(), "Earlier answer".to_string()); chat.on_realtime_transcript_delta("assistant".to_string(), "Answer in ".to_string()); chat.on_realtime_transcript_delta("assistant".to_string(), "progress".to_string()); if command == "/voice" { chat.handle_slash_command_dispatch(crate::slash_command::SlashCommand::Voice); } else { chat.handle_slash_command_with_args_dispatch( crate::slash_command::SlashCommand::Voice, "stop".to_string(), Vec::new(), ); } chat.on_realtime_transcript_done("assistant".to_string(), "Answer in progress".to_string()); chat.on_realtime_conversation_closed(/*reason*/ None); chat.stop_realtime_conversation(); assert_eq!( chat.realtime_conversation.phase, RealtimeConversationPhase::Inactive ); assert!(chat.active_cell_transcript_key().is_none()); let mut rendered = Vec::new(); while let Ok(event) = events.try_recv() { if let AppEvent::InsertHistoryCell(cell) = event { assert!(cell.transcript_animation_tick().is_none()); rendered.extend( cell.display_lines(/*width*/ 80) .into_iter() .map(|line| line.to_string()), ); } } insta::allow_duplicates! { insta::assert_snapshot!(rendered.join("\n"), @r" › Earlier question • Earlier answer • Answer in progress "); } } } #[tokio::test] async fn transcript_completion_waits_for_normal_agent_stream_consolidation() { let (mut chat, _sender, mut events, _ops) = make_chatwidget_manual_with_sender().await; activate_voice(&mut chat); chat.on_agent_message_delta("normal typed output".to_string()); chat.on_realtime_transcript_done("assistant".to_string(), "voice output".to_string()); chat.on_realtime_transcript_delta( "assistant".to_string(), "unfinished voice output".to_string(), ); chat.stop_realtime_conversation(); assert_eq!(chat.realtime_conversation.pending_history_cells.len(), 2); while let Ok(event) = events.try_recv() { if let AppEvent::InsertHistoryCell(cell) = event { assert!( cell.display_lines(/*width*/ 80) .iter() .all(|line| !line.to_string().contains("voice output")) ); } } chat.finalize_completed_assistant_message(Some("normal typed output")); chat.note_stream_consolidation_completed(); chat.flush_realtime_transcript_history(); let mut rendered = Vec::new(); while let Ok(event) = events.try_recv() { if let AppEvent::InsertHistoryCell(cell) = event { rendered.extend( cell.display_lines(/*width*/ 80) .into_iter() .map(|line| line.to_string()), ); } } assert_eq!( rendered, [ "• normal typed output", "• voice output", "• unfinished voice output" ] ); } #[tokio::test] async fn transcript_handoff_moves_deferred_repeats_and_partial_once() { let (mut chat, _sender, mut events, _ops) = make_chatwidget_manual_with_sender().await; chat.config.animations = false; activate_voice(&mut chat); chat.on_agent_message_delta("ordinary stream".to_string()); for _ in 0..super::super::MAX_PENDING_TRANSCRIPT_CELLS { chat.on_realtime_transcript_done("assistant".to_string(), "same words".to_string()); } chat.on_realtime_transcript_delta("assistant".to_string(), "partial words".to_string()); assert_eq!( chat.realtime_conversation.pending_history_cells.len(), super::super::MAX_PENDING_TRANSCRIPT_CELLS ); let cells = chat.take_realtime_transcript_cells_for_replay(); assert_eq!(cells.len(), super::super::MAX_PENDING_TRANSCRIPT_CELLS + 1); assert!(chat.take_realtime_transcript_cells_for_replay().is_empty()); chat.restore_realtime_transcript_cells(cells); chat.finalize_completed_assistant_message(Some("ordinary stream")); chat.note_stream_consolidation_completed(); chat.flush_realtime_transcript_history(); let rendered = std::iter::from_fn(|| events.try_recv().ok()) .filter_map(|event| match event { AppEvent::InsertHistoryCell(cell) => Some( cell.transcript_lines(/*width*/ 80) .into_iter() .map(|line| line.to_string()) .collect::(), ), _ => None, }) .collect::(); assert_eq!( rendered.matches("same words").count(), super::super::MAX_PENDING_TRANSCRIPT_CELLS ); assert_eq!(rendered.matches("partial words").count(), 0); let live = chat .realtime_conversation .live_transcript_cell .as_ref() .unwrap(); assert!( live.display_lines(/*width*/ 80) .iter() .any(|line| line.to_string().contains("partial words")) ); } #[tokio::test] async fn dropped_deferred_captions_do_not_return_on_thread_replacement() { let (mut chat, _sender, _events, _ops) = make_chatwidget_manual_with_sender().await; activate_voice(&mut chat); chat.on_agent_message_delta("ordinary stream".to_string()); for index in 0..(super::super::MAX_PENDING_TRANSCRIPT_CELLS + 2) { chat.on_realtime_transcript_done("assistant".into(), format!("caption {index}")); } assert_eq!( chat.realtime_conversation.pending_history_cells.len(), super::super::MAX_PENDING_TRANSCRIPT_CELLS ); let records = chat.take_realtime_transcript_cells_for_replay(); assert_eq!(records.len(), super::super::MAX_PENDING_TRANSCRIPT_CELLS); assert_eq!( records.front().map(|record| record.text.as_str()), Some("caption 2") ); assert_eq!( records.back().map(|record| record.text.as_str()), Some("caption 33") ); } #[tokio::test] async fn transcripts_are_preserved_while_the_peer_is_connecting() { let (mut chat, _sender, mut events, _ops) = make_chatwidget_manual_with_sender().await; chat.realtime_conversation.phase = RealtimeConversationPhase::Starting; chat.realtime_conversation.backend_started = true; chat.on_realtime_transcript_delta("assistant".to_string(), "Hello".to_string()); assert_eq!( ( chat.realtime_conversation.transcript_role.as_deref(), chat.realtime_conversation.transcript.as_str(), ), (Some("assistant"), "Hello") ); chat.on_realtime_transcript_done("assistant".to_string(), "Hello there".to_string()); let Ok(AppEvent::InsertHistoryCell(cell)) = events.try_recv() else { panic!("voice should preserve transcripts received before the peer connects"); }; assert!( cell.display_lines(/*width*/ 80) .iter() .any(|line| line.to_string().contains("Hello there")) ); assert_eq!( ( chat.realtime_conversation.phase, chat.realtime_conversation.transcript_role.as_deref(), chat.realtime_conversation.transcript.as_str(), ), (RealtimeConversationPhase::Starting, None, "") ); } #[tokio::test] async fn transcript_deltas_keep_a_bounded_utf8_suffix() { let (mut chat, _sender, _events, _ops) = make_chatwidget_manual_with_sender().await; chat.realtime_conversation.phase = RealtimeConversationPhase::Active; chat.on_realtime_transcript_delta("user".to_string(), "é".repeat(600)); chat.on_realtime_transcript_delta("user".to_string(), "done".to_string()); assert_eq!( chat.realtime_conversation.transcript, format!("{}done", "é".repeat(510)) ); } #[tokio::test] async fn completed_transcript_is_added_to_history_and_clears_the_caption() { let (mut chat, _sender, mut events, _ops) = make_chatwidget_manual_with_sender().await; chat.realtime_conversation.phase = RealtimeConversationPhase::Active; chat.on_realtime_transcript_delta("assistant".to_string(), "Hello".to_string()); chat.on_realtime_transcript_done("assistant".to_string(), "Hello there".to_string()); chat.on_realtime_transcript_done("user".to_string(), "Can you hear me?".to_string()); let mut rendered = Vec::new(); while let Ok(event) = events.try_recv() { if let AppEvent::InsertHistoryCell(cell) = event { rendered.extend( cell.display_lines(/*width*/ 80) .into_iter() .map(|line| line.to_string()), ); } } assert_eq!( ( chat.realtime_conversation.transcript_role.as_deref(), chat.realtime_conversation.transcript.as_str(), ), (None, "") ); insta::assert_snapshot!(rendered.join("\n")); } #[tokio::test] async fn completed_transcript_preserves_the_other_speakers_caption() { let (mut chat, _sender, _events, _ops) = make_chatwidget_manual_with_sender().await; chat.realtime_conversation.phase = RealtimeConversationPhase::Active; chat.on_realtime_transcript_delta("assistant".to_string(), "Still speaking".to_string()); chat.on_realtime_transcript_done("user".to_string(), "Interruption".to_string()); assert_eq!( ( chat.realtime_conversation.transcript_role.as_deref(), chat.realtime_conversation.transcript.as_str(), ), (Some("assistant"), "Still speaking") ); } #[tokio::test] async fn live_voice_split_flap_animates_without_changing_final_history() { let (mut chat, _sender, mut events, _ops) = make_chatwidget_manual_with_sender().await; chat.config.animations = true; activate_voice(&mut chat); chat.on_realtime_transcript_delta("assistant".to_string(), "gate 73".to_string()); let Some(cell) = chat.realtime_conversation.live_transcript_cell.as_ref() else { panic!("live voice transcripts should have an animation cell"); }; assert_eq!(cell.raw_lines()[0].to_string(), "gate 73"); assert!( chat.active_cell_transcript_key() .is_some_and(|key| { key.animation_tick.is_some() }) ); chat.on_realtime_transcript_done("assistant".to_string(), "gate 73".to_string()); assert!(chat.active_cell_transcript_key().is_none()); let Ok(AppEvent::InsertHistoryCell(cell)) = events.try_recv() else { panic!("a completed voice transcript should commit ordinary history"); }; assert!(cell.transcript_animation_tick().is_none()); assert!( cell.display_lines(/*width*/ 80) .iter() .any(|line| { line.to_string().contains("gate 73") }) ); }