use super::*;
fn ops(segments: &[Segment]) -> Vec<(Op, &str)> {
segments.iter().map(|s| (s.op, s.text.as_str())).collect()
}
fn rebuilt_after(segments: &[Segment]) -> String {
segments
.iter()
.filter(|s| s.op != Op::Removed)
.map(|s| s.text.as_str())
.collect()
}
fn words_of(segments: &[Segment], skip: Op) -> Vec<String> {
segments
.iter()
.filter(|s| s.op != skip)
.flat_map(|s| {
s.text
.split_whitespace()
.map(str::to_owned)
.collect::<Vec<_>>()
})
.collect()
}
#[test]
fn identical_texts_are_one_same_segment() {
let segments = diff_words("the same text", "the same text");
assert_eq!(ops(&segments), [(Op::Same, "the same text")]);
}
#[test]
fn empty_sides() {
assert!(diff_words("", "").is_empty());
assert_eq!(
ops(&diff_words("", "new words")),
[(Op::Added, "new words")]
);
assert_eq!(
ops(&diff_words("old words", "")),
[(Op::Removed, "old words")]
);
}
#[test]
fn a_replaced_word() {
let segments = diff_words("revenue grew fast", "revenue rose fast");
assert_eq!(
ops(&segments),
[
(Op::Same, "revenue "),
(Op::Removed, "grew "),
(Op::Added, "rose "),
(Op::Same, "fast")
]
);
}
#[test]
fn whitespace_changes_are_not_changes() {
let segments = diff_words("one two\nthree", "one two three");
assert_eq!(ops(&segments), [(Op::Same, "one two three")]);
}
#[test]
fn numbers_and_contractions_stay_whole() {
let segments = diff_words("we don't grow 18% to $4.2M", "we don't grow 19% to $4.2M");
assert_eq!(
ops(&segments),
[
(Op::Same, "we don't grow "),
(Op::Removed, "18% "),
(Op::Added, "19% "),
(Op::Same, "to $4.2M")
]
);
}
#[test]
fn punctuation_is_its_own_token() {
let segments = diff_words("Hello, world.", "Hello world!");
assert_eq!(
ops(&segments),
[
(Op::Same, "Hello "),
(Op::Removed, ", "),
(Op::Same, "world"),
(Op::Removed, "."),
(Op::Added, "!")
]
);
}
#[test]
fn locked_ranges_are_atomic_and_reported_locked() {
let before = "Furthermore, Dana Okafor stated that revenue was $4.2M overall.";
let after = "Dana Okafor says revenue was $4.2M.";
let lock = |text: &str, needle: &str| {
let at = text.find(needle).unwrap();
at..at + needle.len()
};
let segments = diff_words_locked(
before,
after,
&[lock(before, "Dana Okafor"), lock(before, "$4.2M")],
&[lock(after, "Dana Okafor"), lock(after, "$4.2M")],
);
assert_eq!(
ops(&segments),
[
(Op::Removed, "Furthermore, "),
(Op::Locked, "Dana Okafor "),
(Op::Removed, "stated that "),
(Op::Added, "says "),
(Op::Same, "revenue was "),
(Op::Locked, "$4.2M"),
(Op::Removed, "overall"),
(Op::Same, "."),
]
);
}
#[test]
fn a_locked_range_that_changed_is_removed_and_added_whole() {
let before = "said “we doubled down” then";
let after = "said “we doubled up” then";
let quote = |t: &str| {
let s = t.find('“').unwrap();
let e = t.find('”').unwrap() + '”'.len_utf8();
s..e
};
let segments = diff_words_locked(before, after, &[quote(before)], &[quote(after)]);
assert_eq!(
ops(&segments),
[
(Op::Same, "said "),
(Op::Removed, "“we doubled down” "),
(Op::Added, "“we doubled up” "),
(Op::Same, "then")
]
);
}
#[test]
fn bad_ranges_are_ignored() {
let text = "Zoë said hi";
let bad = [0..100, 2..3, 4..4, 0..3, 1..5];
let segments = diff_words_locked(text, text, &bad, &bad);
assert_eq!(rebuilt_after(&segments), text);
}
#[test]
fn leading_whitespace_is_kept() {
let segments = diff_words(" indented", " indented now");
assert_eq!(rebuilt_after(&segments), " indented now");
}
#[test]
fn segments_serialise_snake_case() {
let json = serde_json::to_value(diff_words("a", "b")).unwrap();
assert_eq!(json[0]["op"], "removed");
assert_eq!(json[1]["op"], "added");
}
struct Lcg(u64);
impl Lcg {
fn next(&mut self) -> u64 {
self.0 = self
.0
.wrapping_mul(6_364_136_223_846_793_005)
.wrapping_add(1_442_695_040_888_963_407);
self.0 >> 33
}
fn text(&mut self, words: usize, alphabet: &[&str]) -> String {
let n = usize::try_from(self.next()).unwrap_or(0) % (words + 1);
(0..n)
.map(|_| alphabet[usize::try_from(self.next()).unwrap_or(0) % alphabet.len()])
.collect::<Vec<_>>()
.join(" ")
}
}
fn lcs_len(a: &[&str], b: &[&str]) -> usize {
let mut row = vec![0usize; b.len() + 1];
for x in a {
let mut diag = 0;
for (j, y) in b.iter().enumerate() {
let up = row[j + 1];
row[j + 1] = if x == y { diag + 1 } else { up.max(row[j]) };
diag = up;
}
}
row[b.len()]
}
#[test]
fn random_diffs_rebuild_both_sides_and_are_minimal() {
let alphabet = ["a", "b", "c", "d", "e", "the", "18%", "x"];
let mut rng = Lcg(42);
for _ in 0..2_000 {
let before = rng.text(30, &alphabet);
let after = rng.text(30, &alphabet);
let segments = diff_words(&before, &after);
let a: Vec<&str> = before.split_whitespace().collect();
let b: Vec<&str> = after.split_whitespace().collect();
assert_eq!(rebuilt_after(&segments), after, "{before:?} -> {after:?}");
assert_eq!(words_of(&segments, Op::Added), a, "{before:?} -> {after:?}");
assert_eq!(
words_of(&segments, Op::Removed),
b,
"{before:?} -> {after:?}"
);
let same: usize = segments
.iter()
.filter(|s| s.op == Op::Same)
.map(|s| s.text.split_whitespace().count())
.sum();
assert_eq!(
same,
lcs_len(&a, &b),
"not minimal: {before:?} -> {after:?}"
);
}
}
#[test]
fn a_long_full_rewrite_is_fast_and_linear_in_memory() {
let mut rng = Lcg(7);
let alphabet = [
"alpha", "beta", "gamma", "delta", "epsilon", "zeta", "eta", "theta",
];
let before = rng.text(10_000, &alphabet);
let after = rng.text(10_000, &alphabet);
let segments = diff_words(&before, &after);
assert_eq!(rebuilt_after(&segments), after);
}
#[test]
fn links_hashtags_and_caller_spans_from_the_guard_stay_whole() {
use cratefield_text_guard::{ExtraSpan, Guard};
fn mentions(text: &str) -> Vec<ExtraSpan> {
text.find("@[")
.and_then(|open| {
let close = open + text[open..].find(')')? + 1;
Some(vec![ExtraSpan::new(open..close, "mention")])
})
.unwrap_or_default()
}
let guard = Guard::new().with_extra(mentions);
let before =
"Read https://example.com/notes/v2-3 from @[Acme Co](urn:li:organization:12) #launch";
let after =
"From @[Acme Co](urn:li:organization:12): read https://example.com/notes/v2-3 #launch";
let segments = diff_words_locked(before, after, &guard.ranges(before), &guard.ranges(after));
let locked: Vec<_> = segments
.iter()
.filter(|s| s.op == Op::Locked)
.map(|s| s.text.trim_end())
.collect();
assert!(
locked.contains(&"https://example.com/notes/v2-3"),
"{segments:?}"
);
assert!(locked.contains(&"#launch"), "{segments:?}");
let mention = "@[Acme Co](urn:li:organization:12)";
for op in [Op::Removed, Op::Added] {
assert!(
segments
.iter()
.any(|s| s.op == op && s.text.contains(mention)),
"{segments:?}"
);
}
assert_eq!(rebuilt_after(&segments), after);
let changed =
"Read https://example.com/notes/v2-4 from @[Acme Co](urn:li:organization:12) #launch";
let segments = diff_words_locked(
before,
changed,
&guard.ranges(before),
&guard.ranges(changed),
);
assert_eq!(
ops(&segments)[..3],
[
(Op::Same, "Read "),
(Op::Removed, "https://example.com/notes/v2-3 "),
(Op::Added, "https://example.com/notes/v2-4 "),
]
);
}