thd75_repl/
output.rs

1//! Pure format functions for every user-facing REPL string.
2//!
3//! Every function returns a `String` (or `&'static str`) - zero I/O,
4//! zero async, zero radio access. Testing happens directly on the
5//! returned strings plus the accessibility lint.
6//!
7//! ## Accessibility rules
8//!
9//! Strings are designed for screen-reader output (blind operators) and
10//! fixed-width terminals:
11//!
12//! - Label-colon-value format (screen readers parse "Label: value" well)
13//! - Natural-language units (megahertz, watts, hertz — not symbols)
14//! - Booleans as words (on/off, not true/false or checkmarks)
15//! - Lines under 80 characters (no wrapping in standard terminals)
16//! - ASCII printable only (no box-drawing or symbols)
17
18use kenwood_thd75::types::{Band, BatteryLevel, PowerLevel};
19
20/// Human-readable band name. Matches the pre-extraction helper which
21/// returned "A" for `Band::A` and "B" for every other variant.
22#[must_use]
23pub fn band_name(band: Band) -> &'static str {
24    if band == Band::A { "A" } else { "B" }
25}
26
27/// Format a frequency in megahertz for natural speech output.
28///
29/// Trailing zeros are stripped, but a trailing decimal with a single
30/// zero is kept so `146.5 megahertz` reads cleanly. Values below 1 Hz
31/// render as `0 megahertz`.
32#[must_use]
33#[allow(clippy::cast_precision_loss)]
34pub fn freq_mhz(hz: u32) -> String {
35    let mhz = f64::from(hz) / 1_000_000.0;
36    let s = format!("{mhz:.6}");
37    let s = s.trim_end_matches('0');
38    let s = if s.ends_with('.') {
39        format!("{s}0")
40    } else {
41        s.to_string()
42    };
43    format!("{s} megahertz")
44}
45
46/// `Band {A|B} frequency: {f} megahertz`.
47#[must_use]
48pub fn frequency(band: Band, hz: u32) -> String {
49    format!("Band {} frequency: {}", band_name(band), freq_mhz(hz))
50}
51
52/// `Band {A|B} tuned to {f} megahertz`.
53#[must_use]
54pub fn tuned_to(band: Band, hz: u32) -> String {
55    format!("Band {} tuned to {}", band_name(band), freq_mhz(hz))
56}
57
58/// `Band {A|B} stepped up to {f} megahertz`.
59#[must_use]
60pub fn stepped_up(band: Band, hz: u32) -> String {
61    format!("Band {} stepped up to {}", band_name(band), freq_mhz(hz))
62}
63
64/// `Band {A|B} stepped down to {f} megahertz`.
65#[must_use]
66pub fn stepped_down(band: Band, hz: u32) -> String {
67    format!("Band {} stepped down to {}", band_name(band), freq_mhz(hz))
68}
69
70/// `Band {A|B} step size: {step}`.
71#[must_use]
72pub fn step_size_read(band: Band, step_display: &str) -> String {
73    format!("Band {} step size: {step_display}", band_name(band))
74}
75
76/// `Band {A|B} step size set to {step}`.
77#[must_use]
78pub fn step_size_set(band: Band, step_display: &str) -> String {
79    format!("Band {} step size set to {step_display}", band_name(band))
80}
81
82/// `Band {A|B} transmit offset: {hz} hertz`.
83#[must_use]
84pub fn tx_offset(band: Band, hz: u32) -> String {
85    format!("Band {} transmit offset: {hz} hertz", band_name(band))
86}
87
88/// `Band {A|B} mode: {mode}` (for VFO readout - the full mode name
89/// comes from `kenwood_thd75::types::Mode::fmt`).
90#[must_use]
91pub fn mode_read(band: Band, mode_display: &str) -> String {
92    format!("Band {} mode: {mode_display}", band_name(band))
93}
94
95/// `Error: {message}` - the canonical error prefix.
96#[must_use]
97pub fn error(e: impl std::fmt::Display) -> String {
98    format!("Error: {e}")
99}
100
101/// `Warning: {message}` - the canonical warning prefix.
102#[must_use]
103pub fn warning(e: impl std::fmt::Display) -> String {
104    format!("Warning: {e}")
105}
106
107/// `Band {A|B} mode set to {mode}`.
108#[must_use]
109pub fn mode_set(band: Band, mode_display: &str) -> String {
110    format!("Band {} mode set to {mode_display}", band_name(band))
111}
112
113/// Human-readable power level name with watts in full.
114#[must_use]
115pub const fn power_level_display(level: PowerLevel) -> &'static str {
116    match level {
117        PowerLevel::High => "high, 5 watts",
118        PowerLevel::Medium => "medium, 2 watts",
119        PowerLevel::Low => "low, half watt",
120        PowerLevel::ExtraLow => "extra-low, 50 milliwatts",
121    }
122}
123
124/// `Band {A|B} power: {level}`.
125#[must_use]
126pub fn power_read(band: Band, level: PowerLevel) -> String {
127    format!(
128        "Band {} power: {}",
129        band_name(band),
130        power_level_display(level)
131    )
132}
133
134/// `Band {A|B} power set to {level}`.
135#[must_use]
136pub fn power_set(band: Band, level: PowerLevel) -> String {
137    format!(
138        "Band {} power set to {}",
139        band_name(band),
140        power_level_display(level)
141    )
142}
143
144/// `Band {A|B} squelch level: {level}` (0-5).
145#[must_use]
146pub fn squelch_read(band: Band, level: u8) -> String {
147    format!("Band {} squelch level: {level}", band_name(band))
148}
149
150/// `Band {A|B} squelch set to {level}`.
151#[must_use]
152pub fn squelch_set(band: Band, level: u8) -> String {
153    format!("Band {} squelch set to {level}", band_name(band))
154}
155
156/// `Band {A|B} S-meter: {reading}`.
157#[must_use]
158pub fn smeter(band: Band, reading_display: &str) -> String {
159    format!("Band {} S-meter: {reading_display}", band_name(band))
160}
161
162/// Human-readable battery level for screen reader speech.
163#[must_use]
164pub const fn battery_level_display(level: BatteryLevel) -> &'static str {
165    match level {
166        BatteryLevel::Empty => "empty",
167        BatteryLevel::OneThird => "one third",
168        BatteryLevel::TwoThirds => "two thirds",
169        BatteryLevel::Full => "full",
170        BatteryLevel::Charging => "charging",
171    }
172}
173
174/// Render an `on`/`off` word from a bool.
175#[must_use]
176pub const fn on_off(value: bool) -> &'static str {
177    if value { "on" } else { "off" }
178}
179
180/// `Radio model: {model}`.
181#[must_use]
182pub fn radio_model(model: impl std::fmt::Display) -> String {
183    format!("Radio model: {model}")
184}
185
186/// `Firmware version: {version}`.
187#[must_use]
188pub fn firmware_version(version: impl std::fmt::Display) -> String {
189    format!("Firmware version: {version}")
190}
191
192/// `Battery level: {level}`.
193#[must_use]
194pub fn battery(level: BatteryLevel) -> String {
195    format!("Battery level: {}", battery_level_display(level))
196}
197
198/// `Radio clock: {time}`.
199#[must_use]
200pub fn clock(time: impl std::fmt::Display) -> String {
201    format!("Radio clock: {time}")
202}
203
204/// `Key lock: {on|off}`.
205#[must_use]
206pub fn key_lock(locked: bool) -> String {
207    format!("Key lock: {}", on_off(locked))
208}
209
210/// `Bluetooth: {on|off}`.
211#[must_use]
212pub fn bluetooth(enabled: bool) -> String {
213    format!("Bluetooth: {}", on_off(enabled))
214}
215
216/// `Dual band: {on|off}`.
217#[must_use]
218pub fn dual_band(enabled: bool) -> String {
219    format!("Dual band: {}", on_off(enabled))
220}
221
222/// `Band {A|B} attenuator: {on|off}`.
223#[must_use]
224pub fn attenuator(band: Band, enabled: bool) -> String {
225    format!("Band {} attenuator: {}", band_name(band), on_off(enabled))
226}
227
228/// `VOX: {on|off}`.
229#[must_use]
230pub fn vox(enabled: bool) -> String {
231    format!("VOX: {}", on_off(enabled))
232}
233
234/// `VOX set to {on|off}`.
235#[must_use]
236pub fn vox_set(enabled: bool) -> String {
237    format!("VOX set to {}", on_off(enabled))
238}
239
240/// `VOX gain: {level}`.
241#[must_use]
242pub fn vox_gain_read(level: u8) -> String {
243    format!("VOX gain: {level}")
244}
245
246/// `VOX gain set to {level}`.
247#[must_use]
248pub fn vox_gain_set(level: u8) -> String {
249    format!("VOX gain set to {level}")
250}
251
252/// `VOX delay: {level}`.
253#[must_use]
254pub fn vox_delay_read(level: u8) -> String {
255    format!("VOX delay: {level}")
256}
257
258/// `VOX delay set to {level}`.
259#[must_use]
260pub fn vox_delay_set(level: u8) -> String {
261    format!("VOX delay set to {level}")
262}
263
264/// `FM radio: {on|off}`.
265#[must_use]
266pub fn fm_radio(enabled: bool) -> String {
267    format!("FM radio: {}", on_off(enabled))
268}
269
270/// `FM radio set to {on|off}`.
271#[must_use]
272pub fn fm_radio_set(enabled: bool) -> String {
273    format!("FM radio set to {}", on_off(enabled))
274}
275
276/// `Channel {n}: {f} megahertz`.
277#[must_use]
278pub fn channel_read(number: u16, hz: u32) -> String {
279    format!("Channel {number}: {}", freq_mhz(hz))
280}
281
282/// `Reading channels {start} through {end}, please wait.`
283#[must_use]
284pub fn channels_reading(start: u16, end_inclusive: u16) -> String {
285    format!("Reading channels {start} through {end_inclusive}, please wait.")
286}
287
288/// `{count} programmed channels found.` or `No programmed channels in
289/// that range.`.
290#[must_use]
291pub fn channels_summary(count: usize) -> String {
292    if count == 0 {
293        "No programmed channels in that range.".to_string()
294    } else {
295        format!("{count} programmed channels found.")
296    }
297}
298
299/// `GPS: {on|off}, PC output: {on|off}`.
300#[must_use]
301pub fn gps_config(gps_on: bool, pc_on: bool) -> String {
302    format!("GPS: {}, PC output: {}", on_off(gps_on), on_off(pc_on))
303}
304
305/// `Destination callsign: {call}` or `... suffix {suffix}`.
306#[must_use]
307pub fn urcall_read(call: &str, suffix: &str) -> String {
308    if suffix.is_empty() {
309        format!("Destination callsign: {call}")
310    } else {
311        format!("Destination callsign: {call} suffix {suffix}")
312    }
313}
314
315/// `Destination callsign set to {call}`.
316#[must_use]
317pub fn urcall_set(call: &str) -> String {
318    format!("Destination callsign set to {call}")
319}
320
321/// `Destination set to CQCQCQ`.
322#[must_use]
323pub const fn cq_set() -> &'static str {
324    "Destination set to CQCQCQ"
325}
326
327/// `Connected to {name} module {module}`.
328#[must_use]
329pub fn reflector_connected(name: &str, module: char) -> String {
330    format!("Connected to {name} module {module}")
331}
332
333/// `Disconnected from reflector`.
334#[must_use]
335pub const fn reflector_disconnected() -> &'static str {
336    "Disconnected from reflector"
337}
338
339// ---------------------------------------------------------------------------
340// APRS mode output
341// ---------------------------------------------------------------------------
342
343/// `APRS station heard: {callsign}.`
344#[must_use]
345pub fn aprs_station_heard(callsign: &str) -> String {
346    format!("APRS station heard: {callsign}.")
347}
348
349/// `APRS message received for {addressee}: {text}.`
350#[must_use]
351pub fn aprs_message_received(addressee: &str, text: &str) -> String {
352    format!("APRS message received for {addressee}: {text}.")
353}
354
355/// `APRS message delivered, ID {id}.`
356#[must_use]
357pub fn aprs_message_delivered(id: &str) -> String {
358    format!("APRS message delivered, ID {id}.")
359}
360
361/// `APRS message rejected by remote station, ID {id}.`
362#[must_use]
363pub fn aprs_message_rejected(id: &str) -> String {
364    format!("APRS message rejected by remote station, ID {id}.")
365}
366
367/// `APRS message expired after all retries, ID {id}.`
368#[must_use]
369pub fn aprs_message_expired(id: &str) -> String {
370    format!("APRS message expired after all retries, ID {id}.")
371}
372
373/// `APRS position from {source}: latitude {lat}, longitude {lon}.`
374#[must_use]
375pub fn aprs_position(source: &str, lat: f64, lon: f64) -> String {
376    format!("APRS position from {source}: latitude {lat:.4}, longitude {lon:.4}.")
377}
378
379/// `APRS weather report from {source}.`
380#[must_use]
381pub fn aprs_weather(source: &str) -> String {
382    format!("APRS weather report from {source}.")
383}
384
385/// `APRS packet relayed from {source}.`
386#[must_use]
387pub fn aprs_digipeated(source: &str) -> String {
388    format!("APRS packet relayed from {source}.")
389}
390
391/// `APRS position query from {to}, responded with beacon.`
392///
393/// Note: the previous version used an em dash (U+2014) between `{to}`
394/// and `responded`. This ASCII-only rewrite replaces it with a comma
395/// so screen readers render the line cleanly.
396#[must_use]
397pub fn aprs_query_responded(to: &str) -> String {
398    format!("APRS position query from {to}, responded with beacon.")
399}
400
401/// `APRS packet from {source}.`
402#[must_use]
403pub fn aprs_raw_packet(source: &str) -> String {
404    format!("APRS packet from {source}.")
405}
406
407/// `APRS mode active. Type aprs stop to exit.`
408#[must_use]
409pub const fn aprs_mode_active() -> &'static str {
410    "APRS mode active. Type aprs stop to exit."
411}
412
413/// `APRS-IS connected. Forwarding RF to internet. Press Ctrl-C to stop.`
414///
415/// The original line used a Unicode left-right arrow between `RF` and
416/// `internet`. The ASCII rewrite spells the relationship out so screen
417/// readers can announce it.
418#[must_use]
419pub const fn aprs_is_connected() -> &'static str {
420    "APRS-IS connected. Forwarding RF to internet. Press Ctrl-C to stop."
421}
422
423/// `APRS-IS incoming: {line}`.
424///
425/// Replaces the previous `IS -> {line}` (which used a Unicode right
426/// arrow) with the spoken form `APRS-IS incoming:`.
427#[must_use]
428pub fn aprs_is_incoming(line: &str) -> String {
429    format!("APRS-IS incoming: {line}")
430}
431
432/// `Station {call}{pos}, {n} packets, heard {elapsed} ago.`
433#[must_use]
434pub fn aprs_station_entry(
435    callsign: &str,
436    position: Option<(f64, f64)>,
437    packet_count: u32,
438    elapsed_display: &str,
439) -> String {
440    let pos = match position {
441        Some((lat, lon)) => format!(" at {lat:.4}, {lon:.4}"),
442        None => String::new(),
443    };
444    format!("Station {callsign}{pos}, {packet_count} packets, heard {elapsed_display} ago.")
445}
446
447/// `{count} stations heard.`
448#[must_use]
449pub fn aprs_stations_summary(count: usize) -> String {
450    format!("{count} stations heard.")
451}
452
453// ---------------------------------------------------------------------------
454// D-STAR mode output
455// ---------------------------------------------------------------------------
456
457/// `D-STAR voice from {call}{/suffix}, to {ur}.`
458#[must_use]
459pub fn dstar_voice_start(my_call: &str, my_suffix: &str, ur_call: &str) -> String {
460    let suffix_part = if my_suffix.trim().is_empty() {
461        String::new()
462    } else {
463        format!(" /{}", my_suffix.trim())
464    };
465    format!(
466        "D-STAR voice from {}{suffix_part}, to {}.",
467        my_call.trim(),
468        ur_call.trim()
469    )
470}
471
472/// `D-STAR voice transmission ended.`
473#[must_use]
474pub const fn dstar_voice_end() -> &'static str {
475    "D-STAR voice transmission ended."
476}
477
478/// `D-STAR voice signal lost, no clean end of transmission.`
479#[must_use]
480pub const fn dstar_voice_lost() -> &'static str {
481    "D-STAR voice signal lost, no clean end of transmission."
482}
483
484/// `D-STAR message: "{text}"`
485#[must_use]
486pub fn dstar_text_message(text: &str) -> String {
487    format!("D-STAR message: \"{text}\"")
488}
489
490/// `D-STAR GPS data: {text}.` or `D-STAR GPS position data received.`
491#[must_use]
492pub fn dstar_gps(text: &str) -> String {
493    if text.trim().is_empty() {
494        "D-STAR GPS position data received.".to_string()
495    } else {
496        format!("D-STAR GPS data: {text}")
497    }
498}
499
500/// `D-STAR station heard: {callsign}.`
501#[must_use]
502pub fn dstar_station_heard(callsign: &str) -> String {
503    format!("D-STAR station heard: {callsign}.")
504}
505
506/// `D-STAR command: call CQ.`
507#[must_use]
508pub const fn dstar_command_cq() -> &'static str {
509    "D-STAR command: call CQ."
510}
511
512/// `D-STAR command: echo test.`
513#[must_use]
514pub const fn dstar_command_echo() -> &'static str {
515    "D-STAR command: echo test."
516}
517
518/// `D-STAR command: unlink reflector.`
519#[must_use]
520pub const fn dstar_command_unlink() -> &'static str {
521    "D-STAR command: unlink reflector."
522}
523
524/// `D-STAR command: request info.`
525#[must_use]
526pub const fn dstar_command_info() -> &'static str {
527    "D-STAR command: request info."
528}
529
530/// `D-STAR command: link to {reflector} module {module}.`
531#[must_use]
532pub fn dstar_command_link(reflector: &str, module: char) -> String {
533    format!("D-STAR command: link to {reflector} module {module}.")
534}
535
536/// `D-STAR command: route to callsign {call}.`
537#[must_use]
538pub fn dstar_command_callsign(call: &str) -> String {
539    format!("D-STAR command: route to callsign {call}.")
540}
541
542/// `D-STAR modem: buffer {n}, transmit {active|idle}.`
543#[must_use]
544pub fn dstar_modem_status(buffer: u8, tx_active: bool) -> String {
545    format!(
546        "D-STAR modem: buffer {buffer}, transmit {}.",
547        if tx_active { "active" } else { "idle" }
548    )
549}
550
551// Reflector events
552
553/// `Reflector: connected.`
554#[must_use]
555pub const fn reflector_event_connected() -> &'static str {
556    "Reflector: connected."
557}
558
559/// `Reflector: connection rejected.`
560#[must_use]
561pub const fn reflector_event_rejected() -> &'static str {
562    "Reflector: connection rejected."
563}
564
565/// `Reflector: disconnected.`
566#[must_use]
567pub const fn reflector_event_disconnected() -> &'static str {
568    "Reflector: disconnected."
569}
570
571/// `Reflector: voice from {call}{/suffix}, to {ur}.`
572#[must_use]
573pub fn reflector_event_voice_start(my_call: &str, my_suffix: &str, ur_call: &str) -> String {
574    let suffix_part = if my_suffix.is_empty() {
575        String::new()
576    } else {
577        format!(" /{my_suffix}")
578    };
579    format!("Reflector: voice from {my_call}{suffix_part}, to {ur_call}.")
580}
581
582/// `Reflector: voice transmission ended.`
583#[must_use]
584pub const fn reflector_event_voice_end() -> &'static str {
585    "Reflector: voice transmission ended."
586}
587
588// ---------------------------------------------------------------------------
589// Startup / session
590// ---------------------------------------------------------------------------
591
592/// `Kenwood TH-D75 accessible radio control, version {version}.`
593#[must_use]
594pub fn startup_banner(version: &str) -> String {
595    format!("Kenwood TH-D75 accessible radio control, version {version}.")
596}
597
598/// `Connected via {path}.`
599#[must_use]
600pub fn connected_via(path: &str) -> String {
601    format!("Connected via {path}.")
602}
603
604/// `Goodbye.`
605#[must_use]
606pub const fn goodbye() -> &'static str {
607    "Goodbye."
608}
609
610/// `Type help for a list of commands, or quit to exit.`
611#[must_use]
612pub const fn type_help_hint() -> &'static str {
613    "Type help for a list of commands, or quit to exit."
614}
615
616/// `Radio model: {model}. Firmware version: {fw}.`
617#[must_use]
618pub fn startup_identified(model: &str, firmware: &str) -> String {
619    format!("Radio model: {model}. Firmware version: {firmware}.")
620}
621
622#[cfg(test)]
623mod tests {
624    use super::*;
625    use crate::lint;
626    use kenwood_thd75::types::Band;
627
628    fn assert_lint(s: &str) {
629        lint::check_output(s).unwrap_or_else(|v| {
630            panic!("line {s:?} failed lint: {v:?}");
631        });
632    }
633
634    #[test]
635    fn freq_mhz_standard() {
636        assert_eq!(freq_mhz(146_520_000), "146.52 megahertz");
637        assert_eq!(freq_mhz(446_000_000), "446.0 megahertz");
638        assert_eq!(freq_mhz(0), "0.0 megahertz");
639    }
640
641    #[test]
642    fn freq_mhz_high_band() {
643        assert_eq!(freq_mhz(1_200_000_000), "1200.0 megahertz");
644        assert_eq!(freq_mhz(1_300_000_000), "1300.0 megahertz");
645    }
646
647    #[test]
648    fn frequency_band_a() {
649        let s = frequency(Band::A, 146_520_000);
650        assert_eq!(s, "Band A frequency: 146.52 megahertz");
651        assert_lint(&s);
652    }
653
654    #[test]
655    fn frequency_band_b() {
656        let s = frequency(Band::B, 446_000_000);
657        assert_eq!(s, "Band B frequency: 446.0 megahertz");
658        assert_lint(&s);
659    }
660
661    #[test]
662    fn tuned_to_band_a() {
663        let s = tuned_to(Band::A, 146_520_000);
664        assert_eq!(s, "Band A tuned to 146.52 megahertz");
665        assert_lint(&s);
666    }
667
668    #[test]
669    fn stepped_up_and_down() {
670        let up = stepped_up(Band::A, 146_525_000);
671        let down = stepped_down(Band::A, 146_515_000);
672        assert_eq!(up, "Band A stepped up to 146.525 megahertz");
673        assert_eq!(down, "Band A stepped down to 146.515 megahertz");
674        assert_lint(&up);
675        assert_lint(&down);
676    }
677
678    #[test]
679    fn step_size_formats() {
680        let s = step_size_read(Band::A, "25 kilohertz");
681        assert_eq!(s, "Band A step size: 25 kilohertz");
682        assert_lint(&s);
683    }
684
685    #[test]
686    fn step_size_set_format() {
687        let s = step_size_set(Band::B, "12.5 kilohertz");
688        assert_eq!(s, "Band B step size set to 12.5 kilohertz");
689        assert_lint(&s);
690    }
691
692    #[test]
693    fn error_prefix() {
694        let s = error("invalid frequency");
695        assert_eq!(s, "Error: invalid frequency");
696        assert_lint(&s);
697    }
698
699    #[test]
700    fn warning_prefix() {
701        let s = warning("could not detect time zone");
702        assert_eq!(s, "Warning: could not detect time zone");
703        assert_lint(&s);
704    }
705
706    #[test]
707    fn tx_offset_formatted() {
708        let s = tx_offset(Band::A, 600_000);
709        assert_eq!(s, "Band A transmit offset: 600000 hertz");
710        assert_lint(&s);
711    }
712
713    #[test]
714    fn mode_read_basic() {
715        let s = mode_read(Band::A, "FM");
716        assert_eq!(s, "Band A mode: FM");
717        assert_lint(&s);
718    }
719
720    #[test]
721    fn mode_read_and_set() {
722        let r = mode_read(Band::A, "FM");
723        let s = mode_set(Band::B, "DV");
724        assert_eq!(r, "Band A mode: FM");
725        assert_eq!(s, "Band B mode set to DV");
726        assert_lint(&r);
727        assert_lint(&s);
728    }
729
730    #[test]
731    fn power_read_all_levels() {
732        use kenwood_thd75::types::PowerLevel;
733        assert_eq!(
734            power_read(Band::A, PowerLevel::High),
735            "Band A power: high, 5 watts"
736        );
737        assert_eq!(
738            power_read(Band::A, PowerLevel::Medium),
739            "Band A power: medium, 2 watts"
740        );
741        assert_eq!(
742            power_read(Band::A, PowerLevel::Low),
743            "Band A power: low, half watt"
744        );
745        assert_eq!(
746            power_read(Band::A, PowerLevel::ExtraLow),
747            "Band A power: extra-low, 50 milliwatts"
748        );
749        assert_lint(&power_read(Band::A, PowerLevel::High));
750        assert_lint(&power_read(Band::A, PowerLevel::ExtraLow));
751    }
752
753    #[test]
754    fn power_set_lints() {
755        use kenwood_thd75::types::PowerLevel;
756        let s = power_set(Band::B, PowerLevel::Medium);
757        assert_eq!(s, "Band B power set to medium, 2 watts");
758        assert_lint(&s);
759    }
760
761    #[test]
762    fn squelch_read_and_set() {
763        let r = squelch_read(Band::A, 3);
764        let s = squelch_set(Band::A, 5);
765        assert_eq!(r, "Band A squelch level: 3");
766        assert_eq!(s, "Band A squelch set to 5");
767        assert_lint(&r);
768        assert_lint(&s);
769    }
770
771    #[test]
772    fn smeter_format() {
773        let s = smeter(Band::A, "S0");
774        assert_eq!(s, "Band A S-meter: S0");
775        assert_lint(&s);
776    }
777
778    #[test]
779    fn battery_all_levels() {
780        use kenwood_thd75::types::BatteryLevel;
781        let cases = [
782            (BatteryLevel::Empty, "Battery level: empty"),
783            (BatteryLevel::OneThird, "Battery level: one third"),
784            (BatteryLevel::TwoThirds, "Battery level: two thirds"),
785            (BatteryLevel::Full, "Battery level: full"),
786            (BatteryLevel::Charging, "Battery level: charging"),
787        ];
788        for (level, expected) in cases {
789            let s = battery(level);
790            assert_eq!(s, expected);
791            assert_lint(&s);
792        }
793    }
794
795    #[test]
796    fn radio_model_format() {
797        let s = radio_model("TH-D75");
798        assert_eq!(s, "Radio model: TH-D75");
799        assert_lint(&s);
800    }
801
802    #[test]
803    fn firmware_version_format() {
804        let s = firmware_version("1.03");
805        assert_eq!(s, "Firmware version: 1.03");
806        assert_lint(&s);
807    }
808
809    #[test]
810    fn clock_format() {
811        let s = clock("2026-04-10 14:32:07");
812        assert_eq!(s, "Radio clock: 2026-04-10 14:32:07");
813        assert_lint(&s);
814    }
815
816    #[test]
817    fn booleans_as_words() {
818        assert_eq!(key_lock(true), "Key lock: on");
819        assert_eq!(key_lock(false), "Key lock: off");
820        assert_eq!(bluetooth(true), "Bluetooth: on");
821        assert_eq!(dual_band(false), "Dual band: off");
822        assert_lint(&key_lock(true));
823        assert_lint(&bluetooth(false));
824        assert_lint(&dual_band(true));
825    }
826
827    #[test]
828    fn attenuator_format() {
829        let s = attenuator(Band::A, true);
830        assert_eq!(s, "Band A attenuator: on");
831        assert_lint(&s);
832        let s = attenuator(Band::B, false);
833        assert_eq!(s, "Band B attenuator: off");
834        assert_lint(&s);
835    }
836
837    #[test]
838    fn vox_formats() {
839        assert_eq!(vox(true), "VOX: on");
840        assert_eq!(vox_set(false), "VOX set to off");
841        assert_eq!(vox_gain_read(5), "VOX gain: 5");
842        assert_eq!(vox_gain_set(7), "VOX gain set to 7");
843        assert_eq!(vox_delay_read(3), "VOX delay: 3");
844        assert_eq!(vox_delay_set(1), "VOX delay set to 1");
845        assert_lint(&vox(true));
846        assert_lint(&vox_set(true));
847        assert_lint(&vox_gain_set(9));
848        assert_lint(&vox_delay_read(6));
849    }
850
851    #[test]
852    fn fm_radio_formats() {
853        assert_eq!(fm_radio(true), "FM radio: on");
854        assert_eq!(fm_radio_set(false), "FM radio set to off");
855        assert_lint(&fm_radio(true));
856        assert_lint(&fm_radio_set(false));
857    }
858
859    #[test]
860    fn channel_read_format() {
861        let s = channel_read(5, 146_520_000);
862        assert_eq!(s, "Channel 5: 146.52 megahertz");
863        assert_lint(&s);
864    }
865
866    #[test]
867    fn channels_reading_format() {
868        let s = channels_reading(0, 19);
869        assert_eq!(s, "Reading channels 0 through 19, please wait.");
870        assert_lint(&s);
871    }
872
873    #[test]
874    fn channels_summary_non_empty() {
875        let s = channels_summary(3);
876        assert_eq!(s, "3 programmed channels found.");
877        assert_lint(&s);
878    }
879
880    #[test]
881    fn channels_summary_empty() {
882        let s = channels_summary(0);
883        assert_eq!(s, "No programmed channels in that range.");
884        assert_lint(&s);
885    }
886
887    #[test]
888    fn gps_config_format() {
889        assert_eq!(gps_config(true, true), "GPS: on, PC output: on");
890        assert_eq!(gps_config(false, true), "GPS: off, PC output: on");
891        assert_eq!(gps_config(true, false), "GPS: on, PC output: off");
892        assert_eq!(gps_config(false, false), "GPS: off, PC output: off");
893        assert_lint(&gps_config(true, true));
894    }
895
896    #[test]
897    fn urcall_read_with_and_without_suffix() {
898        assert_eq!(urcall_read("W1AW", ""), "Destination callsign: W1AW");
899        assert_eq!(
900            urcall_read("W1AW", "P"),
901            "Destination callsign: W1AW suffix P"
902        );
903        assert_lint(&urcall_read("W1AW", ""));
904        assert_lint(&urcall_read("W1AW", "P"));
905    }
906
907    #[test]
908    fn urcall_set_format() {
909        assert_eq!(urcall_set("W1AW"), "Destination callsign set to W1AW");
910        assert_lint(&urcall_set("W1AW"));
911    }
912
913    #[test]
914    fn cq_and_reflector_strings() {
915        assert_eq!(cq_set(), "Destination set to CQCQCQ");
916        assert_eq!(
917            reflector_connected("REF030", 'C'),
918            "Connected to REF030 module C"
919        );
920        assert_eq!(reflector_disconnected(), "Disconnected from reflector");
921        assert_lint(cq_set());
922        assert_lint(&reflector_connected("REF030", 'C'));
923        assert_lint(reflector_disconnected());
924    }
925
926    #[test]
927    fn aprs_events_all_pass_lint() {
928        let cases = vec![
929            aprs_station_heard("W1AW"),
930            aprs_message_received("W1AW", "Hello there"),
931            aprs_message_delivered("42"),
932            aprs_message_rejected("43"),
933            aprs_message_expired("44"),
934            aprs_position("W1AW", 35.3, -82.46),
935            aprs_weather("W1AW"),
936            aprs_digipeated("W1AW"),
937            aprs_query_responded("W1AW"),
938            aprs_raw_packet("W1AW"),
939            aprs_mode_active().to_string(),
940            aprs_is_connected().to_string(),
941            aprs_is_incoming("W1AW-7>APRS:hello"),
942            aprs_station_entry("W1AW", Some((35.3, -82.46)), 12, "2 minutes"),
943            aprs_station_entry("W1AW", None, 1, "15 seconds"),
944            aprs_stations_summary(5),
945        ];
946        for s in &cases {
947            assert_lint(s);
948        }
949    }
950
951    #[test]
952    fn aprs_position_format() {
953        let s = aprs_position("W1AW", 35.3, -82.46);
954        assert_eq!(
955            s,
956            "APRS position from W1AW: latitude 35.3000, longitude -82.4600."
957        );
958    }
959
960    #[test]
961    fn aprs_is_connected_has_no_unicode() {
962        let s = aprs_is_connected();
963        assert!(!s.contains('\u{2194}'), "must not contain left-right arrow");
964        assert!(!s.contains('\u{2192}'), "must not contain right arrow");
965        assert_lint(s);
966    }
967
968    #[test]
969    fn aprs_query_responded_uses_comma_not_em_dash() {
970        let s = aprs_query_responded("W1AW");
971        assert!(!s.contains('\u{2014}'), "must not contain em dash");
972        assert_eq!(s, "APRS position query from W1AW, responded with beacon.");
973        assert_lint(&s);
974    }
975
976    #[test]
977    fn aprs_is_incoming_format() {
978        let s = aprs_is_incoming("W1AW>APRS:hello");
979        assert_eq!(s, "APRS-IS incoming: W1AW>APRS:hello");
980        assert_lint(&s);
981    }
982
983    #[test]
984    fn aprs_station_entry_with_position() {
985        let s = aprs_station_entry("W1AW", Some((35.3, -82.46)), 12, "2 minutes");
986        assert_eq!(
987            s,
988            "Station W1AW at 35.3000, -82.4600, 12 packets, heard 2 minutes ago."
989        );
990    }
991
992    #[test]
993    fn aprs_station_entry_without_position() {
994        let s = aprs_station_entry("W1AW", None, 1, "15 seconds");
995        assert_eq!(s, "Station W1AW, 1 packets, heard 15 seconds ago.");
996    }
997
998    #[test]
999    fn dstar_events_pass_lint() {
1000        let cases: Vec<String> = vec![
1001            dstar_voice_start("W1AW", "P", "CQCQCQ"),
1002            dstar_voice_start("W1AW", "", "W9ABC"),
1003            dstar_voice_end().to_string(),
1004            dstar_voice_lost().to_string(),
1005            dstar_text_message("Hello from W1AW"),
1006            dstar_gps(""),
1007            dstar_gps("$GPGGA,..."),
1008            dstar_station_heard("W1AW"),
1009            dstar_command_cq().to_string(),
1010            dstar_command_echo().to_string(),
1011            dstar_command_unlink().to_string(),
1012            dstar_command_info().to_string(),
1013            dstar_command_link("REF030", 'C'),
1014            dstar_command_callsign("W1AW"),
1015            dstar_modem_status(5, false),
1016            dstar_modem_status(0, true),
1017            reflector_event_connected().to_string(),
1018            reflector_event_rejected().to_string(),
1019            reflector_event_disconnected().to_string(),
1020            reflector_event_voice_start("W1AW", "", "CQCQCQ"),
1021            reflector_event_voice_start("W1AW", "P", "CQCQCQ"),
1022            reflector_event_voice_end().to_string(),
1023        ];
1024        for s in &cases {
1025            assert_lint(s);
1026        }
1027    }
1028
1029    #[test]
1030    fn dstar_voice_start_with_suffix() {
1031        let s = dstar_voice_start("W1AW", "P", "CQCQCQ");
1032        assert_eq!(s, "D-STAR voice from W1AW /P, to CQCQCQ.");
1033    }
1034
1035    #[test]
1036    fn dstar_voice_start_without_suffix() {
1037        let s = dstar_voice_start("W1AW", "", "CQCQCQ");
1038        assert_eq!(s, "D-STAR voice from W1AW, to CQCQCQ.");
1039    }
1040
1041    #[test]
1042    fn dstar_gps_empty_and_with_text() {
1043        assert_eq!(dstar_gps(""), "D-STAR GPS position data received.");
1044        assert_eq!(dstar_gps("$GPGGA,..."), "D-STAR GPS data: $GPGGA,...");
1045    }
1046
1047    #[test]
1048    fn dstar_modem_status_formats() {
1049        assert_eq!(
1050            dstar_modem_status(5, false),
1051            "D-STAR modem: buffer 5, transmit idle."
1052        );
1053        assert_eq!(
1054            dstar_modem_status(0, true),
1055            "D-STAR modem: buffer 0, transmit active."
1056        );
1057    }
1058
1059    #[test]
1060    fn startup_strings_lint() {
1061        assert_lint(&startup_banner("0.1.0"));
1062        assert_lint(&connected_via("/dev/cu.usbmodem1234"));
1063        assert_lint(goodbye());
1064        assert_lint(type_help_hint());
1065        assert_lint(&startup_identified("TH-D75", "1.03"));
1066    }
1067
1068    #[test]
1069    fn startup_banner_format() {
1070        assert_eq!(
1071            startup_banner("0.1.0"),
1072            "Kenwood TH-D75 accessible radio control, version 0.1.0."
1073        );
1074    }
1075
1076    #[test]
1077    fn connected_via_format() {
1078        assert_eq!(
1079            connected_via("/dev/cu.usbmodem1234"),
1080            "Connected via /dev/cu.usbmodem1234."
1081        );
1082    }
1083
1084    #[test]
1085    fn goodbye_and_hint_format() {
1086        assert_eq!(goodbye(), "Goodbye.");
1087        assert_eq!(
1088            type_help_hint(),
1089            "Type help for a list of commands, or quit to exit."
1090        );
1091    }
1092
1093    #[test]
1094    fn startup_identified_format() {
1095        assert_eq!(
1096            startup_identified("TH-D75", "1.03"),
1097            "Radio model: TH-D75. Firmware version: 1.03."
1098        );
1099    }
1100
1101    proptest::proptest! {
1102        #[test]
1103        fn frequency_always_lints(hz in 0u32..=1_300_000_000u32) {
1104            let s = frequency(Band::A, hz);
1105            lint::check_line(&s).unwrap_or_else(|v| panic!("{s:?}: {v:?}"));
1106            proptest::prop_assert!(s.chars().count() <= 80);
1107        }
1108    }
1109}