Merge pull request #3344 from gitbutlerapp/separate-integration-tests

separate integration tests for 'changeset' crate
This commit is contained in:
Josh Junon 2024-04-04 14:07:46 +02:00 committed by GitHub
commit b9c07265c2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 358 additions and 360 deletions

View File

@ -2,6 +2,10 @@
name = "gitbutler-changeset" name = "gitbutler-changeset"
version = "0.0.0" version = "0.0.0"
edition = "2021" edition = "2021"
publish = false
[lib]
doctest = false
[dependencies] [dependencies]
thiserror.workspace = true thiserror.workspace = true

View File

@ -101,137 +101,3 @@ pub trait FormatHunk: RawHunk {
} }
impl<T> FormatHunk for T where T: RawHunk {} impl<T> FormatHunk for T where T: RawHunk {}
#[cfg(test)]
mod tests {
use super::*;
#[derive(Debug, Clone, PartialEq, Eq)]
struct TestHunk {
removal_start: usize,
addition_start: usize,
changes: Vec<super::Change>,
}
impl super::RawHunk for TestHunk {
type ChangeIterator = std::vec::IntoIter<super::Change>;
fn get_removal_start(&self) -> usize {
self.removal_start
}
fn get_addition_start(&self) -> usize {
self.addition_start
}
fn changes(&self) -> Self::ChangeIterator {
self.changes.clone().into_iter()
}
}
impl fmt::Display for TestHunk {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
self.fmt_unified(f)
}
}
#[test]
fn empty_hunk() {
let hunk = TestHunk {
removal_start: 1,
addition_start: 1,
changes: vec![],
};
assert_eq!(format!("{hunk}"), "");
}
#[test]
fn single_removal() {
let hunk = TestHunk {
removal_start: 30,
addition_start: 38,
changes: vec![super::Change::Removal("Hello, world!".to_string())],
};
assert_eq!(
format!("{hunk}"),
"@@ -30 +38,0 @@\n-Hello, world!\n\\ No newline at end of file\n"
);
}
#[test]
fn single_removal_trailing_nl() {
let hunk = TestHunk {
removal_start: 30,
addition_start: 38,
changes: vec![super::Change::Removal("Hello, world!\n".to_string())],
};
assert_eq!(format!("{hunk}"), "@@ -30 +38,0 @@\n-Hello, world!\n");
}
#[test]
fn single_addition() {
let hunk = TestHunk {
removal_start: 30,
addition_start: 38,
changes: vec![super::Change::Addition("Hello, world!".to_string())],
};
assert_eq!(
format!("{hunk}"),
"@@ -30,0 +38 @@\n+Hello, world!\n\\ No newline at end of file\n"
);
}
#[test]
fn single_addition_trailing_nl() {
let hunk = TestHunk {
removal_start: 30,
addition_start: 38,
changes: vec![super::Change::Addition("Hello, world!\n".to_string())],
};
assert_eq!(format!("{hunk}"), "@@ -30,0 +38 @@\n+Hello, world!\n");
}
#[test]
fn single_modified_line() {
let hunk = TestHunk {
removal_start: 30,
addition_start: 38,
changes: vec![
super::Change::Removal("Hello, world!".to_string()),
super::Change::Addition("Hello, GitButler!\n".to_string()),
],
};
assert_eq!(
format!("{hunk}"),
"@@ -30 +38 @@\n-Hello, world!\n\\ No newline at end of file\n+Hello, GitButler!\n"
);
}
#[test]
fn preserve_change_order() {
let hunk = TestHunk {
removal_start: 30,
addition_start: 20,
changes: vec![
super::Change::Addition("Hello, GitButler!\n".to_string()),
super::Change::Removal("Hello, world!\n".to_string()),
super::Change::Removal("Hello, world 2!\n".to_string()),
super::Change::Addition("Hello, GitButler 2!\n".to_string()),
super::Change::Removal("Hello, world 3!".to_string()),
super::Change::Addition("Hello, GitButler 3!\n".to_string()),
super::Change::Addition("Hello, GitButler 4!".to_string()),
],
};
assert_eq!(
format!("{hunk}"),
"@@ -30,3 +20,4 @@\n+Hello, GitButler!\n-Hello, world!\n-Hello, world 2!\n+Hello, GitButler 2!\n-Hello, world 3!\n\\ No newline at end of file\n+Hello, GitButler 3!\n+Hello, GitButler 4!\n\\ No newline at end of file\n"
);
}
}

