1use std::path::PathBuf;
9use std::time::{Duration, SystemTime};
10
11use forge_dictate::catalogue::{Catalogue, CatalogueEntry};
12use forge_dictate::{ModelFacts, ModelSpec};
13
14use crate::dictate::{DictateModelState, DictateRole, DictateSnapshot};
15
16pub(crate) const REFRESH_AFTER: Duration = Duration::from_secs(24 * 60 * 60);
21
22pub(crate) use forge_dictate::catalogue::PREFERRED_DOWNLOADS;
26
27#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
29pub struct DictateModelsSnapshot {
30 pub enabled: bool,
34 pub models_dir: Option<PathBuf>,
36 pub speaking: crate::tts::TtsView,
39 pub in_use: Vec<InUseModel>,
41 pub check: CatalogueCheck,
43 pub updates: Vec<ModelUpdate>,
46 pub rows: Vec<CatalogueRow>,
48 pub install: crate::install::InstallState,
50 pub activate: crate::install::ActivateState,
52 pub installed: Vec<crate::install::InstalledModel>,
54 pub bench: crate::bench::BenchState,
57 pub results: Vec<crate::bench::BenchResult>,
59 pub speaking_bench: crate::bench::SpeakingBenchState,
62 pub speaking_results: Vec<crate::bench::SpeakingBenchResult>,
64 pub read_aloud: crate::bench::ReadAloudState,
66}
67
68#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
70#[serde(tag = "state", rename_all = "snake_case")]
71pub enum CatalogueCheck {
72 Never,
74 Checking,
76 Fresh {
78 at: String,
79 release: Option<String>,
81 skipped: usize,
83 },
84 Unreachable { error: String },
87}
88
89#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
91pub struct InUseModel {
92 pub role: DictateRole,
93 pub file: String,
94 pub size: u64,
95 pub sha256: Option<String>,
98 pub state: DictateModelState,
100 pub facts: ModelFacts,
102 pub catalogue: Option<CatalogueJoin>,
104 pub from: crate::install::ActiveFrom,
107 pub at: Option<String>,
109}
110
111#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
113pub struct CatalogueJoin {
114 pub variant: String,
115 pub display_name: String,
116 pub size_bytes: u64,
119 pub streaming: bool,
120 pub languages: Vec<String>,
121 pub speed: Option<SpeedFact>,
122}
123
124#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
126pub struct SpeedFact {
127 pub machine: String,
128 pub backend: String,
129 pub quant: String,
130 pub xrt_wall: f64,
131}
132
133#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
135pub struct CatalogueRow {
136 pub variant: String,
137 pub display_name: String,
138 pub family: String,
139 pub params: Option<u64>,
142 pub license: Option<String>,
143 pub languages: Vec<String>,
144 pub streaming: bool,
145 pub download: Option<DownloadFact>,
147 pub speed: Option<SpeedFact>,
148 pub wer: Option<WerFact>,
151 pub kind: forge_dictate::catalogue::EntryKind,
153 pub url: Option<String>,
157 pub download_count: Option<u64>,
161}
162
163#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
165pub struct DownloadFact {
166 pub quant: String,
167 pub size_bytes: u64,
168}
169
170#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
172pub struct WerFact {
173 pub dataset: String,
174 pub split: String,
175 pub language: String,
176 pub err_pct: f64,
177}
178
179#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
187pub struct ModelUpdate {
188 pub role: DictateRole,
189 pub file: String,
191 pub current: CurrentFacts,
194 pub candidates: Vec<CandidateRow>,
197}
198
199#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
201pub struct CandidateRow {
202 pub row: CatalogueRow,
203 pub verdict: UpdateVerdict,
204}
205
206#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
208#[serde(rename_all = "snake_case")]
209pub enum UpdateVerdict {
210 Recommended,
213 BeatsBoth,
216 Slower,
218 Blunter,
220}
221
222#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize, serde::Deserialize)]
224pub struct CurrentFacts {
225 pub speed_x: f64,
226 pub fleurs_en_wer: f64,
227}
228
229pub(crate) fn row_for(entry: &CatalogueEntry) -> CatalogueRow {
231 CatalogueRow {
232 variant: entry.variant.clone(),
233 display_name: entry.display_name.clone(),
234 family: entry.family.clone(),
235 params: entry.params,
236 license: entry.license.as_ref().map(|license| license.display.clone()),
237 languages: entry.languages.clone(),
238 streaming: entry.streaming(),
239 download: download_fact(entry),
240 speed: speed_fact(entry),
241 wer: wer_fact(entry),
242 kind: entry.kind,
243 url: match entry.kind {
246 forge_dictate::catalogue::EntryKind::Normalizer => {
247 entry.published_repo.as_ref().map(|repo| format!("https://huggingface.co/{repo}"))
248 }
249 forge_dictate::catalogue::EntryKind::Asr => None,
250 },
251 download_count: entry.download_count,
252 }
253}
254
255fn speed_fact(entry: &CatalogueEntry) -> Option<SpeedFact> {
257 let row = entry.m4_metal_speed_row()?;
258 Some(SpeedFact {
259 machine: row.machine.clone(),
260 backend: row.backend.clone(),
261 quant: row.quant.clone(),
262 xrt_wall: row.xrt_wall?,
263 })
264}
265
266fn download_fact(entry: &CatalogueEntry) -> Option<DownloadFact> {
269 PREFERRED_DOWNLOADS.iter().find_map(|quant| {
270 entry.downloads.iter().find(|download| &download.quant == quant).map(|download| {
271 DownloadFact { quant: download.quant.clone(), size_bytes: download.size_bytes }
272 })
273 })
274}
275
276fn wer_fact(entry: &CatalogueEntry) -> Option<WerFact> {
279 if let Some(err_pct) = entry.fleurs_en_wer() {
280 return Some(WerFact {
281 dataset: "fleurs".to_owned(),
282 split: "test".to_owned(),
283 language: "en".to_owned(),
284 err_pct,
285 });
286 }
287 let headline = entry.headline_benchmark.as_ref()?;
288 Some(WerFact {
289 dataset: headline.dataset.clone(),
290 split: headline.split.clone(),
291 language: headline.language.clone(),
292 err_pct: entry.headline_wer()?,
293 })
294}
295
296pub(crate) fn join_for(entries: &[CatalogueEntry], spec: &ModelSpec) -> Option<CatalogueJoin> {
299 let entry = entry_for(entries, spec)?;
300 let download = entry.download_for(&spec.file)?;
301 Some(CatalogueJoin {
302 variant: entry.variant.clone(),
303 display_name: entry.display_name.clone(),
304 size_bytes: download.size_bytes,
305 streaming: entry.streaming(),
306 languages: entry.languages.clone(),
307 speed: speed_fact(entry),
308 })
309}
310
311fn entry_for<'e>(entries: &'e [CatalogueEntry], spec: &ModelSpec) -> Option<&'e CatalogueEntry> {
312 entries.iter().find(|entry| entry.download_for(&spec.file).is_some())
313}
314
315pub(crate) fn in_use(
318 entries: &[CatalogueEntry],
319 active: &[(DictateRole, crate::install::ActiveModel)],
320 states: &DictateSnapshot,
321) -> Vec<InUseModel> {
322 active
323 .iter()
324 .map(|(role, model)| {
325 let spec = &model.spec;
326 let state = states
327 .models
328 .iter()
329 .find(|row| row.file == spec.file)
330 .map_or(DictateModelState::Pending, |row| row.state.clone());
331 InUseModel {
332 role: *role,
333 file: spec.file.clone(),
334 size: spec.size,
335 sha256: spec.sha256.clone(),
336 state,
337 facts: spec.facts.clone(),
338 catalogue: join_for(entries, spec),
339 from: model.from.clone(),
340 at: model.at.clone(),
341 }
342 })
343 .collect()
344}
345
346pub(crate) fn updates_for(
364 entries: &[CatalogueEntry],
365 specs: &[(DictateRole, ModelSpec)],
366) -> Vec<ModelUpdate> {
367 specs
368 .iter()
369 .filter_map(|(role, spec)| {
370 let joined = entry_for(entries, spec)?;
371 if joined.download_for(&spec.file)?.size_bytes != spec.size {
376 return None;
377 }
378 let current_speed = joined.m4_metal_xrt_wall()?;
379 let current_wer = joined.fleurs_en_wer()?;
380
381 let mut ranked: Vec<(&CatalogueEntry, f64, f64)> = Vec::new();
382 for entry in entries {
383 if entry.variant == joined.variant {
384 continue;
385 }
386 if !entry.languages.iter().any(|language| language == "en") {
387 continue;
388 }
389 let (Some(speed), Some(wer)) = (entry.m4_metal_xrt_wall(), entry.fleurs_en_wer())
390 else {
391 continue;
392 };
393 ranked.push((entry, speed, wer));
394 }
395 ranked.sort_by(|(_, a_speed, a_wer), (_, b_speed, b_wer)| {
396 b_speed.total_cmp(a_speed).then(a_wer.total_cmp(b_wer))
397 });
398
399 let mut recommended = false;
400 let candidates: Vec<CandidateRow> = ranked
401 .into_iter()
402 .map(|(entry, speed, wer)| {
403 let beats = speed > current_speed && wer < current_wer;
404 let verdict = if beats && !recommended {
407 recommended = true;
408 UpdateVerdict::Recommended
409 } else if beats {
410 UpdateVerdict::BeatsBoth
411 } else if speed <= current_speed {
412 UpdateVerdict::Slower
413 } else {
414 UpdateVerdict::Blunter
415 };
416 CandidateRow { row: row_for(entry), verdict }
417 })
418 .collect();
419
420 Some(ModelUpdate {
421 role: *role,
422 file: spec.file.clone(),
423 current: CurrentFacts { speed_x: current_speed, fleurs_en_wer: current_wer },
424 candidates,
425 })
426 })
427 .collect()
428}
429
430pub(crate) fn refresh_is_due(catalogue: Option<&Catalogue>, now: SystemTime) -> bool {
434 let Some(catalogue) = catalogue else {
435 return true;
436 };
437 let Ok(at) = time::OffsetDateTime::parse(
438 &catalogue.fetched_at,
439 &time::format_description::well_known::Rfc3339,
440 ) else {
441 return true;
442 };
443 now.duration_since(SystemTime::from(at)).map_or(true, |age| age >= REFRESH_AFTER)
444}
445
446pub(crate) struct CatalogueState {
449 pub(crate) check: CatalogueCheck,
450 pub(crate) catalogue: Option<Catalogue>,
453}
454
455impl Default for CatalogueState {
456 fn default() -> Self {
457 Self { check: CatalogueCheck::Never, catalogue: None }
458 }
459}
460
461impl crate::Workspace {
462 pub fn dictate_models(&self) -> DictateModelsSnapshot {
464 let config = self.config_arc();
465 let settings = &config.dictate;
466 let active = self.active_models();
467 let specs: Vec<(DictateRole, ModelSpec)> =
468 active.iter().map(|(role, model)| (*role, model.spec.clone())).collect();
469 let preflight = self.dictate.snapshot.lock().clone();
470 let state = self.dictate_catalogue.lock();
471 let entries: &[CatalogueEntry] =
472 state.catalogue.as_ref().map_or(&[], |catalogue| catalogue.entries.as_slice());
473 let mut in_use =
474 if settings.enabled { in_use(entries, &active, &preflight) } else { Vec::new() };
475 if self.voice_enabled() {
476 let view = self.voices();
477 let variant = crate::tts::SpeakingVariant::named(&view.active.variant)
478 .unwrap_or_else(crate::tts::SpeakingVariant::pinned);
479 let install = self.speaking_install(&view.active.variant);
480 let (size, sha256) = install.as_ref().map_or_else(
481 || (variant.size, Some(variant.sha256.to_owned())),
482 |row| (row.model_size, Some(row.model_sha256.clone())),
483 );
484 let from = match view.from {
485 crate::tts::SpeakingFrom::Config => crate::install::ActiveFrom::Config {
486 key: "speaking_model".to_owned(),
487 variant: variant.variant.to_owned(),
488 },
489 crate::tts::SpeakingFrom::Installed => {
490 crate::install::ActiveFrom::Installed { variant: variant.variant.to_owned() }
491 }
492 crate::tts::SpeakingFrom::Pin => crate::install::ActiveFrom::Pin,
493 };
494 for row in &view.snapshot.models {
495 in_use.push(InUseModel {
496 role: row.role,
497 file: row.file.clone(),
498 size,
499 sha256: sha256.clone(),
500 state: row.state.clone(),
501 facts: variant.spec().facts,
502 catalogue: None,
503 from: from.clone(),
504 at: None,
505 });
506 }
507 }
508
509 DictateModelsSnapshot {
510 enabled: settings.enabled,
511 models_dir: settings.models_dir(),
512 speaking: self.voices(),
513 in_use,
514 check: state.check.clone(),
515 updates: if settings.enabled { updates_for(entries, &specs) } else { Vec::new() },
516 rows: entries.iter().map(row_for).collect(),
517 install: self.dictate_install(),
518 activate: self.dictate_activate(),
519 installed: self.installed_models(),
520 bench: self.dictate_bench(),
521 results: self.bench_results(),
522 speaking_bench: self.speaking_bench(),
523 speaking_results: self.speaking_bench_results(),
524 read_aloud: self.read_aloud_state(),
525 }
526 }
527
528 pub fn start_dictate_catalogue(self: &std::sync::Arc<Self>) {
536 if !self.config_arc().dictate.enabled {
537 return;
538 }
539 match self.catalogue_dir() {
540 None => {
541 self.spawn_catalogue_refresh();
542 }
543 Some(dir) => match forge_dictate::catalogue::read_catalogue_cache(&dir) {
544 Ok(Some(catalogue)) => {
545 let due = refresh_is_due(Some(&catalogue), SystemTime::now());
546 {
550 let mut state = self.dictate_catalogue.lock();
551 state.check = CatalogueCheck::Fresh {
552 at: catalogue.fetched_at.clone(),
553 release: catalogue.release.clone(),
554 skipped: catalogue.skipped,
555 };
556 state.catalogue = Some(catalogue);
557 }
558 if due {
559 self.spawn_catalogue_refresh();
560 }
561 }
562 Ok(None) => {
563 self.spawn_catalogue_refresh();
564 }
565 Err(error) => {
570 tracing::warn!(
571 event_name = "dictate_catalogue_cache_unreadable",
572 %error,
573 "the cached catalogue could not be read; fetching a fresh one"
574 );
575 self.spawn_catalogue_refresh();
576 }
577 },
578 }
579 }
580
581 pub(crate) fn check_dictate_catalogue(
584 self: &std::sync::Arc<Self>,
585 ) -> Result<(), crate::DispatchError> {
586 if !self.config_arc().dictate.enabled {
587 return Err(crate::DispatchError::DictateOff);
588 }
589 if !self.spawn_catalogue_refresh() {
590 return Err(crate::DispatchError::CatalogueChecking);
591 }
592 Ok(())
593 }
594
595 fn spawn_catalogue_refresh(self: &std::sync::Arc<Self>) -> bool {
601 {
602 let mut state = self.dictate_catalogue.lock();
603 if matches!(state.check, CatalogueCheck::Checking) {
604 return false;
605 }
606 state.check = CatalogueCheck::Checking;
607 }
608 let this = std::sync::Arc::clone(self);
609 tokio::spawn(async move {
610 let _ = this.fetch_catalogue_once().await;
611 let models = this.dictate_models();
612 let _ =
613 this.update_sender().send(crate::SessionUpdate::DictateModelsChanged { models });
614 });
615 true
616 }
617
618 pub(crate) async fn fetch_catalogue_once(
625 &self,
626 ) -> Result<forge_dictate::catalogue::Catalogue, forge_dictate::Error> {
627 let source = self.catalogue_source();
628 let cleanup = self.cleanup_source();
629 let fetched = tokio::task::spawn_blocking(move || {
630 let mut catalogue = forge_dictate::catalogue::fetch_catalogue(&source)?;
631 match forge_dictate::cleanup::fetch_cleanup(&cleanup) {
636 Ok(mut entries) => {
637 catalogue.entries.append(&mut entries);
638 Ok::<_, forge_dictate::Error>((catalogue, true))
639 }
640 Err(error) => {
641 tracing::warn!(
642 event_name = "dictate_cleanup_feed_failed",
643 %error,
644 "the cleanup feed could not be read; the candidates the last fetch left stand"
645 );
646 Ok((catalogue, false))
647 }
648 }
649 })
650 .await;
651 let outcome = fetched
652 .unwrap_or_else(|join| {
653 Err(forge_dictate::Error::Catalogue { message: join.to_string() })
654 })
655 .map(|(mut catalogue, cleanup_ok)| {
656 if !cleanup_ok {
657 let previous: Vec<forge_dictate::catalogue::CatalogueEntry> = self
658 .dictate_catalogue
659 .lock()
660 .catalogue
661 .as_ref()
662 .map(|held| {
663 held.entries
664 .iter()
665 .filter(|entry| {
666 entry.kind == forge_dictate::catalogue::EntryKind::Normalizer
667 })
668 .cloned()
669 .collect()
670 })
671 .unwrap_or_default();
672 catalogue.entries.splice(0..0, previous);
673 }
674 catalogue
675 });
676
677 match outcome {
678 Ok(catalogue) => {
679 if let Some(dir) = self.catalogue_dir()
680 && let Err(error) =
681 forge_dictate::catalogue::write_catalogue_cache(&dir, &catalogue)
682 {
683 tracing::warn!(
684 event_name = "dictate_catalogue_cache_write_failed",
685 %error,
686 "the fetched catalogue was not cached; the next boot fetches it again"
687 );
688 }
689 let mut state = self.dictate_catalogue.lock();
690 state.check = CatalogueCheck::Fresh {
691 at: catalogue.fetched_at.clone(),
692 release: catalogue.release.clone(),
693 skipped: catalogue.skipped,
694 };
695 state.catalogue = Some(catalogue.clone());
696 Ok(catalogue)
697 }
698 Err(error) => {
702 tracing::warn!(
703 event_name = "dictate_catalogue_check_failed",
704 %error,
705 "the catalogue check failed; the last-known feed stands"
706 );
707 self.dictate_catalogue.lock().check =
708 CatalogueCheck::Unreachable { error: error.to_string() };
709 Err(error)
710 }
711 }
712 }
713
714 pub(crate) fn catalogue_source(&self) -> forge_dictate::catalogue::CatalogueSource {
717 #[cfg(any(test, feature = "testing"))]
718 if let Some(source) = self.test_catalogue_source.lock().clone() {
719 return source;
720 }
721 forge_dictate::catalogue::CatalogueSource::default()
722 }
723
724 pub(crate) fn cleanup_source(&self) -> forge_dictate::cleanup::CleanupSource {
728 #[cfg(any(test, feature = "testing"))]
729 if let Some(source) = self.test_cleanup_source.lock().clone() {
730 return source;
731 }
732 #[cfg(any(test, feature = "testing"))]
733 if let Some(base) = self.test_catalogue_source.lock().clone() {
734 return forge_dictate::cleanup::CleanupSource {
735 listing: format!("{}cleanup?filter=", base.entry_base),
736 listing_tail: "&filter=gguf".to_owned(),
737 tags: vec!["text-normalization".to_owned()],
738 blobs_base: format!("{}blobs/", base.entry_base),
739 files_base: format!("{}files/", base.entry_base),
740 };
741 }
742 forge_dictate::cleanup::CleanupSource::default()
743 }
744
745 fn catalogue_dir(&self) -> Option<PathBuf> {
749 #[cfg(any(test, feature = "testing"))]
750 if let Some(dir) = self.test_catalogue_dir.lock().clone() {
751 return Some(dir);
752 }
753 if let Some(base) = &self.app_support {
754 Some(base.join("dictate-catalogue"))
755 } else {
756 tracing::warn!(
757 event_name = "dictate_catalogue_dir_unresolved",
758 "no app-support base was resolved at boot: the fetched catalogue is not cached"
759 );
760 None
761 }
762 }
763}
764
765#[cfg(test)]
766pub(crate) mod tests_catalogue_view {
767 use super::*;
768 use crate::dictate::{DictateModel, DictateModelState, DictateRole, DictateSnapshot};
769 use forge_dictate::catalogue::parse_entry;
770
771 fn entry(
775 variant: &str,
776 languages: &str,
777 speed: f64,
778 wer: f64,
779 ) -> forge_dictate::catalogue::CatalogueEntry {
780 entry_under(variant, languages, speed, wer, 100, "mit", "MIT")
781 }
782
783 pub(crate) fn entry_under(
788 variant: &str,
789 languages: &str,
790 speed: f64,
791 wer: f64,
792 size_bytes: u64,
793 spdx: &str,
794 display: &str,
795 ) -> forge_dictate::catalogue::CatalogueEntry {
796 parse_entry(&format!(
797 r#"{{
798 "schema": "transcribe-catalog-v1",
799 "variant": "{variant}",
800 "display_name": "{variant}",
801 "params": 1000000,
802 "license": {{"spdx": "{spdx}", "display": "{display}"}},
803 "languages": {languages},
804 "downloads": [
805 {{"quant": "F16", "filename": "{variant}-F16.gguf", "size_bytes": 200}},
806 {{"quant": "Q4_K_M", "filename": "{variant}-Q4_K_M.gguf", "size_bytes": {size_bytes}}}
807 ],
808 "speed_benchmarks": [
809 {{"machine": "m4-max", "backend": "metal", "quant": "Q8_0", "xrt_wall": {speed}}}
810 ],
811 "accuracy_benchmarks": [
812 {{"dataset": "fleurs", "split": "test", "language": "en", "quant": "Q8_0", "err_pct": {wer}}}
813 ]
814 }}"#
815 ))
816 .expect("the synthetic entry parses")
817 }
818
819 fn in_use_spec() -> forge_dictate::ModelSpec {
823 let mut spec = forge_dictate::ModelSpec::cohere_transcribe_q4_k_m();
824 spec.file = "in-use-Q4_K_M.gguf".to_owned();
825 spec.size = 100;
826 spec
827 }
828
829 fn active_pin(
832 role: DictateRole,
833 spec: forge_dictate::ModelSpec,
834 ) -> (DictateRole, crate::install::ActiveModel) {
835 (
836 role,
837 crate::install::ActiveModel {
838 role,
839 spec,
840 from: crate::install::ActiveFrom::Pin,
841 at: None,
842 },
843 )
844 }
845
846 #[test]
848 fn a_row_carries_the_facts_the_candidate_list_draws() {
849 let row = row_for(&entry("candidate", r#"["en", "fr"]"#, 388.8, 4.61));
850
851 assert_eq!(row.variant, "candidate");
852 assert_eq!(row.params, Some(1_000_000));
853 assert_eq!(row.license.as_deref(), Some("MIT"));
854 assert_eq!(row.languages, ["en", "fr"]);
855 assert_eq!(
856 row.download.as_ref().map(|download| (download.quant.as_str(), download.size_bytes)),
857 Some(("Q4_K_M", 100)),
858 "the candidate line's size is the Q4 build"
859 );
860 assert_eq!(row.speed.as_ref().map(|speed| speed.xrt_wall), Some(388.8));
861 assert_eq!(row.speed.as_ref().map(|speed| speed.backend.as_str()), Some("metal"));
862 assert_eq!(row.wer.as_ref().map(|wer| wer.err_pct), Some(4.61));
863 assert_eq!(row.wer.as_ref().map(|wer| wer.language.as_str()), Some("en"));
864 }
865
866 #[test]
869 fn the_rows_download_falls_back_when_no_q4_build_exists() {
870 let q8_only = parse_entry(
871 r#"{"schema":"transcribe-catalog-v1","variant":"q8",
872 "downloads":[{"quant":"F32","size_bytes":9},{"quant":"Q8_0","size_bytes":5}]}"#,
873 )
874 .expect("parse");
875 assert_eq!(
876 row_for(&q8_only).download.as_ref().map(|download| download.quant.as_str()),
877 Some("Q8_0"),
878 "without a Q4 build the reference quant chain is next"
879 );
880
881 let f32_only = parse_entry(
882 r#"{"schema":"transcribe-catalog-v1","variant":"f32",
883 "downloads":[{"quant":"F32","size_bytes":9}]}"#,
884 )
885 .expect("parse");
886 assert!(
887 row_for(&f32_only).download.is_none(),
888 "no reference quant means no size, not the F32 one"
889 );
890 }
891
892 #[test]
895 fn the_update_is_the_fastest_candidate_that_beats_the_model_in_use() {
896 let entries = vec![
901 entry("in-use", r#"["en"]"#, 72.9, 5.08),
902 entry("parakeet-like", r#"["en"]"#, 218.8, 3.99),
903 entry("granite-like", r#"["en"]"#, 388.8, 4.61),
904 entry("fast-but-blunt", r#"["en"]"#, 900.0, 5.50),
905 entry("faster-but-equal-wer", r#"["en"]"#, 500.0, 5.08),
906 entry("slower-but-sharper", r#"["en"]"#, 50.0, 1.20),
907 entry("russian", r#"["ru"]"#, 900.0, 0.90),
908 ];
909
910 let updates = updates_for(&entries, &[(DictateRole::Transcribing, in_use_spec())]);
911
912 assert_eq!(updates.len(), 1, "one comparison per model in use");
913 let update = &updates[0];
914 assert_eq!(update.file, "in-use-Q4_K_M.gguf");
915 assert_eq!(update.current.speed_x, 72.9, "the in-use side of the comparison");
916 assert_eq!(update.current.fleurs_en_wer, 5.08);
917
918 let table: Vec<(&str, UpdateVerdict)> = update
921 .candidates
922 .iter()
923 .map(|candidate| (candidate.row.variant.as_str(), candidate.verdict))
924 .collect();
925 assert_eq!(
926 table,
927 vec![
928 ("fast-but-blunt", UpdateVerdict::Blunter),
929 ("faster-but-equal-wer", UpdateVerdict::Blunter),
930 ("granite-like", UpdateVerdict::Recommended),
931 ("parakeet-like", UpdateVerdict::BeatsBoth),
932 ("slower-but-sharper", UpdateVerdict::Slower),
933 ],
934 "the ranked comparison the page tables: fastest first, the pick flagged where it \
935 lands, and every other row carrying the axis it lost on"
936 );
937 }
938
939 #[test]
941 fn of_two_equally_fast_candidates_the_sharper_wins() {
942 let entries = vec![
943 entry("in-use", r#"["en"]"#, 72.9, 5.08),
944 entry("twin-blunter", r#"["en"]"#, 300.0, 4.00),
945 entry("twin-sharper", r#"["en"]"#, 300.0, 3.50),
946 ];
947
948 let updates = updates_for(&entries, &[(DictateRole::Transcribing, in_use_spec())]);
949
950 assert_eq!(updates.len(), 1);
951 assert_eq!(
952 updates[0].candidates[0].row.variant, "twin-sharper",
953 "equal speed is broken by the lower word error rate, not by list order"
954 );
955 assert_eq!(updates[0].candidates[0].verdict, UpdateVerdict::Recommended);
956 }
957
958 #[test]
961 fn a_candidate_with_no_comparable_wer_is_not_a_candidate() {
962 let headline_only = parse_entry(
963 r#"{"schema":"transcribe-catalog-v1","variant":"headline-only","languages":["en"],
964 "headline_benchmark":{"dataset":"librispeech","split":"test-clean","language":"en"},
965 "downloads":[{"quant":"Q4_K_M","filename":"x.gguf","size_bytes":1}],
966 "speed_benchmarks":[{"machine":"m4-max","backend":"metal","quant":"Q8_0","xrt_wall":900.0}],
967 "accuracy_benchmarks":[{"dataset":"librispeech","split":"test-clean","language":"en","quant":"Q8_0","err_pct":1.2}]}"#,
968 )
969 .expect("parse");
970 let entries = vec![entry("in-use", r#"["en"]"#, 72.9, 5.08), headline_only];
971
972 let updates = updates_for(&entries, &[(DictateRole::Transcribing, in_use_spec())]);
973
974 assert_eq!(updates.len(), 1, "the baseline still stands");
975 assert!(
976 updates[0].candidates.is_empty(),
977 "no common axis, no comparison - and no row for it"
978 );
979 }
980
981 #[test]
986 fn a_non_commercial_candidate_is_recommended_with_its_licence_on_the_row() {
987 let entries = vec![
988 entry("in-use", r#"["en"]"#, 72.9, 5.08),
989 entry_under(
990 "faster-but-nc",
991 r#"["en"]"#,
992 401.64,
993 4.30,
994 100,
995 "cc-by-nc-sa-4.0",
996 "CC-BY-NC-SA-4.0",
997 ),
998 entry_under("permissive", r#"["en"]"#, 388.76, 4.61, 100, "apache-2.0", "Apache-2.0"),
999 ];
1000
1001 let updates = updates_for(&entries, &[(DictateRole::Transcribing, in_use_spec())]);
1002
1003 assert_eq!(updates.len(), 1);
1004 assert_eq!(
1005 updates[0].candidates[0].row.variant, "faster-but-nc",
1006 "the fastest entry that beats the model in use, licence and all"
1007 );
1008 assert_eq!(updates[0].candidates[0].verdict, UpdateVerdict::Recommended);
1009 assert_eq!(
1010 updates[0].candidates[0].row.license.as_deref(),
1011 Some("CC-BY-NC-SA-4.0"),
1012 "and the row states the licence the decision is read under"
1013 );
1014 assert_eq!(updates[0].candidates[1].row.variant, "permissive");
1015 assert_eq!(updates[0].candidates[1].verdict, UpdateVerdict::BeatsBoth);
1016 }
1017
1018 #[test]
1022 fn an_update_is_not_proposed_from_a_rebuilt_file() {
1023 let entries = vec![
1024 entry_under("in-use", r#"["en"]"#, 300.0, 4.00, 101, "mit", "MIT"),
1027 entry("candidate", r#"["en"]"#, 500.0, 2.00),
1030 ];
1031
1032 assert!(
1033 updates_for(&entries, &[(DictateRole::Transcribing, in_use_spec())]).is_empty(),
1034 "a same-name different-length download is a rebuild, and no comparison is built on it"
1035 );
1036 let join = join_for(&entries, &in_use_spec())
1037 .expect("the join still crosses, so a reader can see the length moved");
1038 assert_eq!(join.size_bytes, 101, "the feed's own length, against the pin's 100");
1039 }
1040
1041 #[test]
1044 fn the_model_in_use_is_never_its_own_candidate() {
1045 let entries = vec![
1046 entry("in-use", r#"["en"]"#, 72.9, 5.08),
1047 entry("in-use", r#"["en"]"#, 900.0, 1.00),
1048 ];
1049
1050 let updates = updates_for(&entries, &[(DictateRole::Transcribing, in_use_spec())]);
1051
1052 assert_eq!(updates.len(), 1, "the comparison still stands");
1053 assert!(
1054 updates[0].candidates.is_empty(),
1055 "and the variant in use is no row of it: {:?}",
1056 updates[0].candidates
1057 );
1058 }
1059
1060 #[test]
1063 fn in_use_facts_come_from_the_pin_the_state_and_the_join() {
1064 let entries = vec![entry_under("in-use", r#"["en", "fr"]"#, 72.9, 5.08, 200, "mit", "MIT")];
1067 let states = DictateSnapshot {
1068 models: vec![DictateModel {
1069 role: DictateRole::Transcribing,
1070 file: "in-use-Q4_K_M.gguf".to_owned(),
1071 state: DictateModelState::Ready,
1072 }],
1073 failure: None,
1074 };
1075
1076 let rows =
1077 in_use(&entries, &[active_pin(DictateRole::Transcribing, in_use_spec())], &states);
1078
1079 assert_eq!(rows.len(), 1);
1080 let row = &rows[0];
1081 assert_eq!(
1082 row.state,
1083 DictateModelState::Ready,
1084 "the page's loaded chip is the preflight snapshot's own state"
1085 );
1086 assert_eq!(row.facts.quant.as_deref(), Some("Q4_K_M"), "declared facts come off the pin");
1087 assert_eq!(row.size, 100, "the pin's own byte length");
1088 let join = row.catalogue.as_ref().expect("the file joins its catalogue entry");
1089 assert_eq!(join.variant, "in-use");
1090 assert_eq!(join.size_bytes, 200, "the feed's own size for the file, as a witness");
1091 assert_eq!(join.languages, ["en", "fr"]);
1092 assert_eq!(join.speed.as_ref().map(|speed| speed.xrt_wall), Some(72.9));
1093 }
1094
1095 #[test]
1097 fn a_model_in_no_catalogue_entry_still_crosses_whole() {
1098 let rows = in_use(
1099 &[],
1100 &[active_pin(DictateRole::Normalization, forge_dictate::ModelSpec::s1_mini_f16())],
1101 &DictateSnapshot::default(),
1102 );
1103
1104 assert_eq!(rows.len(), 1);
1105 assert!(rows[0].catalogue.is_none(), "the normalizer is in no feed; the pin carries it");
1106 assert_eq!(
1107 rows[0].state,
1108 DictateModelState::Pending,
1109 "no preflight row yet is pending, not missing"
1110 );
1111 }
1112
1113 #[test]
1116 fn a_feed_older_than_a_day_is_due_for_a_refresh() {
1117 let now = std::time::SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(1_791_244_800);
1118 let stamp = |age: std::time::Duration| {
1119 let at = now - age;
1120 time::OffsetDateTime::from(at)
1121 .format(&time::format_description::well_known::Rfc3339)
1122 .expect("a stamp formats")
1123 };
1124 let feed = |fetched_at: String| forge_dictate::catalogue::Catalogue {
1125 fetched_at,
1126 release: None,
1127 entries: Vec::new(),
1128 skipped: 0,
1129 };
1130
1131 assert!(refresh_is_due(None, now), "a machine that never fetched is due");
1132 assert!(
1133 !refresh_is_due(Some(&feed(stamp(std::time::Duration::from_secs(3600)))), now),
1134 "an hour old is not due"
1135 );
1136 assert!(
1137 refresh_is_due(Some(&feed(stamp(std::time::Duration::from_secs(25 * 3600)))), now),
1138 "past a day is due"
1139 );
1140 assert!(
1141 refresh_is_due(Some(&feed("whenever".to_owned())), now),
1142 "a stamp this build cannot read is due, not trusted"
1143 );
1144 }
1145
1146 #[test]
1148 fn the_check_crosses_under_the_names_the_client_narrows() {
1149 for (check, name) in [
1150 (CatalogueCheck::Never, "never"),
1151 (CatalogueCheck::Checking, "checking"),
1152 (CatalogueCheck::Unreachable { error: "502".to_owned() }, "unreachable"),
1153 ] {
1154 let value = serde_json::to_value(&check).expect("serialise");
1155 assert_eq!(value["state"], name, "the client narrows on this tag: {value}");
1156 }
1157
1158 let fresh = CatalogueCheck::Fresh {
1159 at: "2026-10-06T06:12:00Z".to_owned(),
1160 release: Some("v0.3.1".to_owned()),
1161 skipped: 2,
1162 };
1163 let value = serde_json::to_value(&fresh).expect("serialise");
1164 assert_eq!(value["state"], "fresh");
1165 assert_eq!(value["release"], "v0.3.1");
1166 assert_eq!(value["skipped"], 2);
1167 }
1168
1169 use crate::{Command, DispatchError, SessionUpdate, Workspace};
1172 use forge_dictate::catalogue::{
1173 Catalogue, CatalogueSource, read_catalogue_cache, write_catalogue_cache,
1174 };
1175 use std::sync::Arc;
1176
1177 pub(crate) fn enabled_stub()
1183 -> (Arc<Workspace>, tokio::sync::mpsc::UnboundedReceiver<crate::SeatUpdate>, tempfile::TempDir)
1184 {
1185 let config_dir = tempfile::tempdir().expect("a config dir");
1186 let models = tempfile::tempdir().expect("a models dir");
1187 let mut config = crate::config::LoadedConfig::empty_for_test();
1188 config.dictate.enabled = true;
1189 config.dictate.models_dir = Some(models.path().to_string_lossy().into_owned());
1190 let (ws, updates) =
1191 Workspace::testing_stub_with_config(config_dir.path().to_path_buf(), config)
1192 .expect("the stub builds");
1193 (ws, updates, models)
1194 }
1195
1196 pub(crate) fn serve(routes: Vec<(&'static str, u16, Vec<u8>)>) -> String {
1199 serve_with(|_| {
1200 routes
1201 .into_iter()
1202 .map(|(route, status, body)| (route.to_owned(), status, body))
1203 .collect()
1204 })
1205 }
1206
1207 pub(crate) fn serve_with(
1211 routes_for: impl FnOnce(&str) -> Vec<(String, u16, Vec<u8>)>,
1212 ) -> String {
1213 use std::io::{BufRead, BufReader, Write};
1214 let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("a loopback port");
1215 let base = format!("http://{}", listener.local_addr().expect("the bound address"));
1216 let routes = routes_for(&base);
1217 std::thread::spawn(move || {
1218 for stream in listener.incoming() {
1219 let Ok(mut stream) = stream else { break };
1220 let mut reader = BufReader::new(stream.try_clone().expect("a clone"));
1221 let mut request = String::new();
1222 if reader.read_line(&mut request).is_err() {
1223 continue;
1224 }
1225 let path = request.split_whitespace().nth(1).unwrap_or("/").to_string();
1226 loop {
1227 let mut line = String::new();
1228 if reader.read_line(&mut line).unwrap_or(0) == 0 || line == "\r\n" {
1229 break;
1230 }
1231 }
1232 let (status, body) = match routes.iter().find(|(route, _, _)| route == &path) {
1233 Some((_, status, body)) => (*status, body.clone()),
1234 None => (404, Vec::new()),
1235 };
1236 let head = format!(
1237 "HTTP/1.1 {status} X\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
1238 body.len()
1239 );
1240 let _ = stream.write_all(head.as_bytes());
1241 let _ = stream.write_all(&body);
1242 let _ = stream.flush();
1243 }
1244 });
1245 base
1246 }
1247
1248 pub(crate) fn source(base: &str) -> CatalogueSource {
1249 CatalogueSource {
1250 listing: format!("{base}/catalog"),
1251 entry_base: format!("{base}/catalog/"),
1252 release: format!("{base}/release"),
1253 doc_base: format!("{base}/docs/"),
1254 repo_base: format!("{base}/repos/"),
1255 }
1256 }
1257
1258 pub(crate) fn feed_entry() -> Vec<u8> {
1260 br#"{"schema": "transcribe-catalog-v1", "variant": "one", "languages": ["en"],
1261 "downloads": [{"quant": "Q4_K_M", "filename": "one-Q4_K_M.gguf", "size_bytes": 100}],
1262 "speed_benchmarks": [{"machine": "m4-max", "backend": "metal", "quant": "Q8_0", "xrt_wall": 100.0}],
1263 "accuracy_benchmarks": [{"dataset": "fleurs", "split": "test", "language": "en", "quant": "Q8_0", "err_pct": 4.0}]}"#
1264 .to_vec()
1265 }
1266
1267 pub(crate) fn listing() -> Vec<u8> {
1268 br#"[{"name": "one.json", "type": "file"}]"#.to_vec()
1269 }
1270
1271 #[test]
1274 fn the_read_answers_the_pins_before_any_catalogue() {
1275 let (ws, _updates, _models) = enabled_stub();
1276
1277 let view = ws.dictate_models();
1278
1279 assert!(view.enabled);
1280 assert_eq!(view.in_use.len(), 2, "both pinned models cross");
1281 assert_eq!(
1282 view.in_use[0].state,
1283 crate::dictate::DictateModelState::Pending,
1284 "preflight has not got there yet"
1285 );
1286 assert!(view.in_use[0].catalogue.is_none(), "no feed, no join");
1287 assert!(matches!(view.check, CatalogueCheck::Never));
1288 assert!(view.rows.is_empty(), "no fetched feed is no rows");
1289 assert!(view.updates.is_empty(), "and no update to propose");
1290 assert_eq!(
1291 view.bench,
1292 crate::bench::BenchState::Idle,
1293 "no bench is running on a fresh read"
1294 );
1295 assert!(view.results.is_empty(), "and nothing has been measured yet");
1296 }
1297
1298 #[test]
1303 fn a_disabled_dictation_reads_no_models_and_no_proposals() {
1304 let (ws, _updates) = Workspace::testing_stub();
1305 ws.dictate_catalogue.lock().catalogue = Some(Catalogue {
1310 fetched_at: "2026-10-06T00:00:00Z".to_owned(),
1311 release: None,
1312 entries: vec![
1313 entry_under(
1314 "cohere-transcribe-03-2026",
1315 r#"["en"]"#,
1316 72.9,
1317 5.08,
1318 1_558_162_944,
1319 "apache-2.0",
1320 "Apache-2.0",
1321 ),
1322 entry("better", r#"["en"]"#, 500.0, 4.00),
1323 ],
1324 skipped: 0,
1325 });
1326
1327 let view = ws.dictate_models();
1328
1329 assert!(!view.enabled);
1330 assert!(view.in_use.is_empty(), "no pins are in use when dictation is off");
1331 assert!(view.updates.is_empty(), "and nothing is proposed against them");
1332 assert_eq!(view.rows.len(), 2, "the feed's rows still cross");
1333 }
1334
1335 #[test]
1339 fn the_cache_dir_threads_the_workspaces_app_support_base() {
1340 let config_dir = tempfile::tempdir().expect("a config dir");
1341 let models = tempfile::tempdir().expect("a models dir");
1342 let mut config = crate::config::LoadedConfig::empty_for_test();
1343 config.dictate.enabled = true;
1344 config.dictate.models_dir = Some(models.path().to_string_lossy().into_owned());
1345 let (ws, _updates) =
1346 Workspace::testing_stub_with_config(config_dir.path().to_path_buf(), config)
1347 .expect("the stub builds");
1348
1349 assert_eq!(
1350 ws.catalogue_dir(),
1351 Some(config_dir.path().join("app-support").join("dictate-catalogue")),
1352 "the cache dir is derived from the workspace's injected app-support base"
1353 );
1354 assert_ne!(
1355 ws.catalogue_dir(),
1356 forge_sdk::app_support_dir().ok().map(|dir| dir.join("dictate-catalogue")),
1357 "and never from the machine's real app-support dir"
1358 );
1359 }
1360
1361 #[test]
1364 fn a_boot_reads_a_fresh_cache_and_does_not_fetch() {
1365 let (ws, _updates, _models) = enabled_stub();
1366 let dir = tempfile::tempdir().expect("a cache dir");
1367 *ws.test_catalogue_dir.lock() = Some(dir.path().to_path_buf());
1368 let now = std::time::SystemTime::now();
1369 let cached = Catalogue {
1370 fetched_at: time::OffsetDateTime::from(now)
1371 .format(&time::format_description::well_known::Rfc3339)
1372 .expect("a stamp"),
1373 release: Some("v0.3.1".to_owned()),
1374 entries: vec![
1375 parse_entry(&String::from_utf8(feed_entry()).expect("utf-8")).expect("parse"),
1376 ],
1377 skipped: 0,
1378 };
1379 write_catalogue_cache(dir.path(), &cached).expect("the cache writes");
1380
1381 ws.start_dictate_catalogue();
1382
1383 let view = ws.dictate_models();
1384 assert_eq!(view.rows.len(), 1, "the cached rows read before any fetch lands");
1385 assert!(
1386 matches!(&view.check, CatalogueCheck::Fresh { release, .. } if release.as_deref() == Some("v0.3.1")),
1387 "the freshness line is the last check that fetched this cache, got {:?}",
1388 view.check
1389 );
1390 }
1391
1392 #[tokio::test]
1395 async fn a_check_lands_the_feed_and_pushes_the_view() {
1396 let (ws, mut updates, _models) = enabled_stub();
1397 let dir = tempfile::tempdir().expect("a cache dir");
1398 *ws.test_catalogue_dir.lock() = Some(dir.path().to_path_buf());
1399 let base = serve(vec![
1400 ("/catalog", 200, listing()),
1401 ("/catalog/one.json", 200, feed_entry()),
1402 ("/release", 200, br#"{"tag_name": "v0.3.1"}"#.to_vec()),
1403 ]);
1404 *ws.test_catalogue_source.lock() = Some(source(&base));
1405
1406 ws.dispatch(Command::DictateCatalogueCheck).expect("the check dispatches");
1407
1408 let landed = tokio::time::timeout(std::time::Duration::from_secs(15), async {
1409 loop {
1410 match updates
1411 .recv()
1412 .await
1413 .map(|frame| std::sync::Arc::unwrap_or_clone(frame.update))
1414 {
1415 Some(SessionUpdate::DictateModelsChanged { models }) => break models,
1416 Some(_) => {}
1417 None => panic!("the subscription must stay attached"),
1418 }
1419 }
1420 })
1421 .await
1422 .expect("the check must land inside fifteen seconds");
1423
1424 let CatalogueCheck::Fresh { release, skipped, .. } = &landed.check else {
1425 panic!("a landed check is fresh, got {:?}", landed.check)
1426 };
1427 assert_eq!(release.as_deref(), Some("v0.3.1"));
1428 assert_eq!(*skipped, 0);
1429 assert_eq!(landed.rows.len(), 1, "the pushed view carries the feed's rows");
1430 assert_eq!(landed.in_use.len(), 2, "and the pins beside them");
1431 assert_eq!(
1432 read_catalogue_cache(dir.path()).expect("the cache reads").map(|c| c.entries.len()),
1433 Some(1),
1434 "the fetched feed is cached for the next boot"
1435 );
1436
1437 let view = ws.dictate_models();
1438 assert!(
1439 matches!(&view.check, CatalogueCheck::Fresh { .. }),
1440 "the state a later reader gets agrees with the pushed one"
1441 );
1442 }
1443
1444 #[tokio::test]
1449 async fn a_check_lands_the_cleanup_feed_too() {
1450 let (ws, _updates, _models) = enabled_stub();
1451 let cleanup = br#"[{"id": "owner/norm-a", "downloads": 5000,
1452 "pipeline_tag": "text-generation", "library_name": "gguf",
1453 "tags": ["gguf", "text-generation", "text-normalization", "en",
1454 "base_model:owner/norm-base"]}]"#;
1455 let blobs = br#"{"siblings": [{"rfilename": "norm-a-Q4_K_M.gguf", "size": 300000000,
1456 "lfs": {"sha256": "abababab"}}],
1457 "gguf": {"architecture": "qwen2"}}"#;
1458 let base = serve(vec![
1459 ("/catalog", 200, listing()),
1460 ("/catalog/one.json", 200, feed_entry()),
1461 ("/release", 200, br#"{"tag_name": "v0.3.1"}"#.to_vec()),
1462 ("/catalog/cleanup?filter=text-normalization&filter=gguf", 200, cleanup.to_vec()),
1463 ("/catalog/blobs/owner/norm-a?blobs=true", 200, blobs.to_vec()),
1464 ]);
1465 *ws.test_catalogue_source.lock() = Some(source(&base));
1466
1467 let landed = ws.fetch_catalogue_once().await.expect("both feeds land");
1468 let cleanup_rows: Vec<_> = landed
1469 .entries
1470 .iter()
1471 .filter(|entry| entry.kind == forge_dictate::catalogue::EntryKind::Normalizer)
1472 .collect();
1473 assert_eq!(cleanup_rows.len(), 1, "the cleanup listing lands its row");
1474 let row = cleanup_rows[0];
1475 assert_eq!(row.variant, "owner/norm-a");
1476 assert_eq!(row.downloads.len(), 1, "the blobs answer the file");
1477 assert_eq!(row.downloads[0].quant, "Q4_K_M");
1478 assert_eq!(row.downloads[0].size_bytes, 300_000_000);
1479 assert_eq!(
1480 row.downloads[0].sha256.as_deref(),
1481 Some("abababab"),
1482 "the blobs' digest rides the download, which is what an install verifies"
1483 );
1484 assert!(
1485 row.params.is_none(),
1486 "a Hub entry counts no parameters; the row must not invent one"
1487 );
1488
1489 assert!(
1492 landed
1493 .entries
1494 .iter()
1495 .any(|entry| entry.kind == forge_dictate::catalogue::EntryKind::Asr),
1496 "the speech feed's rows land in the same read"
1497 );
1498 }
1499
1500 #[test]
1503 fn a_check_while_one_is_running_is_refused() {
1504 let (ws, _updates, _models) = enabled_stub();
1505 ws.dictate_catalogue.lock().check = CatalogueCheck::Checking;
1506
1507 let err = ws
1508 .dispatch(Command::DictateCatalogueCheck)
1509 .expect_err("a second check must be refused");
1510
1511 assert!(matches!(err, DispatchError::CatalogueChecking), "got: {err:?}");
1512 }
1513
1514 #[test]
1516 fn a_check_with_dictation_off_is_refused() {
1517 let (ws, _updates) = Workspace::testing_stub();
1518
1519 let err =
1520 ws.dispatch(Command::DictateCatalogueCheck).expect_err("no pinned models, no check");
1521
1522 assert!(matches!(err, DispatchError::DictateOff), "got: {err:?}");
1523 }
1524}