hamcall/
call.rs

1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at http://mozilla.org/MPL/2.0/.
4
5//! Analyzer for callsigns based on the data from the ClubLog XML to get further information like the callsigns entity.
6//!
7//! The example `call.rs` shows the basic usage of this module.
8
9use crate::clublog::{Adif, CallsignException, CqZone, Prefix, ADIF_ID_NO_DXCC};
10use crate::clublogquery::ClubLogQuery;
11use chrono::{DateTime, Utc};
12use lazy_static::lazy_static;
13use regex::Regex;
14use thiserror::Error;
15
16/// Representation of a callsign together with detailed information like the name of the entity or the ADIF DXCC identifier.
17#[derive(Debug, PartialEq)]
18pub struct Callsign {
19    /// Complete callsign
20    pub call: String,
21    /// ADIF DXCC identifier
22    pub adif: Adif,
23    /// Name of entity
24    pub dxcc: Option<String>,
25    /// CQ zone
26    pub cqzone: Option<CqZone>,
27    /// Continent
28    pub continent: Option<String>,
29    /// Longitude
30    pub longitude: Option<f32>,
31    /// Latitude
32    pub latitude: Option<f32>,
33}
34
35impl Callsign {
36    /// Check if callsign is assigned to no DXCC (like for /AM, /MM or /SAT)
37    ///
38    /// # Arguments
39    ///
40    /// (None)
41    ///
42    /// # Returns
43    ///
44    /// True if the callsign is assigned to no DXCC
45    pub fn is_special_entity(&self) -> bool {
46        self.adif == ADIF_ID_NO_DXCC
47    }
48
49    /// Instantiate a new callsign that does not belong to an entity (e.g. /MM, /AM, /SAT)
50    ///
51    /// # Arguments
52    ///
53    /// - `call`: Callsign
54    ///
55    /// # Returns
56    ///
57    /// Callsign struct
58    fn new_special_entity(call: &str) -> Callsign {
59        Callsign {
60            call: String::from(call),
61            adif: ADIF_ID_NO_DXCC,
62            dxcc: None,
63            cqzone: None,
64            continent: None,
65            longitude: None,
66            latitude: None,
67        }
68    }
69
70    /// Instantiate a new callsign from a ClubLog prefix
71    ///
72    /// # Arguments
73    ///
74    /// - `call`: Callsign
75    /// - `prefix`: Callsign exception entry
76    ///
77    /// # Returns
78    ///
79    /// Callsign struct
80    fn from_prefix(call: &str, prefix: &Prefix) -> Callsign {
81        Callsign {
82            call: String::from(call),
83            adif: prefix.adif,
84            dxcc: Some(prefix.entity.clone()),
85            cqzone: prefix.cqz,
86            continent: prefix.cont.clone(),
87            longitude: prefix.long,
88            latitude: prefix.lat,
89        }
90    }
91
92    /// Instantiate a new callsign from a ClubLog callsign exception
93    ///
94    /// # Arguments
95    ///
96    /// - `call`: Callsign
97    /// - `exc`: Callsign exception entry
98    ///
99    /// # Returns
100    ///
101    /// Callsign struct
102    fn from_exception(call: &str, exc: &CallsignException) -> Callsign {
103        Callsign {
104            call: String::from(call),
105            adif: exc.adif,
106            dxcc: Some(exc.entity.clone()),
107            cqzone: exc.cqz,
108            continent: exc.cont.clone(),
109            longitude: exc.long,
110            latitude: exc.lat,
111        }
112    }
113}
114
115/// Possible reasons for an invalid callsign
116#[derive(Error, Debug, PartialEq)]
117pub enum CallsignError {
118    /// Callsign is of invalid format or includes invalid characters
119    #[error("Callsign is of invalid format or includes invalid characters")]
120    BasicFormat,
121
122    /// Callsign was used in an invalid operation
123    #[error("Callsign was used in an invalid operation")]
124    InvalidOperation,
125
126    /// Callsign does not begin with a valid prefix
127    #[error("Callsign does not begin with a valid prefix")]
128    BeginWithoutPrefix,
129
130    /// Too much prefixes
131    #[error("Too much prefixes")]
132    TooMuchPrefixes,
133
134    /// Multiple special appendices like /MM, /AM or /6, /8, ...
135    #[error("Multiple special appendices")]
136    MultipleSpecialAppendices,
137}
138
139/// Special appendices that may not be interpreted as prefixes
140const APPENDIX_SPECIAL: [&str; 7] = ["AM", "MM", "SAT", "P", "M", "QRP", "LH"];
141
142/// Type of split
143#[derive(PartialEq, Eq)]
144enum PartType {
145    /// Prefix
146    Prefix,
147    /// Everything other than a prefix
148    Other,
149}
150
151/// State of the call element classification statemachine
152#[derive(PartialEq, Eq)]
153enum State {
154    /// No prefix found so far
155    NoPrefix,
156    /// Single prefix
157    SinglePrefix,
158    /// Double prefix
159    DoublePrefix,
160    /// Found complete prefix, only appendices may follow
161    PrefixComplete(u8),
162}
163
164/// Check if the callsign is whitelisted if the whitelist option is enabled for the entity of the callsign at the given point in time.
165///
166/// # Arguments
167///
168/// - `clublog`: Reference to ClubLog data
169/// - `call`: Callsign to check
170/// - `timestamp`: Timestamp to use for the check
171///
172/// # Returns
173///
174/// Returns true if the callsign is valid or false if whitelisting for that entity is enabled and the callsign is not on the whitelist.
175pub fn check_whitelist(
176    clublog: &dyn ClubLogQuery,
177    call: &Callsign,
178    timestamp: &DateTime<Utc>,
179) -> bool {
180    // Get entity for adif identifier
181    // Note that not all valid adif identifiers refer to an entity (e.g. aeronautical mobile calls)
182    if let Some(entity) = clublog.get_entity(call.adif, timestamp) {
183        // Check if whitelisting is enabled
184        if entity.whitelist == Some(true) {
185            // Check if an exception for the call at the given point in time is present
186            if let Some(prefix) = clublog.get_callsign_exception(&call.call, timestamp) {
187                // There may be a callsign exception for a whitelisted entity but the exception refers a different adif identifier
188                return prefix.adif == call.adif;
189            }
190
191            // Check if the given point in time is before the start of whitelisting for that entity
192            if let Some(whitelist_start) = entity.whitelist_start {
193                if *timestamp < whitelist_start {
194                    return true;
195                }
196            }
197
198            // Check if the given point in time is after the end of whitelisting for that entity
199            if let Some(whitelist_end) = entity.whitelist_end {
200                if *timestamp > whitelist_end {
201                    return true;
202                }
203            }
204
205            return false;
206        }
207    }
208
209    true
210}
211
212/// Analyze callsign to get further information like the name of the entity or the AIDF DXCC identifier.
213///
214/// # Arguments:
215///
216/// - `clublog`: Reference to ClubLog data
217/// - `call`: Callsign to analyze
218/// - `timestamp`: Timestamp to use for the check
219///
220/// # Returns
221///
222/// Returns further information about the callsign or an error.
223pub fn analyze_callsign(
224    clublog: &dyn ClubLogQuery,
225    call: &str,
226    timestamp: &DateTime<Utc>,
227) -> Result<Callsign, CallsignError> {
228    // Strategy
229    // Step 1: Check for an invalid operation
230    // Step 2: Check for a callsign exception
231    // Step 3: Classify each part of the callsign (split by '/') if it is a valid prefix or not
232    // Step 4: Check for basic validity of the callsign by using the classification results and categorize the call into generic callsign structures
233    // Step 5: Handle the call based on the determined category
234
235    lazy_static! {
236        static ref RE_COMPLETE_CALL: Regex = Regex::new(r"^[A-Z0-9]+[A-Z0-9/]*[A-Z0-9]+$").unwrap();
237    }
238
239    // Check that only allowed characters are present and the callsign does not begin or end with a /
240    if !RE_COMPLETE_CALL.is_match(call) {
241        return Err(CallsignError::BasicFormat);
242    }
243
244    // ### Step 1 ###
245    // Check if the callsign was used in an invalid operation
246    if clublog.is_invalid_operation(call, timestamp) {
247        return Err(CallsignError::InvalidOperation);
248    }
249
250    // ### Step 2 ###
251    // Check if clublog lists a callsign exception
252    if let Some(call_exc) = clublog.get_callsign_exception(call, timestamp) {
253        return Ok(Callsign::from_exception(call, call_exc));
254    }
255
256    // Split raw callsign into its parts
257    let parts: Vec<&str> = call.split('/').collect();
258
259    // ### Step 3 ###
260    // Iterate through all parts of the callsign and check wether the part of the callsigns is a valid prefix or something else
261    let mut parttypes: Vec<PartType> = Vec::with_capacity(parts.len());
262    for (pos, part) in parts.iter().enumerate() {
263        let pt = if get_prefix(clublog, part, timestamp, &parts[pos + 1..]).is_some() {
264            // MM and AM may be valid prefixes or special appendices depending on the position within the complete callsign.
265            // For example MM as a prefix evaluates to Scotland, MM as an appendix indicates a maritime mobile activation.
266            // Special appendices are only valid as those if they are right at the beginning of the callsign.
267            // Therefore ignore the first element of the call and check for special appendices beginning from the second element onwards.
268            if pos >= 1 && APPENDIX_SPECIAL.contains(part) {
269                PartType::Other
270            } else {
271                PartType::Prefix
272            }
273        } else {
274            PartType::Other
275        };
276        parttypes.push(pt);
277    }
278
279    // ### Step 4 ###
280    // Check for basic validity with a small statemachine.
281    // For example check that the call begins with a prefix, has not too much prefixes, ...
282    let mut state = State::NoPrefix;
283    for parttype in parttypes.iter() {
284        match (&state, parttype) {
285            (State::NoPrefix, PartType::Prefix) => state = State::SinglePrefix,
286            (State::NoPrefix, PartType::Other) => Err(CallsignError::BeginWithoutPrefix)?,
287            (State::SinglePrefix, PartType::Prefix) => state = State::DoublePrefix,
288            (State::SinglePrefix, PartType::Other) => state = State::PrefixComplete(1),
289            (State::DoublePrefix, PartType::Prefix) => state = State::PrefixComplete(3),
290            (State::DoublePrefix, PartType::Other) => state = State::PrefixComplete(2),
291            (State::PrefixComplete(_), PartType::Prefix) => Err(CallsignError::TooMuchPrefixes)?,
292            (State::PrefixComplete(_), PartType::Other) => (),
293        }
294    }
295
296    // ### Step 5 ###
297    match state {
298        // The callsign consists of a single prefix and zero or more appendices
299        State::SinglePrefix | State::PrefixComplete(1) => {
300            // Complete homecall
301            // Example: W1AW
302            let homecall = &parts[0];
303
304            // Prefix of the homecall
305            // Example: W for the homecall W1AW
306            // Unwrap is safe here, otherwise there is an internal error
307            let mut homecall_prefix = get_prefix(clublog, homecall, timestamp, &parts[1..])
308                .unwrap()
309                .0;
310
311            // Special appendix like /AM or /MM is present
312            // Example: W1ABC/AM
313            if is_no_entity_by_appendix(&parts[1..])? {
314                return Ok(Callsign::new_special_entity(call));
315            }
316
317            // Check if a single digit appendix is present
318            // If so, check if the single digit appendix changes the prefix to a different one
319            // Example: "SV0ABC/9" where SV is Greece, but SV9 is Crete
320            if let Some(pref) = is_different_prefix_by_single_digit_appendix(
321                clublog,
322                homecall,
323                timestamp,
324                &parts[1..],
325            )? {
326                homecall_prefix = pref;
327            }
328
329            // No special rule matched, just return information
330            let mut callsign = Callsign::from_prefix(call, homecall_prefix);
331            check_apply_cqzone_exception(clublog, &mut callsign, timestamp);
332            Ok(callsign)
333        }
334        // The callsign consists of two prefixes and zero or more appendices
335        State::DoublePrefix | State::PrefixComplete(2) => {
336            // Get prefix information for both prefixes.
337            let pref_first = get_prefix(clublog, parts[0], timestamp, &parts[1..]).unwrap();
338            let pref_second = get_prefix(clublog, parts[1], timestamp, &parts[2..]).unwrap();
339
340            // Check if the first prefix may be a valid special prefix like 3D2/R
341            // Example: "3D2ABC/R" contains two valid prefixes at first sight, 3D2 and R but the first and second prefix together form the special prefix 3D2/R
342            let pref = if pref_first.0.call.contains('/') {
343                pref_first.0
344            } else {
345                // Decide which one to use by how many characters were removed from the potential prefix before it matched a prefix from the list.
346                // The prefix which required less character removals wins.
347                // This is probably not 100% correct, but seems good enough.
348                if pref_first.1 <= pref_second.1 {
349                    pref_first.0
350                } else {
351                    pref_second.0
352                }
353            };
354
355            let mut callsign = Callsign::from_prefix(call, pref);
356            check_apply_cqzone_exception(clublog, &mut callsign, timestamp);
357            Ok(callsign)
358        }
359        // The callsign consists out of three prefixes and zero or more appendices
360        // This is a very special case and only takes account of calls with a special prefix like 3D2/R and therefore callsigns like 3D2/W1ABC/R.
361        // Calls like 3D2ABC/R are already covered, since there are only two potential valid prefixes.
362        // The call 3D2/W1ABC/R contains three potential valid prefixes 3D2, W and R but 3D2/R is the actual prefix (according to my understanding of the special prefix annotation)
363        State::PrefixComplete(3) => {
364            let pref = get_prefix(clublog, parts[0], timestamp, &parts[1..]).unwrap();
365            if pref.0.call.contains('/') {
366                let mut callsign = Callsign::from_prefix(call, pref.0);
367                check_apply_cqzone_exception(clublog, &mut callsign, timestamp);
368                Ok(callsign)
369            } else {
370                Err(CallsignError::TooMuchPrefixes)
371            }
372        }
373        _ => panic!("Internal error"),
374    }
375}
376
377/// Check if a CQ zone exception exists based on the gathered callsign information.
378/// If there is one, replace the CQ zone directly in the given callsign struct.
379///
380/// # Arguments
381///
382/// - `clublog`: Reference to ClubLog data
383/// - `call`: Gathered callsign information
384/// - `timestamp`: Timestamp to use for the check
385///
386/// # Returns
387/// (None)
388fn check_apply_cqzone_exception(
389    clublog: &dyn ClubLogQuery,
390    call: &mut Callsign,
391    timestamp: &DateTime<Utc>,
392) {
393    if let Some(cqz) = clublog.get_zone_exception(&call.call, timestamp) {
394        call.cqzone = Some(cqz);
395    }
396}
397
398/// Check if the list of appendices contains an appendix with a single digit that may indicate a different prefix.
399/// If there is such single digit appendix, replace the digit within the callsign and query the prefix information for the potential new prefix.
400///
401/// Example: "SV0ABC/9" where SV is Greece, but SV9 is Crete
402///
403/// # Arguments
404///
405/// - `clublog`: Reference to ClubLog data
406/// - `homecall`: Part of the complete callsign that is assumend to be the homecall
407/// - `timestamp`: Timestamp to use for the check
408/// - `appendices`: List of appendices to the homecall
409///
410/// # Returns
411///
412/// A potential new prefix, `None` if nothing changed or an error.
413fn is_different_prefix_by_single_digit_appendix<'a>(
414    clublog: &'a dyn ClubLogQuery,
415    homecall: &str,
416    timestamp: &DateTime<Utc>,
417    appendices: &[&str],
418) -> Result<Option<&'a Prefix>, CallsignError> {
419    lazy_static! {
420        static ref RE: Regex = Regex::new(r"^([A-Z0-9]+)(\d)([A-Z0-9]+)$").unwrap();
421    }
422
423    // Search for single digits in the list of appendices
424    let single_digits: Vec<&&str> = appendices
425        .iter()
426        .filter(|e| {
427            if e.len() == 1 {
428                e.chars().next().unwrap().is_numeric()
429            } else {
430                false
431            }
432        })
433        .collect();
434
435    // Act based on how much single digit appendices were found
436    let new_digit = match single_digits.len() {
437        // Nothing to do if there is no single digit
438        0 => return Ok(None),
439        // If there is only a single digit, take it
440        1 => single_digits[0],
441        // For multiple single digits throw an error -> not sure which one to choose? Ignoring all would also be unexpected behaviour
442        _ => return Err(CallsignError::MultipleSpecialAppendices),
443    };
444
445    // Assemble potential new intermediate call that will be used to check for a potential different prefix
446    let new_homecall = RE.replace(homecall, format!("${{1}}{}${{3}}", new_digit));
447
448    Ok(get_prefix(clublog, &new_homecall, timestamp, appendices).map(|i| i.0))
449}
450
451/// Check if a special appendix (`MM`, `AM`, `SAT`) is part of the appendices list.
452/// If such a speical appendix is present, it indicates that the actual prefix of the overall call shall be ignored.
453///
454/// Example: /MM indicates maritime mobile and therefore does not reference an entity
455///
456/// # Arguments
457///
458/// - `appendices`: List of callsign appendices, like `QRP`, `5`, ...
459///
460/// # Returns
461///
462/// True if a special prefix is present, false if not. Otherwise an error is returned.
463fn is_no_entity_by_appendix(appendices: &[&str]) -> Result<bool, CallsignError> {
464    let special_cnt = appendices
465        .iter()
466        .filter(|e| **e == "MM" || **e == "AM" || **e == "SAT")
467        .count();
468
469    // Act based on how much special appendices were found
470    match special_cnt {
471        // Zero found, nothing to do
472        0 => Ok(false),
473        // Single one found, return it
474        1 => Ok(true),
475        // Multiple found, throw an error -> which one to choose?
476        _ => Err(CallsignError::MultipleSpecialAppendices),
477    }
478}
479
480/// Search for a matching prefix by brutforcing all possibilities.
481/// The potential prefix will be shortened char by char from the back until a prefix matches.
482/// Furthermore, to take in account of prefixes like SV/A, append all single char appendices to the end of the potential prefix before checking for a match.
483///
484/// # Arguments
485///
486/// - `clublog`: Reference to ClubLog data
487/// - `potential_prefix`: Potential prefix to check against the data
488/// - `timestamp`: Timestamp to use for the check
489/// - `appendices`: List of callsign appendices, like `QRP`, `5`, ...
490///
491/// # Returns
492///
493/// If there is a match, next to the prefix information the number of removed chars is returned.
494fn get_prefix<'a>(
495    clublog: &'a dyn ClubLogQuery,
496    potential_prefix: &str,
497    timestamp: &DateTime<Utc>,
498    appendices: &[&str],
499) -> Option<(&'a Prefix, usize)> {
500    let len_potential_prefix = potential_prefix.len();
501    assert!(len_potential_prefix >= 1);
502
503    // Search for single char appendices
504    // For example SV/A is a valid prefix but indicates a different entity as the prefix SV
505    let single_char_appendices: Vec<&&str> = appendices
506        .iter()
507        .filter(|e| {
508            if e.len() == 1 {
509                e.chars().next().unwrap().is_alphabetic()
510            } else {
511                false
512            }
513        })
514        .collect();
515
516    // Bruteforce all possibilities
517    // Shortening the call from the back is required to due to calls like UA9ABC where both prefixes U and UA9 a potential matches,
518    // but the more explicit one is the correct one.
519    let mut prefix: Option<(&Prefix, usize)> = None;
520    for cnt in (1..len_potential_prefix + 1).rev() {
521        // Shortened call
522        let slice = &potential_prefix[0..cnt];
523
524        // Append all single chars to the call as <call>/<appendix> and check if the prefix is valid
525        // This check is required for prefixes like SV/A where the callsign SV1ABC/A shall match too
526        if let Some(pref) = single_char_appendices
527            .iter()
528            .find_map(|a| clublog.get_prefix(&format!("{}/{}", slice, a), timestamp))
529        {
530            prefix = Some((pref, len_potential_prefix - cnt));
531            break;
532        }
533
534        // Check if prefix is valid
535        if let Some(pref) = clublog.get_prefix(slice, timestamp) {
536            prefix = Some((pref, len_potential_prefix - cnt));
537            break;
538        }
539    }
540
541    prefix
542}
543
544#[cfg(test)]
545mod tests {
546    use super::*;
547    use crate::{clublog::ClubLog, clublogmap::ClubLogMap};
548    use lazy_static::lazy_static;
549    use std::fs;
550
551    fn read_clublog_xml() -> &'static dyn ClubLogQuery {
552        lazy_static! {
553            static ref CLUBLOG: ClubLogMap = ClubLogMap::from(
554                ClubLog::parse(&fs::read_to_string("data/clublog/cty.xml").unwrap()).unwrap()
555            );
556        }
557
558        &*CLUBLOG
559    }
560
561    #[test]
562    fn clublog_prefix_entity_invalid() {
563        let calls = vec!["X5ABC", "X5ABC/P", "X5/W1AW", "X5/W1AW/P"];
564
565        let clublog = read_clublog_xml();
566        for call in calls.iter() {
567            let res = analyze_callsign(
568                clublog,
569                call,
570                &DateTime::parse_from_rfc3339("2020-01-01T00:00:00Z")
571                    .unwrap()
572                    .into(),
573            );
574            assert_eq!(res, Err(CallsignError::BeginWithoutPrefix));
575        }
576    }
577
578    #[test]
579    fn clublog_special_appendix() {
580        let calls = vec![
581            ("KB5SIW/STS50", "2020-01-01T00:00:00Z"), // test for call exception record 2730
582            ("ZY0RK", "1994-08-20T00:00:00Z"),        // test for callsign exception record 28169
583        ];
584
585        let clublog = read_clublog_xml();
586        for call in calls.iter() {
587            println!("Test for: {}", call.0);
588            let res = analyze_callsign(
589                clublog,
590                call.0,
591                &DateTime::parse_from_rfc3339(call.1).unwrap().into(),
592            )
593            .unwrap();
594            assert!(res.is_special_entity());
595        }
596    }
597
598    #[test]
599    fn clublog_whitelist() {
600        let params = vec![
601            ("KH4AB", "1980-04-07T00:00:00Z", true), // Timestamp after start of whitelist and call is part of exception list
602            ("KH4AB", "1981-01-01T00:00:00Z", false), // Timestamp after start of whitelist and call not part of exception list
603        ];
604
605        let clublog = read_clublog_xml();
606
607        for param in params.iter() {
608            println!("Test for: {}", param.0);
609            let timestamp = &DateTime::parse_from_rfc3339(param.1).unwrap().into();
610            let call = analyze_callsign(clublog, param.0, timestamp).unwrap();
611            let res = check_whitelist(clublog, &call, timestamp);
612            assert_eq!(param.2, res);
613        }
614    }
615
616    #[test]
617    fn special_appendix_ok() {
618        let calls = vec![
619            // AM
620            "W1AW/AM",
621            "W1AM/P/AM",
622            "W1AW/AM/P",
623            "W1AW/P/AM/7",
624            // MM
625            "W1AW/MM",
626            "W1AM/P/MM",
627            "W1AW/MM/P",
628            "W1AW/P/MM/7",
629            // SAT
630            "W1AW/SAT",
631            "W1AM/P/SAT",
632            "W1AW/SAT/P",
633            "W1AW/P/SAT/7",
634        ];
635
636        let clublog = read_clublog_xml();
637
638        for call in calls.iter() {
639            let res = analyze_callsign(
640                clublog,
641                call,
642                &DateTime::parse_from_rfc3339("2020-01-01T00:00:00Z")
643                    .unwrap()
644                    .into(),
645            )
646            .unwrap();
647            assert!(res.is_special_entity());
648        }
649    }
650
651    #[test]
652    fn special_appendix_err() {
653        let calls = vec![
654            // AM
655            "W1AW/AM/SAT",
656            "W1AM/AM/MM",
657            "W1AW/AM/AM",
658            "W1AW/AM/MM/P",
659            "W1AW/P/AM/MM",
660            // MM
661            "W1AW/MM/SAT",
662            "W1AM/MM/MM",
663            "W1AW/MM/AM",
664            "W1AW/MM/MM/P",
665            "W1AW/P/MM/AM",
666            // SAT
667            "W1AW/SAT/SAT",
668            "W1AM/SAT/MM",
669            "W1AW/SAT/AM",
670            "W1AW/SAT/MM/P",
671            "W1AW/P/SAT/AM",
672            // Multiple numbers
673            "W1AW/8/9",
674        ];
675
676        let clublog = read_clublog_xml();
677
678        for call in calls.iter() {
679            let res = analyze_callsign(
680                clublog,
681                call,
682                &DateTime::parse_from_rfc3339("2020-01-01T00:00:00Z")
683                    .unwrap()
684                    .into(),
685            );
686            assert_eq!(res, Err(CallsignError::MultipleSpecialAppendices));
687        }
688    }
689
690    #[test]
691    fn special_entity_prefix() {
692        let calls = vec![
693            ("SV1ABC/A", "2020-01-01T00:00:00Z", 180),    // Prefix SV/A
694            ("SV2/W1AW/A", "2020-01-01T00:00:00Z", 180),  // Prefix SV/A
695            ("3D2ABC/R", "2020-01-01T00:00:00Z", 460), // Prefix 3D2/R, where 3D2 and R are potential valid prefixes too
696            ("3D2/W1ABC/R", "2020-01-01T00:00:00Z", 460), // Prefix 3D2/R, where 3D2 and R are potential valid prefixes too
697        ];
698
699        let clublog = read_clublog_xml();
700
701        for call in calls.iter() {
702            let res = analyze_callsign(
703                clublog,
704                call.0,
705                &DateTime::parse_from_rfc3339(call.1).unwrap().into(),
706            )
707            .unwrap();
708            assert_eq!(res.adif, call.2);
709        }
710    }
711
712    #[test]
713    fn cqzone_exception() {
714        let calls = vec![
715            ("W1CBY/VE8", "1993-07-01T00:00:00Z", 1), // Record 548
716            ("VE2BQB", "1992-01-01T00:00:00Z", 2),    // Record 35
717        ];
718
719        let clublog = read_clublog_xml();
720
721        for call in calls.iter() {
722            let res = analyze_callsign(
723                clublog,
724                call.0,
725                &DateTime::parse_from_rfc3339(call.1).unwrap().into(),
726            )
727            .unwrap();
728            assert_eq!(res.cqzone.unwrap(), call.2);
729        }
730    }
731
732    #[test]
733    fn call_exceptions() {
734        let calls = vec![
735            ("AM70URE/8", "2019-05-01T00:00:00Z", 29),
736            ("EA8VK/URE", "2021-01-01T00:00:00Z", 29),
737        ];
738
739        let clublog = read_clublog_xml();
740
741        for call in calls.iter() {
742            let res = analyze_callsign(
743                clublog,
744                call.0,
745                &DateTime::parse_from_rfc3339(call.1).unwrap().into(),
746            )
747            .unwrap();
748            assert_eq!(res.adif, call.2);
749        }
750    }
751
752    #[test]
753    fn invalid_operation() {
754        let calls = vec![
755            ("T8T", "1995-05-01T01:00:00Z"),       // record 490
756            ("3D2/N1GXE", "2021-01-01T00:00:00Z"), // record 1155
757        ];
758
759        let clublog = read_clublog_xml();
760
761        for call in calls.iter() {
762            let res = analyze_callsign(
763                clublog,
764                call.0,
765                &DateTime::parse_from_rfc3339(call.1).unwrap().into(),
766            );
767            assert_eq!(res, Err(CallsignError::InvalidOperation));
768        }
769    }
770
771    #[test]
772    fn genuine_calls() {
773        let calls = vec![
774            ("W1ABC", 291),     // Basic call
775            ("9A1ABC", 497),    // Call beginning with a number
776            ("A71AB", 376),     // Call with two digits, one belonging to the prefix
777            ("LM2T70Y", 266),   // Call with two separated numbers
778            ("UA9ABC", 15),     // Check that the call is not matched for the prefix U
779            ("U1ABC", 54),      // Counterexample for the test call above
780            ("SV0ABC/9", 40),   // SV is Greece, but SV9 is Crete
781            ("UA0JL/6", 54),    // UA0 is Asiatic Russia, but UA6 is European Russia
782            ("MM/W1AW", 279),   // MM is Scotland and not Maritime Mobile
783            ("F/W1AW", 227),    // F is France
784            ("CE0Y/W1ABC", 47), // CE0Y is Easter Island, but CE would be Chile
785            ("W1ABC/CE0Y", 47), // CE0Y is Easter Island, but CE would be Chile
786            ("RW0A", 15),       // Call is also a prefix
787            ("LS4AA/F", 227),   // LS is Argentina but F is France
788            ("VE3LYC/KL7", 6),  // KL is Alaska
789        ];
790
791        let clublog = read_clublog_xml();
792
793        for call in calls.iter() {
794            println!("Test for: {}", call.0);
795            let res = analyze_callsign(
796                clublog,
797                call.0,
798                &DateTime::parse_from_rfc3339("2020-01-01T00:00:00Z")
799                    .unwrap()
800                    .into(),
801            )
802            .unwrap();
803            assert_eq!(res.adif, call.1);
804        }
805    }
806
807    #[test]
808    fn invalid_format() {
809        let calls = vec!["W1AW/", "/W1AW", "W1ABC.", "W1ABC/.", "W1<ABC>"];
810
811        let clublog = read_clublog_xml();
812
813        for call in calls.iter() {
814            let res = analyze_callsign(
815                clublog,
816                call,
817                &DateTime::parse_from_rfc3339("2020-01-01T00:00:00Z")
818                    .unwrap()
819                    .into(),
820            );
821            assert_eq!(res, Err(CallsignError::BasicFormat));
822        }
823    }
824
825    #[test]
826    fn too_much_prefixes() {
827        let calls = vec!["W/K/W1AW", "W1AW/K/W", "K/W1AW/W"];
828
829        let clublog = read_clublog_xml();
830
831        for call in calls.iter() {
832            let res = analyze_callsign(
833                clublog,
834                call,
835                &DateTime::parse_from_rfc3339("2020-01-01T00:00:00Z")
836                    .unwrap()
837                    .into(),
838            );
839            assert_eq!(res, Err(CallsignError::TooMuchPrefixes));
840        }
841    }
842}