| 1110 |
1110 |
|
assert!(!apply_rules_to_sample(&db, "h8").unwrap());
|
| 1111 |
1111 |
|
assert!(crate::tags::get_sample_tags(&db, "h8").unwrap().is_empty());
|
| 1112 |
1112 |
|
}
|
|
1113 |
+ |
|
|
1114 |
+ |
// ── Operator / field matrix ──
|
|
1115 |
+ |
//
|
|
1116 |
+ |
// These exercise `eval_condition` directly against a hand-built `RuleContext`,
|
|
1117 |
+ |
// covering the cross-product of value kind (string / numeric / boolean / list)
|
|
1118 |
+ |
// and operator, plus the missing-value and inapplicable-operator edges that
|
|
1119 |
+ |
// never reach a DB.
|
|
1120 |
+ |
|
|
1121 |
+ |
/// One condition against a context.
|
|
1122 |
+ |
fn eval(ctx: &RuleContext, field: RuleField, op: RuleOp, value: &str) -> bool {
|
|
1123 |
+ |
eval_condition(ctx, &cond(field, op, value))
|
|
1124 |
+ |
}
|
|
1125 |
+ |
|
|
1126 |
+ |
#[test]
|
|
1127 |
+ |
fn str_op_is_case_insensitive_over_all_string_ops() {
|
|
1128 |
+ |
// Positive ops fold case on both sides.
|
|
1129 |
+ |
assert_eq!(str_op(RuleOp::Contains, "Kick DRUM", "kick"), Some(true));
|
|
1130 |
+ |
assert_eq!(str_op(RuleOp::Contains, "snare", "KICK"), Some(false));
|
|
1131 |
+ |
assert_eq!(str_op(RuleOp::Equals, "WaV", "wav"), Some(true));
|
|
1132 |
+ |
assert_eq!(str_op(RuleOp::Equals, "wave", "wav"), Some(false));
|
|
1133 |
+ |
assert_eq!(str_op(RuleOp::StartsWith, "808_Kick", "808"), Some(true));
|
|
1134 |
+ |
assert_eq!(str_op(RuleOp::StartsWith, "kick", "808"), Some(false));
|
|
1135 |
+ |
assert_eq!(str_op(RuleOp::EndsWith, "loop.WAV", ".wav"), Some(true));
|
|
1136 |
+ |
assert_eq!(str_op(RuleOp::EndsWith, "loop.aif", ".wav"), Some(false));
|
|
1137 |
+ |
// Negative ops are the logical inverse.
|
|
1138 |
+ |
assert_eq!(str_op(RuleOp::NotContains, "snare", "kick"), Some(true));
|
|
1139 |
+ |
assert_eq!(str_op(RuleOp::NotContains, "Kick", "kick"), Some(false));
|
|
1140 |
+ |
assert_eq!(str_op(RuleOp::NotEquals, "snare", "kick"), Some(true));
|
|
1141 |
+ |
assert_eq!(str_op(RuleOp::NotEquals, "KICK", "kick"), Some(false));
|
|
1142 |
+ |
// Non-string ops are not str-applicable.
|
|
1143 |
+ |
for op in [RuleOp::Lt, RuleOp::Ge, RuleOp::IsTrue, RuleOp::Exists] {
|
|
1144 |
+ |
assert_eq!(str_op(op, "x", "y"), None, "{op:?} should not be str-applicable");
|
|
1145 |
+ |
}
|
|
1146 |
+ |
}
|
|
1147 |
+ |
|
|
1148 |
+ |
#[test]
|
|
1149 |
+ |
fn num_op_covers_every_comparison_and_bad_input() {
|
|
1150 |
+ |
assert!(num_op(RuleOp::Lt, 1.0, "2"));
|
|
1151 |
+ |
assert!(!num_op(RuleOp::Lt, 2.0, "2"));
|
|
1152 |
+ |
assert!(num_op(RuleOp::Le, 2.0, "2"));
|
|
1153 |
+ |
assert!(!num_op(RuleOp::Le, 3.0, "2"));
|
|
1154 |
+ |
assert!(num_op(RuleOp::Gt, 3.0, "2"));
|
|
1155 |
+ |
assert!(!num_op(RuleOp::Gt, 2.0, "2"));
|
|
1156 |
+ |
assert!(num_op(RuleOp::Ge, 2.0, "2"));
|
|
1157 |
+ |
assert!(!num_op(RuleOp::Ge, 1.0, "2"));
|
|
1158 |
+ |
assert!(num_op(RuleOp::Equals, 2.0, "2"));
|
|
1159 |
+ |
assert!(!num_op(RuleOp::Equals, 2.5, "2"));
|
|
1160 |
+ |
assert!(num_op(RuleOp::NotEquals, 2.5, "2"));
|
|
1161 |
+ |
assert!(!num_op(RuleOp::NotEquals, 2.0, "2"));
|
|
1162 |
+ |
// Whitespace in the operand is tolerated.
|
|
1163 |
+ |
assert!(num_op(RuleOp::Ge, 128.0, " 120 "));
|
|
1164 |
+ |
// Unparseable operand never matches, for any op.
|
|
1165 |
+ |
for op in [RuleOp::Lt, RuleOp::Le, RuleOp::Gt, RuleOp::Ge, RuleOp::Equals, RuleOp::NotEquals] {
|
|
1166 |
+ |
assert!(!num_op(op, 1.0, "notanumber"), "{op:?} should fail on bad operand");
|
|
1167 |
+ |
}
|
|
1168 |
+ |
// String-only ops are not numeric-applicable.
|
|
1169 |
+ |
assert!(!num_op(RuleOp::Contains, 1.0, "1"));
|
|
1170 |
+ |
assert!(!num_op(RuleOp::StartsWith, 1.0, "1"));
|
|
1171 |
+ |
}
|
|
1172 |
+ |
|
|
1173 |
+ |
#[test]
|
|
1174 |
+ |
fn num_op_equals_uses_relative_epsilon() {
|
|
1175 |
+ |
// Exact hits and values within the relative tolerance are equal.
|
|
1176 |
+ |
assert!(num_op(RuleOp::Equals, 44100.0, "44100"));
|
|
1177 |
+ |
assert!(num_op(RuleOp::Equals, 1_000_000.0, "1000000.00005"));
|
|
1178 |
+ |
assert!(!num_op(RuleOp::Equals, 1_000_000.0, "1000001"));
|
|
1179 |
+ |
}
|
|
1180 |
+ |
|
|
1181 |
+ |
#[test]
|
|
1182 |
+ |
fn string_field_present_matrix() {
|
|
1183 |
+ |
let ctx = RuleContext { name: "808 Kick.wav".into(), ..Default::default() };
|
|
1184 |
+ |
assert!(eval(&ctx, RuleField::Name, RuleOp::Contains, "kick"));
|
|
1185 |
+ |
assert!(!eval(&ctx, RuleField::Name, RuleOp::Contains, "snare"));
|
|
1186 |
+ |
assert!(eval(&ctx, RuleField::Name, RuleOp::StartsWith, "808"));
|
|
1187 |
+ |
assert!(eval(&ctx, RuleField::Name, RuleOp::EndsWith, ".wav"));
|
|
1188 |
+ |
assert!(eval(&ctx, RuleField::Name, RuleOp::NotContains, "snare"));
|
|
1189 |
+ |
assert!(eval(&ctx, RuleField::Name, RuleOp::NotEquals, "other"));
|
|
1190 |
+ |
assert!(eval(&ctx, RuleField::Name, RuleOp::Exists, ""));
|
|
1191 |
+ |
assert!(!eval(&ctx, RuleField::Name, RuleOp::NotExists, ""));
|
|
1192 |
+ |
// A numeric operator on a string field never matches.
|
|
1193 |
+ |
assert!(!eval(&ctx, RuleField::Name, RuleOp::Gt, "0"));
|
|
1194 |
+ |
assert!(!eval(&ctx, RuleField::Name, RuleOp::IsTrue, ""));
|
|
1195 |
+ |
}
|
|
1196 |
+ |
|
|
1197 |
+ |
#[test]
|
|
1198 |
+ |
fn string_field_missing_matrix() {
|
|
1199 |
+ |
// source_path is None: positive ops fail, negative ops hold, existence flips.
|
|
1200 |
+ |
let ctx = RuleContext::default();
|
|
1201 |
+ |
assert!(!eval(&ctx, RuleField::SourcePath, RuleOp::Contains, "x"));
|
|
1202 |
+ |
assert!(!eval(&ctx, RuleField::SourcePath, RuleOp::Equals, "x"));
|
|
1203 |
+ |
assert!(!eval(&ctx, RuleField::SourcePath, RuleOp::StartsWith, "x"));
|
|
1204 |
+ |
assert!(eval(&ctx, RuleField::SourcePath, RuleOp::NotContains, "x"));
|
|
1205 |
+ |
assert!(eval(&ctx, RuleField::SourcePath, RuleOp::NotEquals, "x"));
|
|
1206 |
+ |
assert!(!eval(&ctx, RuleField::SourcePath, RuleOp::Exists, ""));
|
|
1207 |
+ |
assert!(eval(&ctx, RuleField::SourcePath, RuleOp::NotExists, ""));
|
|
1208 |
+ |
}
|
|
1209 |
+ |
|
|
1210 |
+ |
#[test]
|
|
1211 |
+ |
fn numeric_field_present_and_missing_matrix() {
|
|
1212 |
+ |
let ctx = RuleContext { bpm: Some(128.0), ..Default::default() };
|
|
1213 |
+ |
assert!(eval(&ctx, RuleField::Bpm, RuleOp::Gt, "120"));
|
|
1214 |
+ |
assert!(eval(&ctx, RuleField::Bpm, RuleOp::Ge, "128"));
|
|
1215 |
+ |
assert!(eval(&ctx, RuleField::Bpm, RuleOp::Le, "128"));
|
|
1216 |
+ |
assert!(!eval(&ctx, RuleField::Bpm, RuleOp::Lt, "128"));
|
|
1217 |
+ |
assert!(eval(&ctx, RuleField::Bpm, RuleOp::Equals, "128"));
|
|
1218 |
+ |
assert!(eval(&ctx, RuleField::Bpm, RuleOp::NotEquals, "120"));
|
|
1219 |
+ |
assert!(eval(&ctx, RuleField::Bpm, RuleOp::Exists, ""));
|
|
1220 |
+ |
assert!(!eval(&ctx, RuleField::Bpm, RuleOp::NotExists, ""));
|
|
1221 |
+ |
// A string operator on a numeric field never matches.
|
|
1222 |
+ |
assert!(!eval(&ctx, RuleField::Bpm, RuleOp::Contains, "12"));
|
|
1223 |
+ |
|
|
1224 |
+ |
// Missing numeric: every comparison fails, only NotExists holds.
|
|
1225 |
+ |
let empty = RuleContext::default();
|
|
1226 |
+ |
for op in [RuleOp::Lt, RuleOp::Le, RuleOp::Gt, RuleOp::Ge, RuleOp::Equals, RuleOp::NotEquals] {
|
|
1227 |
+ |
assert!(!eval(&empty, RuleField::Bpm, op, "128"), "{op:?} on missing num");
|
|
1228 |
+ |
}
|
|
1229 |
+ |
assert!(!eval(&empty, RuleField::Bpm, RuleOp::Exists, ""));
|
|
1230 |
+ |
assert!(eval(&empty, RuleField::Bpm, RuleOp::NotExists, ""));
|
|
1231 |
+ |
}
|
|
1232 |
+ |
|
|
1233 |
+ |
#[test]
|
|
1234 |
+ |
fn boolean_field_matrix() {
|
|
1235 |
+ |
let t = RuleContext { is_loop: Some(true), ..Default::default() };
|
|
1236 |
+ |
let f = RuleContext { is_loop: Some(false), ..Default::default() };
|
|
1237 |
+ |
let n = RuleContext::default();
|
|
1238 |
+ |
assert!(eval(&t, RuleField::IsLoop, RuleOp::IsTrue, ""));
|
|
1239 |
+ |
assert!(!eval(&t, RuleField::IsLoop, RuleOp::IsFalse, ""));
|
|
1240 |
+ |
assert!(eval(&f, RuleField::IsLoop, RuleOp::IsFalse, ""));
|
|
1241 |
+ |
assert!(!eval(&f, RuleField::IsLoop, RuleOp::IsTrue, ""));
|
|
1242 |
+ |
assert!(eval(&t, RuleField::IsLoop, RuleOp::Exists, ""));
|
|
1243 |
+ |
assert!(eval(&n, RuleField::IsLoop, RuleOp::NotExists, ""));
|
|
1244 |
+ |
assert!(!eval(&n, RuleField::IsLoop, RuleOp::IsTrue, ""));
|
|
1245 |
+ |
assert!(!eval(&n, RuleField::IsLoop, RuleOp::IsFalse, ""));
|
|
1246 |
+ |
// Non-boolean operators never match a boolean field.
|
|
1247 |
+ |
assert!(!eval(&t, RuleField::IsLoop, RuleOp::Contains, "true"));
|
|
1248 |
+ |
assert!(!eval(&t, RuleField::IsLoop, RuleOp::Gt, "0"));
|
|
1249 |
+ |
}
|
|
1250 |
+ |
|
|
1251 |
+ |
#[test]
|
|
1252 |
+ |
fn list_field_matrix() {
|
|
1253 |
+ |
let ctx = RuleContext {
|
|
1254 |
+ |
tags: vec!["instrument.drum.kick".into(), "character.punchy".into()],
|
|
1255 |
+ |
..Default::default()
|
|
1256 |
+ |
};
|
|
1257 |
+ |
// Positive ops match if ANY element satisfies.
|
|
1258 |
+ |
assert!(eval(&ctx, RuleField::Tag, RuleOp::Contains, "drum"));
|
|
1259 |
+ |
assert!(eval(&ctx, RuleField::Tag, RuleOp::StartsWith, "instrument"));
|
|
1260 |
+ |
assert!(eval(&ctx, RuleField::Tag, RuleOp::Equals, "character.punchy"));
|
|
1261 |
+ |
assert!(!eval(&ctx, RuleField::Tag, RuleOp::Contains, "bass"));
|
|
1262 |
+ |
// Negative ops hold only when NO element matches the positive form.
|
|
1263 |
+ |
assert!(eval(&ctx, RuleField::Tag, RuleOp::NotContains, "bass"));
|
|
1264 |
+ |
assert!(!eval(&ctx, RuleField::Tag, RuleOp::NotContains, "drum"));
|
|
1265 |
+ |
assert!(eval(&ctx, RuleField::Tag, RuleOp::NotEquals, "nope"));
|
|
1266 |
+ |
assert!(!eval(&ctx, RuleField::Tag, RuleOp::NotEquals, "character.punchy"));
|
|
1267 |
+ |
// Existence tracks emptiness.
|
|
1268 |
+ |
assert!(eval(&ctx, RuleField::Tag, RuleOp::Exists, ""));
|
|
1269 |
+ |
assert!(!eval(&ctx, RuleField::Tag, RuleOp::NotExists, ""));
|
|
1270 |
+ |
|
|
1271 |
+ |
let empty = RuleContext::default();
|
|
1272 |
+ |
assert!(!eval(&empty, RuleField::Tag, RuleOp::Exists, ""));
|
|
1273 |
+ |
assert!(eval(&empty, RuleField::Tag, RuleOp::NotExists, ""));
|
|
1274 |
+ |
// A negative op over an empty list vacuously holds; a positive op does not.
|
|
1275 |
+ |
assert!(eval(&empty, RuleField::Tag, RuleOp::NotContains, "x"));
|
|
1276 |
+ |
assert!(!eval(&empty, RuleField::Tag, RuleOp::Contains, "x"));
|
|
1277 |
+ |
// Inapplicable operator on a list never matches.
|
|
1278 |
+ |
assert!(!eval(&ctx, RuleField::Tag, RuleOp::Gt, "0"));
|
|
1279 |
+ |
assert!(!eval(&ctx, RuleField::Tag, RuleOp::IsTrue, ""));
|
|
1280 |
+ |
}
|
|
1281 |
+ |
|
|
1282 |
+ |
#[test]
|
|
1283 |
+ |
fn match_mode_all_vs_any_over_conditions() {
|
|
1284 |
+ |
let ctx = RuleContext { name: "kick".into(), bpm: Some(90.0), ..Default::default() };
|
|
1285 |
+ |
let conds = vec![
|
|
1286 |
+ |
cond(RuleField::Name, RuleOp::Contains, "kick"), // true
|
|
1287 |
+ |
cond(RuleField::Bpm, RuleOp::Gt, "120"), // false
|
|
1288 |
+ |
];
|
|
1289 |
+ |
let rule = |mode| Rule {
|
|
1290 |
+ |
id: "r".into(),
|
|
1291 |
+ |
name: "r".into(),
|
|
1292 |
+ |
enabled: true,
|
|
1293 |
+ |
priority: 0,
|
|
1294 |
+ |
match_mode: mode,
|
|
1295 |
+ |
conditions: conds.clone(),
|
|
1296 |
+ |
actions: vec![],
|
|
1297 |
+ |
created_at: 0,
|
|
1298 |
+ |
};
|
|
1299 |
+ |
assert!(!rule_matches(&rule(MatchMode::All), &ctx));
|
|
1300 |
+ |
assert!(rule_matches(&rule(MatchMode::Any), &ctx));
|
|
1301 |
+ |
}
|
|
1302 |
+ |
|
|
1303 |
+ |
#[test]
|
|
1304 |
+ |
fn empty_conditions_match_unconditionally() {
|
|
1305 |
+ |
let rule = Rule {
|
|
1306 |
+ |
id: "r".into(),
|
|
1307 |
+ |
name: "r".into(),
|
|
1308 |
+ |
enabled: true,
|
|
1309 |
+ |
priority: 0,
|
|
1310 |
+ |
match_mode: MatchMode::All,
|
|
1311 |
+ |
conditions: vec![],
|
|
1312 |
+ |
actions: vec![],
|
|
1313 |
+ |
created_at: 0,
|
|
1314 |
+ |
};
|
|
1315 |
+ |
assert!(rule_matches(&rule, &RuleContext::default()));
|
|
1316 |
+ |
}
|
| 1113 |
1317 |
|
}
|