Skip to main content

max / audiofiles

browser/ui: extract pure helpers from draw code and unit-test them Cover the testable logic buried in the egui ui/ crate (~9K LOC that was untested). Where logic sat inside draw closures, pull it into free functions first, then test; existing free helpers gain coverage directly. Widgets still drive display unchanged. Extractions: coerce_op / export_has_content (classifier), summarize hoisted out of the multi-select draw fn (detail), encryption_submit_state (sync_panel), range_bounds dedups a sentinel rule repeated 6x (filter_panel), no_match_hint (file_list), bytes_per_sec core split off the config wrapper (export_screens). +61 tests (263 -> 324), clippy clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-17 20:35 UTC
Commit: 9f6d75ec6bfb17aea65d1c09fb80eadc9c82ec35
Parent: e9d0f4f
14 files changed, +701 insertions, -59 deletions
@@ -135,6 +135,14 @@ fn op_needs_value(op: RuleOp) -> bool {
135 135 !matches!(op, RuleOp::Exists | RuleOp::NotExists | RuleOp::IsTrue | RuleOp::IsFalse)
136 136 }
137 137
138 + /// Keep a condition's operator valid for its field's kind: if `op` isn't one the
139 + /// field offers, fall back to the field's first offered op. `ops_for` is never empty,
140 + /// so `ops[0]` always exists.
141 + fn coerce_op(field: RuleField, op: RuleOp) -> RuleOp {
142 + let ops = ops_for(field);
143 + if ops.contains(&op) { op } else { ops[0] }
144 + }
145 +
138 146 /// Draw all tag-classifier sections in Settings.
139 147 pub fn draw_classifier_section(ui: &mut egui::Ui, state: &mut BrowserState) {
140 148 // A background job (train/auto-tag/cluster/export) is running: show progress and let
@@ -325,10 +333,8 @@ fn draw_editor(ui: &mut egui::Ui, state: &mut BrowserState) {
325 333 }
326 334 });
327 335 // Keep the operator valid for the chosen field's kind.
336 + cond.op = coerce_op(cond.field, cond.op);
328 337 let ops = ops_for(cond.field);
329 - if !ops.contains(&cond.op) {
330 - cond.op = ops[0];
331 - }
332 338 egui::ComboBox::from_id_salt(("rule_op", i))
333 339 .selected_text(op_label(cond.op))
334 340 .width(110.0)
@@ -800,9 +806,14 @@ fn draw_sharing_header(ui: &mut egui::Ui, state: &mut BrowserState) {
800 806 let exemplars_avail = state.classifier.head_readiness.map(|(n, _)| n > 0).unwrap_or(false);
801 807 let rules_avail = !state.classifier.rules.is_empty();
802 808 let policy_avail = !state.classifier.policies.is_empty();
803 - let has_content = (state.classifier.export_include_exemplars && exemplars_avail)
804 - || (state.classifier.export_include_rules && rules_avail)
805 - || (state.classifier.export_include_policy && policy_avail);
809 + let has_content = export_has_content(
810 + state.classifier.export_include_exemplars,
811 + exemplars_avail,
812 + state.classifier.export_include_rules,
813 + rules_avail,
814 + state.classifier.export_include_policy,
815 + policy_avail,
816 + );
806 817 ui.add_enabled_ui(!busy && has_content, |ui| {
807 818 if widgets::primary_button(ui, "Export to file...")
808 819 .on_hover_text("Save a .afcl file you can share \u{2014} no audio is included")
@@ -956,6 +967,22 @@ fn draw_sharing_header(ui: &mut egui::Ui, state: &mut BrowserState) {
956 967 });
957 968 }
958 969
970 + /// Whether the current export selection covers anything actually present. Each toggle
971 + /// only counts when its data exists, so "export everything" with nothing tagged still
972 + /// reads as empty and the button stays disabled.
973 + fn export_has_content(
974 + include_exemplars: bool,
975 + exemplars_avail: bool,
976 + include_rules: bool,
977 + rules_avail: bool,
978 + include_policy: bool,
979 + policy_avail: bool,
980 + ) -> bool {
981 + (include_exemplars && exemplars_avail)
982 + || (include_rules && rules_avail)
983 + || (include_policy && policy_avail)
984 + }
985 +
959 986 /// Reduce a user-entered name to a safe-ish default filename stem.
960 987 fn sanitize_filename(name: &str) -> String {
961 988 let cleaned: String = name
@@ -965,3 +992,135 @@ fn sanitize_filename(name: &str) -> String {
965 992 .collect();
966 993 if cleaned.is_empty() { "classifier".to_string() } else { cleaned }
967 994 }
995 +
996 + #[cfg(test)]
997 + mod tests {
998 + use super::*;
999 +
1000 + // ---------------------------------------------------------------
1001 + // field kind / operator sets
1002 + // ---------------------------------------------------------------
1003 +
1004 + #[test]
1005 + fn field_kind_classifies_text_number_bool() {
1006 + assert!(matches!(field_kind(RuleField::Name), Kind::Text));
1007 + assert!(matches!(field_kind(RuleField::MusicalKey), Kind::Text));
1008 + assert!(matches!(field_kind(RuleField::IsLoop), Kind::Bool));
1009 + assert!(matches!(field_kind(RuleField::Bpm), Kind::Number));
1010 + assert!(matches!(field_kind(RuleField::Lufs), Kind::Number));
1011 + }
1012 +
1013 + #[test]
1014 + fn ops_for_matches_the_field_kind() {
1015 + assert_eq!(ops_for(RuleField::Name), STRING_OPS);
1016 + assert_eq!(ops_for(RuleField::Bpm), NUM_OPS);
1017 + assert_eq!(ops_for(RuleField::IsLoop), BOOL_OPS);
1018 + }
1019 +
1020 + #[test]
1021 + fn every_offered_field_has_a_nonempty_op_set() {
1022 + // draw_editor relies on ops[0] as a fallback, so no field may offer zero ops.
1023 + for f in FIELDS {
1024 + assert!(!ops_for(*f).is_empty(), "{f:?} offered no operators");
1025 + }
1026 + }
1027 +
1028 + // ---------------------------------------------------------------
1029 + // coerce_op
1030 + // ---------------------------------------------------------------
1031 +
1032 + #[test]
1033 + fn coerce_op_keeps_a_valid_operator() {
1034 + // Equals is valid for a text field, so it survives unchanged.
1035 + assert_eq!(coerce_op(RuleField::Name, RuleOp::Equals), RuleOp::Equals);
1036 + }
1037 +
1038 + #[test]
1039 + fn coerce_op_replaces_an_invalid_operator_with_the_first_offered() {
1040 + // Lt is number-only; on a text field it falls back to STRING_OPS[0].
1041 + assert_eq!(coerce_op(RuleField::Name, RuleOp::Lt), STRING_OPS[0]);
1042 + // Contains is text-only; on a number field it falls back to NUM_OPS[0].
1043 + assert_eq!(coerce_op(RuleField::Bpm, RuleOp::Contains), NUM_OPS[0]);
1044 + // ...and on a bool field to BOOL_OPS[0].
1045 + assert_eq!(coerce_op(RuleField::IsLoop, RuleOp::Contains), BOOL_OPS[0]);
1046 + }
1047 +
1048 + #[test]
1049 + fn coerce_op_output_is_always_valid_for_the_field() {
1050 + // For every field, coercing any operator yields one the field actually offers.
1051 + for f in FIELDS {
1052 + for op in ops_for(RuleField::Bpm).iter().chain(STRING_OPS).chain(BOOL_OPS) {
1053 + let coerced = coerce_op(*f, *op);
1054 + assert!(
1055 + ops_for(*f).contains(&coerced),
1056 + "coerce_op({f:?}, {op:?}) = {coerced:?} not in the field's op set",
1057 + );
1058 + }
1059 + }
1060 + }
1061 +
1062 + // ---------------------------------------------------------------
1063 + // op_needs_value
1064 + // ---------------------------------------------------------------
1065 +
1066 + #[test]
1067 + fn presence_and_bool_operators_need_no_value() {
1068 + for op in [RuleOp::Exists, RuleOp::NotExists, RuleOp::IsTrue, RuleOp::IsFalse] {
1069 + assert!(!op_needs_value(op), "{op:?} should not require a value");
1070 + }
1071 + }
1072 +
1073 + #[test]
1074 + fn comparison_operators_need_a_value() {
1075 + for op in [RuleOp::Contains, RuleOp::Equals, RuleOp::Lt, RuleOp::StartsWith] {
1076 + assert!(op_needs_value(op), "{op:?} should require a value");
1077 + }
1078 + }
1079 +
1080 + // ---------------------------------------------------------------
1081 + // export_has_content
1082 + // ---------------------------------------------------------------
1083 +
1084 + #[test]
1085 + fn export_is_empty_when_nothing_is_selected() {
1086 + assert!(!export_has_content(false, true, false, true, false, true));
1087 + }
1088 +
1089 + #[test]
1090 + fn export_is_empty_when_selected_data_is_absent() {
1091 + // Everything checked, but none of the underlying data exists.
1092 + assert!(!export_has_content(true, false, true, false, true, false));
1093 + }
1094 +
1095 + #[test]
1096 + fn export_has_content_when_a_selected_category_has_data() {
1097 + assert!(export_has_content(true, true, false, false, false, false));
1098 + assert!(export_has_content(false, false, true, true, false, false));
1099 + assert!(export_has_content(false, false, false, false, true, true));
1100 + }
1101 +
1102 + // ---------------------------------------------------------------
1103 + // sanitize_filename
1104 + // ---------------------------------------------------------------
1105 +
1106 + #[test]
1107 + fn sanitize_replaces_unsafe_chars_with_underscore() {
1108 + assert_eq!(sanitize_filename("My Classifier!"), "My_Classifier_");
1109 + }
1110 +
1111 + #[test]
1112 + fn sanitize_preserves_alphanumeric_dash_underscore() {
1113 + assert_eq!(sanitize_filename("kick-drums_v2"), "kick-drums_v2");
1114 + }
1115 +
1116 + #[test]
1117 + fn sanitize_trims_surrounding_whitespace() {
1118 + assert_eq!(sanitize_filename(" spacey "), "spacey");
1119 + }
1120 +
1121 + #[test]
1122 + fn sanitize_falls_back_when_empty_or_all_unsafe() {
1123 + assert_eq!(sanitize_filename(""), "classifier");
1124 + assert_eq!(sanitize_filename(" "), "classifier");
1125 + }
1126 + }
@@ -481,6 +481,27 @@ pub fn draw_detail(ui: &mut egui::Ui, state: &mut BrowserState) {
481 481 }
482 482 }
483 483
484 + /// Reduce a field across a multi-selection to one displayable value. Returns
485 + /// `None` when the selection is empty or the first item lacks the field (nothing
486 + /// to show), `Some(Err(()))` when the values differ or any item lacks the field
487 + /// (renders as "varies"), and `Some(Ok(v))` when every item shares value `v`.
488 + fn summarize<T, F, V>(items: &[T], extract: F) -> Option<Result<V, ()>>
489 + where
490 + F: Fn(&T) -> Option<V>,
491 + V: PartialEq,
492 + {
493 + let mut iter = items.iter().map(&extract);
494 + let first = iter.next()??;
495 + for v in iter {
496 + match v {
497 + Some(v) if v == first => continue,
498 + Some(_) => return Some(Err(())),
499 + None => return Some(Err(())),
500 + }
501 + }
502 + Some(Ok(first))
503 + }
504 +
484 505 /// Draw a multi-selection summary: common metadata, union of tags, bulk-edit affordance.
485 506 fn draw_multi_summary(ui: &mut egui::Ui, state: &mut BrowserState) {
486 507 let nodes = state.selected_nodes();
@@ -512,23 +533,6 @@ fn draw_multi_summary(ui: &mut egui::Ui, state: &mut BrowserState) {
512 533 }
513 534
514 535 // Common metadata: show value if uniform across the selection, otherwise "varies".
515 - fn summarize<T, F, V>(items: &[T], extract: F) -> Option<Result<V, ()>>
516 - where
517 - F: Fn(&T) -> Option<V>,
518 - V: PartialEq,
519 - {
520 - let mut iter = items.iter().map(&extract);
521 - let first = iter.next()??;
522 - for v in iter {
523 - match v {
524 - Some(v) if v == first => continue,
525 - Some(_) => return Some(Err(())),
526 - None => return Some(Err(())),
527 - }
528 - }
529 - Some(Ok(first))
530 - }
531 -
532 536 ui.group(|ui| {
533 537 egui::Grid::new("detail_multi_metadata")
534 538 .num_columns(2)
@@ -710,3 +714,67 @@ fn classification_tag_suggestions(classification: &str, existing_tags: &[String]
710 714 .copied()
711 715 .collect()
712 716 }
717 +
718 + #[cfg(test)]
719 + mod tests {
720 + use super::*;
721 +
722 + // ---------------------------------------------------------------
723 + // summarize
724 + // ---------------------------------------------------------------
725 +
726 + fn id(v: &Option<i32>) -> Option<i32> {
727 + *v
728 + }
729 +
730 + #[test]
731 + fn summarize_none_when_empty_or_first_missing() {
732 + assert_eq!(summarize::<Option<i32>, _, i32>(&[], id), None);
733 + assert_eq!(summarize(&[None], id), None);
734 + }
735 +
736 + #[test]
737 + fn summarize_ok_when_every_value_matches() {
738 + assert_eq!(summarize(&[Some(120), Some(120), Some(120)], id), Some(Ok(120)));
739 + }
740 +
741 + #[test]
742 + fn summarize_err_when_values_differ_or_any_is_missing() {
743 + assert_eq!(summarize(&[Some(120), Some(90)], id), Some(Err(())));
744 + assert_eq!(summarize(&[Some(120), None], id), Some(Err(())));
745 + }
746 +
747 + // ---------------------------------------------------------------
748 + // classification_tag_suggestions
749 + // ---------------------------------------------------------------
750 +
751 + #[test]
752 + fn suggestions_are_empty_for_unknown_class() {
753 + assert!(classification_tag_suggestions("unknown", &[]).is_empty());
754 + }
755 +
756 + #[test]
757 + fn suggestions_come_from_the_class_table() {
758 + assert_eq!(
759 + classification_tag_suggestions("kick", &[]),
760 + vec!["drums.kick", "percussion", "one-shot"],
761 + );
762 + }
763 +
764 + #[test]
765 + fn suggestions_exclude_already_applied_tags() {
766 + let existing = vec!["percussion".to_string()];
767 + assert_eq!(
768 + classification_tag_suggestions("kick", &existing),
769 + vec!["drums.kick", "one-shot"],
770 + );
771 + }
772 +
773 + #[test]
774 + fn suggestions_exclude_tags_whose_prefix_is_already_applied() {
775 + // A candidate "synth" is suppressed when the sample already carries a
776 + // more specific "synth.bass".
777 + let existing = vec!["synth.bass".to_string()];
778 + assert_eq!(classification_tag_suggestions("synth", &existing), vec!["melodic"]);
779 + }
780 + }
@@ -64,9 +64,16 @@ fn available_disk_space(_path: &Path) -> Option<u64> {
64 64 /// worst-case heuristic. Defaults (`None` config values) bias high so we err
65 65 /// on the side of warning when the user picks "Original".
66 66 fn bytes_per_sec_for_config(config: &ExportConfig) -> u64 {
67 - let rate = config.sample_rate.unwrap_or(48_000) as u64;
68 - let depth_bytes = (config.bit_depth.unwrap_or(24) as u64).div_ceil(8);
69 - let channels = match config.channels {
67 + bytes_per_sec(config.sample_rate, config.bit_depth, &config.channels)
68 + }
69 +
70 + /// Bytes-per-second of PCM audio for the given rate/depth/channels. `None` rate
71 + /// or depth means "Original", which biases high (48 kHz / 24-bit) so the callers'
72 + /// size warnings err toward warning. `Original` channels also biases to stereo.
73 + fn bytes_per_sec(rate: Option<u32>, depth: Option<u16>, channels: &ExportChannels) -> u64 {
74 + let rate = rate.unwrap_or(48_000) as u64;
75 + let depth_bytes = (depth.unwrap_or(24) as u64).div_ceil(8);
76 + let channels = match channels {
70 77 ExportChannels::Mono => 1u64,
71 78 ExportChannels::Stereo => 2u64,
72 79 ExportChannels::Original => 2u64,
@@ -660,3 +667,29 @@ pub fn draw_export_complete(ui: &mut egui::Ui, state: &mut BrowserState) {
660 667 });
661 668 });
662 669 }
670 +
671 + #[cfg(test)]
672 + mod tests {
673 + use super::*;
674 +
675 + #[test]
676 + fn bytes_per_sec_defaults_bias_high() {
677 + // None rate/depth => 48 kHz / 24-bit; Original channels => stereo.
678 + // 48000 * 3 bytes * 2 ch = 288000, the worst-case rate used by callers.
679 + assert_eq!(bytes_per_sec(None, None, &ExportChannels::Original), 288_000);
680 + }
681 +
682 + #[test]
683 + fn bytes_per_sec_scales_with_rate_depth_and_channels() {
684 + assert_eq!(bytes_per_sec(Some(44_100), Some(16), &ExportChannels::Mono), 88_200);
685 + assert_eq!(bytes_per_sec(Some(96_000), Some(24), &ExportChannels::Stereo), 576_000);
686 + }
687 +
688 + #[test]
689 + fn bytes_per_sec_treats_original_channels_as_stereo() {
690 + assert_eq!(
691 + bytes_per_sec(Some(48_000), Some(16), &ExportChannels::Original),
692 + bytes_per_sec(Some(48_000), Some(16), &ExportChannels::Stereo),
693 + );
694 + }
695 + }
@@ -126,13 +126,7 @@ pub fn draw_file_list(
126 126 // Empty state: filters active but no results in this folder
127 127 if state.nav.contents.is_empty() && (state.search.search_filter.is_active() || !state.search.search_query.is_empty()) {
128 128 let filter_count = state.search.search_filter.active_count();
129 - let hint = if filter_count > 0 && !state.search.search_query.is_empty() {
130 - format!("{} filter{} + search active", filter_count, if filter_count == 1 { "" } else { "s" })
131 - } else if filter_count > 0 {
132 - format!("{} filter{} active \u{2014} try broadening your criteria or searching All vaults", filter_count, if filter_count == 1 { "" } else { "s" })
133 - } else {
134 - "No samples match your search in this folder.".to_string()
135 - };
129 + let hint = no_match_hint(filter_count, !state.search.search_query.is_empty());
136 130 // C-3: label names every part of the action. The CTA clears both
137 131 // filters and the search query — matching the toolbar's already-fixed
138 132 // "Clear search and filters" rename from Phase 4 M-4.
@@ -404,6 +398,22 @@ pub fn draw_file_list(
404 398 }
405 399 }
406 400
401 + /// Build the empty-results hint for a folder that has active filters and/or a
402 + /// search but no matching rows. `has_search` is whether the search query is
403 + /// non-empty; `filter_count` is how many filter facets are active.
404 + fn no_match_hint(filter_count: usize, has_search: bool) -> String {
405 + let plural = if filter_count == 1 { "" } else { "s" };
406 + if filter_count > 0 && has_search {
407 + format!("{filter_count} filter{plural} + search active")
408 + } else if filter_count > 0 {
409 + format!(
410 + "{filter_count} filter{plural} active \u{2014} try broadening your criteria or searching All vaults"
411 + )
412 + } else {
413 + "No samples match your search in this folder.".to_string()
414 + }
415 + }
416 +
407 417 /// Handle a click on a file list row, respecting modifier keys for multi-select.
408 418 fn handle_click(state: &mut BrowserState, row_idx: usize, ui: &egui::Ui) {
409 419 let modifiers = ui.input(|i| i.modifiers);
@@ -681,3 +691,25 @@ fn draw_sort_header(
681 691 super::widgets::selectable_row_secondary(ui, is_active, text).clicked()
682 692 }
683 693
694 + #[cfg(test)]
695 + mod tests {
696 + use super::*;
697 +
698 + #[test]
699 + fn no_match_hint_with_filters_and_search() {
700 + assert_eq!(no_match_hint(2, true), "2 filters + search active");
701 + assert_eq!(no_match_hint(1, true), "1 filter + search active");
702 + }
703 +
704 + #[test]
705 + fn no_match_hint_with_filters_only() {
706 + assert!(no_match_hint(3, false).starts_with("3 filters active"));
707 + assert!(no_match_hint(1, false).starts_with("1 filter active"));
708 + }
709 +
710 + #[test]
711 + fn no_match_hint_with_search_only() {
712 + assert_eq!(no_match_hint(0, true), "No samples match your search in this folder.");
713 + }
714 + }
715 +
@@ -491,3 +491,25 @@ pub fn start_os_drag(state: &mut BrowserState) {
491 491 }
492 492 }
493 493 }
494 +
495 + #[cfg(test)]
496 + mod tests {
497 + use super::*;
498 +
499 + #[test]
500 + fn truncate_name_keeps_short_names() {
501 + assert_eq!(truncate_name("short", 10), "short");
502 + assert_eq!(truncate_name("abcdefghij", 10), "abcdefghij"); // exactly max
503 + }
504 +
505 + #[test]
506 + fn truncate_name_trims_long_names_with_ellipsis() {
507 + assert_eq!(truncate_name("abcdefghijk", 10), "abcdefg...");
508 + }
509 +
510 + #[test]
511 + fn truncate_name_counts_unicode_scalars() {
512 + // 5 multibyte chars, max 4 -> take(1) + "..."
513 + assert_eq!(truncate_name("\u{3b1}\u{3b1}\u{3b1}\u{3b1}\u{3b1}", 4), "\u{3b1}...");
514 + }
515 + }
@@ -15,6 +15,16 @@ fn requery_now(resp: &egui::Response) -> bool {
15 15 resp.drag_stopped() || resp.lost_focus()
16 16 }
17 17
18 + /// Map a numeric range filter's raw `(min, max)` onto the stored `(Option, Option)`
19 + /// bounds. A value sitting on its sentinel edge means "no bound": `min == lo` (or
20 + /// below) stores `None`, as does `max == hi` (or above). The caller snaps the
21 + /// sibling for `min <= max` before calling, so this only handles the edge mapping.
22 + fn range_bounds(min: f64, max: f64, lo: f64, hi: f64) -> (Option<f64>, Option<f64>) {
23 + let lower = if min > lo { Some(min) } else { None };
24 + let upper = if max < hi { Some(max) } else { None };
25 + (lower, upper)
26 + }
27 +
18 28 /// Render the per-section "[clear]" mini-button used by every active filter
19 29 /// section. Returns true on click. The wrapping `if active` lives at the call
20 30 /// site so each section's clear semantics stay local.
@@ -58,16 +68,18 @@ pub fn draw_filter_panel(ui: &mut egui::Ui, state: &mut BrowserState) {
58 68 let r = ui.add(egui::DragValue::new(&mut min).speed(1.0).range(0.0..=300.0));
59 69 if r.changed() {
60 70 if min > max { max = min; }
61 - state.search.search_filter.bpm_min = if min > 0.0 { Some(min) } else { None };
62 - state.search.search_filter.bpm_max = if max < 300.0 { Some(max) } else { None };
71 + let (lo, hi) = range_bounds(min, max, 0.0, 300.0);
72 + state.search.search_filter.bpm_min = lo;
73 + state.search.search_filter.bpm_max = hi;
63 74 }
64 75 if requery_now(&r) { changed = true; }
65 76 ui.label("Max");
66 77 let r = ui.add(egui::DragValue::new(&mut max).speed(1.0).range(0.0..=300.0));
67 78 if r.changed() {
68 79 if max < min { min = max; }
69 - state.search.search_filter.bpm_max = if max < 300.0 { Some(max) } else { None };
70 - state.search.search_filter.bpm_min = if min > 0.0 { Some(min) } else { None };
80 + let (lo, hi) = range_bounds(min, max, 0.0, 300.0);
81 + state.search.search_filter.bpm_min = lo;
82 + state.search.search_filter.bpm_max = hi;
71 83 }
72 84 if requery_now(&r) { changed = true; }
73 85 });
@@ -87,16 +99,18 @@ pub fn draw_filter_panel(ui: &mut egui::Ui, state: &mut BrowserState) {
87 99 let r = ui.add(egui::DragValue::new(&mut min).speed(0.1).range(0.0..=600.0));
88 100 if r.changed() {
89 101 if min > max { max = min; }
90 - state.search.search_filter.duration_min = if min > 0.0 { Some(min) } else { None };
91 - state.search.search_filter.duration_max = if max < 600.0 { Some(max) } else { None };
102 + let (lo, hi) = range_bounds(min, max, 0.0, 600.0);
103 + state.search.search_filter.duration_min = lo;
104 + state.search.search_filter.duration_max = hi;
92 105 }
93 106 if requery_now(&r) { changed = true; }
94 107 ui.label("Max");
95 108 let r = ui.add(egui::DragValue::new(&mut max).speed(0.1).range(0.0..=600.0));
96 109 if r.changed() {
97 110 if max < min { min = max; }
98 - state.search.search_filter.duration_max = if max < 600.0 { Some(max) } else { None };
99 - state.search.search_filter.duration_min = if min > 0.0 { Some(min) } else { None };
111 + let (lo, hi) = range_bounds(min, max, 0.0, 600.0);
112 + state.search.search_filter.duration_min = lo;
113 + state.search.search_filter.duration_max = hi;
100 114 }
101 115 if requery_now(&r) { changed = true; }
102 116 });
@@ -116,16 +130,18 @@ pub fn draw_filter_panel(ui: &mut egui::Ui, state: &mut BrowserState) {
116 130 let r = ui.add(egui::DragValue::new(&mut min).speed(0.5).range(-96.0..=0.0).suffix(" dB"));
117 131 if r.changed() {
118 132 if min > max { max = min; }
119 - state.search.search_filter.peak_db_min = if min > -96.0 { Some(min) } else { None };
120 - state.search.search_filter.peak_db_max = if max < 0.0 { Some(max) } else { None };
133 + let (lo, hi) = range_bounds(min, max, -96.0, 0.0);
134 + state.search.search_filter.peak_db_min = lo;
135 + state.search.search_filter.peak_db_max = hi;
121 136 }
122 137 if requery_now(&r) { changed = true; }
123 138 ui.label("Max");
124 139 let r = ui.add(egui::DragValue::new(&mut max).speed(0.5).range(-96.0..=0.0).suffix(" dB"));
125 140 if r.changed() {
126 141 if max < min { min = max; }
127 - state.search.search_filter.peak_db_max = if max < 0.0 { Some(max) } else { None };
128 - state.search.search_filter.peak_db_min = if min > -96.0 { Some(min) } else { None };
142 + let (lo, hi) = range_bounds(min, max, -96.0, 0.0);
143 + state.search.search_filter.peak_db_min = lo;
144 + state.search.search_filter.peak_db_max = hi;
129 145 }
130 146 if requery_now(&r) { changed = true; }
131 147 });
@@ -322,3 +338,32 @@ pub fn draw_filter_panel(ui: &mut egui::Ui, state: &mut BrowserState) {
322 338 state.apply_search();
323 339 }
324 340 }
341 +
342 + #[cfg(test)]
343 + mod tests {
344 + use super::*;
345 +
346 + #[test]
347 + fn range_bounds_treats_sentinel_edges_as_unbounded() {
348 + // BPM: 0 = no lower bound, 300 = no upper bound.
349 + assert_eq!(range_bounds(0.0, 300.0, 0.0, 300.0), (None, None));
350 + }
351 +
352 + #[test]
353 + fn range_bounds_keeps_interior_values() {
354 + assert_eq!(range_bounds(90.0, 140.0, 0.0, 300.0), (Some(90.0), Some(140.0)));
355 + }
356 +
357 + #[test]
358 + fn range_bounds_maps_each_end_independently() {
359 + assert_eq!(range_bounds(90.0, 300.0, 0.0, 300.0), (Some(90.0), None));
360 + assert_eq!(range_bounds(0.0, 140.0, 0.0, 300.0), (None, Some(140.0)));
361 + }
362 +
363 + #[test]
364 + fn range_bounds_handles_negative_sentinels() {
365 + // Loudness: -96 dB = no floor, 0 dB = no ceiling.
366 + assert_eq!(range_bounds(-96.0, 0.0, -96.0, 0.0), (None, None));
367 + assert_eq!(range_bounds(-40.0, -6.0, -96.0, 0.0), (Some(-40.0), Some(-6.0)));
368 + }
369 + }
@@ -341,3 +341,28 @@ fn draw_preview_device(ui: &mut egui::Ui, state: &BrowserState) {
341 341 )
342 342 .on_hover_text("Audio output device used for sample preview");
343 343 }
344 +
345 + #[cfg(test)]
346 + mod tests {
347 + use super::*;
348 +
349 + #[test]
350 + fn error_statuses_are_detected_case_insensitively() {
351 + assert!(is_error_status("Failed to import sample"));
352 + assert!(is_error_status("Import error: bad header"));
353 + assert!(is_error_status("Could not read file"));
354 + assert!(is_error_status("Couldn't open device"));
355 + assert!(is_error_status("Cannot delete the last vault"));
356 + assert!(is_error_status("Can't reach the sync server"));
357 + assert!(is_error_status("ERROR: disk full"));
358 + }
359 +
360 + #[test]
361 + fn informational_statuses_are_not_errors() {
362 + assert!(!is_error_status("Imported 42 samples"));
363 + assert!(!is_error_status("Downloading kick.wav..."));
364 + assert!(!is_error_status(""));
365 + // "error" only matches as a substring; unrelated words don't trip it.
366 + assert!(!is_error_status("Saved collection"));
367 + }
368 + }
@@ -699,3 +699,19 @@ fn draw_adsr_envelope_shape(ui: &mut egui::Ui, attack: f32, decay: f32, sustain:
699 699 painter.line_segment([p_d, p_s], stroke);
700 700 painter.line_segment([p_s, p_r], stroke);
701 701 }
702 +
703 + #[cfg(test)]
704 + mod tests {
705 + use super::*;
706 +
707 + #[test]
708 + fn white_keys_span_one_octave() {
709 + assert_eq!(white_keys_in_range(60, 72), vec![60, 62, 64, 65, 67, 69, 71]);
710 + }
711 +
712 + #[test]
713 + fn white_keys_exclude_black_keys() {
714 + assert_eq!(white_keys_in_range(60, 61), vec![60]); // C4
715 + assert!(white_keys_in_range(61, 62).is_empty()); // C#4 is black
716 + }
717 + }
@@ -1082,3 +1082,16 @@ pub fn draw_dir_rename_modal(ctx: &egui::Context, state: &mut BrowserState) {
1082 1082 }
1083 1083 }
1084 1084
1085 +
1086 + #[cfg(test)]
1087 + mod tests {
1088 + use super::*;
1089 +
1090 + #[test]
1091 + fn cmd_key_matches_platform() {
1092 + #[cfg(target_os = "macos")]
1093 + assert_eq!(cmd_key(), "Cmd");
1094 + #[cfg(not(target_os = "macos"))]
1095 + assert_eq!(cmd_key(), "Ctrl");
1096 + }
1097 + }
@@ -975,3 +975,44 @@ fn draw_advanced_section(ui: &mut egui::Ui, state: &mut BrowserState) {
975 975 }
976 976 });
977 977 }
978 +
979 + #[cfg(test)]
980 + mod tests {
981 + use super::*;
982 +
983 + #[test]
984 + fn scan_age_reads_fresh_and_stale() {
985 + assert_eq!(format_scan_age(30), ("Last scanned just now.".to_string(), false));
986 + assert_eq!(format_scan_age(300), ("Last scanned 5 minutes ago.".to_string(), false));
987 + assert_eq!(format_scan_age(7_200), ("Last scanned 2 hours ago.".to_string(), false));
988 + let (text, stale) = format_scan_age(172_800); // 2 days
989 + assert!(stale);
990 + assert!(text.contains("2 days ago"));
991 + assert!(text.contains("re-scan"));
992 + }
993 +
994 + #[test]
995 + fn deleted_age_pluralizes_and_clamps_negatives() {
996 + assert_eq!(format_deleted_age(-5), "deleted just now");
997 + assert_eq!(format_deleted_age(600), "deleted 10 minutes ago");
998 + assert_eq!(format_deleted_age(3_600), "deleted 1 hour ago");
999 + assert_eq!(format_deleted_age(172_800), "deleted 2 days ago");
1000 + }
1001 +
1002 + #[test]
1003 + fn settings_format_bytes_steps_up_units() {
1004 + assert_eq!(format_bytes(512), "512 B");
1005 + assert_eq!(format_bytes(1024), "1.0 KB");
1006 + assert_eq!(format_bytes(1024 * 1024 * 1024), "1.00 GB");
1007 + }
1008 +
1009 + #[test]
1010 + fn collapse_home_abbreviates_the_home_prefix() {
1011 + if let Some(home) = dirs::home_dir() {
1012 + assert_eq!(collapse_home(&home), "~");
1013 + let shown = collapse_home(&home.join("Music"));
1014 + assert!(shown.starts_with('~'));
1015 + assert!(shown.ends_with("Music"));
1016 + }
1017 + }
1018 + }
@@ -595,3 +595,36 @@ pub fn draw_sidebar(ui: &mut egui::Ui, state: &mut BrowserState) {
595 595 });
596 596
597 597 }
598 +
599 + #[cfg(test)]
600 + mod tests {
601 + use super::*;
602 +
603 + #[test]
604 + fn any_descendant_active_matches_exact_and_prefix() {
605 + let req = vec!["drums.kick".to_string()];
606 + assert!(any_descendant_active("drums", &req)); // prefix segment
607 + assert!(any_descendant_active("drums.kick", &req)); // exact
608 + assert!(!any_descendant_active("bass", &req));
609 + // "drum" is not a segment boundary of "drums.kick".
610 + assert!(!any_descendant_active("drum", &req));
611 + }
612 +
613 + #[test]
614 + fn build_tag_tree_nests_by_dot_segments() {
615 + let tags = vec![
616 + "drums.kick".to_string(),
617 + "drums.snare".to_string(),
618 + "bass".to_string(),
619 + ];
620 + let tree = build_tag_tree(&tags);
621 + // BTreeMap keys come back sorted.
622 + assert_eq!(tree.keys().collect::<Vec<_>>(), vec!["bass", "drums"]);
623 + let drums = &tree["drums"];
624 + assert!(!drums.is_leaf);
625 + assert_eq!(drums.children.keys().collect::<Vec<_>>(), vec!["kick", "snare"]);
626 + assert!(drums.children["kick"].is_leaf);
627 + assert!(tree["bass"].is_leaf);
628 + assert!(tree["bass"].children.is_empty());
629 + }
630 + }
@@ -30,6 +30,31 @@ fn format_cap(cap_bytes: i64) -> String {
30 30 }
31 31 }
32 32
33 + /// Decide whether the encryption form can submit, and the inline hint to show.
34 + /// Unlock (`has_server_key`) only needs a non-empty password. First-time setup
35 + /// requires a >=8-char password confirmed by a matching second entry; the hint
36 + /// is `None` while the user is still mid-entry (so no error flashes prematurely).
37 + fn encryption_submit_state(
38 + has_server_key: bool,
39 + pw: &str,
40 + confirm: &str,
41 + ) -> (bool, Option<&'static str>) {
42 + if has_server_key {
43 + return (!pw.is_empty(), None);
44 + }
45 + if pw.is_empty() {
46 + (false, None)
47 + } else if pw.len() < 8 {
48 + (false, Some("Password must be at least 8 characters."))
49 + } else if confirm.is_empty() {
50 + (false, None)
51 + } else if pw != confirm {
52 + (false, Some("Passwords don't match."))
53 + } else {
54 + (true, None)
55 + }
56 + }
57 +
33 58 /// Draw the sync settings panel as a floating window.
34 59 pub fn draw_sync_panel(
35 60 ctx: &egui::Context,
@@ -443,7 +468,7 @@ fn draw_needs_encryption(
443 468 // First-time setup: confirm field + length gate. The unlock path doesn't
444 469 // need confirmation — a typo there is recoverable (just re-enter).
445 470 let (can_submit, hint): (bool, Option<&str>) = if has_server_key {
446 - (!state.sync.encryption_input.is_empty(), None)
471 + encryption_submit_state(true, &state.sync.encryption_input, "")
447 472 } else {
448 473 ui.add_space(theme::space::SM);
449 474 ui.horizontal(|ui| {
@@ -454,19 +479,11 @@ fn draw_needs_encryption(
454 479 .desired_width(200.0),
455 480 );
456 481 });
457 - let pw = &state.sync.encryption_input;
458 - let confirm = &state.sync.encryption_confirm_input;
459 - if pw.is_empty() {
460 - (false, None)
461 - } else if pw.len() < 8 {
462 - (false, Some("Password must be at least 8 characters."))
463 - } else if confirm.is_empty() {
464 - (false, None)
465 - } else if pw != confirm {
466 - (false, Some("Passwords don't match."))
467 - } else {
468 - (true, None)
469 - }
482 + encryption_submit_state(
483 + false,
484 + &state.sync.encryption_input,
485 + &state.sync.encryption_confirm_input,
486 + )
470 487 };
471 488
472 489 if let Some(msg) = hint {
@@ -730,3 +747,70 @@ fn draw_cap_picker(
730 747 None
731 748 }
732 749 }
750 +
751 + #[cfg(test)]
752 + mod tests {
753 + use super::*;
754 +
755 + // ---------------------------------------------------------------
756 + // format_cents
757 + // ---------------------------------------------------------------
758 +
759 + #[test]
760 + fn format_cents_drops_the_decimal_on_whole_dollars() {
761 + assert_eq!(format_cents(0), "$0");
762 + assert_eq!(format_cents(500), "$5");
763 + }
764 +
765 + #[test]
766 + fn format_cents_zero_pads_the_pennies() {
767 + assert_eq!(format_cents(1599), "$15.99");
768 + assert_eq!(format_cents(1605), "$16.05");
769 + }
770 +
771 + // ---------------------------------------------------------------
772 + // format_cap
773 + // ---------------------------------------------------------------
774 +
775 + #[test]
776 + fn format_cap_shows_gib_below_a_tib() {
777 + assert_eq!(format_cap(10 * GIB), "10 GiB");
778 + assert_eq!(format_cap(512 * GIB), "512 GiB");
779 + }
780 +
781 + #[test]
782 + fn format_cap_switches_to_tib_at_1024_gib() {
783 + assert_eq!(format_cap(1024 * GIB), "1.0 TiB");
784 + assert_eq!(format_cap(1536 * GIB), "1.5 TiB");
785 + }
786 +
787 + // ---------------------------------------------------------------
788 + // encryption_submit_state
789 + // ---------------------------------------------------------------
790 +
791 + #[test]
792 + fn unlock_needs_only_a_nonempty_password() {
793 + assert_eq!(encryption_submit_state(true, "", ""), (false, None));
794 + assert_eq!(encryption_submit_state(true, "x", ""), (true, None));
795 + }
796 +
797 + #[test]
798 + fn setup_holds_hint_silent_until_a_rule_is_broken() {
799 + // Empty password and empty confirm are mid-entry states, not errors.
800 + assert_eq!(encryption_submit_state(false, "", ""), (false, None));
801 + assert_eq!(encryption_submit_state(false, "longenough", ""), (false, None));
802 + }
803 +
804 + #[test]
805 + fn setup_enforces_length_and_match() {
806 + assert_eq!(
807 + encryption_submit_state(false, "short", "short"),
808 + (false, Some("Password must be at least 8 characters.")),
809 + );
810 + assert_eq!(
811 + encryption_submit_state(false, "longenough", "different"),
812 + (false, Some("Passwords don't match.")),
813 + );
814 + assert_eq!(encryption_submit_state(false, "longenough", "longenough"), (true, None));
815 + }
816 + }
@@ -544,3 +544,16 @@ fn sync_label_color_tooltip(
544 544 }
545 545 }
546 546
547 +
548 + #[cfg(test)]
549 + mod tests {
550 + use super::*;
551 +
552 + #[test]
553 + fn sync_label_defaults_when_no_manager() {
554 + let (label, color, tooltip) = sync_label_color_tooltip(None);
555 + assert_eq!(label, "Sync");
556 + assert!(color.is_none());
557 + assert_eq!(tooltip, "Cloud sync settings");
558 + }
559 + }
@@ -682,3 +682,61 @@ pub fn format_bpm(bpm: f64) -> String {
682 682 format!("{:.1}", bpm)
683 683 }
684 684 }
685 +
686 + #[cfg(test)]
687 + mod tests {
688 + use super::*;
689 +
690 + // ---------------------------------------------------------------
691 + // format_bytes
692 + // ---------------------------------------------------------------
693 +
694 + #[test]
695 + fn format_bytes_uses_plain_bytes_below_1k() {
696 + assert_eq!(format_bytes(0), "0 B");
697 + assert_eq!(format_bytes(512), "512 B");
698 + assert_eq!(format_bytes(1023), "1023 B");
699 + }
700 +
701 + #[test]
702 + fn format_bytes_steps_up_units_at_each_1024_boundary() {
703 + assert_eq!(format_bytes(1024), "1.0 KB");
704 + assert_eq!(format_bytes(1536), "1.5 KB");
705 + assert_eq!(format_bytes(1024 * 1024), "1.0 MB");
706 + assert_eq!(format_bytes(1024 * 1024 * 1024), "1.00 GB");
707 + }
708 +
709 + // ---------------------------------------------------------------
710 + // format_duration
711 + // ---------------------------------------------------------------
712 +
713 + #[test]
714 + fn format_duration_shows_seconds_below_a_minute() {
715 + assert_eq!(format_duration(5.0), "5.0s");
716 + assert_eq!(format_duration(59.9), "59.9s");
717 + }
718 +
719 + #[test]
720 + fn format_duration_shows_mm_ss_at_and_above_a_minute() {
721 + assert_eq!(format_duration(60.0), "1:00.0");
722 + assert_eq!(format_duration(90.0), "1:30.0");
723 + assert_eq!(format_duration(125.5), "2:05.5");
724 + }
725 +
726 + // ---------------------------------------------------------------
727 + // format_bpm
728 + // ---------------------------------------------------------------
729 +
730 + #[test]
731 + fn format_bpm_drops_the_decimal_for_near_integer_values() {
732 + assert_eq!(format_bpm(120.0), "120");
733 + // 119.97 is within 0.05 of an integer, so it reads as "120", not "120.0".
734 + assert_eq!(format_bpm(119.97), "120");
735 + }
736 +
737 + #[test]
738 + fn format_bpm_keeps_one_decimal_for_fractional_values() {
739 + assert_eq!(format_bpm(120.5), "120.5");
740 + assert_eq!(format_bpm(128.3), "128.3");
741 + }
742 + }