View File

@ -175,131 +175,3 @@ impl<S: AsRef<str>> From<S> for Signature {
fn bigrams(s: &[u8]) -> impl Iterator<Item = (u8, u8)> + '_ { fn bigrams(s: &[u8]) -> impl Iterator<Item = (u8, u8)> + '_ {
s.iter().copied().zip(s.iter().skip(1).copied()) s.iter().copied().zip(s.iter().skip(1).copied())
} }
#[cfg(test)]
mod tests {
use super::*;
macro_rules! assert_score {
($sig:ident, $s:expr, $e:expr) => {
let score = $sig.score_str($s);
if (score - $e).abs() >= 0.1 {
panic!(
"expected score of {} for string {:?}, got {}",
$e, $s, score
);
}
};
}
#[test]
fn score_signature() {
let sig = Signature::from("hello world");
// NOTE: The scores here are not exact, but are close enough
// to be useful for testing purposes, hence why some have the same
// "score" but different strings.
assert_score!(sig, "hello world", 1.0);
assert_score!(sig, "hello world!", 0.95);
assert_score!(sig, "hello world!!", 0.9);
assert_score!(sig, "hello world!!!", 0.85);
assert_score!(sig, "hello world!!!!", 0.8);
assert_score!(sig, "hello world!!!!!", 0.75);
assert_score!(sig, "hello world!!!!!!", 0.7);
assert_score!(sig, "hello world!!!!!!!", 0.65);
assert_score!(sig, "hello world!!!!!!!!", 0.62);
assert_score!(sig, "hello world!!!!!!!!!", 0.6);
assert_score!(sig, "hello world!!!!!!!!!!", 0.55);
}
#[test]
fn score_ignores_whitespace() {
let sig = Signature::from("hello world");
assert_score!(sig, "hello world", 1.0);
assert_score!(sig, "hello world ", 1.0);
assert_score!(sig, "hello\nworld ", 1.0);
assert_score!(sig, "hello\n\tworld ", 1.0);
assert_score!(sig, "\t\t hel lo\n\two rld \t\t", 1.0);
}
const TEXT1: &str = include_str!("../fixture/text1.txt");
const TEXT2: &str = include_str!("../fixture/text2.txt");
const TEXT3: &str = include_str!("../fixture/text3.txt");
const CODE1: &str = include_str!("../fixture/code1.txt");
const CODE2: &str = include_str!("../fixture/code2.txt");
const CODE3: &str = include_str!("../fixture/code3.txt");
const CODE4: &str = include_str!("../fixture/code4.txt");
const LARGE1: &str = include_str!("../fixture/large1.txt");
const LARGE2: &str = include_str!("../fixture/large2.txt");
macro_rules! real_test {
($a: ident, $b: ident, are_similar) => {
paste::paste! {
#[test]
#[allow(non_snake_case)]
fn [<test_ $a _ $b _are_similar>]() {
let a = Signature::from($a);
let b = Signature::from($b);
assert!(a.score_str($b) >= 0.95);
assert!(b.score_str($a) >= 0.95);
}
}
};
($a: ident, $b: ident, are_not_similar) => {
paste::paste! {
#[test]
#[allow(non_snake_case)]
fn [<test_ $a _ $b _are_not_similar>]() {
let a = Signature::from($a);
let b = Signature::from($b);
assert!(a.score_str($b) < 0.95);
assert!(b.score_str($a) < 0.95);
}
}
};
}
// Only similar pairs:
// - TEXT1, TEXT2
// - CODE1, CODE2
// - LARGE1, LARGE2
real_test!(TEXT1, TEXT2, are_similar);
real_test!(CODE1, CODE2, are_similar);
real_test!(LARGE1, LARGE2, are_similar);
// Check all other combos
real_test!(TEXT1, TEXT3, are_not_similar);
real_test!(TEXT1, CODE1, are_not_similar);
real_test!(TEXT1, CODE2, are_not_similar);
real_test!(TEXT1, CODE3, are_not_similar);
real_test!(TEXT1, CODE4, are_not_similar);
real_test!(TEXT1, LARGE1, are_not_similar);
real_test!(TEXT1, LARGE2, are_not_similar);
real_test!(TEXT2, TEXT3, are_not_similar);
real_test!(TEXT2, CODE1, are_not_similar);
real_test!(TEXT2, CODE2, are_not_similar);
real_test!(TEXT2, CODE3, are_not_similar);
real_test!(TEXT2, CODE4, are_not_similar);
real_test!(TEXT2, LARGE1, are_not_similar);
real_test!(TEXT2, LARGE2, are_not_similar);
real_test!(TEXT3, CODE1, are_not_similar);
real_test!(TEXT3, CODE2, are_not_similar);
real_test!(TEXT3, CODE3, are_not_similar);
real_test!(TEXT3, CODE4, are_not_similar);
real_test!(TEXT3, LARGE1, are_not_similar);
real_test!(TEXT3, LARGE2, are_not_similar);
real_test!(CODE1, CODE3, are_not_similar);
real_test!(CODE1, CODE4, are_not_similar);
real_test!(CODE1, LARGE1, are_not_similar);
real_test!(CODE1, LARGE2, are_not_similar);
real_test!(CODE2, CODE3, are_not_similar);
real_test!(CODE2, CODE4, are_not_similar);
real_test!(CODE2, LARGE1, are_not_similar);
real_test!(CODE2, LARGE2, are_not_similar);
real_test!(CODE3, CODE4, are_not_similar);
real_test!(CODE3, LARGE1, are_not_similar);
real_test!(CODE3, LARGE2, are_not_similar);
real_test!(CODE4, LARGE1, are_not_similar);
real_test!(CODE4, LARGE2, are_not_similar);
}

