From 14aad68aeb79e7c8afed85b511ae19527ef1c327 Mon Sep 17 00:00:00 2001 From: Rudi Zhang Date: Fri, 2 Oct 2026 23:43:19 +0200 Subject: [PATCH 1/2] filter ATC online time by rating and position eligibility --- e2e/lib/api/schema.ts | 9 +- e2e/src/atc/online-time.test.ts | 6 +- src/adapter/compat.rs | 11 + src/modules/atc_position/repository.rs | 14 ++ src/modules/controller/dto.rs | 7 +- src/modules/controller/models.rs | 2 +- src/modules/controller/service.rs | 268 ++++++++++++++++++++++--- 7 files changed, 277 insertions(+), 40 deletions(-) diff --git a/e2e/lib/api/schema.ts b/e2e/lib/api/schema.ts index f377d20..30b193d 100644 --- a/e2e/lib/api/schema.ts +++ b/e2e/lib/api/schema.ts @@ -1371,7 +1371,7 @@ export type components = { last_updated: string; pilots: components["schemas"]["CompatPilotDto"][]; }; - /** @description Seconds grouped by position suffix, independently of the controller's rating. */ + /** @description Eligible seconds grouped by the controller rating required for the position. */ ControllerOnlineTimeByPosition: { /** * Format: int64 @@ -1398,8 +1398,7 @@ export type components = { /** Format: date-time */ as_of: string; /** - * @description Current-quarter seconds for S1 (GND/DEL/RMP), S2 (TWR), S3 (APP), C1+ (CTR). - * DEP and FSS count toward total_seconds only. + * @description Current-quarter eligible seconds for S1 (GND/DEL/RMP), S2 (TWR), S3 (APP), C1+ (CTR). */ by_position: components["schemas"]["ControllerOnlineTimeByPosition"]; lifetime: components["schemas"]["ControllerOnlineTimeSummaryDto"]; @@ -1410,11 +1409,11 @@ export type components = { total_seconds: number; }; ControllerOnlineTimeSummaryDto: { - /** @description Position groups in seconds. DEP and FSS count toward total_seconds only. */ + /** @description Eligible position groups in seconds. */ by_position: components["schemas"]["ControllerOnlineTimeByPosition"]; /** * Format: int64 - * @description All recorded VATPRC controlling seconds, including the current session. + * @description All eligible VATPRC controlling seconds, including the current session. */ total_seconds: number; }; diff --git a/e2e/src/atc/online-time.test.ts b/e2e/src/atc/online-time.test.ts index dff8516..86f831b 100644 --- a/e2e/src/atc/online-time.test.ts +++ b/e2e/src/atc/online-time.test.ts @@ -2,7 +2,9 @@ import { expect, test } from "vitest"; import { getClient } from "../../lib/backend.js"; test("GET /api/users/me/atc/online-time returns quarter and lifetime totals by position", async () => { - const controller = await getClient(["controller"], { cid: "1573922" }); + const controller = await getClient(["controller"], { + cid: process.env.E2E_CONTROLLER_CID ?? "1573922", + }); const { data, error, response } = await controller.GET( "/api/users/me/atc/online-time", @@ -55,6 +57,6 @@ test("GET /api/users/me/atc/online-time returns quarter and lifetime totals by p for (const summary of [data, data.lifetime]) { expect( Object.values(summary.by_position).reduce((a, b) => a + b, 0), - ).toBeLessThanOrEqual(summary.total_seconds); + ).toBe(summary.total_seconds); } }); diff --git a/src/adapter/compat.rs b/src/adapter/compat.rs index 14f7faf..f21331b 100644 --- a/src/adapter/compat.rs +++ b/src/adapter/compat.rs @@ -71,6 +71,12 @@ impl CompatClient { Ok(serde_json::from_str(&self.cached_get(&url).await?)?) } + #[instrument(skip(self), fields(cid = %cid))] + pub async fn get_member(&self, cid: &str) -> Result { + let url = format!("{VATSIM_CORE_BASE_URL}/members/{cid}"); + Ok(serde_json::from_str(&self.cached_get(&url).await?)?) + } + #[instrument(skip(self))] pub async fn get_track_audio_version(&self) -> Result { self.cached_get(TRACK_AUDIO_VERSION_URL).await @@ -231,3 +237,8 @@ pub struct AtcConnection { pub start: Option>, pub end: Option>, } + +#[derive(Debug, Deserialize)] +pub struct VatsimMember { + pub rating: i32, +} diff --git a/src/modules/atc_position/repository.rs b/src/modules/atc_position/repository.rs index 14a562a..5d30845 100644 --- a/src/modules/atc_position/repository.rs +++ b/src/modules/atc_position/repository.rs @@ -12,6 +12,7 @@ const SELECT: &str = r#" pub(crate) trait AtcPositionRepository<'executor> { async fn list_atc_positions(self) -> Result, sqlx::Error>; + async fn list_tier_2_atc_position_callsigns(self) -> Result, sqlx::Error>; async fn find_atc_position(self, callsign: &str) -> Result, sqlx::Error>; async fn find_atc_position_for_update( self, @@ -51,6 +52,19 @@ where .await } + async fn list_tier_2_atc_position_callsigns(self) -> Result, sqlx::Error> { + sqlx::query_scalar( + r#" + SELECT callsign + FROM public.atc_position + WHERE is_tier_2 + ORDER BY callsign + "#, + ) + .fetch_all(self) + .await + } + async fn find_atc_position(self, callsign: &str) -> Result, sqlx::Error> { sqlx::query_as::<_, AtcPosition>(&format!("{SELECT} WHERE callsign = $1")) .bind(callsign) diff --git a/src/modules/controller/dto.rs b/src/modules/controller/dto.rs index 8c91e3d..1c5fbe4 100644 --- a/src/modules/controller/dto.rs +++ b/src/modules/controller/dto.rs @@ -20,17 +20,16 @@ pub struct ControllerOnlineTimeDto { pub period_start: DateTime, pub as_of: DateTime, pub total_seconds: u64, - /// Current-quarter seconds for S1 (GND/DEL/RMP), S2 (TWR), S3 (APP), C1+ (CTR). - /// DEP and FSS count toward total_seconds only. + /// Current-quarter eligible seconds for S1 (GND/DEL/RMP), S2 (TWR), S3 (APP), C1+ (CTR). pub by_position: ControllerOnlineTimeByPosition, pub lifetime: ControllerOnlineTimeSummaryDto, } #[derive(Serialize, utoipa::ToSchema)] pub struct ControllerOnlineTimeSummaryDto { - /// All recorded VATPRC controlling seconds, including the current session. + /// All eligible VATPRC controlling seconds, including the current session. pub total_seconds: u64, - /// Position groups in seconds. DEP and FSS count toward total_seconds only. + /// Eligible position groups in seconds. pub by_position: ControllerOnlineTimeByPosition, } diff --git a/src/modules/controller/models.rs b/src/modules/controller/models.rs index 0b373ad..1f3fa67 100644 --- a/src/modules/controller/models.rs +++ b/src/modules/controller/models.rs @@ -26,7 +26,7 @@ pub struct ControllerOnlineTimeSummary { pub by_position: ControllerOnlineTimeByPosition, } -/// Seconds grouped by position suffix, independently of the controller's rating. +/// Eligible seconds grouped by the controller rating required for the position. #[derive(Default, Debug, PartialEq, Eq, Serialize, utoipa::ToSchema)] pub struct ControllerOnlineTimeByPosition { /// GND, DEL and RMP seconds. diff --git a/src/modules/controller/service.rs b/src/modules/controller/service.rs index bdd8708..a9075ad 100644 --- a/src/modules/controller/service.rs +++ b/src/modules/controller/service.rs @@ -1,10 +1,11 @@ -use std::collections::BTreeMap; +use std::collections::{BTreeMap, HashSet}; use chrono::{DateTime, Datelike, TimeZone, Utc}; use sqlx::{PgPool, Postgres, Transaction}; use uuid::Uuid; use crate::adapter::compat::{AtcConnection, CompatClient, CompatClientError}; +use crate::modules::atc_position::repository::AtcPositionRepository; use crate::modules::audit_log::models::AuditLogEntity; use crate::modules::audit_log::service::{AuditLogService, AuditLogServiceError}; use crate::modules::user::service::user::{UserService, UserServiceError}; @@ -155,11 +156,18 @@ impl ControllerService { .ok_or(ControllerServiceError::UserNotFound(user_id))?; let as_of = Utc::now(); let period_start = current_quarter_start(as_of); - let (sessions, online_data) = tokio::join!( + let (sessions, online_data, member, tier_2_callsigns) = tokio::join!( self.member_atc_sessions(&user.cid), self.compat.get_online_data(), + self.compat.get_member(&user.cid), + self.db.list_tier_2_atc_position_callsigns(), ); let sessions = sessions?; + let rating = member?.rating; + let tier_2_callsigns = tier_2_callsigns? + .into_iter() + .map(|callsign| callsign.to_ascii_uppercase()) + .collect::>(); let mut quarter = ControllerOnlineTimeSummary::default(); let mut lifetime = ControllerOnlineTimeSummary::default(); @@ -173,6 +181,8 @@ impl ControllerService { end, period_start, as_of, + rating, + &tier_2_callsigns, ); } } @@ -192,6 +202,8 @@ impl ControllerService { as_of, period_start, as_of, + rating, + &tier_2_callsigns, ); } } @@ -248,43 +260,129 @@ fn accumulate_session( end: DateTime, period_start: DateTime, as_of: DateTime, + rating: i32, + tier_2_callsigns: &HashSet, ) { + let Some(position) = eligible_position(callsign, rating, tier_2_callsigns) else { + return; + }; + for (summary, since) in [(quarter, period_start), (lifetime, start)] { - if let Some(seconds) = session_seconds(callsign, start, end, since, as_of) { + if let Some(seconds) = overlap_seconds(start, end, since, as_of) { summary.total_seconds += seconds; - match callsign - .rsplit('_') - .next() - .unwrap_or_default() - .to_ascii_uppercase() - .as_str() - { - "GND" | "DEL" | "RMP" => summary.by_position.s1 += seconds, - "TWR" => summary.by_position.s2 += seconds, - "APP" => summary.by_position.s3 += seconds, - "CTR" => summary.by_position.c1_plus += seconds, - _ => {} + match position { + OnlineTimePosition::S1 => summary.by_position.s1 += seconds, + OnlineTimePosition::S2 => summary.by_position.s2 += seconds, + OnlineTimePosition::S3 => summary.by_position.s3 += seconds, + OnlineTimePosition::C1Plus => summary.by_position.c1_plus += seconds, } } } } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum OnlineTimePosition { + S1, + S2, + S3, + C1Plus, +} + +#[cfg(test)] fn session_seconds( callsign: &str, start: DateTime, end: DateTime, period_start: DateTime, as_of: DateTime, + rating: i32, + tier_2_callsigns: &HashSet, ) -> Option { - if !is_vatprc_position(callsign) { - return None; - } + eligible_position(callsign, rating, tier_2_callsigns)?; + overlap_seconds(start, end, period_start, as_of) +} +fn overlap_seconds( + start: DateTime, + end: DateTime, + period_start: DateTime, + as_of: DateTime, +) -> Option { let overlap_start = start.max(period_start); let overlap_end = end.min(as_of); (overlap_end > overlap_start).then(|| (overlap_end - overlap_start).num_seconds() as u64) } +fn eligible_position( + callsign: &str, + rating: i32, + tier_2_callsigns: &HashSet, +) -> Option { + let callsign = callsign.to_ascii_uppercase(); + if !is_vatprc_position(&callsign) + || is_training_position(&callsign) + || is_military_position(&callsign) + || is_tier_2_position(&callsign, tier_2_callsigns) + { + return None; + } + + let position = match callsign.rsplit('_').next()? { + "GND" | "DEL" | "RMP" => OnlineTimePosition::S1, + "TWR" => OnlineTimePosition::S2, + "APP" => OnlineTimePosition::S3, + "CTR" => OnlineTimePosition::C1Plus, + _ => return None, + }; + + rating_allows_position(rating, position).then_some(position) +} + +fn rating_allows_position(rating: i32, position: OnlineTimePosition) -> bool { + match rating { + 2 => matches!(position, OnlineTimePosition::S1 | OnlineTimePosition::S2), + 3 => matches!( + position, + OnlineTimePosition::S1 | OnlineTimePosition::S2 | OnlineTimePosition::S3 + ), + 4 => matches!( + position, + OnlineTimePosition::S2 | OnlineTimePosition::S3 | OnlineTimePosition::C1Plus + ), + 5..=12 => matches!( + position, + OnlineTimePosition::S3 | OnlineTimePosition::C1Plus + ), + _ => false, + } +} + +fn is_training_position(callsign: &str) -> bool { + let parts = callsign.split('_').collect::>(); + parts.len() > 2 + && parts[1..parts.len() - 1].iter().any(|part| { + let Some(suffix) = part.strip_prefix('X').or_else(|| part.strip_prefix('I')) else { + return false; + }; + suffix.bytes().all(|byte| byte.is_ascii_digit()) + }) +} + +fn is_military_position(callsign: &str) -> bool { + let parts = callsign.split('_').collect::>(); + parts.len() > 2 && parts[1..parts.len() - 1].contains(&"MIL") +} + +fn is_tier_2_position(callsign: &str, tier_2_callsigns: &HashSet) -> bool { + tier_2_callsigns.iter().any(|tier_2_callsign| { + tier_2_callsign + .strip_prefix('*') + .map_or(callsign == tier_2_callsign, |suffix| { + callsign.ends_with(suffix) + }) + }) +} + fn is_vatprc_position(callsign: &str) -> bool { let callsign = callsign.to_ascii_uppercase(); let parts = callsign.split('_').collect::>(); @@ -300,10 +398,7 @@ fn is_vatprc_position(callsign: &str) -> bool { && prefix[0] == b'Z' && b"BGHJLPSUWY".contains(&prefix[1]) && prefix[2..].iter().all(u8::is_ascii_alphabetic) - && matches!( - *position, - "DEL" | "GND" | "RMP" | "TWR" | "APP" | "DEP" | "CTR" | "FSS" - ) + && matches!(*position, "DEL" | "GND" | "RMP" | "TWR" | "APP" | "CTR") } fn controller(row: &AtcControllerPermissionRecord) -> Result { @@ -449,20 +544,127 @@ mod tests { #[test] fn recognizes_vatprc_controlling_positions() { - for callsign in ["ZBAA_TWR", "ZSHA_E_CTR", "zgzu_app", "ZUUU_2_GND"] { + for callsign in [ + "ZBAA_TWR", + "ZSAA_TWR", + "ZGAA_TWR", + "ZPAA_TWR", + "ZUAA_TWR", + "ZLAA_TWR", + "ZWAA_TWR", + "ZHAA_TWR", + "ZJAA_TWR", + "ZYAA_TWR", + "ZSHA_E_CTR", + "zgzu_app", + "ZUUU_2_GND", + ] { assert!(is_vatprc_position(callsign), "{callsign}"); } - for callsign in ["VHHH_TWR", "ZKPY_CTR", "ZBAA_OBS", "ZBAA_ATIS", "PRC_FSS"] { + for callsign in [ + "VHHH_TWR", + "ZKPY_CTR", + "ZMAA_TWR", + "ZBAA_OBS", + "ZBAA_ATIS", + "ZBAA_DEP", + "ZSHA_FSS", + ] { assert!(!is_vatprc_position(callsign), "{callsign}"); } } + #[test] + fn allows_only_positions_within_one_rating_level() { + let tier_2_callsigns = HashSet::new(); + for (rating, allowed) in [ + (2, [true, true, false, false]), + (3, [true, true, true, false]), + (4, [false, true, true, true]), + (5, [false, false, true, true]), + (7, [false, false, true, true]), + (8, [false, false, true, true]), + (10, [false, false, true, true]), + (11, [false, false, true, true]), + (12, [false, false, true, true]), + ] { + for (callsign, expected) in ["ZBAA_GND", "ZBAA_TWR", "ZBAA_APP", "ZBAA_CTR"] + .into_iter() + .zip(allowed) + { + assert_eq!( + eligible_position(callsign, rating, &tier_2_callsigns).is_some(), + expected, + "rating {rating}, callsign {callsign}" + ); + } + } + + for rating in [-1, 0, 1, 13] { + assert!(eligible_position("ZBAA_TWR", rating, &tier_2_callsigns).is_none()); + } + } + + #[test] + fn excludes_training_tier_2_and_non_vatprc_positions() { + let tier_2_callsigns = ["ZBAL_TWR", "*_MIL_TWR"] + .into_iter() + .map(str::to_owned) + .collect(); + + for callsign in [ + "ZBAA_X_CTR", + "ZBAA_X1_CTR", + "ZBAA__X2__CTR", + "ZBAA__I_CTR", + "ZBAA_I__CTR", + "ZBAA__I1__CTR", + "ZBAA_I2_CTR", + "zbaa_i2_ctr", + ] { + assert!( + eligible_position(callsign, 5, &tier_2_callsigns).is_none(), + "{callsign}" + ); + } + for callsign in ["ZBAA_W_CTR", "ZBAA_2_CTR", "ZBAA_IX_CTR"] { + assert!( + eligible_position(callsign, 5, &tier_2_callsigns).is_some(), + "{callsign}" + ); + } + for callsign in ["ZBAL_TWR", "ZBAA_MIL_TWR"] { + assert!( + eligible_position(callsign, 3, &tier_2_callsigns).is_none(), + "{callsign}" + ); + } + for callsign in [ + "ZBAA_MIL_TWR", + "ZBAA__MIL__APP", + "ZBAA_MIL_CTR", + "zbaa_mil_gnd", + ] { + assert!( + eligible_position(callsign, 5, &HashSet::new()).is_none(), + "{callsign}" + ); + } + for callsign in ["VHHH_TWR", "ZKPY_CTR", "ZBAA_DEP", "ZSHA_FSS"] { + assert!( + eligible_position(callsign, 5, &tier_2_callsigns).is_none(), + "{callsign}" + ); + } + } + #[test] fn groups_lifetime_and_quarter_seconds_by_position() { let period_start = time("2026-07-01T00:00:00Z"); let as_of = time("2026-07-01T01:00:00Z"); let mut quarter = ControllerOnlineTimeSummary::default(); let mut lifetime = ControllerOnlineTimeSummary::default(); + let tier_2_callsigns = HashSet::new(); for callsign in [ "ZBAA_GND", "ZBAA_DEL", @@ -483,18 +685,20 @@ mod tests { time("2026-07-01T02:00:00Z"), period_start, as_of, + 3, + &tier_2_callsigns, ); } - assert_eq!(quarter.total_seconds, 8 * 3600); - assert_eq!(lifetime.total_seconds, 8 * 7200); + assert_eq!(quarter.total_seconds, 5 * 3600); + assert_eq!(lifetime.total_seconds, 5 * 7200); assert_eq!(quarter.by_position.s1, 3 * 3600); assert_eq!(quarter.by_position.s2, 3600); assert_eq!(quarter.by_position.s3, 3600); - assert_eq!(quarter.by_position.c1_plus, 3600); + assert_eq!(quarter.by_position.c1_plus, 0); assert_eq!(lifetime.by_position.s1, 3 * 7200); assert_eq!(lifetime.by_position.s2, 7200); assert_eq!(lifetime.by_position.s3, 7200); - assert_eq!(lifetime.by_position.c1_plus, 7200); + assert_eq!(lifetime.by_position.c1_plus, 0); } #[test] @@ -503,6 +707,7 @@ mod tests { let as_of = time("2026-07-01T01:00:00Z"); let mut quarter = ControllerOnlineTimeSummary::default(); let mut lifetime = ControllerOnlineTimeSummary::default(); + let tier_2_callsigns = HashSet::new(); for (start, end) in [ ("2026-06-01T00:00:00Z", "2026-06-01T01:00:00Z"), ("2026-07-01T00:30:00Z", "2026-07-01T01:00:00Z"), @@ -517,6 +722,8 @@ mod tests { time(end), period_start, as_of, + 3, + &tier_2_callsigns, ); } assert_eq!(quarter.total_seconds, 1800); @@ -529,6 +736,7 @@ mod tests { fn counts_only_the_part_of_a_session_inside_the_quarter() { let period_start = time("2026-07-01T00:00:00Z"); let as_of = time("2026-08-23T12:00:00Z"); + let tier_2_callsigns = HashSet::new(); assert_eq!( session_seconds( @@ -537,6 +745,8 @@ mod tests { time("2026-07-01T02:30:00Z"), period_start, as_of, + 3, + &tier_2_callsigns, ), Some(9_000) ); @@ -547,6 +757,8 @@ mod tests { as_of, period_start, as_of, + 3, + &tier_2_callsigns, ), None ); From 31e9ed7e1045c87ce0d696c04e6c64bea5c67b96 Mon Sep 17 00:00:00 2001 From: Rudi Zhang Date: Sat, 3 Oct 2026 09:40:24 +0200 Subject: [PATCH 2/2] refactor: address ATC online time review feedback --- src/adapter/compat.rs | 61 ++++++++- src/modules/controller/service.rs | 219 +++++++++++++++++------------- 2 files changed, 186 insertions(+), 94 deletions(-) diff --git a/src/adapter/compat.rs b/src/adapter/compat.rs index f21331b..428ba57 100644 --- a/src/adapter/compat.rs +++ b/src/adapter/compat.rs @@ -240,5 +240,64 @@ pub struct AtcConnection { #[derive(Debug, Deserialize)] pub struct VatsimMember { - pub rating: i32, + pub rating: VatsimRating, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] +#[serde(try_from = "i32")] +pub enum VatsimRating { + Inactive, + Suspended, + Observer, + S1, + S2, + S3, + C1, + C2, + C3, + I1, + I2, + I3, + Supervisor, + Administrator, +} + +impl TryFrom for VatsimRating { + type Error = String; + + fn try_from(value: i32) -> Result { + match value { + -1 => Ok(Self::Inactive), + 0 => Ok(Self::Suspended), + 1 => Ok(Self::Observer), + 2 => Ok(Self::S1), + 3 => Ok(Self::S2), + 4 => Ok(Self::S3), + 5 => Ok(Self::C1), + 6 => Ok(Self::C2), + 7 => Ok(Self::C3), + 8 => Ok(Self::I1), + 9 => Ok(Self::I2), + 10 => Ok(Self::I3), + 11 => Ok(Self::Supervisor), + 12 => Ok(Self::Administrator), + _ => Err(format!("invalid VATSIM controller rating {value}")), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn deserializes_vatsim_member_rating_to_enum() { + let member = serde_json::from_str::(r#"{"rating":11}"#).unwrap(); + assert_eq!(member.rating, VatsimRating::Supervisor); + } + + #[test] + fn rejects_unknown_vatsim_member_rating() { + assert!(serde_json::from_str::(r#"{"rating":13}"#).is_err()); + } } diff --git a/src/modules/controller/service.rs b/src/modules/controller/service.rs index a9075ad..ea6e4a9 100644 --- a/src/modules/controller/service.rs +++ b/src/modules/controller/service.rs @@ -4,7 +4,7 @@ use chrono::{DateTime, Datelike, TimeZone, Utc}; use sqlx::{PgPool, Postgres, Transaction}; use uuid::Uuid; -use crate::adapter::compat::{AtcConnection, CompatClient, CompatClientError}; +use crate::adapter::compat::{AtcConnection, CompatClient, CompatClientError, VatsimRating}; use crate::modules::atc_position::repository::AtcPositionRepository; use crate::modules::audit_log::models::AuditLogEntity; use crate::modules::audit_log::service::{AuditLogService, AuditLogServiceError}; @@ -260,7 +260,7 @@ fn accumulate_session( end: DateTime, period_start: DateTime, as_of: DateTime, - rating: i32, + rating: VatsimRating, tier_2_callsigns: &HashSet, ) { let Some(position) = eligible_position(callsign, rating, tier_2_callsigns) else { @@ -295,7 +295,7 @@ fn session_seconds( end: DateTime, period_start: DateTime, as_of: DateTime, - rating: i32, + rating: VatsimRating, tier_2_callsigns: &HashSet, ) -> Option { eligible_position(callsign, rating, tier_2_callsigns)?; @@ -315,41 +315,40 @@ fn overlap_seconds( fn eligible_position( callsign: &str, - rating: i32, + rating: VatsimRating, tier_2_callsigns: &HashSet, ) -> Option { - let callsign = callsign.to_ascii_uppercase(); - if !is_vatprc_position(&callsign) - || is_training_position(&callsign) - || is_military_position(&callsign) - || is_tier_2_position(&callsign, tier_2_callsigns) - { + let metadata = CallsignMetadata::parse(callsign, tier_2_callsigns); + if !metadata.is_vatprc || metadata.is_training || metadata.is_military || metadata.is_tier_2 { return None; } - let position = match callsign.rsplit('_').next()? { - "GND" | "DEL" | "RMP" => OnlineTimePosition::S1, - "TWR" => OnlineTimePosition::S2, - "APP" => OnlineTimePosition::S3, - "CTR" => OnlineTimePosition::C1Plus, - _ => return None, - }; + let position = metadata.position?; rating_allows_position(rating, position).then_some(position) } -fn rating_allows_position(rating: i32, position: OnlineTimePosition) -> bool { +fn rating_allows_position(rating: VatsimRating, position: OnlineTimePosition) -> bool { match rating { - 2 => matches!(position, OnlineTimePosition::S1 | OnlineTimePosition::S2), - 3 => matches!( + VatsimRating::S1 => { + matches!(position, OnlineTimePosition::S1 | OnlineTimePosition::S2) + } + VatsimRating::S2 => matches!( position, OnlineTimePosition::S1 | OnlineTimePosition::S2 | OnlineTimePosition::S3 ), - 4 => matches!( + VatsimRating::S3 => matches!( position, OnlineTimePosition::S2 | OnlineTimePosition::S3 | OnlineTimePosition::C1Plus ), - 5..=12 => matches!( + VatsimRating::C1 + | VatsimRating::C2 + | VatsimRating::C3 + | VatsimRating::I1 + | VatsimRating::I2 + | VatsimRating::I3 + | VatsimRating::Supervisor + | VatsimRating::Administrator => matches!( position, OnlineTimePosition::S3 | OnlineTimePosition::C1Plus ), @@ -357,48 +356,49 @@ fn rating_allows_position(rating: i32, position: OnlineTimePosition) -> bool { } } -fn is_training_position(callsign: &str) -> bool { - let parts = callsign.split('_').collect::>(); - parts.len() > 2 - && parts[1..parts.len() - 1].iter().any(|part| { - let Some(suffix) = part.strip_prefix('X').or_else(|| part.strip_prefix('I')) else { - return false; - }; - suffix.bytes().all(|byte| byte.is_ascii_digit()) - }) -} - -fn is_military_position(callsign: &str) -> bool { - let parts = callsign.split('_').collect::>(); - parts.len() > 2 && parts[1..parts.len() - 1].contains(&"MIL") +#[derive(Debug, PartialEq, Eq)] +struct CallsignMetadata { + position: Option, + is_vatprc: bool, + is_training: bool, + is_military: bool, + is_tier_2: bool, } -fn is_tier_2_position(callsign: &str, tier_2_callsigns: &HashSet) -> bool { - tier_2_callsigns.iter().any(|tier_2_callsign| { - tier_2_callsign - .strip_prefix('*') - .map_or(callsign == tier_2_callsign, |suffix| { - callsign.ends_with(suffix) - }) - }) -} - -fn is_vatprc_position(callsign: &str) -> bool { - let callsign = callsign.to_ascii_uppercase(); - let parts = callsign.split('_').collect::>(); - let Some(prefix) = parts.first() else { - return false; - }; - let Some(position) = parts.last() else { - return false; - }; - let prefix = prefix.as_bytes(); +impl CallsignMetadata { + fn parse(callsign: &str, tier_2_callsigns: &HashSet) -> Self { + let callsign = callsign.to_ascii_uppercase(); + let parts = callsign.split('_').collect::>(); + let prefix = parts.first().copied().unwrap_or_default().as_bytes(); + let position = parts.last().and_then(|part| match *part { + "GND" | "DEL" | "RMP" => Some(OnlineTimePosition::S1), + "TWR" => Some(OnlineTimePosition::S2), + "APP" => Some(OnlineTimePosition::S3), + "CTR" => Some(OnlineTimePosition::C1Plus), + _ => None, + }); + let middle_parts = if parts.len() > 2 { + &parts[1..parts.len() - 1] + } else { + &[] + }; + let is_military = callsign.contains("_MIL_"); - prefix.len() == 4 - && prefix[0] == b'Z' - && b"BGHJLPSUWY".contains(&prefix[1]) - && prefix[2..].iter().all(u8::is_ascii_alphabetic) - && matches!(*position, "DEL" | "GND" | "RMP" | "TWR" | "APP" | "CTR") + Self { + position, + is_vatprc: prefix.len() == 4 + && prefix[0] == b'Z' + && b"BGHJLPSUWY".contains(&prefix[1]) + && prefix[2..].iter().all(u8::is_ascii_alphabetic) + && position.is_some(), + is_training: !is_military + && middle_parts + .iter() + .any(|part| part.contains('X') || part.contains('I')), + is_military, + is_tier_2: tier_2_callsigns.contains(&callsign), + } + } } fn controller(row: &AtcControllerPermissionRecord) -> Result { @@ -559,7 +559,10 @@ mod tests { "zgzu_app", "ZUUU_2_GND", ] { - assert!(is_vatprc_position(callsign), "{callsign}"); + assert!( + CallsignMetadata::parse(callsign, &HashSet::new()).is_vatprc, + "{callsign}" + ); } for callsign in [ "VHHH_TWR", @@ -570,7 +573,10 @@ mod tests { "ZBAA_DEP", "ZSHA_FSS", ] { - assert!(!is_vatprc_position(callsign), "{callsign}"); + assert!( + !CallsignMetadata::parse(callsign, &HashSet::new()).is_vatprc, + "{callsign}" + ); } } @@ -578,15 +584,15 @@ mod tests { fn allows_only_positions_within_one_rating_level() { let tier_2_callsigns = HashSet::new(); for (rating, allowed) in [ - (2, [true, true, false, false]), - (3, [true, true, true, false]), - (4, [false, true, true, true]), - (5, [false, false, true, true]), - (7, [false, false, true, true]), - (8, [false, false, true, true]), - (10, [false, false, true, true]), - (11, [false, false, true, true]), - (12, [false, false, true, true]), + (VatsimRating::S1, [true, true, false, false]), + (VatsimRating::S2, [true, true, true, false]), + (VatsimRating::S3, [false, true, true, true]), + (VatsimRating::C1, [false, false, true, true]), + (VatsimRating::C3, [false, false, true, true]), + (VatsimRating::I1, [false, false, true, true]), + (VatsimRating::I3, [false, false, true, true]), + (VatsimRating::Supervisor, [false, false, true, true]), + (VatsimRating::Administrator, [false, false, true, true]), ] { for (callsign, expected) in ["ZBAA_GND", "ZBAA_TWR", "ZBAA_APP", "ZBAA_CTR"] .into_iter() @@ -595,22 +601,23 @@ mod tests { assert_eq!( eligible_position(callsign, rating, &tier_2_callsigns).is_some(), expected, - "rating {rating}, callsign {callsign}" + "rating {rating:?}, callsign {callsign}" ); } } - for rating in [-1, 0, 1, 13] { + for rating in [ + VatsimRating::Inactive, + VatsimRating::Suspended, + VatsimRating::Observer, + ] { assert!(eligible_position("ZBAA_TWR", rating, &tier_2_callsigns).is_none()); } } #[test] fn excludes_training_tier_2_and_non_vatprc_positions() { - let tier_2_callsigns = ["ZBAL_TWR", "*_MIL_TWR"] - .into_iter() - .map(str::to_owned) - .collect(); + let tier_2_callsigns = ["ZBAL_TWR"].into_iter().map(str::to_owned).collect(); for callsign in [ "ZBAA_X_CTR", @@ -621,24 +628,20 @@ mod tests { "ZBAA__I1__CTR", "ZBAA_I2_CTR", "zbaa_i2_ctr", + "ZBAA_IX_CTR", ] { assert!( - eligible_position(callsign, 5, &tier_2_callsigns).is_none(), + eligible_position(callsign, VatsimRating::C1, &tier_2_callsigns).is_none(), "{callsign}" ); } - for callsign in ["ZBAA_W_CTR", "ZBAA_2_CTR", "ZBAA_IX_CTR"] { + for callsign in ["ZBAA_W_CTR", "ZBAA_2_CTR"] { assert!( - eligible_position(callsign, 5, &tier_2_callsigns).is_some(), - "{callsign}" - ); - } - for callsign in ["ZBAL_TWR", "ZBAA_MIL_TWR"] { - assert!( - eligible_position(callsign, 3, &tier_2_callsigns).is_none(), + eligible_position(callsign, VatsimRating::C1, &tier_2_callsigns).is_some(), "{callsign}" ); } + assert!(eligible_position("ZBAL_TWR", VatsimRating::S2, &tier_2_callsigns).is_none()); for callsign in [ "ZBAA_MIL_TWR", "ZBAA__MIL__APP", @@ -646,18 +649,48 @@ mod tests { "zbaa_mil_gnd", ] { assert!( - eligible_position(callsign, 5, &HashSet::new()).is_none(), + eligible_position(callsign, VatsimRating::C1, &HashSet::new()).is_none(), "{callsign}" ); } for callsign in ["VHHH_TWR", "ZKPY_CTR", "ZBAA_DEP", "ZSHA_FSS"] { assert!( - eligible_position(callsign, 5, &tier_2_callsigns).is_none(), + eligible_position(callsign, VatsimRating::C1, &tier_2_callsigns).is_none(), "{callsign}" ); } } + #[test] + fn parses_callsign_metadata_once() { + let tier_2_callsigns = ["ZBAL_TWR", "*_MIL_TWR"] + .into_iter() + .map(str::to_owned) + .collect(); + + assert_eq!( + CallsignMetadata::parse("ZBAA__X1__CTR", &tier_2_callsigns), + CallsignMetadata { + position: Some(OnlineTimePosition::C1Plus), + is_vatprc: true, + is_training: true, + is_military: false, + is_tier_2: false, + } + ); + assert_eq!( + CallsignMetadata::parse("ZBAA__MIL__TWR", &tier_2_callsigns), + CallsignMetadata { + position: Some(OnlineTimePosition::S2), + is_vatprc: true, + is_training: false, + is_military: true, + is_tier_2: false, + } + ); + assert!(CallsignMetadata::parse("ZBAL_TWR", &tier_2_callsigns).is_tier_2); + } + #[test] fn groups_lifetime_and_quarter_seconds_by_position() { let period_start = time("2026-07-01T00:00:00Z"); @@ -685,7 +718,7 @@ mod tests { time("2026-07-01T02:00:00Z"), period_start, as_of, - 3, + VatsimRating::S2, &tier_2_callsigns, ); } @@ -722,7 +755,7 @@ mod tests { time(end), period_start, as_of, - 3, + VatsimRating::S2, &tier_2_callsigns, ); } @@ -745,7 +778,7 @@ mod tests { time("2026-07-01T02:30:00Z"), period_start, as_of, - 3, + VatsimRating::S2, &tier_2_callsigns, ), Some(9_000) @@ -757,7 +790,7 @@ mod tests { as_of, period_start, as_of, - 3, + VatsimRating::S2, &tier_2_callsigns, ), None