1use tauri::{AppHandle, Emitter, Runtime};
2
3use crate::config::Profile;
4
5use super::super::settings::Settings;
6use super::super::timers::reset_timers_keep_sleep;
7use super::super::Scheduler;
8
9async fn emit_profile_changed<R: Runtime>(
10 app: &AppHandle<R>,
11 scheduler: &Scheduler,
12) -> tauri::Result<()> {
13 let name = scheduler.active_profile_name.lock().await.clone();
14 app.emit("profile:changed", &name)
15}
16
17#[tauri::command]
20pub async fn list_profiles(scheduler: tauri::State<'_, Scheduler>) -> Result<Vec<String>, String> {
21 Ok(scheduler
22 .profiles
23 .lock()
24 .await
25 .iter()
26 .map(|p| p.name.clone())
27 .collect())
28}
29
30#[tauri::command]
32pub async fn get_active_profile(scheduler: tauri::State<'_, Scheduler>) -> Result<String, String> {
33 Ok(scheduler.active_profile_name.lock().await.clone())
34}
35
36pub async fn set_active_profile_impl<R: Runtime>(
40 app: &AppHandle<R>,
41 scheduler: &Scheduler,
42 name: String,
43) -> Result<(), String> {
44 let next_settings = {
45 let profiles = scheduler.profiles.lock().await;
46 profiles
47 .iter()
48 .find(|p| p.name == name)
49 .map(|p| p.settings.clone())
50 .ok_or_else(|| format!("profile not found: {name}"))?
51 };
52 {
53 let current = scheduler.active_profile_name.lock().await.clone();
54 if current == name {
55 return Ok(());
56 }
57 }
58 {
59 let mut s = scheduler.settings.lock().await;
60 *s = next_settings;
61 s.rebuild_derived();
65 }
66 *scheduler.active_profile_name.lock().await = name.clone();
67 {
68 let mut t = scheduler.timers.lock().await;
69 reset_timers_keep_sleep(&mut t);
70 }
71 super::super::persist_profiles(scheduler).await;
72 {
73 let settings = scheduler.settings.lock().await.clone();
76 super::super::apply_hotkeys(app, &settings);
77 }
78 let _ = app.emit("profile:changed", &name);
79 Ok(())
80}
81
82#[tauri::command]
85pub async fn set_active_profile<R: Runtime>(
86 app: AppHandle<R>,
87 scheduler: tauri::State<'_, Scheduler>,
88 name: String,
89) -> Result<(), String> {
90 set_active_profile_impl(&app, scheduler.inner(), name).await
91}
92
93fn validate_profile_name(name: &str) -> Result<String, String> {
94 let trimmed = name.trim();
95 if trimmed.is_empty() {
96 return Err("profile name cannot be empty".to_string());
97 }
98 Ok(trimmed.to_string())
99}
100
101fn validate_delete(profiles: &[Profile], active: &str, target: &str) -> Result<(), String> {
102 if profiles.len() <= 1 {
103 return Err("cannot delete the only profile".to_string());
104 }
105 if active == target {
106 return Err("cannot delete the active profile".to_string());
107 }
108 if !profiles.iter().any(|p| p.name == target) {
109 return Err(format!("profile not found: {target}"));
110 }
111 Ok(())
112}
113
114fn validate_rename(profiles: &[Profile], from: &str, to: &str) -> Result<(), String> {
115 if from == to {
116 return Ok(());
117 }
118 if !profiles.iter().any(|p| p.name == from) {
119 return Err(format!("profile not found: {from}"));
120 }
121 if profiles.iter().any(|p| p.name == to) {
122 return Err(format!("profile already exists: {to}"));
123 }
124 Ok(())
125}
126
127fn validate_reorder(profiles: &[Profile], desired: &[String]) -> Result<(), String> {
128 if desired.len() != profiles.len() {
129 return Err(format!(
130 "reorder list length {} does not match profile count {}",
131 desired.len(),
132 profiles.len()
133 ));
134 }
135 for (i, name) in desired.iter().enumerate() {
136 if desired[..i].iter().any(|other| other == name) {
137 return Err(format!("duplicate profile in reorder list: {name}"));
138 }
139 if !profiles.iter().any(|p| &p.name == name) {
140 return Err(format!("profile not found: {name}"));
141 }
142 }
143 Ok(())
144}
145
146pub async fn create_profile_impl(scheduler: &Scheduler, name: String) -> Result<(), String> {
151 let name = validate_profile_name(&name)?;
152 {
153 let profiles = scheduler.profiles.lock().await;
154 if profiles.iter().any(|p| p.name == name) {
155 return Err(format!("profile already exists: {name}"));
156 }
157 }
158 let source = {
159 let active = scheduler.active_profile_name.lock().await.clone();
160 let profiles = scheduler.profiles.lock().await;
161 profiles
162 .iter()
163 .find(|p| p.name == active)
164 .map(|p| p.settings.clone())
165 .unwrap_or_default()
166 };
167 scheduler.profiles.lock().await.push(Profile {
168 name: name.clone(),
169 settings: source,
170 });
171 super::super::persist_profiles(scheduler).await;
172 Ok(())
173}
174
175#[tauri::command]
178pub async fn create_profile<R: Runtime>(
179 app: AppHandle<R>,
180 scheduler: tauri::State<'_, Scheduler>,
181 name: String,
182) -> Result<(), String> {
183 create_profile_impl(scheduler.inner(), name).await?;
184 let _ = emit_profile_changed(&app, scheduler.inner()).await;
185 Ok(())
186}
187
188pub async fn duplicate_profile_impl(
190 scheduler: &Scheduler,
191 source: String,
192 name: String,
193) -> Result<(), String> {
194 let name = validate_profile_name(&name)?;
195 let source_settings = {
196 let profiles = scheduler.profiles.lock().await;
197 if profiles.iter().any(|p| p.name == name) {
198 return Err(format!("profile already exists: {name}"));
199 }
200 profiles
201 .iter()
202 .find(|p| p.name == source)
203 .map(|p| p.settings.clone())
204 .ok_or_else(|| format!("profile not found: {source}"))?
205 };
206 scheduler.profiles.lock().await.push(Profile {
207 name: name.clone(),
208 settings: source_settings,
209 });
210 super::super::persist_profiles(scheduler).await;
211 Ok(())
212}
213
214#[tauri::command]
219pub async fn duplicate_profile<R: Runtime>(
220 app: AppHandle<R>,
221 scheduler: tauri::State<'_, Scheduler>,
222 source: String,
223 name: String,
224) -> Result<(), String> {
225 duplicate_profile_impl(scheduler.inner(), source, name).await?;
226 let _ = emit_profile_changed(&app, scheduler.inner()).await;
227 Ok(())
228}
229
230pub async fn rename_profile_impl(
233 scheduler: &Scheduler,
234 from: String,
235 to: String,
236) -> Result<(), String> {
237 let to = validate_profile_name(&to)?;
238 {
239 let profiles = scheduler.profiles.lock().await;
240 validate_rename(&profiles, &from, &to)?;
241 }
242 if from == to {
243 return Ok(());
244 }
245 {
246 let mut profiles = scheduler.profiles.lock().await;
247 if let Some(p) = profiles.iter_mut().find(|p| p.name == from) {
248 p.name = to.clone();
249 }
250 }
251 {
252 let mut active = scheduler.active_profile_name.lock().await;
253 if *active == from {
254 *active = to.clone();
255 }
256 }
257 super::super::persist_profiles(scheduler).await;
258 Ok(())
259}
260
261#[tauri::command]
265pub async fn rename_profile<R: Runtime>(
266 app: AppHandle<R>,
267 scheduler: tauri::State<'_, Scheduler>,
268 from: String,
269 to: String,
270) -> Result<(), String> {
271 rename_profile_impl(scheduler.inner(), from, to).await?;
272 let _ = emit_profile_changed(&app, scheduler.inner()).await;
273 Ok(())
274}
275
276pub async fn delete_profile_impl(scheduler: &Scheduler, name: String) -> Result<(), String> {
278 {
279 let profiles = scheduler.profiles.lock().await;
280 let active = scheduler.active_profile_name.lock().await.clone();
281 validate_delete(&profiles, &active, &name)?;
282 }
283 {
284 let mut profiles = scheduler.profiles.lock().await;
285 profiles.retain(|p| p.name != name);
286 }
287 super::super::persist_profiles(scheduler).await;
288 Ok(())
289}
290
291#[tauri::command]
294pub async fn delete_profile<R: Runtime>(
295 app: AppHandle<R>,
296 scheduler: tauri::State<'_, Scheduler>,
297 name: String,
298) -> Result<(), String> {
299 delete_profile_impl(scheduler.inner(), name).await?;
300 let _ = emit_profile_changed(&app, scheduler.inner()).await;
301 Ok(())
302}
303
304pub async fn reorder_profiles_impl(
306 scheduler: &Scheduler,
307 names: Vec<String>,
308) -> Result<(), String> {
309 {
310 let profiles = scheduler.profiles.lock().await;
311 validate_reorder(&profiles, &names)?;
312 }
313 {
314 let mut profiles = scheduler.profiles.lock().await;
315 let mut next: Vec<Profile> = Vec::with_capacity(profiles.len());
316 for name in &names {
317 if let Some(pos) = profiles.iter().position(|p| &p.name == name) {
318 next.push(profiles.swap_remove(pos));
319 }
320 }
321 *profiles = next;
322 }
323 super::super::persist_profiles(scheduler).await;
324 Ok(())
325}
326
327#[tauri::command]
331pub async fn reorder_profiles<R: Runtime>(
332 app: AppHandle<R>,
333 scheduler: tauri::State<'_, Scheduler>,
334 names: Vec<String>,
335) -> Result<(), String> {
336 reorder_profiles_impl(scheduler.inner(), names).await?;
337 let _ = emit_profile_changed(&app, scheduler.inner()).await;
338 Ok(())
339}
340
341pub async fn reset_profile_to_defaults_impl(
345 scheduler: &Scheduler,
346 name: String,
347) -> Result<(), String> {
348 let active = scheduler.active_profile_name.lock().await.clone();
349 {
350 let profiles = scheduler.profiles.lock().await;
351 if !profiles.iter().any(|p| p.name == name) {
352 return Err(format!("profile not found: {name}"));
353 }
354 }
355 let defaults = Settings::default();
356 {
357 let mut profiles = scheduler.profiles.lock().await;
358 if let Some(p) = profiles.iter_mut().find(|p| p.name == name) {
359 p.settings = defaults.clone();
360 }
361 }
362 if active == name {
363 let mut s = scheduler.settings.lock().await;
364 *s = defaults;
365 s.rebuild_derived();
366 }
367 super::super::persist_profiles(scheduler).await;
368 Ok(())
369}
370
371#[tauri::command]
375pub async fn reset_profile_to_defaults<R: Runtime>(
376 app: AppHandle<R>,
377 scheduler: tauri::State<'_, Scheduler>,
378 name: String,
379) -> Result<(), String> {
380 reset_profile_to_defaults_impl(scheduler.inner(), name).await?;
381 let _ = emit_profile_changed(&app, scheduler.inner()).await;
382 Ok(())
383}
384
385#[cfg(test)]
386mod tests {
387 use super::*;
388
389 fn named_profile(name: &str) -> Profile {
390 Profile {
391 name: name.to_string(),
392 settings: Settings::default(),
393 }
394 }
395
396 #[test]
397 fn validate_delete_rejects_only_profile() {
398 let profiles = vec![named_profile("Default")];
399 let err = validate_delete(&profiles, "Default", "Default").unwrap_err();
400 assert!(err.contains("only profile"));
401 }
402
403 #[test]
404 fn validate_delete_rejects_active() {
405 let profiles = vec![named_profile("Default"), named_profile("Work")];
406 let err = validate_delete(&profiles, "Work", "Work").unwrap_err();
407 assert!(err.contains("active"));
408 }
409
410 #[test]
411 fn validate_delete_rejects_missing() {
412 let profiles = vec![named_profile("Default"), named_profile("Work")];
413 let err = validate_delete(&profiles, "Default", "Missing").unwrap_err();
414 assert!(err.contains("not found"));
415 }
416
417 #[test]
418 fn validate_delete_accepts_inactive() {
419 let profiles = vec![named_profile("Default"), named_profile("Work")];
420 assert!(validate_delete(&profiles, "Default", "Work").is_ok());
421 }
422
423 #[test]
424 fn validate_rename_rejects_collision() {
425 let profiles = vec![named_profile("Default"), named_profile("Work")];
426 let err = validate_rename(&profiles, "Default", "Work").unwrap_err();
427 assert!(err.contains("already exists"));
428 }
429
430 #[test]
431 fn validate_rename_rejects_missing_source() {
432 let profiles = vec![named_profile("Default")];
433 let err = validate_rename(&profiles, "Missing", "Other").unwrap_err();
434 assert!(err.contains("not found"));
435 }
436
437 #[test]
438 fn validate_rename_allows_same_name_noop() {
439 let profiles = vec![named_profile("Default")];
440 assert!(validate_rename(&profiles, "Default", "Default").is_ok());
441 }
442
443 #[test]
444 fn validate_rename_accepts_unique_target() {
445 let profiles = vec![named_profile("Default")];
446 assert!(validate_rename(&profiles, "Default", "Work").is_ok());
447 }
448
449 #[test]
450 fn validate_profile_name_rejects_empty() {
451 assert!(validate_profile_name("").is_err());
452 assert!(validate_profile_name(" ").is_err());
453 }
454
455 #[test]
456 fn validate_reorder_rejects_length_mismatch() {
457 let profiles = vec![named_profile("a"), named_profile("b")];
458 let err = validate_reorder(&profiles, &["a".to_string()]).unwrap_err();
459 assert!(err.contains("does not match"));
460 }
461
462 #[test]
463 fn validate_reorder_rejects_duplicate() {
464 let profiles = vec![named_profile("a"), named_profile("b")];
465 let err = validate_reorder(&profiles, &["a".to_string(), "a".to_string()]).unwrap_err();
466 assert!(err.contains("duplicate"));
467 }
468
469 #[test]
470 fn validate_reorder_rejects_unknown() {
471 let profiles = vec![named_profile("a"), named_profile("b")];
472 let err = validate_reorder(&profiles, &["a".to_string(), "c".to_string()]).unwrap_err();
473 assert!(err.contains("not found"));
474 }
475
476 #[test]
477 fn validate_reorder_accepts_permutation() {
478 let profiles = vec![named_profile("a"), named_profile("b"), named_profile("c")];
479 let desired = vec!["c".to_string(), "a".to_string(), "b".to_string()];
480 assert!(validate_reorder(&profiles, &desired).is_ok());
481 }
482
483 #[test]
484 fn validate_profile_name_trims_whitespace() {
485 assert_eq!(validate_profile_name(" Work ").unwrap(), "Work");
486 }
487
488 #[test]
489 fn postpone_exhaustion_threshold_matches_max() {
490 let s = Settings::default();
491 assert_eq!(s.postpone_max_count, 3);
492 }
493
494 use crate::config::DEFAULT_PROFILE_NAME;
497 use crate::test_support::test_scheduler_with_profiles;
498
499 fn one_profile() -> Vec<Profile> {
500 vec![Profile {
501 name: DEFAULT_PROFILE_NAME.to_string(),
502 settings: Settings {
503 micro_interval_secs: 1234,
504 ..Settings::default()
505 },
506 }]
507 }
508
509 fn two_profiles() -> Vec<Profile> {
510 vec![
511 Profile {
512 name: DEFAULT_PROFILE_NAME.to_string(),
513 settings: Settings {
514 micro_interval_secs: 1234,
515 ..Settings::default()
516 },
517 },
518 Profile {
519 name: "Work".to_string(),
520 settings: Settings {
521 micro_interval_secs: 600,
522 ..Settings::default()
523 },
524 },
525 ]
526 }
527
528 #[cfg(not(target_os = "windows"))]
534 fn profiles_with_derived_source() -> Vec<Profile> {
535 vec![
536 Profile {
537 name: DEFAULT_PROFILE_NAME.to_string(),
538 settings: Settings::default(),
539 },
540 Profile {
541 name: "Fixed".to_string(),
542 settings: Settings {
543 micro_schedule_mode: crate::scheduler::settings::ScheduleMode::Fixed,
544 micro_fixed_times: vec!["09:30".into(), "14:00".into()],
545 app_pause_enabled: true,
546 app_pause_list: vec!["Zoom".into(), "OBS Studio".into()],
547 ..Settings::default()
548 },
549 },
550 ]
551 }
552
553 #[cfg(not(target_os = "windows"))]
558 #[tokio::test]
559 async fn set_active_profile_rebuilds_derived_caches() {
560 use tauri::test::{mock_builder, mock_context, noop_assets};
561
562 let (_dir, sched) =
563 test_scheduler_with_profiles(profiles_with_derived_source(), DEFAULT_PROFILE_NAME);
564 assert!(sched
566 .settings
567 .lock()
568 .await
569 .derived
570 .micro_fixed_minutes
571 .is_empty());
572
573 let app = mock_builder()
574 .build(mock_context(noop_assets()))
575 .expect("mock app builds");
576
577 set_active_profile_impl(app.handle(), &sched, "Fixed".to_string())
578 .await
579 .unwrap();
580
581 let s = sched.settings.lock().await;
582 assert_eq!(s.derived.micro_fixed_minutes, vec![570, 840]);
584 assert_eq!(
585 s.derived.app_pause_targets_lower,
586 vec!["zoom", "obs studio"]
587 );
588 }
589
590 #[tokio::test]
591 async fn create_profile_appends_copy_of_active_settings() {
592 let (_dir, sched) = test_scheduler_with_profiles(one_profile(), DEFAULT_PROFILE_NAME);
593 create_profile_impl(&sched, "Focus".to_string())
594 .await
595 .unwrap();
596 let profiles = sched.profiles.lock().await;
597 assert_eq!(profiles.len(), 2);
598 assert_eq!(profiles[1].name, "Focus");
599 assert_eq!(profiles[1].settings.micro_interval_secs, 1234);
601 }
602
603 #[tokio::test]
604 async fn create_profile_rejects_empty_name() {
605 let (_dir, sched) = test_scheduler_with_profiles(one_profile(), DEFAULT_PROFILE_NAME);
606 let err = create_profile_impl(&sched, " ".to_string())
607 .await
608 .unwrap_err();
609 assert!(err.contains("cannot be empty"));
610 assert_eq!(sched.profiles.lock().await.len(), 1);
611 }
612
613 #[tokio::test]
614 async fn create_profile_rejects_duplicate_name() {
615 let (_dir, sched) = test_scheduler_with_profiles(two_profiles(), DEFAULT_PROFILE_NAME);
616 let err = create_profile_impl(&sched, "Work".to_string())
617 .await
618 .unwrap_err();
619 assert!(err.contains("already exists"));
620 }
621
622 #[tokio::test]
623 async fn duplicate_profile_clones_named_source_without_switching_active() {
624 let (_dir, sched) = test_scheduler_with_profiles(two_profiles(), DEFAULT_PROFILE_NAME);
625 duplicate_profile_impl(&sched, "Work".to_string(), "Focus".to_string())
626 .await
627 .unwrap();
628 let profiles = sched.profiles.lock().await;
629 assert_eq!(profiles.len(), 3);
630 let focus = profiles.iter().find(|p| p.name == "Focus").unwrap();
631 assert_eq!(focus.settings.micro_interval_secs, 600);
633 let active = sched.active_profile_name.lock().await;
635 assert_eq!(*active, DEFAULT_PROFILE_NAME);
636 }
637
638 #[tokio::test]
639 async fn duplicate_profile_errors_when_source_missing() {
640 let (_dir, sched) = test_scheduler_with_profiles(one_profile(), DEFAULT_PROFILE_NAME);
641 let err = duplicate_profile_impl(&sched, "Missing".to_string(), "Focus".to_string())
642 .await
643 .unwrap_err();
644 assert!(err.contains("not found"));
645 assert_eq!(sched.profiles.lock().await.len(), 1);
646 }
647
648 #[tokio::test]
649 async fn rename_profile_renames_in_place_and_follows_active_pointer() {
650 let (_dir, sched) = test_scheduler_with_profiles(two_profiles(), DEFAULT_PROFILE_NAME);
651 rename_profile_impl(
652 &sched,
653 DEFAULT_PROFILE_NAME.to_string(),
654 "Personal".to_string(),
655 )
656 .await
657 .unwrap();
658 let profiles = sched.profiles.lock().await;
659 assert_eq!(profiles[0].name, "Personal");
660 assert_eq!(profiles[1].name, "Work");
661 let active = sched.active_profile_name.lock().await;
662 assert_eq!(*active, "Personal", "active pointer follows the rename");
663 }
664
665 #[tokio::test]
666 async fn rename_profile_leaves_active_alone_when_renaming_inactive() {
667 let (_dir, sched) = test_scheduler_with_profiles(two_profiles(), DEFAULT_PROFILE_NAME);
668 rename_profile_impl(&sched, "Work".to_string(), "Office".to_string())
669 .await
670 .unwrap();
671 assert_eq!(
672 *sched.active_profile_name.lock().await,
673 DEFAULT_PROFILE_NAME,
674 );
675 }
676
677 #[tokio::test]
678 async fn rename_profile_noop_on_same_name() {
679 let (_dir, sched) = test_scheduler_with_profiles(one_profile(), DEFAULT_PROFILE_NAME);
680 rename_profile_impl(
681 &sched,
682 DEFAULT_PROFILE_NAME.to_string(),
683 DEFAULT_PROFILE_NAME.to_string(),
684 )
685 .await
686 .unwrap();
687 assert_eq!(sched.profiles.lock().await.len(), 1);
688 }
689
690 #[tokio::test]
691 async fn delete_profile_removes_inactive_entry() {
692 let (_dir, sched) = test_scheduler_with_profiles(two_profiles(), DEFAULT_PROFILE_NAME);
693 delete_profile_impl(&sched, "Work".to_string())
694 .await
695 .unwrap();
696 let profiles = sched.profiles.lock().await;
697 assert_eq!(profiles.len(), 1);
698 assert_eq!(profiles[0].name, DEFAULT_PROFILE_NAME);
699 }
700
701 #[tokio::test]
702 async fn delete_profile_rejects_active() {
703 let (_dir, sched) = test_scheduler_with_profiles(two_profiles(), DEFAULT_PROFILE_NAME);
704 let err = delete_profile_impl(&sched, DEFAULT_PROFILE_NAME.to_string())
705 .await
706 .unwrap_err();
707 assert!(err.contains("active"));
708 assert_eq!(sched.profiles.lock().await.len(), 2);
709 }
710
711 #[tokio::test]
712 async fn delete_profile_rejects_only_profile() {
713 let (_dir, sched) = test_scheduler_with_profiles(one_profile(), DEFAULT_PROFILE_NAME);
714 let err = delete_profile_impl(&sched, DEFAULT_PROFILE_NAME.to_string())
715 .await
716 .unwrap_err();
717 assert!(err.contains("only profile"));
718 }
719
720 #[tokio::test]
721 async fn reorder_profiles_reorders_in_place() {
722 let three = vec![
723 Profile {
724 name: "a".into(),
725 settings: Settings::default(),
726 },
727 Profile {
728 name: "b".into(),
729 settings: Settings::default(),
730 },
731 Profile {
732 name: "c".into(),
733 settings: Settings::default(),
734 },
735 ];
736 let (_dir, sched) = test_scheduler_with_profiles(three, "a");
737 reorder_profiles_impl(&sched, vec!["c".into(), "a".into(), "b".into()])
738 .await
739 .unwrap();
740 let names: Vec<String> = sched
741 .profiles
742 .lock()
743 .await
744 .iter()
745 .map(|p| p.name.clone())
746 .collect();
747 assert_eq!(names, vec!["c", "a", "b"]);
748 }
749
750 #[tokio::test]
751 async fn reorder_profiles_rejects_unknown_name() {
752 let (_dir, sched) = test_scheduler_with_profiles(two_profiles(), DEFAULT_PROFILE_NAME);
753 let err = reorder_profiles_impl(&sched, vec!["Work".into(), "Missing".into()])
754 .await
755 .unwrap_err();
756 assert!(err.contains("not found"));
757 }
758
759 #[tokio::test]
760 async fn reset_profile_to_defaults_resets_inactive_only_on_disk() {
761 let (_dir, sched) = test_scheduler_with_profiles(two_profiles(), DEFAULT_PROFILE_NAME);
762 reset_profile_to_defaults_impl(&sched, "Work".to_string())
763 .await
764 .unwrap();
765 let profiles = sched.profiles.lock().await;
766 let work = profiles.iter().find(|p| p.name == "Work").unwrap();
767 assert_eq!(
768 work.settings.micro_interval_secs,
769 Settings::default().micro_interval_secs,
770 );
771 assert_eq!(
774 sched.settings.lock().await.micro_interval_secs,
775 1234,
776 "active profile's live settings stay put when the inactive one resets",
777 );
778 }
779
780 #[tokio::test]
781 async fn reset_profile_to_defaults_also_resets_live_settings_when_active() {
782 let (_dir, sched) = test_scheduler_with_profiles(two_profiles(), DEFAULT_PROFILE_NAME);
783 reset_profile_to_defaults_impl(&sched, DEFAULT_PROFILE_NAME.to_string())
784 .await
785 .unwrap();
786 assert_eq!(
787 sched.settings.lock().await.micro_interval_secs,
788 Settings::default().micro_interval_secs,
789 );
790 }
791
792 #[tokio::test]
793 async fn reset_profile_to_defaults_errors_when_missing() {
794 let (_dir, sched) = test_scheduler_with_profiles(one_profile(), DEFAULT_PROFILE_NAME);
795 let err = reset_profile_to_defaults_impl(&sched, "Missing".to_string())
796 .await
797 .unwrap_err();
798 assert!(err.contains("not found"));
799 }
800
801 #[tokio::test]
802 async fn set_active_profile_switches_settings_and_resets_timers() {
803 let (_dir, sched) = test_scheduler_with_profiles(two_profiles(), DEFAULT_PROFILE_NAME);
804 {
806 let mut t = sched.timers.lock().await;
807 t.micro_warned = true;
808 t.long_warned = true;
809 t.last_sleep = Some(std::time::Instant::now());
810 }
811 let next = sched
821 .profiles
822 .lock()
823 .await
824 .iter()
825 .find(|p| p.name == "Work")
826 .map(|p| p.settings.clone())
827 .unwrap();
828 *sched.settings.lock().await = next.clone();
829 *sched.active_profile_name.lock().await = "Work".to_string();
830 crate::scheduler::timers::reset_timers_keep_sleep(&mut *sched.timers.lock().await);
831 assert_eq!(sched.settings.lock().await.micro_interval_secs, 600);
834 assert_eq!(*sched.active_profile_name.lock().await, "Work");
835 let t = sched.timers.lock().await;
836 assert!(!t.micro_warned);
837 assert!(!t.long_warned);
838 assert!(
839 t.last_sleep.is_some(),
840 "sleep marker preserved across switch"
841 );
842 }
843
844 #[tokio::test]
845 async fn list_profiles_returns_names_in_storage_order() {
846 let (_dir, sched) = test_scheduler_with_profiles(two_profiles(), DEFAULT_PROFILE_NAME);
847 let names: Vec<String> = sched
848 .profiles
849 .lock()
850 .await
851 .iter()
852 .map(|p| p.name.clone())
853 .collect();
854 assert_eq!(names, vec![DEFAULT_PROFILE_NAME.to_string(), "Work".into()]);
855 }
856}
857
858#[cfg(all(test, not(target_os = "windows")))]
866mod rig_smoke_tests {
867 use super::*;
868 use crate::config::DEFAULT_PROFILE_NAME;
869 use crate::test_support::{mock_app_with_scheduler, wrap_in_mock_app};
870 use std::sync::atomic::{AtomicUsize, Ordering};
871 use std::sync::Arc;
872 use tauri::{Listener, Manager};
873
874 fn listen_for_profile_changed(
878 app: &tauri::App<tauri::test::MockRuntime>,
879 ) -> Arc<std::sync::Mutex<Option<String>>> {
880 let captured: Arc<std::sync::Mutex<Option<String>>> = Arc::new(std::sync::Mutex::new(None));
881 let captured_handle = captured.clone();
882 app.listen("profile:changed", move |event| {
883 let name: String =
884 serde_json::from_str(event.payload()).expect("profile:changed payload is a string");
885 *captured_handle.lock().unwrap() = Some(name);
886 });
887 captured
888 }
889
890 fn two_rig_profiles() -> Vec<Profile> {
891 vec![
892 Profile {
893 name: DEFAULT_PROFILE_NAME.to_string(),
894 settings: Settings::default(),
895 },
896 Profile {
897 name: "Work".to_string(),
898 settings: Settings {
899 micro_interval_secs: 600,
900 ..Settings::default()
901 },
902 },
903 ]
904 }
905
906 #[tokio::test]
907 async fn create_profile_command_emits_profile_changed() {
908 let (_dir, app, sched) = mock_app_with_scheduler(Settings::default());
909 let captured = listen_for_profile_changed(&app);
910 let state = app.state::<Scheduler>();
911 create_profile(app.handle().clone(), state, "Focus".to_string())
912 .await
913 .expect("create_profile succeeds");
914 assert_eq!(
915 captured.lock().unwrap().as_deref(),
916 Some(DEFAULT_PROFILE_NAME),
917 "emit carries the active profile (unchanged by create)",
918 );
919 assert_eq!(sched.profiles.lock().await.len(), 2);
920 }
921
922 #[tokio::test]
923 async fn create_profile_command_propagates_validation_error() {
924 let (_dir, app, _sched) = mock_app_with_scheduler(Settings::default());
927 let state = app.state::<Scheduler>();
928 let err = create_profile(app.handle().clone(), state, " ".to_string())
929 .await
930 .expect_err("empty name rejected");
931 assert!(err.contains("cannot be empty"));
932 }
933
934 #[tokio::test]
935 async fn duplicate_profile_command_emits_profile_changed() {
936 let (dir, sched) = crate::test_support::test_scheduler_with_profiles(
937 two_rig_profiles(),
938 DEFAULT_PROFILE_NAME,
939 );
940 let app = wrap_in_mock_app(sched.clone());
941 let captured = listen_for_profile_changed(&app);
942 let state = app.state::<Scheduler>();
943 duplicate_profile(
944 app.handle().clone(),
945 state,
946 "Work".to_string(),
947 "Focus".to_string(),
948 )
949 .await
950 .expect("duplicate_profile succeeds");
951 assert_eq!(
952 captured.lock().unwrap().as_deref(),
953 Some(DEFAULT_PROFILE_NAME)
954 );
955 let names: Vec<String> = sched
956 .profiles
957 .lock()
958 .await
959 .iter()
960 .map(|p| p.name.clone())
961 .collect();
962 assert!(names.contains(&"Focus".to_string()));
963 drop(dir);
964 }
965
966 #[tokio::test]
967 async fn rename_profile_command_emits_profile_changed_and_follows_active() {
968 let (dir, sched) = crate::test_support::test_scheduler_with_profiles(
969 two_rig_profiles(),
970 DEFAULT_PROFILE_NAME,
971 );
972 let app = wrap_in_mock_app(sched.clone());
973 let captured = listen_for_profile_changed(&app);
974 let state = app.state::<Scheduler>();
975 rename_profile(
976 app.handle().clone(),
977 state,
978 DEFAULT_PROFILE_NAME.to_string(),
979 "Personal".to_string(),
980 )
981 .await
982 .expect("rename_profile succeeds");
983 assert_eq!(captured.lock().unwrap().as_deref(), Some("Personal"));
986 drop(dir);
987 }
988
989 #[tokio::test]
990 async fn delete_profile_command_emits_profile_changed() {
991 let (dir, sched) = crate::test_support::test_scheduler_with_profiles(
992 two_rig_profiles(),
993 DEFAULT_PROFILE_NAME,
994 );
995 let app = wrap_in_mock_app(sched.clone());
996 let captured = listen_for_profile_changed(&app);
997 let state = app.state::<Scheduler>();
998 delete_profile(app.handle().clone(), state, "Work".to_string())
999 .await
1000 .expect("delete_profile succeeds");
1001 assert_eq!(
1002 captured.lock().unwrap().as_deref(),
1003 Some(DEFAULT_PROFILE_NAME)
1004 );
1005 assert_eq!(sched.profiles.lock().await.len(), 1);
1006 drop(dir);
1007 }
1008
1009 #[tokio::test]
1010 async fn reorder_profiles_command_emits_profile_changed() {
1011 let (dir, sched) = crate::test_support::test_scheduler_with_profiles(
1012 two_rig_profiles(),
1013 DEFAULT_PROFILE_NAME,
1014 );
1015 let app = wrap_in_mock_app(sched.clone());
1016 let captured = listen_for_profile_changed(&app);
1017 let state = app.state::<Scheduler>();
1018 reorder_profiles(
1019 app.handle().clone(),
1020 state,
1021 vec!["Work".to_string(), DEFAULT_PROFILE_NAME.to_string()],
1022 )
1023 .await
1024 .expect("reorder_profiles succeeds");
1025 assert_eq!(
1026 captured.lock().unwrap().as_deref(),
1027 Some(DEFAULT_PROFILE_NAME)
1028 );
1029 let names: Vec<String> = sched
1030 .profiles
1031 .lock()
1032 .await
1033 .iter()
1034 .map(|p| p.name.clone())
1035 .collect();
1036 assert_eq!(
1037 names,
1038 vec!["Work".to_string(), DEFAULT_PROFILE_NAME.to_string()]
1039 );
1040 drop(dir);
1041 }
1042
1043 #[tokio::test]
1044 async fn reset_profile_to_defaults_command_emits_profile_changed() {
1045 let (dir, sched) = crate::test_support::test_scheduler_with_profiles(
1046 two_rig_profiles(),
1047 DEFAULT_PROFILE_NAME,
1048 );
1049 let app = wrap_in_mock_app(sched.clone());
1050 let captured = listen_for_profile_changed(&app);
1051 let state = app.state::<Scheduler>();
1052 reset_profile_to_defaults(app.handle().clone(), state, "Work".to_string())
1053 .await
1054 .expect("reset succeeds");
1055 assert_eq!(
1056 captured.lock().unwrap().as_deref(),
1057 Some(DEFAULT_PROFILE_NAME)
1058 );
1059 drop(dir);
1060 }
1061
1062 #[tokio::test]
1063 async fn set_active_profile_command_emits_profile_changed_with_new_active() {
1064 let (dir, sched) = crate::test_support::test_scheduler_with_profiles(
1065 two_rig_profiles(),
1066 DEFAULT_PROFILE_NAME,
1067 );
1068 let app = wrap_in_mock_app(sched.clone());
1069 let captured = listen_for_profile_changed(&app);
1070 let state = app.state::<Scheduler>();
1071 set_active_profile(app.handle().clone(), state, "Work".to_string())
1072 .await
1073 .expect("set_active_profile succeeds");
1074 assert_eq!(
1075 captured.lock().unwrap().as_deref(),
1076 Some("Work"),
1077 "emit must carry the newly-active name",
1078 );
1079 assert_eq!(*sched.active_profile_name.lock().await, "Work");
1080 drop(dir);
1081 }
1082
1083 #[tokio::test]
1084 async fn set_active_profile_command_no_emit_when_already_active() {
1085 let (dir, sched) = crate::test_support::test_scheduler_with_profiles(
1089 two_rig_profiles(),
1090 DEFAULT_PROFILE_NAME,
1091 );
1092 let app = wrap_in_mock_app(sched.clone());
1093 let fired = Arc::new(AtomicUsize::new(0));
1094 {
1095 let fired = fired.clone();
1096 app.listen("profile:changed", move |_event| {
1097 fired.fetch_add(1, Ordering::SeqCst);
1098 });
1099 }
1100 let state = app.state::<Scheduler>();
1101 set_active_profile(
1102 app.handle().clone(),
1103 state,
1104 DEFAULT_PROFILE_NAME.to_string(),
1105 )
1106 .await
1107 .expect("set_active_profile no-op succeeds");
1108 assert_eq!(
1109 fired.load(Ordering::SeqCst),
1110 0,
1111 "no emit on same-active no-op",
1112 );
1113 drop(dir);
1114 }
1115}