Skip to content

Commit 74e187b

Browse files
committed
test(mutants): kill planner check_* + type_narrowing surviving mutants
- type_narrowing: equal-length/Varchar->Char boundary + exact per-backend impact strings (verified 0 missed) - check_default: per-op boundary tests for apply_op_i64/f64/str/bool, evaluate_op + literal_equals arms, incl. exact-EPSILON (1.0+f64::EPSILON) cases for the float-tolerance < comparisons (verified 0 missed) - check_strengthening: AND/OR conjunct set-logic, BETWEEN narrowing bound tests, exact-EPSILON literal_equals (verified 0 missed); exclude the equivalent classify_strengthening || guard via mutants.toml - check_expr_parser: trailing-operator/incomplete-exponent EOF (buffer over-read guards) + 70-group sequential-paren depth-leak test
1 parent ddf34fa commit 74e187b

5 files changed

Lines changed: 426 additions & 0 deletions

File tree

.cargo/mutants.toml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,14 @@ exclude_re = [
7676
# are byte-identical for every renderer-reachable layout while the other
7777
# pick_anchors arithmetic remains in scope.
7878
"edges[.]rs:152(:[0-9]+)?: replace > with >= in pick_anchors",
79+
# E6: classify_strengthening's `||` guard
80+
# (`old==Unparseable || new==Unparseable -> return None`) is equivalent
81+
# under `&&`. When exactly one side is Unparseable the `&&` mutant skips
82+
# the early return, but `classify_pair(Unparseable, _)` / `(_, Unparseable)`
83+
# has no matching arm and falls to `_ => None` ? identical observable
84+
# result for every input. The two-unparseable case is also caught earlier
85+
# by `if old == new { return None }`. No test can distinguish the mutant.
86+
"check_strengthening[.]rs:233(:[0-9]+)?: replace [|][|] with && in classify_strengthening",
7987
]
8088

8189
# Focus on logic-heavy crates. Exclude:

crates/vespertide-planner/src/validate/check_expr_parser/tests/mod.rs

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -366,6 +366,44 @@ fn and_with_unparseable_second_operand_is_unparseable() {
366366
assert!(matches!(parse("a > 0 AND x > b"), CheckExpr::Unparseable));
367367
}
368368

369+
// A bare operator at end-of-input must not read `bytes[i+1]` out of bounds.
370+
// Pins the two-char-lookahead guard `i + 1 < bytes.len()` (a `-`/`*`/`<=`
371+
// mutant would index past the buffer and panic).
372+
#[test]
373+
fn trailing_operator_at_eof_is_unparseable_not_panic() {
374+
assert!(matches!(parse("age >"), CheckExpr::Unparseable));
375+
}
376+
377+
// Incomplete exponent at EOF in a SIGNED literal slot must not read the
378+
// exponent-sign byte out of bounds. Pins the `i < bytes.len() && ...` guard
379+
// in the signed-number branch (`<=` / `||` mutants index past the buffer).
380+
#[test]
381+
fn incomplete_signed_exponent_at_eof_is_unparseable_not_panic() {
382+
assert!(matches!(parse("x = -1e"), CheckExpr::Unparseable));
383+
}
384+
385+
// Same guard in the UNSIGNED-number branch.
386+
#[test]
387+
fn incomplete_unsigned_exponent_at_eof_is_unparseable_not_panic() {
388+
assert!(matches!(parse("x = 1e"), CheckExpr::Unparseable));
389+
}
390+
391+
// 70 sequential parenthesized groups never nest deeper than 1, so depth must
392+
// return to 0 after each. Pins `self.depth -= 1` in parse_atom: a `+=` or `/=`
393+
// mutant leaks depth, tripping the MAX_CHECK_EXPR_DEPTH (64) guard and
394+
// wrongly rejecting this valid expression.
395+
#[test]
396+
fn many_sequential_paren_groups_do_not_leak_depth() {
397+
let expr = std::iter::repeat("(c > 0)")
398+
.take(70)
399+
.collect::<Vec<_>>()
400+
.join(" OR ");
401+
assert!(
402+
!matches!(parse(&expr), CheckExpr::Unparseable),
403+
"70 flat OR groups must parse; depth must not leak"
404+
);
405+
}
406+
369407
#[rstest]
370408
#[case::null("col = NULL", Literal::Null)]
371409
#[case::bool_true("col = TRUE", Literal::Bool(true))]

