mirror of
https://github.com/helix-editor/helix.git
synced 2024-11-26 11:23:31 +04:00
f5991657f4
Prior to this change, every integration test which wanted its line endings to be handled transparently across platforms, i.e. test with the same input that has its platform's line feed characters, converting the line endings was up to each individual test by calling the `platform_line` helper function. This significantly increases the amount of boilerplate one has to copy between all the tests. However, there are some test cases that need to exert strict control over the exact input text without being manipulated behind the scenes by the test framework. So, with this change, the line feed conversions are factored into the `TestCase` struct. By default, line endings of the input text are converted to the platform's native line feed ending, but one can explicitly specify in their test case when the input text should be left alone and tested as is.
409 lines
12 KiB
Rust
409 lines
12 KiB
Rust
use std::{
|
|
fs::File,
|
|
io::{Read, Write},
|
|
mem::replace,
|
|
path::PathBuf,
|
|
time::Duration,
|
|
};
|
|
|
|
use anyhow::bail;
|
|
use crossterm::event::{Event, KeyEvent};
|
|
use helix_core::{diagnostic::Severity, test, Selection, Transaction};
|
|
use helix_term::{application::Application, args::Args, config::Config, keymap::merge_keys};
|
|
use helix_view::{current_ref, doc, editor::LspConfig, input::parse_macro, Editor};
|
|
use tempfile::NamedTempFile;
|
|
use tokio_stream::wrappers::UnboundedReceiverStream;
|
|
|
|
/// Specify how to set up the input text with line feeds
|
|
#[derive(Clone, Debug)]
|
|
pub enum LineFeedHandling {
|
|
/// Replaces all LF chars with the system's appropriate line feed character,
|
|
/// and if one doesn't exist already, appends the system's appropriate line
|
|
/// ending to the end of a string.
|
|
Native,
|
|
|
|
/// Do not modify the input text in any way. What you give is what you test.
|
|
AsIs,
|
|
}
|
|
|
|
impl LineFeedHandling {
|
|
/// Apply the line feed handling to the input string, yielding a set of
|
|
/// resulting texts with the appropriate line feed substitutions.
|
|
pub fn apply(&self, text: &str) -> String {
|
|
let line_end = match self {
|
|
LineFeedHandling::Native => helix_core::NATIVE_LINE_ENDING,
|
|
LineFeedHandling::AsIs => return text.into(),
|
|
}
|
|
.as_str();
|
|
|
|
// we can assume that the source files in this code base will always
|
|
// be LF, so indoc strings will always insert LF
|
|
let mut output = text.replace('\n', line_end);
|
|
|
|
if !output.ends_with(line_end) {
|
|
output.push_str(line_end);
|
|
}
|
|
|
|
output
|
|
}
|
|
}
|
|
|
|
impl Default for LineFeedHandling {
|
|
fn default() -> Self {
|
|
Self::Native
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Debug)]
|
|
pub struct TestCase {
|
|
pub in_text: String,
|
|
pub in_selection: Selection,
|
|
pub in_keys: String,
|
|
pub out_text: String,
|
|
pub out_selection: Selection,
|
|
|
|
pub line_feed_handling: LineFeedHandling,
|
|
}
|
|
|
|
impl<S, R, V> From<(S, R, V)> for TestCase
|
|
where
|
|
S: Into<String>,
|
|
R: Into<String>,
|
|
V: Into<String>,
|
|
{
|
|
fn from((input, keys, output): (S, R, V)) -> Self {
|
|
TestCase::from((input, keys, output, LineFeedHandling::default()))
|
|
}
|
|
}
|
|
|
|
impl<S, R, V> From<(S, R, V, LineFeedHandling)> for TestCase
|
|
where
|
|
S: Into<String>,
|
|
R: Into<String>,
|
|
V: Into<String>,
|
|
{
|
|
fn from((input, keys, output, line_feed_handling): (S, R, V, LineFeedHandling)) -> Self {
|
|
let (in_text, in_selection) = test::print(&line_feed_handling.apply(&input.into()));
|
|
let (out_text, out_selection) = test::print(&line_feed_handling.apply(&output.into()));
|
|
|
|
TestCase {
|
|
in_text,
|
|
in_selection,
|
|
in_keys: keys.into(),
|
|
out_text,
|
|
out_selection,
|
|
line_feed_handling,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[inline]
|
|
pub async fn test_key_sequence(
|
|
app: &mut Application,
|
|
in_keys: Option<&str>,
|
|
test_fn: Option<&dyn Fn(&Application)>,
|
|
should_exit: bool,
|
|
) -> anyhow::Result<()> {
|
|
test_key_sequences(app, vec![(in_keys, test_fn)], should_exit).await
|
|
}
|
|
|
|
#[allow(clippy::type_complexity)]
|
|
pub async fn test_key_sequences(
|
|
app: &mut Application,
|
|
inputs: Vec<(Option<&str>, Option<&dyn Fn(&Application)>)>,
|
|
should_exit: bool,
|
|
) -> anyhow::Result<()> {
|
|
const TIMEOUT: Duration = Duration::from_millis(500);
|
|
let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
|
|
let mut rx_stream = UnboundedReceiverStream::new(rx);
|
|
let num_inputs = inputs.len();
|
|
|
|
for (i, (in_keys, test_fn)) in inputs.into_iter().enumerate() {
|
|
let (view, doc) = current_ref!(app.editor);
|
|
let state = test::plain(doc.text().slice(..), doc.selection(view.id));
|
|
|
|
log::debug!("executing test with document state:\n\n-----\n\n{}", state);
|
|
|
|
if let Some(in_keys) = in_keys {
|
|
for key_event in parse_macro(in_keys)?.into_iter() {
|
|
let key = Event::Key(KeyEvent::from(key_event));
|
|
log::trace!("sending key: {:?}", key);
|
|
tx.send(Ok(key))?;
|
|
}
|
|
}
|
|
|
|
let app_exited = !app.event_loop_until_idle(&mut rx_stream).await;
|
|
|
|
if !app_exited {
|
|
let (view, doc) = current_ref!(app.editor);
|
|
let state = test::plain(doc.text().slice(..), doc.selection(view.id));
|
|
|
|
log::debug!(
|
|
"finished running test with document state:\n\n-----\n\n{}",
|
|
state
|
|
);
|
|
}
|
|
|
|
// the app should not exit from any test until the last one
|
|
if i < num_inputs - 1 && app_exited {
|
|
bail!("application exited before test function could run");
|
|
}
|
|
|
|
// verify if it exited on the last iteration if it should have and
|
|
// the inverse
|
|
if i == num_inputs - 1 && app_exited != should_exit {
|
|
bail!("expected app to exit: {} != {}", should_exit, app_exited);
|
|
}
|
|
|
|
if let Some(test) = test_fn {
|
|
test(app);
|
|
};
|
|
}
|
|
|
|
if !should_exit {
|
|
for key_event in parse_macro("<esc>:q!<ret>")?.into_iter() {
|
|
tx.send(Ok(Event::Key(KeyEvent::from(key_event))))?;
|
|
}
|
|
|
|
let event_loop = app.event_loop(&mut rx_stream);
|
|
tokio::time::timeout(TIMEOUT, event_loop).await?;
|
|
}
|
|
|
|
let errs = app.close().await;
|
|
|
|
if !errs.is_empty() {
|
|
log::error!("Errors closing app");
|
|
|
|
for err in errs {
|
|
log::error!("{}", err);
|
|
}
|
|
|
|
bail!("Error closing app");
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn test_key_sequence_with_input_text<T: Into<TestCase>>(
|
|
app: Option<Application>,
|
|
test_case: T,
|
|
test_fn: &dyn Fn(&Application),
|
|
should_exit: bool,
|
|
) -> anyhow::Result<()> {
|
|
let test_case = test_case.into();
|
|
|
|
let mut app = match app {
|
|
Some(app) => app,
|
|
None => Application::new(Args::default(), test_config(), test_syntax_loader(None))?,
|
|
};
|
|
|
|
let (view, doc) = helix_view::current!(app.editor);
|
|
let sel = doc.selection(view.id).clone();
|
|
|
|
// replace the initial text with the input text
|
|
let transaction = Transaction::change_by_selection(doc.text(), &sel, |_| {
|
|
(0, doc.text().len_chars(), Some((&test_case.in_text).into()))
|
|
})
|
|
.with_selection(test_case.in_selection.clone());
|
|
|
|
doc.apply(&transaction, view.id);
|
|
|
|
test_key_sequence(
|
|
&mut app,
|
|
Some(&test_case.in_keys),
|
|
Some(test_fn),
|
|
should_exit,
|
|
)
|
|
.await
|
|
}
|
|
|
|
/// Generates language config loader that merge in overrides, like a user language
|
|
/// config. The argument string must be a raw TOML document.
|
|
pub fn test_syntax_loader(overrides: Option<String>) -> helix_core::syntax::Loader {
|
|
let mut lang = helix_loader::config::default_lang_config();
|
|
|
|
if let Some(overrides) = overrides {
|
|
let override_toml = toml::from_str(&overrides).unwrap();
|
|
lang = helix_loader::merge_toml_values(lang, override_toml, 3);
|
|
}
|
|
|
|
helix_core::syntax::Loader::new(lang.try_into().unwrap()).unwrap()
|
|
}
|
|
|
|
/// Use this for very simple test cases where there is one input
|
|
/// document, selection, and sequence of key presses, and you just
|
|
/// want to verify the resulting document and selection.
|
|
pub async fn test_with_config<T: Into<TestCase>>(
|
|
app_builder: AppBuilder,
|
|
test_case: T,
|
|
) -> anyhow::Result<()> {
|
|
let test_case = test_case.into();
|
|
let app = app_builder.build()?;
|
|
|
|
test_key_sequence_with_input_text(
|
|
Some(app),
|
|
test_case.clone(),
|
|
&|app| {
|
|
let doc = doc!(app.editor);
|
|
assert_eq!(&test_case.out_text, doc.text());
|
|
|
|
let mut selections: Vec<_> = doc.selections().values().cloned().collect();
|
|
assert_eq!(1, selections.len());
|
|
|
|
let sel = selections.pop().unwrap();
|
|
assert_eq!(test_case.out_selection, sel);
|
|
},
|
|
false,
|
|
)
|
|
.await
|
|
}
|
|
|
|
pub async fn test<T: Into<TestCase>>(test_case: T) -> anyhow::Result<()> {
|
|
test_with_config(AppBuilder::default(), test_case).await
|
|
}
|
|
|
|
pub fn temp_file_with_contents<S: AsRef<str>>(
|
|
content: S,
|
|
) -> anyhow::Result<tempfile::NamedTempFile> {
|
|
let mut temp_file = tempfile::NamedTempFile::new()?;
|
|
|
|
temp_file
|
|
.as_file_mut()
|
|
.write_all(content.as_ref().as_bytes())?;
|
|
|
|
temp_file.flush()?;
|
|
temp_file.as_file_mut().sync_all()?;
|
|
Ok(temp_file)
|
|
}
|
|
|
|
/// Generates a config with defaults more suitable for integration tests
|
|
pub fn test_config() -> Config {
|
|
Config {
|
|
editor: test_editor_config(),
|
|
keys: helix_term::keymap::default(),
|
|
..Default::default()
|
|
}
|
|
}
|
|
|
|
pub fn test_editor_config() -> helix_view::editor::Config {
|
|
helix_view::editor::Config {
|
|
lsp: LspConfig {
|
|
enable: false,
|
|
..Default::default()
|
|
},
|
|
..Default::default()
|
|
}
|
|
}
|
|
|
|
/// Creates a new temporary file that is set to read only. Useful for
|
|
/// testing write failures.
|
|
pub fn new_readonly_tempfile() -> anyhow::Result<NamedTempFile> {
|
|
let mut file = tempfile::NamedTempFile::new()?;
|
|
let metadata = file.as_file().metadata()?;
|
|
let mut perms = metadata.permissions();
|
|
perms.set_readonly(true);
|
|
file.as_file_mut().set_permissions(perms)?;
|
|
Ok(file)
|
|
}
|
|
|
|
pub struct AppBuilder {
|
|
args: Args,
|
|
config: Config,
|
|
syn_loader: helix_core::syntax::Loader,
|
|
input: Option<(String, Selection)>,
|
|
}
|
|
|
|
impl Default for AppBuilder {
|
|
fn default() -> Self {
|
|
Self {
|
|
args: Args::default(),
|
|
config: test_config(),
|
|
syn_loader: test_syntax_loader(None),
|
|
input: None,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl AppBuilder {
|
|
pub fn new() -> Self {
|
|
AppBuilder::default()
|
|
}
|
|
|
|
pub fn with_file<P: Into<PathBuf>>(
|
|
mut self,
|
|
path: P,
|
|
pos: Option<helix_core::Position>,
|
|
) -> Self {
|
|
self.args.files.push((path.into(), pos.unwrap_or_default()));
|
|
self
|
|
}
|
|
|
|
// Remove this attribute once `with_config` is used in a test:
|
|
#[allow(dead_code)]
|
|
pub fn with_config(mut self, mut config: Config) -> Self {
|
|
let keys = replace(&mut config.keys, helix_term::keymap::default());
|
|
merge_keys(&mut config.keys, keys);
|
|
self.config = config;
|
|
self
|
|
}
|
|
|
|
pub fn with_input_text<S: Into<String>>(mut self, input_text: S) -> Self {
|
|
self.input = Some(test::print(&input_text.into()));
|
|
self
|
|
}
|
|
|
|
pub fn with_lang_loader(mut self, syn_loader: helix_core::syntax::Loader) -> Self {
|
|
self.syn_loader = syn_loader;
|
|
self
|
|
}
|
|
|
|
pub fn build(self) -> anyhow::Result<Application> {
|
|
if let Some(path) = &self.args.working_directory {
|
|
bail!("Changing the working directory to {path:?} is not yet supported for integration tests");
|
|
}
|
|
|
|
if let Some((path, _)) = self.args.files.first().filter(|p| p.0.is_dir()) {
|
|
bail!("Having the directory {path:?} in args.files[0] is not yet supported for integration tests");
|
|
}
|
|
|
|
let mut app = Application::new(self.args, self.config, self.syn_loader)?;
|
|
|
|
if let Some((text, selection)) = self.input {
|
|
let (view, doc) = helix_view::current!(app.editor);
|
|
let sel = doc.selection(view.id).clone();
|
|
let trans = Transaction::change_by_selection(doc.text(), &sel, |_| {
|
|
(0, doc.text().len_chars(), Some((text.clone()).into()))
|
|
})
|
|
.with_selection(selection);
|
|
|
|
// replace the initial text with the input text
|
|
doc.apply(&trans, view.id);
|
|
}
|
|
|
|
Ok(app)
|
|
}
|
|
}
|
|
|
|
pub async fn run_event_loop_until_idle(app: &mut Application) {
|
|
let (_, rx) = tokio::sync::mpsc::unbounded_channel();
|
|
let mut rx_stream = UnboundedReceiverStream::new(rx);
|
|
app.event_loop_until_idle(&mut rx_stream).await;
|
|
}
|
|
|
|
pub fn assert_file_has_content(file: &mut File, content: &str) -> anyhow::Result<()> {
|
|
file.flush()?;
|
|
file.sync_all()?;
|
|
|
|
let mut file_content = String::new();
|
|
file.read_to_string(&mut file_content)?;
|
|
assert_eq!(file_content, content);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
pub fn assert_status_not_error(editor: &Editor) {
|
|
if let Some((_, sev)) = editor.get_status() {
|
|
assert_ne!(&Severity::Error, sev);
|
|
}
|
|
}
|