1use lunar_lite::{solar_to_lunar, SolarDate};
6use time::{Date, Duration, Month, OffsetDateTime};
7
8use crate::types::{CandidateSource, WordItem};
9
10pub const RESULT_LIMIT: usize = 20;
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
13pub struct LocalDateTime {
14 pub year: i32,
15 pub month: u32,
16 pub day: u32,
17 pub weekday: u32,
19 pub hour: u32,
20 pub minute: u32,
21 pub second: u32,
22}
23
24pub(crate) fn is_valid_calendar_date(year: i32, month: u32, day: u32) -> bool {
26 calendar_date(year, month, day).is_some()
27}
28
29fn calendar_date(year: i32, month: u32, day: u32) -> Option<Date> {
30 Date::from_calendar_date(
31 year,
32 Month::try_from(u8::try_from(month).ok()?).ok()?,
33 u8::try_from(day).ok()?,
34 )
35 .ok()
36}
37
38pub(crate) const WEEKDAYS: [&str; 7] = [
39 "星期日",
40 "星期一",
41 "星期二",
42 "星期三",
43 "星期四",
44 "星期五",
45 "星期六",
46];
47const SHORT_WEEKDAYS: [&str; 7] = ["周日", "周一", "周二", "周三", "周四", "周五", "周六"];
48const ENGLISH_WEEKDAYS: [&str; 7] = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
49const ENGLISH_FULL_WEEKDAYS: [&str; 7] = [
50 "Sunday",
51 "Monday",
52 "Tuesday",
53 "Wednesday",
54 "Thursday",
55 "Friday",
56 "Saturday",
57];
58const YEAR_DIGITS: [&str; 10] = ["〇", "一", "二", "三", "四", "五", "六", "七", "八", "九"];
60const NUMBER_DIGITS: [&str; 10] = ["", "一", "二", "三", "四", "五", "六", "七", "八", "九"];
62const FINANCIAL_DIGITS: [&str; 10] = ["零", "壹", "贰", "叁", "肆", "伍", "陆", "柒", "捌", "玖"];
63const STEMS: [&str; 10] = ["甲", "乙", "丙", "丁", "戊", "己", "庚", "辛", "壬", "癸"];
64const BRANCHES: [&str; 12] = [
65 "子", "丑", "寅", "卯", "辰", "巳", "午", "未", "申", "酉", "戌", "亥",
66];
67
68#[cfg_attr(
70 all(target_family = "wasm", target_os = "unknown", not(test)),
71 expect(
72 dead_code,
73 reason = "wasm 上 `now_local` 会 panic,`Clock::default` 改读宿主注入的 UTC 时钟"
74 )
75)]
76pub fn current_local_date_time() -> LocalDateTime {
77 let Ok(now) = OffsetDateTime::now_local() else {
79 return LocalDateTime::default();
80 };
81 LocalDateTime {
82 year: now.year(),
83 month: u32::from(u8::from(now.month())),
84 day: u32::from(now.day()),
85 weekday: u32::from(now.weekday().number_days_from_sunday()),
86 hour: u32::from(now.hour()),
87 minute: u32::from(now.minute()),
88 second: u32::from(now.second()),
89 }
90}
91
92pub fn query_date_time(keyword: &str, now: &LocalDateTime) -> Vec<WordItem> {
94 query_date_time_with_limit(keyword, now, RESULT_LIMIT)
95}
96
97pub fn query_date_time_with_limit(
99 keyword: &str,
100 now: &LocalDateTime,
101 limit: usize,
102) -> Vec<WordItem> {
103 let texts = match keyword {
104 "rq" | "riqi" | "date" => date_candidates_with_label("今天", now),
105 "sj" | "shijian" | "time" => time_candidates(now),
106 "xq" | "xingqi" | "week" => week_candidates(now),
107 "nl" | "nongli" | "yinli" => lunar_candidates(now),
108 "jidu" => quarter_candidates(now),
109 "tianshu" => day_count_candidates(now),
110 "daojishi" => countdown_candidates(now),
111 "zhou" => week_count_candidates(now),
112 "shengxiao" => zodiac_candidates(now),
113 "shu" => zodiac_candidates(now),
114 _ => match inline_date_time_keyword(|candidate| candidate == keyword) {
115 Some((_, _, InlineDateTime::RelativeDay { label, days })) => {
116 let Some(date) = relative_date(now, days) else {
117 return Vec::new();
118 };
119 date_candidates_with_label(label, &date)
120 }
121 Some((_, _, kind @ InlineDateTime::RelativeYear { .. })) => {
122 inline_date_time_rows(kind, now)
123 }
124 _ => return Vec::new(),
125 },
126 };
127 let count = texts.len().min(limit);
128 texts
129 .into_iter()
130 .take(count)
131 .enumerate()
132 .map(|(index, text)| {
133 WordItem::new(
134 "",
135 text,
136 (count - index) as i64,
137 CandidateSource::Generated,
138 "",
139 )
140 })
141 .collect()
142}
143
144#[derive(Debug, Clone, Copy, PartialEq, Eq)]
146pub(crate) enum InlineDateTime {
147 Date,
148 Time,
149 Week,
150 Lunar,
151 RelativeDay { label: &'static str, days: i64 },
152 RelativeYear { label: &'static str, years: i32 },
153}
154
155const INLINE_KEYWORDS: [(&str, &str, InlineDateTime); 19] = [
157 ("riqi", "日期", InlineDateTime::Date),
158 ("rq", "日期", InlineDateTime::Date),
159 ("shijian", "时间", InlineDateTime::Time),
160 ("sj", "时间", InlineDateTime::Time),
161 ("xingqi", "星期", InlineDateTime::Week),
162 ("xq", "星期", InlineDateTime::Week),
163 ("nongli", "农历", InlineDateTime::Lunar),
164 ("nl", "农历", InlineDateTime::Lunar),
165 ("yinli", "阴历", InlineDateTime::Lunar),
166 (
167 "jintian",
168 "今天",
169 InlineDateTime::RelativeDay {
170 label: "今天",
171 days: 0,
172 },
173 ),
174 (
175 "zuotian",
176 "昨天",
177 InlineDateTime::RelativeDay {
178 label: "昨天",
179 days: -1,
180 },
181 ),
182 (
183 "qiantian",
184 "前天",
185 InlineDateTime::RelativeDay {
186 label: "前天",
187 days: -2,
188 },
189 ),
190 (
191 "mingtian",
192 "明天",
193 InlineDateTime::RelativeDay {
194 label: "明天",
195 days: 1,
196 },
197 ),
198 (
199 "houtian",
200 "后天",
201 InlineDateTime::RelativeDay {
202 label: "后天",
203 days: 2,
204 },
205 ),
206 (
207 "mingnian",
208 "明年",
209 InlineDateTime::RelativeYear {
210 label: "明年",
211 years: 1,
212 },
213 ),
214 (
215 "qunian",
216 "去年",
217 InlineDateTime::RelativeYear {
218 label: "去年",
219 years: -1,
220 },
221 ),
222 (
223 "jinnian",
224 "今年",
225 InlineDateTime::RelativeYear {
226 label: "今年",
227 years: 0,
228 },
229 ),
230 (
231 "lainian",
232 "来年",
233 InlineDateTime::RelativeYear {
234 label: "来年",
235 years: 1,
236 },
237 ),
238 (
239 "hounian",
240 "后年",
241 InlineDateTime::RelativeYear {
242 label: "后年",
243 years: 2,
244 },
245 ),
246];
247
248pub(crate) fn inline_date_time_keyword(
250 mut spells: impl FnMut(&str) -> bool,
251) -> Option<(&'static str, &'static str, InlineDateTime)> {
252 INLINE_KEYWORDS
253 .iter()
254 .find(|(keyword, _, _)| spells(keyword))
255 .map(|&(keyword, anchor, kind)| (keyword, anchor, kind))
256}
257
258pub(crate) const MENU_PREFIX: &str = "menu:";
260
261fn menu_label(kind: InlineDateTime) -> &'static str {
263 match kind {
264 InlineDateTime::Time => "🕒时间",
265 InlineDateTime::Week => "📅星期",
266 _ => "📅日期",
267 }
268}
269
270pub(crate) fn is_date_time_menu_item(item: &WordItem) -> bool {
272 item.source == CandidateSource::Generated && item.pinyin.starts_with(MENU_PREFIX)
273}
274
275pub(crate) fn date_time_menu_keyword(item: &WordItem) -> &str {
277 item.pinyin.strip_prefix(MENU_PREFIX).unwrap_or("")
278}
279
280pub(crate) fn inline_date_time_rows(kind: InlineDateTime, now: &LocalDateTime) -> Vec<String> {
282 let LocalDateTime {
283 hour,
284 minute,
285 second,
286 ..
287 } = *now;
288 let weekday = weekday_index(now);
289 match kind {
290 InlineDateTime::Date => {
291 let mut rows = inline_date_rows(now);
292 if calendar_date(now.year, now.month, now.day).is_some() {
293 rows.extend(relative_day_labels("今天", now));
294 }
295 rows
296 }
297 InlineDateTime::Time => {
298 let hour12 = if hour % 12 == 0 { 12 } else { hour % 12 };
299 let period = if hour < 12 { "上午" } else { "下午" };
300 vec![
301 format!("{hour:02}:{minute:02}"),
302 format!("{hour:02}:{minute:02}:{second:02}"),
303 format!("{period}{hour12}:{minute:02}"),
304 ]
305 }
306 InlineDateTime::Week => vec![
307 WEEKDAYS[weekday].to_owned(),
308 SHORT_WEEKDAYS[weekday].to_owned(),
309 ],
310 InlineDateTime::Lunar => lunar_date(now).into_iter().collect(),
311 InlineDateTime::RelativeDay { label, days } => {
312 let Some(date) = relative_date(now, days) else {
313 return Vec::new();
314 };
315 let mut rows = inline_date_rows(&date);
316 rows.extend(relative_day_labels(label, &date));
317 rows
318 }
319 InlineDateTime::RelativeYear { label, years } => {
320 let Some(year) = calendar_date(now.year, now.month, now.day)
321 .and_then(|date| date.year().checked_add(years))
322 .filter(|&year| Date::from_calendar_date(year, Month::January, 1).is_ok())
323 else {
324 return Vec::new();
325 };
326 vec![format!("{label}({year}年)")]
327 }
328 }
329}
330
331fn inline_date_rows(now: &LocalDateTime) -> Vec<String> {
332 let LocalDateTime {
333 year, month, day, ..
334 } = *now;
335 vec![
336 format!("{year}年{month}月{day}日"),
337 format!("{year:04}-{month:02}-{day:02}"),
338 format!("{year}年{month}月{day}日 {}", WEEKDAYS[weekday_index(now)]),
339 ]
340}
341
342fn date_candidates_with_label(label: &str, now: &LocalDateTime) -> Vec<String> {
343 let mut rows = date_candidates(now);
344 if calendar_date(now.year, now.month, now.day).is_some() {
345 rows.extend(relative_day_labels(label, now));
346 }
347 rows
348}
349
350fn relative_date(now: &LocalDateTime, days: i64) -> Option<LocalDateTime> {
351 let date = calendar_date(now.year, now.month, now.day)?.checked_add(Duration::days(days))?;
352 Some(LocalDateTime {
353 year: date.year(),
354 month: u32::from(u8::from(date.month())),
355 day: u32::from(date.day()),
356 weekday: u32::from(date.weekday().number_days_from_sunday()),
357 ..*now
358 })
359}
360
361fn relative_day_labels(label: &str, now: &LocalDateTime) -> [String; 3] {
362 let date = format!("{}月{}日", now.month, now.day);
363 let weekday = WEEKDAYS[weekday_index(now)];
364 [
365 format!("{label}({date})"),
366 format!("{label}({weekday})"),
367 format!("{label}({date} {weekday})"),
368 ]
369}
370
371pub(crate) fn insert_inline_date_time(
374 candidates: &mut Vec<WordItem>,
375 anchor: &str,
376 kind: InlineDateTime,
377 pinyin: &str,
378 now: impl FnOnce() -> LocalDateTime,
379 menu_keyword: Option<&str>,
380) {
381 let Some(position) = candidates.iter().position(|item| item.word == anchor) else {
382 return;
383 };
384 let weight = candidates[position].weight;
385 let mut at = position + 1;
386 for text in inline_date_time_rows(kind, &now()) {
387 match candidates.iter().position(|item| item.word == text) {
388 Some(existing) if existing < at => continue,
390 Some(existing) => {
391 let row = candidates.remove(existing);
392 candidates.insert(at, row);
393 }
394 None => candidates.insert(
395 at,
396 WordItem::new(pinyin, text, weight, CandidateSource::Generated, ""),
397 ),
398 }
399 at += 1;
400 }
401 if let Some(keyword) = menu_keyword
402 .filter(|_| at > position + 1 && !matches!(kind, InlineDateTime::RelativeYear { .. }))
403 {
404 candidates.insert(
405 position + 2,
406 WordItem::new(
407 format!("{MENU_PREFIX}{keyword}"),
408 menu_label(kind),
409 0,
410 CandidateSource::Generated,
411 "",
412 ),
413 );
414 }
415}
416
417fn weekday_index(now: &LocalDateTime) -> usize {
418 now.weekday.min(6) as usize
419}
420
421fn date_candidates(now: &LocalDateTime) -> Vec<String> {
422 let LocalDateTime {
423 year,
424 month,
425 day,
426 hour,
427 minute,
428 ..
429 } = *now;
430 let weekday = weekday_index(now);
431 let mut results = vec![
432 format!("{year}年{month}月{day}日"),
433 format!("{year:04}-{month:02}-{day:02}"),
434 format!("{year:04}/{month:02}/{day:02}"),
435 format!("{year:04}.{month:02}.{day:02}"),
436 format!("{year:04}{month:02}{day:02}"),
437 format!("{:02}年{month}月{day}日", year.rem_euclid(100)),
438 format!("{month}月{day}日"),
439 format!("{month:02}-{day:02}"),
440 format!("{month:02}{day:02}"),
441 format!("{year}年{month}月{day}日 {}", WEEKDAYS[weekday]),
442 format!("{month}月{day}日 {}", SHORT_WEEKDAYS[weekday]),
443 format!(
444 "{year:04}-{month:02}-{day:02} {}",
445 ENGLISH_WEEKDAYS[weekday]
446 ),
447 format!("{year:04}-{month:02}-{day:02} {hour:02}:{minute:02}"),
448 format!("{month}月{day}日 {hour:02}:{minute:02}"),
449 format!(
450 "{}年{}月{}日",
451 year_digits(year.unsigned_abs()),
452 chinese_number(month),
453 chinese_number(day)
454 ),
455 format!(
456 "{}年{}月{}日",
457 financial_digits(year.unsigned_abs(), 1),
458 financial_digits(month, 1),
459 financial_digits(day, 2)
460 ),
461 ];
462 if let Some(lunar) = lunar_date(now) {
464 results.push(lunar);
465 }
466 results
467}
468
469fn time_candidates(now: &LocalDateTime) -> Vec<String> {
470 let LocalDateTime {
471 year,
472 month,
473 day,
474 hour,
475 minute,
476 second,
477 ..
478 } = *now;
479 let hour12 = if hour % 12 == 0 { 12 } else { hour % 12 };
480 let morning = hour < 12;
481 let period = if morning { "上午" } else { "下午" };
482 let meridiem_upper = if morning { "AM" } else { "PM" };
483 let meridiem_lower = if morning { "am" } else { "pm" };
484 let colloquial_hour = if hour12 == 2 {
485 "两".to_owned()
486 } else {
487 chinese_number(hour12)
488 };
489 let colloquial_minutes = match minute {
490 0 => String::new(),
491 30 => "半".to_owned(),
492 _ => format!("{}分", chinese_number(minute)),
493 };
494 vec![
495 format!("{hour:02}:{minute:02}"),
496 format!("{hour:02}:{minute:02}:{second:02}"),
497 format!("{hour:02}{minute:02}"),
498 format!("{hour:02}{minute:02}{second:02}"),
499 format!("{period}{hour12}:{minute:02}"),
500 format!("{period}{hour12}点{minute:02}分"),
501 format!("{period}{colloquial_hour}点{colloquial_minutes}"),
502 format!("{hour12}:{minute:02} {meridiem_upper}"),
503 format!("{hour12}:{minute:02}{meridiem_lower}"),
504 format!("{hour12:02}:{minute:02}:{second:02} {meridiem_upper}"),
505 format!("{year:04}-{month:02}-{day:02} {hour:02}:{minute:02}:{second:02}"),
506 format!("{year}年{month}月{day}日 {hour:02}:{minute:02}"),
507 format!("{month}月{day}日 {period}{hour12}:{minute:02}"),
508 ]
509}
510
511fn week_candidates(now: &LocalDateTime) -> Vec<String> {
512 let weekday = weekday_index(now);
513 let mut results = vec![WEEKDAYS[weekday].to_owned()];
514 if weekday == 0 {
515 results.push("星期天".to_owned());
516 }
517 results.push(ENGLISH_FULL_WEEKDAYS[weekday].to_owned());
518 results.push(ENGLISH_WEEKDAYS[weekday].to_owned());
519 results
520}
521
522fn quarter_candidates(now: &LocalDateTime) -> Vec<String> {
523 [format!("{}年第{}季度", now.year, now.month.div_ceil(3))].into()
524}
525
526fn days_in_year(year: i32) -> u32 {
527 Date::from_calendar_date(year, Month::December, 31)
528 .map(|date| u32::from(date.ordinal()))
529 .unwrap_or(365)
530}
531
532fn day_count_candidates(now: &LocalDateTime) -> Vec<String> {
533 let Some(date) = calendar_date(now.year, now.month, now.day) else {
534 return Vec::new();
535 };
536 let elapsed = u32::from(date.ordinal()).saturating_sub(1);
537 let remaining = days_in_year(now.year).saturating_sub(elapsed);
538 vec![format!("已过{elapsed}天/剩{remaining}天")]
539}
540
541fn countdown_candidates(now: &LocalDateTime) -> Vec<String> {
542 day_count_candidates(now)
543}
544
545fn iso_weeks_in_year(year: i32) -> u8 {
546 Date::from_calendar_date(year, Month::December, 28)
547 .map(|date| date.iso_week())
548 .unwrap_or(52)
549}
550
551fn week_count_candidates(now: &LocalDateTime) -> Vec<String> {
552 let Some(date) = calendar_date(now.year, now.month, now.day) else {
553 return Vec::new();
554 };
555 let elapsed = u32::from(date.iso_week()).saturating_sub(1);
556 let iso_year = date.to_iso_week_date().0;
557 let remaining = u32::from(iso_weeks_in_year(iso_year)).saturating_sub(elapsed);
558 vec![format!("已过{elapsed}周/剩{remaining}周")]
559}
560
561fn zodiac_candidates(now: &LocalDateTime) -> Vec<String> {
562 const ZODIAC: [&str; 12] = [
563 "鼠", "牛", "虎", "兔", "龙", "蛇", "马", "羊", "猴", "鸡", "狗", "猪",
564 ];
565 vec![format!(
566 "{}年",
567 ZODIAC[(now.year - 4).rem_euclid(12) as usize]
568 )]
569}
570
571fn lunar_candidates(now: &LocalDateTime) -> Vec<String> {
573 let Some(lunar) = lunar_date(now) else {
574 return Vec::new();
575 };
576 let weekday = weekday_index(now);
577 let full = format!("{lunar} {}", WEEKDAYS[weekday]);
578 let short = format!("{lunar} {}", SHORT_WEEKDAYS[weekday]);
579 vec![lunar, full, short]
580}
581
582pub(crate) fn year_digits(value: u32) -> String {
583 value
584 .to_string()
585 .bytes()
586 .map(|digit| YEAR_DIGITS[usize::from(digit - b'0')])
587 .collect()
588}
589
590pub(crate) fn chinese_number(value: u32) -> String {
592 let digit = |index: u32| NUMBER_DIGITS[index as usize];
593 match value {
594 0..=9 => digit(value).to_owned(),
595 10 => "十".to_owned(),
596 11..=19 => format!("十{}", digit(value - 10)),
597 _ if value.is_multiple_of(10) => format!("{}十", digit(value / 10 % 10)),
598 _ => format!("{}十{}", digit(value / 10 % 10), digit(value % 10)),
599 }
600}
601
602fn financial_digits(value: u32, minimum_digits: usize) -> String {
603 format!("{value:0minimum_digits$}")
604 .bytes()
605 .map(|digit| FINANCIAL_DIGITS[usize::from(digit - b'0')])
606 .collect()
607}
608
609pub(crate) fn lunar_date(now: &LocalDateTime) -> Option<String> {
611 let solar = SolarDate {
612 year: now.year,
613 month: u8::try_from(now.month).ok()?,
614 day: u8::try_from(now.day).ok()?,
615 };
616 let lunar = solar_to_lunar(solar).ok()?;
617 let cycle = lunar.year - 4;
618 Some(format!(
619 "{}{}年{}{}月{}日",
620 STEMS[cycle.rem_euclid(10) as usize],
621 BRANCHES[cycle.rem_euclid(12) as usize],
622 if lunar.is_leap_month { "闰" } else { "" },
623 chinese_number(u32::from(lunar.month)),
624 chinese_number(u32::from(lunar.day))
625 ))
626}
627
628#[cfg(test)]
629mod tests {
630 use super::*;
631
632 fn sample_time() -> LocalDateTime {
633 LocalDateTime {
634 year: 2026,
635 month: 8,
636 day: 9,
637 weekday: 0,
638 hour: 14,
639 minute: 30,
640 second: 0,
641 }
642 }
643
644 fn at(year: i32, month: u32, day: u32, weekday: u32) -> LocalDateTime {
645 LocalDateTime {
646 year,
647 month,
648 day,
649 weekday,
650 hour: 9,
651 minute: 5,
652 second: 7,
653 }
654 }
655
656 #[track_caller]
657 fn assert_words(actual: &[WordItem], expected: &[&str]) {
658 let words: Vec<&str> = actual.iter().map(|row| row.word.as_str()).collect();
659 assert_eq!(words, expected);
660 assert_generated(actual, expected.len());
661 }
662
663 #[track_caller]
665 fn assert_generated(actual: &[WordItem], expected_len: usize) {
666 assert_eq!(actual.len(), expected_len);
667 for (index, row) in actual.iter().enumerate() {
668 assert!(!row.word.is_empty());
669 assert!(row.pinyin.is_empty());
670 assert_eq!(row.source, CandidateSource::Generated);
671 assert_eq!(row.weight, (actual.len() - index) as i64);
672 }
673 }
674
675 #[test]
677 fn date_aliases_in_windows_order() {
678 let expected = [
679 "2026年8月9日",
680 "2026-08-09",
681 "2026/08/09",
682 "2026.08.09",
683 "20260809",
684 "26年8月9日",
685 "8月9日",
686 "08-09",
687 "0809",
688 "2026年8月9日 星期日",
689 "8月9日 周日",
690 "2026-08-09 Sun",
691 "2026-08-09 14:30",
692 "8月9日 14:30",
693 "二〇二六年八月九日",
694 "贰零贰陆年捌月零玖日",
695 "丙午年六月二十七日",
696 "今天(8月9日)",
697 "今天(星期日)",
698 "今天(8月9日 星期日)",
699 ];
700 for keyword in ["rq", "riqi", "date"] {
701 assert_words(&query_date_time(keyword, &sample_time()), &expected);
702 }
703 }
704
705 #[test]
707 fn time_aliases_in_windows_order() {
708 let expected = [
709 "14:30",
710 "14:30:00",
711 "1430",
712 "143000",
713 "下午2:30",
714 "下午2点30分",
715 "下午两点半",
716 "2:30 PM",
717 "2:30pm",
718 "02:30:00 PM",
719 "2026-08-09 14:30:00",
720 "2026年8月9日 14:30",
721 "8月9日 下午2:30",
722 ];
723 for keyword in ["sj", "shijian", "time"] {
724 assert_words(&query_date_time(keyword, &sample_time()), &expected);
725 }
726 }
727
728 #[test]
729 fn morning_and_colloquial_times() {
730 let mut now = sample_time();
731 now.hour = 0;
732 now.minute = 0;
733 now.second = 5;
734 let rows = query_date_time("time", &now);
735 let words: Vec<&str> = rows.iter().map(|row| row.word.as_str()).collect();
736 assert_eq!(words[4], "上午12:00");
737 assert_eq!(words[6], "上午十二点");
738 assert_eq!(words[7], "12:00 AM");
739 assert_eq!(words[8], "12:00am");
740 assert_eq!(words[9], "12:00:05 AM");
741
742 now.hour = 2;
743 now.minute = 45;
744 let rows = query_date_time("time", &now);
745 assert_eq!(rows[6].word, "上午两点四十五分");
746 now.hour = 23;
747 now.minute = 11;
748 let rows = query_date_time("time", &now);
749 assert_eq!(rows[6].word, "下午十一点十一分");
750 assert_eq!(rows[9].word, "11:11:05 PM");
751 }
752
753 #[test]
755 fn weekday_aliases() {
756 for keyword in ["xq", "xingqi", "week"] {
757 assert_words(
758 &query_date_time(keyword, &sample_time()),
759 &["星期日", "星期天", "Sunday", "Sun"],
760 );
761 }
762 let mut monday = sample_time();
763 monday.weekday = 1;
764 assert_words(
765 &query_date_time("week", &monday),
766 &["星期一", "Monday", "Mon"],
767 );
768 let mut out_of_range = sample_time();
769 out_of_range.weekday = 9;
770 assert_words(
771 &query_date_time("xq", &out_of_range),
772 &["星期六", "Saturday", "Sat"],
773 );
774 }
775
776 #[test]
777 fn inline_rows_and_keywords() {
778 let now = sample_time();
779 assert_eq!(
780 inline_date_time_rows(InlineDateTime::Date, &now),
781 [
782 "2026年8月9日",
783 "2026-08-09",
784 "2026年8月9日 星期日",
785 "今天(8月9日)",
786 "今天(星期日)",
787 "今天(8月9日 星期日)"
788 ]
789 );
790 assert_eq!(
791 inline_date_time_rows(InlineDateTime::Time, &now),
792 ["14:30", "14:30:00", "下午2:30"]
793 );
794 let midnight = LocalDateTime {
795 hour: 0,
796 minute: 5,
797 ..now
798 };
799 assert_eq!(
800 inline_date_time_rows(InlineDateTime::Time, &midnight)[2],
801 "上午12:05"
802 );
803 assert_eq!(
804 inline_date_time_rows(InlineDateTime::Week, &now),
805 ["星期日", "周日"]
806 );
807 assert_eq!(
808 inline_date_time_rows(InlineDateTime::Lunar, &now),
809 ["丙午年六月二十七日"]
810 );
811 assert!(inline_date_time_rows(InlineDateTime::Lunar, &at(2200, 6, 15, 0)).is_empty());
812
813 assert_eq!(
814 inline_date_time_keyword(|keyword| keyword == "sj"),
815 Some(("sj", "时间", InlineDateTime::Time))
816 );
817 assert_eq!(
818 inline_date_time_keyword(|keyword| keyword == "yinli"),
819 Some(("yinli", "阴历", InlineDateTime::Lunar))
820 );
821 assert_eq!(inline_date_time_keyword(|keyword| keyword == "date"), None);
822 }
823
824 #[test]
825 fn inline_rows_follow_the_first_anchor_and_read_the_clock_only_then() {
826 let row = |word: &str| WordItem::new("riqi", word, 10, CandidateSource::Database, "");
827 let mut candidates = vec![row("日起"), row("日期"), row("日期"), row("日企")];
828 insert_inline_date_time(
829 &mut candidates,
830 "日期",
831 InlineDateTime::Week,
832 "riqi",
833 sample_time,
834 None,
835 );
836 let words: Vec<&str> = candidates.iter().map(|item| item.word.as_str()).collect();
837 assert_eq!(words, ["日起", "日期", "星期日", "周日", "日期", "日企"]);
838 assert_eq!(candidates[2].source, CandidateSource::Generated);
839 assert_eq!(candidates[2].pinyin, "riqi");
840 assert!(candidates[2].canonical_pinyin.is_empty());
841
842 let mut listed = vec![row("星期五"), row("星期"), row("星期一"), row("星期日")];
844 insert_inline_date_time(
845 &mut listed,
846 "星期",
847 InlineDateTime::Week,
848 "xingqi",
849 sample_time,
850 None,
851 );
852 let words: Vec<&str> = listed.iter().map(|item| item.word.as_str()).collect();
853 assert_eq!(words, ["星期五", "星期", "星期日", "周日", "星期一"]);
854 assert_eq!(listed[2].source, CandidateSource::Database);
855 assert_eq!(listed[3].source, CandidateSource::Generated);
856 let mut ahead = vec![row("星期日"), row("星期")];
857 insert_inline_date_time(
858 &mut ahead,
859 "星期",
860 InlineDateTime::Week,
861 "xingqi",
862 sample_time,
863 None,
864 );
865 let words: Vec<&str> = ahead.iter().map(|item| item.word.as_str()).collect();
866 assert_eq!(words, ["星期日", "星期", "周日"]);
867
868 let mut without_anchor = vec![row("日起")];
869 insert_inline_date_time(
870 &mut without_anchor,
871 "日期",
872 InlineDateTime::Date,
873 "riqi",
874 || panic!("没有锚定词时不读时钟"),
875 None,
876 );
877 assert_eq!(without_anchor.len(), 1);
878 }
879
880 #[test]
882 fn menu_row_follows_the_first_inline_row() {
883 let row = |word: &str| WordItem::new("rq", word, 10, CandidateSource::Database, "");
884 let mut candidates = vec![row("日期"), row("日企")];
885 insert_inline_date_time(
886 &mut candidates,
887 "日期",
888 InlineDateTime::Date,
889 "rq",
890 sample_time,
891 Some("rq"),
892 );
893 let words: Vec<&str> = candidates.iter().map(|item| item.word.as_str()).collect();
894 assert_eq!(
895 words,
896 [
897 "日期",
898 "2026年8月9日",
899 "📅日期",
900 "2026-08-09",
901 "2026年8月9日 星期日",
902 "今天(8月9日)",
903 "今天(星期日)",
904 "今天(8月9日 星期日)",
905 "日企"
906 ]
907 );
908 let menu = &candidates[2];
909 assert!(is_date_time_menu_item(menu));
910 assert_eq!(date_time_menu_keyword(menu), "rq");
911 assert_eq!(menu.source, CandidateSource::Generated);
912 assert!(!is_date_time_menu_item(&candidates[1]));
914
915 for (kind, label) in [
916 (InlineDateTime::Time, "🕒时间"),
917 (InlineDateTime::Week, "📅星期"),
918 (InlineDateTime::Lunar, "📅日期"),
919 ] {
920 let mut list = vec![row("农历")];
921 insert_inline_date_time(&mut list, "农历", kind, "nl", sample_time, Some("nl"));
922 assert_eq!(list[2].word, label, "{kind:?}");
923 }
924
925 let mut empty = vec![row("农历")];
927 insert_inline_date_time(
928 &mut empty,
929 "农历",
930 InlineDateTime::Lunar,
931 "nl",
932 LocalDateTime::default,
933 Some("nl"),
934 );
935 assert_eq!(empty.len(), 1);
936 let mut no_menu = vec![row("日期")];
938 insert_inline_date_time(
939 &mut no_menu,
940 "日期",
941 InlineDateTime::Date,
942 "7474",
943 sample_time,
944 None,
945 );
946 assert!(!no_menu.iter().any(is_date_time_menu_item));
947 }
948
949 #[test]
950 fn lunar_aliases_with_weekday_variants() {
951 for keyword in ["nl", "nongli", "yinli"] {
952 assert_words(
953 &query_date_time(keyword, &sample_time()),
954 &[
955 "丙午年六月二十七日",
956 "丙午年六月二十七日 星期日",
957 "丙午年六月二十七日 周日",
958 ],
959 );
960 }
961 assert_words(
963 &query_date_time("nongli", &at(2023, 3, 22, 3)),
964 &[
965 "癸卯年闰二月一日",
966 "癸卯年闰二月一日 星期三",
967 "癸卯年闰二月一日 周三",
968 ],
969 );
970 assert_words(
971 &query_date_time_with_limit("nl", &sample_time(), 1),
972 &["丙午年六月二十七日"],
973 );
974 assert!(query_date_time("nl", &at(2200, 6, 15, 0)).is_empty());
976 assert!(query_date_time("yinli", &LocalDateTime::default()).is_empty());
977 assert!(query_date_time("NL", &sample_time()).is_empty());
978 }
979
980 #[test]
982 fn keywords_and_limits() {
983 assert_words(
984 &query_date_time("week", &sample_time()),
985 &["星期日", "星期天", "Sunday", "Sun"],
986 );
987 assert!(query_date_time("today", &sample_time()).is_empty());
988 assert!(query_date_time("RQ", &sample_time()).is_empty());
989 assert!(query_date_time("", &sample_time()).is_empty());
990 assert!(query_date_time_with_limit("rq", &sample_time(), 0).is_empty());
991 let limited = query_date_time_with_limit("rq", &sample_time(), 3);
992 assert_words(&limited, &["2026年8月9日", "2026-08-09", "2026/08/09"]);
993 }
994
995 #[test]
997 fn lunar_row_only_when_the_calendar_resolves() {
998 assert_generated(&query_date_time("rq", &at(2101, 1, 28, 5)), 20);
999 assert_generated(&query_date_time("rq", &at(2101, 1, 29, 6)), 20);
1000 assert_generated(&query_date_time("date", &at(1900, 1, 1, 1)), 20);
1001
1002 assert_generated(&query_date_time("date", &at(1800, 1, 1, 3)), 19);
1003 let far_future = query_date_time("riqi", &at(2200, 6, 15, 0));
1004 assert_generated(&far_future, 19);
1005 assert_eq!(far_future[15].word, "贰贰零零年陆月壹伍日");
1006 assert_eq!(far_future.last().unwrap().weight, 1);
1007
1008 assert_generated(&query_date_time("rq", &at(2023, 2, 30, 0)), 16);
1009 }
1010
1011 #[test]
1013 fn unreadable_clock() {
1014 let zero = LocalDateTime::default();
1015 assert_generated(&query_date_time("rq", &zero), 16);
1016 assert_generated(&query_date_time("sj", &zero), 13);
1017 assert_generated(&query_date_time("xq", &zero), 4);
1018 let dates = query_date_time("rq", &zero);
1019 assert_eq!(dates[0].word, "0年0月0日");
1020 assert_eq!(dates[1].word, "0000-00-00");
1021 assert_eq!(dates[14].word, "〇年月日");
1022 assert_eq!(dates[15].word, "零年零月零零日");
1023 }
1024
1025 #[test]
1026 fn leap_months() {
1027 let rows = query_date_time("rq", &at(2023, 3, 22, 3));
1029 assert_eq!(rows[16].word, "癸卯年闰二月一日");
1030 assert_eq!(
1031 lunar_date(&at(2023, 1, 21, 6)).unwrap(),
1032 "壬寅年十二月三十日"
1033 );
1034 assert_eq!(lunar_date(&at(2023, 1, 22, 0)).unwrap(), "癸卯年一月一日");
1035 assert_eq!(lunar_date(&at(2033, 8, 25, 4)).unwrap(), "癸丑年八月一日");
1037 assert_eq!(
1038 lunar_date(&at(2033, 12, 22, 4)).unwrap(),
1039 "癸丑年闰十一月一日"
1040 );
1041 }
1042
1043 #[test]
1044 fn chinese_numbers() {
1045 let cases = [
1046 (0, ""),
1047 (1, "一"),
1048 (9, "九"),
1049 (10, "十"),
1050 (11, "十一"),
1051 (19, "十九"),
1052 (20, "二十"),
1053 (21, "二十一"),
1054 (30, "三十"),
1055 (59, "五十九"),
1056 ];
1057 for (value, expected) in cases {
1058 assert_eq!(chinese_number(value), expected, "{value}");
1059 }
1060 assert_eq!(financial_digits(7, 2), "零柒");
1061 assert_eq!(financial_digits(12, 2), "壹贰");
1062 assert_eq!(year_digits(2006), "二〇〇六");
1063 }
1064
1065 #[test]
1066 fn wall_clock_is_a_valid_date() {
1067 let now = current_local_date_time();
1068 assert!((1..=12).contains(&now.month));
1069 assert!((1..=31).contains(&now.day));
1070 assert!(now.weekday <= 6);
1071 assert!(now.hour <= 23 && now.minute <= 59 && now.second <= 60);
1072 }
1073
1074 const REFERENCE_LUNAR_YEAR_INFO: [u32; 201] = [
1077 0x04bd8, 0x04ae0, 0x0a570, 0x054d5, 0x0d260, 0x0d950, 0x16554, 0x056a0, 0x09ad0, 0x055d2,
1078 0x04ae0, 0x0a5b6, 0x0a4d0, 0x0d250, 0x1d255, 0x0b540, 0x0d6a0, 0x0ada2, 0x095b0, 0x14977,
1079 0x04970, 0x0a4b0, 0x0b4b5, 0x06a50, 0x06d40, 0x1ab54, 0x02b60, 0x09570, 0x052f2, 0x04970,
1080 0x06566, 0x0d4a0, 0x0ea50, 0x06e95, 0x05ad0, 0x02b60, 0x186e3, 0x092e0, 0x1c8d7, 0x0c950,
1081 0x0d4a0, 0x1d8a6, 0x0b550, 0x056a0, 0x1a5b4, 0x025d0, 0x092d0, 0x0d2b2, 0x0a950, 0x0b557,
1082 0x06ca0, 0x0b550, 0x15355, 0x04da0, 0x0a5b0, 0x14573, 0x052b0, 0x0a9a8, 0x0e950, 0x06aa0,
1083 0x0aea6, 0x0ab50, 0x04b60, 0x0aae4, 0x0a570, 0x05260, 0x0f263, 0x0d950, 0x05b57, 0x056a0,
1084 0x096d0, 0x04dd5, 0x04ad0, 0x0a4d0, 0x0d4d4, 0x0d250, 0x0d558, 0x0b540, 0x0b6a0, 0x195a6,
1085 0x095b0, 0x049b0, 0x0a974, 0x0a4b0, 0x0b27a, 0x06a50, 0x06d40, 0x0af46, 0x0ab60, 0x09570,
1086 0x04af5, 0x04970, 0x064b0, 0x074a3, 0x0ea50, 0x06b58, 0x055c0, 0x0ab60, 0x096d5, 0x092e0,
1087 0x0c960, 0x0d954, 0x0d4a0, 0x0da50, 0x07552, 0x056a0, 0x0abb7, 0x025d0, 0x092d0, 0x0cab5,
1088 0x0a950, 0x0b4a0, 0x0baa4, 0x0ad50, 0x055d9, 0x04ba0, 0x0a5b0, 0x15176, 0x052b0, 0x0a930,
1089 0x07954, 0x06aa0, 0x0ad50, 0x05b52, 0x04b60, 0x0a6e6, 0x0a4e0, 0x0d260, 0x0ea65, 0x0d530,
1090 0x05aa0, 0x076a3, 0x096d0, 0x04bd7, 0x04ad0, 0x0a4d0, 0x1d0b6, 0x0d250, 0x0d520, 0x0dd45,
1091 0x0b5a0, 0x056d0, 0x055b2, 0x049b0, 0x0a577, 0x0a4b0, 0x0aa50, 0x1b255, 0x06d20, 0x0ada0,
1092 0x14b63, 0x09370, 0x049f8, 0x04970, 0x064b0, 0x168a6, 0x0ea50, 0x06b20, 0x1a6c4, 0x0aae0,
1093 0x092e0, 0x0d2e3, 0x0c960, 0x0d557, 0x0d4a0, 0x0da50, 0x05d55, 0x056a0, 0x0a6d0, 0x055d4,
1094 0x052d0, 0x0a9b8, 0x0a950, 0x0b4a0, 0x0b6a6, 0x0ad50, 0x055a0, 0x0aba4, 0x0a5b0, 0x052b0,
1095 0x0b273, 0x06930, 0x07337, 0x06aa0, 0x0ad50, 0x14b55, 0x04b60, 0x0a570, 0x054e4, 0x0d260,
1096 0x0e968, 0x0d520, 0x0daa0, 0x16aa6, 0x056d0, 0x04ae0, 0x0a9d4, 0x0a4d0, 0x0d150, 0x0f252,
1097 0x0d520,
1098 ];
1099
1100 fn reference_lunar(mut days: u32) -> Option<(i32, u32, u32, bool)> {
1102 let info = |year: u32| REFERENCE_LUNAR_YEAR_INFO[(year - 1900) as usize];
1103 let leap_month = |year: u32| info(year) & 0xf;
1104 let leap_days = |year: u32| match leap_month(year) {
1105 0 => 0,
1106 _ if info(year) & 0x10000 != 0 => 30,
1107 _ => 29,
1108 };
1109 let month_days = |year: u32, month: u32| {
1110 if info(year) & (0x10000 >> month) != 0 {
1111 30
1112 } else {
1113 29
1114 }
1115 };
1116 let year_days = |year: u32| {
1117 348 + leap_days(year)
1118 + (4..16).filter(|bit| info(year) & (1 << bit) != 0).count() as u32
1119 };
1120 let mut year = 1900;
1121 while year <= 2100 && days >= year_days(year) {
1122 days -= year_days(year);
1123 year += 1;
1124 }
1125 if year > 2100 {
1126 return None;
1127 }
1128 let mut month = 1;
1129 let mut leap = false;
1130 while month <= 12 {
1131 if days < month_days(year, month) {
1132 break;
1133 }
1134 days -= month_days(year, month);
1135 if leap_month(year) == month {
1136 if days < leap_days(year) {
1137 leap = true;
1138 break;
1139 }
1140 days -= leap_days(year);
1141 }
1142 month += 1;
1143 }
1144 Some((year as i32, month, days + 1, leap))
1145 }
1146
1147 #[test]
1149 fn lunar_lite_matches_the_reference_table() {
1150 let epoch = time::Date::from_calendar_date(1900, time::Month::January, 31).unwrap();
1151 let mut date = epoch;
1152 let mut days = 0;
1153 let mut mismatches = Vec::new();
1154 while let Some((year, month, day, leap)) = reference_lunar(days) {
1155 let lunar = solar_to_lunar(SolarDate {
1156 year: date.year(),
1157 month: u8::from(date.month()),
1158 day: date.day(),
1159 })
1160 .unwrap();
1161 let actual = (
1162 lunar.year,
1163 u32::from(lunar.month),
1164 u32::from(lunar.day),
1165 lunar.is_leap_month,
1166 );
1167 if actual != (year, month, day, leap) {
1168 mismatches.push(date);
1169 }
1170 date = date.next_day().unwrap();
1171 days += 1;
1172 }
1173 assert_eq!(
1174 date,
1175 time::Date::from_calendar_date(2101, time::Month::January, 29).unwrap()
1176 );
1177 let mut runs: Vec<(time::Date, usize)> = Vec::new();
1179 for date in mismatches {
1180 match runs.last_mut() {
1181 Some((first, length))
1182 if first.checked_add(time::Duration::days(*length as i64)) == Some(date) =>
1183 {
1184 *length += 1
1185 }
1186 _ => runs.push((date, 1)),
1187 }
1188 }
1189 let day = |year, month, day| time::Date::from_calendar_date(year, month, day).unwrap();
1190 use time::Month::{August, July, September};
1191 assert_eq!(
1192 runs,
1193 [
1194 (day(1933, July, 22), 30),
1195 (day(1996, July, 15), 30),
1196 (day(1996, September, 12), 30),
1197 (day(2033, August, 25), 148),
1198 (day(2089, September, 4), 30),
1199 (day(2097, August, 7), 30),
1200 ]
1201 );
1202 }
1203}
1204
1205#[cfg(test)]
1206mod relative_date_tests {
1207 use super::*;
1208
1209 #[test]
1210 fn date_and_today_have_identical_inline_and_menu_formats() {
1211 let now = LocalDateTime {
1212 weekday: 5,
1213 ..at(2026, 10, 23)
1214 };
1215 let (_, _, today) = inline_date_time_keyword(|key| key == "jintian").unwrap();
1216 assert_eq!(
1217 inline_date_time_rows(InlineDateTime::Date, &now),
1218 inline_date_time_rows(today, &now)
1219 );
1220 let expected = query_date_time("jintian", &now);
1221 for keyword in ["rq", "riqi", "date"] {
1222 assert_eq!(query_date_time(keyword, &now), expected, "{keyword}");
1223 }
1224 }
1225
1226 #[test]
1227 fn mingnian_generates_the_next_calendar_year() {
1228 let now = at(2026, 10, 23);
1229 let rows = query_date_time("mingnian", &now);
1230 assert_eq!(
1231 rows.iter().map(|row| row.word.as_str()).collect::<Vec<_>>(),
1232 ["明年(2027年)"]
1233 );
1234 assert!(inline_date_time_keyword(|key| key == "mingnian").is_some());
1235 }
1236
1237 #[test]
1238 fn relative_labels_use_the_month_and_day_from_the_requested_format() {
1239 let now = at(2026, 10, 23);
1240 let rows = query_date_time("zuotian", &now);
1241 for expected in ["昨天(10月22日)", "昨天(星期四)", "昨天(10月22日 星期四)"]
1242 {
1243 assert!(rows.iter().any(|row| row.word == expected), "{expected}");
1244 }
1245 }
1246
1247 fn at(year: i32, month: u32, day: u32) -> LocalDateTime {
1248 LocalDateTime {
1250 year,
1251 month,
1252 day,
1253 weekday: 0,
1254 hour: 9,
1255 minute: 5,
1256 second: 7,
1257 }
1258 }
1259
1260 #[test]
1261 fn relative_days_cross_months_years_and_leap_days() {
1262 for (keyword, label, now, expected, month_day, weekday) in [
1263 (
1264 "jintian",
1265 "今天",
1266 at(2026, 10, 23),
1267 "2026-10-23",
1268 "10月23",
1269 "星期五",
1270 ),
1271 (
1272 "zuotian",
1273 "昨天",
1274 at(2026, 1, 1),
1275 "2025-12-31",
1276 "12月31",
1277 "星期三",
1278 ),
1279 (
1280 "qiantian",
1281 "前天",
1282 at(2024, 3, 1),
1283 "2024-02-28",
1284 "2月28",
1285 "星期三",
1286 ),
1287 (
1288 "mingtian",
1289 "明天",
1290 at(2024, 2, 28),
1291 "2024-02-29",
1292 "2月29",
1293 "星期四",
1294 ),
1295 (
1296 "houtian",
1297 "后天",
1298 at(2026, 12, 31),
1299 "2027-01-02",
1300 "1月2",
1301 "星期六",
1302 ),
1303 (
1304 "zuotian",
1305 "昨天",
1306 at(2023, 3, 1),
1307 "2023-02-28",
1308 "2月28",
1309 "星期二",
1310 ),
1311 ] {
1312 let rows = query_date_time(keyword, &now);
1313 assert_eq!(rows.len(), 20, "{keyword}");
1314 assert_eq!(rows[1].word, expected);
1315 let labels = [
1316 format!("{label}({month_day}日)"),
1317 format!("{label}({weekday})"),
1318 format!("{label}({month_day}日 {weekday})"),
1319 ];
1320 assert_eq!(
1321 rows[17..].iter().map(|row| &row.word).collect::<Vec<_>>(),
1322 labels.iter().collect::<Vec<_>>()
1323 );
1324 let (_, anchor, kind) =
1325 inline_date_time_keyword(|candidate| candidate == keyword).unwrap();
1326 assert_eq!(anchor, label);
1327 let inline = inline_date_time_rows(kind, &now);
1328 assert_eq!(inline.len(), 6);
1329 assert_eq!(inline[1], expected);
1330 assert_eq!(inline[3..], labels);
1331 assert_eq!(query_date_time_with_limit(keyword, &now, 2).len(), 2);
1332 assert!(rows
1333 .iter()
1334 .all(|row| row.source == CandidateSource::Generated
1335 && row.canonical_pinyin.is_empty()));
1336 }
1337 }
1338
1339 #[test]
1340 fn relative_years_only_show_the_named_year_without_a_redundant_menu() {
1341 for (keyword, label, year) in [
1342 ("mingnian", "明年", 2027),
1343 ("qunian", "去年", 2025),
1344 ("jinnian", "今年", 2026),
1345 ("lainian", "来年", 2027),
1346 ] {
1347 let now = at(2026, 10, 23);
1348 let expected = format!("{label}({year}年)");
1349 let rows = query_date_time(keyword, &now);
1350 assert_eq!(
1351 rows.iter().map(|row| row.word.as_str()).collect::<Vec<_>>(),
1352 [expected.as_str()]
1353 );
1354 let (_, anchor, kind) =
1355 inline_date_time_keyword(|candidate| candidate == keyword).unwrap();
1356 let mut candidates = vec![WordItem::new(
1357 keyword,
1358 anchor,
1359 10,
1360 CandidateSource::Database,
1361 "",
1362 )];
1363 insert_inline_date_time(
1364 &mut candidates,
1365 anchor,
1366 kind,
1367 keyword,
1368 || now,
1369 Some(keyword),
1370 );
1371 assert_eq!(candidates.len(), 2);
1372 assert_eq!(candidates[1].word, expected);
1373 assert!(!candidates.iter().any(is_date_time_menu_item));
1374 }
1375 }
1376
1377 #[test]
1378 fn invalid_or_out_of_range_relative_dates_produce_no_guessed_rows() {
1379 for keyword in [
1380 "jintian", "zuotian", "qiantian", "mingtian", "houtian", "mingnian", "qunian",
1381 "jinnian", "lainian",
1382 ] {
1383 for now in [LocalDateTime::default(), at(2026, 2, 30)] {
1384 assert!(
1385 query_date_time(keyword, &now).is_empty(),
1386 "{keyword}: {now:?}"
1387 );
1388 let (_, _, kind) =
1389 inline_date_time_keyword(|candidate| candidate == keyword).unwrap();
1390 assert!(inline_date_time_rows(kind, &now).is_empty());
1391 }
1392 }
1393 for (keyword, date) in [
1394 ("zuotian", Date::MIN),
1395 ("mingtian", Date::MAX),
1396 ("mingnian", Date::MAX),
1397 ("lainian", Date::MAX),
1398 ] {
1399 assert!(query_date_time(
1400 keyword,
1401 &at(
1402 date.year(),
1403 u32::from(u8::from(date.month())),
1404 u32::from(date.day())
1405 )
1406 )
1407 .is_empty());
1408 }
1409 }
1410
1411 #[test]
1412 fn calendar_summary_triggers_report_the_current_year() {
1413 let now = at(2026, 10, 26);
1414 for (keyword, expected) in [
1415 ("jidu", "2026年第4季度"),
1416 ("tianshu", "已过298天/剩67天"),
1417 ("daojishi", "已过298天/剩67天"),
1418 ("zhou", "已过43周/剩10周"),
1419 ("shengxiao", "马年"),
1420 ("shu", "马年"),
1421 ] {
1422 let rows = query_date_time(keyword, &now);
1423 assert_eq!(rows.len(), 1, "{keyword}");
1424 assert_eq!(rows[0].word, expected, "{keyword}");
1425 }
1426 assert_eq!(
1427 query_date_time("zhou", &at(2027, 1, 1))[0].word,
1428 "已过52周/剩1周"
1429 );
1430 }
1431}