All checks were successful
Check / build (pull_request) Successful in 9m54s
345 lines
11 KiB
Rust
345 lines
11 KiB
Rust
use reconcile_text::{
|
|
BuiltinTokenizer, CursorPosition, EditedText, TextWithCursors, Token, reconcile,
|
|
};
|
|
use test_case::{test_case, test_matrix};
|
|
|
|
#[test_matrix([
|
|
BuiltinTokenizer::Character,
|
|
BuiltinTokenizer::Line,
|
|
BuiltinTokenizer::Markdown,
|
|
BuiltinTokenizer::Word
|
|
])]
|
|
fn identical_edits_preserve_text(tokenizer: BuiltinTokenizer) {
|
|
let texts = [
|
|
"",
|
|
"a",
|
|
"b\n",
|
|
"\r\n\r\n",
|
|
"# Héllo\n\n- 世界\n",
|
|
"a a a\nb b b\n",
|
|
];
|
|
for parent in texts {
|
|
for updated in texts {
|
|
let input = updated.into();
|
|
assert_eq!(
|
|
reconcile(parent, &input, &input, &*tokenizer)
|
|
.apply()
|
|
.text(),
|
|
updated,
|
|
"tokenizer={tokenizer:?}, parent={parent:?}, updated={updated:?}",
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test_matrix([
|
|
BuiltinTokenizer::Character,
|
|
BuiltinTokenizer::Line,
|
|
BuiltinTokenizer::Markdown,
|
|
BuiltinTokenizer::Word
|
|
])]
|
|
fn identical_short_edits_preserve_text(tokenizer: BuiltinTokenizer) {
|
|
let mut texts = vec![String::new()];
|
|
let mut level = texts.clone();
|
|
for _ in 0..3 {
|
|
level = level
|
|
.iter()
|
|
.flat_map(|text| ['a', 'b', ' ', '\n'].map(|ch| format!("{text}{ch}")))
|
|
.collect();
|
|
texts.extend(level.iter().cloned());
|
|
}
|
|
|
|
for parent in &texts {
|
|
for updated in &texts {
|
|
let input = updated.into();
|
|
assert_eq!(
|
|
reconcile(parent, &input, &input, &*tokenizer)
|
|
.apply()
|
|
.text(),
|
|
*updated,
|
|
"tokenizer={tokenizer:?}, parent={parent:?}, updated={updated:?}",
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test_case("A", "a"; "lowercase")]
|
|
#[test_case("a", "A"; "uppercase")]
|
|
#[test_case("Hello WORLD", "HELLO world")]
|
|
#[test_case("X A", "x a\n")]
|
|
#[test_case("A", "new a"; "normalized_suffix")]
|
|
#[test_case("a old B", "A new b"; "normalized_prefix_and_suffix")]
|
|
fn identical_edits_preserve_original_spelling(parent: &str, updated: &str) {
|
|
let tokenizer = |text: &str| {
|
|
text.chars()
|
|
.map(|ch| Token::new(ch.to_ascii_lowercase(), ch.to_string(), false, false))
|
|
.collect()
|
|
};
|
|
let input = updated.into();
|
|
assert_eq!(
|
|
reconcile(parent, &input, &input, &tokenizer).apply().text(),
|
|
updated,
|
|
);
|
|
}
|
|
|
|
#[test_matrix([
|
|
BuiltinTokenizer::Character,
|
|
BuiltinTokenizer::Line,
|
|
BuiltinTokenizer::Markdown,
|
|
BuiltinTokenizer::Word
|
|
])]
|
|
fn replay_short_edits_preserves_merged_text(tokenizer: BuiltinTokenizer) {
|
|
let mut texts = vec![String::new()];
|
|
let mut level = texts.clone();
|
|
for _ in 0..2 {
|
|
level = level
|
|
.iter()
|
|
.flat_map(|text| ['a', 'b', ' ', '\n'].map(|ch| format!("{text}{ch}")))
|
|
.collect();
|
|
texts.extend(level.iter().cloned());
|
|
}
|
|
|
|
for parent in &texts {
|
|
for left in &texts {
|
|
for right in &texts {
|
|
let merged = reconcile(parent, &left.into(), &right.into(), &*tokenizer).apply();
|
|
assert_eq!(
|
|
reconcile(parent, &right.into(), &left.into(), &*tokenizer)
|
|
.apply()
|
|
.text(),
|
|
merged.text(),
|
|
"swapped inputs: tokenizer={tokenizer:?}, parent={parent:?}, left={left:?}, right={right:?}",
|
|
);
|
|
for replayed in [left, right] {
|
|
let actual = reconcile(parent, &merged, &replayed.into(), &*tokenizer).apply();
|
|
assert_eq!(
|
|
actual.text(),
|
|
merged.text(),
|
|
"tokenizer={tokenizer:?}, parent={parent:?}, left={left:?}, right={right:?}, replayed={replayed:?}"
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test_matrix(["", "a"], [
|
|
BuiltinTokenizer::Character,
|
|
BuiltinTokenizer::Line,
|
|
BuiltinTokenizer::Markdown,
|
|
BuiltinTokenizer::Word
|
|
])]
|
|
fn replay_conflicting_replacements_keeps_both_edits(parent: &str, tokenizer: BuiltinTokenizer) {
|
|
let left = TextWithCursors::new("b".to_owned(), vec![CursorPosition::new(1, 1)]);
|
|
let right = TextWithCursors::new("x".to_owned(), vec![CursorPosition::new(2, 1)]);
|
|
let merged = reconcile(parent, &left, &right, &*tokenizer).apply();
|
|
assert_eq!(merged.text(), "bx");
|
|
assert_eq!(
|
|
merged.cursors(),
|
|
vec![CursorPosition::new(1, 1), CursorPosition::new(2, 2)]
|
|
);
|
|
for input in ["b", "x"] {
|
|
let replayed = reconcile(parent, &merged, &input.into(), &*tokenizer).apply();
|
|
assert_eq!(replayed, merged);
|
|
}
|
|
let left_operations = EditedText::from_strings_with_tokenizer(parent, &left, &*tokenizer);
|
|
let right_operations = EditedText::from_strings_with_tokenizer(parent, &right, &*tokenizer);
|
|
assert_eq!(
|
|
left_operations
|
|
.merge_with_tokenizer(&right_operations, &*tokenizer)
|
|
.apply(),
|
|
merged
|
|
);
|
|
}
|
|
|
|
#[test_matrix([
|
|
BuiltinTokenizer::Character,
|
|
BuiltinTokenizer::Line,
|
|
BuiltinTokenizer::Markdown,
|
|
BuiltinTokenizer::Word
|
|
])]
|
|
fn replay_preserves_repeated_text_and_unicode_cursors(tokenizer: BuiltinTokenizer) {
|
|
let merged = reconcile("a", &"bb".into(), &"x".into(), &*tokenizer).apply();
|
|
assert_eq!(merged.text(), "bbx");
|
|
assert_eq!(
|
|
reconcile("a", &merged, &"x".into(), &*tokenizer).apply(),
|
|
merged
|
|
);
|
|
|
|
let left = TextWithCursors::new(
|
|
"🦊x".to_owned(),
|
|
vec![CursorPosition::new(1, 1), CursorPosition::new(2, 2)],
|
|
);
|
|
let right = TextWithCursors::new("x".to_owned(), vec![CursorPosition::new(3, 1)]);
|
|
let edited = reconcile("a", &left, &right, &*tokenizer);
|
|
let result = edited.apply();
|
|
assert_eq!(result.text(), "🦊x");
|
|
for (id, position) in [(1, 1), (2, 2), (3, 2)] {
|
|
assert!(
|
|
result
|
|
.cursors()
|
|
.contains(&CursorPosition::new(id, position))
|
|
);
|
|
}
|
|
let history_text: String = edited
|
|
.apply_with_history()
|
|
.iter()
|
|
.filter(|span| {
|
|
!matches!(
|
|
span.history(),
|
|
reconcile_text::History::RemovedFromLeft
|
|
| reconcile_text::History::RemovedFromRight
|
|
)
|
|
})
|
|
.map(reconcile_text::SpanWithHistory::text)
|
|
.collect();
|
|
assert_eq!(history_text, result.text());
|
|
assert_eq!(edited.apply_with_all().0, result);
|
|
}
|
|
|
|
#[test_matrix([
|
|
BuiltinTokenizer::Character,
|
|
BuiltinTokenizer::Line,
|
|
BuiltinTokenizer::Markdown,
|
|
BuiltinTokenizer::Word
|
|
])]
|
|
fn replay_longer_mixed_edits_is_stable(tokenizer: BuiltinTokenizer) {
|
|
let mut seed = 0x12ab_34cd_56ef_7890u64;
|
|
let mut next = || {
|
|
seed ^= seed << 13;
|
|
seed ^= seed >> 7;
|
|
seed ^= seed << 17;
|
|
seed
|
|
};
|
|
let alphabet = ['a', 'b', 'c', ' ', '\n', '\r', 'é', '🦊'];
|
|
for case in 0..2000 {
|
|
let mut text = || {
|
|
(0..next() % 16)
|
|
.map(|_| alphabet[usize::try_from(next() % 8).unwrap()])
|
|
.collect::<String>()
|
|
};
|
|
let parent = text();
|
|
let left = text();
|
|
let right = text();
|
|
let merged = reconcile(
|
|
&parent,
|
|
&left.clone().into(),
|
|
&right.clone().into(),
|
|
&*tokenizer,
|
|
)
|
|
.apply();
|
|
if case < 200 {
|
|
let left_with_cursors = TextWithCursors::new(
|
|
left.clone(),
|
|
(0..=left.chars().count())
|
|
.map(|position| CursorPosition::new(position, position))
|
|
.collect(),
|
|
);
|
|
let right_with_cursors = TextWithCursors::new(
|
|
right.clone(),
|
|
(0..=right.chars().count())
|
|
.map(|position| CursorPosition::new(100 + position, position))
|
|
.collect(),
|
|
);
|
|
let result = reconcile(
|
|
&parent,
|
|
&left_with_cursors,
|
|
&right_with_cursors,
|
|
&*tokenizer,
|
|
)
|
|
.apply();
|
|
assert_eq!(result.text(), merged.text());
|
|
assert_eq!(
|
|
result.cursors().len(),
|
|
left_with_cursors.cursors().len() + right_with_cursors.cursors().len()
|
|
);
|
|
assert!(
|
|
result
|
|
.cursors()
|
|
.iter()
|
|
.all(|cursor| cursor.char_index() <= result.text().chars().count())
|
|
);
|
|
for cursor in left_with_cursors
|
|
.cursors()
|
|
.into_iter()
|
|
.chain(right_with_cursors.cursors())
|
|
{
|
|
assert!(
|
|
result
|
|
.cursors()
|
|
.iter()
|
|
.any(|updated| updated.id() == cursor.id())
|
|
);
|
|
}
|
|
}
|
|
for input in [&left, &right] {
|
|
assert_eq!(
|
|
reconcile(&parent, &merged, &input.into(), &*tokenizer)
|
|
.apply()
|
|
.text(),
|
|
merged.text(),
|
|
"tokenizer={tokenizer:?}, parent={parent:?}, left={left:?}, right={right:?}, input={input:?}"
|
|
);
|
|
}
|
|
assert_eq!(
|
|
reconcile(&parent, &right.into(), &left.into(), &*tokenizer)
|
|
.apply()
|
|
.text(),
|
|
merged.text()
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn replay_with_normalized_tokens_preserves_spelling() {
|
|
let tokenizer = |text: &str| {
|
|
text.chars()
|
|
.map(|ch| Token::new(ch.to_ascii_lowercase(), ch.to_string(), false, false))
|
|
.collect()
|
|
};
|
|
let texts = ["", "A", "a", "AB", "ba", "Aa", "a A", " A", "\nA"];
|
|
for parent in texts {
|
|
for left in texts {
|
|
for right in texts {
|
|
let merged = reconcile(parent, &left.into(), &right.into(), &tokenizer).apply();
|
|
for input in [left, right] {
|
|
assert_eq!(
|
|
reconcile(parent, &merged, &input.into(), &tokenizer)
|
|
.apply()
|
|
.text(),
|
|
merged.text()
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn ambiguous_ancestral_text_prefers_a_stable_input() {
|
|
let parent = "a b ";
|
|
let left = "a b a b ".into();
|
|
let right = "c d ".into();
|
|
let tokenizer = &*BuiltinTokenizer::Word;
|
|
let result = reconcile(parent, &left, &right, tokenizer);
|
|
let merged = result.apply();
|
|
assert_eq!(merged.text(), "a b a b ");
|
|
for input in [&left, &right] {
|
|
assert_eq!(
|
|
reconcile(parent, &merged, input, tokenizer).apply().text(),
|
|
merged.text()
|
|
);
|
|
}
|
|
assert!(
|
|
result
|
|
.apply_with_history()
|
|
.iter()
|
|
.any(|span| span.history() == reconcile_text::History::AddedFromLeft)
|
|
);
|
|
assert!(
|
|
!result
|
|
.apply_with_history()
|
|
.iter()
|
|
.any(|span| span.history() == reconcile_text::History::AddedFromRight)
|
|
);
|
|
}
|