Skip to main content

max / audiofiles

17.0 KB · 521 lines History Blame Raw
1 //! Tests for [`super`].
2
3 use super::*;
4
5 fn db_with_sample(hash: &str, name: &str) -> Database {
6 let db = Database::open_in_memory().unwrap();
7 db.conn()
8 .execute(
9 "INSERT INTO samples (hash, original_name, file_extension, file_size, import_date, last_modified) \
10 VALUES (?1, ?2, 'wav', 1000, 0, 0)",
11 rusqlite::params![hash, name],
12 )
13 .unwrap();
14 db
15 }
16
17 fn cond(field: RuleField, op: RuleOp, value: &str) -> RuleCondition {
18 RuleCondition {
19 field,
20 op,
21 value: value.to_string(),
22 }
23 }
24
25 fn new_rule(name: &str, conds: Vec<RuleCondition>, acts: Vec<RuleAction>) -> NewRule {
26 NewRule {
27 name: name.to_string(),
28 enabled: true,
29 priority: None,
30 match_mode: MatchMode::All,
31 conditions: conds,
32 actions: acts,
33 }
34 }
35
36 #[test]
37 fn name_contains_applies_tag() {
38 let db = db_with_sample("h1", "808 Kick Loud.wav");
39 create_rule(
40 &db,
41 new_rule(
42 "kicks",
43 vec![cond(RuleField::Name, RuleOp::Contains, "kick")],
44 vec![RuleAction::AddTag("instrument.drum.kick".into())],
45 ),
46 )
47 .unwrap();
48
49 assert!(apply_rules_to_sample(&db, "h1").unwrap());
50 let tags = crate::tags::get_sample_tags(&db, "h1").unwrap();
51 assert_eq!(tags, vec!["instrument.drum.kick"]);
52
53 let prov = sample_tag_provenance(&db, "h1").unwrap();
54 assert_eq!(prov.len(), 1);
55 assert_eq!(prov[0].1, "rule");
56 }
57
58 #[test]
59 fn numeric_condition_on_analysis() {
60 let db = db_with_sample("h2", "loop.wav");
61 db.conn()
62 .execute(
63 "INSERT INTO audio_analysis (hash, duration, sample_rate, channels, bpm, analyzed_at) \
64 VALUES ('h2', 4.0, 44100, 2, 128.0, 0)",
65 [],
66 )
67 .unwrap();
68 create_rule(
69 &db,
70 new_rule(
71 "fast",
72 vec![cond(RuleField::Bpm, RuleOp::Ge, "120")],
73 vec![RuleAction::AddTag("tempo.fast".into())],
74 ),
75 )
76 .unwrap();
77 apply_rules_to_sample(&db, "h2").unwrap();
78 assert!(
79 crate::tags::get_sample_tags(&db, "h2")
80 .unwrap()
81 .contains(&"tempo.fast".to_string())
82 );
83 }
84
85 #[test]
86 fn manual_tags_are_sticky() {
87 let db = db_with_sample("h3", "kick.wav");
88 crate::tags::add_tag(&db, "h3", "manual.keep").unwrap();
89 let rule = create_rule(
90 &db,
91 new_rule(
92 "kicks",
93 vec![cond(RuleField::Name, RuleOp::Contains, "kick")],
94 vec![RuleAction::AddTag("instrument.drum.kick".into())],
95 ),
96 )
97 .unwrap();
98 apply_rules_to_sample(&db, "h3").unwrap();
99
100 // Deleting the rule must remove its tag but keep the manual one.
101 delete_rule(&db, &rule.id).unwrap();
102 let tags = crate::tags::get_sample_tags(&db, "h3").unwrap();
103 assert_eq!(tags, vec!["manual.keep"]);
104 }
105
106 #[test]
107 fn reconcile_removes_tags_when_rule_no_longer_matches() {
108 let db = db_with_sample("h4", "kick.wav");
109 let mut rule = create_rule(
110 &db,
111 new_rule(
112 "kicks",
113 vec![cond(RuleField::Name, RuleOp::Contains, "kick")],
114 vec![RuleAction::AddTag("instrument.drum.kick".into())],
115 ),
116 )
117 .unwrap();
118 apply_rules_to_sample(&db, "h4").unwrap();
119 assert!(!crate::tags::get_sample_tags(&db, "h4").unwrap().is_empty());
120
121 // Narrow the rule so it no longer matches, then reconcile.
122 rule.conditions = vec![cond(RuleField::Name, RuleOp::Contains, "snare")];
123 update_rule(&db, &rule).unwrap();
124 apply_rules_to_sample(&db, "h4").unwrap();
125 assert!(crate::tags::get_sample_tags(&db, "h4").unwrap().is_empty());
126 }
127
128 #[test]
129 fn toggling_enabled_reconciles_membership() {
130 let db = db_with_sample("h6", "kick.wav");
131 let rule = create_rule(
132 &db,
133 new_rule(
134 "kicks",
135 vec![cond(RuleField::Name, RuleOp::Contains, "kick")],
136 vec![RuleAction::AddTag("instrument.drum.kick".into())],
137 ),
138 )
139 .unwrap();
140 apply_rules_to_sample(&db, "h6").unwrap();
141 assert!(!crate::tags::get_sample_tags(&db, "h6").unwrap().is_empty());
142
143 // Disabling must remove the rule-sourced tag immediately (no separate
144 // apply_* call), not leave it stale.
145 set_rule_enabled(&db, &rule.id, false).unwrap();
146 assert!(crate::tags::get_sample_tags(&db, "h6").unwrap().is_empty());
147
148 // Re-enabling must re-apply it across the library, again without an
149 // explicit apply_* call.
150 set_rule_enabled(&db, &rule.id, true).unwrap();
151 assert!(
152 crate::tags::get_sample_tags(&db, "h6")
153 .unwrap()
154 .contains(&"instrument.drum.kick".to_string())
155 );
156 }
157
158 #[test]
159 fn update_unknown_rule_errors_not_resurrects() {
160 let db = db_with_sample("h7", "kick.wav");
161 let rule = create_rule(
162 &db,
163 new_rule(
164 "kicks",
165 vec![cond(RuleField::Name, RuleOp::Contains, "kick")],
166 vec![RuleAction::AddTag("instrument.drum.kick".into())],
167 ),
168 )
169 .unwrap();
170 delete_rule(&db, &rule.id).unwrap();
171
172 // Updating the now-deleted rule must error, not silently re-insert it.
173 assert!(matches!(
174 update_rule(&db, &rule),
175 Err(CoreError::RuleNotFound(_))
176 ));
177 assert!(get_rule(&db, &rule.id).unwrap().is_none());
178 }
179
180 #[test]
181 fn match_mode_any_vs_all() {
182 let db = db_with_sample("h5", "snare hit.wav");
183 let any = create_rule(
184 &db,
185 NewRule {
186 match_mode: MatchMode::Any,
187 ..new_rule(
188 "any",
189 vec![
190 cond(RuleField::Name, RuleOp::Contains, "kick"),
191 cond(RuleField::Name, RuleOp::Contains, "snare"),
192 ],
193 vec![RuleAction::AddTag("matched.any".into())],
194 )
195 },
196 )
197 .unwrap();
198 assert_eq!(preview_rule_matches(&db, &any).unwrap(), 1);
199
200 let all = Rule {
201 match_mode: MatchMode::All,
202 ..any
203 };
204 assert_eq!(preview_rule_matches(&db, &all).unwrap(), 0);
205 }
206
207 #[test]
208 fn stop_action_halts_later_rules() {
209 let db = db_with_sample("h6", "kick.wav");
210 create_rule(
211 &db,
212 NewRule {
213 priority: Some(0),
214 ..new_rule(
215 "first",
216 vec![],
217 vec![RuleAction::AddTag("a.first".into()), RuleAction::Stop],
218 )
219 },
220 )
221 .unwrap();
222 create_rule(
223 &db,
224 NewRule {
225 priority: Some(1),
226 ..new_rule(
227 "second",
228 vec![],
229 vec![RuleAction::AddTag("a.second".into())],
230 )
231 },
232 )
233 .unwrap();
234 apply_rules_to_sample(&db, "h6").unwrap();
235 let tags = crate::tags::get_sample_tags(&db, "h6").unwrap();
236 assert_eq!(tags, vec!["a.first"]);
237 }
238
239 #[test]
240 fn rules_round_trip_through_db() {
241 let db = db_with_sample("h7", "x.wav");
242 let created = create_rule(
243 &db,
244 new_rule(
245 "complex",
246 vec![
247 cond(RuleField::SpectralFlatness, RuleOp::Lt, "0.2"),
248 cond(RuleField::Tag, RuleOp::StartsWith, "instrument.drum"),
249 ],
250 vec![
251 RuleAction::AddTag("character.tonal".into()),
252 RuleAction::Stop,
253 ],
254 ),
255 )
256 .unwrap();
257 let fetched = get_rule(&db, &created.id).unwrap().unwrap();
258 assert_eq!(created, fetched);
259 }
260
261 #[test]
262 fn empty_ruleset_is_noop() {
263 let db = db_with_sample("h8", "kick.wav");
264 assert!(!apply_rules_to_sample(&db, "h8").unwrap());
265 assert!(crate::tags::get_sample_tags(&db, "h8").unwrap().is_empty());
266 }
267
268 // Operator / field matrix
269 //
270 // These exercise `eval_condition` directly against a hand-built `RuleContext`,
271 // covering the cross-product of value kind (string / numeric / boolean / list)
272 // and operator, plus the missing-value and inapplicable-operator edges that
273 // never reach a DB.
274
275 /// One condition against a context.
276 fn eval(ctx: &RuleContext, field: RuleField, op: RuleOp, value: &str) -> bool {
277 eval_condition(ctx, &cond(field, op, value))
278 }
279
280 #[test]
281 fn str_op_is_case_insensitive_over_all_string_ops() {
282 // Positive ops fold case on both sides.
283 assert_eq!(str_op(RuleOp::Contains, "Kick DRUM", "kick"), Some(true));
284 assert_eq!(str_op(RuleOp::Contains, "snare", "KICK"), Some(false));
285 assert_eq!(str_op(RuleOp::Equals, "WaV", "wav"), Some(true));
286 assert_eq!(str_op(RuleOp::Equals, "wave", "wav"), Some(false));
287 assert_eq!(str_op(RuleOp::StartsWith, "808_Kick", "808"), Some(true));
288 assert_eq!(str_op(RuleOp::StartsWith, "kick", "808"), Some(false));
289 assert_eq!(str_op(RuleOp::EndsWith, "loop.WAV", ".wav"), Some(true));
290 assert_eq!(str_op(RuleOp::EndsWith, "loop.aif", ".wav"), Some(false));
291 // Negative ops are the logical inverse.
292 assert_eq!(str_op(RuleOp::NotContains, "snare", "kick"), Some(true));
293 assert_eq!(str_op(RuleOp::NotContains, "Kick", "kick"), Some(false));
294 assert_eq!(str_op(RuleOp::NotEquals, "snare", "kick"), Some(true));
295 assert_eq!(str_op(RuleOp::NotEquals, "KICK", "kick"), Some(false));
296 // Non-string ops are not str-applicable.
297 for op in [RuleOp::Lt, RuleOp::Ge, RuleOp::IsTrue, RuleOp::Exists] {
298 assert_eq!(
299 str_op(op, "x", "y"),
300 None,
301 "{op:?} should not be str-applicable"
302 );
303 }
304 }
305
306 #[test]
307 fn num_op_covers_every_comparison_and_bad_input() {
308 assert!(num_op(RuleOp::Lt, 1.0, "2"));
309 assert!(!num_op(RuleOp::Lt, 2.0, "2"));
310 assert!(num_op(RuleOp::Le, 2.0, "2"));
311 assert!(!num_op(RuleOp::Le, 3.0, "2"));
312 assert!(num_op(RuleOp::Gt, 3.0, "2"));
313 assert!(!num_op(RuleOp::Gt, 2.0, "2"));
314 assert!(num_op(RuleOp::Ge, 2.0, "2"));
315 assert!(!num_op(RuleOp::Ge, 1.0, "2"));
316 assert!(num_op(RuleOp::Equals, 2.0, "2"));
317 assert!(!num_op(RuleOp::Equals, 2.5, "2"));
318 assert!(num_op(RuleOp::NotEquals, 2.5, "2"));
319 assert!(!num_op(RuleOp::NotEquals, 2.0, "2"));
320 // Whitespace in the operand is tolerated.
321 assert!(num_op(RuleOp::Ge, 128.0, " 120 "));
322 // Unparseable operand never matches, for any op.
323 for op in [
324 RuleOp::Lt,
325 RuleOp::Le,
326 RuleOp::Gt,
327 RuleOp::Ge,
328 RuleOp::Equals,
329 RuleOp::NotEquals,
330 ] {
331 assert!(
332 !num_op(op, 1.0, "notanumber"),
333 "{op:?} should fail on bad operand"
334 );
335 }
336 // String-only ops are not numeric-applicable.
337 assert!(!num_op(RuleOp::Contains, 1.0, "1"));
338 assert!(!num_op(RuleOp::StartsWith, 1.0, "1"));
339 }
340
341 #[test]
342 fn num_op_equals_uses_relative_epsilon() {
343 // Exact hits and values within the relative tolerance are equal.
344 assert!(num_op(RuleOp::Equals, 44100.0, "44100"));
345 assert!(num_op(RuleOp::Equals, 1_000_000.0, "1000000.00005"));
346 assert!(!num_op(RuleOp::Equals, 1_000_000.0, "1000001"));
347 }
348
349 #[test]
350 fn string_field_present_matrix() {
351 let ctx = RuleContext {
352 name: "808 Kick.wav".into(),
353 ..Default::default()
354 };
355 assert!(eval(&ctx, RuleField::Name, RuleOp::Contains, "kick"));
356 assert!(!eval(&ctx, RuleField::Name, RuleOp::Contains, "snare"));
357 assert!(eval(&ctx, RuleField::Name, RuleOp::StartsWith, "808"));
358 assert!(eval(&ctx, RuleField::Name, RuleOp::EndsWith, ".wav"));
359 assert!(eval(&ctx, RuleField::Name, RuleOp::NotContains, "snare"));
360 assert!(eval(&ctx, RuleField::Name, RuleOp::NotEquals, "other"));
361 assert!(eval(&ctx, RuleField::Name, RuleOp::Exists, ""));
362 assert!(!eval(&ctx, RuleField::Name, RuleOp::NotExists, ""));
363 // A numeric operator on a string field never matches.
364 assert!(!eval(&ctx, RuleField::Name, RuleOp::Gt, "0"));
365 assert!(!eval(&ctx, RuleField::Name, RuleOp::IsTrue, ""));
366 }
367
368 #[test]
369 fn string_field_missing_matrix() {
370 // source_path is None: positive ops fail, negative ops hold, existence flips.
371 let ctx = RuleContext::default();
372 assert!(!eval(&ctx, RuleField::SourcePath, RuleOp::Contains, "x"));
373 assert!(!eval(&ctx, RuleField::SourcePath, RuleOp::Equals, "x"));
374 assert!(!eval(&ctx, RuleField::SourcePath, RuleOp::StartsWith, "x"));
375 assert!(eval(&ctx, RuleField::SourcePath, RuleOp::NotContains, "x"));
376 assert!(eval(&ctx, RuleField::SourcePath, RuleOp::NotEquals, "x"));
377 assert!(!eval(&ctx, RuleField::SourcePath, RuleOp::Exists, ""));
378 assert!(eval(&ctx, RuleField::SourcePath, RuleOp::NotExists, ""));
379 }
380
381 #[test]
382 fn numeric_field_present_and_missing_matrix() {
383 let ctx = RuleContext {
384 bpm: Some(128.0),
385 ..Default::default()
386 };
387 assert!(eval(&ctx, RuleField::Bpm, RuleOp::Gt, "120"));
388 assert!(eval(&ctx, RuleField::Bpm, RuleOp::Ge, "128"));
389 assert!(eval(&ctx, RuleField::Bpm, RuleOp::Le, "128"));
390 assert!(!eval(&ctx, RuleField::Bpm, RuleOp::Lt, "128"));
391 assert!(eval(&ctx, RuleField::Bpm, RuleOp::Equals, "128"));
392 assert!(eval(&ctx, RuleField::Bpm, RuleOp::NotEquals, "120"));
393 assert!(eval(&ctx, RuleField::Bpm, RuleOp::Exists, ""));
394 assert!(!eval(&ctx, RuleField::Bpm, RuleOp::NotExists, ""));
395 // A string operator on a numeric field never matches.
396 assert!(!eval(&ctx, RuleField::Bpm, RuleOp::Contains, "12"));
397
398 // Missing numeric: every comparison fails, only NotExists holds.
399 let empty = RuleContext::default();
400 for op in [
401 RuleOp::Lt,
402 RuleOp::Le,
403 RuleOp::Gt,
404 RuleOp::Ge,
405 RuleOp::Equals,
406 RuleOp::NotEquals,
407 ] {
408 assert!(
409 !eval(&empty, RuleField::Bpm, op, "128"),
410 "{op:?} on missing num"
411 );
412 }
413 assert!(!eval(&empty, RuleField::Bpm, RuleOp::Exists, ""));
414 assert!(eval(&empty, RuleField::Bpm, RuleOp::NotExists, ""));
415 }
416
417 #[test]
418 fn boolean_field_matrix() {
419 let t = RuleContext {
420 is_loop: Some(true),
421 ..Default::default()
422 };
423 let f = RuleContext {
424 is_loop: Some(false),
425 ..Default::default()
426 };
427 let n = RuleContext::default();
428 assert!(eval(&t, RuleField::IsLoop, RuleOp::IsTrue, ""));
429 assert!(!eval(&t, RuleField::IsLoop, RuleOp::IsFalse, ""));
430 assert!(eval(&f, RuleField::IsLoop, RuleOp::IsFalse, ""));
431 assert!(!eval(&f, RuleField::IsLoop, RuleOp::IsTrue, ""));
432 assert!(eval(&t, RuleField::IsLoop, RuleOp::Exists, ""));
433 assert!(eval(&n, RuleField::IsLoop, RuleOp::NotExists, ""));
434 assert!(!eval(&n, RuleField::IsLoop, RuleOp::IsTrue, ""));
435 assert!(!eval(&n, RuleField::IsLoop, RuleOp::IsFalse, ""));
436 // Non-boolean operators never match a boolean field.
437 assert!(!eval(&t, RuleField::IsLoop, RuleOp::Contains, "true"));
438 assert!(!eval(&t, RuleField::IsLoop, RuleOp::Gt, "0"));
439 }
440
441 #[test]
442 fn list_field_matrix() {
443 let ctx = RuleContext {
444 tags: vec!["instrument.drum.kick".into(), "character.punchy".into()],
445 ..Default::default()
446 };
447 // Positive ops match if ANY element satisfies.
448 assert!(eval(&ctx, RuleField::Tag, RuleOp::Contains, "drum"));
449 assert!(eval(&ctx, RuleField::Tag, RuleOp::StartsWith, "instrument"));
450 assert!(eval(
451 &ctx,
452 RuleField::Tag,
453 RuleOp::Equals,
454 "character.punchy"
455 ));
456 assert!(!eval(&ctx, RuleField::Tag, RuleOp::Contains, "bass"));
457 // Negative ops hold only when NO element matches the positive form.
458 assert!(eval(&ctx, RuleField::Tag, RuleOp::NotContains, "bass"));
459 assert!(!eval(&ctx, RuleField::Tag, RuleOp::NotContains, "drum"));
460 assert!(eval(&ctx, RuleField::Tag, RuleOp::NotEquals, "nope"));
461 assert!(!eval(
462 &ctx,
463 RuleField::Tag,
464 RuleOp::NotEquals,
465 "character.punchy"
466 ));
467 // Existence tracks emptiness.
468 assert!(eval(&ctx, RuleField::Tag, RuleOp::Exists, ""));
469 assert!(!eval(&ctx, RuleField::Tag, RuleOp::NotExists, ""));
470
471 let empty = RuleContext::default();
472 assert!(!eval(&empty, RuleField::Tag, RuleOp::Exists, ""));
473 assert!(eval(&empty, RuleField::Tag, RuleOp::NotExists, ""));
474 // A negative op over an empty list vacuously holds; a positive op does not.
475 assert!(eval(&empty, RuleField::Tag, RuleOp::NotContains, "x"));
476 assert!(!eval(&empty, RuleField::Tag, RuleOp::Contains, "x"));
477 // Inapplicable operator on a list never matches.
478 assert!(!eval(&ctx, RuleField::Tag, RuleOp::Gt, "0"));
479 assert!(!eval(&ctx, RuleField::Tag, RuleOp::IsTrue, ""));
480 }
481
482 #[test]
483 fn match_mode_all_vs_any_over_conditions() {
484 let ctx = RuleContext {
485 name: "kick".into(),
486 bpm: Some(90.0),
487 ..Default::default()
488 };
489 let conds = vec![
490 cond(RuleField::Name, RuleOp::Contains, "kick"), // true
491 cond(RuleField::Bpm, RuleOp::Gt, "120"), // false
492 ];
493 let rule = |mode| Rule {
494 id: "r".into(),
495 name: "r".into(),
496 enabled: true,
497 priority: 0,
498 match_mode: mode,
499 conditions: conds.clone(),
500 actions: vec![],
501 created_at: 0,
502 };
503 assert!(!rule_matches(&rule(MatchMode::All), &ctx));
504 assert!(rule_matches(&rule(MatchMode::Any), &ctx));
505 }
506
507 #[test]
508 fn empty_conditions_match_unconditionally() {
509 let rule = Rule {
510 id: "r".into(),
511 name: "r".into(),
512 enabled: true,
513 priority: 0,
514 match_mode: MatchMode::All,
515 conditions: vec![],
516 actions: vec![],
517 created_at: 0,
518 };
519 assert!(rule_matches(&rule, &RuleContext::default()));
520 }
521