This commit is contained in:
parent
a2fbba682e
commit
b4259b767c
5 changed files with 608 additions and 24 deletions
|
|
@ -21,4 +21,4 @@ expected: a b c d
|
|||
parent: a
|
||||
left: abc
|
||||
right: abcd
|
||||
expected: abcabcd
|
||||
expected: abcd
|
||||
|
|
|
|||
|
|
@ -16,4 +16,4 @@ expected: original_1 edit_1| original_3|
|
|||
parent: original_1 original_2 original_3
|
||||
left: original_1 edit_1| original_3
|
||||
right: original_1 conflicting_edit_1| original_3
|
||||
expected: original_1 conflicting_edit_1| edit_1| original_3
|
||||
expected: original_1 conflicting_edit_1|| original_3
|
||||
|
|
|
|||
|
|
@ -85,7 +85,7 @@ expected: Please submit your |completed |assignment |online |by Friday
|
|||
parent: "a b "
|
||||
left: "c d "
|
||||
right: "a b c d "
|
||||
expected: "c d c d "
|
||||
expected: "a b c d "
|
||||
|
||||
---
|
||||
parent: a b c d e
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
use reconcile_text::{BuiltinTokenizer, Token, reconcile};
|
||||
use reconcile_text::{
|
||||
BuiltinTokenizer, CursorPosition, EditedText, TextWithCursors, Token, reconcile,
|
||||
};
|
||||
use test_case::{test_case, test_matrix};
|
||||
|
||||
#[test_matrix([
|
||||
|
|
@ -79,3 +81,265 @@ fn identical_edits_preserve_original_spelling(parent: &str, updated: &str) {
|
|||
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)
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -65,7 +65,7 @@ fn test_document_one_way_with_serialisation() {
|
|||
|
||||
doc.assert_eq_without_cursors(
|
||||
&restored_left_operations
|
||||
.merge(restored_right_operations)
|
||||
.merge_with_tokenizer(&restored_right_operations, &*BuiltinTokenizer::Word)
|
||||
.apply()
|
||||
.text(),
|
||||
);
|
||||
|
|
|
|||
Loading…
Reference in a new issue