• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

veeso / tui-realm-stdlib / 13283025409

12 Feb 2025 10:15AM UTC coverage: 68.369% (-4.7%) from 73.043%
13283025409

push

github

web-flow
Fix coverage tui (stdlib) (#29)

* chore(workflows/coverage): replace "actions-rs/toolchain" with "dtolnay/rust-toolchain"

* chore(workflows/ratatui): remove unknown option "override"

* chore(workflows/coverage): update "coverallsapp/github-action" to 2.x

* chore(workflows/coverage): replace coverage generation with "cargo-llvm-cov"

3279 of 4796 relevant lines covered (68.37%)

1.64 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

64.72
/src/components/select.rs
1
//! ## Select
2
//!
3
//! `Select` represents a select field, like in HTML. The size for the component must be 3 (border + selected) + the quantity of rows
4
//! you want to display other options when opened (at least 3)
5

6
use tuirealm::command::{Cmd, CmdResult, Direction};
7
use tuirealm::props::{
8
    Alignment, AttrValue, Attribute, BorderSides, Borders, Color, PropPayload, PropValue, Props,
9
    Style, TextModifiers,
10
};
11
use tuirealm::ratatui::text::Line as Spans;
12
use tuirealm::ratatui::{
13
    layout::{Constraint, Direction as LayoutDirection, Layout, Rect},
14
    widgets::{Block, List, ListItem, ListState, Paragraph},
15
};
16
use tuirealm::{Frame, MockComponent, State, StateValue};
17

18
// -- states
19

20
/// ## SelectStates
21
///
22
/// Component states
23
#[derive(Default)]
24
pub struct SelectStates {
25
    /// Available choices
26
    pub choices: Vec<String>,
27
    /// Currently selected choice
28
    pub selected: usize,
29
    /// Choice selected before opening the tab
30
    pub previously_selected: usize,
31
    pub tab_open: bool,
32
}
33

34
impl SelectStates {
35
    /// ### next_choice
36
    ///
37
    /// Move choice index to next choice
38
    pub fn next_choice(&mut self, rewind: bool) {
11✔
39
        if self.tab_open {
11✔
40
            if rewind && self.selected + 1 >= self.choices.len() {
9✔
41
                self.selected = 0;
1✔
42
            } else if self.selected + 1 < self.choices.len() {
8✔
43
                self.selected += 1;
6✔
44
            }
6✔
45
        }
2✔
46
    }
11✔
47

48
    /// ### prev_choice
49
    ///
50
    /// Move choice index to previous choice
51
    pub fn prev_choice(&mut self, rewind: bool) {
11✔
52
        if self.tab_open {
11✔
53
            if rewind && self.selected == 0 && !self.choices.is_empty() {
8✔
54
                self.selected = self.choices.len() - 1;
1✔
55
            } else if self.selected > 0 {
7✔
56
                self.selected -= 1;
6✔
57
            }
6✔
58
        }
3✔
59
    }
11✔
60

61
    /// ### set_choices
62
    ///
63
    /// Set SelectStates choices from a vector of str
64
    /// In addition resets current selection and keep index if possible or set it to the first value
65
    /// available
66
    pub fn set_choices(&mut self, choices: &[String]) {
5✔
67
        self.choices = choices.to_vec();
5✔
68
        // Keep index if possible
5✔
69
        if self.selected >= self.choices.len() {
5✔
70
            self.selected = match self.choices.len() {
2✔
71
                0 => 0,
1✔
72
                l => l - 1,
1✔
73
            };
74
        }
3✔
75
    }
5✔
76

77
    pub fn select(&mut self, i: usize) {
3✔
78
        if i < self.choices.len() {
3✔
79
            self.selected = i;
3✔
80
        }
3✔
81
    }
3✔
82

83
    /// ### close_tab
84
    ///
85
    /// Close tab
86
    pub fn close_tab(&mut self) {
6✔
87
        self.tab_open = false;
6✔
88
    }
6✔
89

90
    /// ### open_tab
91
    ///
92
    /// Open tab
93
    pub fn open_tab(&mut self) {
6✔
94
        self.previously_selected = self.selected;
6✔
95
        self.tab_open = true;
6✔
96
    }
6✔
97

98
    /// Cancel tab open
99
    pub fn cancel_tab(&mut self) {
1✔
100
        self.close_tab();
1✔
101
        self.selected = self.previously_selected;
1✔
102
    }
1✔
103

104
    /// ### is_tab_open
105
    ///
106
    /// Returns whether the tab is open
107
    pub fn is_tab_open(&self) -> bool {
23✔
108
        self.tab_open
23✔
109
    }
23✔
110
}
111

112
// -- component
113

114
#[derive(Default)]
115
pub struct Select {
116
    props: Props,
117
    pub states: SelectStates,
118
    hg_str: Option<String>, // CRAP CRAP CRAP
119
}
120

121
impl Select {
122
    pub fn foreground(mut self, fg: Color) -> Self {
1✔
123
        self.attr(Attribute::Foreground, AttrValue::Color(fg));
1✔
124
        self
1✔
125
    }
1✔
126

127
    pub fn background(mut self, bg: Color) -> Self {
1✔
128
        self.attr(Attribute::Background, AttrValue::Color(bg));
1✔
129
        self
1✔
130
    }
1✔
131

132
    pub fn borders(mut self, b: Borders) -> Self {
1✔
133
        self.attr(Attribute::Borders, AttrValue::Borders(b));
1✔
134
        self
1✔
135
    }
1✔
136

137
    pub fn title<S: Into<String>>(mut self, t: S, a: Alignment) -> Self {
1✔
138
        self.attr(Attribute::Title, AttrValue::Title((t.into(), a)));
1✔
139
        self
1✔
140
    }
1✔
141

142
    pub fn highlighted_str<S: Into<String>>(mut self, s: S) -> Self {
1✔
143
        self.attr(Attribute::HighlightedStr, AttrValue::String(s.into()));
1✔
144
        self
1✔
145
    }
1✔
146

147
    pub fn highlighted_color(mut self, c: Color) -> Self {
1✔
148
        self.attr(Attribute::HighlightedColor, AttrValue::Color(c));
1✔
149
        self
1✔
150
    }
1✔
151

152
    pub fn inactive(mut self, s: Style) -> Self {
×
153
        self.attr(Attribute::FocusStyle, AttrValue::Style(s));
×
154
        self
×
155
    }
×
156

157
    pub fn rewind(mut self, r: bool) -> Self {
1✔
158
        self.attr(Attribute::Rewind, AttrValue::Flag(r));
1✔
159
        self
1✔
160
    }
1✔
161

162
    pub fn choices<S: AsRef<str>>(mut self, choices: &[S]) -> Self {
1✔
163
        self.attr(
1✔
164
            Attribute::Content,
1✔
165
            AttrValue::Payload(PropPayload::Vec(
1✔
166
                choices
1✔
167
                    .iter()
1✔
168
                    .map(|x| PropValue::Str(x.as_ref().to_string()))
3✔
169
                    .collect(),
1✔
170
            )),
1✔
171
        );
1✔
172
        self
1✔
173
    }
1✔
174

175
    pub fn value(mut self, i: usize) -> Self {
1✔
176
        // Set state
1✔
177
        self.attr(
1✔
178
            Attribute::Value,
1✔
179
            AttrValue::Payload(PropPayload::One(PropValue::Usize(i))),
1✔
180
        );
1✔
181
        self
1✔
182
    }
1✔
183

184
    /// ### render_open_tab
185
    ///
186
    /// Render component when tab is open
187
    fn render_open_tab(&mut self, render: &mut Frame, area: Rect) {
×
188
        // Make choices
×
189
        let choices: Vec<ListItem> = self
×
190
            .states
×
191
            .choices
×
192
            .iter()
×
193
            .map(|x| ListItem::new(Spans::from(x.clone())))
×
194
            .collect();
×
195
        let foreground = self
×
196
            .props
×
197
            .get_or(Attribute::Foreground, AttrValue::Color(Color::Reset))
×
198
            .unwrap_color();
×
199
        let background = self
×
200
            .props
×
201
            .get_or(Attribute::Background, AttrValue::Color(Color::Reset))
×
202
            .unwrap_color();
×
203
        let hg: Color = self
×
204
            .props
×
205
            .get_or(Attribute::HighlightedColor, AttrValue::Color(foreground))
×
206
            .unwrap_color();
×
207
        // Prepare layout
×
208
        let chunks = Layout::default()
×
209
            .direction(LayoutDirection::Vertical)
×
210
            .margin(0)
×
211
            .constraints([Constraint::Length(2), Constraint::Min(1)].as_ref())
×
212
            .split(area);
×
213
        // Render like "closed" tab in chunk 0
214
        let selected_text: String = match self.states.choices.get(self.states.selected) {
×
215
            None => String::default(),
×
216
            Some(s) => s.clone(),
×
217
        };
218
        let borders = self
×
219
            .props
×
220
            .get_or(Attribute::Borders, AttrValue::Borders(Borders::default()))
×
221
            .unwrap_borders();
×
222
        let block: Block = Block::default()
×
223
            .borders(BorderSides::LEFT | BorderSides::TOP | BorderSides::RIGHT)
×
224
            .border_style(borders.style())
×
225
            .border_type(borders.modifiers)
×
226
            .style(Style::default().bg(background));
×
227
        let title = self.props.get(Attribute::Title).map(|x| x.unwrap_title());
×
228
        let block = match title {
×
229
            Some((text, alignment)) => block.title(text).title_alignment(alignment),
×
230
            None => block,
×
231
        };
232
        let focus = self
×
233
            .props
×
234
            .get_or(Attribute::Focus, AttrValue::Flag(false))
×
235
            .unwrap_flag();
×
236
        let inactive_style = self
×
237
            .props
×
238
            .get(Attribute::FocusStyle)
×
239
            .map(|x| x.unwrap_style());
×
240
        let p: Paragraph = Paragraph::new(selected_text)
×
241
            .style(match focus {
×
242
                true => borders.style(),
×
243
                false => inactive_style.unwrap_or_default(),
×
244
            })
245
            .block(block);
×
246
        render.render_widget(p, chunks[0]);
×
247
        // Render the list of elements in chunks [1]
248
        // Make list
249
        let mut list = List::new(choices)
×
250
            .block(
×
251
                Block::default()
×
252
                    .borders(BorderSides::LEFT | BorderSides::BOTTOM | BorderSides::RIGHT)
×
253
                    .border_style(match focus {
×
254
                        true => borders.style(),
×
255
                        false => Style::default(),
×
256
                    })
257
                    .border_type(borders.modifiers)
×
258
                    .style(Style::default().bg(background)),
×
259
            )
×
260
            .direction(tuirealm::ratatui::widgets::ListDirection::TopToBottom)
×
261
            .style(Style::default().fg(foreground).bg(background))
×
262
            .highlight_style(
×
263
                Style::default()
×
264
                    .fg(hg)
×
265
                    .add_modifier(TextModifiers::REVERSED),
×
266
            );
×
267
        // Highlighted symbol
×
268
        self.hg_str = self
×
269
            .props
×
270
            .get(Attribute::HighlightedStr)
×
271
            .map(|x| x.unwrap_string());
×
272
        if let Some(hg_str) = &self.hg_str {
×
273
            list = list.highlight_symbol(hg_str);
×
274
        }
×
275
        let mut state: ListState = ListState::default();
×
276
        state.select(Some(self.states.selected));
×
277
        render.render_stateful_widget(list, chunks[1], &mut state);
×
278
    }
×
279

280
    /// ### render_closed_tab
281
    ///
282
    /// Render component when tab is closed
283
    fn render_closed_tab(&self, render: &mut Frame, area: Rect) {
×
284
        let foreground = self
×
285
            .props
×
286
            .get_or(Attribute::Foreground, AttrValue::Color(Color::Reset))
×
287
            .unwrap_color();
×
288
        let background = self
×
289
            .props
×
290
            .get_or(Attribute::Background, AttrValue::Color(Color::Reset))
×
291
            .unwrap_color();
×
292
        let inactive_style = self
×
293
            .props
×
294
            .get(Attribute::FocusStyle)
×
295
            .map(|x| x.unwrap_style());
×
296
        let focus = self
×
297
            .props
×
298
            .get_or(Attribute::Focus, AttrValue::Flag(false))
×
299
            .unwrap_flag();
×
300
        let style = match focus {
×
301
            true => Style::default().bg(background).fg(foreground),
×
302
            false => inactive_style.unwrap_or_default(),
×
303
        };
304
        let borders = self
×
305
            .props
×
306
            .get_or(Attribute::Borders, AttrValue::Borders(Borders::default()))
×
307
            .unwrap_borders();
×
308
        let borders_style = match focus {
×
309
            true => borders.style(),
×
310
            false => inactive_style.unwrap_or_default(),
×
311
        };
312
        let block: Block = Block::default()
×
313
            .borders(BorderSides::ALL)
×
314
            .border_style(borders_style)
×
315
            .border_type(borders.modifiers)
×
316
            .style(style);
×
317
        let title = self.props.get(Attribute::Title).map(|x| x.unwrap_title());
×
318
        let block = match title {
×
319
            Some((text, alignment)) => block.title(text).title_alignment(alignment),
×
320
            None => block,
×
321
        };
322
        let selected_text: String = match self.states.choices.get(self.states.selected) {
×
323
            None => String::default(),
×
324
            Some(s) => s.clone(),
×
325
        };
326
        let p: Paragraph = Paragraph::new(selected_text).style(style).block(block);
×
327
        render.render_widget(p, area);
×
328
    }
×
329

330
    fn rewindable(&self) -> bool {
8✔
331
        self.props
8✔
332
            .get_or(Attribute::Rewind, AttrValue::Flag(false))
8✔
333
            .unwrap_flag()
8✔
334
    }
8✔
335
}
336

337
impl MockComponent for Select {
338
    fn view(&mut self, render: &mut Frame, area: Rect) {
×
339
        if self.props.get_or(Attribute::Display, AttrValue::Flag(true)) == AttrValue::Flag(true) {
×
340
            match self.states.is_tab_open() {
×
341
                true => self.render_open_tab(render, area),
×
342
                false => self.render_closed_tab(render, area),
×
343
            }
344
        }
×
345
    }
×
346

347
    fn query(&self, attr: Attribute) -> Option<AttrValue> {
×
348
        self.props.get(attr)
×
349
    }
×
350

351
    fn attr(&mut self, attr: Attribute, value: AttrValue) {
10✔
352
        match attr {
×
353
            Attribute::Content => {
1✔
354
                // Reset choices
1✔
355
                let choices: Vec<String> = value
1✔
356
                    .unwrap_payload()
1✔
357
                    .unwrap_vec()
1✔
358
                    .iter()
1✔
359
                    .map(|x| x.clone().unwrap_str())
3✔
360
                    .collect();
1✔
361
                self.states.set_choices(&choices);
1✔
362
            }
1✔
363
            Attribute::Value => {
2✔
364
                self.states
2✔
365
                    .select(value.unwrap_payload().unwrap_one().unwrap_usize());
2✔
366
            }
2✔
367
            Attribute::Focus if self.states.is_tab_open() => {
×
368
                if let AttrValue::Flag(false) = value {
×
369
                    self.states.cancel_tab();
×
370
                }
×
371
                self.props.set(attr, value);
×
372
            }
373
            attr => {
7✔
374
                self.props.set(attr, value);
7✔
375
            }
7✔
376
        }
377
    }
10✔
378

379
    fn state(&self) -> State {
3✔
380
        if self.states.is_tab_open() {
3✔
381
            State::None
×
382
        } else {
383
            State::One(StateValue::Usize(self.states.selected))
3✔
384
        }
385
    }
3✔
386

387
    fn perform(&mut self, cmd: Cmd) -> CmdResult {
11✔
388
        match cmd {
8✔
389
            Cmd::Move(Direction::Down) => {
390
                // Increment choice
391
                self.states.next_choice(self.rewindable());
4✔
392
                // Return CmdResult On Change or None if tab is closed
4✔
393
                match self.states.is_tab_open() {
4✔
394
                    false => CmdResult::None,
1✔
395
                    true => CmdResult::Changed(State::One(StateValue::Usize(self.states.selected))),
3✔
396
                }
397
            }
398
            Cmd::Move(Direction::Up) => {
399
                // Increment choice
400
                self.states.prev_choice(self.rewindable());
4✔
401
                // Return CmdResult On Change or None if tab is closed
4✔
402
                match self.states.is_tab_open() {
4✔
403
                    false => CmdResult::None,
1✔
404
                    true => CmdResult::Changed(State::One(StateValue::Usize(self.states.selected))),
3✔
405
                }
406
            }
407
            Cmd::Cancel => {
408
                self.states.cancel_tab();
×
409
                CmdResult::Changed(self.state())
×
410
            }
411
            Cmd::Submit => {
412
                // Open or close tab
413
                if self.states.is_tab_open() {
3✔
414
                    self.states.close_tab();
2✔
415
                    CmdResult::Submit(self.state())
2✔
416
                } else {
417
                    self.states.open_tab();
1✔
418
                    CmdResult::None
1✔
419
                }
420
            }
421
            _ => CmdResult::None,
×
422
        }
423
    }
11✔
424
}
425

426
#[cfg(test)]
427
mod test {
428

429
    use super::*;
430

431
    use pretty_assertions::assert_eq;
432

433
    use tuirealm::props::{PropPayload, PropValue};
434

435
    #[test]
436
    fn test_components_select_states() {
1✔
437
        let mut states: SelectStates = SelectStates::default();
1✔
438
        assert_eq!(states.selected, 0);
1✔
439
        assert_eq!(states.choices.len(), 0);
1✔
440
        assert_eq!(states.tab_open, false);
1✔
441
        let choices: &[String] = &[
1✔
442
            "lemon".to_string(),
1✔
443
            "strawberry".to_string(),
1✔
444
            "vanilla".to_string(),
1✔
445
            "chocolate".to_string(),
1✔
446
        ];
1✔
447
        states.set_choices(&choices);
1✔
448
        assert_eq!(states.selected, 0);
1✔
449
        assert_eq!(states.choices.len(), 4);
1✔
450
        // Move
451
        states.prev_choice(false);
1✔
452
        assert_eq!(states.selected, 0);
1✔
453
        states.next_choice(false);
1✔
454
        // Tab is closed!!!
1✔
455
        assert_eq!(states.selected, 0);
1✔
456
        states.open_tab();
1✔
457
        assert_eq!(states.is_tab_open(), true);
1✔
458
        // Now we can move
459
        states.next_choice(false);
1✔
460
        assert_eq!(states.selected, 1);
1✔
461
        states.next_choice(false);
1✔
462
        assert_eq!(states.selected, 2);
1✔
463
        // Forward overflow
464
        states.next_choice(false);
1✔
465
        states.next_choice(false);
1✔
466
        assert_eq!(states.selected, 3);
1✔
467
        states.prev_choice(false);
1✔
468
        assert_eq!(states.selected, 2);
1✔
469
        // Close tab
470
        states.close_tab();
1✔
471
        assert_eq!(states.is_tab_open(), false);
1✔
472
        states.prev_choice(false);
1✔
473
        assert_eq!(states.selected, 2);
1✔
474
        // Update
475
        let choices: &[String] = &["lemon".to_string(), "strawberry".to_string()];
1✔
476
        states.set_choices(&choices);
1✔
477
        assert_eq!(states.selected, 1); // Move to first index available
1✔
478
        assert_eq!(states.choices.len(), 2);
1✔
479
        let choices = vec![];
1✔
480
        states.set_choices(&choices);
1✔
481
        assert_eq!(states.selected, 0); // Move to first index available
1✔
482
        assert_eq!(states.choices.len(), 0);
1✔
483
        // Rewind
484
        let choices: &[String] = &[
1✔
485
            "lemon".to_string(),
1✔
486
            "strawberry".to_string(),
1✔
487
            "vanilla".to_string(),
1✔
488
            "chocolate".to_string(),
1✔
489
        ];
1✔
490
        states.set_choices(choices);
1✔
491
        states.open_tab();
1✔
492
        assert_eq!(states.selected, 0);
1✔
493
        states.prev_choice(true);
1✔
494
        assert_eq!(states.selected, 3);
1✔
495
        states.next_choice(true);
1✔
496
        assert_eq!(states.selected, 0);
1✔
497
        states.next_choice(true);
1✔
498
        assert_eq!(states.selected, 1);
1✔
499
        states.prev_choice(true);
1✔
500
        assert_eq!(states.selected, 0);
1✔
501
        // Cancel tab
502
        states.close_tab();
1✔
503
        states.select(2);
1✔
504
        states.open_tab();
1✔
505
        states.prev_choice(true);
1✔
506
        states.prev_choice(true);
1✔
507
        assert_eq!(states.selected, 0);
1✔
508
        states.cancel_tab();
1✔
509
        assert_eq!(states.selected, 2);
1✔
510
        assert_eq!(states.is_tab_open(), false);
1✔
511
    }
1✔
512

513
    #[test]
514
    fn test_components_select() {
1✔
515
        // Make component
1✔
516
        let mut component = Select::default()
1✔
517
            .foreground(Color::Red)
1✔
518
            .background(Color::Black)
1✔
519
            .borders(Borders::default())
1✔
520
            .highlighted_color(Color::Red)
1✔
521
            .highlighted_str(">>")
1✔
522
            .title("C'est oui ou bien c'est non?", Alignment::Center)
1✔
523
            .choices(&["Oui!", "Non", "Peut-ĂȘtre"])
1✔
524
            .value(1)
1✔
525
            .rewind(false);
1✔
526
        assert_eq!(component.states.is_tab_open(), false);
1✔
527
        component.states.open_tab();
1✔
528
        assert_eq!(component.states.is_tab_open(), true);
1✔
529
        component.states.close_tab();
1✔
530
        assert_eq!(component.states.is_tab_open(), false);
1✔
531
        // Update
532
        component.attr(
1✔
533
            Attribute::Value,
1✔
534
            AttrValue::Payload(PropPayload::One(PropValue::Usize(2))),
1✔
535
        );
1✔
536
        // Get value
1✔
537
        assert_eq!(component.state(), State::One(StateValue::Usize(2)));
1✔
538
        // Open tab
539
        component.states.open_tab();
1✔
540
        // Events
1✔
541
        // Move cursor
1✔
542
        assert_eq!(
1✔
543
            component.perform(Cmd::Move(Direction::Up)),
1✔
544
            CmdResult::Changed(State::One(StateValue::Usize(1))),
1✔
545
        );
1✔
546
        assert_eq!(
1✔
547
            component.perform(Cmd::Move(Direction::Up)),
1✔
548
            CmdResult::Changed(State::One(StateValue::Usize(0))),
1✔
549
        );
1✔
550
        // Upper boundary
551
        assert_eq!(
1✔
552
            component.perform(Cmd::Move(Direction::Up)),
1✔
553
            CmdResult::Changed(State::One(StateValue::Usize(0))),
1✔
554
        );
1✔
555
        // Move down
556
        assert_eq!(
1✔
557
            component.perform(Cmd::Move(Direction::Down)),
1✔
558
            CmdResult::Changed(State::One(StateValue::Usize(1))),
1✔
559
        );
1✔
560
        assert_eq!(
1✔
561
            component.perform(Cmd::Move(Direction::Down)),
1✔
562
            CmdResult::Changed(State::One(StateValue::Usize(2))),
1✔
563
        );
1✔
564
        // Lower boundary
565
        assert_eq!(
1✔
566
            component.perform(Cmd::Move(Direction::Down)),
1✔
567
            CmdResult::Changed(State::One(StateValue::Usize(2))),
1✔
568
        );
1✔
569
        // Press enter
570
        assert_eq!(
1✔
571
            component.perform(Cmd::Submit),
1✔
572
            CmdResult::Submit(State::One(StateValue::Usize(2))),
1✔
573
        );
1✔
574
        // Tab should be closed
575
        assert_eq!(component.states.is_tab_open(), false);
1✔
576
        // Re open
577
        assert_eq!(component.perform(Cmd::Submit), CmdResult::None);
1✔
578
        assert_eq!(component.states.is_tab_open(), true);
1✔
579
        // Move arrows
580
        assert_eq!(
1✔
581
            component.perform(Cmd::Submit),
1✔
582
            CmdResult::Submit(State::One(StateValue::Usize(2))),
1✔
583
        );
1✔
584
        assert_eq!(component.states.is_tab_open(), false);
1✔
585
        assert_eq!(
1✔
586
            component.perform(Cmd::Move(Direction::Down)),
1✔
587
            CmdResult::None
1✔
588
        );
1✔
589
        assert_eq!(component.perform(Cmd::Move(Direction::Up)), CmdResult::None);
1✔
590
    }
1✔
591
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc