Coverage Report

Created: 2026-10-23 22:23

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
super-stt-app/src/ui/views/models/status.rs
Line
Count
Source
1
// SPDX-License-Identifier: GPL-3.0-only
2
use crate::core::app::AppModel;
3
use crate::daemon::backends::BackendInfo;
4
5
/// The required secrets / options that the user has not yet set for this
6
/// backend, by human-readable label. Returned in declaration order so the
7
/// inline warnings are stable across renders. Drives both the inline
8
/// "{label} must be set." rows and whether the Select button is enabled.
9
///
10
/// Takes the daemon-reported "is this secret configured?" map directly rather
11
/// than the whole [`AppModel`] so the rule stays pure and unit-testable.
12
12
pub(super) fn unmet_requirements<'a>(
13
12
    secret_configured: &std::collections::HashMap<(String, String), bool>,
14
12
    backend: &'a BackendInfo,
15
12
) -> Vec<&'a str> {
16
12
    let mut out = Vec::new();
17
12
    for 
secret10
in &backend.secrets {
18
10
        if !secret.required {
19
1
            continue;
20
9
        }
21
9
        let configured = secret_configured
22
9
            .get(&(backend.source.clone(), secret.name.clone()))
23
9
            .copied()
24
9
            .unwrap_or(false);
25
9
        if !configured {
26
5
            out.push(secret.label.as_deref().unwrap_or(&secret.name));
27
5
        
}4
28
    }
29
12
    for 
option4
in &backend.options {
30
4
        if !option.required {
31
0
            continue;
32
4
        }
33
4
        let value = option.value.as_deref().unwrap_or("").trim();
34
4
        if value.is_empty() {
35
3
            out.push(option.label.as_deref().unwrap_or(&option.name));
36
3
        
}1
37
    }
38
12
    out
