Compare commits

...

6 Commits

Author SHA1 Message Date
sjg 20b3f505e3 chore(trx-rs): upgrade Cargo.toml
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-04-19 19:08:19 +02:00
sjg 6bd06d7872 [test](trx-reporting): expand PSKReporter tests with spot conversion, maidenhead, and helpers
Add tests for decoded_to_spot (FT8, WSPR, CW rejection), maidenhead
grid computation for known cities, callsign/locator validation, padding,
string encoding, and directed FT8 message parsing.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-04-08 22:01:28 +02:00
sjg 7cb2b84d94 [test](trx-decode-log): add unit tests for config, template resolution, and write round-trip
Cover disabled config, template date token substitution, logger
initialization, and JSON Lines write+read verification for FT8 and
APRS payloads.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-04-08 21:58:01 +02:00
sjg cee84aa904 [test](trx-aprs): add unit tests for CRC, AX.25, APRS parsing, and demodulator
Cover CRC-16-CCITT, AX.25 address decoding, frame parsing, APRS
position formats (uncompressed, compressed, timestamped), packet type
detection, HDLC bit-to-byte conversion, and demodulator reset.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-04-08 21:56:46 +02:00
sjg 79053cdc5b [fix](trx-frontend-http): add missing context app_data in test_toggle_ft8_decode
The toggle_ft8_decode handler requires FrontendRuntimeContext for
multi-rig state resolution, but the test did not register it.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-04-08 21:54:42 +02:00
sjg 82e1c19d3a [docs](trx-rs): add FIX_PLAN.md with codebase weak spot analysis
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-04-08 21:51:44 +02:00
7 changed files with 1178 additions and 373 deletions
Generated
+429 -373
View File
File diff suppressed because it is too large Load Diff
+143
View File
@@ -0,0 +1,143 @@
# Fix Plan
Current state analysis of trx-rs as of 2026-04-08.
## Overall Assessment
The codebase is in good shape. Clippy is clean, no `unsafe` code, no TODO/FIXME markers,
robust error handling throughout. One broken test and several untested crates are the
main weak spots.
---
## P0 — Broken Test
### 1. `test_toggle_ft8_decode` returns 500 instead of 200
**Location:** `src/trx-client/trx-frontend/trx-frontend-http/src/api/mod.rs:1016`
**Root cause:** The handler `toggle_ft8_decode` (decoder.rs:353) requires
`context: web::Data<Arc<FrontendRuntimeContext>>` for multi-rig state resolution.
The test registers `state_rx` and `rig_tx` but not `context`, so actix-web returns 500
(missing app data). A `make_context()` helper already exists at line 757 but is unused
by this test.
**Fix:** Add `.app_data(web::Data::new(make_context()))` to the test's `App` builder
(line 1036-1041). ~1 line change.
**Impact:** This is the only failing test in the entire suite (50 pass, 1 fail).
---
## P1 — Test Coverage Gaps
### 2. trx-aprs decoder — 0 tests (596 LOC)
**Location:** `src/decoders/trx-aprs/src/lib.rs`
Bell 202 AFSK demodulator + AX.25 HDLC frame parser + CRC-16 validation.
No `#[cfg(test)]` module at all.
**Suggested tests:**
- CRC-16 computation on known frames
- HDLC flag detection and bit-unstuffing
- Full frame decode from synthetic AFSK audio (1200 baud sine pairs)
- Rejection of corrupted frames (bad CRC, truncated)
### 3. trx-decode-log — 0 tests (226 LOC)
**Location:** `src/decoders/trx-decode-log/src/lib.rs`
JSON Lines file writer with date-based rotation. Pure I/O wrapper.
**Suggested tests:**
- Write + read-back round-trip in a tempdir
- Date rotation triggers new file creation
- Flush error logging (mock writer)
### 4. trx-reporting — partial tests (1,065 LOC across 2 files)
**Location:** `src/trx-reporting/src/pskreporter.rs` (582 LOC),
`src/trx-reporting/src/aprsfi.rs` (483 LOC)
Both files have `#[cfg(test)]` modules but coverage is limited to serialization.
Network behavior (reconnect, rate-limit, batching) is untested.
**Suggested tests:**
- PSKReporter UDP datagram encoding round-trip
- APRS-IS login line formatting
- Spot batching and dedup logic (unit-testable without network)
---
## P2 — Code Quality
### 5. `audio.rs` is 4,000 LOC
**Location:** `src/trx-server/src/audio.rs`
Houses all decoder task launchers (FT8, FT4, FT2, APRS, AIS, VDES, CW, WSPR, LRPT,
WEFAX). Each launcher follows the same pattern. The file is coherent but large.
**Suggested improvement:** Extract decoder launchers into a `decoders/` submodule
within trx-server, one file per decoder family (e.g., `ftx.rs`, `aprs.rs`, `wefax.rs`).
Keep the audio pipeline and capture logic in `audio.rs`.
### 6. `scheduler.rs` is 1,585 LOC
**Location:** `src/trx-client/trx-frontend/trx-frontend-http/src/scheduler.rs`
Mixes grayline computation, timespan matching, satellite pass prediction, and the
scheduler state machine. Well-tested but dense.
**Suggested improvement:** Extract grayline and satellite pass logic into separate
modules (these are pure functions with no HTTP dependencies).
---
## P3 — Minor
### 7. `#[allow(dead_code)]` in soapysdr backend (4 annotations)
**Locations:**
- `vchan_impl.rs:66,87``fixed_slot_count`, `process_pair`
- `real_iq_source.rs:20``device`
- `demod.rs:113` — lifetime anchor
All documented as intentional (lifetime anchors / reserved capacity). No action needed
unless the fields can be converted to `PhantomData` or `_`-prefixed without breaking
semantics.
### 8. FrontendRuntimeContext test helper duplication risk
**Location:** `src/trx-client/trx-frontend/trx-frontend-http/src/api/mod.rs:757`
`make_context()` and `spawn_rig_responder()` are good helpers but only used by some
tests. As new endpoint tests are added, ensure they consistently use these helpers to
avoid repeating the `test_toggle_ft8_decode` bug.
---
## Implementation Order
```mermaid
gantt
title Fix Plan
dateFormat X
axisFormat %s
section P0
Fix test_toggle_ft8_decode :p0, 0, 1
section P1
Add trx-aprs tests :p1a, 1, 3
Add trx-decode-log tests :p1b, 1, 2
Expand trx-reporting tests :p1c, 1, 3
section P2
Split audio.rs decoder launchers :p2a, 3, 5
Extract scheduler pure functions :p2b, 3, 5
```
P0 is a one-line fix. P1 items are independent and can be parallelized. P2 items are
refactors that should wait until P1 tests provide regression safety.
+327
View File
@@ -594,3 +594,330 @@ impl AprsDecoder {
} }
} }
} }
#[cfg(test)]
mod tests {
use super::*;
// ======================================================================
// CRC-16-CCITT
// ======================================================================
#[test]
fn crc16_empty() {
// CRC of empty input = 0xFFFF ^ 0xFFFF = 0x0000
assert_eq!(crc16ccitt(&[]), 0x0000);
}
#[test]
fn crc16_known_vector() {
// "123456789" has well-known CCITT (x25) CRC = 0x906E
assert_eq!(crc16ccitt(b"123456789"), 0x906E);
}
#[test]
fn crc16_frame_with_appended_fcs_is_zero() {
// When the FCS is appended to the payload, the CRC of the whole
// sequence should yield the residue constant 0x0F47.
let payload = b"123456789";
let fcs = crc16ccitt(payload);
let mut with_fcs = payload.to_vec();
with_fcs.push(fcs as u8);
with_fcs.push((fcs >> 8) as u8);
assert_eq!(crc16ccitt(&with_fcs), 0x0F47);
}
// ======================================================================
// AX.25 address decoding
// ======================================================================
#[test]
fn decode_ax25_address_basic() {
// AX.25 addresses are left-shifted by 1 bit. "N0CALL" → bytes shifted.
let mut addr = [0u8; 7];
for (i, &ch) in b"N0CALL".iter().enumerate() {
addr[i] = ch << 1;
}
addr[6] = (0 << 1) | 1; // SSID=0, last=true
let decoded = decode_ax25_address(&addr, 0);
assert_eq!(decoded.call, "N0CALL");
assert_eq!(decoded.ssid, 0);
assert!(decoded.last);
}
#[test]
fn decode_ax25_address_with_ssid() {
let mut addr = [0u8; 7];
for (i, &ch) in b"SP2SJG".iter().enumerate() {
addr[i] = ch << 1;
}
addr[6] = (5 << 1) | 0; // SSID=5, last=false
let decoded = decode_ax25_address(&addr, 0);
assert_eq!(decoded.call, "SP2SJG");
assert_eq!(decoded.ssid, 5);
assert!(!decoded.last);
}
#[test]
fn decode_ax25_address_short_call() {
// Short callsign "W1AW" padded with spaces (0x20)
let mut addr = [0u8; 7];
for (i, &ch) in b"W1AW ".iter().enumerate() {
addr[i] = ch << 1;
}
addr[6] = (0 << 1) | 1;
let decoded = decode_ax25_address(&addr, 0);
assert_eq!(decoded.call, "W1AW");
}
// ======================================================================
// AX.25 frame parsing
// ======================================================================
/// Build a minimal valid AX.25 UI frame from src/dest callsigns and info.
fn build_ax25_frame(dest: &str, src: &str, info: &[u8]) -> Vec<u8> {
let mut frame = Vec::new();
// Destination address (7 bytes)
let dest_bytes = format!("{:<6}", dest);
for &ch in dest_bytes.as_bytes().iter().take(6) {
frame.push(ch << 1);
}
frame.push(0 << 1); // SSID=0, last=false
// Source address (7 bytes)
let src_bytes = format!("{:<6}", src);
for &ch in src_bytes.as_bytes().iter().take(6) {
frame.push(ch << 1);
}
frame.push((0 << 1) | 1); // SSID=0, last=true
// Control + PID
frame.push(0x03); // UI frame
frame.push(0xF0); // No layer-3 protocol
// Info field
frame.extend_from_slice(info);
frame
}
#[test]
fn parse_ax25_minimal_frame() {
let frame = build_ax25_frame("APRS", "SP2SJG", b"!5213.78N/02100.73E-Test");
let parsed = parse_ax25(&frame).unwrap();
assert_eq!(parsed.src.call, "SP2SJG");
assert_eq!(parsed.dest.call, "APRS");
assert!(parsed.digis.is_empty());
assert_eq!(parsed.info, b"!5213.78N/02100.73E-Test");
}
#[test]
fn parse_ax25_too_short_returns_none() {
assert!(parse_ax25(&[0u8; 10]).is_none());
}
// ======================================================================
// APRS position parsing
// ======================================================================
#[test]
fn parse_aprs_lat_north() {
let lat = parse_aprs_lat(b"5213.78N").unwrap();
assert!((lat - 52.229667).abs() < 0.001);
}
#[test]
fn parse_aprs_lat_south() {
let lat = parse_aprs_lat(b"3352.13S").unwrap();
assert!(lat < 0.0);
assert!((lat + 33.868833).abs() < 0.001);
}
#[test]
fn parse_aprs_lon_east() {
let lon = parse_aprs_lon(b"02100.73E").unwrap();
assert!((lon - 21.012167).abs() < 0.001);
}
#[test]
fn parse_aprs_lon_west() {
let lon = parse_aprs_lon(b"08737.79W").unwrap();
assert!(lon < 0.0);
}
#[test]
fn parse_aprs_position_uncompressed() {
let info = b"!5213.78N/02100.73E-Test";
let (lat, lon, sym_table, sym_code) = parse_aprs_position(info).unwrap();
assert!((lat - 52.229667).abs() < 0.001);
assert!((lon - 21.012167).abs() < 0.001);
assert_eq!(sym_table, '/');
assert_eq!(sym_code, '-');
}
#[test]
fn parse_aprs_position_with_timestamp() {
// '@' type requires 7-byte timestamp before position
let info = b"@092345z5213.78N/02100.73E-Test";
let (lat, lon, _, _) = parse_aprs_position(info).unwrap();
assert!((lat - 52.229667).abs() < 0.001);
assert!((lon - 21.012167).abs() < 0.001);
}
#[test]
fn parse_aprs_compressed_position() {
// Compressed format: symbol_table + 4 lat chars + 4 lon chars + symbol_code + ...
// Encode lat=52.23, lon=21.01
let lat_val = ((90.0_f64 - 52.23) * 380926.0).round() as u32;
let lon_val = ((21.01_f64 + 180.0) * 190463.0).round() as u32;
let mut pos = vec![b'/']; // symbol table
for i in (0..4).rev() {
pos.push(((lat_val / 91u32.pow(i)) % 91 + 33) as u8);
}
for i in (0..4).rev() {
pos.push(((lon_val / 91u32.pow(i)) % 91 + 33) as u8);
}
pos.push(b'-'); // symbol code
let result = parse_aprs_compressed(&pos);
assert!(result.is_some());
let (lat, lon, sym_table, sym_code) = result.unwrap();
assert!((lat - 52.23).abs() < 0.01);
assert!((lon - 21.01).abs() < 0.01);
assert_eq!(sym_table, '/');
assert_eq!(sym_code, '-');
}
#[test]
fn parse_aprs_position_empty_returns_none() {
assert!(parse_aprs_position(b"").is_none());
}
// ======================================================================
// APRS packet type detection
// ======================================================================
#[test]
fn aprs_packet_type_detection() {
let frame = build_ax25_frame("APRS", "N0CALL", b"!5213.78N/02100.73E-");
let ax25 = parse_ax25(&frame).unwrap();
let pkt = parse_aprs(&ax25);
assert_eq!(pkt.packet_type, "Position");
assert_eq!(pkt.src_call, "N0CALL");
}
#[test]
fn aprs_message_type() {
let frame = build_ax25_frame("APRS", "N0CALL", b":BLN1 :Test bulletin");
let ax25 = parse_ax25(&frame).unwrap();
let pkt = parse_aprs(&ax25);
assert_eq!(pkt.packet_type, "Message");
}
#[test]
fn aprs_status_type() {
let frame = build_ax25_frame("APRS", "N0CALL", b">On the air");
let ax25 = parse_ax25(&frame).unwrap();
let pkt = parse_aprs(&ax25);
assert_eq!(pkt.packet_type, "Status");
}
#[test]
fn aprs_mic_e_type() {
let frame = build_ax25_frame("APRS", "N0CALL", b"`test mic-e");
let ax25 = parse_ax25(&frame).unwrap();
let pkt = parse_aprs(&ax25);
assert_eq!(pkt.packet_type, "Mic-E");
}
// ======================================================================
// format_call
// ======================================================================
#[test]
fn format_call_no_ssid() {
let addr = Ax25Address {
call: "N0CALL".to_string(),
ssid: 0,
last: true,
};
assert_eq!(format_call(&addr), "N0CALL");
}
#[test]
fn format_call_with_ssid() {
let addr = Ax25Address {
call: "SP2SJG".to_string(),
ssid: 15,
last: true,
};
assert_eq!(format_call(&addr), "SP2SJG-15");
}
// ======================================================================
// HDLC bits_to_bytes
// ======================================================================
#[test]
fn bits_to_bytes_too_short_returns_none() {
let demod = Demodulator::new(48000, 1200.0, 1200.0, 2200.0, 1.0);
// Less than 17 bytes worth of bits
let mut d = demod;
d.frame_bits = vec![0; 8 * 10]; // only 10 bytes
assert!(d.bits_to_bytes().is_none());
}
#[test]
fn bits_to_bytes_valid_frame() {
let payload = b"Hello, AX.25 World!";
let fcs = crc16ccitt(payload);
// Convert payload + FCS to LSB-first bit stream
let mut bits = Vec::new();
for &byte in payload.iter() {
for j in 0..8 {
bits.push((byte >> j) & 1);
}
}
bits.push((fcs as u8) & 1);
for j in 1..8 {
bits.push(((fcs as u8) >> j) & 1);
}
let fcs_hi = (fcs >> 8) as u8;
for j in 0..8 {
bits.push((fcs_hi >> j) & 1);
}
let mut demod = Demodulator::new(48000, 1200.0, 1200.0, 2200.0, 1.0);
demod.frame_bits = bits;
let frame = demod.bits_to_bytes().unwrap();
assert!(frame.crc_ok);
assert_eq!(frame.payload, payload);
}
// ======================================================================
// Demodulator smoke test
// ======================================================================
#[test]
fn demodulator_silence_produces_no_frames() {
let mut decoder = AprsDecoder::new(48000);
let silence = vec![0.0f32; 48000]; // 1 second of silence
let packets = decoder.process_samples(&silence);
assert!(packets.is_empty());
}
#[test]
fn decoder_reset_clears_state() {
let mut decoder = AprsDecoder::new(48000);
let noise: Vec<f32> = (0..4800).map(|i| (i as f32 * 0.1).sin() * 0.5).collect();
decoder.process_samples(&noise);
decoder.reset();
// After reset, internal state should be clean
for demod in &decoder.demodulators {
assert_eq!(demod.mark_phase, 0.0);
assert_eq!(demod.space_phase, 0.0);
assert!(!demod.in_frame);
assert!(demod.frame_bits.is_empty());
assert!(demod.frames.is_empty());
}
}
}
+3
View File
@@ -14,3 +14,6 @@ dirs = "6"
serde = { workspace = true, features = ["derive"] } serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true } serde_json = { workspace = true }
tracing = { workspace = true } tracing = { workspace = true }
[dev-dependencies]
tempfile = "3"
+129
View File
@@ -224,3 +224,132 @@ impl DecoderLoggers {
self.wefax.write_payload(msg); self.wefax.write_payload(msg);
} }
} }
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn resolve_file_name_substitutes_date_tokens() {
let template = "LOG-%YYYY%-%MM%-%DD%.log";
let resolved = DecoderFileLogger::resolve_file_name(template);
// Must not contain any template tokens
assert!(!resolved.contains("%YYYY%"));
assert!(!resolved.contains("%MM%"));
assert!(!resolved.contains("%DD%"));
// Must end with .log
assert!(resolved.ends_with(".log"));
// Must start with LOG-
assert!(resolved.starts_with("LOG-"));
// Year should be 4 digits
let parts: Vec<&str> = resolved
.trim_start_matches("LOG-")
.trim_end_matches(".log")
.split('-')
.collect();
assert_eq!(parts.len(), 3);
assert_eq!(parts[0].len(), 4); // YYYY
assert_eq!(parts[1].len(), 2); // MM
assert_eq!(parts[2].len(), 2); // DD
}
#[test]
fn from_config_disabled_returns_none() {
let cfg = DecodeLogsConfig {
enabled: false,
..Default::default()
};
let result = DecoderLoggers::from_config(&cfg).unwrap();
assert!(result.is_none());
}
#[test]
fn from_config_enabled_creates_loggers() {
let dir = tempfile::tempdir().unwrap();
let cfg = DecodeLogsConfig {
enabled: true,
dir: dir.path().to_string_lossy().to_string(),
..Default::default()
};
let result = DecoderLoggers::from_config(&cfg).unwrap();
assert!(result.is_some());
}
#[test]
fn log_ft8_writes_json_line() {
let dir = tempfile::tempdir().unwrap();
let cfg = DecodeLogsConfig {
enabled: true,
dir: dir.path().to_string_lossy().to_string(),
ft8_file: "ft8-test.log".to_string(),
..Default::default()
};
let loggers = DecoderLoggers::from_config(&cfg).unwrap().unwrap();
let msg = Ft8Message {
rig_id: None,
ts_ms: 1000,
snr_db: -12.0,
dt_s: 0.1,
freq_hz: 1234.0,
message: "CQ SP2SJG JO93".to_string(),
};
loggers.log_ft8(&msg);
// Read back the log file
let log_path = dir.path().join("ft8-test.log");
let content = std::fs::read_to_string(&log_path).unwrap();
let lines: Vec<&str> = content.lines().collect();
assert_eq!(lines.len(), 1);
let parsed: serde_json::Value = serde_json::from_str(lines[0]).unwrap();
assert_eq!(parsed["decoder"], "ft8");
assert!(parsed["ts_ms"].is_number());
assert_eq!(parsed["payload"]["message"], "CQ SP2SJG JO93");
assert_eq!(parsed["payload"]["snr_db"], -12.0);
}
#[test]
fn log_aprs_writes_json_line() {
let dir = tempfile::tempdir().unwrap();
let cfg = DecodeLogsConfig {
enabled: true,
dir: dir.path().to_string_lossy().to_string(),
aprs_file: "aprs-test.log".to_string(),
..Default::default()
};
let loggers = DecoderLoggers::from_config(&cfg).unwrap().unwrap();
let pkt = AprsPacket {
rig_id: None,
ts_ms: Some(2000),
src_call: "N0CALL".to_string(),
dest_call: "APRS".to_string(),
path: "WIDE1-1".to_string(),
info: ">Test".to_string(),
info_bytes: b">Test".to_vec(),
packet_type: "Status".to_string(),
crc_ok: true,
lat: None,
lon: None,
symbol_table: None,
symbol_code: None,
};
loggers.log_aprs(&pkt);
let log_path = dir.path().join("aprs-test.log");
let content = std::fs::read_to_string(&log_path).unwrap();
let parsed: serde_json::Value = serde_json::from_str(content.trim()).unwrap();
assert_eq!(parsed["decoder"], "aprs");
assert_eq!(parsed["payload"]["src_call"], "N0CALL");
}
#[test]
fn default_config_has_template_tokens() {
let cfg = DecodeLogsConfig::default();
assert!(cfg.ft8_file.contains("%YYYY%"));
assert!(cfg.aprs_file.contains("%MM%"));
assert!(cfg.cw_file.contains("%DD%"));
assert!(!cfg.enabled);
}
}
@@ -1037,6 +1037,7 @@ mod tests {
App::new() App::new()
.app_data(web::Data::new(state_rx)) .app_data(web::Data::new(state_rx))
.app_data(web::Data::new(rig_tx)) .app_data(web::Data::new(rig_tx))
.app_data(web::Data::new(make_context()))
.service(decoder::toggle_ft8_decode), .service(decoder::toggle_ft8_decode),
) )
.await; .await;
+146
View File
@@ -579,4 +579,150 @@ mod tests {
let len = u16::from_be_bytes([SENDER_TEMPLATE[2], SENDER_TEMPLATE[3]]); let len = u16::from_be_bytes([SENDER_TEMPLATE[2], SENDER_TEMPLATE[3]]);
assert_eq!(len as usize, SENDER_TEMPLATE.len()); assert_eq!(len as usize, SENDER_TEMPLATE.len());
} }
#[test]
fn maidenhead_known_grids() {
// Warsaw, Poland → JO91 subsquare
let grid = maidenhead_from_lat_lon(52.2297, 21.0122);
assert!(grid.starts_with("KO02"));
// New York → FN20
let grid = maidenhead_from_lat_lon(40.7128, -74.0060);
assert!(grid.starts_with("FN20"));
// Sydney → QF56
let grid = maidenhead_from_lat_lon(-33.8688, 151.2093);
assert!(grid.starts_with("QF56"));
}
#[test]
fn decoded_to_spot_ft8() {
use trx_core::decode::{DecodedMessage, Ft8Message};
let msg = Ft8Message {
rig_id: None,
ts_ms: 1_700_000_000_000,
snr_db: -12.0,
dt_s: 0.1,
freq_hz: 1234.0,
message: "CQ SP2SJG JO93".to_string(),
};
let spot = decoded_to_spot(DecodedMessage::Ft8(msg), 14_074_000).unwrap();
assert_eq!(spot.sender_callsign, "SP2SJG");
assert_eq!(spot.sender_locator, Some("JO93".to_string()));
assert_eq!(spot.mode, "FT8");
assert_eq!(spot.abs_freq_hz, 14_075_234);
assert_eq!(spot.snr_db, -12.0);
}
#[test]
fn decoded_to_spot_wspr() {
use trx_core::decode::{DecodedMessage, WsprMessage};
let msg = WsprMessage {
rig_id: None,
ts_ms: 1_700_000_000_000,
snr_db: -24.0,
dt_s: 0.0,
freq_hz: 500.0,
message: "SP2SJG JO93 37".to_string(),
};
let spot = decoded_to_spot(DecodedMessage::Wspr(msg), 7_040_000).unwrap();
assert_eq!(spot.sender_callsign, "SP2SJG");
assert_eq!(spot.mode, "WSPR");
assert_eq!(spot.abs_freq_hz, 7_040_500);
}
#[test]
fn decoded_to_spot_cw_returns_none() {
use trx_core::decode::{CwEvent, DecodedMessage};
let evt = CwEvent {
rig_id: None,
text: "CQ".to_string(),
wpm: 20,
tone_hz: 700,
signal_on: false,
};
assert!(decoded_to_spot(DecodedMessage::Cw(evt), 7_000_000).is_none());
}
#[test]
fn is_callsign_accepts_valid() {
assert!(is_callsign("SP2SJG"));
assert!(is_callsign("W1AW"));
assert!(is_callsign("VK2ABC"));
assert!(is_callsign("JA1ABC"));
assert!(is_callsign("4X1RF"));
}
#[test]
fn is_callsign_rejects_invalid() {
assert!(!is_callsign("CQ"));
assert!(!is_callsign("QRZ"));
assert!(!is_callsign("DE"));
assert!(!is_callsign("AB")); // too short
assert!(!is_callsign("ABCDEFGHIJKLMN")); // too long
assert!(!is_callsign("HELLO")); // no digits
assert!(!is_callsign("12345")); // no letters
}
#[test]
fn is_locator_valid() {
assert!(is_locator("JO93"));
assert!(is_locator("FN30"));
assert!(is_locator("JO93AB"));
}
#[test]
fn is_locator_invalid() {
assert!(!is_locator("ZZ99")); // Z > R
assert!(!is_locator("JO9")); // too short
assert!(!is_locator("JO93ABX")); // too long
}
#[test]
fn pad_to_4_pads_correctly() {
let mut v = vec![1, 2, 3];
pad_to_4(&mut v);
assert_eq!(v.len(), 4);
assert_eq!(v[3], 0);
let mut v = vec![1, 2, 3, 4];
pad_to_4(&mut v);
assert_eq!(v.len(), 4); // already aligned
let mut v = vec![1];
pad_to_4(&mut v);
assert_eq!(v.len(), 4);
}
#[test]
fn push_prefixed_string_encodes_length() {
let mut buf = Vec::new();
push_prefixed_string(&mut buf, "SP2SJG").unwrap();
assert_eq!(buf[0], 6); // length prefix
assert_eq!(&buf[1..], b"SP2SJG");
}
#[test]
fn push_prefixed_string_rejects_too_long() {
let mut buf = Vec::new();
let long = "A".repeat(255);
assert!(push_prefixed_string(&mut buf, &long).is_err());
}
#[test]
fn ft8_callsign_directed_message() {
// "K1ABC SP2SJG R-07" → sender is SP2SJG (second token in directed msg)
assert_eq!(
parse_sender_callsign_ft8("K1ABC SP2SJG R-07"),
Some("SP2SJG".to_string())
);
}
#[test]
fn ft8_callsign_cq_with_region() {
// Some FT8 messages have "CQ DX SP2SJG JO93"
// The second token "DX" is not a callsign, so it should find SP2SJG
assert_eq!(
parse_sender_callsign_ft8("CQ DX SP2SJG JO93"),
Some("SP2SJG".to_string())
);
}
} }