max / audiofiles
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
- Claude-Session
- https://claude.ai/code/session_01EEmeiSJnmyL98QzA5Dwsvz
14 files changed,
+5493 insertions,
-3040 deletions
| @@ -1233,61 +1233,4 @@ | |||
| 1233 | 1233 | } | |
| 1234 | 1234 | ||
| 1235 | 1235 | #[cfg(test)] | |
| 1236 | - | mod tests { | |
| 1237 | - | use super::*; | |
| 1238 | - | ||
| 1239 | - | #[test] | |
| 1240 | - | fn macro_average_counts_an_unpredicted_class_as_zero() { | |
| 1241 | - | // Skipping it would let a layer raise its macro precision by predicting | |
| 1242 | - | // a hard class less often, which is backwards. | |
| 1243 | - | let classes = vec!["a".to_string(), "b".to_string()]; | |
| 1244 | - | let mut counts = BTreeMap::new(); | |
| 1245 | - | counts.insert( | |
| 1246 | - | "a".to_string(), | |
| 1247 | - | Counts { | |
| 1248 | - | tp: 10, | |
| 1249 | - | fp: 0, | |
| 1250 | - | fn_: 0, | |
| 1251 | - | }, | |
| 1252 | - | ); | |
| 1253 | - | counts.insert( | |
| 1254 | - | "b".to_string(), | |
| 1255 | - | Counts { | |
| 1256 | - | tp: 0, | |
| 1257 | - | fp: 0, | |
| 1258 | - | fn_: 10, | |
| 1259 | - | }, | |
| 1260 | - | ); | |
| 1261 | - | assert_eq!( | |
| 1262 | - | macro_average(&classes, &counts, Counts::precision), | |
| 1263 | - | Some(0.5) | |
| 1264 | - | ); | |
| 1265 | - | } | |
| 1266 | - | ||
| 1267 | - | #[test] | |
| 1268 | - | fn class_points_score_an_absent_class_as_zero() { | |
| 1269 | - | // A class missing from a sample's scores is a real zero, not a gap: the | |
| 1270 | - | // index considered it and gave it no neighbourhood weight. Treating it | |
| 1271 | - | // as missing would drop true negatives and inflate precision. | |
| 1272 | - | let p = vec![Prediction { | |
| 1273 | - | truth: "instrument.drum.kick".into(), | |
| 1274 | - | top1: Some("instrument.drum.kick".into()), | |
| 1275 | - | scores: BTreeMap::from([("instrument.drum.kick".to_string(), 0.9)]), | |
| 1276 | - | fold: 0, | |
| 1277 | - | origin: "kick".into(), | |
| 1278 | - | }]; | |
| 1279 | - | let pts = class_points(&p, "instrument.drum.snare"); | |
| 1280 | - | assert_eq!(pts.len(), 1); | |
| 1281 | - | assert!((pts[0].score - 0.0).abs() < f64::EPSILON); | |
| 1282 | - | assert!(!pts[0].actual); | |
| 1283 | - | } | |
| 1284 | - | ||
| 1285 | - | #[test] | |
| 1286 | - | fn k_sweep_always_contains_the_runtime_k() { | |
| 1287 | - | // Safe to set: this test does not read the env, it checks the invariant | |
| 1288 | - | // the parser must hold whatever the env said. | |
| 1289 | - | let ks = k_sweep_from_env(); | |
| 1290 | - | assert!(ks.contains(&DEFAULT_K)); | |
| 1291 | - | assert!(ks.windows(2).all(|w| w[0] < w[1]), "sorted and deduped"); | |
| 1292 | - | } | |
| 1293 | - | } | |
| 1236 | + | mod tests; |
| @@ -879,523 +879,4 @@ | |||
| 879 | 879 | } | |
| 880 | 880 | ||
| 881 | 881 | #[cfg(test)] | |
| 882 | - | mod tests { | |
| 883 | - | use super::*; | |
| 884 | - | ||
| 885 | - | fn db_with_sample(hash: &str, name: &str) -> Database { | |
| 886 | - | let db = Database::open_in_memory().unwrap(); | |
| 887 | - | db.conn() | |
| 888 | - | .execute( | |
| 889 | - | "INSERT INTO samples (hash, original_name, file_extension, file_size, import_date, last_modified) \ | |
| 890 | - | VALUES (?1, ?2, 'wav', 1000, 0, 0)", | |
| 891 | - | rusqlite::params![hash, name], | |
| 892 | - | ) | |
| 893 | - | .unwrap(); | |
| 894 | - | db | |
| 895 | - | } | |
| 896 | - | ||
| 897 | - | fn cond(field: RuleField, op: RuleOp, value: &str) -> RuleCondition { | |
| 898 | - | RuleCondition { | |
| 899 | - | field, | |
| 900 | - | op, | |
| 901 | - | value: value.to_string(), | |
| 902 | - | } | |
| 903 | - | } | |
| 904 | - | ||
| 905 | - | fn new_rule(name: &str, conds: Vec<RuleCondition>, acts: Vec<RuleAction>) -> NewRule { | |
| 906 | - | NewRule { | |
| 907 | - | name: name.to_string(), | |
| 908 | - | enabled: true, | |
| 909 | - | priority: None, | |
| 910 | - | match_mode: MatchMode::All, | |
| 911 | - | conditions: conds, | |
| 912 | - | actions: acts, | |
| 913 | - | } | |
| 914 | - | } | |
| 915 | - | ||
| 916 | - | #[test] | |
| 917 | - | fn name_contains_applies_tag() { | |
| 918 | - | let db = db_with_sample("h1", "808 Kick Loud.wav"); | |
| 919 | - | create_rule( | |
| 920 | - | &db, | |
| 921 | - | new_rule( | |
| 922 | - | "kicks", | |
| 923 | - | vec![cond(RuleField::Name, RuleOp::Contains, "kick")], | |
| 924 | - | vec![RuleAction::AddTag("instrument.drum.kick".into())], | |
| 925 | - | ), | |
| 926 | - | ) | |
| 927 | - | .unwrap(); | |
| 928 | - | ||
| 929 | - | assert!(apply_rules_to_sample(&db, "h1").unwrap()); | |
| 930 | - | let tags = crate::tags::get_sample_tags(&db, "h1").unwrap(); | |
| 931 | - | assert_eq!(tags, vec!["instrument.drum.kick"]); | |
| 932 | - | ||
| 933 | - | let prov = sample_tag_provenance(&db, "h1").unwrap(); | |
| 934 | - | assert_eq!(prov.len(), 1); | |
| 935 | - | assert_eq!(prov[0].1, "rule"); | |
| 936 | - | } | |
| 937 | - | ||
| 938 | - | #[test] | |
| 939 | - | fn numeric_condition_on_analysis() { | |
| 940 | - | let db = db_with_sample("h2", "loop.wav"); | |
| 941 | - | db.conn() | |
| 942 | - | .execute( | |
| 943 | - | "INSERT INTO audio_analysis (hash, duration, sample_rate, channels, bpm, analyzed_at) \ | |
| 944 | - | VALUES ('h2', 4.0, 44100, 2, 128.0, 0)", | |
| 945 | - | [], | |
| 946 | - | ) | |
| 947 | - | .unwrap(); | |
| 948 | - | create_rule( | |
| 949 | - | &db, | |
| 950 | - | new_rule( | |
| 951 | - | "fast", | |
| 952 | - | vec![cond(RuleField::Bpm, RuleOp::Ge, "120")], | |
| 953 | - | vec![RuleAction::AddTag("tempo.fast".into())], | |
| 954 | - | ), | |
| 955 | - | ) | |
| 956 | - | .unwrap(); | |
| 957 | - | apply_rules_to_sample(&db, "h2").unwrap(); | |
| 958 | - | assert!( | |
| 959 | - | crate::tags::get_sample_tags(&db, "h2") | |
| 960 | - | .unwrap() | |
| 961 | - | .contains(&"tempo.fast".to_string()) | |
| 962 | - | ); | |
| 963 | - | } | |
| 964 | - | ||
| 965 | - | #[test] | |
| 966 | - | fn manual_tags_are_sticky() { | |
| 967 | - | let db = db_with_sample("h3", "kick.wav"); | |
| 968 | - | crate::tags::add_tag(&db, "h3", "manual.keep").unwrap(); | |
| 969 | - | let rule = create_rule( | |
| 970 | - | &db, | |
| 971 | - | new_rule( | |
| 972 | - | "kicks", | |
| 973 | - | vec![cond(RuleField::Name, RuleOp::Contains, "kick")], | |
| 974 | - | vec![RuleAction::AddTag("instrument.drum.kick".into())], | |
| 975 | - | ), | |
| 976 | - | ) | |
| 977 | - | .unwrap(); | |
| 978 | - | apply_rules_to_sample(&db, "h3").unwrap(); | |
| 979 | - | ||
| 980 | - | // Deleting the rule must remove its tag but keep the manual one. | |
| 981 | - | delete_rule(&db, &rule.id).unwrap(); | |
| 982 | - | let tags = crate::tags::get_sample_tags(&db, "h3").unwrap(); | |
| 983 | - | assert_eq!(tags, vec!["manual.keep"]); | |
| 984 | - | } | |
| 985 | - | ||
| 986 | - | #[test] | |
| 987 | - | fn reconcile_removes_tags_when_rule_no_longer_matches() { | |
| 988 | - | let db = db_with_sample("h4", "kick.wav"); | |
| 989 | - | let mut rule = create_rule( | |
| 990 | - | &db, | |
| 991 | - | new_rule( | |
| 992 | - | "kicks", | |
| 993 | - | vec![cond(RuleField::Name, RuleOp::Contains, "kick")], | |
| 994 | - | vec![RuleAction::AddTag("instrument.drum.kick".into())], | |
| 995 | - | ), | |
| 996 | - | ) | |
| 997 | - | .unwrap(); | |
| 998 | - | apply_rules_to_sample(&db, "h4").unwrap(); | |
| 999 | - | assert!(!crate::tags::get_sample_tags(&db, "h4").unwrap().is_empty()); | |
| 1000 | - | ||
| 1001 | - | // Narrow the rule so it no longer matches, then reconcile. | |
| 1002 | - | rule.conditions = vec![cond(RuleField::Name, RuleOp::Contains, "snare")]; | |
| 1003 | - | update_rule(&db, &rule).unwrap(); | |
| 1004 | - | apply_rules_to_sample(&db, "h4").unwrap(); | |
| 1005 | - | assert!(crate::tags::get_sample_tags(&db, "h4").unwrap().is_empty()); | |
| 1006 | - | } | |
| 1007 | - | ||
| 1008 | - | #[test] | |
| 1009 | - | fn toggling_enabled_reconciles_membership() { | |
| 1010 | - | let db = db_with_sample("h6", "kick.wav"); | |
| 1011 | - | let rule = create_rule( | |
| 1012 | - | &db, | |
| 1013 | - | new_rule( | |
| 1014 | - | "kicks", | |
| 1015 | - | vec![cond(RuleField::Name, RuleOp::Contains, "kick")], | |
| 1016 | - | vec![RuleAction::AddTag("instrument.drum.kick".into())], | |
| 1017 | - | ), | |
| 1018 | - | ) | |
| 1019 | - | .unwrap(); | |
| 1020 | - | apply_rules_to_sample(&db, "h6").unwrap(); | |
| 1021 | - | assert!(!crate::tags::get_sample_tags(&db, "h6").unwrap().is_empty()); | |
| 1022 | - | ||
| 1023 | - | // Disabling must remove the rule-sourced tag immediately (no separate | |
| 1024 | - | // apply_* call), not leave it stale. | |
| 1025 | - | set_rule_enabled(&db, &rule.id, false).unwrap(); | |
| 1026 | - | assert!(crate::tags::get_sample_tags(&db, "h6").unwrap().is_empty()); | |
| 1027 | - | ||
| 1028 | - | // Re-enabling must re-apply it across the library, again without an | |
| 1029 | - | // explicit apply_* call. | |
| 1030 | - | set_rule_enabled(&db, &rule.id, true).unwrap(); | |
| 1031 | - | assert!( | |
| 1032 | - | crate::tags::get_sample_tags(&db, "h6") | |
| 1033 | - | .unwrap() | |
| 1034 | - | .contains(&"instrument.drum.kick".to_string()) | |
| 1035 | - | ); | |
| 1036 | - | } | |
| 1037 | - | ||
| 1038 | - | #[test] | |
| 1039 | - | fn update_unknown_rule_errors_not_resurrects() { | |
| 1040 | - | let db = db_with_sample("h7", "kick.wav"); | |
| 1041 | - | let rule = create_rule( | |
| 1042 | - | &db, | |
| 1043 | - | new_rule( | |
| 1044 | - | "kicks", | |
| 1045 | - | vec![cond(RuleField::Name, RuleOp::Contains, "kick")], | |
| 1046 | - | vec![RuleAction::AddTag("instrument.drum.kick".into())], | |
| 1047 | - | ), | |
| 1048 | - | ) | |
| 1049 | - | .unwrap(); | |
| 1050 | - | delete_rule(&db, &rule.id).unwrap(); | |
| 1051 | - | ||
| 1052 | - | // Updating the now-deleted rule must error, not silently re-insert it. | |
| 1053 | - | assert!(matches!( | |
| 1054 | - | update_rule(&db, &rule), | |
| 1055 | - | Err(CoreError::RuleNotFound(_)) | |
| 1056 | - | )); | |
| 1057 | - | assert!(get_rule(&db, &rule.id).unwrap().is_none()); | |
| 1058 | - | } | |
| 1059 | - | ||
| 1060 | - | #[test] | |
| 1061 | - | fn match_mode_any_vs_all() { | |
| 1062 | - | let db = db_with_sample("h5", "snare hit.wav"); | |
| 1063 | - | let any = create_rule( | |
| 1064 | - | &db, | |
| 1065 | - | NewRule { | |
| 1066 | - | match_mode: MatchMode::Any, | |
| 1067 | - | ..new_rule( | |
| 1068 | - | "any", | |
| 1069 | - | vec![ | |
| 1070 | - | cond(RuleField::Name, RuleOp::Contains, "kick"), | |
| 1071 | - | cond(RuleField::Name, RuleOp::Contains, "snare"), | |
| 1072 | - | ], | |
| 1073 | - | vec![RuleAction::AddTag("matched.any".into())], | |
| 1074 | - | ) | |
| 1075 | - | }, | |
| 1076 | - | ) | |
| 1077 | - | .unwrap(); | |
| 1078 | - | assert_eq!(preview_rule_matches(&db, &any).unwrap(), 1); | |
| 1079 | - | ||
| 1080 | - | let all = Rule { | |
| 1081 | - | match_mode: MatchMode::All, | |
| 1082 | - | ..any | |
| 1083 | - | }; | |
| 1084 | - | assert_eq!(preview_rule_matches(&db, &all).unwrap(), 0); | |
| 1085 | - | } | |
| 1086 | - | ||
| 1087 | - | #[test] | |
| 1088 | - | fn stop_action_halts_later_rules() { | |
| 1089 | - | let db = db_with_sample("h6", "kick.wav"); | |
| 1090 | - | create_rule( | |
| 1091 | - | &db, | |
| 1092 | - | NewRule { | |
| 1093 | - | priority: Some(0), | |
| 1094 | - | ..new_rule( | |
| 1095 | - | "first", | |
| 1096 | - | vec![], | |
| 1097 | - | vec![RuleAction::AddTag("a.first".into()), RuleAction::Stop], | |
| 1098 | - | ) | |
| 1099 | - | }, | |
| 1100 | - | ) | |
| 1101 | - | .unwrap(); | |
| 1102 | - | create_rule( | |
| 1103 | - | &db, | |
| 1104 | - | NewRule { | |
| 1105 | - | priority: Some(1), | |
| 1106 | - | ..new_rule( | |
| 1107 | - | "second", | |
| 1108 | - | vec![], | |
| 1109 | - | vec![RuleAction::AddTag("a.second".into())], | |
| 1110 | - | ) | |
| 1111 | - | }, | |
| 1112 | - | ) | |
| 1113 | - | .unwrap(); | |
| 1114 | - | apply_rules_to_sample(&db, "h6").unwrap(); | |
| 1115 | - | let tags = crate::tags::get_sample_tags(&db, "h6").unwrap(); | |
| 1116 | - | assert_eq!(tags, vec!["a.first"]); | |
| 1117 | - | } | |
| 1118 | - | ||
| 1119 | - | #[test] | |
| 1120 | - | fn rules_round_trip_through_db() { | |
| 1121 | - | let db = db_with_sample("h7", "x.wav"); | |
| 1122 | - | let created = create_rule( | |
| 1123 | - | &db, | |
| 1124 | - | new_rule( | |
| 1125 | - | "complex", | |
| 1126 | - | vec![ | |
| 1127 | - | cond(RuleField::SpectralFlatness, RuleOp::Lt, "0.2"), | |
| 1128 | - | cond(RuleField::Tag, RuleOp::StartsWith, "instrument.drum"), | |
| 1129 | - | ], | |
| 1130 | - | vec![ | |
| 1131 | - | RuleAction::AddTag("character.tonal".into()), | |
| 1132 | - | RuleAction::Stop, | |
| 1133 | - | ], | |
| 1134 | - | ), | |
| 1135 | - | ) | |
| 1136 | - | .unwrap(); | |
| 1137 | - | let fetched = get_rule(&db, &created.id).unwrap().unwrap(); | |
| 1138 | - | assert_eq!(created, fetched); | |
| 1139 | - | } | |
| 1140 | - | ||
| 1141 | - | #[test] | |
| 1142 | - | fn empty_ruleset_is_noop() { | |
| 1143 | - | let db = db_with_sample("h8", "kick.wav"); | |
| 1144 | - | assert!(!apply_rules_to_sample(&db, "h8").unwrap()); | |
| 1145 | - | assert!(crate::tags::get_sample_tags(&db, "h8").unwrap().is_empty()); | |
| 1146 | - | } | |
| 1147 | - | ||
| 1148 | - | // Operator / field matrix | |
| 1149 | - | // | |
| 1150 | - | // These exercise `eval_condition` directly against a hand-built `RuleContext`, | |
| 1151 | - | // covering the cross-product of value kind (string / numeric / boolean / list) | |
| 1152 | - | // and operator, plus the missing-value and inapplicable-operator edges that | |
| 1153 | - | // never reach a DB. | |
| 1154 | - | ||
| 1155 | - | /// One condition against a context. | |
| 1156 | - | fn eval(ctx: &RuleContext, field: RuleField, op: RuleOp, value: &str) -> bool { | |
| 1157 | - | eval_condition(ctx, &cond(field, op, value)) | |
| 1158 | - | } | |
| 1159 | - | ||
| 1160 | - | #[test] | |
| 1161 | - | fn str_op_is_case_insensitive_over_all_string_ops() { | |
| 1162 | - | // Positive ops fold case on both sides. | |
| 1163 | - | assert_eq!(str_op(RuleOp::Contains, "Kick DRUM", "kick"), Some(true)); | |
| 1164 | - | assert_eq!(str_op(RuleOp::Contains, "snare", "KICK"), Some(false)); | |
| 1165 | - | assert_eq!(str_op(RuleOp::Equals, "WaV", "wav"), Some(true)); | |
| 1166 | - | assert_eq!(str_op(RuleOp::Equals, "wave", "wav"), Some(false)); | |
| 1167 | - | assert_eq!(str_op(RuleOp::StartsWith, "808_Kick", "808"), Some(true)); | |
| 1168 | - | assert_eq!(str_op(RuleOp::StartsWith, "kick", "808"), Some(false)); | |
| 1169 | - | assert_eq!(str_op(RuleOp::EndsWith, "loop.WAV", ".wav"), Some(true)); | |
| 1170 | - | assert_eq!(str_op(RuleOp::EndsWith, "loop.aif", ".wav"), Some(false)); | |
| 1171 | - | // Negative ops are the logical inverse. | |
| 1172 | - | assert_eq!(str_op(RuleOp::NotContains, "snare", "kick"), Some(true)); | |
| 1173 | - | assert_eq!(str_op(RuleOp::NotContains, "Kick", "kick"), Some(false)); | |
| 1174 | - | assert_eq!(str_op(RuleOp::NotEquals, "snare", "kick"), Some(true)); | |
| 1175 | - | assert_eq!(str_op(RuleOp::NotEquals, "KICK", "kick"), Some(false)); | |
| 1176 | - | // Non-string ops are not str-applicable. | |
| 1177 | - | for op in [RuleOp::Lt, RuleOp::Ge, RuleOp::IsTrue, RuleOp::Exists] { | |
| 1178 | - | assert_eq!( | |
| 1179 | - | str_op(op, "x", "y"), | |
| 1180 | - | None, | |
| 1181 | - | "{op:?} should not be str-applicable" | |
| 1182 | - | ); | |
| 1183 | - | } | |
| 1184 | - | } | |
| 1185 | - | ||
| 1186 | - | #[test] | |
| 1187 | - | fn num_op_covers_every_comparison_and_bad_input() { | |
| 1188 | - | assert!(num_op(RuleOp::Lt, 1.0, "2")); | |
| 1189 | - | assert!(!num_op(RuleOp::Lt, 2.0, "2")); | |
| 1190 | - | assert!(num_op(RuleOp::Le, 2.0, "2")); | |
| 1191 | - | assert!(!num_op(RuleOp::Le, 3.0, "2")); | |
| 1192 | - | assert!(num_op(RuleOp::Gt, 3.0, "2")); | |
| 1193 | - | assert!(!num_op(RuleOp::Gt, 2.0, "2")); | |
| 1194 | - | assert!(num_op(RuleOp::Ge, 2.0, "2")); | |
| 1195 | - | assert!(!num_op(RuleOp::Ge, 1.0, "2")); | |
| 1196 | - | assert!(num_op(RuleOp::Equals, 2.0, "2")); | |
| 1197 | - | assert!(!num_op(RuleOp::Equals, 2.5, "2")); | |
| 1198 | - | assert!(num_op(RuleOp::NotEquals, 2.5, "2")); | |
| 1199 | - | assert!(!num_op(RuleOp::NotEquals, 2.0, "2")); | |
| 1200 | - | // Whitespace in the operand is tolerated. | |
| 1201 | - | assert!(num_op(RuleOp::Ge, 128.0, " 120 ")); | |
| 1202 | - | // Unparseable operand never matches, for any op. | |
| 1203 | - | for op in [ | |
| 1204 | - | RuleOp::Lt, | |
| 1205 | - | RuleOp::Le, | |
| 1206 | - | RuleOp::Gt, | |
| 1207 | - | RuleOp::Ge, | |
| 1208 | - | RuleOp::Equals, | |
| 1209 | - | RuleOp::NotEquals, | |
| 1210 | - | ] { | |
| 1211 | - | assert!( | |
| 1212 | - | !num_op(op, 1.0, "notanumber"), | |
| 1213 | - | "{op:?} should fail on bad operand" | |
| 1214 | - | ); | |
| 1215 | - | } | |
| 1216 | - | // String-only ops are not numeric-applicable. | |
| 1217 | - | assert!(!num_op(RuleOp::Contains, 1.0, "1")); | |
| 1218 | - | assert!(!num_op(RuleOp::StartsWith, 1.0, "1")); | |
| 1219 | - | } | |
| 1220 | - | ||
| 1221 | - | #[test] | |
| 1222 | - | fn num_op_equals_uses_relative_epsilon() { | |
| 1223 | - | // Exact hits and values within the relative tolerance are equal. | |
| 1224 | - | assert!(num_op(RuleOp::Equals, 44100.0, "44100")); | |
| 1225 | - | assert!(num_op(RuleOp::Equals, 1_000_000.0, "1000000.00005")); | |
| 1226 | - | assert!(!num_op(RuleOp::Equals, 1_000_000.0, "1000001")); | |
| 1227 | - | } | |
| 1228 | - | ||
| 1229 | - | #[test] | |
| 1230 | - | fn string_field_present_matrix() { | |
| 1231 | - | let ctx = RuleContext { | |
| 1232 | - | name: "808 Kick.wav".into(), | |
| 1233 | - | ..Default::default() | |
| 1234 | - | }; | |
| 1235 | - | assert!(eval(&ctx, RuleField::Name, RuleOp::Contains, "kick")); | |
| 1236 | - | assert!(!eval(&ctx, RuleField::Name, RuleOp::Contains, "snare")); | |
| 1237 | - | assert!(eval(&ctx, RuleField::Name, RuleOp::StartsWith, "808")); | |
| 1238 | - | assert!(eval(&ctx, RuleField::Name, RuleOp::EndsWith, ".wav")); | |
| 1239 | - | assert!(eval(&ctx, RuleField::Name, RuleOp::NotContains, "snare")); | |
| 1240 | - | assert!(eval(&ctx, RuleField::Name, RuleOp::NotEquals, "other")); | |
| 1241 | - | assert!(eval(&ctx, RuleField::Name, RuleOp::Exists, "")); | |
| 1242 | - | assert!(!eval(&ctx, RuleField::Name, RuleOp::NotExists, "")); | |
| 1243 | - | // A numeric operator on a string field never matches. | |
| 1244 | - | assert!(!eval(&ctx, RuleField::Name, RuleOp::Gt, "0")); | |
| 1245 | - | assert!(!eval(&ctx, RuleField::Name, RuleOp::IsTrue, "")); | |
| 1246 | - | } | |
| 1247 | - | ||
| 1248 | - | #[test] | |
| 1249 | - | fn string_field_missing_matrix() { | |
| 1250 | - | // source_path is None: positive ops fail, negative ops hold, existence flips. | |
| 1251 | - | let ctx = RuleContext::default(); | |
| 1252 | - | assert!(!eval(&ctx, RuleField::SourcePath, RuleOp::Contains, "x")); | |
| 1253 | - | assert!(!eval(&ctx, RuleField::SourcePath, RuleOp::Equals, "x")); | |
| 1254 | - | assert!(!eval(&ctx, RuleField::SourcePath, RuleOp::StartsWith, "x")); | |
| 1255 | - | assert!(eval(&ctx, RuleField::SourcePath, RuleOp::NotContains, "x")); | |
| 1256 | - | assert!(eval(&ctx, RuleField::SourcePath, RuleOp::NotEquals, "x")); | |
| 1257 | - | assert!(!eval(&ctx, RuleField::SourcePath, RuleOp::Exists, "")); | |
| 1258 | - | assert!(eval(&ctx, RuleField::SourcePath, RuleOp::NotExists, "")); | |
| 1259 | - | } | |
| 1260 | - | ||
| 1261 | - | #[test] | |
| 1262 | - | fn numeric_field_present_and_missing_matrix() { | |
| 1263 | - | let ctx = RuleContext { | |
| 1264 | - | bpm: Some(128.0), | |
| 1265 | - | ..Default::default() | |
| 1266 | - | }; | |
| 1267 | - | assert!(eval(&ctx, RuleField::Bpm, RuleOp::Gt, "120")); | |
| 1268 | - | assert!(eval(&ctx, RuleField::Bpm, RuleOp::Ge, "128")); | |
| 1269 | - | assert!(eval(&ctx, RuleField::Bpm, RuleOp::Le, "128")); | |
| 1270 | - | assert!(!eval(&ctx, RuleField::Bpm, RuleOp::Lt, "128")); | |
| 1271 | - | assert!(eval(&ctx, RuleField::Bpm, RuleOp::Equals, "128")); | |
| 1272 | - | assert!(eval(&ctx, RuleField::Bpm, RuleOp::NotEquals, "120")); | |
| 1273 | - | assert!(eval(&ctx, RuleField::Bpm, RuleOp::Exists, "")); | |
| 1274 | - | assert!(!eval(&ctx, RuleField::Bpm, RuleOp::NotExists, "")); | |
| 1275 | - | // A string operator on a numeric field never matches. | |
| 1276 | - | assert!(!eval(&ctx, RuleField::Bpm, RuleOp::Contains, "12")); | |
| 1277 | - | ||
| 1278 | - | // Missing numeric: every comparison fails, only NotExists holds. | |
| 1279 | - | let empty = RuleContext::default(); | |
| 1280 | - | for op in [ | |
| 1281 | - | RuleOp::Lt, | |
| 1282 | - | RuleOp::Le, | |
| 1283 | - | RuleOp::Gt, | |
| 1284 | - | RuleOp::Ge, | |
| 1285 | - | RuleOp::Equals, | |
| 1286 | - | RuleOp::NotEquals, | |
| 1287 | - | ] { | |
| 1288 | - | assert!( | |
| 1289 | - | !eval(&empty, RuleField::Bpm, op, "128"), | |
| 1290 | - | "{op:?} on missing num" | |
| 1291 | - | ); | |
| 1292 | - | } | |
| 1293 | - | assert!(!eval(&empty, RuleField::Bpm, RuleOp::Exists, "")); | |
| 1294 | - | assert!(eval(&empty, RuleField::Bpm, RuleOp::NotExists, "")); | |
| 1295 | - | } | |
| 1296 | - | ||
| 1297 | - | #[test] | |
| 1298 | - | fn boolean_field_matrix() { | |
| 1299 | - | let t = RuleContext { | |
| 1300 | - | is_loop: Some(true), | |
| 1301 | - | ..Default::default() | |
| 1302 | - | }; | |
| 1303 | - | let f = RuleContext { | |
| 1304 | - | is_loop: Some(false), | |
| 1305 | - | ..Default::default() | |
| 1306 | - | }; | |
| 1307 | - | let n = RuleContext::default(); | |
| 1308 | - | assert!(eval(&t, RuleField::IsLoop, RuleOp::IsTrue, "")); | |
| 1309 | - | assert!(!eval(&t, RuleField::IsLoop, RuleOp::IsFalse, "")); | |
| 1310 | - | assert!(eval(&f, RuleField::IsLoop, RuleOp::IsFalse, "")); | |
| 1311 | - | assert!(!eval(&f, RuleField::IsLoop, RuleOp::IsTrue, "")); | |
| 1312 | - | assert!(eval(&t, RuleField::IsLoop, RuleOp::Exists, "")); | |
| 1313 | - | assert!(eval(&n, RuleField::IsLoop, RuleOp::NotExists, "")); | |
| 1314 | - | assert!(!eval(&n, RuleField::IsLoop, RuleOp::IsTrue, "")); | |
| 1315 | - | assert!(!eval(&n, RuleField::IsLoop, RuleOp::IsFalse, "")); | |
| 1316 | - | // Non-boolean operators never match a boolean field. | |
| 1317 | - | assert!(!eval(&t, RuleField::IsLoop, RuleOp::Contains, "true")); | |
| 1318 | - | assert!(!eval(&t, RuleField::IsLoop, RuleOp::Gt, "0")); | |
| 1319 | - | } | |
| 1320 | - | ||
| 1321 | - | #[test] | |
| 1322 | - | fn list_field_matrix() { | |
| 1323 | - | let ctx = RuleContext { | |
| 1324 | - | tags: vec!["instrument.drum.kick".into(), "character.punchy".into()], | |
| 1325 | - | ..Default::default() | |
| 1326 | - | }; | |
| 1327 | - | // Positive ops match if ANY element satisfies. | |
| 1328 | - | assert!(eval(&ctx, RuleField::Tag, RuleOp::Contains, "drum")); | |
| 1329 | - | assert!(eval(&ctx, RuleField::Tag, RuleOp::StartsWith, "instrument")); | |
| 1330 | - | assert!(eval( | |
| 1331 | - | &ctx, | |
| 1332 | - | RuleField::Tag, | |
| 1333 | - | RuleOp::Equals, | |
| 1334 | - | "character.punchy" | |
| 1335 | - | )); | |
| 1336 | - | assert!(!eval(&ctx, RuleField::Tag, RuleOp::Contains, "bass")); | |
| 1337 | - | // Negative ops hold only when NO element matches the positive form. | |
| 1338 | - | assert!(eval(&ctx, RuleField::Tag, RuleOp::NotContains, "bass")); | |
| 1339 | - | assert!(!eval(&ctx, RuleField::Tag, RuleOp::NotContains, "drum")); | |
| 1340 | - | assert!(eval(&ctx, RuleField::Tag, RuleOp::NotEquals, "nope")); | |
| 1341 | - | assert!(!eval( | |
| 1342 | - | &ctx, | |
| 1343 | - | RuleField::Tag, | |
| 1344 | - | RuleOp::NotEquals, | |
| 1345 | - | "character.punchy" | |
| 1346 | - | )); | |
| 1347 | - | // Existence tracks emptiness. | |
| 1348 | - | assert!(eval(&ctx, RuleField::Tag, RuleOp::Exists, "")); | |
| 1349 | - | assert!(!eval(&ctx, RuleField::Tag, RuleOp::NotExists, "")); | |
| 1350 | - | ||
| 1351 | - | let empty = RuleContext::default(); | |
| 1352 | - | assert!(!eval(&empty, RuleField::Tag, RuleOp::Exists, "")); | |
| 1353 | - | assert!(eval(&empty, RuleField::Tag, RuleOp::NotExists, "")); | |
| 1354 | - | // A negative op over an empty list vacuously holds; a positive op does not. | |
| 1355 | - | assert!(eval(&empty, RuleField::Tag, RuleOp::NotContains, "x")); | |
| 1356 | - | assert!(!eval(&empty, RuleField::Tag, RuleOp::Contains, "x")); | |
| 1357 | - | // Inapplicable operator on a list never matches. | |
| 1358 | - | assert!(!eval(&ctx, RuleField::Tag, RuleOp::Gt, "0")); | |
| 1359 | - | assert!(!eval(&ctx, RuleField::Tag, RuleOp::IsTrue, "")); | |
| 1360 | - | } | |
| 1361 | - | ||
| 1362 | - | #[test] | |
| 1363 | - | fn match_mode_all_vs_any_over_conditions() { | |
| 1364 | - | let ctx = RuleContext { | |
| 1365 | - | name: "kick".into(), | |
| 1366 | - | bpm: Some(90.0), | |
| 1367 | - | ..Default::default() | |
| 1368 | - | }; | |
| 1369 | - | let conds = vec![ | |
| 1370 | - | cond(RuleField::Name, RuleOp::Contains, "kick"), // true | |
| 1371 | - | cond(RuleField::Bpm, RuleOp::Gt, "120"), // false | |
| 1372 | - | ]; | |
| 1373 | - | let rule = |mode| Rule { | |
| 1374 | - | id: "r".into(), | |
| 1375 | - | name: "r".into(), | |
| 1376 | - | enabled: true, | |
| 1377 | - | priority: 0, | |
| 1378 | - | match_mode: mode, |
Lines truncated
| @@ -1061,709 +1061,4 @@ | |||
| 1061 | 1061 | } | |
| 1062 | 1062 | ||
| 1063 | 1063 | #[cfg(test)] | |
| 1064 | - | mod tests { | |
| 1065 | - | use super::*; | |
| 1066 | - | use crate::analysis::{self, AnalysisResult}; | |
| 1067 | - | use crate::test_helpers::insert_fake_sample; | |
| 1068 | - | ||
| 1069 | - | /// `(anchor, neighbours)` from a list of `(hash, distance)` pairs. | |
| 1070 | - | fn hood(anchor: &str, rows: &[(&str, f64)]) -> (String, Vec<SimilarResult>) { | |
| 1071 | - | ( | |
| 1072 | - | anchor.to_owned(), | |
| 1073 | - | rows.iter() | |
| 1074 | - | .map(|(hash, distance)| SimilarResult { | |
| 1075 | - | hash: (*hash).to_owned(), | |
| 1076 | - | distance: *distance, | |
| 1077 | - | }) | |
| 1078 | - | .collect(), | |
| 1079 | - | ) | |
| 1080 | - | } | |
| 1081 | - | ||
| 1082 | - | fn ranked(hits: &[BasketHit]) -> Vec<&str> { | |
| 1083 | - | hits.iter().map(|hit| hit.hash.as_str()).collect() | |
| 1084 | - | } | |
| 1085 | - | ||
| 1086 | - | /// The contract the basket rests on: one anchor merged is the one-anchor | |
| 1087 | - | /// query, same rows in the same order. If this ever fails, a basket of one | |
| 1088 | - | /// has become a second kind of search rather than a special case of this one. | |
| 1089 | - | #[test] | |
| 1090 | - | fn a_basket_of_one_is_the_single_anchor_query() { | |
| 1091 | - | let rows = [("b", 0.1), ("c", 0.4), ("d", 0.2)]; | |
| 1092 | - | let hits = merge_neighbourhoods(&[hood("a", &rows)], 10); | |
| 1093 | - | ||
| 1094 | - | let mut expected: Vec<(&str, f64)> = rows.to_vec(); | |
| 1095 | - | expected.sort_by(|a, b| a.1.total_cmp(&b.1)); | |
| 1096 | - | assert_eq!( | |
| 1097 | - | ranked(&hits), | |
| 1098 | - | expected.iter().map(|(h, _)| *h).collect::<Vec<_>>() | |
| 1099 | - | ); | |
| 1100 | - | for hit in &hits { | |
| 1101 | - | assert_eq!(hit.matched, vec!["a".to_owned()]); | |
| 1102 | - | } | |
| 1103 | - | } | |
| 1104 | - | ||
| 1105 | - | /// "Near all of these" is the worst distance, not the sum and not the best. | |
| 1106 | - | /// `near_both` is further from the first anchor than `near_one` is, and still | |
| 1107 | - | /// wins, because `near_one` is not near the second anchor at all. | |
| 1108 | - | #[test] | |
| 1109 | - | fn the_ranking_minimises_the_worst_distance_to_any_anchor() { | |
| 1110 | - | let hits = merge_neighbourhoods( | |
| 1111 | - | &[ | |
| 1112 | - | hood( | |
| 1113 | - | "a", | |
| 1114 | - | &[("near_one", 0.05), ("near_both", 0.30), ("far", 0.90)], | |
| 1115 | - | ), | |
| 1116 | - | hood("b", &[("near_both", 0.20), ("far", 0.80)]), | |
| 1117 | - | ], | |
| 1118 | - | 10, | |
| 1119 | - | ); | |
| 1120 | - | ||
| 1121 | - | assert_eq!(ranked(&hits), vec!["near_both", "near_one", "far"]); | |
| 1122 | - | let near_both = &hits[0]; | |
| 1123 | - | assert!( | |
| 1124 | - | (near_both.score - 0.30).abs() < f64::EPSILON, | |
| 1125 | - | "{near_both:?}" | |
| 1126 | - | ); | |
| 1127 | - | assert_eq!(near_both.matched, vec!["a".to_owned(), "b".to_owned()]); | |
| 1128 | - | } | |
| 1129 | - | ||
| 1130 | - | /// A sample missing from an anchor's neighbourhood is not at a known | |
| 1131 | - | /// distance from it. The last row that anchor did return stands in, so the | |
| 1132 | - | /// score is a lower bound: `near_one` scores 0.80 -- b's worst -- rather than | |
| 1133 | - | /// its own 0.05. | |
| 1134 | - | #[test] | |
| 1135 | - | fn a_sample_missing_from_a_neighbourhood_is_scored_from_that_anchors_last_row() { | |
| 1136 | - | let hits = merge_neighbourhoods( | |
| 1137 | - | &[ | |
| 1138 | - | hood("a", &[("near_one", 0.05)]), | |
| 1139 | - | hood("b", &[("other", 0.80)]), | |
| 1140 | - | ], | |
| 1141 | - | 10, | |
| 1142 | - | ); | |
| 1143 | - | ||
| 1144 | - | let near_one = hits.iter().find(|hit| hit.hash == "near_one").unwrap(); | |
| 1145 | - | assert!((near_one.score - 0.80).abs() < f64::EPSILON, "{near_one:?}"); | |
| 1146 | - | assert_eq!(near_one.matched, vec!["a".to_owned()]); | |
| 1147 | - | } | |
| 1148 | - | ||
| 1149 | - | /// The accounting is the decided part, and it reads out in basket order | |
| 1150 | - | /// however the anchors happened to answer. | |
| 1151 | - | #[test] | |
| 1152 | - | fn each_hit_names_the_anchors_it_answered_to_in_basket_order() { | |
| 1153 | - | let hits = merge_neighbourhoods( | |
| 1154 | - | &[ | |
| 1155 | - | hood("a", &[("x", 0.1)]), | |
| 1156 | - | hood("b", &[]), | |
| 1157 | - | hood("c", &[("x", 0.2)]), | |
| 1158 | - | ], | |
| 1159 | - | 10, | |
| 1160 | - | ); | |
| 1161 | - | ||
| 1162 | - | let x = hits.iter().find(|hit| hit.hash == "x").unwrap(); | |
| 1163 | - | assert_eq!(x.matched, vec!["a".to_owned(), "c".to_owned()]); | |
| 1164 | - | } | |
| 1165 | - | ||
| 1166 | - | /// Every anchor, not merely the one being looked up. A per-anchor query drops | |
| 1167 | - | /// itself, so without this a basket of two hands back its own members. | |
| 1168 | - | #[test] | |
| 1169 | - | fn no_anchor_comes_back_as_its_own_result() { | |
| 1170 | - | let hits = merge_neighbourhoods( | |
| 1171 | - | &[ | |
| 1172 | - | hood("a", &[("b", 0.01), ("x", 0.5)]), | |
| 1173 | - | hood("b", &[("a", 0.01), ("x", 0.6)]), | |
| 1174 | - | ], | |
| 1175 | - | 10, | |
| 1176 | - | ); | |
| 1177 | - | ||
| 1178 | - | assert_eq!(ranked(&hits), vec!["x"]); | |
| 1179 | - | } | |
| 1180 | - | ||
| 1181 | - | /// An anchor that came back cold says nothing about anything, so it must not | |
| 1182 | - | /// make every other anchor's answers unrankable. | |
| 1183 | - | #[test] | |
| 1184 | - | fn an_anchor_with_no_neighbours_penalises_nothing() { | |
| 1185 | - | let hits = merge_neighbourhoods(&[hood("a", &[("x", 0.2)]), hood("b", &[])], 10); | |
| 1186 | - | ||
| 1187 | - | let x = hits.iter().find(|hit| hit.hash == "x").unwrap(); | |
| 1188 | - | assert!((x.score - 0.2).abs() < f64::EPSILON, "{x:?}"); | |
| 1189 | - | } | |
| 1190 | - | ||
| 1191 | - | /// Two queries over an unchanged library give the same list in the same | |
| 1192 | - | /// order, including where scores tie. A ranking that shuffles is one the | |
| 1193 | - | /// reader cannot keep their place in. | |
| 1194 | - | #[test] | |
| 1195 | - | fn the_ranking_is_stable_where_scores_tie() { | |
| 1196 | - | let tied = [ | |
| 1197 | - | hood("a", &[("q", 0.5), ("p", 0.5), ("r", 0.5)]), | |
| 1198 | - | hood("b", &[("r", 0.5), ("q", 0.5), ("p", 0.5)]), | |
| 1199 | - | ]; | |
| 1200 | - | assert_eq!( | |
| 1201 | - | ranked(&merge_neighbourhoods(&tied, 10)), | |
| 1202 | - | vec!["p", "q", "r"] | |
| 1203 | - | ); | |
| 1204 | - | assert_eq!( | |
| 1205 | - | merge_neighbourhoods(&tied, 10), | |
| 1206 | - | merge_neighbourhoods(&tied, 10) | |
| 1207 | - | ); | |
| 1208 | - | } | |
| 1209 | - | ||
| 1210 | - | #[test] | |
| 1211 | - | fn an_empty_basket_answers_nothing() { | |
| 1212 | - | assert!(merge_neighbourhoods(&[], 10).is_empty()); | |
| 1213 | - | } | |
| 1214 | - | ||
| 1215 | - | #[test] | |
| 1216 | - | fn the_limit_takes_the_nearest_rather_than_the_first_found() { | |
| 1217 | - | let hits = merge_neighbourhoods( | |
| 1218 | - | &[hood("a", &[("far", 0.9), ("near", 0.1), ("mid", 0.5)])], | |
| 1219 | - | 2, | |
| 1220 | - | ); | |
| 1221 | - | assert_eq!(ranked(&hits), vec!["near", "mid"]); | |
| 1222 | - | } | |
| 1223 | - | ||
| 1224 | - | /// `feature_distance` is documented as a true weighted-Euclidean metric, the | |
| 1225 | - | /// VP-tree's triangle-inequality prune depends on it (a pseudometric could | |
| 1226 | - | /// drop a genuine nearest neighbour). Prove the property rather than assert it | |
| 1227 | - | /// in prose: over randomized vectors (incl. missing/imputed dims and the | |
| 1228 | - | /// non-finite values the ingest guard maps to imputed), the triangle | |
| 1229 | - | /// inequality d(a,c) <= d(a,b) + d(b,c) must hold. | |
| 1230 | - | #[test] | |
| 1231 | - | fn feature_distance_satisfies_triangle_inequality() { | |
| 1232 | - | // Deterministic LCG (no rand dep; reproducible). Numerical Recipes constants. | |
| 1233 | - | let mut state: u64 = 0x9E37_79B9_7F4A_7C15; | |
| 1234 | - | let mut next = || { | |
| 1235 | - | state = state | |
| 1236 | - | .wrapping_mul(6_364_136_223_846_793_005) | |
| 1237 | - | .wrapping_add(1_442_695_040_888_963_407); | |
| 1238 | - | (state >> 33) as f64 / (1u64 << 31) as f64 // [0, 2) | |
| 1239 | - | }; | |
| 1240 | - | // A dim is None ~1/4 of the time, NaN ~1/16 (exercise the finite guard), | |
| 1241 | - | // else a value in roughly the normalized [0,1] range (with some spill). | |
| 1242 | - | let mut gen_vec = || { | |
| 1243 | - | let mut d = || -> Option<f64> { | |
| 1244 | - | let r = next(); | |
| 1245 | - | if r < 0.5 { | |
| 1246 | - | None | |
| 1247 | - | } else if r < 0.625 { | |
| 1248 | - | Some(f64::NAN) | |
| 1249 | - | } else { | |
| 1250 | - | Some(next() / 2.0) // ~[0,1) | |
| 1251 | - | } | |
| 1252 | - | }; | |
| 1253 | - | FeatureVector { | |
| 1254 | - | bpm: d(), | |
| 1255 | - | duration: d(), | |
| 1256 | - | lufs: d(), | |
| 1257 | - | spectral_centroid: d(), | |
| 1258 | - | spectral_flatness: d(), | |
| 1259 | - | spectral_rolloff: d(), | |
| 1260 | - | zero_crossing_rate: d(), | |
| 1261 | - | onset_strength: d(), | |
| 1262 | - | spectral_bandwidth: d(), | |
| 1263 | - | centroid_variance: d(), | |
| 1264 | - | crest_factor: d(), | |
| 1265 | - | attack_time: d(), | |
| 1266 | - | } | |
| 1267 | - | }; | |
| 1268 | - | ||
| 1269 | - | let weights = FeatureWeights::default(); | |
| 1270 | - | for _ in 0..5000 { | |
| 1271 | - | let a = gen_vec(); | |
| 1272 | - | let b = gen_vec(); | |
| 1273 | - | let c = gen_vec(); | |
| 1274 | - | let dab = feature_distance(&a, &b, &weights); | |
| 1275 | - | let dbc = feature_distance(&b, &c, &weights); | |
| 1276 | - | let dac = feature_distance(&a, &c, &weights); | |
| 1277 | - | // All distances finite (the non-finite guard holds). | |
| 1278 | - | assert!(dab.is_finite() && dbc.is_finite() && dac.is_finite()); | |
| 1279 | - | // Triangle inequality with a small float-rounding tolerance. | |
| 1280 | - | assert!( | |
| 1281 | - | dac <= dab + dbc + 1e-9, | |
| 1282 | - | "triangle inequality violated: d(a,c)={dac} > d(a,b)+d(b,c)={}", | |
| 1283 | - | dab + dbc | |
| 1284 | - | ); | |
| 1285 | - | } | |
| 1286 | - | } | |
| 1287 | - | ||
| 1288 | - | fn insert_with_features(db: &Database, hash: &str, bpm: f64, duration: f64) { | |
| 1289 | - | insert_fake_sample(db, hash); | |
| 1290 | - | let result = AnalysisResult { | |
| 1291 | - | hash: hash.to_string(), | |
| 1292 | - | duration, | |
| 1293 | - | sample_rate: 44100, | |
| 1294 | - | channels: 1, | |
| 1295 | - | peak_db: None, | |
| 1296 | - | rms_db: None, | |
| 1297 | - | lufs: Some(-14.0), | |
| 1298 | - | bpm: Some(bpm), | |
| 1299 | - | musical_key: None, | |
| 1300 | - | is_loop: None, | |
| 1301 | - | spectral_centroid: Some(1000.0), | |
| 1302 | - | spectral_flatness: Some(0.5), | |
| 1303 | - | spectral_rolloff: Some(5000.0), | |
| 1304 | - | zero_crossing_rate: Some(0.1), | |
| 1305 | - | onset_strength: Some(20.0), | |
| 1306 | - | fingerprint: None, | |
| 1307 | - | spectral_bandwidth: Some(2000.0), | |
| 1308 | - | centroid_variance: Some(50000.0), | |
| 1309 | - | crest_factor: Some(3.0), | |
| 1310 | - | attack_time: Some(0.01), | |
| 1311 | - | feature_vector: None, | |
| 1312 | - | feature_version: None, | |
| 1313 | - | }; | |
| 1314 | - | analysis::save_analysis_batch(db, std::slice::from_ref(&result)).unwrap(); | |
| 1315 | - | } | |
| 1316 | - | ||
| 1317 | - | #[test] | |
| 1318 | - | fn normalize_values() { | |
| 1319 | - | let fv = FeatureVector { | |
| 1320 | - | bpm: Some(120.0), | |
| 1321 | - | duration: Some(2.0), | |
| 1322 | - | ..Default::default() | |
| 1323 | - | }; | |
| 1324 | - | let ranges = NormRanges { | |
| 1325 | - | bpm: (100.0, 200.0), | |
| 1326 | - | duration: (1.0, 3.0), | |
| 1327 | - | ..Default::default() | |
| 1328 | - | }; | |
| 1329 | - | let normed = normalize(&fv, &ranges); | |
| 1330 | - | assert!((normed.bpm.unwrap() - 0.2).abs() < 1e-10); | |
| 1331 | - | assert!((normed.duration.unwrap() - 0.5).abs() < 1e-10); | |
| 1332 | - | } | |
| 1333 | - | ||
| 1334 | - | #[test] | |
| 1335 | - | fn distance_zero_for_identical() { | |
| 1336 | - | let fv = FeatureVector { | |
| 1337 | - | bpm: Some(0.5), | |
| 1338 | - | duration: Some(0.5), | |
| 1339 | - | lufs: Some(0.5), | |
| 1340 | - | spectral_centroid: Some(0.5), | |
| 1341 | - | spectral_flatness: Some(0.5), | |
| 1342 | - | spectral_rolloff: Some(0.5), | |
| 1343 | - | zero_crossing_rate: Some(0.5), | |
| 1344 | - | onset_strength: Some(0.5), | |
| 1345 | - | spectral_bandwidth: Some(0.5), | |
| 1346 | - | centroid_variance: Some(0.5), | |
| 1347 | - | crest_factor: Some(0.5), | |
| 1348 | - | attack_time: Some(0.5), | |
| 1349 | - | }; | |
| 1350 | - | let d = feature_distance(&fv, &fv, &FeatureWeights::default()); | |
| 1351 | - | assert!((d - 0.0).abs() < f64::EPSILON); | |
| 1352 | - | } | |
| 1353 | - | ||
| 1354 | - | #[test] | |
| 1355 | - | fn distance_symmetric() { | |
| 1356 | - | let a = FeatureVector { | |
| 1357 | - | bpm: Some(0.0), | |
| 1358 | - | duration: Some(1.0), | |
| 1359 | - | ..Default::default() | |
| 1360 | - | }; | |
| 1361 | - | let b = FeatureVector { | |
| 1362 | - | bpm: Some(1.0), | |
| 1363 | - | duration: Some(0.0), | |
| 1364 | - | ..Default::default() | |
| 1365 | - | }; | |
| 1366 | - | let w = FeatureWeights::default(); | |
| 1367 | - | let d1 = feature_distance(&a, &b, &w); | |
| 1368 | - | let d2 = feature_distance(&b, &a, &w); | |
| 1369 | - | assert!((d1 - d2).abs() < f64::EPSILON); | |
| 1370 | - | } | |
| 1371 | - | ||
| 1372 | - | #[test] | |
| 1373 | - | fn distance_satisfies_triangle_inequality_with_missing_dims() { | |
| 1374 | - | // The VP-tree index prunes on the triangle inequality, so the distance | |
| 1375 | - | // must satisfy d(a,c) <= d(a,b) + d(b,c) even when vectors have | |
| 1376 | - | // different sets of missing dimensions (the case the old per-pair | |
| 1377 | - | // denominator broke). | |
| 1378 | - | let w = FeatureWeights::default(); | |
| 1379 | - | let a = FeatureVector { | |
| 1380 | - | bpm: Some(0.1), | |
| 1381 | - | duration: Some(0.9), | |
| 1382 | - | ..Default::default() | |
| 1383 | - | }; | |
| 1384 | - | let b = FeatureVector { | |
| 1385 | - | bpm: Some(0.8), | |
| 1386 | - | lufs: Some(0.2), | |
| 1387 | - | ..Default::default() | |
| 1388 | - | }; | |
| 1389 | - | let c = FeatureVector { | |
| 1390 | - | duration: Some(0.1), | |
| 1391 | - | spectral_centroid: Some(0.7), | |
| 1392 | - | ..Default::default() | |
| 1393 | - | }; | |
| 1394 | - | ||
| 1395 | - | let ab = feature_distance(&a, &b, &w); | |
| 1396 | - | let bc = feature_distance(&b, &c, &w); | |
| 1397 | - | let ac = feature_distance(&a, &c, &w); | |
| 1398 | - | assert!( | |
| 1399 | - | ac <= ab + bc + 1e-9, | |
| 1400 | - | "triangle inequality violated: d(a,c)={ac} > d(a,b)+d(b,c)={}", | |
| 1401 | - | ab + bc | |
| 1402 | - | ); | |
| 1403 | - | } | |
| 1404 | - | ||
| 1405 | - | #[test] | |
| 1406 | - | fn ranking_correctness() { | |
| 1407 | - | let db = Database::open_in_memory().unwrap(); | |
| 1408 | - | insert_with_features(&db, "ref", 120.0, 1.0); | |
| 1409 | - | insert_with_features(&db, "close", 122.0, 1.1); | |
| 1410 | - | insert_with_features(&db, "far", 200.0, 10.0); | |
| 1411 | - | ||
| 1412 | - | let results = find_similar(&db, "ref", 10).unwrap(); | |
| 1413 | - | assert_eq!(results.len(), 2); | |
| 1414 | - | assert_eq!(results[0].hash, "close"); | |
| 1415 | - | assert_eq!(results[1].hash, "far"); | |
| 1416 | - | assert!(results[0].distance < results[1].distance); | |
| 1417 | - | } | |
| 1418 | - | ||
| 1419 | - | #[test] | |
| 1420 | - | fn limit_respected() { | |
| 1421 | - | let db = Database::open_in_memory().unwrap(); | |
| 1422 | - | insert_with_features(&db, "ref", 120.0, 1.0); | |
| 1423 | - | insert_with_features(&db, "a", 121.0, 1.0); | |
| 1424 | - | insert_with_features(&db, "b", 122.0, 1.0); | |
| 1425 | - | insert_with_features(&db, "c", 123.0, 1.0); | |
| 1426 | - | ||
| 1427 | - | let results = find_similar(&db, "ref", 2).unwrap(); | |
| 1428 | - | assert_eq!(results.len(), 2); | |
| 1429 | - | } | |
| 1430 | - | ||
| 1431 | - | #[test] | |
| 1432 | - | fn missing_hash_errors() { | |
| 1433 | - | let db = Database::open_in_memory().unwrap(); | |
| 1434 | - | let result = find_similar(&db, "nonexistent", 10); | |
| 1435 | - | assert!(result.is_err()); | |
| 1436 | - | } | |
| 1437 | - | ||
| 1438 | - | // --- SimilarityIndex tests --- | |
| 1439 | - | ||
| 1440 | - | #[test] | |
| 1441 | - | fn index_build_empty() { | |
| 1442 | - | let db = Database::open_in_memory().unwrap(); | |
| 1443 | - | let idx = SimilarityIndex::build(&db).unwrap(); | |
| 1444 | - | assert!(idx.is_empty()); | |
| 1445 | - | assert_eq!(idx.len(), 0); | |
| 1446 | - | } | |
| 1447 | - | ||
| 1448 | - | #[test] | |
| 1449 | - | fn index_ranking_matches_linear() { | |
| 1450 | - | let db = Database::open_in_memory().unwrap(); | |
| 1451 | - | insert_with_features(&db, "ref", 120.0, 1.0); | |
| 1452 | - | insert_with_features(&db, "close", 122.0, 1.1); | |
| 1453 | - | insert_with_features(&db, "far", 200.0, 10.0); | |
| 1454 | - | ||
| 1455 | - | let linear = find_similar(&db, "ref", 10).unwrap(); | |
| 1456 | - | let idx = SimilarityIndex::build(&db).unwrap(); | |
| 1457 | - | let ref_features = load_features(&db, "ref").unwrap(); | |
| 1458 | - | let indexed = idx.find_similar("ref", &ref_features, 10); | |
| 1459 | - | ||
| 1460 | - | // Same ranking order. | |
| 1461 | - | assert_eq!(linear.len(), indexed.len()); | |
| 1462 | - | for (l, i) in linear.iter().zip(indexed.iter()) { | |
| 1463 | - | assert_eq!(l.hash, i.hash, "Ranking order differs"); | |
| 1464 | - | } | |
| 1465 | - | } | |
| 1466 | - | ||
| 1467 | - | #[test] | |
| 1468 | - | fn index_limit_respected() { | |
| 1469 | - | let db = Database::open_in_memory().unwrap(); | |
| 1470 | - | insert_with_features(&db, "ref", 120.0, 1.0); | |
| 1471 | - | insert_with_features(&db, "a", 121.0, 1.0); | |
| 1472 | - | insert_with_features(&db, "b", 122.0, 1.0); | |
| 1473 | - | insert_with_features(&db, "c", 123.0, 1.0); | |
| 1474 | - | ||
| 1475 | - | let idx = SimilarityIndex::build(&db).unwrap(); | |
| 1476 | - | let ref_features = load_features(&db, "ref").unwrap(); | |
| 1477 | - | let results = idx.find_similar("ref", &ref_features, 2); | |
| 1478 | - | assert_eq!(results.len(), 2); | |
| 1479 | - | } | |
| 1480 | - | ||
| 1481 | - | #[test] | |
| 1482 | - | fn index_excludes_self() { | |
| 1483 | - | let db = Database::open_in_memory().unwrap(); | |
| 1484 | - | insert_with_features(&db, "only", 120.0, 1.0); | |
| 1485 | - | ||
| 1486 | - | let idx = SimilarityIndex::build(&db).unwrap(); | |
| 1487 | - | let features = load_features(&db, "only").unwrap(); | |
| 1488 | - | let results = idx.find_similar("only", &features, 10); | |
| 1489 | - | assert!(results.is_empty()); | |
| 1490 | - | } | |
| 1491 | - | ||
| 1492 | - | // --- NeighbourGraph tests --- | |
| 1493 | - | ||
| 1494 | - | /// A five-sample fixture whose pairwise distances are all distinct, so a | |
| 1495 | - | /// ranking disagreement is a real disagreement and never a tie broken two | |
| 1496 | - | /// ways. Only bpm and duration vary; the other ten dimensions are constant | |
| 1497 | - | /// across `insert_with_features`, which is what makes the arithmetic here | |
| 1498 | - | /// checkable by hand. | |
| 1499 | - | fn fixture_library() -> Database { | |
| 1500 | - | let db = Database::open_in_memory().unwrap(); | |
| 1501 | - | insert_with_features(&db, "a", 100.0, 1.0); | |
| 1502 | - | insert_with_features(&db, "b", 120.0, 2.0); | |
| 1503 | - | insert_with_features(&db, "c", 150.0, 4.0); | |
| 1504 | - | insert_with_features(&db, "d", 180.0, 7.0); | |
| 1505 | - | insert_with_features(&db, "e", 200.0, 11.0); | |
| 1506 | - | db | |
| 1507 | - | } | |
| 1508 | - | ||
| 1509 | - | /// Every stored edge, in a comparable order. | |
| 1510 | - | fn stored_edges(db: &Database) -> Vec<(String, String, f64, i64)> { | |
| 1511 | - | let mut stmt = db | |
| 1512 | - | .conn() | |
| 1513 | - | .prepare( | |
| 1514 | - | "SELECT hash, neighbour_hash, distance, rank FROM sample_neighbours | |
| 1515 | - | ORDER BY hash, rank", | |
| 1516 | - | ) | |
| 1517 | - | .unwrap(); | |
| 1518 | - | stmt.query_map([], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?))) | |
| 1519 | - | .unwrap() | |
| 1520 | - | .collect::<std::result::Result<_, _>>() | |
| 1521 | - | .unwrap() | |
| 1522 | - | } | |
| 1523 | - | ||
| 1524 | - | /// The graph's ranking must be the brute-force oracle's ranking, for every | |
| 1525 | - | /// sample. This is the whole correctness claim: the table is a cache of an | |
| 1526 | - | /// answer that already has a right value, so it is checked against the | |
| 1527 | - | /// definition rather than against the index that produced it. | |
| 1528 | - | #[test] | |
| 1529 | - | fn graph_agrees_with_the_brute_force_oracle() { | |
| 1530 | - | let db = fixture_library(); | |
| 1531 | - | let index = SimilarityIndex::build(&db).unwrap(); | |
| 1532 | - | NeighbourGraph::rebuild(&db, &index).unwrap(); | |
| 1533 | - | ||
| 1534 | - | for hash in ["a", "b", "c", "d", "e"] { | |
| 1535 | - | let oracle = find_similar(&db, hash, GRAPH_K).unwrap(); | |
| 1536 | - | let stored = NeighbourGraph::neighbours(&db, hash, GRAPH_K) | |
| 1537 | - | .unwrap() | |
| 1538 | - | .unwrap_or_else(|| panic!("graph declined to answer for {hash}")); | |
| 1539 | - | let oracle_hashes: Vec<&str> = oracle.iter().map(|r| r.hash.as_str()).collect(); | |
| 1540 | - | let stored_hashes: Vec<&str> = stored.iter().map(|r| r.hash.as_str()).collect(); | |
| 1541 | - | assert_eq!(stored_hashes, oracle_hashes, "ranking differs for {hash}"); | |
| 1542 | - | for (s, o) in stored.iter().zip(oracle.iter()) { | |
| 1543 | - | assert!( | |
| 1544 | - | (s.distance - o.distance).abs() < 1e-12, | |
| 1545 | - | "distance differs for {hash} -> {}: {} vs {}", | |
| 1546 | - | s.hash, | |
| 1547 | - | s.distance, | |
| 1548 | - | o.distance | |
| 1549 | - | ); | |
| 1550 | - | } | |
| 1551 | - | } | |
| 1552 | - | } | |
| 1553 | - | ||
| 1554 | - | /// Patching one sample in must land on exactly the graph a rebuild would | |
| 1555 | - | /// have produced. If it does not, the incremental path is a second | |
| 1556 | - | /// implementation of the same idea rather than a cheaper route to it. | |
| 1557 | - | #[test] | |
| 1558 | - | fn incremental_insert_matches_a_full_rebuild() { | |
| 1559 | - | let db = fixture_library(); | |
| 1560 | - | let before = SimilarityIndex::build(&db).unwrap(); |
Lines truncated
| @@ -678,642 +678,4 @@ | |||
| 678 | 678 | } | |
| 679 | 679 | ||
| 680 | 680 | #[cfg(test)] | |
| 681 | - | mod tests { | |
| 682 | - | use super::*; | |
| 683 | - | use crate::test_helpers::insert_fake_sample; | |
| 684 | - | ||
| 685 | - | fn setup() -> Database { | |
| 686 | - | Database::open_in_memory().unwrap() | |
| 687 | - | } | |
| 688 | - | ||
| 689 | - | #[test] | |
| 690 | - | fn create_and_list_vfs() { | |
| 691 | - | let db = setup(); | |
| 692 | - | let id = create_vfs(&db, "Library").unwrap(); | |
| 693 | - | let list = list_vfs(&db).unwrap(); | |
| 694 | - | assert_eq!(list.len(), 1); | |
| 695 | - | assert_eq!(list[0].id, id); | |
| 696 | - | assert_eq!(list[0].name, "Library"); | |
| 697 | - | } | |
| 698 | - | ||
| 699 | - | #[test] | |
| 700 | - | fn full_tree_excludes_tombstoned_files_keeps_folders() { | |
| 701 | - | let db = setup(); | |
| 702 | - | let vfs_id = create_vfs(&db, "Library").unwrap(); | |
| 703 | - | let dir = create_directory(&db, vfs_id, None, "Drums").unwrap(); | |
| 704 | - | insert_fake_sample(&db, "live"); | |
| 705 | - | insert_fake_sample(&db, "dead"); | |
| 706 | - | create_sample_link( | |
| 707 | - | &db, | |
| 708 | - | vfs_id, | |
| 709 | - | Some(dir), | |
| 710 | - | "live.wav", | |
| 711 | - | &crate::SampleHash::from_trusted("live"), | |
| 712 | - | ) | |
| 713 | - | .unwrap(); | |
| 714 | - | create_sample_link( | |
| 715 | - | &db, | |
| 716 | - | vfs_id, | |
| 717 | - | Some(dir), | |
| 718 | - | "dead.wav", | |
| 719 | - | &crate::SampleHash::from_trusted("dead"), | |
| 720 | - | ) | |
| 721 | - | .unwrap(); | |
| 722 | - | ||
| 723 | - | let before = list_full_tree(&db).unwrap(); | |
| 724 | - | assert!(before.iter().any(|n| n.path.ends_with("dead.wav"))); | |
| 725 | - | ||
| 726 | - | // Tombstone "dead": its leaf must vanish from the mirror's tree, but the | |
| 727 | - | // Drums folder (which still holds live.wav) must remain. | |
| 728 | - | crate::store::tombstone_sample(&db, "dead").unwrap(); | |
| 729 | - | let after = list_full_tree(&db).unwrap(); | |
| 730 | - | assert!(!after.iter().any(|n| n.path.ends_with("dead.wav"))); | |
| 731 | - | assert!(after.iter().any(|n| n.path.ends_with("live.wav"))); | |
| 732 | - | assert!( | |
| 733 | - | after | |
| 734 | - | .iter() | |
| 735 | - | .any(|n| n.node_type == NodeType::Directory && n.path.ends_with("Drums")) | |
| 736 | - | ); | |
| 737 | - | } | |
| 738 | - | ||
| 739 | - | #[test] | |
| 740 | - | fn rename_vfs_works() { | |
| 741 | - | let db = setup(); | |
| 742 | - | let id = create_vfs(&db, "Old").unwrap(); | |
| 743 | - | rename_vfs(&db, id, "New").unwrap(); | |
| 744 | - | let list = list_vfs(&db).unwrap(); | |
| 745 | - | assert_eq!(list[0].name, "New"); | |
| 746 | - | } | |
| 747 | - | ||
| 748 | - | #[test] | |
| 749 | - | fn delete_vfs_cascades_nodes() { | |
| 750 | - | let db = setup(); | |
| 751 | - | let vfs_id = create_vfs(&db, "Test").unwrap(); | |
| 752 | - | create_directory(&db, vfs_id, None, "folder").unwrap(); | |
| 753 | - | ||
| 754 | - | delete_vfs(&db, vfs_id).unwrap(); | |
| 755 | - | ||
| 756 | - | let count: i64 = db | |
| 757 | - | .conn() | |
| 758 | - | .query_row("SELECT COUNT(*) FROM vfs_nodes", [], |row| row.get(0)) | |
| 759 | - | .unwrap(); | |
| 760 | - | assert_eq!(count, 0); | |
| 761 | - | } | |
| 762 | - | ||
| 763 | - | #[test] | |
| 764 | - | fn directory_tree_crud() { | |
| 765 | - | let db = setup(); | |
| 766 | - | let vfs_id = create_vfs(&db, "Lib").unwrap(); | |
| 767 | - | ||
| 768 | - | let dir_id = create_directory(&db, vfs_id, None, "Drums").unwrap(); | |
| 769 | - | let sub_id = create_directory(&db, vfs_id, Some(dir_id), "Kicks").unwrap(); | |
| 770 | - | ||
| 771 | - | let root_children = list_children(&db, vfs_id, None).unwrap(); | |
| 772 | - | assert_eq!(root_children.len(), 1); | |
| 773 | - | assert_eq!(root_children[0].name, "Drums"); | |
| 774 | - | ||
| 775 | - | let sub_children = list_children(&db, vfs_id, Some(dir_id)).unwrap(); | |
| 776 | - | assert_eq!(sub_children.len(), 1); | |
| 777 | - | assert_eq!(sub_children[0].name, "Kicks"); | |
| 778 | - | assert_eq!(sub_children[0].id, sub_id); | |
| 779 | - | } | |
| 780 | - | ||
| 781 | - | #[test] | |
| 782 | - | fn sample_links() { | |
| 783 | - | let db = setup(); | |
| 784 | - | insert_fake_sample(&db, "abc123"); | |
| 785 | - | let vfs_id = create_vfs(&db, "Lib").unwrap(); | |
| 786 | - | ||
| 787 | - | let node_id = create_sample_link( | |
| 788 | - | &db, | |
| 789 | - | vfs_id, | |
| 790 | - | None, | |
| 791 | - | "kick.wav", | |
| 792 | - | &crate::SampleHash::from_trusted("abc123"), | |
| 793 | - | ) | |
| 794 | - | .unwrap(); | |
| 795 | - | let node = get_node(&db, node_id).unwrap(); | |
| 796 | - | assert_eq!(node.node_type, NodeType::Sample); | |
| 797 | - | assert_eq!(node.sample_hash.as_deref(), Some("abc123")); | |
| 798 | - | } | |
| 799 | - | ||
| 800 | - | #[test] | |
| 801 | - | fn rename_and_move_node() { | |
| 802 | - | let db = setup(); | |
| 803 | - | let vfs_id = create_vfs(&db, "Lib").unwrap(); | |
| 804 | - | let dir_a = create_directory(&db, vfs_id, None, "A").unwrap(); | |
| 805 | - | let dir_b = create_directory(&db, vfs_id, None, "B").unwrap(); | |
| 806 | - | let child = create_directory(&db, vfs_id, Some(dir_a), "Child").unwrap(); | |
| 807 | - | ||
| 808 | - | rename_node(&db, child, "Renamed").unwrap(); | |
| 809 | - | let node = get_node(&db, child).unwrap(); | |
| 810 | - | assert_eq!(node.name, "Renamed"); | |
| 811 | - | ||
| 812 | - | move_node(&db, child, Some(dir_b)).unwrap(); | |
| 813 | - | let node = get_node(&db, child).unwrap(); | |
| 814 | - | assert_eq!(node.parent_id, Some(dir_b)); | |
| 815 | - | } | |
| 816 | - | ||
| 817 | - | #[test] | |
| 818 | - | fn delete_node_cascades() { | |
| 819 | - | let db = setup(); | |
| 820 | - | let vfs_id = create_vfs(&db, "Lib").unwrap(); | |
| 821 | - | let parent = create_directory(&db, vfs_id, None, "Parent").unwrap(); | |
| 822 | - | create_directory(&db, vfs_id, Some(parent), "Child").unwrap(); | |
| 823 | - | ||
| 824 | - | delete_node(&db, parent).unwrap(); | |
| 825 | - | ||
| 826 | - | let count: i64 = db | |
| 827 | - | .conn() | |
| 828 | - | .query_row("SELECT COUNT(*) FROM vfs_nodes", [], |row| row.get(0)) | |
| 829 | - | .unwrap(); | |
| 830 | - | assert_eq!(count, 0); | |
| 831 | - | } | |
| 832 | - | ||
| 833 | - | #[test] | |
| 834 | - | fn root_level_name_conflict() { | |
| 835 | - | let db = setup(); | |
| 836 | - | let vfs_id = create_vfs(&db, "Lib").unwrap(); | |
| 837 | - | create_directory(&db, vfs_id, None, "Drums").unwrap(); | |
| 838 | - | ||
| 839 | - | let result = create_directory(&db, vfs_id, None, "Drums"); | |
| 840 | - | assert!(matches!(result, Err(CoreError::NameConflict(_)))); | |
| 841 | - | } | |
| 842 | - | ||
| 843 | - | #[test] | |
| 844 | - | fn root_name_uniqueness_enforced_by_index() { | |
| 845 | - | // The partial unique index (M029) is the backstop for the non-atomic | |
| 846 | - | // COUNT-then-INSERT check: a second same-name root insert must be | |
| 847 | - | // rejected by the DB even when it bypasses the Rust-side check, the | |
| 848 | - | // concurrent-insert race past check_root_name_conflict. | |
| 849 | - | let db = setup(); | |
| 850 | - | let vfs_id = create_vfs(&db, "Lib").unwrap(); | |
| 851 | - | db.conn() | |
| 852 | - | .execute( | |
| 853 | - | "INSERT INTO vfs_nodes (vfs_id, parent_id, name, node_type, created_at) | |
| 854 | - | VALUES (?1, NULL, 'Drums', 'directory', 0)", | |
| 855 | - | rusqlite::params![vfs_id], | |
| 856 | - | ) | |
| 857 | - | .unwrap(); | |
| 858 | - | let dup = db.conn().execute( | |
| 859 | - | "INSERT INTO vfs_nodes (vfs_id, parent_id, name, node_type, created_at) | |
| 860 | - | VALUES (?1, NULL, 'Drums', 'directory', 0)", | |
| 861 | - | rusqlite::params![vfs_id], | |
| 862 | - | ); | |
| 863 | - | assert!( | |
| 864 | - | dup.is_err(), | |
| 865 | - | "partial unique index must reject a duplicate root name" | |
| 866 | - | ); | |
| 867 | - | assert!(crate::error::is_unique_violation(&dup.unwrap_err())); | |
| 868 | - | } | |
| 869 | - | ||
| 870 | - | #[test] | |
| 871 | - | fn breadcrumb_trail() { | |
| 872 | - | let db = setup(); | |
| 873 | - | let vfs_id = create_vfs(&db, "Lib").unwrap(); | |
| 874 | - | let a = create_directory(&db, vfs_id, None, "A").unwrap(); | |
| 875 | - | let b = create_directory(&db, vfs_id, Some(a), "B").unwrap(); | |
| 876 | - | let c = create_directory(&db, vfs_id, Some(b), "C").unwrap(); | |
| 877 | - | ||
| 878 | - | let crumbs = get_breadcrumb(&db, c).unwrap(); | |
| 879 | - | assert_eq!(crumbs.len(), 3); | |
| 880 | - | assert_eq!(crumbs[0].name, "A"); | |
| 881 | - | assert_eq!(crumbs[1].name, "B"); | |
| 882 | - | assert_eq!(crumbs[2].name, "C"); | |
| 883 | - | } | |
| 884 | - | ||
| 885 | - | #[test] | |
| 886 | - | fn list_children_sorts_dirs_first() { | |
| 887 | - | let db = setup(); | |
| 888 | - | insert_fake_sample(&db, "sample1"); | |
| 889 | - | let vfs_id = create_vfs(&db, "Lib").unwrap(); | |
| 890 | - | ||
| 891 | - | create_sample_link( | |
| 892 | - | &db, | |
| 893 | - | vfs_id, | |
| 894 | - | None, | |
| 895 | - | "zzz.wav", | |
| 896 | - | &crate::SampleHash::from_trusted("sample1"), | |
| 897 | - | ) | |
| 898 | - | .unwrap(); | |
| 899 | - | create_directory(&db, vfs_id, None, "AAA").unwrap(); | |
| 900 | - | ||
| 901 | - | let children = list_children(&db, vfs_id, None).unwrap(); | |
| 902 | - | assert_eq!(children[0].node_type, NodeType::Directory); | |
| 903 | - | assert_eq!(children[1].node_type, NodeType::Sample); | |
| 904 | - | } | |
| 905 | - | ||
| 906 | - | #[test] | |
| 907 | - | fn collect_subtree_flat() { | |
| 908 | - | let db = setup(); | |
| 909 | - | let vfs_id = create_vfs(&db, "Lib").unwrap(); | |
| 910 | - | insert_fake_sample(&db, "s1"); | |
| 911 | - | let dir = create_directory(&db, vfs_id, None, "Dir").unwrap(); | |
| 912 | - | create_sample_link( | |
| 913 | - | &db, | |
| 914 | - | vfs_id, | |
| 915 | - | Some(dir), | |
| 916 | - | "s1.wav", | |
| 917 | - | &crate::SampleHash::from_trusted("s1"), | |
| 918 | - | ) | |
| 919 | - | .unwrap(); | |
| 920 | - | ||
| 921 | - | let subtree = collect_subtree(&db, dir).unwrap(); | |
| 922 | - | assert_eq!(subtree.len(), 2); // dir + sample | |
| 923 | - | assert!(subtree.iter().any(|n| n.name == "Dir")); | |
| 924 | - | assert!(subtree.iter().any(|n| n.name == "s1.wav")); | |
| 925 | - | } | |
| 926 | - | ||
| 927 | - | #[test] | |
| 928 | - | fn collect_subtree_nested() { | |
| 929 | - | let db = setup(); | |
| 930 | - | let vfs_id = create_vfs(&db, "Lib").unwrap(); | |
| 931 | - | let a = create_directory(&db, vfs_id, None, "A").unwrap(); | |
| 932 | - | let b = create_directory(&db, vfs_id, Some(a), "B").unwrap(); | |
| 933 | - | create_directory(&db, vfs_id, Some(b), "C").unwrap(); | |
| 934 | - | ||
| 935 | - | let subtree = collect_subtree(&db, a).unwrap(); | |
| 936 | - | assert_eq!(subtree.len(), 3); | |
| 937 | - | let names: Vec<&str> = subtree.iter().map(|n| n.name.as_str()).collect(); | |
| 938 | - | assert!(names.contains(&"A")); | |
| 939 | - | assert!(names.contains(&"B")); | |
| 940 | - | assert!(names.contains(&"C")); | |
| 941 | - | } | |
| 942 | - | ||
| 943 | - | #[test] | |
| 944 | - | fn collect_subtree_empty_dir() { | |
| 945 | - | let db = setup(); | |
| 946 | - | let vfs_id = create_vfs(&db, "Lib").unwrap(); | |
| 947 | - | let dir = create_directory(&db, vfs_id, None, "Empty").unwrap(); | |
| 948 | - | ||
| 949 | - | let subtree = collect_subtree(&db, dir).unwrap(); | |
| 950 | - | assert_eq!(subtree.len(), 1); | |
| 951 | - | assert_eq!(subtree[0].name, "Empty"); | |
| 952 | - | } | |
| 953 | - | ||
| 954 | - | #[test] | |
| 955 | - | fn list_all_directories_works() { | |
| 956 | - | let db = setup(); | |
| 957 | - | let vfs_id = create_vfs(&db, "Lib").unwrap(); | |
| 958 | - | let drums = create_directory(&db, vfs_id, None, "Drums").unwrap(); | |
| 959 | - | create_directory(&db, vfs_id, Some(drums), "Kicks").unwrap(); | |
| 960 | - | create_directory(&db, vfs_id, Some(drums), "Snares").unwrap(); | |
| 961 | - | create_directory(&db, vfs_id, None, "Vocals").unwrap(); | |
| 962 | - | ||
| 963 | - | let dirs = list_all_directories(&db, vfs_id).unwrap(); | |
| 964 | - | let paths: Vec<&str> = dirs.iter().map(|(_, p)| p.as_str()).collect(); | |
| 965 | - | assert_eq!( | |
| 966 | - | paths, | |
| 967 | - | vec!["Drums", "Drums/Kicks", "Drums/Snares", "Vocals"] | |
| 968 | - | ); | |
| 969 | - | } | |
| 970 | - | ||
| 971 | - | #[test] | |
| 972 | - | fn list_all_directories_empty_vfs() { | |
| 973 | - | let db = setup(); | |
| 974 | - | let vfs_id = create_vfs(&db, "Lib").unwrap(); | |
| 975 | - | let dirs = list_all_directories(&db, vfs_id).unwrap(); | |
| 976 | - | assert!(dirs.is_empty()); | |
| 977 | - | } | |
| 978 | - | ||
| 979 | - | #[test] | |
| 980 | - | fn enriched_query_includes_cloud_only_false() { | |
| 981 | - | let db = setup(); | |
| 982 | - | let vfs_id = create_vfs(&db, "Lib").unwrap(); | |
| 983 | - | crate::test_helpers::insert_fake_sample(&db, "hash1"); | |
| 984 | - | create_sample_link( | |
| 985 | - | &db, | |
| 986 | - | vfs_id, | |
| 987 | - | None, | |
| 988 | - | "kick.wav", | |
| 989 | - | &crate::SampleHash::from_trusted("hash1"), | |
| 990 | - | ) | |
| 991 | - | .unwrap(); | |
| 992 | - | ||
| 993 | - | let nodes = list_children_enriched(&db, vfs_id, None).unwrap(); | |
| 994 | - | assert_eq!(nodes.len(), 1); | |
| 995 | - | assert!(!nodes[0].cloud_only); | |
| 996 | - | } | |
| 997 | - | ||
| 998 | - | #[test] | |
| 999 | - | fn enriched_query_includes_cloud_only_true() { | |
| 1000 | - | let db = setup(); | |
| 1001 | - | let vfs_id = create_vfs(&db, "Lib").unwrap(); | |
| 1002 | - | crate::test_helpers::insert_fake_sample(&db, "hash1"); | |
| 1003 | - | create_sample_link( | |
| 1004 | - | &db, | |
| 1005 | - | vfs_id, | |
| 1006 | - | None, | |
| 1007 | - | "kick.wav", | |
| 1008 | - | &crate::SampleHash::from_trusted("hash1"), | |
| 1009 | - | ) | |
| 1010 | - | .unwrap(); | |
| 1011 | - | ||
| 1012 | - | // Set cloud_only=1 directly | |
| 1013 | - | db.conn() | |
| 1014 | - | .execute("UPDATE samples SET cloud_only = 1 WHERE hash = 'hash1'", []) | |
| 1015 | - | .unwrap(); | |
| 1016 | - | ||
| 1017 | - | let nodes = list_children_enriched(&db, vfs_id, None).unwrap(); | |
| 1018 | - | assert_eq!(nodes.len(), 1); | |
| 1019 | - | assert!(nodes[0].cloud_only); | |
| 1020 | - | } | |
| 1021 | - | ||
| 1022 | - | #[test] | |
| 1023 | - | fn enriched_query_directory_cloud_only_false() { | |
| 1024 | - | let db = setup(); | |
| 1025 | - | let vfs_id = create_vfs(&db, "Lib").unwrap(); | |
| 1026 | - | create_directory(&db, vfs_id, None, "Drums").unwrap(); | |
| 1027 | - | ||
| 1028 | - | let nodes = list_children_enriched(&db, vfs_id, None).unwrap(); | |
| 1029 | - | assert_eq!(nodes.len(), 1); | |
| 1030 | - | // Directories have no sample_hash so cloud_only defaults to false | |
| 1031 | - | assert!(!nodes[0].cloud_only); | |
| 1032 | - | } | |
| 1033 | - | ||
| 1034 | - | #[test] | |
| 1035 | - | fn find_nodes_by_hashes_returns_correct_results() { | |
| 1036 | - | let db = setup(); | |
| 1037 | - | let vfs_id = create_vfs(&db, "Lib").unwrap(); | |
| 1038 | - | insert_fake_sample(&db, "hash_a"); | |
| 1039 | - | insert_fake_sample(&db, "hash_b"); | |
| 1040 | - | insert_fake_sample(&db, "hash_c"); | |
| 1041 | - | create_sample_link( | |
| 1042 | - | &db, | |
| 1043 | - | vfs_id, | |
| 1044 | - | None, | |
| 1045 | - | "a.wav", | |
| 1046 | - | &crate::SampleHash::from_trusted("hash_a"), | |
| 1047 | - | ) | |
| 1048 | - | .unwrap(); | |
| 1049 | - | create_sample_link( | |
| 1050 | - | &db, | |
| 1051 | - | vfs_id, | |
| 1052 | - | None, | |
| 1053 | - | "b.wav", | |
| 1054 | - | &crate::SampleHash::from_trusted("hash_b"), | |
| 1055 | - | ) | |
| 1056 | - | .unwrap(); | |
| 1057 | - | create_sample_link( | |
| 1058 | - | &db, | |
| 1059 | - | vfs_id, | |
| 1060 | - | None, | |
| 1061 | - | "c.wav", | |
| 1062 | - | &crate::SampleHash::from_trusted("hash_c"), | |
| 1063 | - | ) | |
| 1064 | - | .unwrap(); | |
| 1065 | - | ||
| 1066 | - | let results = find_nodes_by_hashes(&db, vfs_id, &["hash_b", "hash_a"]).unwrap(); | |
| 1067 | - | ||
| 1068 | - | // Returns results in input order | |
| 1069 | - | assert_eq!(results.len(), 2); | |
| 1070 | - | assert_eq!(results[0].node.sample_hash.as_deref(), Some("hash_b")); | |
| 1071 | - | assert_eq!(results[1].node.sample_hash.as_deref(), Some("hash_a")); | |
| 1072 | - | } | |
| 1073 | - | ||
| 1074 | - | #[test] | |
| 1075 | - | fn find_nodes_by_hashes_empty_input() { | |
| 1076 | - | let db = setup(); | |
| 1077 | - | let vfs_id = create_vfs(&db, "Lib").unwrap(); | |
| 1078 | - | ||
| 1079 | - | let results = find_nodes_by_hashes(&db, vfs_id, &[]).unwrap(); | |
| 1080 | - | assert!(results.is_empty()); | |
| 1081 | - | } | |
| 1082 | - | ||
| 1083 | - | #[test] | |
| 1084 | - | fn find_nodes_by_hashes_nonexistent_hash() { | |
| 1085 | - | let db = setup(); | |
| 1086 | - | let vfs_id = create_vfs(&db, "Lib").unwrap(); | |
| 1087 | - | ||
| 1088 | - | let results = find_nodes_by_hashes(&db, vfs_id, &["nonexistent"]).unwrap(); | |
| 1089 | - | assert!(results.is_empty()); | |
| 1090 | - | } | |
| 1091 | - | ||
| 1092 | - | #[test] | |
| 1093 | - | fn find_nodes_by_hashes_includes_cloud_only() { | |
| 1094 | - | let db = setup(); | |
| 1095 | - | let vfs_id = create_vfs(&db, "Lib").unwrap(); | |
| 1096 | - | insert_fake_sample(&db, "hash_cloud"); | |
| 1097 | - | create_sample_link( | |
| 1098 | - | &db, | |
| 1099 | - | vfs_id, | |
| 1100 | - | None, | |
| 1101 | - | "cloud.wav", | |
| 1102 | - | &crate::SampleHash::from_trusted("hash_cloud"), | |
| 1103 | - | ) | |
| 1104 | - | .unwrap(); | |
| 1105 | - | ||
| 1106 | - | db.conn() | |
| 1107 | - | .execute( | |
| 1108 | - | "UPDATE samples SET cloud_only = 1 WHERE hash = 'hash_cloud'", | |
| 1109 | - | [], | |
| 1110 | - | ) | |
| 1111 | - | .unwrap(); | |
| 1112 | - | ||
| 1113 | - | let results = find_nodes_by_hashes(&db, vfs_id, &["hash_cloud"]).unwrap(); | |
| 1114 | - | assert_eq!(results.len(), 1); | |
| 1115 | - | assert!(results[0].cloud_only); | |
| 1116 | - | } | |
| 1117 | - | ||
| 1118 | - | #[test] | |
| 1119 | - | fn move_node_rejects_circular_parent_to_child() { | |
| 1120 | - | let db = setup(); | |
| 1121 | - | let vfs_id = create_vfs(&db, "Lib").unwrap(); | |
| 1122 | - | let a = create_directory(&db, vfs_id, None, "A").unwrap(); | |
| 1123 | - | let b = create_directory(&db, vfs_id, Some(a), "B").unwrap(); | |
| 1124 | - | let c = create_directory(&db, vfs_id, Some(b), "C").unwrap(); | |
| 1125 | - | ||
| 1126 | - | // Moving A under C would create A -> B -> C -> A cycle | |
| 1127 | - | let result = move_node(&db, a, Some(c)); | |
| 1128 | - | assert!(result.is_err()); | |
| 1129 | - | let err_msg = format!("{}", result.unwrap_err()); | |
| 1130 | - | assert!( | |
| 1131 | - | err_msg.contains("circular"), | |
| 1132 | - | "expected circular error, got: {err_msg}" | |
| 1133 | - | ); | |
| 1134 | - | } | |
| 1135 | - | ||
| 1136 | - | #[test] | |
| 1137 | - | fn move_node_allows_valid_reparent() { | |
| 1138 | - | let db = setup(); | |
| 1139 | - | let vfs_id = create_vfs(&db, "Lib").unwrap(); | |
| 1140 | - | let a = create_directory(&db, vfs_id, None, "A").unwrap(); | |
| 1141 | - | let b = create_directory(&db, vfs_id, Some(a), "B").unwrap(); | |
| 1142 | - | let c = create_directory(&db, vfs_id, Some(b), "C").unwrap(); | |
| 1143 | - | ||
| 1144 | - | // Moving C under A (skipping B) is valid, no cycle | |
| 1145 | - | move_node(&db, c, Some(a)).unwrap(); | |
| 1146 | - | let node = get_node(&db, c).unwrap(); | |
| 1147 | - | assert_eq!(node.parent_id, Some(a)); | |
| 1148 | - | } | |
| 1149 | - | ||
| 1150 | - | #[test] | |
| 1151 | - | fn move_node_to_root_succeeds() { | |
| 1152 | - | let db = setup(); | |
| 1153 | - | let vfs_id = create_vfs(&db, "Lib").unwrap(); | |
| 1154 | - | let a = create_directory(&db, vfs_id, None, "A").unwrap(); | |
| 1155 | - | let b = create_directory(&db, vfs_id, Some(a), "B").unwrap(); | |
| 1156 | - | ||
| 1157 | - | // Moving A to root is always valid | |
| 1158 | - | move_node(&db, a, None).unwrap(); | |
| 1159 | - | let node = get_node(&db, a).unwrap(); | |
| 1160 | - | assert_eq!(node.parent_id, None); | |
| 1161 | - | ||
| 1162 | - | // Moving B to root is also valid | |
| 1163 | - | move_node(&db, b, None).unwrap(); | |
| 1164 | - | let node = get_node(&db, b).unwrap(); | |
| 1165 | - | assert_eq!(node.parent_id, None); | |
| 1166 | - | } | |
| 1167 | - | ||
| 1168 | - | #[test] | |
| 1169 | - | fn move_node_rejects_self_as_parent() { | |
| 1170 | - | let db = setup(); | |
| 1171 | - | let vfs_id = create_vfs(&db, "Lib").unwrap(); | |
| 1172 | - | let a = create_directory(&db, vfs_id, None, "A").unwrap(); | |
| 1173 | - | ||
| 1174 | - | // Moving A under itself creates a trivial cycle | |
| 1175 | - | let result = move_node(&db, a, Some(a)); | |
| 1176 | - | assert!(result.is_err()); | |
| 1177 | - | } |
Lines truncated
| @@ -238,1107 +238,4 @@ | |||
| 238 | 238 | } | |
| 239 | 239 | ||
| 240 | 240 | #[cfg(test)] | |
| 241 | - | mod tests { | |
| 242 | - | use super::*; | |
| 243 | - | use crate::db::Database; | |
| 244 | - | use crate::store::SampleStore; | |
| 245 | - | use crate::vfs; | |
| 246 | - | use std::fs; | |
| 247 | - | use std::io::Write; | |
| 248 | - | use std::path::Path; | |
| 249 | - | use std::sync::atomic::AtomicBool; | |
| 250 | - | ||
| 251 | - | fn setup_vfs_with_samples(db: &Database, store: &SampleStore, dir: &Path) -> crate::VfsId { | |
| 252 | - | let vfs_id = vfs::create_vfs(db, "TestVFS").unwrap(); | |
| 253 | - | ||
| 254 | - | // Create a real audio file in a temp location, then import it | |
| 255 | - | let wav_path = dir.join("kick.wav"); | |
| 256 | - | write_test_wav(&wav_path, 1, 44100, &[0.5, -0.5, 0.25, 0.0]); | |
| 257 | - | let hash = store.import(&wav_path, db).unwrap(); | |
| 258 | - | ||
| 259 | - | // Create directory structure in VFS | |
| 260 | - | let drums_id = vfs::create_directory(db, vfs_id, None, "Drums").unwrap(); | |
| 261 | - | vfs::create_sample_link( | |
| 262 | - | db, | |
| 263 | - | vfs_id, | |
| 264 | - | Some(drums_id), | |
| 265 | - | "kick.wav", | |
| 266 | - | &crate::SampleHash::from_trusted(hash.clone()), | |
| 267 | - | ) | |
| 268 | - | .unwrap(); | |
| 269 | - | ||
| 270 | - | vfs_id | |
| 271 | - | } | |
| 272 | - | ||
| 273 | - | fn write_test_wav(path: &Path, channels: u16, sample_rate: u32, samples: &[f32]) { | |
| 274 | - | let bytes_per_sample = 4u16; | |
| 275 | - | let block_align = channels * bytes_per_sample; | |
| 276 | - | let data_size = (samples.len() as u32) * 4; | |
| 277 | - | let file_size = 36 + data_size; | |
| 278 | - | ||
| 279 | - | let mut buf = Vec::with_capacity(44 + data_size as usize); | |
| 280 | - | buf.extend_from_slice(b"RIFF"); | |
| 281 | - | buf.extend_from_slice(&file_size.to_le_bytes()); | |
| 282 | - | buf.extend_from_slice(b"WAVE"); | |
| 283 | - | buf.extend_from_slice(b"fmt "); | |
| 284 | - | buf.extend_from_slice(&16u32.to_le_bytes()); | |
| 285 | - | buf.extend_from_slice(&3u16.to_le_bytes()); | |
| 286 | - | buf.extend_from_slice(&channels.to_le_bytes()); | |
| 287 | - | buf.extend_from_slice(&sample_rate.to_le_bytes()); | |
| 288 | - | buf.extend_from_slice(&(sample_rate * block_align as u32).to_le_bytes()); | |
| 289 | - | buf.extend_from_slice(&block_align.to_le_bytes()); | |
| 290 | - | buf.extend_from_slice(&(bytes_per_sample * 8).to_le_bytes()); | |
| 291 | - | buf.extend_from_slice(b"data"); | |
| 292 | - | buf.extend_from_slice(&data_size.to_le_bytes()); | |
| 293 | - | for &s in samples { | |
| 294 | - | buf.extend_from_slice(&s.to_le_bytes()); | |
| 295 | - | } | |
| 296 | - | ||
| 297 | - | let mut file = fs::File::create(path).unwrap(); | |
| 298 | - | file.write_all(&buf).unwrap(); | |
| 299 | - | } | |
| 300 | - | ||
| 301 | - | #[test] | |
| 302 | - | fn collect_export_items_builds_relative_paths() { | |
| 303 | - | let dir = tempfile::tempdir().unwrap(); | |
| 304 | - | let db = Database::open_in_memory().unwrap(); | |
| 305 | - | let store = SampleStore::new(dir.path().join("store")).unwrap(); | |
| 306 | - | let vfs_id = setup_vfs_with_samples(&db, &store, dir.path()); | |
| 307 | - | ||
| 308 | - | let items = collect_export_items(&db, vfs_id, None).unwrap(); | |
| 309 | - | assert_eq!(items.len(), 1); | |
| 310 | - | assert_eq!(items[0].name, "kick.wav"); | |
| 311 | - | assert_eq!(items[0].relative_path, PathBuf::from("Drums/kick.wav")); | |
| 312 | - | } | |
| 313 | - | ||
| 314 | - | #[test] | |
| 315 | - | fn export_single_original_copies_file() { | |
| 316 | - | let dir = tempfile::tempdir().unwrap(); | |
| 317 | - | let db = Database::open_in_memory().unwrap(); | |
| 318 | - | let store = SampleStore::new(dir.path().join("store")).unwrap(); | |
| 319 | - | let vfs_id = setup_vfs_with_samples(&db, &store, dir.path()); | |
| 320 | - | ||
| 321 | - | let items = collect_export_items(&db, vfs_id, None).unwrap(); | |
| 322 | - | let dest_dir = dir.path().join("export"); | |
| 323 | - | ||
| 324 | - | let config = ExportConfig { | |
| 325 | - | format: ExportFormat::Original, | |
| 326 | - | sample_rate: None, | |
| 327 | - | bit_depth: None, | |
| 328 | - | channels: ExportChannels::Original, | |
| 329 | - | naming_pattern: None, | |
| 330 | - | flatten: false, | |
| 331 | - | metadata_sidecar: false, | |
| 332 | - | destination: dest_dir.clone(), | |
| 333 | - | device_profile: None, | |
| 334 | - | naming_rules: None, | |
| 335 | - | max_file_size_bytes: None, | |
| 336 | - | name_overrides: None, | |
| 337 | - | }; | |
| 338 | - | ||
| 339 | - | let summary = run_export( | |
| 340 | - | &items, | |
| 341 | - | &config, | |
| 342 | - | &store, | |
| 343 | - | &AtomicBool::new(false), | |
| 344 | - | |_, _, _| true, | |
| 345 | - | ) | |
| 346 | - | .unwrap(); | |
| 347 | - | assert_eq!(summary.total, 1); | |
| 348 | - | assert!(summary.errors.is_empty()); | |
| 349 | - | ||
| 350 | - | // Verify the file was copied with directory structure | |
| 351 | - | let exported = dest_dir.join("Drums").join("kick.wav"); | |
| 352 | - | assert!(exported.exists()); | |
| 353 | - | ||
| 354 | - | // Verify content matches (hardlink or copy) | |
| 355 | - | let source_path = store.sample_path(&items[0].hash, &items[0].ext).unwrap(); | |
| 356 | - | let source_bytes = fs::read(&source_path).unwrap(); | |
| 357 | - | let export_bytes = fs::read(&exported).unwrap(); | |
| 358 | - | assert_eq!(source_bytes, export_bytes); | |
| 359 | - | } | |
| 360 | - | ||
| 361 | - | #[test] | |
| 362 | - | fn export_single_wav_16bit() { | |
| 363 | - | let dir = tempfile::tempdir().unwrap(); | |
| 364 | - | let db = Database::open_in_memory().unwrap(); | |
| 365 | - | let store = SampleStore::new(dir.path().join("store")).unwrap(); | |
| 366 | - | let vfs_id = setup_vfs_with_samples(&db, &store, dir.path()); | |
| 367 | - | ||
| 368 | - | let items = collect_export_items(&db, vfs_id, None).unwrap(); | |
| 369 | - | let dest_dir = dir.path().join("export"); | |
| 370 | - | ||
| 371 | - | let config = ExportConfig { | |
| 372 | - | format: ExportFormat::Wav, | |
| 373 | - | sample_rate: Some(44100), | |
| 374 | - | bit_depth: Some(16), | |
| 375 | - | channels: ExportChannels::Original, | |
| 376 | - | naming_pattern: None, | |
| 377 | - | flatten: false, | |
| 378 | - | metadata_sidecar: false, | |
| 379 | - | destination: dest_dir.clone(), | |
| 380 | - | device_profile: None, | |
| 381 | - | naming_rules: None, | |
| 382 | - | max_file_size_bytes: None, | |
| 383 | - | name_overrides: None, | |
| 384 | - | }; | |
| 385 | - | ||
| 386 | - | let summary = run_export( | |
| 387 | - | &items, | |
| 388 | - | &config, | |
| 389 | - | &store, | |
| 390 | - | &AtomicBool::new(false), | |
| 391 | - | |_, _, _| true, | |
| 392 | - | ) | |
| 393 | - | .unwrap(); | |
| 394 | - | assert_eq!(summary.total, 1); | |
| 395 | - | assert!(summary.errors.is_empty()); | |
| 396 | - | ||
| 397 | - | // Verify the WAV was created | |
| 398 | - | let exported = dest_dir.join("Drums").join("kick.wav"); | |
| 399 | - | assert!(exported.exists()); | |
| 400 | - | ||
| 401 | - | // Verify it's a valid 16-bit WAV | |
| 402 | - | let reader = hound::WavReader::open(&exported).unwrap(); | |
| 403 | - | assert_eq!(reader.spec().bits_per_sample, 16); | |
| 404 | - | assert_eq!(reader.spec().sample_rate, 44100); | |
| 405 | - | } | |
| 406 | - | ||
| 407 | - | #[test] | |
| 408 | - | fn export_single_wav_24bit() { | |
| 409 | - | let dir = tempfile::tempdir().unwrap(); | |
| 410 | - | let db = Database::open_in_memory().unwrap(); | |
| 411 | - | let store = SampleStore::new(dir.path().join("store")).unwrap(); | |
| 412 | - | let vfs_id = setup_vfs_with_samples(&db, &store, dir.path()); | |
| 413 | - | ||
| 414 | - | let items = collect_export_items(&db, vfs_id, None).unwrap(); | |
| 415 | - | let dest_dir = dir.path().join("export"); | |
| 416 | - | ||
| 417 | - | let config = ExportConfig { | |
| 418 | - | format: ExportFormat::Wav, | |
| 419 | - | sample_rate: None, | |
| 420 | - | bit_depth: Some(24), | |
| 421 | - | channels: ExportChannels::Mono, | |
| 422 | - | naming_pattern: None, | |
| 423 | - | flatten: false, | |
| 424 | - | metadata_sidecar: false, | |
| 425 | - | destination: dest_dir.clone(), | |
| 426 | - | device_profile: None, | |
| 427 | - | naming_rules: None, | |
| 428 | - | max_file_size_bytes: None, | |
| 429 | - | name_overrides: None, | |
| 430 | - | }; | |
| 431 | - | ||
| 432 | - | let summary = run_export( | |
| 433 | - | &items, | |
| 434 | - | &config, | |
| 435 | - | &store, | |
| 436 | - | &AtomicBool::new(false), | |
| 437 | - | |_, _, _| true, | |
| 438 | - | ) | |
| 439 | - | .unwrap(); | |
| 440 | - | assert!(summary.errors.is_empty()); | |
| 441 | - | ||
| 442 | - | let exported = dest_dir.join("Drums").join("kick.wav"); | |
| 443 | - | let reader = hound::WavReader::open(&exported).unwrap(); | |
| 444 | - | assert_eq!(reader.spec().bits_per_sample, 24); | |
| 445 | - | assert_eq!(reader.spec().channels, 1); | |
| 446 | - | } | |
| 447 | - | ||
| 448 | - | #[test] | |
| 449 | - | fn export_flat_with_pattern() { | |
| 450 | - | let dir = tempfile::tempdir().unwrap(); | |
| 451 | - | let db = Database::open_in_memory().unwrap(); | |
| 452 | - | let store = SampleStore::new(dir.path().join("store")).unwrap(); | |
| 453 | - | let vfs_id = setup_vfs_with_samples(&db, &store, dir.path()); | |
| 454 | - | ||
| 455 | - | let items = collect_export_items(&db, vfs_id, None).unwrap(); | |
| 456 | - | let dest_dir = dir.path().join("export_flat"); | |
| 457 | - | ||
| 458 | - | let config = ExportConfig { | |
| 459 | - | format: ExportFormat::Original, | |
| 460 | - | sample_rate: None, | |
| 461 | - | bit_depth: None, | |
| 462 | - | channels: ExportChannels::Original, | |
| 463 | - | naming_pattern: Some("{nn}_{name}".to_string()), | |
| 464 | - | flatten: true, | |
| 465 | - | metadata_sidecar: false, | |
| 466 | - | destination: dest_dir.clone(), | |
| 467 | - | device_profile: None, | |
| 468 | - | naming_rules: None, | |
| 469 | - | max_file_size_bytes: None, | |
| 470 | - | name_overrides: None, | |
| 471 | - | }; | |
| 472 | - | ||
| 473 | - | let summary = run_export( | |
| 474 | - | &items, | |
| 475 | - | &config, | |
| 476 | - | &store, | |
| 477 | - | &AtomicBool::new(false), | |
| 478 | - | |_, _, _| true, | |
| 479 | - | ) | |
| 480 | - | .unwrap(); | |
| 481 | - | assert!(summary.errors.is_empty()); | |
| 482 | - | ||
| 483 | - | // Should be flat (no Drums/ subdirectory) | |
| 484 | - | let exported = dest_dir.join("01_kick.wav"); | |
| 485 | - | assert!(exported.exists(), "expected 01_kick.wav in flat export"); | |
| 486 | - | assert!(!dest_dir.join("Drums").exists()); | |
| 487 | - | } | |
| 488 | - | ||
| 489 | - | #[test] | |
| 490 | - | fn split_name_ext_works() { | |
| 491 | - | use crate::util::split_name_ext; | |
| 492 | - | assert_eq!(split_name_ext("kick.wav"), ("kick".into(), "wav".into())); | |
| 493 | - | assert_eq!(split_name_ext("noext"), ("noext".into(), String::new())); | |
| 494 | - | assert_eq!( | |
| 495 | - | split_name_ext("archive.tar.gz"), | |
| 496 | - | ("archive.tar".into(), "gz".into()) | |
| 497 | - | ); | |
| 498 | - | } | |
| 499 | - | ||
| 500 | - | #[test] | |
| 501 | - | fn export_with_sidecar_writes_json() { | |
| 502 | - | let dir = tempfile::tempdir().unwrap(); | |
| 503 | - | let db = Database::open_in_memory().unwrap(); | |
| 504 | - | let store = SampleStore::new(dir.path().join("store")).unwrap(); | |
| 505 | - | let vfs_id = setup_vfs_with_samples(&db, &store, dir.path()); | |
| 506 | - | ||
| 507 | - | let mut items = collect_export_items(&db, vfs_id, None).unwrap(); | |
| 508 | - | enrich_with_tags(&db, &mut items); | |
| 509 | - | let dest_dir = dir.path().join("export_sidecar"); | |
| 510 | - | ||
| 511 | - | let config = ExportConfig { | |
| 512 | - | format: ExportFormat::Original, | |
| 513 | - | sample_rate: None, | |
| 514 | - | bit_depth: None, | |
| 515 | - | channels: ExportChannels::Original, | |
| 516 | - | naming_pattern: None, | |
| 517 | - | flatten: false, | |
| 518 | - | metadata_sidecar: true, | |
| 519 | - | destination: dest_dir.clone(), | |
| 520 | - | device_profile: None, | |
| 521 | - | naming_rules: None, | |
| 522 | - | max_file_size_bytes: None, | |
| 523 | - | name_overrides: None, | |
| 524 | - | }; | |
| 525 | - | ||
| 526 | - | let summary = run_export( | |
| 527 | - | &items, | |
| 528 | - | &config, | |
| 529 | - | &store, | |
| 530 | - | &AtomicBool::new(false), | |
| 531 | - | |_, _, _| true, | |
| 532 | - | ) | |
| 533 | - | .unwrap(); | |
| 534 | - | assert!(summary.errors.is_empty()); | |
| 535 | - | ||
| 536 | - | let sidecar = dest_dir.join("Drums").join("kick.wav.audiofiles.json"); | |
| 537 | - | assert!(sidecar.exists(), "sidecar file should exist"); | |
| 538 | - | ||
| 539 | - | let content: serde_json::Value = | |
| 540 | - | serde_json::from_str(&fs::read_to_string(&sidecar).unwrap()).unwrap(); | |
| 541 | - | assert_eq!(content["name"], "kick.wav"); | |
| 542 | - | assert!(content["hash"].is_string()); | |
| 543 | - | } | |
| 544 | - | ||
| 545 | - | #[test] | |
| 546 | - | fn hardlink_or_copy_works() { | |
| 547 | - | let dir = tempfile::tempdir().unwrap(); | |
| 548 | - | let db = Database::open_in_memory().unwrap(); | |
| 549 | - | let store = SampleStore::new(dir.path().join("store")).unwrap(); | |
| 550 | - | let vfs_id = setup_vfs_with_samples(&db, &store, dir.path()); | |
| 551 | - | ||
| 552 | - | let items = collect_export_items(&db, vfs_id, None).unwrap(); | |
| 553 | - | let dest_dir = dir.path().join("export_hl"); | |
| 554 | - | ||
| 555 | - | let config = ExportConfig { | |
| 556 | - | format: ExportFormat::Original, | |
| 557 | - | sample_rate: None, | |
| 558 | - | bit_depth: None, | |
| 559 | - | channels: ExportChannels::Original, | |
| 560 | - | naming_pattern: None, | |
| 561 | - | flatten: false, | |
| 562 | - | metadata_sidecar: false, | |
| 563 | - | destination: dest_dir.clone(), | |
| 564 | - | device_profile: None, | |
| 565 | - | naming_rules: None, | |
| 566 | - | max_file_size_bytes: None, | |
| 567 | - | name_overrides: None, | |
| 568 | - | }; | |
| 569 | - | ||
| 570 | - | let summary = run_export( | |
| 571 | - | &items, | |
| 572 | - | &config, | |
| 573 | - | &store, | |
| 574 | - | &AtomicBool::new(false), | |
| 575 | - | |_, _, _| true, | |
| 576 | - | ) | |
| 577 | - | .unwrap(); | |
| 578 | - | assert!(summary.errors.is_empty()); | |
| 579 | - | ||
| 580 | - | let exported = dest_dir.join("Drums").join("kick.wav"); | |
| 581 | - | assert!(exported.exists()); | |
| 582 | - | ||
| 583 | - | let source_path = store.sample_path(&items[0].hash, &items[0].ext).unwrap(); | |
| 584 | - | let source_bytes = fs::read(&source_path).unwrap(); | |
| 585 | - | let export_bytes = fs::read(&exported).unwrap(); | |
| 586 | - | assert_eq!(source_bytes, export_bytes); | |
| 587 | - | } | |
| 588 | - | ||
| 589 | - | #[test] | |
| 590 | - | fn enrich_with_tags_populates() { | |
| 591 | - | let dir = tempfile::tempdir().unwrap(); | |
| 592 | - | let db = Database::open_in_memory().unwrap(); | |
| 593 | - | let store = SampleStore::new(dir.path().join("store")).unwrap(); | |
| 594 | - | let vfs_id = setup_vfs_with_samples(&db, &store, dir.path()); | |
| 595 | - | ||
| 596 | - | let mut items = collect_export_items(&db, vfs_id, None).unwrap(); | |
| 597 | - | assert!(items[0].tags.is_empty()); | |
| 598 | - | ||
| 599 | - | crate::tags::add_tag(&db, &items[0].hash, "kick").unwrap(); | |
| 600 | - | crate::tags::add_tag(&db, &items[0].hash, "drums").unwrap(); | |
| 601 | - | ||
| 602 | - | enrich_with_tags(&db, &mut items); | |
| 603 | - | assert_eq!(items[0].tags.len(), 2); | |
| 604 | - | assert!(items[0].tags.contains(&"drums".to_string())); | |
| 605 | - | assert!(items[0].tags.contains(&"kick".to_string())); | |
| 606 | - | } | |
| 607 | - | ||
| 608 | - | #[test] | |
| 609 | - | fn export_with_naming_rules_sanitizes() { | |
| 610 | - | use crate::export::profile::{NamingCase, NamingRules}; | |
| 611 | - | ||
| 612 | - | let dir = tempfile::tempdir().unwrap(); | |
| 613 | - | let db = Database::open_in_memory().unwrap(); | |
| 614 | - | let store = SampleStore::new(dir.path().join("store")).unwrap(); | |
| 615 | - | let vfs_id = setup_vfs_with_samples(&db, &store, dir.path()); | |
| 616 | - | ||
| 617 | - | let items = collect_export_items(&db, vfs_id, None).unwrap(); | |
| 618 | - | let dest_dir = dir.path().join("export_sanitize"); | |
| 619 | - | ||
| 620 | - | let config = ExportConfig { | |
| 621 | - | format: ExportFormat::Original, | |
| 622 | - | sample_rate: None, | |
| 623 | - | bit_depth: None, | |
| 624 | - | channels: ExportChannels::Original, | |
| 625 | - | naming_pattern: None, | |
| 626 | - | flatten: true, | |
| 627 | - | metadata_sidecar: false, | |
| 628 | - | destination: dest_dir.clone(), | |
| 629 | - | device_profile: None, | |
| 630 | - | naming_rules: Some(NamingRules { | |
| 631 | - | case: NamingCase::Upper, | |
| 632 | - | separator: '_', | |
| 633 | - | max_length: 8, | |
| 634 | - | strip_special: true, | |
| 635 | - | }), | |
| 636 | - | max_file_size_bytes: None, | |
| 637 | - | name_overrides: None, | |
| 638 | - | }; | |
| 639 | - | ||
| 640 | - | let summary = run_export( | |
| 641 | - | &items, | |
| 642 | - | &config, | |
| 643 | - | &store, | |
| 644 | - | &AtomicBool::new(false), | |
| 645 | - | |_, _, _| true, | |
| 646 | - | ) | |
| 647 | - | .unwrap(); | |
| 648 | - | assert!(summary.errors.is_empty()); | |
| 649 | - | ||
| 650 | - | // "kick" uppercased -> "KICK", truncated to 8 (already short enough) | |
| 651 | - | let exported = dest_dir.join("KICK.wav"); | |
| 652 | - | assert!( | |
| 653 | - | exported.exists(), | |
| 654 | - | "expected KICK.wav, got: {:?}", | |
| 655 | - | fs::read_dir(&dest_dir) | |
| 656 | - | .unwrap() | |
| 657 | - | .map(|e| e.unwrap().file_name()) | |
| 658 | - | .collect::<Vec<_>>() | |
| 659 | - | ); | |
| 660 | - | } | |
| 661 | - | ||
| 662 | - | #[test] | |
| 663 | - | fn export_with_max_file_size_rejects_oversized() { | |
| 664 | - | let dir = tempfile::tempdir().unwrap(); | |
| 665 | - | let db = Database::open_in_memory().unwrap(); | |
| 666 | - | let store = SampleStore::new(dir.path().join("store")).unwrap(); | |
| 667 | - | let vfs_id = setup_vfs_with_samples(&db, &store, dir.path()); | |
| 668 | - | ||
| 669 | - | let items = collect_export_items(&db, vfs_id, None).unwrap(); | |
| 670 | - | let dest_dir = dir.path().join("export_size"); | |
| 671 | - | ||
| 672 | - | let config = ExportConfig { | |
| 673 | - | format: ExportFormat::Original, | |
| 674 | - | sample_rate: None, | |
| 675 | - | bit_depth: None, | |
| 676 | - | channels: ExportChannels::Original, | |
| 677 | - | naming_pattern: None, | |
| 678 | - | flatten: true, | |
| 679 | - | metadata_sidecar: false, | |
| 680 | - | destination: dest_dir.clone(), | |
| 681 | - | device_profile: None, | |
| 682 | - | naming_rules: None, | |
| 683 | - | max_file_size_bytes: Some(1), // 1 byte: everything will exceed | |
| 684 | - | name_overrides: None, | |
| 685 | - | }; | |
| 686 | - | ||
| 687 | - | let summary = run_export( | |
| 688 | - | &items, | |
| 689 | - | &config, | |
| 690 | - | &store, | |
| 691 | - | &AtomicBool::new(false), | |
| 692 | - | |_, _, _| true, | |
| 693 | - | ) | |
| 694 | - | .unwrap(); | |
| 695 | - | assert_eq!(summary.errors.len(), 1); | |
| 696 | - | assert!( | |
| 697 | - | summary.errors[0] | |
| 698 | - | .1 | |
| 699 | - | .contains("exceeds device file size limit") | |
| 700 | - | ); | |
| 701 | - | ||
| 702 | - | // Verify the file was cleaned up | |
| 703 | - | let exported = dest_dir.join("kick.wav"); | |
| 704 | - | assert!( | |
| 705 | - | !exported.exists(), | |
| 706 | - | "oversized file should have been removed" | |
| 707 | - | ); | |
| 708 | - | } | |
| 709 | - | ||
| 710 | - | #[test] | |
| 711 | - | fn export_naming_rules_dedup() { | |
| 712 | - | use crate::export::profile::{NamingCase, NamingRules}; | |
| 713 | - | ||
| 714 | - | let dir = tempfile::tempdir().unwrap(); | |
| 715 | - | let db = Database::open_in_memory().unwrap(); | |
| 716 | - | let store = SampleStore::new(dir.path().join("store")).unwrap(); | |
| 717 | - | let vfs_id = setup_vfs_with_samples(&db, &store, dir.path()); | |
| 718 | - | ||
| 719 | - | // Add a second sample that will collide after sanitization | |
| 720 | - | let wav_path = dir.path().join("KICK.wav"); | |
| 721 | - | write_test_wav(&wav_path, 1, 44100, &[0.1, -0.1]); | |
| 722 | - | let hash2 = store.import(&wav_path, &db).unwrap(); | |
| 723 | - | vfs::create_sample_link( | |
| 724 | - | &db, | |
| 725 | - | vfs_id, | |
| 726 | - | None, | |
| 727 | - | "KICK.wav", | |
| 728 | - | &crate::SampleHash::from_trusted(hash2.clone()), | |
| 729 | - | ) | |
| 730 | - | .unwrap(); | |
| 731 | - | ||
| 732 | - | let items = collect_export_items(&db, vfs_id, None).unwrap(); | |
| 733 | - | assert_eq!(items.len(), 2); | |
| 734 | - | ||
| 735 | - | let dest_dir = dir.path().join("export_dedup"); | |
| 736 | - | ||
| 737 | - | let config = ExportConfig { |
Lines truncated
| @@ -1096,1266 +1096,4 @@ | |||
| 1096 | 1096 | } | |
| 1097 | 1097 | ||
| 1098 | 1098 | #[cfg(test)] | |
| 1099 | - | mod tests { | |
| 1100 | - | use super::*; | |
| 1101 | - | use std::io::Write; | |
| 1102 | - | use tempfile::TempDir; | |
| 1103 | - | ||
| 1104 | - | fn setup() -> (TempDir, Database, SampleStore) { | |
| 1105 | - | let dir = TempDir::new().unwrap(); | |
| 1106 | - | let db = Database::open_in_memory().unwrap(); | |
| 1107 | - | let store_dir = dir.path().join("store"); | |
| 1108 | - | let store = SampleStore::new(&store_dir).unwrap(); | |
| 1109 | - | (dir, db, store) | |
| 1110 | - | } | |
| 1111 | - | ||
| 1112 | - | fn create_test_file(dir: &TempDir, name: &str, content: &[u8]) -> PathBuf { | |
| 1113 | - | let path = dir.path().join(name); | |
| 1114 | - | let mut f = fs::File::create(&path).unwrap(); | |
| 1115 | - | f.write_all(content).unwrap(); | |
| 1116 | - | path | |
| 1117 | - | } | |
| 1118 | - | ||
| 1119 | - | /// Count every file anywhere under `root`, shard directories included. | |
| 1120 | - | /// | |
| 1121 | - | /// Blob-count assertions must not stop at the root's own entries: under the | |
| 1122 | - | /// sharded layout that reads 0 whatever the store actually holds, which would | |
| 1123 | - | /// turn an orphan-detection test into one that cannot fail. | |
| 1124 | - | fn count_blobs_recursively(root: &Path) -> usize { | |
| 1125 | - | let Ok(entries) = fs::read_dir(root) else { | |
| 1126 | - | return 0; | |
| 1127 | - | }; | |
| 1128 | - | entries | |
| 1129 | - | .filter_map(std::result::Result::ok) | |
| 1130 | - | .map(|e| { | |
| 1131 | - | let path = e.path(); | |
| 1132 | - | if path.is_dir() { | |
| 1133 | - | count_blobs_recursively(&path) | |
| 1134 | - | } else { | |
| 1135 | - | 1 | |
| 1136 | - | } | |
| 1137 | - | }) | |
| 1138 | - | .sum() | |
| 1139 | - | } | |
| 1140 | - | ||
| 1141 | - | /// Give a sample one VFS placement, so CASCADE behaviour and placement | |
| 1142 | - | /// preservation are observable. | |
| 1143 | - | fn place_sample(db: &Database, hash: &str) { | |
| 1144 | - | db.conn() | |
| 1145 | - | .execute( | |
| 1146 | - | "INSERT OR IGNORE INTO vfs (id, name, created_at, modified_at) \ | |
| 1147 | - | VALUES (1, 'Library', 0, 0)", | |
| 1148 | - | [], | |
| 1149 | - | ) | |
| 1150 | - | .unwrap(); | |
| 1151 | - | db.conn() | |
| 1152 | - | .execute( | |
| 1153 | - | "INSERT INTO vfs_nodes (vfs_id, parent_id, name, node_type, sample_hash, created_at) \ | |
| 1154 | - | VALUES (1, NULL, ?1, 'sample', ?1, 0)", | |
| 1155 | - | [hash], | |
| 1156 | - | ) | |
| 1157 | - | .unwrap(); | |
| 1158 | - | } | |
| 1159 | - | ||
| 1160 | - | fn count(db: &Database, sql: &str, hash: &str) -> i64 { | |
| 1161 | - | db.conn().query_row(sql, [hash], |r| r.get(0)).unwrap() | |
| 1162 | - | } | |
| 1163 | - | ||
| 1164 | - | #[test] | |
| 1165 | - | fn tombstone_hides_sample_and_undelete_restores() { | |
| 1166 | - | let (dir, db, store) = setup(); | |
| 1167 | - | let hash = store | |
| 1168 | - | .import(&create_test_file(&dir, "kick.wav", b"fake audio data"), &db) | |
| 1169 | - | .unwrap(); | |
| 1170 | - | ||
| 1171 | - | // Live: visible to the read path, absent from Trash. | |
| 1172 | - | assert!(sample_extension(&db, &crate::SampleHash::from_trusted(hash.clone())).is_ok()); | |
| 1173 | - | assert!(tombstoned_samples(&db).unwrap().is_empty()); | |
| 1174 | - | ||
| 1175 | - | // Tombstone is a one-shot: the second call is a no-op. | |
| 1176 | - | assert!(tombstone_sample(&db, &hash).unwrap()); | |
| 1177 | - | assert!(!tombstone_sample(&db, &hash).unwrap()); | |
| 1178 | - | ||
| 1179 | - | // Hidden from the read path, surfaced in Trash with a timestamp. | |
| 1180 | - | assert!(matches!( | |
| 1181 | - | sample_extension(&db, &crate::SampleHash::from_trusted(hash.clone())), | |
| 1182 | - | Err(CoreError::SampleNotFound(_)) | |
| 1183 | - | )); | |
| 1184 | - | let trash = tombstoned_samples(&db).unwrap(); | |
| 1185 | - | assert_eq!(trash.len(), 1); | |
| 1186 | - | assert_eq!(trash[0].hash, hash); | |
| 1187 | - | assert!(trash[0].deleted_at > 0); | |
| 1188 | - | ||
| 1189 | - | // Undelete restores it; a second undelete is a no-op. | |
| 1190 | - | assert!(undelete_sample(&db, &hash).unwrap()); | |
| 1191 | - | assert!(!undelete_sample(&db, &hash).unwrap()); | |
| 1192 | - | assert!(sample_extension(&db, &crate::SampleHash::from_trusted(hash.clone())).is_ok()); | |
| 1193 | - | assert!(tombstoned_samples(&db).unwrap().is_empty()); | |
| 1194 | - | } | |
| 1195 | - | ||
| 1196 | - | #[test] | |
| 1197 | - | fn tombstone_preserves_placements_and_blob() { | |
| 1198 | - | let (dir, db, store) = setup(); | |
| 1199 | - | let hash = store | |
| 1200 | - | .import(&create_test_file(&dir, "kick.wav", b"fake audio data"), &db) | |
| 1201 | - | .unwrap(); | |
| 1202 | - | place_sample(&db, &hash); | |
| 1203 | - | ||
| 1204 | - | assert!(tombstone_sample(&db, &hash).unwrap()); | |
| 1205 | - | ||
| 1206 | - | // The whole point of soft delete: placements and the blob survive so the | |
| 1207 | - | // user can recover everything. | |
| 1208 | - | assert_eq!( | |
| 1209 | - | count( | |
| 1210 | - | &db, | |
| 1211 | - | "SELECT COUNT(*) FROM vfs_nodes WHERE sample_hash = ?1", | |
| 1212 | - | &hash | |
| 1213 | - | ), | |
| 1214 | - | 1 | |
| 1215 | - | ); | |
| 1216 | - | assert!( | |
| 1217 | - | store | |
| 1218 | - | .exists(&crate::SampleHash::from_trusted(hash.clone()), "wav") | |
| 1219 | - | .unwrap() | |
| 1220 | - | ); | |
| 1221 | - | } | |
| 1222 | - | ||
| 1223 | - | #[test] | |
| 1224 | - | fn remove_purges_tombstoned_row_and_cascades() { | |
| 1225 | - | let (dir, db, store) = setup(); | |
| 1226 | - | let hash = store | |
| 1227 | - | .import(&create_test_file(&dir, "kick.wav", b"fake audio data"), &db) | |
| 1228 | - | .unwrap(); | |
| 1229 | - | place_sample(&db, &hash); | |
| 1230 | - | assert!(tombstone_sample(&db, &hash).unwrap()); | |
| 1231 | - | ||
| 1232 | - | // Permanent delete must work on a tombstoned row even though the | |
| 1233 | - | // filtered `sample_extension` would hide it, `remove` resolves the | |
| 1234 | - | // blob path unfiltered. | |
| 1235 | - | store | |
| 1236 | - | .remove(&crate::SampleHash::from_trusted(hash.clone()), &db) | |
| 1237 | - | .unwrap(); | |
| 1238 | - | ||
| 1239 | - | assert!( | |
| 1240 | - | !store | |
| 1241 | - | .exists(&crate::SampleHash::from_trusted(hash.clone()), "wav") | |
| 1242 | - | .unwrap() | |
| 1243 | - | ); | |
| 1244 | - | assert_eq!( | |
| 1245 | - | count(&db, "SELECT COUNT(*) FROM samples WHERE hash = ?1", &hash), | |
| 1246 | - | 0 | |
| 1247 | - | ); | |
| 1248 | - | assert_eq!( | |
| 1249 | - | count( | |
| 1250 | - | &db, | |
| 1251 | - | "SELECT COUNT(*) FROM vfs_nodes WHERE sample_hash = ?1", | |
| 1252 | - | &hash | |
| 1253 | - | ), | |
| 1254 | - | 0 | |
| 1255 | - | ); | |
| 1256 | - | } | |
| 1257 | - | ||
| 1258 | - | #[test] | |
| 1259 | - | fn sweep_hard_deletes_only_expired_tombstones() { | |
| 1260 | - | let (dir, db, store) = setup(); | |
| 1261 | - | let live = store | |
| 1262 | - | .import(&create_test_file(&dir, "live.wav", b"live audio"), &db) | |
| 1263 | - | .unwrap(); | |
| 1264 | - | let fresh = store | |
| 1265 | - | .import(&create_test_file(&dir, "fresh.wav", b"fresh audio"), &db) | |
| 1266 | - | .unwrap(); | |
| 1267 | - | let old = store | |
| 1268 | - | .import(&create_test_file(&dir, "old.wav", b"old audio data"), &db) | |
| 1269 | - | .unwrap(); | |
| 1270 | - | place_sample(&db, &old); | |
| 1271 | - | ||
| 1272 | - | // fresh: tombstoned just now. old: tombstoned beyond the 30-day window. | |
| 1273 | - | assert!(tombstone_sample(&db, &fresh).unwrap()); | |
| 1274 | - | assert!(tombstone_sample(&db, &old).unwrap()); | |
| 1275 | - | db.conn() | |
| 1276 | - | .execute( | |
| 1277 | - | "UPDATE samples SET deleted_at = ?1 WHERE hash = ?2", | |
| 1278 | - | rusqlite::params![unix_now() - 31 * 86_400, old], | |
| 1279 | - | ) | |
| 1280 | - | .unwrap(); | |
| 1281 | - | ||
| 1282 | - | let removed = store.sweep_expired_tombstones(&db).unwrap(); | |
| 1283 | - | assert_eq!(removed, 1); | |
| 1284 | - | ||
| 1285 | - | // old is gone (row, blob, and CASCADE'd placement); fresh + live stay. | |
| 1286 | - | assert!( | |
| 1287 | - | !store | |
| 1288 | - | .exists(&crate::SampleHash::from_trusted(old.clone()), "wav") | |
| 1289 | - | .unwrap() | |
| 1290 | - | ); | |
| 1291 | - | assert_eq!( | |
| 1292 | - | count(&db, "SELECT COUNT(*) FROM samples WHERE hash = ?1", &old), | |
| 1293 | - | 0 | |
| 1294 | - | ); | |
| 1295 | - | assert_eq!( | |
| 1296 | - | count( | |
| 1297 | - | &db, | |
| 1298 | - | "SELECT COUNT(*) FROM vfs_nodes WHERE sample_hash = ?1", | |
| 1299 | - | &old | |
| 1300 | - | ), | |
| 1301 | - | 0 | |
| 1302 | - | ); | |
| 1303 | - | assert_eq!(tombstoned_samples(&db).unwrap().len(), 1); | |
| 1304 | - | assert!(sample_extension(&db, &crate::SampleHash::from_trusted(live.clone())).is_ok()); | |
| 1305 | - | assert!( | |
| 1306 | - | store | |
| 1307 | - | .exists(&crate::SampleHash::from_trusted(fresh.clone()), "wav") | |
| 1308 | - | .unwrap() | |
| 1309 | - | ); | |
| 1310 | - | } | |
| 1311 | - | ||
| 1312 | - | #[test] | |
| 1313 | - | fn sweep_respects_retain_days_config() { | |
| 1314 | - | let (dir, db, store) = setup(); | |
| 1315 | - | let hash = store | |
| 1316 | - | .import(&create_test_file(&dir, "s.wav", b"some audio"), &db) | |
| 1317 | - | .unwrap(); | |
| 1318 | - | assert!(tombstone_sample(&db, &hash).unwrap()); | |
| 1319 | - | db.conn() | |
| 1320 | - | .execute( | |
| 1321 | - | "UPDATE samples SET deleted_at = ?1 WHERE hash = ?2", | |
| 1322 | - | rusqlite::params![unix_now() - 5 * 86_400, hash], | |
| 1323 | - | ) | |
| 1324 | - | .unwrap(); | |
| 1325 | - | ||
| 1326 | - | // 5 days old, default 30-day window: not yet expired. | |
| 1327 | - | assert_eq!(tombstone_retain_days(&db), 30); | |
| 1328 | - | assert_eq!(store.sweep_expired_tombstones(&db).unwrap(), 0); | |
| 1329 | - | ||
| 1330 | - | // Shrink the window to 3 days: now it sweeps. | |
| 1331 | - | db.conn() | |
| 1332 | - | .execute( | |
| 1333 | - | "UPDATE user_config SET value = '3' WHERE key = 'sample_tombstone_retain_days'", | |
| 1334 | - | [], | |
| 1335 | - | ) | |
| 1336 | - | .unwrap(); | |
| 1337 | - | assert_eq!(tombstone_retain_days(&db), 3); | |
| 1338 | - | assert_eq!(store.sweep_expired_tombstones(&db).unwrap(), 1); | |
| 1339 | - | } | |
| 1340 | - | ||
| 1341 | - | #[test] | |
| 1342 | - | fn clamp_original_name_caps_length_on_char_boundary() { | |
| 1343 | - | // Short names pass through untouched. | |
| 1344 | - | assert_eq!(clamp_original_name("kick.wav".to_string()), "kick.wav"); | |
| 1345 | - | ||
| 1346 | - | // Over-long ASCII is capped to 255 bytes. | |
| 1347 | - | let long = "a".repeat(1000); | |
| 1348 | - | assert_eq!(clamp_original_name(long).len(), 255); | |
| 1349 | - | ||
| 1350 | - | // Multi-byte chars at the cap don't split mid-codepoint (valid UTF-8). | |
| 1351 | - | let multibyte = "é".repeat(200); // 400 bytes | |
| 1352 | - | let clamped = clamp_original_name(multibyte); | |
| 1353 | - | assert!(clamped.len() <= 255); | |
| 1354 | - | assert!(std::str::from_utf8(clamped.as_bytes()).is_ok()); | |
| 1355 | - | } | |
| 1356 | - | ||
| 1357 | - | #[test] | |
| 1358 | - | fn hash_file_matches_import_and_rejects_bad_input() { | |
| 1359 | - | let (dir, db, store) = setup(); | |
| 1360 | - | let src = create_test_file(&dir, "kick.wav", b"fake audio data"); | |
| 1361 | - | ||
| 1362 | - | // hash_file's digest equals the hash import() records. | |
| 1363 | - | let (hash, size) = hash_file(&src).unwrap(); | |
| 1364 | - | assert_eq!(size, "fake audio data".len() as i64); | |
| 1365 | - | let imported = store.import(&src, &db).unwrap(); | |
| 1366 | - | assert_eq!(hash, imported); | |
| 1367 | - | ||
| 1368 | - | // Zero-byte and non-audio files are rejected (same guards as import). | |
| 1369 | - | let empty = create_test_file(&dir, "empty.wav", b""); | |
| 1370 | - | assert!(hash_file(&empty).is_err()); | |
| 1371 | - | let txt = create_test_file(&dir, "notes.txt", b"hello"); | |
| 1372 | - | assert!(hash_file(&txt).is_err()); | |
| 1373 | - | } | |
| 1374 | - | ||
| 1375 | - | #[test] | |
| 1376 | - | fn hash_file_matches_the_reference_sha256_digest() { | |
| 1377 | - | // The content address is the library's primary key, so the exact hex | |
| 1378 | - | // string a given byte sequence produces is a compatibility guarantee: | |
| 1379 | - | // change it and every stored path and database row stops resolving. | |
| 1380 | - | // Pinned against an independent SHA-256 of the same bytes, so a hasher | |
| 1381 | - | // or hex-encoding swap has to survive a known answer, not just agree | |
| 1382 | - | // with itself. | |
| 1383 | - | let (dir, _db, _store) = setup(); | |
| 1384 | - | let src = create_test_file(&dir, "kick.wav", b"fake audio data"); | |
| 1385 | - | let (hash, _) = hash_file(&src).unwrap(); | |
| 1386 | - | assert_eq!( | |
| 1387 | - | hash, "cec560f942befcb4e4a4d1161c5c03b3a787e2d525f650042641e62bf8773c69", | |
| 1388 | - | "SHA-256 of b\"fake audio data\", lowercase hex, no separators" | |
| 1389 | - | ); | |
| 1390 | - | } | |
| 1391 | - | ||
| 1392 | - | #[test] | |
| 1393 | - | fn import_hashed_matches_serial_import() { | |
| 1394 | - | let (dir, db, store) = setup(); | |
| 1395 | - | let src = create_test_file(&dir, "snare.wav", b"some audio bytes"); | |
| 1396 | - | ||
| 1397 | - | // Pre-hash then record, the prehashed path must land the same blob + row | |
| 1398 | - | // as the all-in-one import(). | |
| 1399 | - | let (hash, size) = hash_file(&src).unwrap(); | |
| 1400 | - | store | |
| 1401 | - | .import_hashed( | |
| 1402 | - | &src, | |
| 1403 | - | &crate::SampleHash::from_trusted(hash.clone()), | |
| 1404 | - | size, | |
| 1405 | - | &db, | |
| 1406 | - | ) | |
| 1407 | - | .unwrap(); | |
| 1408 | - | ||
| 1409 | - | assert!( | |
| 1410 | - | store | |
| 1411 | - | .exists(&crate::SampleHash::from_trusted(hash.clone()), "wav") | |
| 1412 | - | .unwrap() | |
| 1413 | - | ); | |
| 1414 | - | let count: i64 = db | |
| 1415 | - | .conn() | |
| 1416 | - | .query_row( | |
| 1417 | - | "SELECT COUNT(*) FROM samples WHERE hash = ?1", | |
| 1418 | - | [&hash], | |
| 1419 | - | |r| r.get(0), | |
| 1420 | - | ) | |
| 1421 | - | .unwrap(); | |
| 1422 | - | assert_eq!(count, 1); | |
| 1423 | - | } | |
| 1424 | - | ||
| 1425 | - | #[test] | |
| 1426 | - | fn hash_files_parallel_aligns_results() { | |
| 1427 | - | let (dir, _db, _store) = setup(); | |
| 1428 | - | let a = create_test_file(&dir, "a.wav", b"aaaa"); | |
| 1429 | - | let b = create_test_file(&dir, "b.wav", b"bbbbbb"); | |
| 1430 | - | let bad = create_test_file(&dir, "z.txt", b"nope"); // non-audio -> Err | |
| 1431 | - | ||
| 1432 | - | let results = hash_files_parallel(&[a.clone(), b.clone(), bad.clone()]); | |
| 1433 | - | assert_eq!(results.len(), 3); | |
| 1434 | - | // Aligned to input order; each Ok hash equals a direct hash_file call. | |
| 1435 | - | assert_eq!(results[0].as_ref().unwrap().0, hash_file(&a).unwrap().0); | |
| 1436 | - | assert_eq!(results[1].as_ref().unwrap().1, 6); | |
| 1437 | - | assert!(results[2].is_err()); | |
| 1438 | - | } | |
| 1439 | - | ||
| 1440 | - | #[test] | |
| 1441 | - | fn import_creates_file_and_row() { | |
| 1442 | - | let (dir, db, store) = setup(); | |
| 1443 | - | let src = create_test_file(&dir, "kick.wav", b"fake audio data"); | |
| 1444 | - | ||
| 1445 | - | let hash = store.import(&src, &db).unwrap(); | |
| 1446 | - | ||
| 1447 | - | // File exists in store | |
| 1448 | - | assert!( | |
| 1449 | - | store | |
| 1450 | - | .exists(&crate::SampleHash::from_trusted(hash.clone()), "wav") | |
| 1451 | - | .unwrap() | |
| 1452 | - | ); | |
| 1453 | - | ||
| 1454 | - | // Row exists in DB | |
| 1455 | - | let count: i64 = db | |
| 1456 | - | .conn() | |
| 1457 | - | .query_row( | |
| 1458 | - | "SELECT COUNT(*) FROM samples WHERE hash = ?1", | |
| 1459 | - | [&hash], | |
| 1460 | - | |row| row.get(0), | |
| 1461 | - | ) | |
| 1462 | - | .unwrap(); | |
| 1463 | - | assert_eq!(count, 1); | |
| 1464 | - | } | |
| 1465 | - | ||
| 1466 | - | /// The two entry points make opposite promises about the directory fsync, | |
| 1467 | - | /// and the split is the whole of the 27% the hoist bought: `import_hashed` | |
| 1468 | - | /// is the batch path and leaves its shard directory owing a flush, while | |
| 1469 | - | /// `import` is one-shot and owes nothing on return. | |
| 1470 | - | /// | |
| 1471 | - | /// Pins the bookkeeping rather than the fsync, which is not observable. The | |
| 1472 | - | /// regression it guards is a caller (or a future one) that loops | |
| 1473 | - | /// `import_hashed` and never flushes, or an `import` that quietly starts | |
| 1474 | - | /// deferring on behalf of callers that have no batch end. | |
| 1475 | - | #[test] | |
| 1476 | - | fn the_batch_path_defers_its_directory_fsync_and_the_one_shot_path_does_not() { | |
| 1477 | - | let (dir, db, store) = setup(); | |
| 1478 | - | ||
| 1479 | - | let batched = create_test_file(&dir, "batched.wav", b"batched bytes"); | |
| 1480 | - | let (hash, size) = hash_file(&batched).unwrap(); | |
| 1481 | - | let hash = crate::SampleHash::from_trusted(hash); | |
| 1482 | - | store.import_hashed(&batched, &hash, size, &db).unwrap(); | |
| 1483 | - | ||
| 1484 | - | assert_eq!( | |
| 1485 | - | store.pending_dirs().len(), | |
| 1486 | - | 1, | |
| 1487 | - | "the batch path leaves its shard directory owing an fsync", | |
| 1488 | - | ); | |
| 1489 | - | // Deferring the fsync must not defer the blob: it is renamed into place | |
| 1490 | - | // and readable now, which is why the batch end is soon enough. | |
| 1491 | - | assert!(store.exists(&hash, "wav").unwrap()); | |
| 1492 | - | ||
| 1493 | - | store.flush_dirs(); | |
| 1494 | - | assert!( | |
| 1495 | - | store.pending_dirs().is_empty(), | |
| 1496 | - | "flush_dirs drains the set, so a second run does not re-sync the world", | |
| 1497 | - | ); | |
| 1498 | - | ||
| 1499 | - | let one_shot = create_test_file(&dir, "one_shot.wav", b"one-shot bytes"); | |
| 1500 | - | store.import(&one_shot, &db).unwrap(); | |
| 1501 | - | assert!( | |
| 1502 | - | store.pending_dirs().is_empty(), | |
| 1503 | - | "the one-shot path has no batch end to defer to, so it flushes itself", | |
| 1504 | - | ); | |
| 1505 | - | } | |
| 1506 | - | ||
| 1507 | - | #[test] | |
| 1508 | - | fn import_deduplicates() { | |
| 1509 | - | let (dir, db, store) = setup(); | |
| 1510 | - | let src = create_test_file(&dir, "kick.wav", b"same content"); | |
| 1511 | - | ||
| 1512 | - | let hash1 = store.import(&src, &db).unwrap(); | |
| 1513 | - | let hash2 = store.import(&src, &db).unwrap(); | |
| 1514 | - | ||
| 1515 | - | assert_eq!(hash1, hash2); | |
| 1516 | - | ||
| 1517 | - | // Only one row | |
| 1518 | - | let count: i64 = db | |
| 1519 | - | .conn() | |
| 1520 | - | .query_row("SELECT COUNT(*) FROM samples", [], |row| row.get(0)) | |
| 1521 | - | .unwrap(); | |
| 1522 | - | assert_eq!(count, 1); | |
| 1523 | - | } | |
| 1524 | - | ||
| 1525 | - | #[test] | |
| 1526 | - | fn import_same_bytes_different_extension_reuses_blob() { | |
| 1527 | - | let (dir, db, store) = setup(); | |
| 1528 | - | // Identical bytes, two different audio extensions (is_audio_file keys on | |
| 1529 | - | // extension, so the content can be arbitrary). | |
| 1530 | - | let wav = create_test_file(&dir, "loop.wav", b"identical bytes"); | |
| 1531 | - | let aiff = create_test_file(&dir, "loop.aiff", b"identical bytes"); | |
| 1532 | - | ||
| 1533 | - | let h1 = store.import(&wav, &db).unwrap(); | |
| 1534 | - | let h2 = store.import(&aiff, &db).unwrap(); | |
| 1535 | - | assert_eq!(h1, h2, "identical bytes hash to the same sample"); | |
| 1536 | - | ||
| 1537 | - | // Exactly one blob on disk: the second import must reuse `{hash}.wav`, not | |
| 1538 | - | // write an unreachable `{hash}.aiff` orphan. Counted recursively, because | |
| 1539 | - | // blobs live under a shard directory now; counting only the root's own files | |
| 1540 | - | // would read 0 here and pass this test vacuously for the wrong reason. | |
| 1541 | - | let blob_count = || count_blobs_recursively(store.root()); | |
| 1542 | - | assert_eq!( | |
| 1543 | - | blob_count(), | |
| 1544 | - | 1, | |
| 1545 | - | "second extension must reuse the first blob" | |
| 1546 | - | ); | |
| 1547 | - | ||
| 1548 | - | // And remove() leaves nothing behind, the orphan would otherwise survive, | |
| 1549 | - | // since remove() resolves the blob path from the DB row's extension. | |
| 1550 | - | store | |
| 1551 | - | .remove(&crate::SampleHash::from_trusted(h1.clone()), &db) | |
| 1552 | - | .unwrap(); | |
| 1553 | - | assert_eq!(blob_count(), 0, "no orphan blob remains after remove"); | |
| 1554 | - | } | |
| 1555 | - | ||
| 1556 | - | #[test] | |
| 1557 | - | fn import_repairs_a_truncated_preexisting_blob() { | |
| 1558 | - | // Reproduces the corrupt-blob trap: a crash mid-copy (or any partial | |
| 1559 | - | // write) can leave a truncated file at the canonical content-addressed | |
| 1560 | - | // path. A content-addressed store must never trust it, import must | |
| 1561 | - | // detect the size mismatch and rewrite the correct bytes. | |
| 1562 | - | let (dir, db, store) = setup(); | |
| 1563 | - | let content = b"the genuine full sample payload"; | |
| 1564 | - | let src = create_test_file(&dir, "kick.wav", content); | |
| 1565 | - | ||
| 1566 | - | // Pre-place a truncated blob at the canonical path the real import targets. | |
| 1567 | - | let hash = hex::encode(Sha256::digest(content)); | |
| 1568 | - | let dest = store | |
| 1569 | - | .sample_path(&crate::SampleHash::from_trusted(hash.clone()), "wav") | |
| 1570 | - | .unwrap(); | |
| 1571 | - | fs::create_dir_all(dest.parent().unwrap()).unwrap(); | |
| 1572 | - | fs::write(&dest, b"trunc").unwrap(); | |
| 1573 | - | ||
| 1574 | - | let imported = store.import(&src, &db).unwrap(); | |
| 1575 | - | assert_eq!(imported, hash); | |
| 1576 | - | ||
| 1577 | - | // The stored blob now matches the source bytes exactly (hash verified). | |
| 1578 | - | let stored = fs::read(&dest).unwrap(); | |
| 1579 | - | assert_eq!(stored, content, "truncated blob must be repaired on import"); | |
| 1580 | - | assert_eq!( | |
| 1581 | - | hex::encode(Sha256::digest(&stored)), | |
| 1582 | - | hash, | |
| 1583 | - | "repaired blob hashes back to its content address" | |
| 1584 | - | ); | |
| 1585 | - | } | |
| 1586 | - | ||
| 1587 | - | #[test] | |
| 1588 | - | fn remove_deletes_file_and_row() { | |
| 1589 | - | let (dir, db, store) = setup(); | |
| 1590 | - | let src = create_test_file(&dir, "snare.wav", b"snare data"); | |
| 1591 | - | ||
| 1592 | - | let hash = store.import(&src, &db).unwrap(); | |
| 1593 | - | assert!( | |
| 1594 | - | store | |
| 1595 | - | .exists(&crate::SampleHash::from_trusted(hash.clone()), "wav") |
Lines truncated
| @@ -259,1813 +259,4 @@ | |||
| 259 | 259 | } | |
| 260 | 260 | ||
| 261 | 261 | #[cfg(test)] | |
| 262 | - | mod tests { | |
| 263 | - | use super::super::{ | |
| 264 | - | DELETE_ORDER, UPSERT_ORDER, pk_columns, | |
| 265 | - | resolve::{apply_delete, apply_remote_changes, apply_upsert}, | |
| 266 | - | table_columns, | |
| 267 | - | }; | |
| 268 | - | use super::*; | |
| 269 | - | use audiofiles_core::db::Database; | |
| 270 | - | use serde_json::json; | |
| 271 | - | use synckit_client::{ChangeEntry, ChangeOp}; | |
| 272 | - | ||
| 273 | - | fn setup_test_db() -> Database { | |
| 274 | - | Database::open_in_memory().expect("Failed to create test DB") | |
| 275 | - | } | |
| 276 | - | ||
| 277 | - | fn insert_sample(conn: &Connection, hash: &str, name: &str, ext: &str) { | |
| 278 | - | let now = chrono::Utc::now().timestamp(); | |
| 279 | - | conn.execute( | |
| 280 | - | "INSERT INTO samples (hash, original_name, file_extension, file_size, import_date, last_modified) VALUES (?1, ?2, ?3, 1024, ?4, ?4)", | |
| 281 | - | rusqlite::params![hash, name, ext, now], | |
| 282 | - | ).unwrap(); | |
| 283 | - | } | |
| 284 | - | ||
| 285 | - | fn insert_vfs(conn: &Connection, name: &str, sync_files: bool) -> i64 { | |
| 286 | - | let now = chrono::Utc::now().timestamp(); | |
| 287 | - | conn.execute( | |
| 288 | - | "INSERT INTO vfs (name, created_at, modified_at, sync_files) VALUES (?1, ?2, ?2, ?3)", | |
| 289 | - | rusqlite::params![name, now, sync_files as i64], | |
| 290 | - | ) | |
| 291 | - | .unwrap(); | |
| 292 | - | conn.last_insert_rowid() | |
| 293 | - | } | |
| 294 | - | ||
| 295 | - | fn clear_changelog(conn: &Connection) { | |
| 296 | - | conn.execute("DELETE FROM sync_changelog", []).unwrap(); | |
| 297 | - | } | |
| 298 | - | ||
| 299 | - | fn changelog_count(conn: &Connection, table: Option<&str>, op: Option<&str>) -> i64 { | |
| 300 | - | match (table, op) { | |
| 301 | - | (Some(t), Some(o)) => conn | |
| 302 | - | .query_row( | |
| 303 | - | "SELECT COUNT(*) FROM sync_changelog WHERE table_name = ?1 AND op = ?2", | |
| 304 | - | rusqlite::params![t, o], | |
| 305 | - | |row| row.get(0), | |
| 306 | - | ) | |
| 307 | - | .unwrap(), | |
| 308 | - | (Some(t), None) => conn | |
| 309 | - | .query_row( | |
| 310 | - | "SELECT COUNT(*) FROM sync_changelog WHERE table_name = ?1", | |
| 311 | - | [t], | |
| 312 | - | |row| row.get(0), | |
| 313 | - | ) | |
| 314 | - | .unwrap(), | |
| 315 | - | (None, Some(o)) => conn | |
| 316 | - | .query_row( | |
| 317 | - | "SELECT COUNT(*) FROM sync_changelog WHERE op = ?1", | |
| 318 | - | [o], | |
| 319 | - | |row| row.get(0), | |
| 320 | - | ) | |
| 321 | - | .unwrap(), | |
| 322 | - | (None, None) => conn | |
| 323 | - | .query_row("SELECT COUNT(*) FROM sync_changelog", [], |row| row.get(0)) | |
| 324 | - | .unwrap(), | |
| 325 | - | } | |
| 326 | - | } | |
| 327 | - | ||
| 328 | - | fn change( | |
| 329 | - | table: &str, | |
| 330 | - | op: ChangeOp, | |
| 331 | - | row_id: &str, | |
| 332 | - | data: Option<serde_json::Value>, | |
| 333 | - | ) -> ChangeEntry { | |
| 334 | - | ChangeEntry { | |
| 335 | - | table: table.to_string(), | |
| 336 | - | op, | |
| 337 | - | row_id: row_id.to_string(), | |
| 338 | - | timestamp: chrono::Utc::now(), | |
| 339 | - | hlc: synckit_client::Hlc::zero(synckit_client::DeviceId::nil()), | |
| 340 | - | data, | |
| 341 | - | extra: serde_json::Map::default(), | |
| 342 | - | } | |
| 343 | - | } | |
| 344 | - | ||
| 345 | - | // FK ordering | |
| 346 | - | ||
| 347 | - | #[test] | |
| 348 | - | fn upsert_order_parents_before_children() { | |
| 349 | - | let pos = |t: &str| UPSERT_ORDER.iter().position(|x| *x == t).unwrap(); | |
| 350 | - | assert!(pos("vfs") < pos("vfs_nodes")); | |
| 351 | - | assert!(pos("samples") < pos("audio_analysis")); | |
| 352 | - | assert!(pos("samples") < pos("tags")); | |
| 353 | - | assert!(pos("samples") < pos("collection_members")); | |
| 354 | - | assert!(pos("collections") < pos("collection_members")); | |
| 355 | - | } | |
| 356 | - | ||
| 357 | - | #[test] | |
| 358 | - | fn delete_order_children_before_parents() { | |
| 359 | - | let pos = |t: &str| DELETE_ORDER.iter().position(|x| *x == t).unwrap(); | |
| 360 | - | assert!(pos("vfs_nodes") < pos("vfs")); | |
| 361 | - | assert!(pos("audio_analysis") < pos("samples")); | |
| 362 | - | assert!(pos("tags") < pos("samples")); | |
| 363 | - | assert!(pos("collection_members") < pos("collections")); | |
| 364 | - | assert!(pos("collection_members") < pos("samples")); | |
| 365 | - | } | |
| 366 | - | ||
| 367 | - | #[test] | |
| 368 | - | fn orders_are_exact_reverses() { | |
| 369 | - | let reversed: Vec<&str> = UPSERT_ORDER.iter().rev().copied().collect(); | |
| 370 | - | assert_eq!(reversed, DELETE_ORDER); | |
| 371 | - | } | |
| 372 | - | ||
| 373 | - | // Column whitelists | |
| 374 | - | ||
| 375 | - | #[test] | |
| 376 | - | fn all_tables_have_column_whitelists() { | |
| 377 | - | for table in UPSERT_ORDER { | |
| 378 | - | assert!( | |
| 379 | - | table_columns(table).is_some(), | |
| 380 | - | "missing column whitelist for: {table}" | |
| 381 | - | ); | |
| 382 | - | } | |
| 383 | - | } | |
| 384 | - | ||
| 385 | - | #[test] | |
| 386 | - | fn unknown_table_returns_none() { | |
| 387 | - | assert!(table_columns("nonexistent").is_none()); | |
| 388 | - | assert!(table_columns("fingerprints").is_none()); | |
| 389 | - | } | |
| 390 | - | ||
| 391 | - | #[test] | |
| 392 | - | fn pk_columns_covers_all_tables() { | |
| 393 | - | for table in UPSERT_ORDER { | |
| 394 | - | let pks = pk_columns(table); | |
| 395 | - | assert!(!pks.is_empty(), "missing pk_columns for: {table}"); | |
| 396 | - | } | |
| 397 | - | assert_eq!(pk_columns("tags"), &["sample_hash", "tag"]); | |
| 398 | - | assert_eq!( | |
| 399 | - | pk_columns("collection_members"), | |
| 400 | - | &["collection_id", "sample_hash"] | |
| 401 | - | ); | |
| 402 | - | } | |
| 403 | - | ||
| 404 | - | // Triggers | |
| 405 | - | ||
| 406 | - | #[test] | |
| 407 | - | fn sample_insert_fires_trigger() { | |
| 408 | - | let db = setup_test_db(); | |
| 409 | - | let conn = db.conn(); | |
| 410 | - | clear_changelog(conn); | |
| 411 | - | ||
| 412 | - | insert_sample(conn, "abc123", "kick.wav", "wav"); | |
| 413 | - | ||
| 414 | - | assert_eq!(changelog_count(conn, Some("samples"), Some("INSERT")), 1); | |
| 415 | - | ||
| 416 | - | // Post-M018: row_id is `hash_row_id(salt, "abc123")` so we look up | |
| 417 | - | // by table+op and inspect the canonical hash inside the encrypted- | |
| 418 | - | // at-the-wire `data` field. | |
| 419 | - | let data: String = conn | |
| 420 | - | .query_row( | |
| 421 | - | "SELECT data FROM sync_changelog WHERE table_name = 'samples' AND op = 'INSERT'", | |
| 422 | - | [], | |
| 423 | - | |row| row.get(0), | |
| 424 | - | ) | |
| 425 | - | .unwrap(); | |
| 426 | - | let parsed: serde_json::Value = serde_json::from_str(&data).unwrap(); | |
| 427 | - | assert!(parsed.get("cloud_only").is_some()); | |
| 428 | - | assert_eq!(parsed["hash"], "abc123"); | |
| 429 | - | } | |
| 430 | - | ||
| 431 | - | #[test] | |
| 432 | - | fn vfs_insert_fires_trigger() { | |
| 433 | - | let db = setup_test_db(); | |
| 434 | - | let conn = db.conn(); | |
| 435 | - | clear_changelog(conn); | |
| 436 | - | ||
| 437 | - | let vfs_id = insert_vfs(conn, "Library", true); | |
| 438 | - | ||
| 439 | - | assert_eq!(changelog_count(conn, Some("vfs"), Some("INSERT")), 1); | |
| 440 | - | ||
| 441 | - | let row_id: String = conn | |
| 442 | - | .query_row( | |
| 443 | - | "SELECT row_id FROM sync_changelog WHERE table_name = 'vfs'", | |
| 444 | - | [], | |
| 445 | - | |row| row.get(0), | |
| 446 | - | ) | |
| 447 | - | .unwrap(); | |
| 448 | - | assert_eq!(row_id, vfs_id.to_string()); | |
| 449 | - | } | |
| 450 | - | ||
| 451 | - | #[test] | |
| 452 | - | fn tag_insert_fires_trigger() { | |
| 453 | - | let db = setup_test_db(); | |
| 454 | - | let conn = db.conn(); | |
| 455 | - | insert_sample(conn, "hash1", "snare.wav", "wav"); | |
| 456 | - | clear_changelog(conn); | |
| 457 | - | ||
| 458 | - | conn.execute( | |
| 459 | - | "INSERT INTO tags (sample_hash, tag) VALUES ('hash1', 'drums')", | |
| 460 | - | [], | |
| 461 | - | ) | |
| 462 | - | .unwrap(); | |
| 463 | - | ||
| 464 | - | assert_eq!(changelog_count(conn, Some("tags"), Some("INSERT")), 1); | |
| 465 | - | ||
| 466 | - | // Post-M018: row_id is hashed; the cleartext key lives in `data`. | |
| 467 | - | let data: String = conn | |
| 468 | - | .query_row( | |
| 469 | - | "SELECT data FROM sync_changelog WHERE table_name = 'tags'", | |
| 470 | - | [], | |
| 471 | - | |row| row.get(0), | |
| 472 | - | ) | |
| 473 | - | .unwrap(); | |
| 474 | - | let parsed: serde_json::Value = serde_json::from_str(&data).unwrap(); | |
| 475 | - | assert_eq!(parsed["sample_hash"], "hash1"); | |
| 476 | - | assert_eq!(parsed["tag"], "drums"); | |
| 477 | - | } | |
| 478 | - | ||
| 479 | - | #[test] | |
| 480 | - | fn sample_features_insert_fires_trigger() { | |
| 481 | - | let db = setup_test_db(); | |
| 482 | - | let conn = db.conn(); | |
| 483 | - | insert_sample(conn, "hf", "kick.wav", "wav"); | |
| 484 | - | clear_changelog(conn); | |
| 485 | - | ||
| 486 | - | conn.execute( | |
| 487 | - | "INSERT INTO sample_features (hash, feat_version, vector, computed_at) \ | |
| 488 | - | VALUES ('hf', 1, '[1.0,2.0]', 100)", | |
| 489 | - | [], | |
| 490 | - | ) | |
| 491 | - | .unwrap(); | |
| 492 | - | ||
| 493 | - | assert_eq!( | |
| 494 | - | changelog_count(conn, Some("sample_features"), Some("INSERT")), | |
| 495 | - | 1 | |
| 496 | - | ); | |
| 497 | - | ||
| 498 | - | let data: String = conn | |
| 499 | - | .query_row( | |
| 500 | - | "SELECT data FROM sync_changelog WHERE table_name = 'sample_features'", | |
| 501 | - | [], | |
| 502 | - | |row| row.get(0), | |
| 503 | - | ) | |
| 504 | - | .unwrap(); | |
| 505 | - | let parsed: serde_json::Value = serde_json::from_str(&data).unwrap(); | |
| 506 | - | assert_eq!(parsed["hash"], "hf"); | |
| 507 | - | assert_eq!(parsed["feat_version"], 1); | |
| 508 | - | // The JSON-array vector embeds as a string payload. | |
| 509 | - | assert_eq!(parsed["vector"], "[1.0,2.0]"); | |
| 510 | - | } | |
| 511 | - | ||
| 512 | - | #[test] | |
| 513 | - | fn tag_rules_insert_fires_trigger() { | |
| 514 | - | let db = setup_test_db(); | |
| 515 | - | let conn = db.conn(); | |
| 516 | - | clear_changelog(conn); | |
| 517 | - | ||
| 518 | - | conn.execute( | |
| 519 | - | "INSERT INTO tag_rules (id, name, enabled, priority, match_mode, conditions, actions, created_at) \ | |
| 520 | - | VALUES ('r1', 'kicks', 1, 0, '\"all\"', '[]', '[]', 0)", | |
| 521 | - | [], | |
| 522 | - | ).unwrap(); | |
| 523 | - | ||
| 524 | - | assert_eq!(changelog_count(conn, Some("tag_rules"), Some("INSERT")), 1); | |
| 525 | - | let row_id: String = conn | |
| 526 | - | .query_row( | |
| 527 | - | "SELECT row_id FROM sync_changelog WHERE table_name = 'tag_rules'", | |
| 528 | - | [], | |
| 529 | - | |row| row.get(0), | |
| 530 | - | ) | |
| 531 | - | .unwrap(); | |
| 532 | - | // Opaque rule id is non-sensitive -> cleartext row_id. | |
| 533 | - | assert_eq!(row_id, "r1"); | |
| 534 | - | } | |
| 535 | - | ||
| 536 | - | #[test] | |
| 537 | - | fn tag_provenance_insert_fires_trigger() { | |
| 538 | - | let db = setup_test_db(); | |
| 539 | - | let conn = db.conn(); | |
| 540 | - | insert_sample(conn, "ph", "kick.wav", "wav"); | |
| 541 | - | clear_changelog(conn); | |
| 542 | - | ||
| 543 | - | conn.execute( | |
| 544 | - | "INSERT INTO tag_provenance (sample_hash, tag, source, rule_id) \ | |
| 545 | - | VALUES ('ph', 'instrument.drum.kick', 'rule', 'r1')", | |
| 546 | - | [], | |
| 547 | - | ) | |
| 548 | - | .unwrap(); | |
| 549 | - | ||
| 550 | - | assert_eq!( | |
| 551 | - | changelog_count(conn, Some("tag_provenance"), Some("INSERT")), | |
| 552 | - | 1 | |
| 553 | - | ); | |
| 554 | - | let data: String = conn | |
| 555 | - | .query_row( | |
| 556 | - | "SELECT data FROM sync_changelog WHERE table_name = 'tag_provenance'", | |
| 557 | - | [], | |
| 558 | - | |row| row.get(0), | |
| 559 | - | ) | |
| 560 | - | .unwrap(); | |
| 561 | - | let parsed: serde_json::Value = serde_json::from_str(&data).unwrap(); | |
| 562 | - | assert_eq!(parsed["sample_hash"], "ph"); | |
| 563 | - | assert_eq!(parsed["tag"], "instrument.drum.kick"); | |
| 564 | - | assert_eq!(parsed["source"], "rule"); | |
| 565 | - | assert_eq!(parsed["rule_id"], "r1"); | |
| 566 | - | } | |
| 567 | - | ||
| 568 | - | #[test] | |
| 569 | - | fn collection_member_insert_fires_trigger() { | |
| 570 | - | let db = setup_test_db(); | |
| 571 | - | let conn = db.conn(); | |
| 572 | - | insert_sample(conn, "hash2", "hat.wav", "wav"); | |
| 573 | - | let now = chrono::Utc::now().timestamp(); | |
| 574 | - | conn.execute( | |
| 575 | - | "INSERT INTO collections (name, description, created_at) VALUES ('Faves', NULL, ?1)", | |
| 576 | - | [now], | |
| 577 | - | ) | |
| 578 | - | .unwrap(); | |
| 579 | - | let collection_id = conn.last_insert_rowid(); | |
| 580 | - | clear_changelog(conn); | |
| 581 | - | ||
| 582 | - | conn.execute( | |
| 583 | - | "INSERT INTO collection_members (collection_id, sample_hash, added_at) VALUES (?1, 'hash2', ?2)", | |
| 584 | - | rusqlite::params![collection_id, now], | |
| 585 | - | ).unwrap(); | |
| 586 | - | ||
| 587 | - | assert_eq!( | |
| 588 | - | changelog_count(conn, Some("collection_members"), Some("INSERT")), | |
| 589 | - | 1 | |
| 590 | - | ); | |
| 591 | - | ||
| 592 | - | let data: String = conn | |
| 593 | - | .query_row( | |
| 594 | - | "SELECT data FROM sync_changelog WHERE table_name = 'collection_members'", | |
| 595 | - | [], | |
| 596 | - | |row| row.get(0), | |
| 597 | - | ) | |
| 598 | - | .unwrap(); | |
| 599 | - | let parsed: serde_json::Value = serde_json::from_str(&data).unwrap(); | |
| 600 | - | assert_eq!(parsed["collection_id"].as_i64().unwrap(), collection_id); | |
| 601 | - | assert_eq!(parsed["sample_hash"], "hash2"); | |
| 602 | - | } | |
| 603 | - | ||
| 604 | - | // Trigger suppression | |
| 605 | - | ||
| 606 | - | #[test] | |
| 607 | - | fn trigger_suppression_during_remote_apply() { | |
| 608 | - | let db = setup_test_db(); | |
| 609 | - | let conn = db.conn(); | |
| 610 | - | clear_changelog(conn); | |
| 611 | - | ||
| 612 | - | set_sync_state(conn, "applying_remote", "1").unwrap(); | |
| 613 | - | insert_sample(conn, "suppressed", "test.wav", "wav"); | |
| 614 | - | assert_eq!(changelog_count(conn, None, None), 0); | |
| 615 | - | ||
| 616 | - | set_sync_state(conn, "applying_remote", "0").unwrap(); | |
| 617 | - | insert_sample(conn, "unsuppressed", "test2.wav", "wav"); | |
| 618 | - | assert_eq!(changelog_count(conn, None, None), 1); | |
| 619 | - | } | |
| 620 | - | ||
| 621 | - | // apply_upsert | |
| 622 | - | ||
| 623 | - | #[test] | |
| 624 | - | fn apply_upsert_inserts_sample() { | |
| 625 | - | let db = setup_test_db(); | |
| 626 | - | let conn = db.conn(); | |
| 627 | - | ||
| 628 | - | let data = json!({ | |
| 629 | - | "hash": "upsert_hash", | |
| 630 | - | "original_name": "synced.wav", | |
| 631 | - | "file_extension": "wav", | |
| 632 | - | "file_size": 2048, | |
| 633 | - | "import_date": 1_000_000, | |
| 634 | - | "last_modified": 1_000_000, | |
| 635 | - | "cloud_only": 0 | |
| 636 | - | }); | |
| 637 | - | ||
| 638 | - | apply_upsert(conn, "samples", &data).unwrap(); | |
| 639 | - | ||
| 640 | - | let name: String = conn | |
| 641 | - | .query_row( | |
| 642 | - | "SELECT original_name FROM samples WHERE hash = 'upsert_hash'", | |
| 643 | - | [], | |
| 644 | - | |row| row.get(0), | |
| 645 | - | ) | |
| 646 | - | .unwrap(); | |
| 647 | - | assert_eq!(name, "synced.wav"); | |
| 648 | - | } | |
| 649 | - | ||
| 650 | - | #[test] | |
| 651 | - | fn apply_upsert_inserts_vfs_node() { | |
| 652 | - | let db = setup_test_db(); | |
| 653 | - | let conn = db.conn(); | |
| 654 | - | ||
| 655 | - | let vfs_id = insert_vfs(conn, "TestVFS", false); | |
| 656 | - | insert_sample(conn, "node_hash", "pad.wav", "wav"); | |
| 657 | - | ||
| 658 | - | let data = json!({ | |
| 659 | - | "id": 999, | |
| 660 | - | "vfs_id": vfs_id, | |
| 661 | - | "parent_id": null, | |
| 662 | - | "name": "pad.wav", | |
| 663 | - | "node_type": "sample", | |
| 664 | - | "sample_hash": "node_hash", | |
| 665 | - | "created_at": 1_000_000 | |
| 666 | - | }); | |
| 667 | - | ||
| 668 | - | apply_upsert(conn, "vfs_nodes", &data).unwrap(); | |
| 669 | - | ||
| 670 | - | let name: String = conn | |
| 671 | - | .query_row("SELECT name FROM vfs_nodes WHERE id = 999", [], |row| { | |
| 672 | - | row.get(0) | |
| 673 | - | }) | |
| 674 | - | .unwrap(); | |
| 675 | - | assert_eq!(name, "pad.wav"); | |
| 676 | - | } | |
| 677 | - | ||
| 678 | - | #[test] | |
| 679 | - | fn apply_upsert_unknown_table_is_no_op() { | |
| 680 | - | let db = setup_test_db(); | |
| 681 | - | let conn = db.conn(); | |
| 682 | - | ||
| 683 | - | let data = json!({"id": "abc"}); | |
| 684 | - | let result = apply_upsert(conn, "nonexistent_table", &data); | |
| 685 | - | assert!(result.is_ok()); | |
| 686 | - | } | |
| 687 | - | ||
| 688 | - | // apply_delete | |
| 689 | - | ||
| 690 | - | #[test] | |
| 691 | - | fn apply_delete_samples_tombstones_not_hard_deletes() { | |
| 692 | - | let db = setup_test_db(); | |
| 693 | - | let conn = db.conn(); | |
| 694 | - | insert_sample(conn, "del_hash", "delete_me.wav", "wav"); | |
| 695 | - | ||
| 696 | - | // A remote samples delete must soft-delete (tombstone), never hard-delete: | |
| 697 | - | // the row stays so the engine-level CASCADE never fires. | |
| 698 | - | apply_delete(conn, "samples", "del_hash", None).unwrap(); | |
| 699 | - | ||
| 700 | - | let (count, tombstoned): (i64, i64) = conn | |
| 701 | - | .query_row( | |
| 702 | - | "SELECT COUNT(*), COUNT(deleted_at) FROM samples WHERE hash = 'del_hash'", | |
| 703 | - | [], | |
| 704 | - | |row| Ok((row.get(0)?, row.get(1)?)), | |
| 705 | - | ) | |
| 706 | - | .unwrap(); | |
| 707 | - | assert_eq!(count, 1, "row must survive a remote delete"); | |
| 708 | - | assert_eq!(tombstoned, 1, "row must be tombstoned (deleted_at set)"); | |
| 709 | - | } | |
| 710 | - | ||
| 711 | - | #[test] | |
| 712 | - | fn apply_delete_samples_preserves_placements_and_tags() { | |
| 713 | - | let db = setup_test_db(); | |
| 714 | - | let conn = db.conn(); | |
| 715 | - | let vfs_id = insert_vfs(conn, "Library", true); | |
| 716 | - | insert_sample(conn, "keep_hash", "kick.wav", "wav"); | |
| 717 | - | conn.execute( | |
| 718 | - | "INSERT INTO vfs_nodes (vfs_id, parent_id, name, node_type, sample_hash, created_at) \ | |
| 719 | - | VALUES (?1, NULL, 'kick.wav', 'sample', 'keep_hash', 1000)", | |
| 720 | - | [vfs_id], | |
| 721 | - | ) | |
| 722 | - | .unwrap(); | |
| 723 | - | conn.execute( | |
| 724 | - | "INSERT INTO tags (sample_hash, tag) VALUES ('keep_hash', 'drums')", | |
| 725 | - | [], | |
| 726 | - | ) | |
| 727 | - | .unwrap(); | |
| 728 | - | ||
| 729 | - | // The M018+ wire form: hash lives in `data`, row_id is opaque. | |
| 730 | - | apply_delete( | |
| 731 | - | conn, | |
| 732 | - | "samples", | |
| 733 | - | "opaque_row_id", | |
| 734 | - | Some(&json!({ "hash": "keep_hash" })), | |
| 735 | - | ) | |
| 736 | - | .unwrap(); | |
| 737 | - | ||
| 738 | - | let placements: i64 = conn | |
| 739 | - | .query_row( | |
| 740 | - | "SELECT COUNT(*) FROM vfs_nodes WHERE sample_hash = 'keep_hash'", | |
| 741 | - | [], | |
| 742 | - | |r| r.get(0), | |
| 743 | - | ) | |
| 744 | - | .unwrap(); | |
| 745 | - | let tags: i64 = conn | |
| 746 | - | .query_row( | |
| 747 | - | "SELECT COUNT(*) FROM tags WHERE sample_hash = 'keep_hash'", | |
| 748 | - | [], | |
| 749 | - | |r| r.get(0), | |
| 750 | - | ) | |
| 751 | - | .unwrap(); | |
| 752 | - | assert_eq!( | |
| 753 | - | placements, 1, | |
| 754 | - | "remote delete must not cascade-wipe placements" | |
| 755 | - | ); | |
| 756 | - | assert_eq!(tags, 1, "remote delete must not cascade-wipe tags"); | |
| 757 | - | } | |
| 758 | - |
Lines truncated
| @@ -1,0 +1,58 @@ | |||
| 1 | + | //! Tests for [`super`]. | |
| 2 | + | ||
| 3 | + | use super::*; | |
| 4 | + | ||
| 5 | + | #[test] | |
| 6 | + | fn macro_average_counts_an_unpredicted_class_as_zero() { | |
| 7 | + | // Skipping it would let a layer raise its macro precision by predicting | |
| 8 | + | // a hard class less often, which is backwards. | |
| 9 | + | let classes = vec!["a".to_string(), "b".to_string()]; | |
| 10 | + | let mut counts = BTreeMap::new(); | |
| 11 | + | counts.insert( | |
| 12 | + | "a".to_string(), | |
| 13 | + | Counts { | |
| 14 | + | tp: 10, | |
| 15 | + | fp: 0, | |
| 16 | + | fn_: 0, | |
| 17 | + | }, | |
| 18 | + | ); | |
| 19 | + | counts.insert( | |
| 20 | + | "b".to_string(), | |
| 21 | + | Counts { | |
| 22 | + | tp: 0, | |
| 23 | + | fp: 0, | |
| 24 | + | fn_: 10, | |
| 25 | + | }, | |
| 26 | + | ); | |
| 27 | + | assert_eq!( | |
| 28 | + | macro_average(&classes, &counts, Counts::precision), | |
| 29 | + | Some(0.5) | |
| 30 | + | ); | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | #[test] | |
| 34 | + | fn class_points_score_an_absent_class_as_zero() { | |
| 35 | + | // A class missing from a sample's scores is a real zero, not a gap: the | |
| 36 | + | // index considered it and gave it no neighbourhood weight. Treating it | |
| 37 | + | // as missing would drop true negatives and inflate precision. | |
| 38 | + | let p = vec![Prediction { | |
| 39 | + | truth: "instrument.drum.kick".into(), | |
| 40 | + | top1: Some("instrument.drum.kick".into()), | |
| 41 | + | scores: BTreeMap::from([("instrument.drum.kick".to_string(), 0.9)]), | |
| 42 | + | fold: 0, | |
| 43 | + | origin: "kick".into(), | |
| 44 | + | }]; | |
| 45 | + | let pts = class_points(&p, "instrument.drum.snare"); | |
| 46 | + | assert_eq!(pts.len(), 1); | |
| 47 | + | assert!((pts[0].score - 0.0).abs() < f64::EPSILON); | |
| 48 | + | assert!(!pts[0].actual); | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | #[test] | |
| 52 | + | fn k_sweep_always_contains_the_runtime_k() { | |
| 53 | + | // Safe to set: this test does not read the env, it checks the invariant | |
| 54 | + | // the parser must hold whatever the env said. | |
| 55 | + | let ks = k_sweep_from_env(); | |
| 56 | + | assert!(ks.contains(&DEFAULT_K)); | |
| 57 | + | assert!(ks.windows(2).all(|w| w[0] < w[1]), "sorted and deduped"); | |
| 58 | + | } |
| @@ -1,0 +1,1104 @@ | |||
| 1 | + | //! Tests for [`super`]. | |
| 2 | + | ||
| 3 | + | use super::*; | |
| 4 | + | use crate::db::Database; | |
| 5 | + | use crate::store::SampleStore; | |
| 6 | + | use crate::vfs; | |
| 7 | + | use std::fs; | |
| 8 | + | use std::io::Write; | |
| 9 | + | use std::path::Path; | |
| 10 | + | use std::sync::atomic::AtomicBool; | |
| 11 | + | ||
| 12 | + | fn setup_vfs_with_samples(db: &Database, store: &SampleStore, dir: &Path) -> crate::VfsId { | |
| 13 | + | let vfs_id = vfs::create_vfs(db, "TestVFS").unwrap(); | |
| 14 | + | ||
| 15 | + | // Create a real audio file in a temp location, then import it | |
| 16 | + | let wav_path = dir.join("kick.wav"); | |
| 17 | + | write_test_wav(&wav_path, 1, 44100, &[0.5, -0.5, 0.25, 0.0]); | |
| 18 | + | let hash = store.import(&wav_path, db).unwrap(); | |
| 19 | + | ||
| 20 | + | // Create directory structure in VFS | |
| 21 | + | let drums_id = vfs::create_directory(db, vfs_id, None, "Drums").unwrap(); | |
| 22 | + | vfs::create_sample_link( | |
| 23 | + | db, | |
| 24 | + | vfs_id, | |
| 25 | + | Some(drums_id), | |
| 26 | + | "kick.wav", | |
| 27 | + | &crate::SampleHash::from_trusted(hash.clone()), | |
| 28 | + | ) | |
| 29 | + | .unwrap(); | |
| 30 | + | ||
| 31 | + | vfs_id | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | fn write_test_wav(path: &Path, channels: u16, sample_rate: u32, samples: &[f32]) { | |
| 35 | + | let bytes_per_sample = 4u16; | |
| 36 | + | let block_align = channels * bytes_per_sample; | |
| 37 | + | let data_size = (samples.len() as u32) * 4; | |
| 38 | + | let file_size = 36 + data_size; | |
| 39 | + | ||
| 40 | + | let mut buf = Vec::with_capacity(44 + data_size as usize); | |
| 41 | + | buf.extend_from_slice(b"RIFF"); | |
| 42 | + | buf.extend_from_slice(&file_size.to_le_bytes()); | |
| 43 | + | buf.extend_from_slice(b"WAVE"); | |
| 44 | + | buf.extend_from_slice(b"fmt "); | |
| 45 | + | buf.extend_from_slice(&16u32.to_le_bytes()); | |
| 46 | + | buf.extend_from_slice(&3u16.to_le_bytes()); | |
| 47 | + | buf.extend_from_slice(&channels.to_le_bytes()); | |
| 48 | + | buf.extend_from_slice(&sample_rate.to_le_bytes()); | |
| 49 | + | buf.extend_from_slice(&(sample_rate * block_align as u32).to_le_bytes()); | |
| 50 | + | buf.extend_from_slice(&block_align.to_le_bytes()); | |
| 51 | + | buf.extend_from_slice(&(bytes_per_sample * 8).to_le_bytes()); | |
| 52 | + | buf.extend_from_slice(b"data"); | |
| 53 | + | buf.extend_from_slice(&data_size.to_le_bytes()); | |
| 54 | + | for &s in samples { | |
| 55 | + | buf.extend_from_slice(&s.to_le_bytes()); | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | let mut file = fs::File::create(path).unwrap(); | |
| 59 | + | file.write_all(&buf).unwrap(); | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | #[test] | |
| 63 | + | fn collect_export_items_builds_relative_paths() { | |
| 64 | + | let dir = tempfile::tempdir().unwrap(); | |
| 65 | + | let db = Database::open_in_memory().unwrap(); | |
| 66 | + | let store = SampleStore::new(dir.path().join("store")).unwrap(); | |
| 67 | + | let vfs_id = setup_vfs_with_samples(&db, &store, dir.path()); | |
| 68 | + | ||
| 69 | + | let items = collect_export_items(&db, vfs_id, None).unwrap(); | |
| 70 | + | assert_eq!(items.len(), 1); | |
| 71 | + | assert_eq!(items[0].name, "kick.wav"); | |
| 72 | + | assert_eq!(items[0].relative_path, PathBuf::from("Drums/kick.wav")); | |
| 73 | + | } | |
| 74 | + | ||
| 75 | + | #[test] | |
| 76 | + | fn export_single_original_copies_file() { | |
| 77 | + | let dir = tempfile::tempdir().unwrap(); | |
| 78 | + | let db = Database::open_in_memory().unwrap(); | |
| 79 | + | let store = SampleStore::new(dir.path().join("store")).unwrap(); | |
| 80 | + | let vfs_id = setup_vfs_with_samples(&db, &store, dir.path()); | |
| 81 | + | ||
| 82 | + | let items = collect_export_items(&db, vfs_id, None).unwrap(); | |
| 83 | + | let dest_dir = dir.path().join("export"); | |
| 84 | + | ||
| 85 | + | let config = ExportConfig { | |
| 86 | + | format: ExportFormat::Original, | |
| 87 | + | sample_rate: None, | |
| 88 | + | bit_depth: None, | |
| 89 | + | channels: ExportChannels::Original, | |
| 90 | + | naming_pattern: None, | |
| 91 | + | flatten: false, | |
| 92 | + | metadata_sidecar: false, | |
| 93 | + | destination: dest_dir.clone(), | |
| 94 | + | device_profile: None, | |
| 95 | + | naming_rules: None, | |
| 96 | + | max_file_size_bytes: None, | |
| 97 | + | name_overrides: None, | |
| 98 | + | }; | |
| 99 | + | ||
| 100 | + | let summary = run_export( | |
| 101 | + | &items, | |
| 102 | + | &config, | |
| 103 | + | &store, | |
| 104 | + | &AtomicBool::new(false), | |
| 105 | + | |_, _, _| true, | |
| 106 | + | ) | |
| 107 | + | .unwrap(); | |
| 108 | + | assert_eq!(summary.total, 1); | |
| 109 | + | assert!(summary.errors.is_empty()); | |
| 110 | + | ||
| 111 | + | // Verify the file was copied with directory structure | |
| 112 | + | let exported = dest_dir.join("Drums").join("kick.wav"); | |
| 113 | + | assert!(exported.exists()); | |
| 114 | + | ||
| 115 | + | // Verify content matches (hardlink or copy) | |
| 116 | + | let source_path = store.sample_path(&items[0].hash, &items[0].ext).unwrap(); | |
| 117 | + | let source_bytes = fs::read(&source_path).unwrap(); | |
| 118 | + | let export_bytes = fs::read(&exported).unwrap(); | |
| 119 | + | assert_eq!(source_bytes, export_bytes); | |
| 120 | + | } | |
| 121 | + | ||
| 122 | + | #[test] | |
| 123 | + | fn export_single_wav_16bit() { | |
| 124 | + | let dir = tempfile::tempdir().unwrap(); | |
| 125 | + | let db = Database::open_in_memory().unwrap(); | |
| 126 | + | let store = SampleStore::new(dir.path().join("store")).unwrap(); | |
| 127 | + | let vfs_id = setup_vfs_with_samples(&db, &store, dir.path()); | |
| 128 | + | ||
| 129 | + | let items = collect_export_items(&db, vfs_id, None).unwrap(); | |
| 130 | + | let dest_dir = dir.path().join("export"); | |
| 131 | + | ||
| 132 | + | let config = ExportConfig { | |
| 133 | + | format: ExportFormat::Wav, | |
| 134 | + | sample_rate: Some(44100), | |
| 135 | + | bit_depth: Some(16), | |
| 136 | + | channels: ExportChannels::Original, | |
| 137 | + | naming_pattern: None, | |
| 138 | + | flatten: false, | |
| 139 | + | metadata_sidecar: false, | |
| 140 | + | destination: dest_dir.clone(), | |
| 141 | + | device_profile: None, | |
| 142 | + | naming_rules: None, | |
| 143 | + | max_file_size_bytes: None, | |
| 144 | + | name_overrides: None, | |
| 145 | + | }; | |
| 146 | + | ||
| 147 | + | let summary = run_export( | |
| 148 | + | &items, | |
| 149 | + | &config, | |
| 150 | + | &store, | |
| 151 | + | &AtomicBool::new(false), | |
| 152 | + | |_, _, _| true, | |
| 153 | + | ) | |
| 154 | + | .unwrap(); | |
| 155 | + | assert_eq!(summary.total, 1); | |
| 156 | + | assert!(summary.errors.is_empty()); | |
| 157 | + | ||
| 158 | + | // Verify the WAV was created | |
| 159 | + | let exported = dest_dir.join("Drums").join("kick.wav"); | |
| 160 | + | assert!(exported.exists()); | |
| 161 | + | ||
| 162 | + | // Verify it's a valid 16-bit WAV | |
| 163 | + | let reader = hound::WavReader::open(&exported).unwrap(); | |
| 164 | + | assert_eq!(reader.spec().bits_per_sample, 16); | |
| 165 | + | assert_eq!(reader.spec().sample_rate, 44100); | |
| 166 | + | } | |
| 167 | + | ||
| 168 | + | #[test] | |
| 169 | + | fn export_single_wav_24bit() { | |
| 170 | + | let dir = tempfile::tempdir().unwrap(); | |
| 171 | + | let db = Database::open_in_memory().unwrap(); | |
| 172 | + | let store = SampleStore::new(dir.path().join("store")).unwrap(); | |
| 173 | + | let vfs_id = setup_vfs_with_samples(&db, &store, dir.path()); | |
| 174 | + | ||
| 175 | + | let items = collect_export_items(&db, vfs_id, None).unwrap(); | |
| 176 | + | let dest_dir = dir.path().join("export"); | |
| 177 | + | ||
| 178 | + | let config = ExportConfig { | |
| 179 | + | format: ExportFormat::Wav, | |
| 180 | + | sample_rate: None, | |
| 181 | + | bit_depth: Some(24), | |
| 182 | + | channels: ExportChannels::Mono, | |
| 183 | + | naming_pattern: None, | |
| 184 | + | flatten: false, | |
| 185 | + | metadata_sidecar: false, | |
| 186 | + | destination: dest_dir.clone(), | |
| 187 | + | device_profile: None, | |
| 188 | + | naming_rules: None, | |
| 189 | + | max_file_size_bytes: None, | |
| 190 | + | name_overrides: None, | |
| 191 | + | }; | |
| 192 | + | ||
| 193 | + | let summary = run_export( | |
| 194 | + | &items, | |
| 195 | + | &config, | |
| 196 | + | &store, | |
| 197 | + | &AtomicBool::new(false), | |
| 198 | + | |_, _, _| true, | |
| 199 | + | ) | |
| 200 | + | .unwrap(); | |
| 201 | + | assert!(summary.errors.is_empty()); | |
| 202 | + | ||
| 203 | + | let exported = dest_dir.join("Drums").join("kick.wav"); | |
| 204 | + | let reader = hound::WavReader::open(&exported).unwrap(); | |
| 205 | + | assert_eq!(reader.spec().bits_per_sample, 24); | |
| 206 | + | assert_eq!(reader.spec().channels, 1); | |
| 207 | + | } | |
| 208 | + | ||
| 209 | + | #[test] | |
| 210 | + | fn export_flat_with_pattern() { | |
| 211 | + | let dir = tempfile::tempdir().unwrap(); | |
| 212 | + | let db = Database::open_in_memory().unwrap(); | |
| 213 | + | let store = SampleStore::new(dir.path().join("store")).unwrap(); | |
| 214 | + | let vfs_id = setup_vfs_with_samples(&db, &store, dir.path()); | |
| 215 | + | ||
| 216 | + | let items = collect_export_items(&db, vfs_id, None).unwrap(); | |
| 217 | + | let dest_dir = dir.path().join("export_flat"); | |
| 218 | + | ||
| 219 | + | let config = ExportConfig { | |
| 220 | + | format: ExportFormat::Original, | |
| 221 | + | sample_rate: None, | |
| 222 | + | bit_depth: None, | |
| 223 | + | channels: ExportChannels::Original, | |
| 224 | + | naming_pattern: Some("{nn}_{name}".to_string()), | |
| 225 | + | flatten: true, | |
| 226 | + | metadata_sidecar: false, | |
| 227 | + | destination: dest_dir.clone(), | |
| 228 | + | device_profile: None, | |
| 229 | + | naming_rules: None, | |
| 230 | + | max_file_size_bytes: None, | |
| 231 | + | name_overrides: None, | |
| 232 | + | }; | |
| 233 | + | ||
| 234 | + | let summary = run_export( | |
| 235 | + | &items, | |
| 236 | + | &config, | |
| 237 | + | &store, | |
| 238 | + | &AtomicBool::new(false), | |
| 239 | + | |_, _, _| true, | |
| 240 | + | ) | |
| 241 | + | .unwrap(); | |
| 242 | + | assert!(summary.errors.is_empty()); | |
| 243 | + | ||
| 244 | + | // Should be flat (no Drums/ subdirectory) | |
| 245 | + | let exported = dest_dir.join("01_kick.wav"); | |
| 246 | + | assert!(exported.exists(), "expected 01_kick.wav in flat export"); | |
| 247 | + | assert!(!dest_dir.join("Drums").exists()); | |
| 248 | + | } | |
| 249 | + | ||
| 250 | + | #[test] | |
| 251 | + | fn split_name_ext_works() { | |
| 252 | + | use crate::util::split_name_ext; | |
| 253 | + | assert_eq!(split_name_ext("kick.wav"), ("kick".into(), "wav".into())); | |
| 254 | + | assert_eq!(split_name_ext("noext"), ("noext".into(), String::new())); | |
| 255 | + | assert_eq!( | |
| 256 | + | split_name_ext("archive.tar.gz"), | |
| 257 | + | ("archive.tar".into(), "gz".into()) | |
| 258 | + | ); | |
| 259 | + | } | |
| 260 | + | ||
| 261 | + | #[test] | |
| 262 | + | fn export_with_sidecar_writes_json() { | |
| 263 | + | let dir = tempfile::tempdir().unwrap(); | |
| 264 | + | let db = Database::open_in_memory().unwrap(); | |
| 265 | + | let store = SampleStore::new(dir.path().join("store")).unwrap(); | |
| 266 | + | let vfs_id = setup_vfs_with_samples(&db, &store, dir.path()); | |
| 267 | + | ||
| 268 | + | let mut items = collect_export_items(&db, vfs_id, None).unwrap(); | |
| 269 | + | enrich_with_tags(&db, &mut items); | |
| 270 | + | let dest_dir = dir.path().join("export_sidecar"); | |
| 271 | + | ||
| 272 | + | let config = ExportConfig { | |
| 273 | + | format: ExportFormat::Original, | |
| 274 | + | sample_rate: None, | |
| 275 | + | bit_depth: None, | |
| 276 | + | channels: ExportChannels::Original, | |
| 277 | + | naming_pattern: None, | |
| 278 | + | flatten: false, | |
| 279 | + | metadata_sidecar: true, | |
| 280 | + | destination: dest_dir.clone(), | |
| 281 | + | device_profile: None, | |
| 282 | + | naming_rules: None, | |
| 283 | + | max_file_size_bytes: None, | |
| 284 | + | name_overrides: None, | |
| 285 | + | }; | |
| 286 | + | ||
| 287 | + | let summary = run_export( | |
| 288 | + | &items, | |
| 289 | + | &config, | |
| 290 | + | &store, | |
| 291 | + | &AtomicBool::new(false), | |
| 292 | + | |_, _, _| true, | |
| 293 | + | ) | |
| 294 | + | .unwrap(); | |
| 295 | + | assert!(summary.errors.is_empty()); | |
| 296 | + | ||
| 297 | + | let sidecar = dest_dir.join("Drums").join("kick.wav.audiofiles.json"); | |
| 298 | + | assert!(sidecar.exists(), "sidecar file should exist"); | |
| 299 | + | ||
| 300 | + | let content: serde_json::Value = | |
| 301 | + | serde_json::from_str(&fs::read_to_string(&sidecar).unwrap()).unwrap(); | |
| 302 | + | assert_eq!(content["name"], "kick.wav"); | |
| 303 | + | assert!(content["hash"].is_string()); | |
| 304 | + | } | |
| 305 | + | ||
| 306 | + | #[test] | |
| 307 | + | fn hardlink_or_copy_works() { | |
| 308 | + | let dir = tempfile::tempdir().unwrap(); | |
| 309 | + | let db = Database::open_in_memory().unwrap(); | |
| 310 | + | let store = SampleStore::new(dir.path().join("store")).unwrap(); | |
| 311 | + | let vfs_id = setup_vfs_with_samples(&db, &store, dir.path()); | |
| 312 | + | ||
| 313 | + | let items = collect_export_items(&db, vfs_id, None).unwrap(); | |
| 314 | + | let dest_dir = dir.path().join("export_hl"); | |
| 315 | + | ||
| 316 | + | let config = ExportConfig { | |
| 317 | + | format: ExportFormat::Original, | |
| 318 | + | sample_rate: None, | |
| 319 | + | bit_depth: None, | |
| 320 | + | channels: ExportChannels::Original, | |
| 321 | + | naming_pattern: None, | |
| 322 | + | flatten: false, | |
| 323 | + | metadata_sidecar: false, | |
| 324 | + | destination: dest_dir.clone(), | |
| 325 | + | device_profile: None, | |
| 326 | + | naming_rules: None, | |
| 327 | + | max_file_size_bytes: None, | |
| 328 | + | name_overrides: None, | |
| 329 | + | }; | |
| 330 | + | ||
| 331 | + | let summary = run_export( | |
| 332 | + | &items, | |
| 333 | + | &config, | |
| 334 | + | &store, | |
| 335 | + | &AtomicBool::new(false), | |
| 336 | + | |_, _, _| true, | |
| 337 | + | ) | |
| 338 | + | .unwrap(); | |
| 339 | + | assert!(summary.errors.is_empty()); | |
| 340 | + | ||
| 341 | + | let exported = dest_dir.join("Drums").join("kick.wav"); | |
| 342 | + | assert!(exported.exists()); | |
| 343 | + | ||
| 344 | + | let source_path = store.sample_path(&items[0].hash, &items[0].ext).unwrap(); | |
| 345 | + | let source_bytes = fs::read(&source_path).unwrap(); | |
| 346 | + | let export_bytes = fs::read(&exported).unwrap(); | |
| 347 | + | assert_eq!(source_bytes, export_bytes); | |
| 348 | + | } | |
| 349 | + | ||
| 350 | + | #[test] | |
| 351 | + | fn enrich_with_tags_populates() { | |
| 352 | + | let dir = tempfile::tempdir().unwrap(); | |
| 353 | + | let db = Database::open_in_memory().unwrap(); | |
| 354 | + | let store = SampleStore::new(dir.path().join("store")).unwrap(); | |
| 355 | + | let vfs_id = setup_vfs_with_samples(&db, &store, dir.path()); | |
| 356 | + | ||
| 357 | + | let mut items = collect_export_items(&db, vfs_id, None).unwrap(); | |
| 358 | + | assert!(items[0].tags.is_empty()); | |
| 359 | + | ||
| 360 | + | crate::tags::add_tag(&db, &items[0].hash, "kick").unwrap(); | |
| 361 | + | crate::tags::add_tag(&db, &items[0].hash, "drums").unwrap(); | |
| 362 | + | ||
| 363 | + | enrich_with_tags(&db, &mut items); | |
| 364 | + | assert_eq!(items[0].tags.len(), 2); | |
| 365 | + | assert!(items[0].tags.contains(&"drums".to_string())); | |
| 366 | + | assert!(items[0].tags.contains(&"kick".to_string())); | |
| 367 | + | } | |
| 368 | + | ||
| 369 | + | #[test] | |
| 370 | + | fn export_with_naming_rules_sanitizes() { | |
| 371 | + | use crate::export::profile::{NamingCase, NamingRules}; | |
| 372 | + | ||
| 373 | + | let dir = tempfile::tempdir().unwrap(); | |
| 374 | + | let db = Database::open_in_memory().unwrap(); | |
| 375 | + | let store = SampleStore::new(dir.path().join("store")).unwrap(); | |
| 376 | + | let vfs_id = setup_vfs_with_samples(&db, &store, dir.path()); | |
| 377 | + | ||
| 378 | + | let items = collect_export_items(&db, vfs_id, None).unwrap(); | |
| 379 | + | let dest_dir = dir.path().join("export_sanitize"); | |
| 380 | + | ||
| 381 | + | let config = ExportConfig { | |
| 382 | + | format: ExportFormat::Original, | |
| 383 | + | sample_rate: None, | |
| 384 | + | bit_depth: None, | |
| 385 | + | channels: ExportChannels::Original, | |
| 386 | + | naming_pattern: None, | |
| 387 | + | flatten: true, | |
| 388 | + | metadata_sidecar: false, | |
| 389 | + | destination: dest_dir.clone(), | |
| 390 | + | device_profile: None, | |
| 391 | + | naming_rules: Some(NamingRules { | |
| 392 | + | case: NamingCase::Upper, | |
| 393 | + | separator: '_', | |
| 394 | + | max_length: 8, | |
| 395 | + | strip_special: true, | |
| 396 | + | }), | |
| 397 | + | max_file_size_bytes: None, | |
| 398 | + | name_overrides: None, | |
| 399 | + | }; | |
| 400 | + | ||
| 401 | + | let summary = run_export( | |
| 402 | + | &items, | |
| 403 | + | &config, | |
| 404 | + | &store, | |
| 405 | + | &AtomicBool::new(false), | |
| 406 | + | |_, _, _| true, | |
| 407 | + | ) | |
| 408 | + | .unwrap(); | |
| 409 | + | assert!(summary.errors.is_empty()); | |
| 410 | + | ||
| 411 | + | // "kick" uppercased -> "KICK", truncated to 8 (already short enough) | |
| 412 | + | let exported = dest_dir.join("KICK.wav"); | |
| 413 | + | assert!( | |
| 414 | + | exported.exists(), | |
| 415 | + | "expected KICK.wav, got: {:?}", | |
| 416 | + | fs::read_dir(&dest_dir) | |
| 417 | + | .unwrap() | |
| 418 | + | .map(|e| e.unwrap().file_name()) | |
| 419 | + | .collect::<Vec<_>>() | |
| 420 | + | ); | |
| 421 | + | } | |
| 422 | + | ||
| 423 | + | #[test] | |
| 424 | + | fn export_with_max_file_size_rejects_oversized() { | |
| 425 | + | let dir = tempfile::tempdir().unwrap(); | |
| 426 | + | let db = Database::open_in_memory().unwrap(); | |
| 427 | + | let store = SampleStore::new(dir.path().join("store")).unwrap(); | |
| 428 | + | let vfs_id = setup_vfs_with_samples(&db, &store, dir.path()); | |
| 429 | + | ||
| 430 | + | let items = collect_export_items(&db, vfs_id, None).unwrap(); | |
| 431 | + | let dest_dir = dir.path().join("export_size"); | |
| 432 | + | ||
| 433 | + | let config = ExportConfig { | |
| 434 | + | format: ExportFormat::Original, | |
| 435 | + | sample_rate: None, | |
| 436 | + | bit_depth: None, | |
| 437 | + | channels: ExportChannels::Original, | |
| 438 | + | naming_pattern: None, | |
| 439 | + | flatten: true, | |
| 440 | + | metadata_sidecar: false, | |
| 441 | + | destination: dest_dir.clone(), | |
| 442 | + | device_profile: None, | |
| 443 | + | naming_rules: None, | |
| 444 | + | max_file_size_bytes: Some(1), // 1 byte: everything will exceed | |
| 445 | + | name_overrides: None, | |
| 446 | + | }; | |
| 447 | + | ||
| 448 | + | let summary = run_export( | |
| 449 | + | &items, | |
| 450 | + | &config, | |
| 451 | + | &store, | |
| 452 | + | &AtomicBool::new(false), | |
| 453 | + | |_, _, _| true, | |
| 454 | + | ) | |
| 455 | + | .unwrap(); | |
| 456 | + | assert_eq!(summary.errors.len(), 1); | |
| 457 | + | assert!( | |
| 458 | + | summary.errors[0] | |
| 459 | + | .1 | |
| 460 | + | .contains("exceeds device file size limit") | |
| 461 | + | ); | |
| 462 | + | ||
| 463 | + | // Verify the file was cleaned up | |
| 464 | + | let exported = dest_dir.join("kick.wav"); | |
| 465 | + | assert!( | |
| 466 | + | !exported.exists(), | |
| 467 | + | "oversized file should have been removed" | |
| 468 | + | ); | |
| 469 | + | } | |
| 470 | + | ||
| 471 | + | #[test] | |
| 472 | + | fn export_naming_rules_dedup() { | |
| 473 | + | use crate::export::profile::{NamingCase, NamingRules}; | |
| 474 | + | ||
| 475 | + | let dir = tempfile::tempdir().unwrap(); | |
| 476 | + | let db = Database::open_in_memory().unwrap(); | |
| 477 | + | let store = SampleStore::new(dir.path().join("store")).unwrap(); | |
| 478 | + | let vfs_id = setup_vfs_with_samples(&db, &store, dir.path()); | |
| 479 | + | ||
| 480 | + | // Add a second sample that will collide after sanitization | |
| 481 | + | let wav_path = dir.path().join("KICK.wav"); | |
| 482 | + | write_test_wav(&wav_path, 1, 44100, &[0.1, -0.1]); | |
| 483 | + | let hash2 = store.import(&wav_path, &db).unwrap(); | |
| 484 | + | vfs::create_sample_link( | |
| 485 | + | &db, | |
| 486 | + | vfs_id, | |
| 487 | + | None, | |
| 488 | + | "KICK.wav", | |
| 489 | + | &crate::SampleHash::from_trusted(hash2.clone()), | |
| 490 | + | ) | |
| 491 | + | .unwrap(); | |
| 492 | + | ||
| 493 | + | let items = collect_export_items(&db, vfs_id, None).unwrap(); | |
| 494 | + | assert_eq!(items.len(), 2); | |
| 495 | + | ||
| 496 | + | let dest_dir = dir.path().join("export_dedup"); | |
| 497 | + | ||
| 498 | + | let config = ExportConfig { | |
| 499 | + | format: ExportFormat::Original, | |
| 500 | + | sample_rate: None, |
Lines truncated