View File

@ -105,101 +105,3 @@ impl LineSpan {
}) })
} }
} }
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn span_new() {
for s in 0..20 {
for e in s + 1..=20 {
let span = LineSpan::new(s, e);
assert_eq!(span.start(), s);
assert_eq!(span.end(), e);
}
}
}
#[test]
fn span_extract() {
let lines = [
"Hello, world!",
"This is a test.",
"This is another test.\r",
"This is a third test.\r",
"This is a fourth test.",
"This is a fifth test.\r",
"This is a sixth test.",
"This is a seventh test.\r",
"This is an eighth test.",
"This is a ninth test.\r",
"This is a tenth test.", // note no newline at end
];
let full_text = lines.join("\n");
// calculate the known character offsets of each line
let mut offsets = vec![];
let mut start = 0;
for (i, line) in lines.iter().enumerate() {
// If it's not the last line, add 1 for the newline character.
let end = start + line.len() + (i != (lines.len() - 1)) as usize;
offsets.push((start, end));
start = end;
}
// Test single-line extraction
for i in 0..lines.len() - 1 {
let span = LineSpan::new(i, i + 1);
let expected = &full_text[offsets[i].0..offsets[i].1];
let (extracted, start_offset, end_offset) = span.extract(&full_text).unwrap();
assert_eq!(extracted, expected);
assert_eq!((start_offset, end_offset), (offsets[i].0, offsets[i].1));
}
// Test multi-line extraction
for i in 0..lines.len() {
for j in i..=lines.len() {
let span = LineSpan::new(i, j);
assert!(span.line_count() == (j - i));
if i == j {
assert!(span.is_empty());
continue;
}
let expected_start = offsets[i].0;
let expected_end = offsets[j - 1].1;
let expected_text = &full_text[expected_start..expected_end];
let (extracted, start_offset, end_offset) = span.extract(&full_text).unwrap();
assert_eq!(extracted, expected_text);
assert_eq!((start_offset, end_offset), (expected_start, expected_end));
}
}
}
#[test]
fn span_intersects() {
let span = LineSpan::new(5, 11); // Exclusive end
assert!(span.intersects(&LineSpan::new(10, 11))); // Intersect at start
assert!(span.intersects(&LineSpan::new(0, 11))); // Fully contained
assert!(span.intersects(&LineSpan::new(10, 15))); // Partial overlap
assert!(span.intersects(&LineSpan::new(4, 6))); // Intersect at end
assert!(span.intersects(&LineSpan::new(5, 6))); // Exact match start
assert!(span.intersects(&LineSpan::new(0, 6))); // Overlap at end
assert!(span.intersects(&LineSpan::new(0, 8))); // Overlap middle
assert!(span.intersects(&LineSpan::new(0, 10))); // Overlap up to end
assert!(span.intersects(&LineSpan::new(9, 10))); // Overlap at single point
assert!(span.intersects(&LineSpan::new(7, 9))); // Overlap inside
// Test cases where there should be no intersection due to exclusive end
assert!(!span.intersects(&LineSpan::new(0, 5))); // Before start
assert!(!span.intersects(&LineSpan::new(11, 20))); // After end
assert!(!span.intersects(&LineSpan::new(11, 12))); // Just after end
}
}

