Skip to main content

max / audiofiles

Move seven core and sync test modules to sibling files store, export, similarity, vfs, rules, the sync service state and the bench layer_eval each carried a trailing inline test module. Each becomes a tests.rs sibling behind a `#[cfg(test)] mod tests;` declaration. The lopsided ones are the point: audiofiles-sync/src/service/state.rs was 2071 lines holding 262 of production, and audiofiles-core/src/export/mod.rs 1344 holding 241. Both now read as what they are. No production line changes. The production files keep their #[cfg(test)] attribute, so they stay in witchbroom's mutation scope.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session
https://claude.ai/code/session_01EEmeiSJnmyL98QzA5Dwsvz
Author: Max Johnson <me@maxj.phd> · 2026-09-03 22:51 UTC
Signed with PGP, not checked
Commit: c3735978a991aec441855dcc0790b4d039b36fc7
Parent: 95a11b1
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