Coverage Report

Created: 2026-07-21 00:46

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