View File

@ -0,0 +1,3 @@
mod diff;
mod signature;
mod span;

View File

@ -0,0 +1,133 @@
mod hunk {
use gitbutler_changeset::{Change, FormatHunk, RawHunk};
use std::fmt;
#[derive(Debug, Clone, PartialEq, Eq)]
struct TestHunk {
removal_start: usize,
addition_start: usize,
changes: Vec<Change>,
}
impl RawHunk for TestHunk {
type ChangeIterator = std::vec::IntoIter<Change>;
fn get_removal_start(&self) -> usize {
self.removal_start
}
fn get_addition_start(&self) -> usize {
self.addition_start
}
fn changes(&self) -> Self::ChangeIterator {
self.changes.clone().into_iter()
}
}
impl fmt::Display for TestHunk {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
self.fmt_unified(f)
}
}
#[test]
fn empty_hunk() {
let hunk = TestHunk {
removal_start: 1,
addition_start: 1,
changes: vec![],
};
assert_eq!(format!("{hunk}"), "");
}
#[test]
fn single_removal() {
let hunk = TestHunk {
removal_start: 30,
addition_start: 38,
changes: vec![Change::Removal("Hello, world!".to_string())],
};
assert_eq!(
format!("{hunk}"),
"@@ -30 +38,0 @@\n-Hello, world!\n\\ No newline at end of file\n"
);
}
#[test]
fn single_removal_trailing_nl() {
let hunk = TestHunk {
removal_start: 30,
addition_start: 38,
changes: vec![Change::Removal("Hello, world!\n".to_string())],
};
assert_eq!(format!("{hunk}"), "@@ -30 +38,0 @@\n-Hello, world!\n");
}
#[test]
fn single_addition() {
let hunk = TestHunk {
removal_start: 30,
addition_start: 38,
changes: vec![Change::Addition("Hello, world!".to_string())],
};
assert_eq!(
format!("{hunk}"),
"@@ -30,0 +38 @@\n+Hello, world!\n\\ No newline at end of file\n"
);
}
#[test]
fn single_addition_trailing_nl() {
let hunk = TestHunk {
removal_start: 30,
addition_start: 38,
changes: vec![Change::Addition("Hello, world!\n".to_string())],
};
assert_eq!(format!("{hunk}"), "@@ -30,0 +38 @@\n+Hello, world!\n");
}
#[test]
fn single_modified_line() {
let hunk = TestHunk {
removal_start: 30,
addition_start: 38,
changes: vec![
Change::Removal("Hello, world!".to_string()),
Change::Addition("Hello, GitButler!\n".to_string()),
],
};
assert_eq!(
format!("{hunk}"),
"@@ -30 +38 @@\n-Hello, world!\n\\ No newline at end of file\n+Hello, GitButler!\n"
);
}
#[test]
fn preserve_change_order() {
let hunk = TestHunk {
removal_start: 30,
addition_start: 20,
changes: vec![
Change::Addition("Hello, GitButler!\n".to_string()),
Change::Removal("Hello, world!\n".to_string()),
Change::Removal("Hello, world 2!\n".to_string()),
Change::Addition("Hello, GitButler 2!\n".to_string()),
Change::Removal("Hello, world 3!".to_string()),
Change::Addition("Hello, GitButler 3!\n".to_string()),
Change::Addition("Hello, GitButler 4!".to_string()),
],
};
assert_eq!(
format!("{hunk}"),
"@@ -30,3 +20,4 @@\n+Hello, GitButler!\n-Hello, world!\n-Hello, world 2!\n+Hello, GitButler 2!\n-Hello, world 3!\n\\ No newline at end of file\n+Hello, GitButler 3!\n+Hello, GitButler 4!\n\\ No newline at end of file\n"
);
}
}

