//! Tests for [`super`]. use super::*; fn db_with_sample(hash: &str, name: &str) -> Database { let db = Database::open_in_memory().unwrap(); db.conn() .execute( "INSERT INTO samples (hash, original_name, file_extension, file_size, import_date, last_modified) \ VALUES (?1, ?2, 'wav', 1000, 0, 0)", rusqlite::params![hash, name], ) .unwrap(); db } fn cond(field: RuleField, op: RuleOp, value: &str) -> RuleCondition { RuleCondition { field, op, value: value.to_string(), } } fn new_rule(name: &str, conds: Vec, acts: Vec) -> NewRule { NewRule { name: name.to_string(), enabled: true, priority: None, match_mode: MatchMode::All, conditions: conds, actions: acts, } } #[test] fn name_contains_applies_tag() { let db = db_with_sample("h1", "808 Kick Loud.wav"); create_rule( &db, new_rule( "kicks", vec![cond(RuleField::Name, RuleOp::Contains, "kick")], vec![RuleAction::AddTag("instrument.drum.kick".into())], ), ) .unwrap(); assert!(apply_rules_to_sample(&db, "h1").unwrap()); let tags = crate::tags::get_sample_tags(&db, "h1").unwrap(); assert_eq!(tags, vec!["instrument.drum.kick"]); let prov = sample_tag_provenance(&db, "h1").unwrap(); assert_eq!(prov.len(), 1); assert_eq!(prov[0].1, "rule"); } #[test] fn numeric_condition_on_analysis() { let db = db_with_sample("h2", "loop.wav"); db.conn() .execute( "INSERT INTO audio_analysis (hash, duration, sample_rate, channels, bpm, analyzed_at) \ VALUES ('h2', 4.0, 44100, 2, 128.0, 0)", [], ) .unwrap(); create_rule( &db, new_rule( "fast", vec![cond(RuleField::Bpm, RuleOp::Ge, "120")], vec![RuleAction::AddTag("tempo.fast".into())], ), ) .unwrap(); apply_rules_to_sample(&db, "h2").unwrap(); assert!( crate::tags::get_sample_tags(&db, "h2") .unwrap() .contains(&"tempo.fast".to_string()) ); } #[test] fn manual_tags_are_sticky() { let db = db_with_sample("h3", "kick.wav"); crate::tags::add_tag(&db, "h3", "manual.keep").unwrap(); let rule = create_rule( &db, new_rule( "kicks", vec![cond(RuleField::Name, RuleOp::Contains, "kick")], vec![RuleAction::AddTag("instrument.drum.kick".into())], ), ) .unwrap(); apply_rules_to_sample(&db, "h3").unwrap(); // Deleting the rule must remove its tag but keep the manual one. delete_rule(&db, &rule.id).unwrap(); let tags = crate::tags::get_sample_tags(&db, "h3").unwrap(); assert_eq!(tags, vec!["manual.keep"]); } #[test] fn reconcile_removes_tags_when_rule_no_longer_matches() { let db = db_with_sample("h4", "kick.wav"); let mut rule = create_rule( &db, new_rule( "kicks", vec![cond(RuleField::Name, RuleOp::Contains, "kick")], vec![RuleAction::AddTag("instrument.drum.kick".into())], ), ) .unwrap(); apply_rules_to_sample(&db, "h4").unwrap(); assert!(!crate::tags::get_sample_tags(&db, "h4").unwrap().is_empty()); // Narrow the rule so it no longer matches, then reconcile. rule.conditions = vec![cond(RuleField::Name, RuleOp::Contains, "snare")]; update_rule(&db, &rule).unwrap(); apply_rules_to_sample(&db, "h4").unwrap(); assert!(crate::tags::get_sample_tags(&db, "h4").unwrap().is_empty()); } #[test] fn toggling_enabled_reconciles_membership() { let db = db_with_sample("h6", "kick.wav"); let rule = create_rule( &db, new_rule( "kicks", vec![cond(RuleField::Name, RuleOp::Contains, "kick")], vec![RuleAction::AddTag("instrument.drum.kick".into())], ), ) .unwrap(); apply_rules_to_sample(&db, "h6").unwrap(); assert!(!crate::tags::get_sample_tags(&db, "h6").unwrap().is_empty()); // Disabling must remove the rule-sourced tag immediately (no separate // apply_* call), not leave it stale. set_rule_enabled(&db, &rule.id, false).unwrap(); assert!(crate::tags::get_sample_tags(&db, "h6").unwrap().is_empty()); // Re-enabling must re-apply it across the library, again without an // explicit apply_* call. set_rule_enabled(&db, &rule.id, true).unwrap(); assert!( crate::tags::get_sample_tags(&db, "h6") .unwrap() .contains(&"instrument.drum.kick".to_string()) ); } #[test] fn update_unknown_rule_errors_not_resurrects() { let db = db_with_sample("h7", "kick.wav"); let rule = create_rule( &db, new_rule( "kicks", vec![cond(RuleField::Name, RuleOp::Contains, "kick")], vec![RuleAction::AddTag("instrument.drum.kick".into())], ), ) .unwrap(); delete_rule(&db, &rule.id).unwrap(); // Updating the now-deleted rule must error, not silently re-insert it. assert!(matches!( update_rule(&db, &rule), Err(CoreError::RuleNotFound(_)) )); assert!(get_rule(&db, &rule.id).unwrap().is_none()); } #[test] fn match_mode_any_vs_all() { let db = db_with_sample("h5", "snare hit.wav"); let any = create_rule( &db, NewRule { match_mode: MatchMode::Any, ..new_rule( "any", vec![ cond(RuleField::Name, RuleOp::Contains, "kick"), cond(RuleField::Name, RuleOp::Contains, "snare"), ], vec![RuleAction::AddTag("matched.any".into())], ) }, ) .unwrap(); assert_eq!(preview_rule_matches(&db, &any).unwrap(), 1); let all = Rule { match_mode: MatchMode::All, ..any }; assert_eq!(preview_rule_matches(&db, &all).unwrap(), 0); } #[test] fn stop_action_halts_later_rules() { let db = db_with_sample("h6", "kick.wav"); create_rule( &db, NewRule { priority: Some(0), ..new_rule( "first", vec![], vec![RuleAction::AddTag("a.first".into()), RuleAction::Stop], ) }, ) .unwrap(); create_rule( &db, NewRule { priority: Some(1), ..new_rule( "second", vec![], vec![RuleAction::AddTag("a.second".into())], ) }, ) .unwrap(); apply_rules_to_sample(&db, "h6").unwrap(); let tags = crate::tags::get_sample_tags(&db, "h6").unwrap(); assert_eq!(tags, vec!["a.first"]); } #[test] fn rules_round_trip_through_db() { let db = db_with_sample("h7", "x.wav"); let created = create_rule( &db, new_rule( "complex", vec![ cond(RuleField::SpectralFlatness, RuleOp::Lt, "0.2"), cond(RuleField::Tag, RuleOp::StartsWith, "instrument.drum"), ], vec![ RuleAction::AddTag("character.tonal".into()), RuleAction::Stop, ], ), ) .unwrap(); let fetched = get_rule(&db, &created.id).unwrap().unwrap(); assert_eq!(created, fetched); } #[test] fn empty_ruleset_is_noop() { let db = db_with_sample("h8", "kick.wav"); assert!(!apply_rules_to_sample(&db, "h8").unwrap()); assert!(crate::tags::get_sample_tags(&db, "h8").unwrap().is_empty()); } // Operator / field matrix // // These exercise `eval_condition` directly against a hand-built `RuleContext`, // covering the cross-product of value kind (string / numeric / boolean / list) // and operator, plus the missing-value and inapplicable-operator edges that // never reach a DB. /// One condition against a context. fn eval(ctx: &RuleContext, field: RuleField, op: RuleOp, value: &str) -> bool { eval_condition(ctx, &cond(field, op, value)) } #[test] fn str_op_is_case_insensitive_over_all_string_ops() { // Positive ops fold case on both sides. assert_eq!(str_op(RuleOp::Contains, "Kick DRUM", "kick"), Some(true)); assert_eq!(str_op(RuleOp::Contains, "snare", "KICK"), Some(false)); assert_eq!(str_op(RuleOp::Equals, "WaV", "wav"), Some(true)); assert_eq!(str_op(RuleOp::Equals, "wave", "wav"), Some(false)); assert_eq!(str_op(RuleOp::StartsWith, "808_Kick", "808"), Some(true)); assert_eq!(str_op(RuleOp::StartsWith, "kick", "808"), Some(false)); assert_eq!(str_op(RuleOp::EndsWith, "loop.WAV", ".wav"), Some(true)); assert_eq!(str_op(RuleOp::EndsWith, "loop.aif", ".wav"), Some(false)); // Negative ops are the logical inverse. assert_eq!(str_op(RuleOp::NotContains, "snare", "kick"), Some(true)); assert_eq!(str_op(RuleOp::NotContains, "Kick", "kick"), Some(false)); assert_eq!(str_op(RuleOp::NotEquals, "snare", "kick"), Some(true)); assert_eq!(str_op(RuleOp::NotEquals, "KICK", "kick"), Some(false)); // Non-string ops are not str-applicable. for op in [RuleOp::Lt, RuleOp::Ge, RuleOp::IsTrue, RuleOp::Exists] { assert_eq!( str_op(op, "x", "y"), None, "{op:?} should not be str-applicable" ); } } #[test] fn num_op_covers_every_comparison_and_bad_input() { assert!(num_op(RuleOp::Lt, 1.0, "2")); assert!(!num_op(RuleOp::Lt, 2.0, "2")); assert!(num_op(RuleOp::Le, 2.0, "2")); assert!(!num_op(RuleOp::Le, 3.0, "2")); assert!(num_op(RuleOp::Gt, 3.0, "2")); assert!(!num_op(RuleOp::Gt, 2.0, "2")); assert!(num_op(RuleOp::Ge, 2.0, "2")); assert!(!num_op(RuleOp::Ge, 1.0, "2")); assert!(num_op(RuleOp::Equals, 2.0, "2")); assert!(!num_op(RuleOp::Equals, 2.5, "2")); assert!(num_op(RuleOp::NotEquals, 2.5, "2")); assert!(!num_op(RuleOp::NotEquals, 2.0, "2")); // Whitespace in the operand is tolerated. assert!(num_op(RuleOp::Ge, 128.0, " 120 ")); // Unparseable operand never matches, for any op. for op in [ RuleOp::Lt, RuleOp::Le, RuleOp::Gt, RuleOp::Ge, RuleOp::Equals, RuleOp::NotEquals, ] { assert!( !num_op(op, 1.0, "notanumber"), "{op:?} should fail on bad operand" ); } // String-only ops are not numeric-applicable. assert!(!num_op(RuleOp::Contains, 1.0, "1")); assert!(!num_op(RuleOp::StartsWith, 1.0, "1")); } #[test] fn num_op_equals_uses_relative_epsilon() { // Exact hits and values within the relative tolerance are equal. assert!(num_op(RuleOp::Equals, 44100.0, "44100")); assert!(num_op(RuleOp::Equals, 1_000_000.0, "1000000.00005")); assert!(!num_op(RuleOp::Equals, 1_000_000.0, "1000001")); } #[test] fn string_field_present_matrix() { let ctx = RuleContext { name: "808 Kick.wav".into(), ..Default::default() }; assert!(eval(&ctx, RuleField::Name, RuleOp::Contains, "kick")); assert!(!eval(&ctx, RuleField::Name, RuleOp::Contains, "snare")); assert!(eval(&ctx, RuleField::Name, RuleOp::StartsWith, "808")); assert!(eval(&ctx, RuleField::Name, RuleOp::EndsWith, ".wav")); assert!(eval(&ctx, RuleField::Name, RuleOp::NotContains, "snare")); assert!(eval(&ctx, RuleField::Name, RuleOp::NotEquals, "other")); assert!(eval(&ctx, RuleField::Name, RuleOp::Exists, "")); assert!(!eval(&ctx, RuleField::Name, RuleOp::NotExists, "")); // A numeric operator on a string field never matches. assert!(!eval(&ctx, RuleField::Name, RuleOp::Gt, "0")); assert!(!eval(&ctx, RuleField::Name, RuleOp::IsTrue, "")); } #[test] fn string_field_missing_matrix() { // source_path is None: positive ops fail, negative ops hold, existence flips. let ctx = RuleContext::default(); assert!(!eval(&ctx, RuleField::SourcePath, RuleOp::Contains, "x")); assert!(!eval(&ctx, RuleField::SourcePath, RuleOp::Equals, "x")); assert!(!eval(&ctx, RuleField::SourcePath, RuleOp::StartsWith, "x")); assert!(eval(&ctx, RuleField::SourcePath, RuleOp::NotContains, "x")); assert!(eval(&ctx, RuleField::SourcePath, RuleOp::NotEquals, "x")); assert!(!eval(&ctx, RuleField::SourcePath, RuleOp::Exists, "")); assert!(eval(&ctx, RuleField::SourcePath, RuleOp::NotExists, "")); } #[test] fn numeric_field_present_and_missing_matrix() { let ctx = RuleContext { bpm: Some(128.0), ..Default::default() }; assert!(eval(&ctx, RuleField::Bpm, RuleOp::Gt, "120")); assert!(eval(&ctx, RuleField::Bpm, RuleOp::Ge, "128")); assert!(eval(&ctx, RuleField::Bpm, RuleOp::Le, "128")); assert!(!eval(&ctx, RuleField::Bpm, RuleOp::Lt, "128")); assert!(eval(&ctx, RuleField::Bpm, RuleOp::Equals, "128")); assert!(eval(&ctx, RuleField::Bpm, RuleOp::NotEquals, "120")); assert!(eval(&ctx, RuleField::Bpm, RuleOp::Exists, "")); assert!(!eval(&ctx, RuleField::Bpm, RuleOp::NotExists, "")); // A string operator on a numeric field never matches. assert!(!eval(&ctx, RuleField::Bpm, RuleOp::Contains, "12")); // Missing numeric: every comparison fails, only NotExists holds. let empty = RuleContext::default(); for op in [ RuleOp::Lt, RuleOp::Le, RuleOp::Gt, RuleOp::Ge, RuleOp::Equals, RuleOp::NotEquals, ] { assert!( !eval(&empty, RuleField::Bpm, op, "128"), "{op:?} on missing num" ); } assert!(!eval(&empty, RuleField::Bpm, RuleOp::Exists, "")); assert!(eval(&empty, RuleField::Bpm, RuleOp::NotExists, "")); } #[test] fn boolean_field_matrix() { let t = RuleContext { is_loop: Some(true), ..Default::default() }; let f = RuleContext { is_loop: Some(false), ..Default::default() }; let n = RuleContext::default(); assert!(eval(&t, RuleField::IsLoop, RuleOp::IsTrue, "")); assert!(!eval(&t, RuleField::IsLoop, RuleOp::IsFalse, "")); assert!(eval(&f, RuleField::IsLoop, RuleOp::IsFalse, "")); assert!(!eval(&f, RuleField::IsLoop, RuleOp::IsTrue, "")); assert!(eval(&t, RuleField::IsLoop, RuleOp::Exists, "")); assert!(eval(&n, RuleField::IsLoop, RuleOp::NotExists, "")); assert!(!eval(&n, RuleField::IsLoop, RuleOp::IsTrue, "")); assert!(!eval(&n, RuleField::IsLoop, RuleOp::IsFalse, "")); // Non-boolean operators never match a boolean field. assert!(!eval(&t, RuleField::IsLoop, RuleOp::Contains, "true")); assert!(!eval(&t, RuleField::IsLoop, RuleOp::Gt, "0")); } #[test] fn list_field_matrix() { let ctx = RuleContext { tags: vec!["instrument.drum.kick".into(), "character.punchy".into()], ..Default::default() }; // Positive ops match if ANY element satisfies. assert!(eval(&ctx, RuleField::Tag, RuleOp::Contains, "drum")); assert!(eval(&ctx, RuleField::Tag, RuleOp::StartsWith, "instrument")); assert!(eval( &ctx, RuleField::Tag, RuleOp::Equals, "character.punchy" )); assert!(!eval(&ctx, RuleField::Tag, RuleOp::Contains, "bass")); // Negative ops hold only when NO element matches the positive form. assert!(eval(&ctx, RuleField::Tag, RuleOp::NotContains, "bass")); assert!(!eval(&ctx, RuleField::Tag, RuleOp::NotContains, "drum")); assert!(eval(&ctx, RuleField::Tag, RuleOp::NotEquals, "nope")); assert!(!eval( &ctx, RuleField::Tag, RuleOp::NotEquals, "character.punchy" )); // Existence tracks emptiness. assert!(eval(&ctx, RuleField::Tag, RuleOp::Exists, "")); assert!(!eval(&ctx, RuleField::Tag, RuleOp::NotExists, "")); let empty = RuleContext::default(); assert!(!eval(&empty, RuleField::Tag, RuleOp::Exists, "")); assert!(eval(&empty, RuleField::Tag, RuleOp::NotExists, "")); // A negative op over an empty list vacuously holds; a positive op does not. assert!(eval(&empty, RuleField::Tag, RuleOp::NotContains, "x")); assert!(!eval(&empty, RuleField::Tag, RuleOp::Contains, "x")); // Inapplicable operator on a list never matches. assert!(!eval(&ctx, RuleField::Tag, RuleOp::Gt, "0")); assert!(!eval(&ctx, RuleField::Tag, RuleOp::IsTrue, "")); } #[test] fn match_mode_all_vs_any_over_conditions() { let ctx = RuleContext { name: "kick".into(), bpm: Some(90.0), ..Default::default() }; let conds = vec![ cond(RuleField::Name, RuleOp::Contains, "kick"), // true cond(RuleField::Bpm, RuleOp::Gt, "120"), // false ]; let rule = |mode| Rule { id: "r".into(), name: "r".into(), enabled: true, priority: 0, match_mode: mode, conditions: conds.clone(), actions: vec![], created_at: 0, }; assert!(!rule_matches(&rule(MatchMode::All), &ctx)); assert!(rule_matches(&rule(MatchMode::Any), &ctx)); } #[test] fn empty_conditions_match_unconditionally() { let rule = Rule { id: "r".into(), name: "r".into(), enabled: true, priority: 0, match_mode: MatchMode::All, conditions: vec![], actions: vec![], created_at: 0, }; assert!(rule_matches(&rule, &RuleContext::default())); }