summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorTim Oram <dev@mitmaro.ca>2023-08-01 16:23:36 -0230
committerTim Oram <dev@mitmaro.ca>2023-08-06 20:13:15 -0230
commitbbee0ed97998e4ec6bf69d0faa7a8560f66fbef3 (patch)
treea48d5459964a6b7e2e5be5dc32a66abfd085f88f
parent914f8f67afe2df555d0221f779736dc91ebdb244 (diff)
Refactor assert_rendered_output internals
Previously assert_rendered_output used String building to generate matching lines. While this works, it makes adding more dynamic pattern matching impossible. This updates those Strings into a set of LinePattern matchers, that can match dynamically, based on the type, instead of String comparisons. As well, previously, assert_rendered_output matched against the entire rendered output, including an often repeated header. This also provides a new mode for the macro, that will only render and match against the view body. This should allow assertions to be simplified in cases where the header values are not important.
-rw-r--r--Cargo.lock25
-rw-r--r--src/view/Cargo.toml3
-rw-r--r--src/view/src/render_slice/tests.rs17
-rw-r--r--src/view/src/testutil.rs451
-rw-r--r--src/view/src/testutil/assert_rendered_output.rs481
-rw-r--r--src/view/src/testutil/mod.rs168
-rw-r--r--src/view/src/testutil/render_view_line.rs151
7 files changed, 842 insertions, 454 deletions
diff --git a/Cargo.lock b/Cargo.lock
index 56a89bc..dd2c816 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -177,6 +177,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0e25ea47919b1560c4e3b7fe0aaab9becf5b84a10325ddf7db0f0ba5e1026499"
[[package]]
+name = "either"
+version = "1.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a26ae43d7bcc3b814de94796a5e736d4029efb0ee900c12e2d54c993ad1a1e07"
+
+[[package]]
name = "errno"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -467,6 +473,7 @@ dependencies = [
"girt-config",
"girt-display",
"girt-runtime",
+ "iter_tools",
"parking_lot",
"rustc_version",
"unicode-segmentation",
@@ -539,6 +546,24 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cb56e1aa765b4b4f3aadfab769793b7087bb03a4ea4920644a6d238e2df5b9ed"
[[package]]
+name = "iter_tools"
+version = "0.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "531cafdc99b3b3252bb32f5620e61d56b19415efc19900b12d1b2e7483854897"
+dependencies = [
+ "itertools",
+]
+
+[[package]]
+name = "itertools"
+version = "0.10.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473"
+dependencies = [
+ "either",
+]
+
+[[package]]
name = "itoa"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
diff --git a/src/view/Cargo.toml b/src/view/Cargo.toml
index f502a9b..90e8185 100644
--- a/src/view/Cargo.toml
+++ b/src/view/Cargo.toml
@@ -15,7 +15,7 @@ readme = "README.md"
name = "view"
[features]
-testutils = ["dep:bitflags"]
+testutils = ["dep:bitflags", "dep:iter_tools"]
[dependencies]
anyhow = "1.0.72"
@@ -25,6 +25,7 @@ crossbeam-channel = "0.5.8"
parking_lot = "0.12.1"
unicode-segmentation = "1.10.1"
unicode-width = "0.1.10"
+iter_tools = { version = "0.1.4", optional = true }
xi-unicode = "0.3.0"
girt-display = {version = "2.3.0", path = "../display"}
girt-runtime = {version = "2.3.0", path = "../runtime"}
diff --git a/src/view/src/render_slice/tests.rs b/src/view/src/render_slice/tests.rs
index 01df909..fe12ed5 100644
--- a/src/view/src/render_slice/tests.rs
+++ b/src/view/src/render_slice/tests.rs
@@ -1,7 +1,13 @@
use display::DisplayColor;
use super::*;
-use crate::testutil::{_assert_rendered_output, render_view_line, AssertRenderOptions};
+use crate::testutil::{
+ assert_rendered_output::_assert_rendered_output,
+ render_view_line,
+ AssertRenderOptions,
+ ExactPattern,
+ LinePattern,
+};
fn assert_rendered(render_slice: &RenderSlice, expected: &[&str]) {
let mut output = vec![];
@@ -48,10 +54,17 @@ fn assert_rendered(render_slice: &RenderSlice, expected: &[&str]) {
}
}
}
+
_assert_rendered_output(
AssertRenderOptions::default(),
&output,
- &expected.iter().map(|s| String::from(*s)).collect::<Vec<String>>(),
+ &expected
+ .iter()
+ .map(|s| {
+ let pattern: Box<dyn LinePattern> = Box::new(ExactPattern::new(s));
+ pattern
+ })
+ .collect::<Vec<Box<dyn LinePattern>>>(),
);
}
diff --git a/src/view/src/testutil.rs b/src/view/src/testutil.rs
deleted file mode 100644
index c5db374..0000000
--- a/src/view/src/testutil.rs
+++ /dev/null
@@ -1,451 +0,0 @@
-//! Utilities for writing tests that interact with input events.
-use std::time::Duration;
-
-use bitflags::bitflags;
-use display::DisplayColor;
-
-use crate::{
- render_slice::RenderAction,
- thread::ViewAction,
- view_data::ViewData,
- view_line::ViewLine,
- LineSegment,
- State,
-};
-
-const STARTS_WITH: &str = "{{StartsWith}}";
-const ENDS_WITH: &str = "{{EndsWith}}";
-const ANY_LINE: &str = "{{Any}}";
-const VISIBLE_SPACE_REPLACEMENT: &str = "\u{b7}"; // "·"
-const VISIBLE_TAB_REPLACEMENT: &str = " \u{2192}"; // " →"
-
-/// Assert the rendered output from a `ViewData`.
-#[macro_export]
-macro_rules! render_line {
- (AnyLine) => {{ concat!("{{Any}}") }};
- (AnyLine $count:expr) => {{ concat!("{{Any(", $count, ")}}") }};
- (StartsWith $line:expr) => {{ concat!("{{StartsWith}}", $line) }};
- (EndsWith $line:expr) => {{ concat!("{{EndsWith}}", $line) }};
-}
-
-bitflags! {
- /// Options for the `assert_rendered_output!` macro
- #[derive(Default)]
- pub struct AssertRenderOptions: u8 {
- /// Ignore trailing whitespace
- const INCLUDE_TRAILING_WHITESPACE = 0b0000_0001;
- /// Ignore pinned indicator
- const INCLUDE_PINNED = 0b0000_0010;
- /// Don't include style information
- const EXCLUDE_STYLE = 0b0000_0100;
- }
-}
-
-fn replace_invisibles(line: &str) -> String {
- line.replace(' ', VISIBLE_SPACE_REPLACEMENT)
- .replace('\t', VISIBLE_TAB_REPLACEMENT)
-}
-
-fn render_style(line_segment: &LineSegment) -> String {
- let color_string = match line_segment.get_color() {
- DisplayColor::ActionBreak => String::from("ActionBreak"),
- DisplayColor::ActionDrop => String::from("ActionDrop"),
- DisplayColor::ActionEdit => String::from("ActionEdit"),
- DisplayColor::ActionExec => String::from("ActionExec"),
- DisplayColor::ActionFixup => String::from("ActionFixup"),
- DisplayColor::ActionPick => String::from("ActionPick"),
- DisplayColor::ActionReword => String::from("ActionReword"),
- DisplayColor::ActionSquash => String::from("ActionSquash"),
- DisplayColor::DiffAddColor => String::from("DiffAddColor"),
- DisplayColor::DiffChangeColor => String::from("DiffChangeColor"),
- DisplayColor::DiffRemoveColor => String::from("DiffRemoveColor"),
- DisplayColor::DiffContextColor => String::from("DiffContextColor"),
- DisplayColor::DiffWhitespaceColor => String::from("DiffWhitespaceColor"),
- DisplayColor::IndicatorColor => String::from("IndicatorColor"),
- DisplayColor::Normal => String::from("Normal"),
- DisplayColor::ActionLabel => String::from("ActionLabel"),
- DisplayColor::ActionReset => String::from("ActionReset"),
- DisplayColor::ActionMerge => String::from("ActionMerge"),
- DisplayColor::ActionUpdateRef => String::from("ActionUpdateRef"),
- };
-
- let mut style = vec![];
- if line_segment.is_dimmed() {
- style.push("Dimmed");
- }
- if line_segment.is_underlined() {
- style.push("Underline");
- }
- if line_segment.is_reversed() {
- style.push("Reversed");
- }
-
- if style.is_empty() {
- format!("{{{color_string}}}")
- }
- else {
- format!("{{{color_string},{}}}", style.join(","))
- }
-}
-
-/// Render a `ViewLine` to a `String` using similar logic that is used in the `View`.
-#[must_use]
-#[inline]
-pub fn render_view_line(view_line: &ViewLine, options: Option<AssertRenderOptions>) -> String {
- let mut line = String::new();
-
- let opts = options.unwrap_or_default();
-
- if opts.contains(AssertRenderOptions::INCLUDE_PINNED) {
- let pinned = view_line.get_number_of_pinned_segment();
- if pinned > 0 {
- line.push_str(format!("{{Pin({pinned})}}").as_str());
- }
- }
-
- if view_line.get_selected() {
- line.push_str("{Selected}");
- }
-
- let mut last_style = String::new();
- for segment in view_line.get_segments() {
- if !opts.contains(AssertRenderOptions::EXCLUDE_STYLE) {
- let style = render_style(segment);
- if style != last_style {
- line.push_str(style.as_str());
- last_style = style;
- }
- }
- line.push_str(segment.get_content());
- }
- if let Some(padding) = view_line.get_padding().as_ref() {
- if !opts.contains(AssertRenderOptions::EXCLUDE_STYLE) {
- let style = render_style(padding);
- if style != last_style {
- line.push_str(style.as_str());
- }
- }
- line.push_str(format!("{{Pad({})}}", padding.get_content()).as_str());
- }
- line
-}
-
-fn render_view_data(view_data: &ViewData, options: AssertRenderOptions) -> Vec<String> {
- let mut lines = vec![];
- if view_data.show_title() {
- if view_data.show_help() {
- lines.push(String::from("{TITLE}{HELP}"));
- }
- else {
- lines.push(String::from("{TITLE}"));
- }
- }
-
- if view_data.is_empty() {
- lines.push(String::from("{EMPTY}"));
- }
-
- let leading_lines = view_data.get_leading_lines();
- if !leading_lines.is_empty() {
- lines.push(String::from("{LEADING}"));
- for line in leading_lines {
- lines.push(render_view_line(line, Some(options)));
- }
- }
-
- let body_lines = view_data.get_lines();
- if !body_lines.is_empty() {
- lines.push(String::from("{BODY}"));
- for line in body_lines {
- lines.push(render_view_line(line, Some(options)));
- }
- }
-
- let trailing_lines = view_data.get_trailing_lines();
- if !trailing_lines.is_empty() {
- lines.push(String::from("{TRAILING}"));
- for line in trailing_lines {
- lines.push(render_view_line(line, Some(options)));
- }
- }
- lines
-}
-
-#[allow(clippy::panic)]
-fn expand_expected(expected: &[String]) -> Vec<String> {
- expected
- .iter()
- .flat_map(|f| {
- if f.starts_with("{{Any(") && f.ends_with(")}}") {
- let lines = f
- .replace("{{Any(", "")
- .replace(")}}", "")
- .as_str()
- .parse::<u32>()
- .unwrap_or_else(|_| panic!("Expected {f} to have integer line count"));
- vec![String::from(ANY_LINE); lines as usize]
- }
- else {
- vec![f.clone()]
- }
- })
- .collect::<Vec<String>>()
-}
-
-#[allow(clippy::string_slice, clippy::panic)]
-pub(crate) fn _assert_rendered_output(options: AssertRenderOptions, actual: &[String], expected: &[String]) {
- let mut mismatch = false;
- let mut error_output = vec![
- String::from("\nUnexpected output!"),
- String::from("--- Expected"),
- String::from("+++ Actual"),
- String::from("=========="),
- ];
-
- for (expected_line, output_line) in expected.iter().zip(actual.iter()) {
- let output = if options.contains(AssertRenderOptions::INCLUDE_TRAILING_WHITESPACE) {
- output_line.as_str()
- }
- else {
- output_line.trim_end()
- };
-
- let mut e = replace_invisibles(expected_line);
- let o = replace_invisibles(output);
-
- if expected_line == ANY_LINE {
- error_output.push(format!(" {o}"));
- continue;
- }
-
- if expected_line.starts_with(ENDS_WITH) {
- e = e.replace(ENDS_WITH, "");
- if output.ends_with(&expected_line.replace(ENDS_WITH, "")) {
- error_output.push(format!(" {o}"));
- }
- else {
- mismatch = true;
- error_output.push(format!("-EndsWith {e}"));
- error_output.push(format!("+ {}", &o[o.len() - e.len() + 2..]));
- }
- continue;
- }
-
- if expected_line.starts_with(STARTS_WITH) {
- e = e.replace(STARTS_WITH, "");
- if output.starts_with(&expected_line.replace(STARTS_WITH, "")) {
- error_output.push(format!(" {o}"));
- }
- else {
- mismatch = true;
- error_output.push(format!("-StartsWith {e}"));
- error_output.push(format!("+ {}", &o.chars().take(e.len()).collect::<String>()));
- }
- continue;
- }
-
- if expected_line == output {
- error_output.push(format!(" {e}"));
- }
- else {
- mismatch = true;
- error_output.push(format!("-{e}"));
- error_output.push(format!("+{o}"));
- }
- }
-
- match expected.len() {
- a if a > actual.len() => {
- mismatch = true;
- for line in expected.iter().skip(actual.len()) {
- error_output.push(format!("-{}", replace_invisibles(line)));
- }
- },
- a if a < actual.len() => {
- mismatch = true;
- for line in actual.iter().skip(expected.len()) {
- error_output.push(format!("+{}", replace_invisibles(line)));
- }
- },
- _ => {},
- }
-
- if mismatch {
- error_output.push(String::from("==========\n"));
- panic!("{}", error_output.join("\n"));
- }
-}
-
-/// Assert the rendered output from a `ViewData`. Generally this function is not used directly,
-/// instead use the `assert_rendered_output!` macro.
-#[inline]
-pub fn _assert_rendered_output_from_view_data(view_data: &ViewData, expected: &[String], options: AssertRenderOptions) {
- let output = render_view_data(view_data, options);
-
- _assert_rendered_output(options, &output, &expand_expected(expected));
-}
-
-/// Assert the rendered output from a `ViewData`.
-#[macro_export]
-macro_rules! assert_rendered_output {
- ($view_data:expr) => {
- use $crate::testutil::{_assert_rendered_output_from_view_data, AssertRenderOptions};
- let expected: Vec<String> = vec![];
- _assert_rendered_output_from_view_data($view_data, &expected, AssertRenderOptions::default());
- };
- ($view_data:expr, $($arg:expr),*) => {
- use $crate::testutil::{_assert_rendered_output_from_view_data, AssertRenderOptions};
- let expected = vec![$( String::from($arg), )*];
- _assert_rendered_output_from_view_data($view_data, &expected, AssertRenderOptions::default());
- };
- (Options $options:expr, $view_data:expr, $($arg:expr),*) => {
- use $crate::testutil::{_assert_rendered_output_from_view_data, AssertRenderOptions};
- let expected = vec![$( String::from($arg), )*];
- _assert_rendered_output_from_view_data($view_data, &expected, $options);
- };
-}
-
-#[allow(clippy::panic)]
-fn assert_view_state_actions(state: &State, expected_actions: &[String]) {
- let actions = state
- .render_slice()
- .lock()
- .get_actions()
- .iter()
- .map(|a| {
- match *a {
- RenderAction::ScrollDown => String::from("ScrollDown"),
- RenderAction::ScrollUp => String::from("ScrollUp"),
- RenderAction::ScrollRight => String::from("ScrollRight"),
- RenderAction::ScrollLeft => String::from("ScrollLeft"),
- RenderAction::ScrollTop => String::from("ScrollTop"),
- RenderAction::ScrollBottom => String::from("ScrollBottom"),
- RenderAction::PageUp => String::from("PageUp"),
- RenderAction::PageDown => String::from("PageDown"),
- RenderAction::Resize(width, height) => format!("Resize({width}, {height})"),
- }
- })
- .collect::<Vec<String>>();
-
- let mut mismatch = false;
- let mut error_output = vec![
- String::from("\nUnexpected actions!"),
- String::from("--- Expected"),
- String::from("+++ Actual"),
- String::from("=========="),
- ];
-
- for (expected_action, actual_action) in expected_actions.iter().zip(actions.iter()) {
- if expected_action == actual_action {
- error_output.push(format!(" {expected_action}"));
- }
- else {
- mismatch = true;
- error_output.push(format!("-{expected_action}"));
- error_output.push(format!("+{actual_action}"));
- }
- }
-
- match expected_actions.len() {
- a if a > actions.len() => {
- mismatch = true;
- for action in expected_actions.iter().skip(actions.len()) {
- error_output.push(format!("-{action}"));
- }
- },
- a if a < actions.len() => {
- mismatch = true;
- for action in actions.iter().skip(expected_actions.len()) {
- error_output.push(format!("+{action}"));
- }
- },
- _ => {},
- }
-
- if mismatch {
- error_output.push(String::from("==========\n"));
- panic!("{}", error_output.join("\n"));
- }
-}
-
-fn action_to_string(action: ViewAction) -> String {
- String::from(match action {
- ViewAction::Stop => "Stop",
- ViewAction::Refresh => "Refresh",
- ViewAction::Render => "Render",
- ViewAction::Start => "Start",
- ViewAction::End => "End",
- })
-}
-
-/// Context for a view state test.
-#[derive(Debug)]
-#[non_exhaustive]
-pub struct TestContext {
- /// The state instance.
- pub state: State,
-}
-
-impl TestContext {
- /// Assert that render actions were sent.
- #[inline]
- pub fn assert_render_action(&self, actions: &[&str]) {
- assert_view_state_actions(
- &self.state,
- actions
- .iter()
- .map(|s| String::from(*s))
- .collect::<Vec<String>>()
- .as_slice(),
- );
- }
-
- /// Assert that certain messages were sent by the `State`.
- #[inline]
- #[allow(clippy::missing_panics_doc, clippy::panic)]
- pub fn assert_sent_messages(&self, messages: Vec<&str>) {
- let mut mismatch = false;
- let mut error_output = vec![
- String::from("\nUnexpected messages!"),
- String::from("--- Expected"),
- String::from("+++ Actual"),
- String::from("=========="),
- ];
-
- let update_receiver = self.state.update_receiver();
- for message in messages {
- if let Ok(action) = update_receiver.recv_timeout(Duration::new(1, 0)) {
- let action_name = action_to_string(action);
- if message == action_name {
- error_output.push(format!(" {message}"));
- }
- else {
- mismatch = true;
- error_output.push(format!("-{message}"));
- error_output.push(format!("+{action_name}"));
- }
- }
- else {
- error_output.push(format!("-{message}"));
- }
- }
-
- // wait some time for any other actions that were sent that should have not been
- while let Ok(action) = update_receiver.recv_timeout(Duration::new(0, 10000)) {
- mismatch = true;
- error_output.push(format!("+{}", action_to_string(action)));
- }
-
- if mismatch {
- error_output.push(String::from("==========\n"));
- panic!("{}", error_output.join("\n"));
- }
- }
-}
-
-/// Provide a `State` instance for use within a view test.
-#[inline]
-pub fn with_view_state<C>(callback: C)
-where C: FnOnce(TestContext) {
- callback(TestContext { state: State::new() });
-}
diff --git a/src/view/src/testutil/assert_rendered_output.rs b/src/view/src/testutil/assert_rendered_output.rs
new file mode 100644
index 0000000..994ab32
--- /dev/null
+++ b/src/view/src/testutil/assert_rendered_output.rs
@@ -0,0 +1,481 @@
+use std::fmt::{Debug, Formatter};
+
+use iter_tools::Itertools;
+
+use crate::{
+ testutil::{render_view_line::render_view_data, AssertRenderOptions},
+ ViewData,
+};
+
+const VISIBLE_SPACE_REPLACEMENT: &str = "\u{b7}"; // "·"
+const VISIBLE_TAB_REPLACEMENT: &str = " \u{2192}"; // " →"
+
+/// Replace invisible characters with visible counterparts
+#[inline]
+#[must_use]
+pub fn replace_invisibles(line: &str) -> String {
+ line.replace(' ', VISIBLE_SPACE_REPLACEMENT)
+ .replace('\t', VISIBLE_TAB_REPLACEMENT)
+}
+
+/// A pattern matcher for a rendered line
+pub trait LinePattern: Debug {
+ /// Check if the rendered line matches the matchers pattern
+ fn matches(&self, rendered: &str) -> bool;
+
+ /// A formatted expected value for the matcher
+ fn expected(&self) -> String;
+
+ /// A formatted actual value for the matcher
+ #[inline]
+ #[must_use]
+ fn actual(&self, rendered: &str) -> String {
+ replace_invisibles(rendered)
+ }
+
+ /// Does this matcher use styles for matching
+ #[inline]
+ fn use_styles(&self) -> bool {
+ true
+ }
+}
+
+impl LinePattern for String {
+ #[inline]
+ fn matches(&self, rendered: &str) -> bool {
+ rendered == self
+ }
+
+ #[inline]
+ fn expected(&self) -> String {
+ replace_invisibles(self.as_str())
+ }
+}
+
+impl LinePattern for &str {
+ #[inline]
+ fn matches(&self, rendered: &str) -> bool {
+ rendered == *self
+ }
+
+ #[inline]
+ fn expected(&self) -> String {
+ replace_invisibles(self)
+ }
+}
+
+/// A pattern matcher that will match any line
+#[derive(Debug, Copy, Clone)]
+#[non_exhaustive]
+pub struct AnyLinePattern;
+
+impl AnyLinePattern {
+ /// Create a new instance
+ #[inline]
+ #[must_use]
+ pub fn new() -> Self {
+ Self
+ }
+}
+
+impl LinePattern for AnyLinePattern {
+ #[inline]
+ fn matches(&self, _: &str) -> bool {
+ true
+ }
+
+ #[inline]
+ fn expected(&self) -> String {
+ String::from("{{Any}}")
+ }
+
+ #[inline]
+ fn actual(&self, _: &str) -> String {
+ String::from("{{Any}}")
+ }
+}
+
+/// A pattern matcher that matches that a rendered line is an exact match
+#[derive(Debug, Clone)]
+#[non_exhaustive]
+pub struct ExactPattern(String);
+
+impl ExactPattern {
+ /// Create a new matcher against a line pattern
+ #[inline]
+ #[must_use]
+ pub fn new(pattern: &str) -> Self {
+ Self(String::from(pattern))
+ }
+}
+
+impl LinePattern for ExactPattern {
+ #[inline]
+ fn matches(&self, rendered: &str) -> bool {
+ rendered == self.0
+ }
+
+ #[inline]
+ fn expected(&self) -> String {
+ replace_invisibles(self.0.as_str())
+ }
+}
+
+/// A pattern that matches that a rendered line starts with a pattern
+#[derive(Debug, Clone)]
+#[non_exhaustive]
+pub struct StartsWithPattern(String);
+
+impl StartsWithPattern {
+ /// Create a new matcher with a pattern
+ #[inline]
+ #[must_use]
+ pub fn new(pattern: &str) -> Self {
+ Self(String::from(pattern))
+ }
+}
+
+impl LinePattern for StartsWithPattern {
+ #[inline]
+ fn matches(&self, rendered: &str) -> bool {
+ rendered.starts_with(self.0.as_str())
+ }
+
+ #[inline]
+ fn expected(&self) -> String {
+ format!("StartsWith {}", replace_invisibles(self.0.as_str()))
+ }
+
+ #[inline]
+ fn actual(&self, rendered: &str) -> String {
+ format!(
+ " {}",
+ replace_invisibles(rendered.chars().take(self.0.len()).collect::<String>().as_str())
+ )
+ }
+}
+
+/// A pattern that matches that a rendered line ends with a pattern
+#[derive(Debug, Clone)]
+#[non_exhaustive]
+pub struct EndsWithPattern(String);
+
+impl EndsWithPattern {
+ /// Create a new matcher with a pattern
+ #[inline]
+ #[must_use]
+ pub fn new(pattern: &str) -> Self {
+ Self(String::from(pattern))
+ }
+}
+
+impl LinePattern for EndsWithPattern {
+ #[inline]
+ fn matches(&self, rendered: &str) -> bool {
+ rendered.ends_with(self.0.as_str())
+ }
+
+ #[inline]
+ fn expected(&self) -> String {
+ format!("EndsWith {}", replace_invisibles(self.0.as_str()))
+ }
+
+ #[allow(clippy::string_slice)]
+ #[inline]
+ fn actual(&self, rendered: &str) -> String {
+ format!(
+ " {}",
+ replace_invisibles(&rendered[rendered.len() - self.0.len() + 2..])
+ )
+ }
+}
+
+/// A pattern that matches that a rendered line contains a pattern
+#[derive(Debug, Clone)]
+#[non_exhaustive]
+pub struct ContainsPattern(String);
+
+impl ContainsPattern {
+ /// Create a new matcher with a pattern
+ #[inline]
+ #[must_use]
+ pub fn new(pattern: &str) -> Self {
+ Self(String::from(pattern))
+ }
+}
+
+/// A pattern that matches that a rendered line matches all patterns
+#[derive(Debug)]
+#[non_exhaustive]
+pub struct NotPattern(Box<dyn LinePattern>);
+
+impl LinePattern for ContainsPattern {
+ #[inline]
+ fn matches(&self, rendered: &str) -> bool {
+ rendered.contains(self.0.as_str())
+ }
+
+ #[inline]
+ fn expected(&self) -> String {
+ format!("Contains {}", replace_invisibles(self.0.as_str()))
+ }
+
+ #[allow(clippy::string_slice)]
+ #[inline]
+ fn actual(&self, rendered: &str) -> String {
+ format!(" {}", replace_invisibles(rendered))
+ }
+}
+
+impl NotPattern {
+ /// Create a new matcher with a pattern
+ #[inline]
+ #[must_use]
+ pub fn new(pattern: Box<dyn LinePattern>) -> Self {
+ Self(pattern)
+ }
+}
+
+impl LinePattern for NotPattern {
+ #[inline]
+ fn matches(&self, rendered: &str) -> bool {
+ !self.0.matches(rendered)
+ }
+
+ #[inline]
+ fn expected(&self) -> String {
+ format!("Not({})", self.0.expected())
+ }
+
+ #[inline]
+ fn actual(&self, rendered: &str) -> String {
+ format!("Not({})", self.0.actual(rendered))
+ }
+}
+
+/// A pattern that matches that a rendered line matches all of a set of patterns
+#[non_exhaustive]
+pub struct AllPattern(Vec<Box<dyn LinePattern>>);
+
+impl AllPattern {
+ /// Create a new matcher with patterns
+ #[inline]
+ #[must_use]
+ pub fn new(patterns: Vec<Box<dyn LinePattern>>) -> Self {
+ Self(patterns)
+ }
+}
+
+impl LinePattern for AllPattern {
+ #[inline]
+ fn matches(&self, rendered: &str) -> bool {
+ self.0.iter().all(|pattern| pattern.matches(rendered))
+ }
+
+ #[inline]
+ fn expected(&self) -> String {
+ format!("All({})", self.0.iter().map(|p| { p.expected() }).join(", "))
+ }
+}
+
+impl Debug for AllPattern {
+ #[inline]
+ fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
+ write!(f, "All({})", self.0.iter().map(|p| format!("{p:?}")).join(", "))
+ }
+}
+
+/// A pattern that matches that a rendered line matches any of a set of patterns
+#[non_exhaustive]
+pub struct AnyPattern(Vec<Box<dyn LinePattern>>);
+
+impl AnyPattern {
+ /// Create a new matcher with patterns
+ #[inline]
+ #[must_use]
+ pub fn new(patterns: Vec<Box<dyn LinePattern>>) -> Self {
+ Self(patterns)
+ }
+}
+
+impl LinePattern for AnyPattern {
+ #[inline]
+ fn matches(&self, rendered: &str) -> bool {
+ self.0.iter().any(|pattern| pattern.matches(rendered))
+ }
+
+ #[inline]
+ fn expected(&self) -> String {
+ format!("Any({})", self.0.iter().map(|p| { p.expected() }).join(", "))
+ }
+}
+
+impl Debug for AnyPattern {
+ #[inline]
+ fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
+ write!(f, "Any({})", self.0.iter().map(|p| format!("{p:?}")).join(", "))
+ }
+}
+
+#[allow(clippy::string_slice, clippy::panic)]
+pub(crate) fn _assert_rendered_output(
+ options: AssertRenderOptions,
+ actual: &[String],
+ expected_patterns: &[Box<dyn LinePattern>],
+) {
+ let mut mismatch = false;
+ let mut error_output = vec![
+ String::from("\nUnexpected output!"),
+ String::from("--- Expected"),
+ String::from("+++ Actual"),
+ String::from("=========="),
+ ];
+
+ for (expected_pattern, output_line) in expected_patterns.iter().zip(actual.iter()) {
+ let output = if options.contains(AssertRenderOptions::INCLUDE_TRAILING_WHITESPACE) {
+ output_line.as_str()
+ }
+ else {
+ output_line.trim_end()
+ };
+
+ if expected_pattern.matches(output) {
+ error_output.push(format!(" {}", expected_pattern.expected()));
+ }
+ else {
+ mismatch = true;
+ error_output.push(format!("-{}", expected_pattern.expected()));
+ error_output.push(format!("+{}", expected_pattern.actual(output)));
+ }
+ }
+
+ match expected_patterns.len() {
+ a if a > actual.len() => {
+ mismatch = true;
+ for expected_pattern in expected_patterns.iter().skip(actual.len()) {
+ error_output.push(format!("-{}", expected_pattern.expected().as_str()));
+ }
+ },
+ a if a < actual.len() => {
+ mismatch = true;
+ for line in actual.iter().skip(expected_patterns.len()) {
+ error_output.push(format!("+{}", replace_invisibles(line)));
+ }
+ },
+ _ => {},
+ }
+
+ if mismatch {
+ error_output.push(String::from("==========\n"));
+ panic!("{}", error_output.join("\n"));
+ }
+}
+
+/// Assert the rendered output from a `ViewData`. Generally this function is not used directly,
+/// instead use the `assert_rendered_output!` macro.
+#[inline]
+pub fn _assert_rendered_output_from_view_data(
+ view_data: &ViewData,
+ expected: &[Box<dyn LinePattern>],
+ options: AssertRenderOptions,
+ skip_start: Option<usize>,
+ skip_end: Option<usize>,
+) {
+ let rendered = render_view_data(view_data, options);
+ let mut length = rendered.len();
+ let mut output_iter: Box<dyn Iterator<Item = String>> = Box::new(rendered.into_iter());
+
+ if let Some(skip) = skip_start {
+ length = length.saturating_sub(skip);
+ output_iter = Box::new(output_iter.skip(skip));
+ }
+
+ if let Some(skip) = skip_end {
+ output_iter = Box::new(output_iter.take(length - skip));
+ }
+
+ _assert_rendered_output(options, &output_iter.collect::<Vec<String>>(), expected);
+}
+
+/// Create an assertion on a line. For use in `assert_rendered_output` macro.
+#[macro_export]
+macro_rules! render_line {
+ ($line:expr) => {{ $crate::testutil::ExactPattern::new($line) }};
+ (Line) => {{ $crate::testutil::AnyLinePattern::new() }};
+ (StartsWith $line:expr) => {{ $crate::testutil::StartsWithPattern::new($line) }};
+ (Not StartsWith $line:expr) => {{ $crate::testutil::NotPattern::new(
+ Box::new($crate::testutil::StartsWithPattern::new($line))
+ ) }};
+ (EndsWith $line:expr) => {{ $crate::testutil::EndsWithPattern::new($line) }};
+ (Not EndsWith $line:expr) => {{ $crate::testutil::NotPattern::new(
+ Box::new($crate::testutil::EndsWithPattern::new($line))
+ ) }};
+ (Contains $line:expr) => {{ $crate::testutil::ContainsPattern::new($line) }};
+ (Not Contains $line:expr) => {{ $crate::testutil::NotPattern::new(
+ Box::new($crate::testutil::ContainsPattern::new($line))
+ ) }};
+ (Not $pattern:expr) => {{ $crate::testutil::NotPattern::new(Box::new($pattern)) }};
+ (All $($patterns:expr),*) => {{
+ let patterns: Vec<Box<dyn LinePattern>> = vec![$( Box::new($patterns), )*];
+ $crate::testutil::AllPattern::new(patterns)
+ }};
+ (Any $($patterns:expr),*) => {{
+ let patterns: Vec<Box<dyn LinePattern>> = vec![$( Box::new($patterns), )*];
+ $crate::testutil::AnyPattern::new(patterns)
+ }};
+}
+
+/// Assert the rendered output from a `ViewData`.
+#[macro_export]
+macro_rules! assert_rendered_output {
+ ($view_data:expr, $($arg:expr),*) => {
+ assert_rendered_output!(
+ @base AssertRenderOptions::default(), None, None, $view_data, $($arg),*
+ )
+ };
+ (Body $view_data:expr, $($arg:expr),*) => {
+ assert_rendered_output!(
+ @base AssertRenderOptions::BODY_ONLY, None, None, $view_data, $($arg),*
+ )
+ };
+ (Skip $start:expr, $view_data:expr, $($arg:expr),*) => {
+ assert_rendered_output!(
+ @base AssertRenderOptions::default(), Some($start), None, $view_data, $($arg),*
+ )
+ };
+ (Skip $start:expr;$end:expr, $view_data:expr, $($arg:expr),*) => {
+ assert_rendered_output!(
+ @base AssertRenderOptions::default(), Some($start), Some($end), $view_data, $($arg),*
+ )
+ };
+ (Options $options:expr, $view_data:expr, $($arg:expr),*) => {
+ assert_rendered_output!(
+ @base $options, None, None, $view_data, $($arg),*
+ )
+ };
+ (Body, Skip $start:expr, $view_data:expr, $($arg:expr),*) => {
+ assert_rendered_output!(
+ @base AssertRenderOptions::BODY_ONLY, Some($start), None, $view_data, $($arg),*
+ )
+ };
+ (Body, Skip $start:expr;$end:expr, $view_data:expr, $($arg:expr),*) => {
+ assert_rendered_output!(
+ @base AssertRenderOptions::BODY_ONLY, Some($start), Some($end), $view_data, $($arg),*
+ )
+ };
+ (Options $options:expr, Skip $start:expr, $view_data:expr, $($arg:expr),*) => {
+ assert_rendered_output!(
+ @base $options, Some($start), None, $view_data, $($arg),*
+ )
+ };
+ (Options $options:expr, Skip $start:expr;$end:expr, $view_data:expr, $($arg:expr),*) => {
+ assert_rendered_output!(
+ @base $options, Some($start), Some($end), $view_data, $($arg),*
+ )
+ };
+ (@base $options:expr, $start:expr, $end:expr, $view_data:expr, $($arg:expr),*) => {
+ use $crate::testutil::{_assert_rendered_output_from_view_data, AssertRenderOptions, LinePattern};
+ let expected: Vec<Box<dyn LinePattern>> = vec![$( Box::new