View File

@ -0,0 +1,124 @@
use gitbutler_changeset::Signature;
macro_rules! assert_score {
($sig:ident, $s:expr, $e:expr) => {
let score = $sig.score_str($s);
if (score - $e).abs() >= 0.1 {
panic!(
"expected score of {} for string {:?}, got {}",
$e, $s, score
);
}
};
}
#[test]
fn score_signature() {
let sig = Signature::from("hello world");
// NOTE: The scores here are not exact, but are close enough
// to be useful for testing purposes, hence why some have the same
// "score" but different strings.
assert_score!(sig, "hello world", 1.0);
assert_score!(sig, "hello world!", 0.95);
assert_score!(sig, "hello world!!", 0.9);
assert_score!(sig, "hello world!!!", 0.85);
assert_score!(sig, "hello world!!!!", 0.8);
assert_score!(sig, "hello world!!!!!", 0.75);
assert_score!(sig, "hello world!!!!!!", 0.7);
assert_score!(sig, "hello world!!!!!!!", 0.65);
assert_score!(sig, "hello world!!!!!!!!", 0.62);
assert_score!(sig, "hello world!!!!!!!!!", 0.6);
assert_score!(sig, "hello world!!!!!!!!!!", 0.55);
}
#[test]
fn score_ignores_whitespace() {
let sig = Signature::from("hello world");
assert_score!(sig, "hello world", 1.0);
assert_score!(sig, "hello world ", 1.0);
assert_score!(sig, "hello\nworld ", 1.0);
assert_score!(sig, "hello\n\tworld ", 1.0);
assert_score!(sig, "\t\t hel lo\n\two rld \t\t", 1.0);
}
const TEXT1: &str = include_str!("../fixtures/text1.txt");
const TEXT2: &str = include_str!("../fixtures/text2.txt");
const TEXT3: &str = include_str!("../fixtures/text3.txt");
const CODE1: &str = include_str!("../fixtures/code1.txt");
const CODE2: &str = include_str!("../fixtures/code2.txt");
const CODE3: &str = include_str!("../fixtures/code3.txt");
const CODE4: &str = include_str!("../fixtures/code4.txt");
const LARGE1: &str = include_str!("../fixtures/large1.txt");
const LARGE2: &str = include_str!("../fixtures/large2.txt");
macro_rules! real_test {
($a: ident, $b: ident, are_similar) => {
paste::paste! {
#[test]
#[allow(non_snake_case)]
fn [<$a _ $b _are_similar>]() {
let a = Signature::from($a);
let b = Signature::from($b);
assert!(a.score_str($b) >= 0.95);
assert!(b.score_str($a) >= 0.95);
}
}
};
($a: ident, $b: ident, are_not_similar) => {
paste::paste! {
#[test]
#[allow(non_snake_case)]
fn [<$a _ $b _are_not_similar>]() {
let a = Signature::from($a);
let b = Signature::from($b);
assert!(a.score_str($b) < 0.95);
assert!(b.score_str($a) < 0.95);
}
}
};
}
// Only similar pairs:
// - TEXT1, TEXT2
// - CODE1, CODE2
// - LARGE1, LARGE2
real_test!(TEXT1, TEXT2, are_similar);
real_test!(CODE1, CODE2, are_similar);
real_test!(LARGE1, LARGE2, are_similar);
// Check all other combos
real_test!(TEXT1, TEXT3, are_not_similar);
real_test!(TEXT1, CODE1, are_not_similar);
real_test!(TEXT1, CODE2, are_not_similar);
real_test!(TEXT1, CODE3, are_not_similar);
real_test!(TEXT1, CODE4, are_not_similar);
real_test!(TEXT1, LARGE1, are_not_similar);
real_test!(TEXT1, LARGE2, are_not_similar);
real_test!(TEXT2, TEXT3, are_not_similar);
real_test!(TEXT2, CODE1, are_not_similar);
real_test!(TEXT2, CODE2, are_not_similar);
real_test!(TEXT2, CODE3, are_not_similar);
real_test!(TEXT2, CODE4, are_not_similar);
real_test!(TEXT2, LARGE1, are_not_similar);
real_test!(TEXT2, LARGE2, are_not_similar);
real_test!(TEXT3, CODE1, are_not_similar);
real_test!(TEXT3, CODE2, are_not_similar);
real_test!(TEXT3, CODE3, are_not_similar);
real_test!(TEXT3, CODE4, are_not_similar);
real_test!(TEXT3, LARGE1, are_not_similar);
real_test!(TEXT3, LARGE2, are_not_similar);
real_test!(CODE1, CODE3, are_not_similar);
real_test!(CODE1, CODE4, are_not_similar);
real_test!(CODE1, LARGE1, are_not_similar);
real_test!(CODE1, LARGE2, are_not_similar);
real_test!(CODE2, CODE3, are_not_similar);
real_test!(CODE2, CODE4, are_not_similar);
real_test!(CODE2, LARGE1, are_not_similar);
real_test!(CODE2, LARGE2, are_not_similar);
real_test!(CODE3, CODE4, are_not_similar);
real_test!(CODE3, LARGE1, are_not_similar);
real_test!(CODE3, LARGE2, are_not_similar);
real_test!(CODE4, LARGE1, are_not_similar);
real_test!(CODE4, LARGE2, are_not_similar);

