Compare commits
6 Commits
53dfe72143
...
20b3f505e3
| Author | SHA1 | Date | |
|---|---|---|---|
|
20b3f505e3
|
|||
| 6bd06d7872 | |||
| 7cb2b84d94 | |||
| cee84aa904 | |||
| 79053cdc5b | |||
| 82e1c19d3a |
Generated
+429
-373
File diff suppressed because it is too large
Load Diff
+143
@@ -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.
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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())
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user