thd75_repl/
check.rs

1//! Accessibility compliance self-check.
2//!
3//! The `check` subcommand runs without connecting to a radio. It
4//! exercises every `output::*` formatter with representative inputs,
5//! runs the accessibility lint on every result, and prints a report.
6//!
7//! A blind ham can run `thd75-repl check` and read the report via
8//! their screen reader to verify their build meets the spec.
9
10use crate::{help_text, lint, output};
11use kenwood_thd75::types::{Band, BatteryLevel, PowerLevel};
12
13/// One entry in the coverage table: a human-readable source name and
14/// a generator that produces the representative outputs for that
15/// source.
16type Generator = fn() -> Vec<String>;
17
18const COVERAGE: &[(&str, Generator)] = &[
19    ("output::frequency", gen_frequency),
20    ("output::tuned_to", gen_tuned_to),
21    ("output::stepped_up", gen_stepped_up),
22    ("output::stepped_down", gen_stepped_down),
23    ("output::mode_read", gen_mode_read),
24    ("output::mode_set", gen_mode_set),
25    ("output::power_read", gen_power_read),
26    ("output::power_set", gen_power_set),
27    ("output::squelch_read", gen_squelch_read),
28    ("output::squelch_set", gen_squelch_set),
29    ("output::smeter", gen_smeter),
30    ("output::battery", gen_battery),
31    ("output::radio_model", gen_radio_model),
32    ("output::firmware_version", gen_firmware_version),
33    ("output::clock", gen_clock),
34    ("output::key_lock", gen_key_lock),
35    ("output::bluetooth", gen_bluetooth),
36    ("output::dual_band", gen_dual_band),
37    ("output::attenuator", gen_attenuator),
38    ("output::vox", gen_vox),
39    ("output::fm_radio", gen_fm_radio),
40    ("output::channel_read", gen_channel_read),
41    ("output::channels_summary", gen_channels_summary),
42    ("output::gps_config", gen_gps_config),
43    ("output::urcall_read", gen_urcall_read),
44    ("output::urcall_set", gen_urcall_set),
45    ("output::reflector_connected", gen_reflector_connected),
46    ("output::error", gen_error),
47    ("output::warning", gen_warning),
48    ("output::aprs_events", gen_aprs_events),
49    ("output::dstar_events", gen_dstar_events),
50    ("help_text::for_command", gen_help_text),
51];
52
53fn gen_frequency() -> Vec<String> {
54    let mut v = Vec::new();
55    for band in [Band::A, Band::B] {
56        for hz in [0u32, 146_520_000, 446_000_000, 1_200_000_000, 1_300_000_000] {
57            v.push(output::frequency(band, hz));
58        }
59    }
60    v
61}
62
63fn gen_tuned_to() -> Vec<String> {
64    vec![
65        output::tuned_to(Band::A, 146_520_000),
66        output::tuned_to(Band::B, 446_000_000),
67    ]
68}
69
70fn gen_stepped_up() -> Vec<String> {
71    vec![output::stepped_up(Band::A, 146_525_000)]
72}
73
74fn gen_stepped_down() -> Vec<String> {
75    vec![output::stepped_down(Band::A, 146_515_000)]
76}
77
78fn gen_mode_read() -> Vec<String> {
79    ["FM", "NFM", "AM", "DV", "LSB", "USB", "CW"]
80        .iter()
81        .map(|m| output::mode_read(Band::A, m))
82        .collect()
83}
84
85fn gen_mode_set() -> Vec<String> {
86    ["FM", "DV"]
87        .iter()
88        .map(|m| output::mode_set(Band::A, m))
89        .collect()
90}
91
92fn gen_power_read() -> Vec<String> {
93    [
94        PowerLevel::High,
95        PowerLevel::Medium,
96        PowerLevel::Low,
97        PowerLevel::ExtraLow,
98    ]
99    .iter()
100    .map(|p| output::power_read(Band::A, *p))
101    .collect()
102}
103
104fn gen_power_set() -> Vec<String> {
105    [PowerLevel::High, PowerLevel::Medium]
106        .iter()
107        .map(|p| output::power_set(Band::B, *p))
108        .collect()
109}
110
111fn gen_squelch_read() -> Vec<String> {
112    (0u8..=5)
113        .map(|l| output::squelch_read(Band::A, l))
114        .collect()
115}
116
117fn gen_squelch_set() -> Vec<String> {
118    (0u8..=5).map(|l| output::squelch_set(Band::A, l)).collect()
119}
120
121fn gen_smeter() -> Vec<String> {
122    ["S0", "S1", "S3", "S5", "S7", "S9"]
123        .iter()
124        .map(|r| output::smeter(Band::A, r))
125        .collect()
126}
127
128fn gen_battery() -> Vec<String> {
129    [
130        BatteryLevel::Empty,
131        BatteryLevel::OneThird,
132        BatteryLevel::TwoThirds,
133        BatteryLevel::Full,
134        BatteryLevel::Charging,
135    ]
136    .iter()
137    .map(|l| output::battery(*l))
138    .collect()
139}
140
141fn gen_radio_model() -> Vec<String> {
142    vec![output::radio_model("TH-D75")]
143}
144
145fn gen_firmware_version() -> Vec<String> {
146    vec![output::firmware_version("1.03")]
147}
148
149fn gen_clock() -> Vec<String> {
150    vec![output::clock("2026-04-10 14:32:07")]
151}
152
153fn gen_key_lock() -> Vec<String> {
154    vec![output::key_lock(true), output::key_lock(false)]
155}
156
157fn gen_bluetooth() -> Vec<String> {
158    vec![output::bluetooth(true), output::bluetooth(false)]
159}
160
161fn gen_dual_band() -> Vec<String> {
162    vec![output::dual_band(true), output::dual_band(false)]
163}
164
165fn gen_attenuator() -> Vec<String> {
166    vec![
167        output::attenuator(Band::A, true),
168        output::attenuator(Band::B, false),
169    ]
170}
171
172fn gen_vox() -> Vec<String> {
173    vec![
174        output::vox(true),
175        output::vox(false),
176        output::vox_set(true),
177        output::vox_gain_read(5),
178        output::vox_gain_set(7),
179        output::vox_delay_read(3),
180        output::vox_delay_set(4),
181    ]
182}
183
184fn gen_fm_radio() -> Vec<String> {
185    vec![
186        output::fm_radio(true),
187        output::fm_radio(false),
188        output::fm_radio_set(true),
189    ]
190}
191
192fn gen_channel_read() -> Vec<String> {
193    vec![
194        output::channel_read(0, 146_520_000),
195        output::channel_read(999, 446_000_000),
196    ]
197}
198
199fn gen_channels_summary() -> Vec<String> {
200    vec![
201        output::channels_summary(0),
202        output::channels_summary(1),
203        output::channels_summary(42),
204    ]
205}
206
207fn gen_gps_config() -> Vec<String> {
208    vec![
209        output::gps_config(true, true),
210        output::gps_config(false, false),
211        output::gps_config(true, false),
212        output::gps_config(false, true),
213    ]
214}
215
216fn gen_urcall_read() -> Vec<String> {
217    vec![
218        output::urcall_read("W1AW", ""),
219        output::urcall_read("W1AW", "P"),
220    ]
221}
222
223fn gen_urcall_set() -> Vec<String> {
224    vec![output::urcall_set("W1AW")]
225}
226
227fn gen_reflector_connected() -> Vec<String> {
228    vec![
229        output::reflector_connected("REF030", 'C'),
230        output::reflector_disconnected().to_string(),
231    ]
232}
233
234fn gen_error() -> Vec<String> {
235    vec![
236        output::error("invalid frequency"),
237        output::error("connection lost"),
238    ]
239}
240
241fn gen_warning() -> Vec<String> {
242    vec![output::warning("could not detect local time zone")]
243}
244
245fn gen_aprs_events() -> Vec<String> {
246    vec![
247        output::aprs_station_heard("W1AW"),
248        output::aprs_message_received("W1AW", "hi"),
249        output::aprs_message_delivered("1"),
250        output::aprs_message_rejected("2"),
251        output::aprs_message_expired("3"),
252        output::aprs_position("W1AW", 35.3, -82.46),
253        output::aprs_weather("W1AW"),
254        output::aprs_digipeated("W1AW"),
255        output::aprs_query_responded("W1AW"),
256        output::aprs_raw_packet("W1AW"),
257        output::aprs_mode_active().to_string(),
258        output::aprs_is_connected().to_string(),
259        output::aprs_is_incoming("W1AW>APRS:hello"),
260        output::aprs_stations_summary(5),
261    ]
262}
263
264fn gen_dstar_events() -> Vec<String> {
265    vec![
266        output::dstar_voice_start("W1AW", "P", "CQCQCQ"),
267        output::dstar_voice_start("W1AW", "", "W9ABC"),
268        output::dstar_voice_end().to_string(),
269        output::dstar_voice_lost().to_string(),
270        output::dstar_text_message("hello"),
271        output::dstar_gps(""),
272        output::dstar_station_heard("W1AW"),
273        output::dstar_command_cq().to_string(),
274        output::dstar_command_echo().to_string(),
275        output::dstar_command_unlink().to_string(),
276        output::dstar_command_info().to_string(),
277        output::dstar_command_link("REF030", 'C'),
278        output::dstar_command_callsign("W1AW"),
279        output::dstar_modem_status(5, false),
280        output::reflector_event_connected().to_string(),
281        output::reflector_event_voice_start("W1AW", "", "CQCQCQ"),
282        output::reflector_event_voice_end().to_string(),
283    ]
284}
285
286fn gen_help_text() -> Vec<String> {
287    let mut v = Vec::new();
288    for cmd in help_text::ALL_COMMANDS {
289        if let Some(text) = help_text::for_command(cmd) {
290            for line in text.lines() {
291                v.push(line.to_string());
292            }
293        }
294    }
295    v
296}
297
298/// Run the compliance check, print the report, return the exit code.
299///
300/// Returns 0 if all rules pass, 1 if any violation is found. Prints
301/// the full report to stdout.
302#[must_use]
303#[allow(clippy::too_many_lines)]
304pub fn run() -> i32 {
305    println!(
306        "Accessibility compliance check, thd75-repl version {}.",
307        env!("CARGO_PKG_VERSION")
308    );
309    println!("Standard: WCAG 2.1 Level AA.");
310    println!("Standard: Section 508 of the US Rehabilitation Act.");
311    println!("Standard: CHI 2021 CLI accessibility paper.");
312    println!("Standard: EN 301 549 version 3.2.1.");
313    println!("Standard: ISO 9241-171.");
314    println!("Standard: ITU-T Recommendation F.790.");
315    println!("Standard: BRLTTY compatibility.");
316    println!("Standard: Handi-Ham Program recommendations.");
317    println!("Standard: ARRL accessibility resources.");
318    println!();
319
320    // Collect per-rule counts and violations.
321    let mut rule_violations: std::collections::BTreeMap<lint::Rule, Vec<(String, String)>> =
322        std::collections::BTreeMap::new();
323    let mut rule_string_counts: std::collections::BTreeMap<lint::Rule, usize> =
324        std::collections::BTreeMap::new();
325    let mut total_strings: usize = 0;
326
327    for (source, generator) in COVERAGE {
328        let outputs = generator();
329        total_strings += outputs.len();
330        for s in &outputs {
331            if let Err(violations) = lint::check_output(s) {
332                for v in violations {
333                    rule_violations
334                        .entry(v.rule)
335                        .or_default()
336                        .push(((*source).to_string(), v.message));
337                }
338            }
339        }
340    }
341
342    let all_rules = [
343        lint::Rule::AsciiOnly,
344        lint::Rule::LineLength,
345        lint::Rule::NoAnsi,
346        lint::Rule::ErrorPrefix,
347        lint::Rule::WarningPrefix,
348        lint::Rule::ConfirmOnSet,
349        lint::Rule::ListCountSummary,
350        lint::Rule::LabelColonValue,
351        lint::Rule::BooleanWords,
352        lint::Rule::NoNakedPrint,
353        lint::Rule::NoCursorMoves,
354        lint::Rule::UnitsSpelledOut,
355        lint::Rule::NoAdHocTimestamps,
356        lint::Rule::StdoutStderrSeparation,
357    ];
358    for rule in all_rules {
359        let _ = rule_string_counts.insert(rule, total_strings);
360    }
361
362    let mut failed = 0usize;
363    for rule in all_rules {
364        let count = rule_string_counts.get(&rule).copied().unwrap_or(0);
365        if let Some(vs) = rule_violations.get(&rule) {
366            failed += 1;
367            println!(
368                "Rule {} {}: FAILED, {} violations, {count} strings checked.",
369                rule.id(),
370                rule.description(),
371                vs.len()
372            );
373            for (source, message) in vs.iter().take(5) {
374                println!("  Source: {source}. {message}");
375            }
376        } else {
377            println!(
378                "Rule {} {}: passed, {count} strings checked.",
379                rule.id(),
380                rule.description()
381            );
382        }
383    }
384
385    println!();
386    if failed == 0 {
387        println!(
388            "All 14 rules passed. {total_strings} strings checked from {} sources.",
389            COVERAGE.len()
390        );
391        0
392    } else {
393        println!(
394            "{failed} of 14 rules failed. {total_strings} strings checked from {} sources.",
395            COVERAGE.len()
396        );
397        1
398    }
399}
400
401#[cfg(test)]
402mod tests {
403    use super::*;
404
405    #[test]
406    fn run_exits_zero_on_clean_build() {
407        // This test calls `run()` which prints to stdout as a side
408        // effect. We only check the return code, not the output.
409        let code = run();
410        assert_eq!(code, 0, "check command reported violations");
411    }
412
413    #[test]
414    fn every_generator_produces_at_least_one_string() {
415        for (name, generator) in COVERAGE {
416            let outputs = generator();
417            assert!(!outputs.is_empty(), "generator {name} produced no outputs");
418        }
419    }
420}