View File

@ -0,0 +1,94 @@
use gitbutler_changeset::LineSpan;
#[test]
fn new() {
for s in 0..20 {
for e in s + 1..=20 {
let span = LineSpan::new(s, e);
assert_eq!(span.start(), s);
assert_eq!(span.end(), e);
}
}
}
#[test]
fn extract() {
let lines = [
"Hello, world!",
"This is a test.",
"This is another test.\r",
"This is a third test.\r",
"This is a fourth test.",
"This is a fifth test.\r",
"This is a sixth test.",
"This is a seventh test.\r",
"This is an eighth test.",
"This is a ninth test.\r",
"This is a tenth test.", // note no newline at end
];
let full_text = lines.join("\n");
// calculate the known character offsets of each line
let mut offsets = vec![];
let mut start = 0;
for (i, line) in lines.iter().enumerate() {
// If it's not the last line, add 1 for the newline character.
let end = start + line.len() + (i != (lines.len() - 1)) as usize;
offsets.push((start, end));
start = end;
}
// Test single-line extraction
for i in 0..lines.len() - 1 {
let span = LineSpan::new(i, i + 1);
let expected = &full_text[offsets[i].0..offsets[i].1];
let (extracted, start_offset, end_offset) = span.extract(&full_text).unwrap();
assert_eq!(extracted, expected);
assert_eq!((start_offset, end_offset), (offsets[i].0, offsets[i].1));
}
// Test multi-line extraction
for i in 0..lines.len() {
for j in i..=lines.len() {
let span = LineSpan::new(i, j);
assert!(span.line_count() == (j - i));
if i == j {
assert!(span.is_empty());
continue;
}
let expected_start = offsets[i].0;
let expected_end = offsets[j - 1].1;
let expected_text = &full_text[expected_start..expected_end];
let (extracted, start_offset, end_offset) = span.extract(&full_text).unwrap();
assert_eq!(extracted, expected_text);
assert_eq!((start_offset, end_offset), (expected_start, expected_end));
}
}
}
#[test]
fn intersects() {
let span = LineSpan::new(5, 11); // Exclusive end
assert!(span.intersects(&LineSpan::new(10, 11))); // Intersect at start
assert!(span.intersects(&LineSpan::new(0, 11))); // Fully contained
assert!(span.intersects(&LineSpan::new(10, 15))); // Partial overlap
assert!(span.intersects(&LineSpan::new(4, 6))); // Intersect at end
assert!(span.intersects(&LineSpan::new(5, 6))); // Exact match start
assert!(span.intersects(&LineSpan::new(0, 6))); // Overlap at end
assert!(span.intersects(&LineSpan::new(0, 8))); // Overlap middle
assert!(span.intersects(&LineSpan::new(0, 10))); // Overlap up to end
assert!(span.intersects(&LineSpan::new(9, 10))); // Overlap at single point
assert!(span.intersects(&LineSpan::new(7, 9))); // Overlap inside
// Test cases where there should be no intersection due to exclusive end
assert!(!span.intersects(&LineSpan::new(0, 5))); // Before start
assert!(!span.intersects(&LineSpan::new(11, 20))); // After end
assert!(!span.intersects(&LineSpan::new(11, 12))); // Just after end
}