Coverage Report

Created: 2026-09-05 23:00

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
super-stt-app/src/state/language.rs
Line
Count
Source
1
// SPDX-License-Identifier: GPL-3.0-only
2
3
//! Transcription-language UI state, extracted from `AppModel` following the
4
//! `RegistryState` template (App Tier 3 #15).
5
6
use crate::state::LanguageResolution;
7
8
/// The resolution blocks the daemon has answered for, keyed by
9
/// `(source, model)` — the blocks behind
10
/// `GET /pipeline/{stage}/model/{model}/language`.
11
///
12
/// A single slot held one block while only the transcription card had a
13
/// language control. Both pipeline stages have one now, and a stage-1 fetch
14
/// would overwrite the block stage 2 is rendering (and the other way round),
15
/// so answers are keyed by the pair they describe — the same shape, and for
16
/// the same reason, as [`crate::state::device_offers::DeviceOffers`].
17
///
18
/// A miss means "not asked yet, or the answer hasn't landed", which is what
19
/// makes a stale answer harmless: a lookup for a pair simply misses until its
20
/// own answer arrives.
21
#[derive(Debug, Clone, Default)]
22
pub struct ModelLanguages {
23
    blocks: Vec<((u32, String, String), LanguageResolution)>,
24
    /// The tags each answered-for model can be pinned to — what
25
    /// `GET /pipeline/{stage}/model/{model}/language/list` returns.
26
    ///
27
    /// Held beside the blocks rather than inside them because the two arrive
28
    /// from different requests and have different lifetimes: the block changes
29
    /// every time the user picks a language, the offer never changes for a
30
    /// given model. A single record would make every pick re-fetch a list that
31
    /// cannot have moved.
32
    offers: Vec<((u32, String, String), Vec<String>)>,
33
}
34
35
impl ModelLanguages {
36
    /// The block for `(stage, source, model)`, or `None` if the daemon hasn't
37
    /// answered for it.
38
    #[must_use]
39
6
    pub fn get(&self, stage: u32, source: &str, model: &str) -> Option<&LanguageResolution> {
40
6
        self.blocks
41
6
            .iter()
42
7
            .
find6
(|((st, s, m), _)| *st == stage &&
s == source5
&&
m == model5
)
43
6
            .map(|(_, block)| block)
44
6
    }
45
46
    /// Record an answer, replacing any it supersedes.
47
6
    pub fn record(&mut self, stage: u32, source: String, model: String, block: LanguageResolution) {
48
6
        let key = (stage, source, model);
49
6
        if let Some(
existing1
) = self.blocks.iter_mut().find(|(k, _)|
*k3
==
key3
) {
50
1
            existing.1 = block;
51
1
            return;
52
5
        }
53
5
        self.blocks.push((key, block));
54
6
    }
55
56
    /// Every pair answered for, so a global language change can refresh all of
57
    /// them: a per-model block that follows the global value goes stale the
58
    /// moment that value changes, whichever card is showing it.
59
2
    pub fn pairs(&self) -> impl Iterator<Item = (u32, String, String)> + '_ {
60
3
        
self.blocks.iter()2
.
map2
(|(key, _)| key.clone())
61
2
    }
62
63
    /// The tags `(stage, source, model)` can be pinned to, or `None` if the
64
    /// daemon has not answered for it.
65
    ///
66
    /// A miss and an empty answer are different, and a picker reads them
67
    /// differently: not asked yet is a control still loading, while an empty
68
    /// offer is a monolingual model with nothing to choose.
69
    #[must_use]
70
4
    pub fn offered(&self, stage: u32, source: &str, model: &str) -> Option<&[String]> {
71
4
        self.offers
72
4
            .iter()
73
4
            .find(|((st, s, m), _)| *st == stage && 
s == source3
&&
m == model3
)
74
4
            .map(|(_, tags)| 
tags3
.
as_slice3
())
75
4
    }
76
77
    /// Record what a model can be pinned to.
78
3
    pub fn record_offer(&mut self, stage: u32, source: String, model: String, tags: Vec<String>) {
79
3
        let key = (stage, source, model);
80
3
        if let Some(
existing0
) = self.offers.iter_mut().find(|(k, _)|
*k1
==
key1
) {
81
0
            existing.1 = tags;
82
0
            return;
83
3
        }
84
3
        self.offers.push((key, tags));
85
3
    }
