Coverage Report

Created: 2026-09-05 23:00

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