i18n+filtering fork - fluent-templates v2
at main 726 lines 27 kB view raw
1// Facet calculation logic for event filtering 2// 3// Calculates facets (aggregated counts) for different filter dimensions, 4// providing users with insight into available filtering options. 5 6use sqlx::{PgPool, QueryBuilder, Row}; 7use serde::{Deserialize, Serialize}; 8use tracing::instrument; 9use chrono::Datelike; 10use unic_langid::LanguageIdentifier; 11use fluent_templates::Loader; 12 13use super::{EventFilterCriteria, FilterError}; 14use crate::atproto::lexicon::community::lexicon::calendar::event::{Mode, Status}; 15use crate::storage::handle::handles_by_did; 16 17/// Represents a single facet value with its count 18#[derive(Debug, Clone, Serialize, Deserialize)] 19pub struct FacetValue { 20 pub value: String, 21 pub count: i64, 22 pub i18n_key: Option<String>, 23 /// Pre-calculated display name (locale-specific) 24 pub display_name: Option<String>, 25} 26 27/// Collection of facets for different dimensions 28#[derive(Debug, Clone, Serialize, Deserialize, Default)] 29pub struct EventFacets { 30 pub modes: Vec<FacetValue>, 31 pub statuses: Vec<FacetValue>, 32 pub creators: Vec<FacetValue>, 33 pub date_ranges: Vec<FacetValue>, 34 pub total_count: i64, 35} 36 37/// Facet calculator for events 38#[derive(Debug, Clone)] 39pub struct FacetCalculator { 40 pool: PgPool, 41} 42 43impl FacetCalculator { 44 /// Create a new facet calculator 45 pub fn new(pool: PgPool) -> Self { 46 Self { pool } 47 } 48 49 /// Calculate all facets for the given filter criteria 50 #[instrument(skip(self, criteria))] 51 pub async fn calculate_facets( 52 &self, 53 criteria: &EventFilterCriteria, 54 ) -> Result<EventFacets, FilterError> { 55 let mut facets = EventFacets::default(); 56 57 // Calculate total count 58 facets.total_count = self.calculate_total_count(criteria).await?; 59 60 // Calculate mode facets 61 facets.modes = self.calculate_mode_facets(criteria).await?; 62 63 // Calculate status facets 64 facets.statuses = self.calculate_status_facets(criteria).await?; 65 66 // Calculate creator facets 67 facets.creators = self.calculate_creator_facets(criteria).await?; 68 69 // Calculate date range facets 70 facets.date_ranges = self.calculate_date_range_facets(criteria).await?; 71 72 Ok(facets) 73 } 74 75 /// Calculate all facets for the given filter criteria with locale support 76 #[instrument(skip(self, criteria))] 77 pub async fn calculate_facets_with_locale( 78 &self, 79 criteria: &EventFilterCriteria, 80 locale: &LanguageIdentifier, 81 ) -> Result<EventFacets, FilterError> { 82 let mut facets = EventFacets::default(); 83 84 // Calculate total count 85 facets.total_count = self.calculate_total_count(criteria).await?; 86 87 // Calculate mode facets with i18n support 88 facets.modes = self.calculate_mode_facets_with_locale(criteria, locale).await?; 89 90 // Calculate status facets with i18n support 91 facets.statuses = self.calculate_status_facets_with_locale(criteria, locale).await?; 92 93 // Calculate creator facets 94 facets.creators = self.calculate_creator_facets(criteria).await?; 95 96 // Calculate date range facets with i18n support 97 facets.date_ranges = self.calculate_date_range_facets_with_locale(criteria, locale).await?; 98 99 Ok(facets) 100 } 101 102 /// Calculate total count of events matching the criteria 103 async fn calculate_total_count(&self, criteria: &EventFilterCriteria) -> Result<i64, FilterError> { 104 let mut query = QueryBuilder::new("SELECT COUNT(*) FROM events"); 105 106 // Apply same WHERE conditions as main query but exclude the specific facet being calculated 107 self.apply_base_filters(&mut query, criteria); 108 109 let count: (i64,) = query 110 .build_query_as() 111 .fetch_one(&self.pool) 112 .await?; 113 114 Ok(count.0) 115 } 116 117 /// Calculate mode facets 118 async fn calculate_mode_facets( 119 &self, 120 criteria: &EventFilterCriteria, 121 ) -> Result<Vec<FacetValue>, FilterError> { 122 let mut query = QueryBuilder::new( 123 "SELECT record->>'mode' as mode, COUNT(*) as count FROM events" 124 ); 125 126 // Apply filters excluding modes to show all available modes 127 let criteria_without_modes = EventFilterCriteria { 128 modes: vec![], // Exclude mode filters for facet calculation 129 ..criteria.clone() 130 }; 131 let has_where = self.apply_base_filters(&mut query, &criteria_without_modes); 132 133 query.push(if has_where { " AND " } else { " WHERE " }); 134 query.push("record->>'mode' IS NOT NULL"); 135 query.push(" GROUP BY mode"); 136 query.push(" ORDER BY count DESC, mode ASC"); 137 138 let rows = query 139 .build() 140 .fetch_all(&self.pool) 141 .await?; 142 143 let mut facets = Vec::new(); 144 for row in rows { 145 let mode_str: Option<String> = row.try_get("mode")?; 146 let count: i64 = row.try_get("count")?; 147 148 if let Some(mode_str) = mode_str { 149 facets.push(FacetValue { 150 value: mode_str.clone(), 151 count, 152 i18n_key: Some(Self::generate_mode_i18n_key(&mode_str)), 153 display_name: None, 154 }); 155 } 156 } 157 158 Ok(facets) 159 } 160 161 /// Calculate mode facets with locale support 162 pub async fn calculate_mode_facets_with_locale( 163 &self, 164 criteria: &EventFilterCriteria, 165 locale: &LanguageIdentifier, 166 ) -> Result<Vec<FacetValue>, FilterError> { 167 let mut query = QueryBuilder::new( 168 "SELECT record->>'mode' as mode, COUNT(*) as count FROM events" 169 ); 170 171 // Apply filters excluding modes to show all available modes 172 let criteria_without_modes = EventFilterCriteria { 173 modes: vec![], // Exclude mode filters for facet calculation 174 ..criteria.clone() 175 }; 176 let has_where = self.apply_base_filters(&mut query, &criteria_without_modes); 177 178 query.push(if has_where { " AND " } else { " WHERE " }); 179 query.push("record->>'mode' IS NOT NULL"); 180 query.push(" GROUP BY mode"); 181 query.push(" ORDER BY count DESC, mode ASC"); 182 183 let rows = query 184 .build() 185 .fetch_all(&self.pool) 186 .await?; 187 188 let mut facets = Vec::new(); 189 for row in rows { 190 let mode_str: Option<String> = row.try_get("mode")?; 191 let count: i64 = row.try_get("count")?; 192 193 if let Some(mode_str) = mode_str { 194 let i18n_key = Self::generate_mode_i18n_key(&mode_str); 195 facets.push(FacetValue { 196 value: mode_str.clone(), 197 count, 198 i18n_key: Some(i18n_key.clone()), 199 display_name: Some(self.get_translated_facet_name(&i18n_key, locale)), 200 }); 201 } 202 } 203 204 Ok(facets) 205 } 206 207 /// Calculate status facets 208 async fn calculate_status_facets( 209 &self, 210 criteria: &EventFilterCriteria, 211 ) -> Result<Vec<FacetValue>, FilterError> { 212 let mut query = QueryBuilder::new( 213 "SELECT record->>'status' as status, COUNT(*) as count FROM events" 214 ); 215 216 // Apply filters excluding statuses to show all available statuses 217 let criteria_without_statuses = EventFilterCriteria { 218 statuses: vec![], // Exclude status filters for facet calculation 219 ..criteria.clone() 220 }; 221 let has_where = self.apply_base_filters(&mut query, &criteria_without_statuses); 222 223 query.push(if has_where { " AND " } else { " WHERE " }); 224 query.push("record->>'status' IS NOT NULL"); 225 query.push(" GROUP BY status"); 226 query.push(" ORDER BY count DESC, status ASC"); 227 228 let rows = query 229 .build() 230 .fetch_all(&self.pool) 231 .await?; 232 233 let mut facets = Vec::new(); 234 for row in rows { 235 let status_str: Option<String> = row.try_get("status")?; 236 let count: i64 = row.try_get("count")?; 237 238 if let Some(status_str) = status_str { 239 facets.push(FacetValue { 240 value: status_str.clone(), 241 count, 242 i18n_key: Some(Self::generate_status_i18n_key(&status_str)), 243 display_name: None, 244 }); 245 } 246 } 247 248 Ok(facets) 249 } 250 251 /// Calculate status facets with locale support 252 pub async fn calculate_status_facets_with_locale( 253 &self, 254 criteria: &EventFilterCriteria, 255 locale: &LanguageIdentifier, 256 ) -> Result<Vec<FacetValue>, FilterError> { 257 let mut query = QueryBuilder::new( 258 "SELECT record->>'status' as status, COUNT(*) as count FROM events" 259 ); 260 261 // Apply filters excluding statuses to show all available statuses 262 let criteria_without_statuses = EventFilterCriteria { 263 statuses: vec![], // Exclude status filters for facet calculation 264 ..criteria.clone() 265 }; 266 let has_where = self.apply_base_filters(&mut query, &criteria_without_statuses); 267 268 query.push(if has_where { " AND " } else { " WHERE " }); 269 query.push("record->>'status' IS NOT NULL"); 270 query.push(" GROUP BY status"); 271 query.push(" ORDER BY count DESC, status ASC"); 272 273 let rows = query 274 .build() 275 .fetch_all(&self.pool) 276 .await?; 277 278 let mut facets = Vec::new(); 279 for row in rows { 280 let status_str: Option<String> = row.try_get("status")?; 281 let count: i64 = row.try_get("count")?; 282 283 if let Some(status_str) = status_str { 284 let i18n_key = Self::generate_status_i18n_key(&status_str); 285 facets.push(FacetValue { 286 value: status_str.clone(), 287 count, 288 i18n_key: Some(i18n_key.clone()), 289 display_name: Some(self.get_translated_facet_name(&i18n_key, locale)), 290 }); 291 } 292 } 293 294 Ok(facets) 295 } 296 297 /// Calculate creator facets (top event creators) 298 async fn calculate_creator_facets( 299 &self, 300 criteria: &EventFilterCriteria, 301 ) -> Result<Vec<FacetValue>, FilterError> { 302 let mut query = QueryBuilder::new( 303 "SELECT did, COUNT(*) as count FROM events" 304 ); 305 306 // Apply filters excluding creator to show all available creators 307 let criteria_without_creator = EventFilterCriteria { 308 creator_did: None, // Exclude creator filter for facet calculation 309 ..criteria.clone() 310 }; 311 self.apply_base_filters(&mut query, &criteria_without_creator); 312 313 query.push(" GROUP BY did"); 314 query.push(" ORDER BY count DESC"); 315 query.push(" LIMIT 20"); // Top creators only 316 317 let rows = query 318 .build() 319 .fetch_all(&self.pool) 320 .await?; 321 322 // Collect DIDs for batch handle resolution 323 let dids: Vec<String> = rows.iter() 324 .map(|row| row.try_get::<String, _>("did").unwrap_or_default()) 325 .collect(); 326 327 // Batch resolve handles for all DIDs 328 let handle_map = handles_by_did(&self.pool, dids) 329 .await 330 .unwrap_or_default(); 331 332 let mut facets = Vec::new(); 333 for row in rows { 334 let did: String = row.try_get("did")?; 335 let count: i64 = row.try_get("count")?; 336 337 // Try to get the handle, fallback to shortened DID 338 let display_name = handle_map.get(&did) 339 .map(|handle| handle.handle.clone()) 340 .unwrap_or_else(|| { 341 // Create a shortened DID as fallback (show first 8 and last 8 chars) 342 if did.len() > 20 { 343 format!("{}...{}", &did[..8], &did[did.len()-8..]) 344 } else { 345 did.clone() 346 } 347 }); 348 349 facets.push(FacetValue { 350 value: did, 351 count, 352 i18n_key: None, 353 display_name: Some(display_name), 354 }); 355 } 356 357 Ok(facets) 358 } 359 360 /// Calculate date range facets 361 async fn calculate_date_range_facets( 362 &self, 363 criteria: &EventFilterCriteria, 364 ) -> Result<Vec<FacetValue>, FilterError> { 365 let mut facets = Vec::new(); 366 367 // Define date ranges to calculate 368 let date_ranges = vec![ 369 ("today", "Today"), 370 ("this-week", "This Week"), 371 ("this-month", "This Month"), 372 ("next-week", "Next Week"), 373 ("next-month", "Next Month"), 374 ]; 375 376 for (key, _label) in date_ranges { 377 let count = self.count_events_in_date_range(criteria, key).await?; 378 if count > 0 { 379 facets.push(FacetValue { 380 value: key.to_string(), 381 count, 382 i18n_key: Some(format!("date-range-{}", key)), 383 display_name: None, // No locale context in non-locale method 384 }); 385 } 386 } 387 388 Ok(facets) 389 } 390 391 /// Calculate date range facets with locale support 392 pub async fn calculate_date_range_facets_with_locale( 393 &self, 394 criteria: &EventFilterCriteria, 395 locale: &LanguageIdentifier, 396 ) -> Result<Vec<FacetValue>, FilterError> { 397 let mut facets = Vec::new(); 398 399 // Define date ranges to calculate 400 let date_ranges = vec![ 401 ("today", "Today"), 402 ("this-week", "This Week"), 403 ("this-month", "This Month"), 404 ("next-week", "Next Week"), 405 ("next-month", "Next Month"), 406 ]; 407 408 for (key, _label) in date_ranges { 409 let count = self.count_events_in_date_range(criteria, key).await?; 410 if count > 0 { 411 let i18n_key = format!("date-range-{}", key); 412 facets.push(FacetValue { 413 value: key.to_string(), 414 count, 415 i18n_key: Some(i18n_key.clone()), 416 display_name: Some(self.get_translated_facet_name(&i18n_key, locale)), 417 }); 418 } 419 } 420 421 Ok(facets) 422 } 423 424 /// Count events in a specific date range 425 async fn count_events_in_date_range( 426 &self, 427 criteria: &EventFilterCriteria, 428 range_key: &str, 429 ) -> Result<i64, FilterError> { 430 let now = chrono::Utc::now(); 431 432 let (start_date, end_date) = match range_key { 433 "today" => { 434 let start = now.date_naive().and_hms_opt(0, 0, 0).unwrap().and_utc(); 435 let end = now.date_naive().and_hms_opt(23, 59, 59).unwrap().and_utc(); 436 (start, end) 437 } 438 "this_week" | "this-week" => { 439 let days_since_monday = now.weekday().num_days_from_monday(); 440 let start = (now - chrono::Duration::days(days_since_monday as i64)) 441 .date_naive().and_hms_opt(0, 0, 0).unwrap().and_utc(); 442 let end = start + chrono::Duration::days(6); 443 (start, end) 444 } 445 "this_month" | "this-month" => { 446 let start = now.date_naive().with_day(1).unwrap().and_hms_opt(0, 0, 0).unwrap().and_utc(); 447 let end = if now.month() == 12 { 448 chrono::NaiveDate::from_ymd_opt(now.year() + 1, 1, 1).unwrap() 449 } else { 450 chrono::NaiveDate::from_ymd_opt(now.year(), now.month() + 1, 1).unwrap() 451 }.and_hms_opt(0, 0, 0).unwrap().and_utc(); 452 (start, end) 453 } 454 "next_week" | "next-week" => { 455 let days_until_next_monday = 7 - now.weekday().num_days_from_monday(); 456 let start = (now + chrono::Duration::days(days_until_next_monday as i64)) 457 .date_naive().and_hms_opt(0, 0, 0).unwrap().and_utc(); 458 let end = start + chrono::Duration::days(6); 459 (start, end) 460 } 461 "next_month" | "next-month" => { 462 let start = if now.month() == 12 { 463 chrono::NaiveDate::from_ymd_opt(now.year() + 1, 1, 1).unwrap() 464 } else { 465 chrono::NaiveDate::from_ymd_opt(now.year(), now.month() + 1, 1).unwrap() 466 }.and_hms_opt(0, 0, 0).unwrap().and_utc(); 467 let end = if start.month() == 12 { 468 chrono::NaiveDate::from_ymd_opt(start.year() + 1, 1, 1).unwrap() 469 } else { 470 chrono::NaiveDate::from_ymd_opt(start.year(), start.month() + 1, 1).unwrap() 471 }.and_hms_opt(0, 0, 0).unwrap().and_utc(); 472 (start, end) 473 } 474 _ => return Ok(0), 475 }; 476 477 let mut query = QueryBuilder::new("SELECT COUNT(*) FROM events"); 478 479 // Apply base filters excluding date range 480 let criteria_without_dates = EventFilterCriteria { 481 start_date: None, 482 end_date: None, 483 ..criteria.clone() 484 }; 485 let has_where = self.apply_base_filters(&mut query, &criteria_without_dates); 486 487 // Add the specific date range 488 query.push(if has_where { " AND " } else { " WHERE " }); 489 query.push("(record->>'startsAt')::timestamptz >= "); 490 query.push_bind(start_date); 491 query.push(" AND (record->>'startsAt')::timestamptz < "); 492 query.push_bind(end_date); 493 494 let count: (i64,) = query 495 .build_query_as() 496 .fetch_one(&self.pool) 497 .await?; 498 499 Ok(count.0) 500 } 501 502 /// Apply base filters (same as query builder but modular) 503 fn apply_base_filters<'a>( 504 &self, 505 query: &mut QueryBuilder<'a, sqlx::Postgres>, 506 criteria: &'a EventFilterCriteria, 507 ) -> bool { 508 let mut has_where = false; 509 510 // Text search 511 if let Some(ref term) = criteria.search_term { 512 if !term.trim().is_empty() { 513 query.push(if has_where { " AND " } else { " WHERE " }); 514 query.push("(name ILIKE "); 515 query.push_bind(format!("%{}%", term)); 516 query.push(" OR record->>'description' ILIKE "); 517 query.push_bind(format!("%{}%", term)); 518 query.push(")"); 519 has_where = true; 520 } 521 } 522 523 // Date filtering - using overlap logic for events within date range 524 // An event overlaps with the filter range if: 525 // event_start <= filter_end AND event_end >= filter_start 526 if criteria.start_date.is_some() || criteria.end_date.is_some() { 527 query.push(if has_where { " AND " } else { " WHERE " }); 528 query.push("("); 529 530 let mut condition_added = false; 531 532 if let Some(filter_start) = criteria.start_date { 533 // Event must end on or after filter start date (for overlap) 534 // If no endsAt, use end of start day (startsAt date + 23:59:59) 535 query.push("COALESCE((record->>'endsAt')::timestamptz, \ 536 DATE_TRUNC('day', (record->>'startsAt')::timestamptz) + INTERVAL '1 day' - INTERVAL '1 second') >= "); 537 query.push_bind(filter_start); 538 condition_added = true; 539 } 540 541 if let Some(filter_end) = criteria.end_date { 542 // Event must start on or before filter end date (for overlap) 543 if condition_added { 544 query.push(" AND "); 545 } 546 query.push("(record->>'startsAt')::timestamptz <= "); 547 query.push_bind(filter_end); 548 } 549 550 query.push(")"); 551 has_where = true; 552 } 553 554 // Creator filtering 555 if let Some(ref creator_did) = criteria.creator_did { 556 query.push(if has_where { " AND " } else { " WHERE " }); 557 query.push("did = "); 558 query.push_bind(creator_did); 559 has_where = true; 560 } 561 562 // Mode filtering 563 if !criteria.modes.is_empty() { 564 query.push(if has_where { " AND " } else { " WHERE " }); 565 query.push("("); 566 for (i, mode) in criteria.modes.iter().enumerate() { 567 if i > 0 { 568 query.push(" OR "); 569 } 570 // Convert Mode enum to its string representation 571 let mode_str = match mode { 572 Mode::InPerson => "community.lexicon.calendar.event#inperson", 573 Mode::Virtual => "community.lexicon.calendar.event#virtual", 574 Mode::Hybrid => "community.lexicon.calendar.event#hybrid", 575 }; 576 query.push("record->>'mode' = "); 577 query.push_bind(mode_str); 578 } 579 query.push(")"); 580 has_where = true; 581 } 582 583 // Status filtering 584 if !criteria.statuses.is_empty() { 585 query.push(if has_where { " AND " } else { " WHERE " }); 586 query.push("("); 587 for (i, status) in criteria.statuses.iter().enumerate() { 588 if i > 0 { 589 query.push(" OR "); 590 } 591 // Convert Status enum to its string representation 592 let status_str = match status { 593 Status::Scheduled => "community.lexicon.calendar.event#scheduled", 594 Status::Rescheduled => "community.lexicon.calendar.event#rescheduled", 595 Status::Cancelled => "community.lexicon.calendar.event#cancelled", 596 Status::Postponed => "community.lexicon.calendar.event#postponed", 597 Status::Planned => "community.lexicon.calendar.event#planned", 598 }; 599 query.push("record->>'status' = "); 600 query.push_bind(status_str); 601 } 602 query.push(")"); 603 has_where = true; 604 } 605 606 // Location filtering 607 if let Some(ref location) = criteria.location { 608 query.push(if has_where { " AND " } else { " WHERE " }); 609 query.push("ST_DWithin("); 610 query.push("ST_MakePoint("); 611 query.push("(record->'location'->>'longitude')::float8, "); 612 query.push("(record->'location'->>'latitude')::float8"); 613 query.push(")::geography, "); 614 query.push("ST_MakePoint("); 615 query.push_bind(location.longitude); 616 query.push(", "); 617 query.push_bind(location.latitude); 618 query.push(")::geography, "); 619 query.push_bind(location.radius_km * 1000.0); 620 query.push(")"); 621 has_where = true; 622 } 623 624 has_where 625 } 626 627 /// Generate i18n key for mode facets 628 pub fn generate_mode_i18n_key(mode: &str) -> String { 629 match mode { 630 "community.lexicon.calendar.event#inperson" => "mode-in-person".to_string(), 631 "community.lexicon.calendar.event#virtual" => "mode-virtual".to_string(), 632 "community.lexicon.calendar.event#hybrid" => "mode-hybrid".to_string(), 633 _ => format!("mode-{}", mode.to_lowercase().replace('#', "-").replace('.', "-")), 634 } 635 } 636 637 /// Generate i18n key for status facets 638 pub fn generate_status_i18n_key(status: &str) -> String { 639 match status { 640 "community.lexicon.calendar.event#scheduled" => "status-scheduled".to_string(), 641 "community.lexicon.calendar.event#rescheduled" => "status-rescheduled".to_string(), 642 "community.lexicon.calendar.event#cancelled" => "status-cancelled".to_string(), 643 "community.lexicon.calendar.event#postponed" => "status-postponed".to_string(), 644 "community.lexicon.calendar.event#planned" => "status-planned".to_string(), 645 _ => format!("status-{}", status.to_lowercase().replace('#', "-").replace('.', "-")), 646 } 647 } 648 649 650 651 652 653 654 655 /// Get translated facet name with fallback to formatted readable name 656 pub fn get_translated_facet_name(&self, i18n_key: &str, locale: &LanguageIdentifier) -> String { 657 // Try to get the translation from the fluent loader 658 let translated = { 659 let locales = &*crate::i18n::fluent_loader::LOCALES; 660 locales.lookup(locale, i18n_key) 661 }; 662 663 // If translation returns the key itself, it means no translation found 664 if translated == i18n_key { 665 // Extract readable name from key and format it nicely 666 // Examples: 667 // "date-range-today" -> "Today" 668 // "mode-inperson" -> "In Person" 669 // "status-scheduled" -> "Scheduled" 670 let readable = i18n_key 671 .split('-') 672 .skip(1) // Skip the prefix (date-range, mode, status, etc.) 673 .collect::<Vec<_>>() 674 .join(" ") 675 .split('_') 676 .map(|word| { 677 let mut chars = word.chars(); 678 match chars.next() { 679 None => String::new(), 680 Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(), 681 } 682 }) 683 .collect::<Vec<_>>() 684 .join(" "); 685 686 if readable.is_empty() { 687 i18n_key.to_string() 688 } else { 689 readable 690 } 691 } else { 692 translated 693 } 694 } 695} 696 697#[cfg(test)] 698mod tests { 699 use super::*; 700 701 #[test] 702 fn test_generate_mode_i18n_key() { 703 assert_eq!( 704 FacetCalculator::generate_mode_i18n_key("community.lexicon.calendar.event#inperson"), 705 "mode-in-person" 706 ); 707 708 assert_eq!( 709 FacetCalculator::generate_mode_i18n_key("community.lexicon.calendar.event#virtual"), 710 "mode-virtual" 711 ); 712 } 713 714 #[test] 715 fn test_generate_status_i18n_key() { 716 assert_eq!( 717 FacetCalculator::generate_status_i18n_key("community.lexicon.calendar.event#scheduled"), 718 "status-scheduled" 719 ); 720 721 assert_eq!( 722 FacetCalculator::generate_status_i18n_key("community.lexicon.calendar.event#cancelled"), 723 "status-cancelled" 724 ); 725 } 726}