86
}
87
88
/// The global Primary Language plus the per-model override picker state.
89
#[derive(Debug, Clone, Default)]
90
pub struct LanguageState {
91
    /// Global Primary Language from the daemon (`None` = unset). Display-only cache.
92
    pub primary_language: Option<String>,
93
    /// The tags the global setting accepts, from
94
    /// `GET /settings/language/list`.
95
    ///
96
    /// The daemon's vocabulary rather than one this client curates: which of
97
    /// `en` and `en-US` a model wants is a rule only the daemon's resolver
98
    /// knows, so a list of our own would offer tags the setter now refuses.
99
    /// Empty until the answer lands, which renders as a picker still loading.
100
    pub global_offers: Vec<String>,
101
    /// Per-model resolution blocks, one per `(source, model)` asked about.
102
    pub model_languages: ModelLanguages,
103
    /// The `(source, model)` pair the open per-model language sheet configures.
104
    /// `None` when the sheet is in global mode.
105
    pub language_picker_target: Option<(u32, String, String)>,
106
    /// Live query text for the language search sheet.
107
    pub language_picker_query: String,
108
}
109
110
#[cfg(test)]
111
mod tests {
112
    use super::ModelLanguages;
113
    use crate::state::LanguageResolution;
114
115
6
    fn block(primary: &str) -> LanguageResolution {
116
6
        LanguageResolution {
117
6
            effective: Some(primary.to_string()),
118
6
            source: "default".to_string(),
119
6
            primary: primary.to_string(),
120
6
        }
121
6
    }
122
123
    /// The regression the store exists for: two stages each showing a language
124
    /// control must not overwrite each other's block. A single slot meant the
125
    /// second fetch to land won and the other card fell back to a neutral
126
    /// label.
127
    #[test]
128
1
    fn two_models_are_held_at_once() {
129
1
        let mut langs = ModelLanguages::default();
130
1
        langs.record(1, "src/a".into(), "whisper".into(), block("es"));
131
1
        langs.record(2, "src/b".into(), "s1-mini".into(), block("en"));
132
133
1
        assert_eq!(langs.get(1, "src/a", "whisper").unwrap().primary, "es");
134
1
        assert_eq!(langs.get(2, "src/b", "s1-mini").unwrap().primary, "en");
135
1
    }
136
137
    /// The same `(source, model)` in two stages is two entries, not one.
138
    ///
139
    /// A backend serving a model both stages can run is the case that needs it:
140
    /// keyed on the pair alone, staging it in one card would answer for the
141
    /// other, and a language change in stage 1 would silently relabel stage 2.
142
    #[test]
143
1
    fn one_pair_in_two_stages_is_two_entries() {
144
1
        let mut langs = ModelLanguages::default();
145
1
        langs.record(1, "src/a".into(), "shared".into(), block("es"));
146
1
        langs.record(2, "src/a".into(), "shared".into(), block("en"));
147
148
1
        assert_eq!(langs.get(1, "src/a", "shared").unwrap().primary, "es");
149
1
        assert_eq!(langs.get(2, "src/a", "shared").unwrap().primary, "en");
150
1
        assert_eq!(langs.pairs().count(), 2);
151
1
    }
152
153
    /// A second answer for the same pair replaces the first rather than
154
    /// stacking, or a language change would never be reflected.
155
    #[test]
156
1
    fn a_new_answer_replaces_the_pairs_old_one() {
157
1
        let mut langs = ModelLanguages::default();
158
1
        langs.record(1, "src/a".into(), "whisper".into(), block("es"));
159
1
        langs.record(1, "src/a".into(), "whisper".into(), block("de"));
160
161
1
        assert_eq!(langs.get(1, "src/a", "whisper").unwrap().primary, "de");
162
1
        assert_eq!(langs.pairs().count(), 1);
163
1
    }
164
165
    /// A pair never asked about misses, which is what the callers render as
166
    /// "not answered yet" rather than as an absent language.
167
    #[test]
168
1
    fn an_unasked_pair_misses() {
169
1
        let langs = ModelLanguages::default();
170
1
        assert!(langs.get(1, "src/a", "whisper").is_none());
171
1
    }
172
173
    /// An offer of nothing is not the same as no offer, and the picker draws
174
    /// them differently: a monolingual model has nothing to choose, while an
175
    /// unanswered one is still loading.
176
    #[test]
177
1
    fn an_empty_offer_is_not_a_missing_one() {
178
1
        let mut langs = ModelLanguages::default();
179
1
        assert!(langs.offered(1, "src/a", "mono").is_none());
180
181
1
        langs.record_offer(1, "src/a".into(), "mono".into(), Vec::new());
182
1
        assert_eq!(langs.offered(1, "src/a", "mono"), Some(&[][..]));
183
1
    }
184
185
    /// Offers are keyed like blocks, so two stages showing the same model do
186
    /// not answer for each other.
187
    #[test]
188
1
    fn offers_are_keyed_by_stage_too() {
189
1
        let mut langs = ModelLanguages::default();
190
1
        langs.record_offer(1, "src/a".into(), "shared".into(), vec!["auto".into()]);
191
1
        langs.record_offer(2, "src/a".into(), "shared".into(), Vec::new());
192
193
1
        assert_eq!(
194
1
            langs.offered(1, "src/a", "shared").map(<[String]>::len),
195
            Some(1)
196
        );
197
1
        assert_eq!(
198
1
            langs.offered(2, "src/a", "shared").map(<[String]>::len),
199
            Some(0)
200
        );
201
1
    }
202
}