39
12
}
40
41
#[cfg(test)]
42
mod unmet_requirements_tests {
43
    //! Pin the rule for which secrets/options gate the per-backend Select
44
    //! button: only `required` ones, the daemon-reported configured map decides
45
    //! per-secret, and the human-readable `label` (not the wire `name`) is what
46
    //! surfaces.
47
    use super::*;
48
    use crate::daemon::backends::{BackendInfo, BackendModel, BackendOption, BackendSecret};
49
    use std::collections::HashMap;
50
51
8
    fn backend(secrets: Vec<BackendSecret>, options: Vec<BackendOption>) -> BackendInfo {
52
8
        BackendInfo {
53
8
            source: "github.com/super-stt/openai".to_string(),
54
8
            description: String::new(),
55
8
            name: "OpenAI".to_string(),
56
8
            version: "1.0.0".to_string(),
57
8
            kind: "wasm".to_string(),
58
8
            allowed_hosts: Vec::new(),
59
8
            installed_accel: Vec::new(),
60
8
            models: vec![BackendModel {
61
8
                name: "whisper-1".to_string(),
62
8
                provider: String::new(),
63
8
                supported_devices: vec!["none".to_string()],
64
8
                estimated_vram_bytes: 0,
65
8
                multilingual: false,
66
8
                supported_languages: Vec::new(),
67
8
                primary_language: String::new(),
68
8
                realtime: false,
69
8
                role: "transcription".into(),
70
8
            }],
71
8
            secrets,
72
8
            options,
73
8
            accepts_context: false,
74
8
        }
75
8
    }
76
77
6
    fn secret(name: &str, label: Option<&str>, required: bool) -> BackendSecret {
78
6
        BackendSecret {
79
6
            name: name.to_string(),
80
6
            label: label.map(str::to_string),
81
6
            description: String::new(),
82
6
            required,
83
6
        }
84
6
    }
85
86
4
    fn option_value(
87
4
        name: &str,
88
4
        label: Option<&str>,
89
4
        required: bool,
90
4
        value: Option<&str>,
91
4
    ) -> BackendOption {
92
4
        BackendOption {
93
4
            name: name.to_string(),
94
4
            label: label.map(str::to_string),
95
4
            description: String::new(),
96
4
            r#type: None,
97
4
            default: None,
98
4
            choices: Vec::new(),
99
4
            min: None,
100
4
            max: None,
101
4
            step: None,
102
4
            required,
103
4
            value: value.map(str::to_string),
104
4
        }
105
4
    }
106
107
    /// A required, unconfigured secret with a label surfaces with the label
108
    /// (not the `snake_case` wire name).
109
    #[test]
110
1
    fn required_secret_unconfigured_surfaces_label() {
111
1
        let bi = backend(
112
1
            vec![secret("openai_api_key", Some("OpenAI API key"), true)],
113
1
            Vec::new(),
114
        );
115
1
        let map: HashMap<(String, String), bool> = HashMap::new();
116
117
1
        let missing = unmet_requirements(&map, &bi);
118
1
        assert_eq!(missing, vec!["OpenAI API key"]);
119
1
    }
120
121
    /// A required secret with no label falls back to its `name`. The UI is
122
    /// then no worse than today but no better — every real backend should
123
    /// supply a label.
124
    #[test]
125
1
    fn required_secret_without_label_falls_back_to_name() {
126
1
        let bi = backend(vec![secret("openai_api_key", None, true)], Vec::new());
127
1
        let map: HashMap<(String, String), bool> = HashMap::new();
128
129
1
        let missing = unmet_requirements(&map, &bi);
130
1
        assert_eq!(missing, vec!["openai_api_key"]);
131
1
    }
132
133
    /// A required secret that's marked configured in the daemon-reported map is
134
    /// not surfaced as unmet — Select must be enabled.
135
    #[test]
136
1
    fn configured_secret_is_not_unmet() {
137
1
        let bi = backend(
138
1
            vec![secret("openai_api_key", Some("OpenAI API key"), true)],
139
1
            Vec::new(),
140
        );
141
1
        let mut map = HashMap::new();
142
1
        map.insert((bi.source.clone(), "openai_api_key".to_string()), true);
143
144
1
        assert!(unmet_requirements(&map, &bi).is_empty());
145
1
    }
146
147
    /// A *non*-required secret never surfaces, configured or not — the daemon
148
    /// doesn't need it for a load, so it doesn't gate the Select button.
149
    #[test]
150
1
    fn non_required_secret_never_surfaces() {
151
1
        let bi = backend(
152
1
            vec![secret("openai_org", Some("OpenAI Org"), false)],
153
1
            Vec::new(),
154
        );
155
1
        let map = HashMap::new();
156
157
1
        assert!(unmet_requirements(&map, &bi).is_empty());
158
1
    }
159
160
    /// A required option with no effective value surfaces (its `label`); one
161
    /// with a value (including a manifest default) does not.
162
    #[test]
163
1
    fn required_option_value_gating() {
164
1
        let bi = backend(
165
1
            Vec::new(),
166
1
            vec![option_value("base_url", Some("Base URL"), true, None)],
167
        );
168
1
        let map = HashMap::new();
169
1
        assert_eq!(unmet_requirements(&map, &bi), vec!["Base URL"]);
170
171
1
        let bi_with_value = backend(
172
1
            Vec::new(),
173
1
            vec![option_value(
174
1
                "base_url",
175
1
                Some("Base URL"),
176
                true,
177
1
                Some("https://api.openai.com"),
178
            )],
179
        );
180
1
        assert!(unmet_requirements(&map, &bi_with_value).is_empty());
181
1
    }
182
183
    /// A whitespace-only value is treated as empty — `value = "   "` does not
184
    /// satisfy a `required` option.
185
    #[test]
186
1
    fn required_option_whitespace_is_empty() {
187
1
        let bi = backend(
188
1
            Vec::new(),
189
1
            vec![option_value(
190
1
                "base_url",
191
1
                Some("Base URL"),
192
                true,
193
1
                Some("   "),
194
            )],
195
        );
196
1
        let map = HashMap::new();
197
1
        assert_eq!(unmet_requirements(&map, &bi), vec!["Base URL"]);
198
1
    }
199
200
    /// Multiple unmet requirements are returned in declaration order
201
    /// (secrets first, then options) — keeps the inline warnings stable
202
    /// across renders rather than depending on `HashMap` iteration order.
203
    #[test]
204
1
    fn returns_in_declaration_order() {
205
1
        let bi = backend(
206
1
            vec![
207
1
                secret("alpha_key", Some("Alpha key"), true),
208
1
                secret("beta_key", Some("Beta key"), true),
209
            ],
210
1
            vec![option_value("base_url", Some("Base URL"), true, None)],
211
        );
212
1
        let map = HashMap::new();
213
1
        assert_eq!(
214
1
            unmet_requirements(&map, &bi),
215
1
            vec!["Alpha key", "Beta key", "Base URL"]
216
        );
217
1
    }
218
}
219
220
/// Readiness of the Models page, surfaced as the 4-state status dot in the
221
/// title row. Strictly ordered from "least ready" to "ready":
222
/// [`None`](ModelStatus::None) → [`Blocked`](ModelStatus::Blocked) →
223
/// [`Idle`](ModelStatus::Idle) → [`Ready`](ModelStatus::Ready).
224
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
225
pub(super) enum ModelStatus {
226
    /// No backend is selected.
227
    None,
228
    /// A backend is selected but at least one required secret/option is unset.
229
    Blocked,
230
    /// Backend selected and fully configured, but no model is loaded yet.
231
    Idle,
232
    /// A model is loaded and ready.
233
    Ready,
234
}
235
236
/// Compute the [`ModelStatus`] for the current app state — the same rule
237
/// that drives the in-card warning rows is also what flips the dot's color.
238
0
pub(super) fn model_status(app: &AppModel) -> ModelStatus {
239
0
    classify_model_status(
240
0
        app.models_page.active_backend.as_deref(),
241
0
        &app.backends,
242
0
        &app.backend_secret_configured,
243
0
        &app.current_model,
244
0
        &app.current_source,
245
    )
246
0
}
247
248
/// Pure implementation of [`model_status`] — takes only the inputs the rule
249
/// depends on so it's directly unit-testable without building an
250
/// [`AppModel`]. Two arms surface as [`ModelStatus::None`]: there's no
251
/// active backend at all, OR the daemon reports one but its catalog entry
252
/// is gone (uninstalled while running). Both are "no backend" from the
253
/// user's perspective.
254
6
pub(super) fn classify_model_status(
255
6
    active_backend: Option<&str>,
256
6
    backends: &[BackendInfo],
257
6
    secret_configured: &std::collections::HashMap<(String, String), bool>,
258
6
    current_model: &str,
259
6
    current_source: &str,
260
6
) -> ModelStatus {
261
6
    let Some(
active_source5
) = active_backend else {
262
1
        return ModelStatus::None;
263
    };
264
5
    let Some(
backend4
) = backends.iter().find(|b|
b.source.as_str()4
==
active_source4
) else {
265
1
        return ModelStatus::None;
266
    };
267
4
    if !unmet_requirements(secret_configured, backend).is_empty() {
268
1
        return ModelStatus::Blocked;
269
3
    }
270
3
    let model_loaded = !current_model.is_empty() && 
current_source == active_source2
;
271
3
    if model_loaded {
272
1
        ModelStatus::Ready
273
    } else {
274
2
        ModelStatus::Idle
275
    }
276
6
}
277
278
#[cfg(test)]
279
mod model_status_tests {
280
    //! Pin the 4-state status-dot rule that lives in the page header. The
281
    //! states are: no backend selected → gray; backend selected but
282
    //! requirements unmet → red; ready but no model loaded → yellow; model
283
    //! loaded → green.
284
    use super::*;
285
    use crate::daemon::backends::{BackendInfo, BackendModel, BackendSecret};
286
    use std::collections::HashMap;
287
288
5
    fn backend_with_required_secret() -> BackendInfo {
289
5
        BackendInfo {
290
5
            source: "github.com/super-stt/openai".to_string(),
291
5
            description: String::new(),
292
5
            name: "OpenAI".to_string(),
293
5
            version: "1.0.0".to_string(),
294
5
            kind: "wasm".to_string(),
295
5
            allowed_hosts: Vec::new(),
296
5
            installed_accel: Vec::new(),
297
5
            models: vec![BackendModel {
298
5
                name: "whisper-1".to_string(),
299
5
                provider: String::new(),
300
5
                supported_devices: vec!["none".to_string()],
301
5
                estimated_vram_bytes: 0,
302
5
                multilingual: false,
303
5
                supported_languages: Vec::new(),
304
5
                primary_language: String::new(),
305
5
                realtime: false,
306
5
                role: "transcription".into(),
307
5
            }],
308
5
            secrets: vec![BackendSecret {
309
5
                name: "openai_api_key".to_string(),
310
5
                label: Some("OpenAI API key".to_string()),
311
5
                description: String::new(),
312
5
                required: true,
313
5
            }],
314
5
            options: Vec::new(),
315
5
            accepts_context: false,
316
5
        }
317
5
    }
318
319
    /// No active backend → gray dot regardless of what else is in state.
320
    #[test]
321
1
    fn no_active_backend_is_none() {
322
1
        let backends = vec![backend_with_required_secret()];
323
1
        let map = HashMap::new();
324
1
        assert_eq!(
325
1
            classify_model_status(None, &backends, &map, "", ""),
326
            ModelStatus::None,
327
        );
328
1
    }
329
330
    /// Active backend whose catalog entry is gone (e.g. uninstalled while
331
    /// running) still reads as "no backend" — the daemon's state is stale,
332
    /// and the dot shouldn't lie about readiness.
333
    #[test]
334
1
    fn active_backend_missing_from_catalog_is_none() {
335
1
        let map = HashMap::new();
336
1
        assert_eq!(
337
1
            classify_model_status(Some("github.com/super-stt/openai"), &[], &map, "", "",),
338
            ModelStatus::None,
339
        );
340
1
    }
341
342
    /// Active backend with an unmet required secret → red, even if a model
343
    /// from another backend happens to be loaded (which shouldn't really
344
    /// happen after `set_active_backend` unloads on switch, but the dot
345
    /// should still reflect the current backend's state).
346
    #[test]
347
1
    fn unmet_requirement_is_blocked() {
348
1
        let backends = vec![backend_with_required_secret()];
349
1
        let map = HashMap::new();
350
1
        assert_eq!(
351
1
            classify_model_status(Some("github.com/super-stt/openai"), &backends, &map, "", "",),
352
            ModelStatus::Blocked,
353
        );
354
1
    }
355
356
    /// All requirements satisfied but no model loaded for the active backend
357
    /// → yellow.
358
    #[test]
359
1
    fn requirements_met_no_model_is_idle() {
360
1
        let backends = vec![backend_with_required_secret()];
361
1
        let mut map = HashMap::new();
362
1
        map.insert(
363
1
            (backends[0].source.clone(), "openai_api_key".to_string()),
364
            true,
365
        );
366
1
        assert_eq!(
367
1
            classify_model_status(Some("github.com/super-stt/openai"), &backends, &map, "", "",),
368
            ModelStatus::Idle,
369
        );
370
1
    }
371
372
    /// A loaded model from a *different* source than the active backend is
373
    /// not "this backend ready" — the dot stays yellow until the user picks
374
    /// a model from the active backend.
375
    #[test]
376
1
    fn loaded_model_from_other_source_is_idle() {
377
1
        let backends = vec![backend_with_required_secret()];
378
1
        let mut map = HashMap::new();
379
1
        map.insert(
380
1
            (backends[0].source.clone(), "openai_api_key".to_string()),
381
            true,
382
        );
383
1
        assert_eq!(
384
1
            classify_model_status(
385
1
                Some("github.com/super-stt/openai"),
386
1
                &backends,
387
1
                &map,
388
1
                "voxtral-mini-latest",
389
1
                "github.com/super-stt/mistral",
390
            ),
391
            ModelStatus::Idle,
392
        );
393
1
    }
394
395
    /// Requirements met *and* a model from this backend is loaded → green.
396
    #[test]
397
1
    fn loaded_model_from_active_backend_is_ready() {
398
1
        let backends = vec![backend_with_required_secret()];
399
1
        let mut map = HashMap::new();
400
1
        map.insert(
401
1
            (backends[0].source.clone(), "openai_api_key".to_string()),
402
            true,
403
        );
404
1
        assert_eq!(
405
1
            classify_model_status(
406
1
                Some("github.com/super-stt/openai"),
407
1
                &backends,
408
1
                &map,
409
1
                "whisper-1",
410
1
                "github.com/super-stt/openai",
411
            ),
412
            ModelStatus::Ready,
413
        );
414
1
    }
415
}