Skip to main content

max / audiofiles

Add coverage for rule operator/field matrix and classifier state Cover the condition-evaluation cross-product in core/rules.rs (str_op, num_op, eval_condition across every value kind and operator, plus missing-value and inapplicable-operator edges), and add state-level tests for the untested classifier draft, move, toggle, guard, and policy methods. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-17 19:23 UTC
Commit: e9d0f4f094bf41db102a622b31180f10ab573bd6
Parent: e032cd4
2 files changed, +361 insertions, -0 deletions
@@ -2099,6 +2099,163 @@ mod misc {
2099 2099 }
2100 2100 }
2101 2101
2102 + // ---- Tag classifier (rule builder state) ----
2103 +
2104 + mod classifier {
2105 + use super::*;
2106 + use audiofiles_core::rules::{MatchMode, RuleAction, RuleField, RuleOp};
2107 +
2108 + /// Author and persist an unconditional rule that adds `tag`, returning its id.
2109 + fn save_tag_all_rule(state: &mut BrowserState, name: &str, tag: &str) -> String {
2110 + state.classifier_new_draft();
2111 + {
2112 + let d = state.classifier.editing.as_mut().unwrap();
2113 + d.name = name.to_string();
2114 + d.conditions.clear();
2115 + d.actions = vec![RuleAction::AddTag(tag.to_string())];
2116 + }
2117 + state.classifier_save_draft();
2118 + state.classifier.rules.iter().find(|r| r.name == name).unwrap().id.clone()
2119 + }
2120 +
2121 + #[test]
2122 + fn new_draft_has_default_shape() {
2123 + let (mut state, _dir) = make_state();
2124 + state.classifier_new_draft();
2125 + let d = state.classifier.editing.as_ref().expect("draft started");
2126 + assert!(d.id.is_none());
2127 + assert!(d.name.is_empty());
2128 + assert!(d.enabled);
2129 + assert_eq!(d.match_mode, MatchMode::All);
2130 + assert_eq!(d.conditions.len(), 1);
2131 + assert_eq!(d.conditions[0].field, RuleField::Name);
2132 + assert_eq!(d.conditions[0].op, RuleOp::Contains);
2133 + assert_eq!(d.actions.len(), 1);
2134 + assert!(matches!(d.actions[0], RuleAction::AddTag(_)));
2135 + }
2136 +
2137 + #[test]
2138 + fn edit_rule_populates_draft_then_cancel_clears() {
2139 + let (mut state, _dir) = make_state();
2140 + save_tag_all_rule(&mut state, "keeper", "auto.keep");
2141 + let rule = state.classifier.rules[0].clone();
2142 +
2143 + state.classifier_edit_rule(&rule);
2144 + let d = state.classifier.editing.as_ref().expect("editing");
2145 + assert_eq!(d.id.as_deref(), Some(rule.id.as_str()));
2146 + assert_eq!(d.name, "keeper");
2147 + assert_eq!(d.actions, rule.actions);
2148 +
2149 + state.classifier_cancel_draft();
2150 + assert!(state.classifier.editing.is_none());
2151 + }
2152 +
2153 + #[test]
2154 + fn save_draft_rejects_blank_name() {
2155 + let (mut state, _dir) = make_state();
2156 + state.refresh_rules();
2157 + state.classifier_new_draft(); // name left empty
2158 + state.classifier_save_draft();
2159 + // Draft stays open, nothing persisted, and the user is told why.
2160 + assert!(state.classifier.editing.is_some());
2161 + assert!(state.classifier.rules.is_empty());
2162 + assert!(state.status.to_lowercase().contains("name"));
2163 + }
2164 +
2165 + #[test]
2166 + fn test_draft_caches_match_count() {
2167 + let (mut state, _dir) = make_state();
2168 + insert_fake_sample(&state, "kickone"); // original_name = "kickone.wav"
2169 + insert_fake_sample(&state, "snaretwo");
2170 +
2171 + state.classifier_new_draft();
2172 + {
2173 + let d = state.classifier.editing.as_mut().unwrap();
2174 + d.name = "kicks".to_string();
2175 + d.conditions = vec![audiofiles_core::rules::RuleCondition {
2176 + field: RuleField::Name,
2177 + op: RuleOp::Contains,
2178 + value: "kick".to_string(),
2179 + }];
2180 + }
2181 + state.classifier_test_draft();
2182 + assert_eq!(state.classifier.editing.as_ref().unwrap().match_count, Some(1));
2183 + assert!(state.status.contains("1 sample"));
2184 + }
2185 +
2186 + #[test]
2187 + fn move_rule_swaps_priority_and_ignores_out_of_bounds() {
2188 + let (mut state, _dir) = make_state();
2189 + save_tag_all_rule(&mut state, "alpha", "t.a");
2190 + save_tag_all_rule(&mut state, "beta", "t.b");
2191 + assert_eq!(state.classifier.rules[0].name, "alpha");
2192 + assert_eq!(state.classifier.rules[1].name, "beta");
2193 +
2194 + // Move beta up: the two swap evaluation order.
2195 + state.classifier_move_rule(1, true);
2196 + assert_eq!(state.classifier.rules[0].name, "beta");
2197 + assert_eq!(state.classifier.rules[1].name, "alpha");
2198 +
2199 + // Moving the first rule up, or the last rule down, is a no-op.
2200 + state.classifier_move_rule(0, true);
2201 + state.classifier_move_rule(1, false);
2202 + assert_eq!(state.classifier.rules[0].name, "beta");
2203 + assert_eq!(state.classifier.rules[1].name, "alpha");
2204 + }
2205 +
2206 + #[test]
2207 + fn toggle_rule_disables_then_reenables_tag() {
2208 + let (mut state, _dir) = make_state();
2209 + insert_fake_sample(&state, "aaa111");
2210 + let id = save_tag_all_rule(&mut state, "tag-all", "auto.on");
2211 + assert!(state.backend.get_sample_tags("aaa111").unwrap().contains(&"auto.on".to_string()));
2212 +
2213 + // Disabling reconciles the rule-sourced tag away immediately.
2214 + state.classifier_toggle_rule(&id, false);
2215 + assert!(!state.backend.get_sample_tags("aaa111").unwrap().contains(&"auto.on".to_string()));
2216 +
2217 + // Re-enabling re-applies it across the library.
2218 + state.classifier_toggle_rule(&id, true);
2219 + assert!(state.backend.get_sample_tags("aaa111").unwrap().contains(&"auto.on".to_string()));
2220 + }
2221 +
2222 + #[test]
2223 + fn add_policy_ignores_blank_tag() {
2224 + let (mut state, _dir) = make_state();
2225 + state.classifier.new_policy_tag = " ".to_string();
2226 + state.classifier_add_policy();
2227 + assert!(state.classifier.policies.is_empty());
2228 + }
2229 +
2230 + #[test]
2231 + fn set_policy_persists_thresholds() {
2232 + let (mut state, _dir) = make_state();
2233 + state.classifier_set_policy("instrument.drum.kick", 0.55, 0.85);
2234 + state.refresh_policies();
2235 + let (_, policy) = state
2236 + .classifier
2237 + .policies
2238 + .iter()
2239 + .find(|(t, _)| t == "instrument.drum.kick")
2240 + .expect("policy persisted");
2241 + assert!((policy.review_threshold - 0.55).abs() < 1e-9);
2242 + assert!((policy.auto_threshold - 0.85).abs() < 1e-9);
2243 + }
2244 +
2245 + #[test]
2246 + fn undo_tag_source_removes_a_whole_pass() {
2247 + let (mut state, _dir) = make_state();
2248 + insert_fake_sample(&state, "aaa111");
2249 + save_tag_all_rule(&mut state, "tag-all", "auto.rule");
2250 + assert!(state.backend.get_sample_tags("aaa111").unwrap().contains(&"auto.rule".to_string()));
2251 +
2252 + // Undo every rule-sourced tag in one pass.
2253 + state.undo_tag_source("rule");
2254 + assert!(state.backend.get_sample_tags("aaa111").unwrap().is_empty());
2255 + assert!(state.status.to_lowercase().contains("removed"));
2256 + }
2257 + }
2258 +
2102 2259 // ---- Sample Forge (chop / conform / batch) ----
2103 2260
2104 2261 mod forge {
@@ -1110,4 +1110,208 @@ mod tests {
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 }