use super::{BufferPlugin, InitialEditorBuffer, error::BufferCommandError};
use crate::{
StatusInfoText,
buffer::{BufferEdit, BufferEditError, BufferEditKind, BufferEditShape, BufferFile},
domain::viewport::TextViewport,
ecs::{
components::{
buffer::{
BufferEntity, BufferRevision, BufferText, BufferUndoHistory, EditorBuffer,
EditorView, ViewEntity,
},
cursor::{ByteIndex, CursorPosition},
layout::{TextSpanLayout, ViewportState, VisibleTextRange},
vim::VimModalState,
},
events::{
command::{
CommandFailed, CommandFailureClass, CommandFailureDiagnostic, CommandFailureSource,
CommandRejected, CommandRejectionReason, CommandRequested, CommandRetryAdvice,
},
cursor::{
CursorMoveRejected, CursorMoveRejectionReason, CursorMoveRequestShape,
CursorMoveRequested,
},
edit::{
BufferEditRejected, BufferEditRejectionReason, BufferEditRequested, BufferEdited,
EditHistoryApplied, EditHistoryDirection, EditHistoryRejected,
EditHistoryRejectionReason, EditHistoryRequested,
},
lifecycle::{LifecycleRequested, ShutdownRequest},
status::{StatusMessage, StatusMessageRequested},
status::{StatusMessageRejected, StatusRejectionReason},
},
},
fs_utils::{FileWriteError, FilesystemConfig},
presentation::{ResolvedTheme, TransparencyMode},
text_stream::{TextByteStream, TextStreamError},
vim::{InsertState, QuitPolicy, VimCommand, VimError, VimMode, VimStatusMessage},
};
use bevy::{
app::AppExit,
prelude::{App, Entity, IntoScheduleConfigs, MessageReader, Resource, Update, With},
window::{PrimaryWindow, Window},
};
use proptest::prelude::*;
use std::{
fs,
path::PathBuf,
time::{SystemTime, UNIX_EPOCH},
};
#[derive(Default, Resource)]
struct CapturedEdits {
events: Vec<BufferEdited>,
}
#[derive(Default, Resource)]
struct CapturedRejectedEdits {
events: Vec<BufferEditRejected>,
}
#[derive(Default, Resource)]
struct CapturedEditHistory {
applied: Vec<EditHistoryApplied>,
rejected: Vec<EditHistoryRejected>,
}
#[derive(Default, Resource)]
struct CapturedRejectedCommands {
events: Vec<CommandRejected>,
}
#[derive(Default, Resource)]
struct CapturedCommandFailures {
events: Vec<CommandFailed>,
}
#[derive(Default, Resource)]
struct CapturedRejectedCursorMoves {
events: Vec<CursorMoveRejected>,
}
#[derive(Default, Resource)]
struct CapturedRejectedStatusMessages {
events: Vec<StatusMessageRejected>,
}
#[derive(Default, Resource)]
struct CapturedExits {
events: Vec<AppExit>,
}
fn capture_edits(
mut reader: MessageReader<BufferEdited>,
mut captured: bevy::prelude::ResMut<CapturedEdits>,
) {
captured.events.extend(reader.read().copied());
}
fn capture_rejected_edits(
mut reader: MessageReader<BufferEditRejected>,
mut captured: bevy::prelude::ResMut<CapturedRejectedEdits>,
) {
captured.events.extend(reader.read().cloned());
}
fn capture_edit_history(
mut applied_reader: MessageReader<EditHistoryApplied>,
mut rejected_reader: MessageReader<EditHistoryRejected>,
mut captured: bevy::prelude::ResMut<CapturedEditHistory>,
) {
captured.applied.extend(applied_reader.read().copied());
captured.rejected.extend(rejected_reader.read().cloned());
}
fn capture_rejected_commands(
mut reader: MessageReader<CommandRejected>,
mut captured: bevy::prelude::ResMut<CapturedRejectedCommands>,
) {
captured.events.extend(reader.read().copied());
}
fn capture_command_failures(
mut reader: MessageReader<CommandFailed>,
mut captured: bevy::prelude::ResMut<CapturedCommandFailures>,
) {
captured.events.extend(reader.read().copied());
}
fn capture_rejected_cursor_moves(
mut reader: MessageReader<CursorMoveRejected>,
mut captured: bevy::prelude::ResMut<CapturedRejectedCursorMoves>,
) {
captured.events.extend(reader.read().copied());
}
fn capture_rejected_status_messages(
mut reader: MessageReader<StatusMessageRejected>,
mut captured: bevy::prelude::ResMut<CapturedRejectedStatusMessages>,
) {
captured.events.extend(reader.read().cloned());
}
fn capture_exits(
mut reader: MessageReader<AppExit>,
mut captured: bevy::prelude::ResMut<CapturedExits>,
) {
captured.events.extend(reader.read().cloned());
}
fn temp_file_path(name: &str) -> PathBuf {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("system time should be after unix epoch")
.as_nanos();
std::env::temp_dir().join(format!("alma-{name}-{}-{nanos}.txt", std::process::id()))
}
fn temp_filesystem_config() -> FilesystemConfig {
FilesystemConfig::from_workspace_root(std::env::temp_dir())
.expect("temporary directory should be a workspace root")
}
fn editor_buffer_entity(app: &mut App) -> Entity {
let mut query = app
.world_mut()
.query_filtered::<Entity, With<crate::ecs::components::buffer::EditorBuffer>>();
query
.iter(app.world())
.next()
.expect("editor buffer should exist")
}
fn editor_view_entity(app: &mut App) -> Entity {
let mut query = app.world_mut().query_filtered::<Entity, With<EditorView>>();
query
.iter(app.world())
.next()
.expect("editor view should exist")
}
fn view_for_buffer(app: &mut App, buffer: Entity) -> Entity {
let mut query = app.world_mut().query::<(Entity, &EditorView)>();
query
.iter(app.world())
.find_map(|(entity, view)| (view.buffer == BufferEntity(buffer)).then_some(entity))
.expect("editor view should exist for buffer")
}
fn spawn_editor_buffer_entity(app: &mut App, text: &str) -> Entity {
let stream = TextByteStream::new(text);
let revision = stream.revision();
let buffer = app
.world_mut()
.spawn((
EditorBuffer,
BufferFile::scratch(revision),
BufferText { stream },
BufferRevision { revision },
BufferUndoHistory::default(),
))
.id();
let _view = app
.world_mut()
.spawn((
EditorView {
buffer: BufferEntity(buffer),
},
CursorPosition {
byte_index: ByteIndex(0),
desired_column: None,
},
ViewportState {
viewport: TextViewport::default(),
pending_intent: None,
},
VisibleTextRange::default(),
TextSpanLayout::default(),
VimModalState::default(),
))
.id();
buffer
}
fn vim_modal_state(app: &bevy::prelude::App, entity: Entity) -> &VimModalState {
app.world()
.get::<VimModalState>(entity)
.expect("editor buffer should have Vim modal state")
}
fn buffer_text(app: &bevy::prelude::App, entity: Entity) -> String {
app.world()
.get::<BufferText>(entity)
.expect("editor buffer should have text")
.stream
.as_str()
.to_owned()
}
#[test]
fn buffer_plugin_applies_valid_edits_and_rejects_invalid_ranges() {
let mut app = App::new();
let _app = app
.insert_resource(InitialEditorBuffer {
stream: TextByteStream::new("aλ"),
file: BufferFile::scratch(0),
})
.init_resource::<CapturedEdits>()
.init_resource::<CapturedRejectedEdits>()
.add_plugins(crate::ecs::EditorCorePlugin)
.add_plugins(BufferPlugin)
.add_systems(Update, (capture_edits, capture_rejected_edits));
app.update();
let target = editor_buffer_entity(&mut app);
let source = editor_view_entity(&mut app);
let _message = app.world_mut().write_message(BufferEditRequested {
target: BufferEntity(target),
edit: BufferEdit::delete_unchecked(1..2),
});
app.update();
app.update();
let buffer = app
.world()
.get::<BufferText>(target)
.expect("editor buffer should exist");
let cursor = app
.world()
.get::<CursorPosition>(source)
.expect("editor view should exist");
assert_eq!(buffer.stream.as_str(), "aλ");
assert_eq!(cursor.byte_index, ByteIndex(0));
assert!(app.world().resource::<CapturedEdits>().events.is_empty());
assert_eq!(
app.world().resource::<CapturedRejectedEdits>().events,
vec![BufferEditRejected {
target: BufferEntity(target),
rejected: BufferEditShape::from_edit(&BufferEdit::delete_unchecked(1..2)),
reason: BufferEditRejectionReason::InvalidEdit {
error: BufferEditError::InvalidTextBoundary {
source: TextStreamError::NotCharBoundary { index: 2 },
},
},
}]
);
let _message = app.world_mut().write_message(BufferEditRequested {
target: BufferEntity(target),
edit: BufferEdit::insert("aλ".len(), String::from("!")),
});
app.update();
app.update();
let mut query = app.world_mut().query::<&BufferText>();
let buffer = query
.iter(app.world())
.next()
.expect("editor buffer should exist");
assert_eq!(buffer.stream.as_str(), "aλ!");
assert_eq!(
app.world().resource::<CapturedEdits>().events,
vec![BufferEdited {
target: BufferEntity(target),
previous_revision: BufferRevision { revision: 0.into() },
current_revision: BufferRevision { revision: 1.into() },
}]
);
assert_eq!(
app.world().resource::<CapturedRejectedEdits>().events.len(),
1
);
}
#[test]
fn undo_and_redo_replay_edit_transactions() {
let mut app = App::new();
let _app = app
.insert_resource(InitialEditorBuffer {
stream: TextByteStream::new("abc"),
file: BufferFile::scratch(0),
})
.init_resource::<CapturedEditHistory>()
.add_plugins(crate::ecs::EditorCorePlugin)
.add_plugins(BufferPlugin)
.add_systems(Update, capture_edit_history);
app.update();
let target = editor_buffer_entity(&mut app);
let _message = app.world_mut().write_message(BufferEditRequested {
target: BufferEntity(target),
edit: BufferEdit::insert(1, "λ"),
});
app.update();
assert_eq!(buffer_text(&app, target), "aλbc");
let _message = app
.world_mut()
.write_message(EditHistoryRequested::undo(BufferEntity(target)));
app.update();
app.update();
assert_eq!(buffer_text(&app, target), "abc");
assert_eq!(
app.world().resource::<CapturedEditHistory>().applied[0].direction,
EditHistoryDirection::Undo
);
let _message = app
.world_mut()
.write_message(EditHistoryRequested::redo(BufferEntity(target)));
app.update();
app.update();
assert_eq!(buffer_text(&app, target), "aλbc");
let captured = app.world().resource::<CapturedEditHistory>();
assert_eq!(captured.applied[1].direction, EditHistoryDirection::Redo);
assert!(captured.rejected.is_empty());
}
#[test]
fn empty_undo_is_observable_without_mutating_buffer() {
let mut app = App::new();
let _app = app
.insert_resource(InitialEditorBuffer {
stream: TextByteStream::new("abc"),
file: BufferFile::scratch(0),
})
.init_resource::<CapturedEditHistory>()
.add_plugins(crate::ecs::EditorCorePlugin)
.add_plugins(BufferPlugin)
.add_systems(Update, capture_edit_history);
app.update();
let target = editor_buffer_entity(&mut app);
let _message = app
.world_mut()
.write_message(EditHistoryRequested::undo(BufferEntity(target)));
app.update();
app.update();
assert_eq!(buffer_text(&app, target), "abc");
assert_eq!(
app.world().resource::<CapturedEditHistory>().rejected,
vec![EditHistoryRejected {
target: BufferEntity(target),
direction: EditHistoryDirection::Undo,
reason: EditHistoryRejectionReason::EmptyUndoStack,
}]
);
}
#[test]
fn buffer_edit_missing_target_emits_typed_rejection() {
let mut app = App::new();
let _app = app
.insert_resource(InitialEditorBuffer {
stream: TextByteStream::new("alma"),
file: BufferFile::scratch(0),
})
.init_resource::<CapturedRejectedEdits>()
.add_plugins(crate::ecs::EditorCorePlugin)
.add_plugins(BufferPlugin)
.add_systems(Update, capture_rejected_edits);
app.update();
let missing =
BufferEntity(Entity::from_raw_u32(999).expect("test entity index should be valid"));
let edit = BufferEdit::insert(0, String::from("x"));
let _message = app.world_mut().write_message(BufferEditRequested {
target: missing,
edit: edit.clone(),
});
app.update();
app.update();
assert_eq!(
app.world().resource::<CapturedRejectedEdits>().events,
vec![BufferEditRejected {
target: missing,
rejected: BufferEditShape::from_edit(&edit),
reason: BufferEditRejectionReason::MissingTarget { target: missing },
}]
);
}
#[test]
fn rejected_edit_debug_output_redacts_user_text() {
let secret = "secret replacement text";
let mut app = App::new();
let _app = app
.insert_resource(InitialEditorBuffer {
stream: TextByteStream::new("aλ"),
file: BufferFile::scratch(0),
})
.init_resource::<CapturedRejectedEdits>()
.add_plugins(crate::ecs::EditorCorePlugin)
.add_plugins(BufferPlugin)
.add_systems(Update, capture_rejected_edits);
app.update();
let target = editor_buffer_entity(&mut app);
let _message = app.world_mut().write_message(BufferEditRequested {
target: BufferEntity(target),
edit: BufferEdit::replace_unchecked(1..2, String::from(secret)),
});
app.update();
app.update();
let events = &app.world().resource::<CapturedRejectedEdits>().events;
assert_eq!(events.len(), 1);
assert_eq!(events[0].rejected.kind(), BufferEditKind::Replace);
assert_eq!(
events[0].rejected.range(),
Some(crate::text_stream::TextRange::new(1, 2))
);
assert_eq!(
events[0].rejected.replacement_byte_len(),
Some(secret.len())
);
assert!(!format!("{:?}", events[0]).contains(secret));
}
#[test]
fn buffer_edit_clamps_only_attached_view_cursors() {
let mut app = App::new();
let _app = app
.insert_resource(InitialEditorBuffer {
stream: TextByteStream::new("first"),
file: BufferFile::scratch(0),
})
.add_plugins(crate::ecs::EditorCorePlugin)
.add_plugins(BufferPlugin);
app.update();
let first = editor_buffer_entity(&mut app);
let first_view = editor_view_entity(&mut app);
let second = spawn_editor_buffer_entity(&mut app, "second");
let second_view = view_for_buffer(&mut app, second);
app.world_mut()
.get_mut::<CursorPosition>(first_view)
.expect("first cursor should exist")
.byte_index = ByteIndex("first".len());
app.world_mut()
.get_mut::<CursorPosition>(second_view)
.expect("second cursor should exist")
.byte_index = ByteIndex("second".len());
let _message = app.world_mut().write_message(BufferEditRequested {
target: BufferEntity(first),
edit: BufferEdit::delete_unchecked(1..5),
});
app.update();
let first_cursor = app
.world()
.get::<CursorPosition>(first_view)
.expect("first cursor should exist");
let second_cursor = app
.world()
.get::<CursorPosition>(second_view)
.expect("second cursor should exist");
assert_eq!(
app.world()
.get::<BufferText>(first)
.expect("first buffer should exist")
.stream
.as_str(),
"f"
);
assert_eq!(first_cursor.byte_index, ByteIndex(0));
assert_eq!(second_cursor.byte_index, ByteIndex("second".len()));
}
#[test]
fn command_request_write_updates_saved_revision_after_successful_write() {
let path = temp_file_path("write-success");
let mut app = App::new();
let _app = app
.insert_resource(temp_filesystem_config())
.insert_resource(InitialEditorBuffer {
stream: TextByteStream::new("saved text"),
file: BufferFile::backed_by(path.clone(), 0),
})
.add_plugins(crate::ecs::EditorCorePlugin)
.add_plugins(BufferPlugin);
app.update();
let target = editor_buffer_entity(&mut app);
let source = editor_view_entity(&mut app);
let _message = app.world_mut().write_message(CommandRequested {
target: BufferEntity(target),
source: ViewEntity(source),
command: VimCommand::Write,
});
app.update();
app.update();
let mut query = app.world_mut().query::<&BufferText>();
let stream = query
.iter(app.world())
.next()
.expect("editor buffer should exist")
.stream
.clone();
let buffer_file = app
.world()
.get::<BufferFile>(target)
.expect("target buffer file should exist");
assert!(!buffer_file.is_modified(&stream));
assert_eq!(
fs::read_to_string(&path).expect("write should create file contents"),
"saved text"
);
assert_eq!(
vim_modal_state(&app, source).editor.status.message(),
Some(&VimStatusMessage::Info(StatusInfoText::trusted_generated(
format!(
"\"{}\" 1L, 10B written",
path.file_name()
.expect("test path should have a file name")
.to_string_lossy()
)
)))
);
assert!(
!format!(
"{:?}",
vim_modal_state(&app, source).editor.status.message()
)
.contains(std::env::temp_dir().to_string_lossy().as_ref())
);
let _cleanup = fs::remove_file(path);
}
#[test]
fn command_requests_use_the_target_buffer_file() {
let first_path = temp_file_path("targeted-write-first");
let second_path = temp_file_path("targeted-write-second");
let mut app = App::new();
let _app = app
.insert_resource(temp_filesystem_config())
.insert_resource(InitialEditorBuffer {
stream: TextByteStream::new("first"),
file: BufferFile::backed_by(first_path.clone(), 0),
})
.add_plugins(crate::ecs::EditorCorePlugin)
.add_plugins(BufferPlugin);
app.update();
let first = editor_buffer_entity(&mut app);
let first_view = editor_view_entity(&mut app);
let second = spawn_editor_buffer_entity(&mut app, "second");
let second_view = view_for_buffer(&mut app, second);
app.world_mut()
.get_mut::<BufferFile>(second)
.expect("second buffer file should exist")
.clone_from(&BufferFile::backed_by(second_path.clone(), 0));
let _message = app.world_mut().write_message(CommandRequested {
target: BufferEntity(second),
source: ViewEntity(second_view),
command: VimCommand::Write,
});
app.update();
app.update();
assert!(!first_path.exists());
assert_eq!(
fs::read_to_string(&second_path).expect("write should create file contents"),
"second"
);
let first_file = app
.world()
.get::<BufferFile>(first)
.expect("first buffer file should exist");
let first_stream = &app
.world()
.get::<BufferText>(first)
.expect("first buffer text should exist")
.stream;
let second_file = app
.world()
.get::<BufferFile>(second)
.expect("second buffer file should exist");
let second_stream = &app
.world()
.get::<BufferText>(second)
.expect("second buffer text should exist")
.stream;
assert!(!first_file.is_modified(first_stream));
assert!(!second_file.is_modified(second_stream));
assert_eq!(
vim_modal_state(&app, first_view).editor.status.message(),
None
);
assert_eq!(
vim_modal_state(&app, second_view).editor.status.message(),
Some(&VimStatusMessage::Info(StatusInfoText::trusted_generated(
format!(
"\"{}\" 1L, 6B written",
second_path
.file_name()
.expect("test path should have a file name")
.to_string_lossy()
)
)))
);
let _cleanup = fs::remove_file(second_path);
}
#[test]
fn command_request_updates_transparency_theme_and_window() {
let mut app = App::new();
let _app = app
.insert_resource(ResolvedTheme::default())
.insert_resource(InitialEditorBuffer {
stream: TextByteStream::new("presentation"),
file: BufferFile::scratch(0),
})
.add_plugins(crate::ecs::EditorCorePlugin)
.add_plugins(BufferPlugin);
let window = app
.world_mut()
.spawn((Window::default(), PrimaryWindow))
.id();
app.update();
let target = editor_buffer_entity(&mut app);
let source = editor_view_entity(&mut app);
let _message = app.world_mut().write_message(CommandRequested {
target: BufferEntity(target),
source: ViewEntity(source),
command: VimCommand::SetTransparency(TransparencyMode::Transparent),
});
app.update();
app.update();
assert_eq!(
app.world().resource::<ResolvedTheme>().transparency_mode,
TransparencyMode::Transparent
);
assert!(
app.world()
.get::<Window>(window)
.expect("test window should exist")
.transparent
);
assert_eq!(
vim_modal_state(&app, source).editor.status.message(),
Some(&VimStatusMessage::Info(StatusInfoText::trusted_generated(
String::from("transparency=transparent")
)))
);
}
#[test]
fn command_request_failed_write_leaves_saved_revision_unchanged() {
let mut app = App::new();
let _app = app
.insert_resource(InitialEditorBuffer {
stream: TextByteStream::new("scratch"),
file: BufferFile::scratch(99),
})
.add_plugins(crate::ecs::EditorCorePlugin)
.add_plugins(BufferPlugin);
app.update();
let target = editor_buffer_entity(&mut app);
let source = editor_view_entity(&mut app);
let _message = app.world_mut().write_message(CommandRequested {
target: BufferEntity(target),
source: ViewEntity(source),
command: VimCommand::Write,
});
app.update();
app.update();
let mut query = app.world_mut().query::<&BufferText>();
let stream = query
.iter(app.world())
.next()
.expect("editor buffer should exist")
.stream
.clone();
let buffer_file = app
.world()
.get::<BufferFile>(target)
.expect("target buffer file should exist");
assert!(buffer_file.is_modified(&stream));
assert_eq!(
vim_modal_state(&app, source).editor.status.message(),
Some(&VimStatusMessage::Error(VimError::NoFileName))
);
}
#[test]
fn command_request_write_failure_emits_redacted_diagnostic_event() {
let path = temp_file_path("write-directory-target");
fs::create_dir(&path).expect("directory target should be created");
let mut app = App::new();
let _app = app
.insert_resource(temp_filesystem_config())
.insert_resource(InitialEditorBuffer {
stream: TextByteStream::new("scratch"),
file: BufferFile::backed_by(path.clone(), 0),
})
.init_resource::<CapturedCommandFailures>()
.add_plugins(crate::ecs::EditorCorePlugin)
.add_plugins(BufferPlugin)
.add_systems(Update, capture_command_failures);
app.update();
let target = editor_buffer_entity(&mut app);
let source = editor_view_entity(&mut app);
let _message = app.world_mut().write_message(CommandRequested {
target: BufferEntity(target),
source: ViewEntity(source),
command: VimCommand::Write,
});
app.update();
app.update();
assert_eq!(
app.world().resource::<CapturedCommandFailures>().events,
vec![CommandFailed {
target: BufferEntity(target),
source: ViewEntity(source),
command: VimCommand::Write,
diagnostic: CommandFailureDiagnostic {
class: CommandFailureClass::Persistence,
status_error: VimError::CantOpenFileForWriting,
source: Some(CommandFailureSource::UnsupportedFileType),
retry: CommandRetryAdvice::RetryAfterChange,
},
}]
);
assert_eq!(
vim_modal_state(&app, source).editor.status.message(),
Some(&VimStatusMessage::Error(VimError::CantOpenFileForWriting))
);
let _cleanup = fs::remove_dir(path);
}
#[test]
fn command_missing_target_emits_typed_rejection() {
let mut app = App::new();
let _app = app
.insert_resource(InitialEditorBuffer {
stream: TextByteStream::new("scratch"),
file: BufferFile::scratch(0),
})
.init_resource::<CapturedRejectedCommands>()
.add_plugins(crate::ecs::EditorCorePlugin)
.add_plugins(BufferPlugin)
.add_systems(Update, capture_rejected_commands);
app.update();
let source = editor_view_entity(&mut app);
let missing =
BufferEntity(Entity::from_raw_u32(998).expect("test entity index should be valid"));
let _message = app.world_mut().write_message(CommandRequested {
target: missing,
source: ViewEntity(source),
command: VimCommand::Write,
});
app.update();
app.update();
assert_eq!(
app.world().resource::<CapturedRejectedCommands>().events,
vec![CommandRejected {
target: missing,
source: ViewEntity(source),
command: VimCommand::Write,
reason: CommandRejectionReason::MissingTarget { target: missing },
}]
);
}
#[test]
fn cursor_move_missing_target_emits_typed_rejection() {
let mut app = App::new();
let _app = app
.insert_resource(InitialEditorBuffer {
stream: TextByteStream::new("scratch"),
file: BufferFile::scratch(0),
})
.init_resource::<CapturedRejectedCursorMoves>()
.add_plugins(crate::ecs::EditorCorePlugin)
.add_plugins(BufferPlugin)
.add_systems(Update, capture_rejected_cursor_moves);
app.update();
let missing = ViewEntity(Entity::from_raw_u32(997).expect("test entity index should be valid"));
let request = CursorMoveRequested::cursor_cell(missing, ByteIndex(3), Some(3));
let _message = app.world_mut().write_message(request);
app.update();
app.update();
assert_eq!(
app.world().resource::<CapturedRejectedCursorMoves>().events,
vec![CursorMoveRejected {
target: missing,
rejected: CursorMoveRequestShape::from(&request),
reason: CursorMoveRejectionReason::MissingTarget { target: missing },
}]
);
}
#[test]
fn cursor_move_missing_buffer_emits_typed_rejection() {
let mut app = App::new();
let _app = app
.insert_resource(InitialEditorBuffer {
stream: TextByteStream::new("scratch"),
file: BufferFile::scratch(0),
})
.init_resource::<CapturedRejectedCursorMoves>()
.add_plugins(crate::ecs::EditorCorePlugin)
.add_plugins(BufferPlugin)
.add_systems(Update, capture_rejected_cursor_moves);
app.update();
let target = editor_view_entity(&mut app);
let missing_buffer =
BufferEntity(Entity::from_raw_u32(996).expect("test entity index should be valid"));
app.world_mut()
.get_mut::<EditorView>(target)
.expect("editor view should exist")
.buffer = missing_buffer;
let request = CursorMoveRequested::cursor_cell(ViewEntity(target), ByteIndex(3), Some(3));
let _message = app.world_mut().write_message(request);
app.update();
app.update();
assert_eq!(
app.world().resource::<CapturedRejectedCursorMoves>().events,
vec![CursorMoveRejected {
target: ViewEntity(target),
rejected: CursorMoveRequestShape::from(&request),
reason: CursorMoveRejectionReason::MissingBuffer {
target: ViewEntity(target),
buffer: missing_buffer,
},
}]
);
}
#[test]
fn cursor_owner_preserves_insert_caret_boundaries() {
let mut app = App::new();
let _app = app
.insert_resource(InitialEditorBuffer {
stream: TextByteStream::new("abc"),
file: BufferFile::scratch(0),
})
.add_plugins(crate::ecs::EditorCorePlugin)
.add_plugins(BufferPlugin);
app.update();
let target = editor_view_entity(&mut app);
app.world_mut()
.get_mut::<VimModalState>(target)
.expect("editor view should have Vim modal state")
.editor
.mode = VimMode::Insert(InsertState::default());
let _message = app
.world_mut()
.write_message(CursorMoveRequested::insert_caret(
ViewEntity(target),
ByteIndex("abc".len()),
None,
));
app.update();
let cursor = app
.world()
.get::<CursorPosition>(target)
.expect("editor view should have cursor position");
assert_eq!(cursor.byte_index, ByteIndex("abc".len()));
}
#[test]
fn cursor_owner_keeps_normal_moves_on_cursor_cells() {
let mut app = App::new();
let _app = app
.insert_resource(InitialEditorBuffer {
stream: TextByteStream::new("abc"),
file: BufferFile::scratch(0),
})
.add_plugins(crate::ecs::EditorCorePlugin)
.add_plugins(BufferPlugin);
app.update();
let target = editor_view_entity(&mut app);
let _message = app
.world_mut()
.write_message(CursorMoveRequested::cursor_cell(
ViewEntity(target),
ByteIndex("abc".len()),
None,
));
app.update();
let cursor = app
.world()
.get::<CursorPosition>(target)
.expect("editor view should have cursor position");
assert_eq!(cursor.byte_index, ByteIndex("ab".len()));
}
#[test]
fn cursor_owner_uses_request_destination_instead_of_mode_for_insert_carets() {
let mut app = App::new();
let _app = app
.insert_resource(InitialEditorBuffer {
stream: TextByteStream::new("abc"),
file: BufferFile::scratch(0),
})
.add_plugins(crate::ecs::EditorCorePlugin)
.add_plugins(BufferPlugin);
app.update();
let target = editor_view_entity(&mut app);
let _message = app
.world_mut()
.write_message(CursorMoveRequested::insert_caret(
ViewEntity(target),
ByteIndex("abc".len()),
None,
));
app.update();
let cursor = app
.world()
.get::<CursorPosition>(target)
.expect("editor view should have cursor position");
assert_eq!(cursor.byte_index, ByteIndex("abc".len()));
}
#[test]
fn cursor_owner_uses_request_destination_instead_of_mode_for_cursor_cells() {
let mut app = App::new();
let _app = app
.insert_resource(InitialEditorBuffer {
stream: TextByteStream::new("abc"),
file: BufferFile::scratch(0),
})
.add_plugins(crate::ecs::EditorCorePlugin)
.add_plugins(BufferPlugin);
app.update();
let target = editor_view_entity(&mut app);
app.world_mut()
.get_mut::<VimModalState>(target)
.expect("editor view should have Vim modal state")
.editor
.mode = VimMode::Insert(InsertState::default());
let _message = app
.world_mut()
.write_message(CursorMoveRequested::cursor_cell(
ViewEntity(target),
ByteIndex("abc".len()),
None,
));
app.update();
let cursor = app
.world()
.get::<CursorPosition>(target)
.expect("editor view should have cursor position");
assert_eq!(cursor.byte_index, ByteIndex("ab".len()));
}
#[test]
fn insert_edit_and_caret_requests_apply_in_owner_order() {
let mut app = App::new();
let _app = app
.insert_resource(InitialEditorBuffer {
stream: TextByteStream::new("a"),
file: BufferFile::scratch(0),
})
.add_plugins(crate::ecs::EditorCorePlugin)
.add_plugins(BufferPlugin);
app.update();
let buffer = editor_buffer_entity(&mut app);
let view = editor_view_entity(&mut app);
app.world_mut()
.get_mut::<VimModalState>(view)
.expect("editor view should have Vim modal state")
.editor
.mode = VimMode::Insert(InsertState::default());
let _message = app.world_mut().write_message(BufferEditRequested {
target: BufferEntity(buffer),
edit: BufferEdit::insert("a".len(), "λ"),
});
let _message = app
.world_mut()
.write_message(CursorMoveRequested::insert_caret(
ViewEntity(view),
ByteIndex("aλ".len()),
None,
));
app.update();
let buffer_text = app
.world()
.get::<BufferText>(buffer)
.expect("editor buffer should exist");
let cursor = app
.world()
.get::<CursorPosition>(view)
.expect("editor view should have cursor position");
assert_eq!(buffer_text.stream.as_str(), "aλ");
assert_eq!(cursor.byte_index, ByteIndex("aλ".len()));
}
#[test]
fn insert_backspace_edit_and_caret_requests_preserve_utf8_boundary() {
let mut app = App::new();
let _app = app
.insert_resource(InitialEditorBuffer {
stream: TextByteStream::new("aλ"),
file: BufferFile::scratch(0),
})
.add_plugins(crate::ecs::EditorCorePlugin)
.add_plugins(BufferPlugin);
app.update();
let buffer = editor_buffer_entity(&mut app);
let view = editor_view_entity(&mut app);
app.world_mut()
.get_mut::<VimModalState>(view)
.expect("editor view should have Vim modal state")
.editor
.mode = VimMode::Insert(InsertState::default());
app.world_mut()
.get_mut::<CursorPosition>(view)
.expect("editor view should have cursor position")
.byte_index = ByteIndex("aλ".len());
let _message = app.world_mut().write_message(BufferEditRequested {
target: BufferEntity(buffer),
edit: BufferEdit::delete_unchecked("a".len().."aλ".len()),
});
let _message = app
.world_mut()
.write_message(CursorMoveRequested::insert_caret(
ViewEntity(view),
ByteIndex("a".len()),
None,
));
app.update();
let buffer_text = app
.world()
.get::<BufferText>(buffer)
.expect("editor buffer should exist");
let cursor = app
.world()
.get::<CursorPosition>(view)
.expect("editor view should have cursor position");
assert_eq!(buffer_text.stream.as_str(), "a");
assert_eq!(cursor.byte_index, ByteIndex("a".len()));
}
#[test]
fn command_errors_classify_disclosure_boundary() {
let vim_error = BufferCommandError::Vim(VimError::NoFileName);
assert_eq!(vim_error.class(), CommandFailureClass::UserVisible);
assert_eq!(vim_error.status_error(), VimError::NoFileName);
assert!(vim_error.write_error().is_none());
assert_eq!(
vim_error.retry_advice(),
CommandRetryAdvice::RetryAfterChange
);
let write_error = BufferCommandError::Write(FileWriteError::UnsupportedFileType {
path: PathBuf::from("not-a-file"),
});
assert_eq!(write_error.class(), CommandFailureClass::Persistence);
assert_eq!(write_error.status_error(), VimError::CantOpenFileForWriting);
assert!(write_error.write_error().is_some());
assert_eq!(
write_error.retry_advice(),
CommandRetryAdvice::RetryAfterChange
);
let config_error = BufferCommandError::FilesystemUnavailable;
assert_eq!(config_error.class(), CommandFailureClass::RuntimeConfig);
assert_eq!(
config_error.status_error(),
VimError::CantOpenFileForWriting
);
assert!(config_error.write_error().is_none());
assert_eq!(
config_error.retry_advice(),
CommandRetryAdvice::RetryAfterChange
);
}
#[test]
fn command_request_quit_refuses_modified_buffer_but_force_quits() {
let mut app = App::new();
let _app = app
.insert_resource(InitialEditorBuffer {
stream: TextByteStream::new("dirty"),
file: BufferFile::scratch(0),
})
.init_resource::<CapturedExits>()
.add_plugins(crate::ecs::EditorCorePlugin)
.add_plugins(BufferPlugin)
.add_systems(
Update,
capture_exits.after(crate::ecs::schedules::EditorSet::Edit),
);
app.update();
let target = editor_buffer_entity(&mut app);
let source = editor_view_entity(&mut app);
let _message = app.world_mut().write_message(BufferEditRequested {
target: BufferEntity(target),
edit: BufferEdit::insert("dirty".len(), String::from("!")),
});
app.update();
let _message = app.world_mut().write_message(CommandRequested {
target: BufferEntity(target),
source: ViewEntity(source),
command: VimCommand::Quit(QuitPolicy::PreserveChanges),
});
app.update();
app.update();
assert_eq!(
vim_modal_state(&app, source).editor.status.message(),
Some(&VimStatusMessage::Error(VimError::NoWriteSinceLastChange))
);
assert!(app.world().resource::<CapturedExits>().events.is_empty());
let _message = app.world_mut().write_message(CommandRequested {
target: BufferEntity(target),
source: ViewEntity(source),
command: VimCommand::Quit(QuitPolicy::Force),
});
app.update();
assert_eq!(
app.world().resource::<CapturedExits>().events,
vec![AppExit::Success]
);
}
#[test]
fn command_request_quit_exits_cleanly_when_buffer_is_unmodified() {
let mut app = App::new();
let _app = app
.insert_resource(InitialEditorBuffer {
stream: TextByteStream::new("clean"),
file: BufferFile::scratch(0),
})
.init_resource::<CapturedExits>()
.add_plugins(crate::ecs::EditorCorePlugin)
.add_plugins(BufferPlugin)
.add_systems(
Update,
capture_exits.after(crate::ecs::schedules::EditorSet::Edit),
);
app.update();
let target = editor_buffer_entity(&mut app);
let source = editor_view_entity(&mut app);
let _message = app.world_mut().write_message(CommandRequested {
target: BufferEntity(target),
source: ViewEntity(source),
command: VimCommand::Quit(QuitPolicy::PreserveChanges),
});
app.update();
assert_eq!(
app.world().resource::<CapturedExits>().events,
vec![AppExit::Success]
);
}
#[test]
fn accepted_shutdown_remains_observable_until_runner_exits() {
let mut app = App::new();
let _app = app
.insert_resource(InitialEditorBuffer {
stream: TextByteStream::new("clean"),
file: BufferFile::scratch(0),
})
.init_resource::<CapturedExits>()
.add_plugins(crate::ecs::EditorCorePlugin)
.add_plugins(BufferPlugin)
.add_systems(
Update,
capture_exits.after(crate::ecs::schedules::EditorSet::Edit),
);
app.update();
let _message = app.world_mut().write_message(LifecycleRequested::Shutdown(
ShutdownRequest::successful_user_command(),
));
app.update();
app.update();
assert_eq!(
app.world().resource::<CapturedExits>().events,
vec![AppExit::Success, AppExit::Success]
);
}
#[test]
fn buffer_and_cursor_owner_requests_only_mutate_target_entity() {
let mut app = App::new();
let _app = app
.insert_resource(InitialEditorBuffer {
stream: TextByteStream::new("first"),
file: BufferFile::scratch(0),
})
.init_resource::<CapturedEdits>()
.add_plugins(crate::ecs::EditorCorePlugin)
.add_plugins(BufferPlugin)
.add_systems(Update, capture_edits);
app.update();
let first = editor_buffer_entity(&mut app);
let first_view = editor_view_entity(&mut app);
let second = spawn_editor_buffer_entity(&mut app, "second");
let second_view = view_for_buffer(&mut app, second);
let _message = app.world_mut().write_message(BufferEditRequested {
target: BufferEntity(second),
edit: BufferEdit::insert("second".len(), String::from("!")),
});
let _message = app
.world_mut()
.write_message(CursorMoveRequested::cursor_cell(
ViewEntity(second_view),
ByteIndex("sec".len()),
None,
));
app.update();
app.update();
let first_buffer = app
.world()
.get::<BufferText>(first)
.expect("first buffer should exist");
let first_cursor = app
.world()
.get::<CursorPosition>(first_view)
.expect("first cursor should exist");
let second_buffer = app
.world()
.get::<BufferText>(second)
.expect("second buffer should exist");
let second_cursor = app
.world()
.get::<CursorPosition>(second_view)
.expect("second cursor should exist");
assert_eq!(first_buffer.stream.as_str(), "first");
assert_eq!(first_cursor.byte_index, ByteIndex(0));
assert_eq!(second_buffer.stream.as_str(), "second!");
assert_eq!(second_cursor.byte_index, ByteIndex("sec".len()));
assert_eq!(
app.world().resource::<CapturedEdits>().events,
vec![BufferEdited {
target: BufferEntity(second),
previous_revision: BufferRevision { revision: 0.into() },
current_revision: BufferRevision { revision: 1.into() },
}]
);
}
#[test]
fn status_requests_only_mutate_target_entity() {
let mut app = App::new();
let _app = app
.insert_resource(InitialEditorBuffer {
stream: TextByteStream::new("first"),
file: BufferFile::scratch(0),
})
.add_plugins(crate::ecs::EditorCorePlugin)
.add_plugins(BufferPlugin);
app.update();
let _first = editor_buffer_entity(&mut app);
let first_view = editor_view_entity(&mut app);
let second = spawn_editor_buffer_entity(&mut app, "second");
let second_view = view_for_buffer(&mut app, second);
let _message = app.world_mut().write_message(StatusMessageRequested {
target: ViewEntity(second_view),
message: StatusMessage::Error(VimError::NoFileName),
});
app.update();
assert_eq!(
vim_modal_state(&app, first_view).editor.status.message(),
None
);
assert_eq!(
vim_modal_state(&app, second_view).editor.status.message(),
Some(&VimStatusMessage::Error(VimError::NoFileName))
);
}
#[test]
fn status_missing_target_emits_typed_rejection() {
let mut app = App::new();
let _app = app
.insert_resource(InitialEditorBuffer {
stream: TextByteStream::new("first"),
file: BufferFile::scratch(0),
})
.init_resource::<CapturedRejectedStatusMessages>()
.add_plugins(crate::ecs::EditorCorePlugin)
.add_plugins(BufferPlugin)
.add_systems(Update, capture_rejected_status_messages);
app.update();
let missing = ViewEntity(Entity::from_raw_u32(996).expect("test entity index should be valid"));
let request = StatusMessageRequested {
target: missing,
message: StatusMessage::Error(VimError::NoFileName),
};
let _message = app.world_mut().write_message(request.clone());
app.update();
app.update();
assert_eq!(
app.world()
.resource::<CapturedRejectedStatusMessages>()
.events,
vec![StatusMessageRejected {
target: missing,
rejected: request.message,
reason: StatusRejectionReason::MissingTarget { target: missing },
}]
);
}
proptest! {
#[test]
fn targeted_buffer_edits_only_mutate_selected_entity(
inserted in "\\PC{0,16}",
target_second in any::<bool>(),
) {
let mut app = App::new();
let _app = app
.insert_resource(InitialEditorBuffer {
stream: TextByteStream::new("first"),
file: BufferFile::scratch(0),
})
.add_plugins(crate::ecs::EditorCorePlugin)
.add_plugins(BufferPlugin);
app.update();
let first = editor_buffer_entity(&mut app);
let first_view = editor_view_entity(&mut app);
let second = spawn_editor_buffer_entity(&mut app, "second");
let second_view = view_for_buffer(&mut app, second);
app.world_mut()
.get_mut::<VimModalState>(first_view)
.expect("first modal state should exist")
.editor.status
.set_info(StatusInfoText::trusted_static("first modal"));
app.world_mut()
.get_mut::<VimModalState>(second_view)
.expect("second modal state should exist")
.editor.status
.set_error(VimError::NoFileName);
let target = if target_second { second } else { first };
let byte_index = if target_second {
"second".len()
} else {
"first".len()
};
let _message = app.world_mut().write_message(BufferEditRequested {
target: BufferEntity(target),
edit: BufferEdit::insert(byte_index, inserted.clone()),
});
app.update();
let first_text = app
.world()
.get::<BufferText>(first)
.expect("first buffer should exist")
.stream
.as_str()
.to_owned();
let second_text = app
.world()
.get::<BufferText>(second)
.expect("second buffer should exist")
.stream
.as_str()
.to_owned();
if target_second {
prop_assert_eq!(first_text, "first");
prop_assert_eq!(second_text, format!("second{inserted}"));
} else {
prop_assert_eq!(first_text, format!("first{inserted}"));
prop_assert_eq!(second_text, "second");
}
prop_assert_eq!(
vim_modal_state(&app, first_view).editor.status.message(),
Some(&VimStatusMessage::Info(StatusInfoText::trusted_static(
"first modal"
)))
);
prop_assert_eq!(
vim_modal_state(&app, second_view).editor.status.message(),
Some(&VimStatusMessage::Error(VimError::NoFileName))
);
}
}