crates/vespertide-planner/src/validate/check_strengthening.rs

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -503,6 +503,125 @@ mod tests {
503503
assert_eq!(warnings[0].kind, CheckStrengtheningKind::BoundaryTightened);
504504
}
505505

506+
// -- classify_pair AND/OR set logic boundary kills ---------------------
507+
508+
fn classify(old: &str, new: &str) -> Vec<CheckStrengtheningKind> {
509+
let baseline = baseline_with_check("t", "chk", old);
510+
let p = plan(vec![add_check("t", "chk", new)]);
511+
find_check_strengthenings(&p, &baseline)
512+
.into_iter()
513+
.map(|w| w.kind)
514+
.collect()
515+
}
516+
517+
#[test]
518+
fn and_conjunct_added_is_strengthening() {
519+
assert_eq!(
520+
classify("a > 0 AND b > 0", "a > 0 AND b > 0 AND c > 0"),
521+
vec![CheckStrengtheningKind::ConjunctAdded]
522+
);
523+
}
524+
525+
#[test]
526+
fn and_reordered_same_conjuncts_is_not_strengthening() {
527+
// Equal length, same set, reordered -> NOT a strengthening. Pins
528+
// `new.len() > old.len()` (a `>=` mutant would fire on equal length).
529+
assert!(classify("a > 0 AND b > 0", "b > 0 AND a > 0").is_empty());
530+
}
531+
532+
#[test]
533+
fn and_added_non_overlapping_conjuncts_is_not_strengthening() {
534+
// New is longer but does NOT contain every old conjunct -> not a
535+
// pure addition. Pins the `&&` (a `||` mutant would fire on length
536+
// alone) and the `np == op` all-present check (a `!=` mutant would
537+
// make all-present trivially true).
538+
assert!(classify("a > 0 AND b > 0", "a > 0 AND c > 0 AND d > 0").is_empty());
539+
}
540+
541+
#[test]
542+
fn or_disjunct_removed_is_strengthening() {
543+
assert_eq!(
544+
classify("a > 0 OR b > 0 OR c > 0", "a > 0 OR b > 0"),
545+
vec![CheckStrengtheningKind::DisjunctRemoved]
546+
);
547+
}
548+
549+
#[test]
550+
fn or_reordered_same_disjuncts_is_not_strengthening() {
551+
// Equal length, reordered -> not a removal. Pins `new.len() < old.len()`.
552+
assert!(classify("a > 0 OR b > 0", "b > 0 OR a > 0").is_empty());
553+
}
554+
555+
#[test]
556+
fn or_new_disjunct_not_in_old_is_not_strengthening() {
557+
// Fewer disjuncts but one is brand new (not in old) -> not a pure
558+
// removal. Pins the `&&` and the `op == np` subset check.
559+
assert!(classify("a > 0 OR b > 0 OR c > 0", "a > 0 OR d > 0").is_empty());
560+
}
561+
562+
// -- between_is_narrower boundary kills --------------------------------
563+
564+
#[test]
565+
fn between_tighten_low_only_is_narrower() {
566+
// [0,10] -> [5,10]: low tightened, high equal. Pins `lo_cmp ==
567+
// Ordering::Less` in any_strict (a `!=` mutant drops the only strict
568+
// inequality, making it report "not narrower").
569+
assert_eq!(
570+
classify("x BETWEEN 0 AND 10", "x BETWEEN 5 AND 10"),
571+
vec![CheckStrengtheningKind::BetweenNarrowed]
572+
);
573+
}
574+
575+
#[test]
576+
fn between_tighten_high_only_is_narrower() {
577+
// [0,10] -> [0,8]: high tightened, low equal. Pins `hi_cmp ==
578+
// Ordering::Greater` in any_strict (a `!=` mutant drops the only
579+
// strict inequality, making it report "not narrower").
580+
assert_eq!(
581+
classify("x BETWEEN 0 AND 10", "x BETWEEN 0 AND 8"),
582+
vec![CheckStrengtheningKind::BetweenNarrowed]
583+
);
584+
}
585+
586+
#[test]
587+
fn between_widened_low_tightened_high_is_not_narrower() {
588+
// [5,10] -> [0,8]: low widened (lo_ok=false), high tightened. Range
589+
// is NOT a subset, so not narrower. Pins both `&&`s in
590+
// `lo_ok && hi_ok && any_strict` (either `||` mutant would wrongly
591+
// report narrowing from the high-only tightening).
592+
assert!(classify("x BETWEEN 5 AND 10", "x BETWEEN 0 AND 8").is_empty());
593+
}
594+
595+
// -- literal_equals EPSILON-boundary kills -----------------------------
596+
// `1.0000000000000002` is exactly `1.0 + f64::EPSILON`. A literal exactly
597+
// EPSILON away is DISTINCT under the strict `(a-b).abs() < EPSILON`
598+
// tolerance, so the bound genuinely tightened (the `<=` mutant would call
599+
// the two literals equal and suppress the warning).
600+
601+
#[test]
602+
fn float_boundary_one_epsilon_tighter_is_tightening() {
603+
assert_eq!(
604+
classify("x > 1.0", "x > 1.0000000000000002"),
605+
vec![CheckStrengtheningKind::BoundaryTightened]
606+
);
607+
}
608+
609+
#[test]
610+
fn int_vs_float_boundary_one_epsilon_tighter_is_tightening() {
611+
assert_eq!(
612+
classify("x > 1", "x > 1.0000000000000002"),
613+
vec![CheckStrengtheningKind::BoundaryTightened]
614+
);
615+
}
616+
617+
#[test]
618+
fn float_vs_int_boundary_one_epsilon_apart_is_not_operator_tightening() {
619+
// `>= 1.0000000000000002` vs `> 1`: literals differ by exactly EPSILON,
620+
// so this is NOT the (Ge,Gt)-same-literal operator-tightening pattern.
621+
// Pins the (Float,Integer) EPSILON arm of literal_equals.
622+
assert!(classify("x >= 1.0000000000000002", "x > 1").is_empty());
623+
}
624+
506625
#[test]
507626
fn ge_boundary_tightened() {
508627
let baseline = baseline_with_check("t", "c", "x >= 1");

crates/vespertide-planner/src/validate/tests/check_default.rs

Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,205 @@ fn integer_default_violates_ne() {
157157
assert!(is_default_violates_check(&validate_one(table).unwrap_err()));
158158
}
159159

160+
// ---------------------------------------------------------------------------
161+
// Boundary kills: each comparison op exactly at its threshold so the
162+
// `<`/`>`/`==` and EPSILON-arithmetic mutations are distinguished.
163+
// ---------------------------------------------------------------------------
164+
165+
fn boundary_table(ty: SimpleColumnType, default: DefaultValue, expr: &str) -> TableDef {
166+
table_with(
167+
"t",
168+
col_with_default("v", ColumnType::Simple(ty), default),
169+
vec![check_constraint("c", expr)],
170+
)
171+
}
172+
173+
#[test]
174+
fn integer_default_equal_violates_lt_boundary() {
175+
// 5 < 5 is false (violates). Kills apply_op_i64 `< -> <=` and `< -> ==`.
176+
let t = boundary_table(SimpleColumnType::Integer, DefaultValue::Integer(5), "v < 5");
177+
assert!(is_default_violates_check(&validate_one(t).unwrap_err()));
178+
}
179+
180+
#[test]
181+
fn float_default_equal_violates_lt_boundary() {
182+
// 5.0 < 5.0 is false. Kills apply_op_f64 Lt `< -> <=`.
183+
let t = boundary_table(SimpleColumnType::Real, DefaultValue::Float(5.0), "v < 5.0");
184+
assert!(is_default_violates_check(&validate_one(t).unwrap_err()));
185+
}
186+
187+
#[test]
188+
fn float_default_equal_violates_gt_boundary() {
189+
// 5.0 > 5.0 is false. Kills apply_op_f64 Gt `> -> >=`.
190+
let t = boundary_table(SimpleColumnType::Real, DefaultValue::Float(5.0), "v > 5.0");
191+
assert!(is_default_violates_check(&validate_one(t).unwrap_err()));
192+
}
193+
194+
#[test]
195+
fn float_default_equal_satisfies_eq() {
196+
// (2-2).abs() < EPS is true. Kills apply_op_f64 Eq `- -> +`, `- -> /`,
197+
// and `< -> ==`.
198+
let t = boundary_table(SimpleColumnType::Real, DefaultValue::Float(2.0), "v = 2.0");
199+
assert!(validate_one(t).is_ok());
200+
}
201+
202+
#[test]
203+
fn float_opposite_sign_satisfies_ne() {
204+
// 2 <> -2: (2-(-2)).abs()=4 >= EPS true. Kills apply_op_f64 Ne `- -> +`
205+
// (where (2+(-2)).abs()=0 would wrongly report "equal").
206+
let t = boundary_table(
207+
SimpleColumnType::Real,
208+
DefaultValue::Float(2.0),
209+
"v <> -2.0",
210+
);
211+
assert!(validate_one(t).is_ok());
212+
}
213+
214+
#[test]
215+
fn float_zero_satisfies_ne_nonzero() {
216+
// 0 <> 5: (0-5).abs()=5 >= EPS true. Kills apply_op_f64 Ne `- -> /`
217+
// (where (0/5).abs()=0 would wrongly report "equal").
218+
let t = boundary_table(SimpleColumnType::Real, DefaultValue::Float(0.0), "v <> 5.0");
219+
assert!(validate_one(t).is_ok());
220+
}
221+
222+
#[test]
223+
fn string_default_equal_violates_lt_boundary() {
224+
// "'m'" < "'m'" is false. Kills apply_op_str `< -> <=` and `< -> ==`.
225+
let t = boundary_table(
226+
SimpleColumnType::Text,
227+
DefaultValue::String("'m'".into()),
228+
"v < 'm'",
229+
);
230+
assert!(is_default_violates_check(&validate_one(t).unwrap_err()));
231+
}
232+
233+
#[test]
234+
fn string_default_equal_violates_gt_boundary() {
235+
// "'m'" > "'m'" is false. Kills apply_op_str `> -> >=`.
236+
let t = boundary_table(
237+
SimpleColumnType::Text,
238+
DefaultValue::String("'m'".into()),
239+
"v > 'm'",
240+
);
241+
assert!(is_default_violates_check(&validate_one(t).unwrap_err()));
242+
}
243+
244+
#[test]
245+
fn bool_default_equal_violates_ne() {
246+
// true <> true is false (violates). Kills apply_op_bool delete of the
247+
// `Op::Ne` arm (which would fall through to `_ => true`).
248+
let t = boundary_table(
249+
SimpleColumnType::Boolean,
250+
DefaultValue::Bool(true),
251+
"v <> true",
252+
);
253+
assert!(is_default_violates_check(&validate_one(t).unwrap_err()));
254+
}
255+
256+
#[test]
257+
fn float_default_int_literal_violates_evaluate_op_arm() {
258+
// (Float, Integer) arm: 5.0 < 3 is false. Kills evaluate_op delete of the
259+
// `(Float, Integer)` arm (which would fall through to `_ => true`).
260+
let t = boundary_table(SimpleColumnType::Real, DefaultValue::Float(5.0), "v < 3");
261+
assert!(is_default_violates_check(&validate_one(t).unwrap_err()));
262+
}
263+
264+
#[test]
265+
fn integer_in_list_match_exercises_literal_equals_int_arm() {
266+
// 5 matches the IN list -> literal_equals (Int,Int) arm. Kills the
267+
// delete of that arm (-> `_ => false` -> not in list -> would violate).
268+
let t = boundary_table(
269+
SimpleColumnType::Integer,
270+
DefaultValue::Integer(5),
271+
"v IN (1, 5, 9)",
272+
);
273+
assert!(validate_one(t).is_ok());
274+
}
275+
276+
#[test]
277+
fn float_in_list_matches_exercise_literal_equals_float_arms() {
278+
// Float==Float, Int==Float, Float==Int IN-list matches. Each
279+
// `(a-b).abs() < EPS` distinguishes `< -> >` (0 > EPS would miss).
280+
assert!(
281+
validate_one(boundary_table(
282+
SimpleColumnType::Real,
283+
DefaultValue::Float(2.0),
284+
"v IN (2.0)"
285+
))
286+
.is_ok()
287+
);
288+
assert!(
289+
validate_one(boundary_table(
290+
SimpleColumnType::Integer,
291+
DefaultValue::Integer(2),
292+
"v IN (2.0)"
293+
))
294+
.is_ok()
295+
);
296+
assert!(
297+
validate_one(boundary_table(
298+
SimpleColumnType::Real,
299+
DefaultValue::Float(2.0),
300+
"v IN (2)"
301+
))
302+
.is_ok()
303+
);
304+
}
305+
306+
#[test]
307+
fn bool_in_list_match_exercises_literal_equals_bool_arm() {
308+
// true IN (true) -> literal_equals (Bool,Bool) `==`. Kills `== -> !=`.
309+
let t = boundary_table(
310+
SimpleColumnType::Boolean,
311+
DefaultValue::Bool(true),
312+
"v IN (true)",
313+
);
314+
assert!(validate_one(t).is_ok());
315+
}
316+
317+
// `1.0000000000000002` is exactly `1.0 + f64::EPSILON` (the next representable
318+
// double). A value exactly EPSILON away is treated as DISTINCT by the strict
319+
// `(a-b).abs() < EPSILON` tolerance, so the default violates an `=`/`IN` check.
320+
// These pin `<` against `<=` at the tolerance boundary (the `<=` mutant would
321+
// treat the pair as equal and wrongly pass).
322+
const ONE_PLUS_EPS: &str = "1.0000000000000002";
323+
324+
#[test]
325+
fn float_eq_at_exact_epsilon_distance_violates() {
326+
// apply_op_f64 Eq line: `(a-b).abs() < EPSILON`. Kills `< -> <=`.
327+
let expr = format!("v = {ONE_PLUS_EPS}");
328+
let t = boundary_table(SimpleColumnType::Real, DefaultValue::Float(1.0), &expr);
329+
assert!(is_default_violates_check(&validate_one(t).unwrap_err()));
330+
}
331+
332+
#[test]
333+
fn float_float_in_list_at_exact_epsilon_distance_misses() {
334+
// literal_equals (Float,Float) EPSILON line. Kills `< -> <=`.
335+
let expr = format!("v IN ({ONE_PLUS_EPS})");
336+
let t = boundary_table(SimpleColumnType::Real, DefaultValue::Float(1.0), &expr);
337+
assert!(is_default_violates_check(&validate_one(t).unwrap_err()));
338+
}
339+
340+
#[test]
341+
fn int_float_in_list_at_exact_epsilon_distance_misses() {
342+
// literal_equals (Integer,Float) EPSILON line. Kills `< -> <=`.
343+
let expr = format!("v IN ({ONE_PLUS_EPS})");
344+
let t = boundary_table(SimpleColumnType::Integer, DefaultValue::Integer(1), &expr);
345+
assert!(is_default_violates_check(&validate_one(t).unwrap_err()));
346+
}
347+
348+
#[test]
349+
fn float_int_in_list_at_exact_epsilon_distance_misses() {
350+
// literal_equals (Float,Integer) EPSILON line. Kills `< -> <=`.
351+
let t = boundary_table(
352+
SimpleColumnType::Real,
353+
DefaultValue::Float(1.000_000_000_000_000_2),
354+
"v IN (1)",
355+
);
356+
assert!(is_default_violates_check(&validate_one(t).unwrap_err()));
357+
}
358+
160359
// ---------------------------------------------------------------------------
161360
// Satisfied: every op passes when the default fits
162361
// ---------------------------------------------------------------------------

0 commit comments

Comments
 (0)