Skip to main content

max / makenotwork

Add tests to kill surviving mutants in shared crates Mutation-coverage pass against docengine, tagtree, and theme-common. Each test pins a specific decision boundary that prior tests left unconstrained — Display impls, is_empty distinguishers, http vs https arms, edit_distance arithmetic, dev_themes_dir Some/None split, etc. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-05-15 17:13 UTC
Commit: 6fc1e1e5cc6d5c9b4056cdd383c25bcd2945b987
Parent: 3259753
5 files changed, +226 insertions, -1 deletion
@@ -317,6 +317,43 @@ mod tests {
317 317 }
318 318
319 319 #[test]
320 + fn rewrite_preserves_plain_http_urls() {
321 + // Distinct from https — catches the `url.starts_with("http://")` arm
322 + // mutation (L244 `||` → `&&`). Without this case, the only protocol
323 + // tested is https, leaving the http arm uncovered.
324 + let md = "Visit [legacy](http://example.com) today.";
325 + let result = rewrite_links(md, "/docs", Some("unpublished/"));
326 + assert_eq!(result, md);
327 + }
328 +
329 + #[test]
330 + fn rewrite_preserves_external_md_links() {
331 + // Absolute URLs that happen to end in .md must NOT be rewritten.
332 + // This catches the L244 `||` → `&&` mutation: under the mutant, the
333 + // early-return short-circuit fails (since one URL can't both start
334 + // with "http://" AND "https://"), so the URL falls through to the
335 + // .md-rewrite path and gets incorrectly mangled.
336 + let md_http = "See [external](http://example.com/foo.md).";
337 + assert_eq!(
338 + rewrite_links(md_http, "/docs", Some("unpublished/")),
339 + md_http,
340 + "http:// + .md must be preserved"
341 + );
342 + let md_https = "See [external](https://example.com/foo.md).";
343 + assert_eq!(
344 + rewrite_links(md_https, "/docs", Some("unpublished/")),
345 + md_https,
346 + "https:// + .md must be preserved"
347 + );
348 + let md_mailto = "Email [us](mailto:a@b.md).";
349 + assert_eq!(
350 + rewrite_links(md_mailto, "/docs", Some("unpublished/")),
351 + md_mailto,
352 + "mailto: + .md must be preserved"
353 + );
354 + }
355 +
356 + #[test]
320 357 fn rewrite_preserves_mailto() {
321 358 let md = "Email [us](mailto:test@example.com)";
322 359 let result = rewrite_links(md, "/docs", Some("unpublished/"));
@@ -61,7 +61,10 @@ mod tests {
61 61 let fm = fm.unwrap();
62 62 assert_eq!(fm.title.as_deref(), Some("Hello"));
63 63 assert_eq!(fm.date.as_deref(), Some("2026-01-01"));
64 - assert!(rest.contains("# Body"));
64 + // Exact match — `rest.contains("# Body")` would pass even if rest were
65 + // the entire input, so it's too loose to catch L38 arithmetic mutations
66 + // on `rest_offset`. Pinning the exact slice tightens the boundary.
67 + assert_eq!(rest, "\n# Body");
65 68 }
66 69
67 70 #[test]
@@ -69,3 +69,39 @@ pub fn render_strict(markdown: &str) -> String {
69 69 pub fn sanitize_html(html: &str) -> String {
70 70 Renderer::sanitize_only().sanitize_html(html)
71 71 }
72 +
73 + #[cfg(test)]
74 + mod top_level_tests {
75 + use super::*;
76 +
77 + // Direct unit tests for the top-level convenience wrappers. Without these,
78 + // mutating any of them to return `String::new()` or a sentinel passes the
79 + // suite (the wrappers were untested at the function level).
80 +
81 + #[test]
82 + fn render_permissive_emits_paragraph_markup() {
83 + let out = render_permissive("Hello **world**.");
84 + assert!(out.contains("<p>"), "expected paragraph: {out:?}");
85 + assert!(out.contains("<strong>world</strong>"), "expected bold: {out:?}");
86 + }
87 +
88 + #[test]
89 + fn render_standard_strips_images() {
90 + let out = render_standard("![alt](pic.png)");
91 + assert!(!out.contains("<img"), "standard preset must strip images: {out:?}");
92 + }
93 +
94 + #[test]
95 + fn render_strict_strips_images_and_raw_html() {
96 + let out = render_strict("Hi <script>evil()</script> ![x](y.png)");
97 + assert!(!out.contains("<img"), "strict must strip images: {out:?}");
98 + assert!(!out.contains("<script"), "strict must strip scripts: {out:?}");
99 + }
100 +
101 + #[test]
102 + fn sanitize_html_strips_dangerous_tags() {
103 + let out = sanitize_html("<p>safe</p><script>bad()</script>");
104 + assert!(out.contains("safe"), "kept text: {out:?}");
105 + assert!(!out.contains("<script"), "stripped script: {out:?}");
106 + }
107 + }
@@ -528,6 +528,8 @@ fn edit_distance(a: &str, b: &str, max: usize) -> Option<usize> {
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,4 +1869,122 @@ mod tests {
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 }
@@ -481,6 +481,35 @@ primary = "#ff0000"
481 481 }
482 482
483 483 #[test]
484 + fn dev_themes_dir_returns_some_when_structure_exists() {
485 + // Construct a fake tree: <tmp>/leaf where <tmp>/MNW/shared/themes exists.
486 + // Walking up 1 level from `leaf` should locate the themes dir.
487 + // Catches `dev_themes_dir -> Option<PathBuf> with None` (always-None mutant).
488 + let root = tempfile::tempdir().unwrap();
489 + let leaf = root.path().join("leaf");
490 + std::fs::create_dir_all(&leaf).unwrap();
491 + let themes = root.path().join("MNW").join("shared").join("themes");
492 + std::fs::create_dir_all(&themes).unwrap();
493 +
494 + let result = dev_themes_dir(&leaf, 1);
495 + // Compare canonical forms — tempfile may return paths with /private prefix on macOS.
496 + let result_canon = result.expect("expected Some").canonicalize().unwrap();
497 + let themes_canon = themes.canonicalize().unwrap();
498 + assert_eq!(result_canon, themes_canon);
499 + }
500 +
501 + #[test]
502 + fn dev_themes_dir_returns_none_when_structure_missing() {
503 + // No MNW/shared/themes under the temp root → None.
504 + let root = tempfile::tempdir().unwrap();
505 + let leaf = root.path().join("leaf");
506 + std::fs::create_dir_all(&leaf).unwrap();
507 + // No themes dir created.
508 + let result = dev_themes_dir(&leaf, 1);
509 + assert!(result.is_none());
510 + }
511 +
512 + #[test]
484 513 fn import_theme_valid() {
485 514 let src_dir = tempfile::tempdir().unwrap();
486 515 let custom_dir = tempfile::tempdir().unwrap();