| 528 |
528 |
|
if d <= max { Some(d) } else { None }
|
| 529 |
529 |
|
}
|
| 530 |
530 |
|
|
|
531 |
+ |
#[cfg(test)] pub fn tagtree_test_dist(a:&str,b:&str)->Option<usize>{edit_distance(a,b,2)}
|
|
532 |
+ |
|
| 531 |
533 |
|
// ---------------------------------------------------------------------------
|
| 532 |
534 |
|
// Bulk tag operations
|
| 533 |
535 |
|
// ---------------------------------------------------------------------------
|
| 1867 |
1869 |
|
assert_eq!(batch_rename(&ops, &mut idx), 2);
|
| 1868 |
1870 |
|
assert!(idx.contains("x.y.c"));
|
| 1869 |
1871 |
|
}
|
|
1872 |
+ |
|
|
1873 |
+ |
// ──────────────────────────────────────────────────────────────────────
|
|
1874 |
+ |
// Mutation-coverage tests — added to close gaps surfaced by cargo-mutants.
|
|
1875 |
+ |
// Each test pins down a specific decision boundary that prior tests did
|
|
1876 |
+ |
// not constrain. See `_meta/remediation_todo.md` § C1 for context.
|
|
1877 |
+ |
// ──────────────────────────────────────────────────────────────────────
|
|
1878 |
+ |
|
|
1879 |
+ |
#[test]
|
|
1880 |
+ |
fn tag_error_display_includes_message() {
|
|
1881 |
+ |
// Catches `<impl fmt::Display for TagError>::fmt -> Ok(Default::default())`.
|
|
1882 |
+ |
// Without this test, the Display impl was never exercised.
|
|
1883 |
+ |
let err = TagError("bad thing".into());
|
|
1884 |
+ |
let s = format!("{err}");
|
|
1885 |
+ |
assert!(s.contains("bad thing"), "display output: {s:?}");
|
|
1886 |
+ |
assert!(s.starts_with("invalid tag:"), "display output: {s:?}");
|
|
1887 |
+ |
}
|
|
1888 |
+ |
|
|
1889 |
+ |
#[test]
|
|
1890 |
+ |
fn tag_index_is_empty_distinguishes_states() {
|
|
1891 |
+ |
// Catches `TagIndex::is_empty -> bool with true` (always-true mutation).
|
|
1892 |
+ |
let empty = TagIndex::new(Vec::<String>::new());
|
|
1893 |
+ |
assert!(empty.is_empty());
|
|
1894 |
+ |
|
|
1895 |
+ |
let non_empty = TagIndex::new(vec!["genre.rock".into()]);
|
|
1896 |
+ |
assert!(!non_empty.is_empty(), "non-empty index must report not-empty");
|
|
1897 |
+ |
}
|
|
1898 |
+ |
|
|
1899 |
+ |
#[test]
|
|
1900 |
+ |
fn suggest_fuzzy_keeps_searching_after_non_zero_match() {
|
|
1901 |
+ |
// Catches L902 `if best == 0 { break; }` mutation to `!=`.
|
|
1902 |
+ |
// Under the mutant, the loop exits the moment any segment returns ANY
|
|
1903 |
+ |
// distance (zero or not), so a later segment that would give a *better*
|
|
1904 |
+ |
// (lower) score is never examined. That changes the score and therefore
|
|
1905 |
+ |
// the sort order across tags.
|
|
1906 |
+ |
//
|
|
1907 |
+ |
// Setup:
|
|
1908 |
+ |
// tag1 = "abxy.xz" — input "xy"
|
|
1909 |
+ |
// orig: "abxy" dist 2 (continue), "xz" dist 1 → best=1
|
|
1910 |
+ |
// mut: "abxy" dist 2 → break → best=2
|
|
1911 |
+ |
// tag2 = "aaaa.bb" — input "xy"
|
|
1912 |
+ |
// both: "aaaa" None, "bb" dist 2 → best=2
|
|
1913 |
+ |
//
|
|
1914 |
+ |
// Original ordering by score: [abxy.xz (1), aaaa.bb (2)]
|
|
1915 |
+ |
// Mutant ordering by score: [aaaa.bb (2), abxy.xz (2)] (alphabetical tiebreak)
|
|
1916 |
+ |
//
|
|
1917 |
+ |
// Neither tag is captured by tier 1 (no path prefix matches "xy") nor
|
|
1918 |
+ |
// tier 2 (no segment starts with "xy"), so both flow through tier 3.
|
|
1919 |
+ |
let idx = TagIndex::new(vec!["abxy.xz".into(), "aaaa.bb".into()]);
|
|
1920 |
+ |
let results = idx.suggest_fuzzy("xy", 10);
|
|
1921 |
+ |
assert_eq!(
|
|
1922 |
+ |
results[0], "abxy.xz",
|
|
1923 |
+ |
"abxy.xz should rank first because its second segment is distance 1 from \"xy\"; \
|
|
1924 |
+ |
got {results:?}"
|
|
1925 |
+ |
);
|
|
1926 |
+ |
}
|
|
1927 |
+ |
|
|
1928 |
+ |
#[test]
|
|
1929 |
+ |
fn suggest_fuzzy_distance_one_via_segment_typo() {
|
|
1930 |
+ |
// Catches L518 edit_distance arithmetic mutation `row[i - 1]` → `row[i / 1]`
|
|
1931 |
+ |
// (which equals `row[i]`, breaking the DP recurrence).
|
|
1932 |
+ |
//
|
|
1933 |
+ |
// With the broken DP, the computed Levenshtein distance changes; with
|
|
1934 |
+ |
// a one-substitution typo against a known-distance segment, the mutant
|
|
1935 |
+ |
// would either reject the candidate (distance > 2) or accept a wrong
|
|
1936 |
+ |
// candidate that should have been rejected.
|
|
1937 |
+ |
//
|
|
1938 |
+ |
// Test pair: "gerne" → "genre" is a single transposition (distance 2:
|
|
1939 |
+ |
// delete 'r', insert 'r' at the other position; or two substitutions).
|
|
1940 |
+ |
// "junk" vs every segment is distance ≥ 3, must NOT appear.
|
|
1941 |
+ |
let idx = TagIndex::new(vec!["genre.rock".into(), "junk.bar".into()]);
|
|
1942 |
+ |
let results = idx.suggest_fuzzy("gerne", 10);
|
|
1943 |
+ |
assert!(
|
|
1944 |
+ |
results.contains(&"genre.rock"),
|
|
1945 |
+ |
"gerne should fuzzy-match genre.rock; got {results:?}"
|
|
1946 |
+ |
);
|
|
1947 |
+ |
assert!(
|
|
1948 |
+ |
!results.contains(&"junk.bar"),
|
|
1949 |
+ |
"junk.bar is distance >2 from gerne, must not match; got {results:?}"
|
|
1950 |
+ |
);
|
|
1951 |
+ |
}
|
|
1952 |
+ |
|
|
1953 |
+ |
#[test]
|
|
1954 |
+ |
fn suggest_fuzzy_rejects_distance_above_two() {
|
|
1955 |
+ |
// Companion to the above. Ensures the threshold ≤ 2 is enforced (no
|
|
1956 |
+ |
// tag farther than that should appear from fuzzy matching). A broken
|
|
1957 |
+ |
// edit_distance under L518 might erroneously return Some(n) for n > 2.
|
|
1958 |
+ |
let idx = TagIndex::new(vec!["abcdefg".into(), "hijklmn".into()]);
|
|
1959 |
+ |
// "xy" is distance 7 from "abcdefg" and 7 from "hijklmn" — neither
|
|
1960 |
+ |
// can match under any correct implementation with threshold 2.
|
|
1961 |
+ |
let results = idx.suggest_fuzzy("xy", 10);
|
|
1962 |
+ |
assert!(
|
|
1963 |
+ |
results.is_empty(),
|
|
1964 |
+ |
"no fuzzy match should exist for distance ≫ 2; got {results:?}"
|
|
1965 |
+ |
);
|
|
1966 |
+ |
}
|
|
1967 |
+ |
|
|
1968 |
+ |
#[test]
|
|
1969 |
+ |
fn suggest_fuzzy_handles_rotation_distance_two() {
|
|
1970 |
+ |
// Targeted at L518 `row[i - 1]` → `row[i / 1]` (= `row[i]`) mutation.
|
|
1971 |
+ |
//
|
|
1972 |
+ |
// The mutation drops the "delete from `a`" axis of the Levenshtein DP.
|
|
1973 |
+ |
// The broken DP is ARGUMENT-ORDER DEPENDENT — `edit_distance("abc", "cab")`
|
|
1974 |
+ |
// computes the wrong value while `edit_distance("cab", "abc")` happens
|
|
1975 |
+ |
// to be correct. The function is called as `edit_distance(input, seg, 2)`,
|
|
1976 |
+ |
// so the test must pick (input, segment) such that `input` is the side
|
|
1977 |
+ |
// where the broken DP fails.
|
|
1978 |
+ |
//
|
|
1979 |
+ |
// Empirically (verified by hand-trace + manual mutation):
|
|
1980 |
+ |
// edit_distance("abc", "cab", 2) — correct: Some(2); mutant: None
|
|
1981 |
+ |
//
|
|
1982 |
+ |
// So: input = "abc", a tag whose segment is "cab".
|
|
1983 |
+ |
let idx = TagIndex::new(vec!["cab.x".into()]);
|
|
1984 |
+ |
let results = idx.suggest_fuzzy("abc", 10);
|
|
1985 |
+ |
assert!(
|
|
1986 |
+ |
results.contains(&"cab.x"),
|
|
1987 |
+ |
"abc should fuzzy-match cab.x via segment 'cab' (distance 2); got {results:?}"
|
|
1988 |
+ |
);
|
|
1989 |
+ |
}
|
| 1870 |
1990 |
|
}
|