Skip to main content

msime_engine/local/
date_time.rs

1//! `T` mode (date_time_query.cpp, core-session.md §10.2): `rq`/`riqi`/`date`, `sj`/`shijian`/`time`, `xq`/`xingqi`/`week`, formatted from an injected local time. The lunar date comes from `lunar-lite` rather than the hand-typed 1900-2100 table.
2//!
3//! `nl`/`nongli`/`yinli` 是参考实现之外新增的农历关键词(#5952):农历原本只排在 `rq` 列表最后一行,单独的关键词让它不用翻页就能选到。
4
5use 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    /// 0 = Sunday.
18    pub weekday: u32,
19    pub hour: u32,
20    pub minute: u32,
21    pub second: u32,
22}
23
24/// 日期字段是否构成有效公历日期。
25pub(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];
58/// Year digits read one by one, with 〇 for zero.
59const YEAR_DIGITS: [&str; 10] = ["〇", "一", "二", "三", "四", "五", "六", "七", "八", "九"];
60/// Counting digits; zero is spelled by omission.
61const NUMBER_DIGITS: [&str; 10] = ["", "一", "二", "三", "四", "五", "六", "七", "八", "九"];
62const FINANCIAL_DIGITS: [&str; 10] = ["零", "壹", "贰", "叁", "肆", "伍", "陆", "柒", "捌", "玖"];
63const STEMS: [&str; 10] = ["甲", "乙", "丙", "丁", "戊", "己", "庚", "辛", "壬", "癸"];
64const BRANCHES: [&str; 12] = [
65    "子", "丑", "寅", "卯", "辰", "巳", "午", "未", "申", "酉", "戌", "亥",
66];
67
68/// The wall clock in the local offset, via `time`; zeros when the offset cannot be determined, as the reference did on a `localtime` failure.
69#[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    // A clock that cannot be read still yields well-formed rows (date_time_query.cpp:312-328), so there is nothing to report.
78    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
92/// 生成行的权重为 `count - index`,最多 20 条;未知关键词没有候选。
93pub fn query_date_time(keyword: &str, now: &LocalDateTime) -> Vec<WordItem> {
94    query_date_time_with_limit(keyword, now, RESULT_LIMIT)
95}
96
97/// `query_date_time` with the reference's explicit limit; zero answers nothing.
98pub 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/// 拼音组字里直接给出的几种日期时间(#5952)。
145#[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
155/// 拼音组字正好是这些全拼或简拼时,候选里接着锚定词给出当前的日期、时间、星期或农历:(关键词, 锚定词, 种类)。只认锚定词出现在候选里的情况,所以词库里没有这个词、或者它被别的读法挤出列表时什么都不加。
156const 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
248/// 第一个让 `spells` 认出的关键词、锚定词和种类;`spells` 判断组字(全拼字母或九宫格数字)是否拼出这个关键词。
249pub(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
258/// 混输里「📅日期」入口行的 `pinyin` 前缀(`date_time_query.cpp` 的 `kDateTimeMenuPrefix`):入口不是要上屏的字,选中它把候选窗换成这一组的全部格式。
259pub(crate) const MENU_PREFIX: &str = "menu:";
260
261/// 入口文案与参考的 `date_time_menu_item` 一致:时间组用「🕒时间」、星期组用「📅星期」,其余(日期、农历)用「📅日期」。
262fn menu_label(kind: InlineDateTime) -> &'static str {
263    match kind {
264        InlineDateTime::Time => "🕒时间",
265        InlineDateTime::Week => "📅星期",
266        _ => "📅日期",
267    }
268}
269
270/// 入口行的判定同参考 `is_date_time_menu_item`:这边的日期时间行都是 `Generated`,所以靠 `menu:` 前缀区分。
271pub(crate) fn is_date_time_menu_item(item: &WordItem) -> bool {
272    item.source == CandidateSource::Generated && item.pinyin.starts_with(MENU_PREFIX)
273}
274
275/// 入口行记住的关键词,选中时按它给出 `query_date_time` 的整组格式。
276pub(crate) fn date_time_menu_keyword(item: &WordItem) -> &str {
277    item.pinyin.strip_prefix(MENU_PREFIX).unwrap_or("")
278}
279
280/// 行内复用完整日期格式中的年月日、ISO 和带星期写法,再附三种相对日标签;年份只有一行。时间是时分、时分秒和上下午,星期有长短两种,农历换算失败时没有行。
281pub(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
371/// 把 `kind` 的几行接在第一个锚定词候选后面,`pinyin` 是选中它们时吃掉的整段组字。候选里没有锚定词时不加,也不读时钟。新加的行是 `Generated` 且没有规范读音,上屏时不进词库、不记个人上下文;列表里已有同样文字的行时挪那一行,不重复。
372/// `menu_keyword` 给「📅日期」入口的关键词(`utility.date_time_menu`):有行可跟时把入口放在第一行后面——锚定词在首位时正好是参考的第三位;一行都没有(农历换算不出来)时不放入口。入口的 `pinyin` 用 `menu:` 前缀记关键词而不是组字,选中它不会上屏。
373pub(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            // 词库里本来就有这一行(`xingqi` 下的 星期六):已经排在锚定词前面就不动,在后面就挪上来,仍是词库行,选中照常调频。
389            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    // A date the calendar cannot convert drops the row rather than showing a blank one that commits nothing (date_time_query.cpp:262-267).
463    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
571/// 农历日期,再加带星期的两种写法;日历换算不了的日期(超出 lunar-lite 覆盖范围或零时钟)没有任何行,和 `rq` 列表丢掉农历行的做法一致。
572fn 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
590/// 0 is the empty string, 10 is 十, 11-19 are 十X, other multiples of ten X十, the rest X十Y (date_time_query.cpp:58-78). Only month, day, hour and minute values reach it.
591pub(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
609/// `<stem><branch>年[闰]<month>月<day>日` of the lunar year, or `None` outside what the calendar covers (lunar years 1850..=2150) or for an impossible date such as the zero clock.
610pub(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    /// No blank row, Generated, and a contiguous descending weight run (test_local_modes.cpp:94-106).
664    #[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_local_modes.cpp:113-138.
676    #[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_local_modes.cpp:140-160.
706    #[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_local_modes.cpp:162-173.
754    #[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        // 列表里已有的同一行挪到锚定词后面,不重复;排在锚定词前面的不动。
843        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    /// `utility.date_time_menu`:入口跟在第一条行内日期时间行后面(锚定词在首位时即参考的第三位),`pinyin` 用 `menu:` 前缀记关键词、不是组字,选中不上屏。
881    #[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        // 其余行不是入口:前缀判定不会误伤普通日期行。
913        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        // 农历换算不出来时没有行可跟,入口也不放。
926        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        // `menu_keyword` 为 None 时不放入口(九宫格路径)。
937        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        // 闰月照样标出,星期取注入时钟的那一天。
962        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        // 日历换算不了的日期不给出空行或只有星期的行。
975        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_local_modes.cpp:175-183.
981    #[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_local_modes.cpp:185-215, moved to lunar-lite's range: its table covers lunar years 1850..=2150 where the reference's ended at 2101-01-28, so the dropped-row cases use dates past that range instead.
996    #[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    /// A clock that could not be read is all zeros; every keyword still gives well-formed rows (test_local_modes.cpp:217-226).
1012    #[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        // 2023-03-22 is the first day of the leap second month of 癸卯.
1028        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        // GB/T 33661-2017 puts the 2033 leap month after the eleventh; the reference table had the older 闰七月.
1036        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    // ---- the reference's lunar table, kept only to pin down where lunar-lite answers differently ----
1075
1076    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    /// (year, month, day, leap) for `days` after 1900-01-31 by the reference's walk (date_time_query.cpp:188-239).
1101    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    /// Every day the reference table covered agrees except six month boundaries where the table disagrees with the calendar lunar-lite was generated from: it makes the 1933 leap fifth month and the 1996 fifth and seventh months 29 days where lunar-lite has 30, it carries the old 2033 闰七月 instead of GB/T 33661-2017's 闰十一月, and it puts the 2089 and 2097 new moons that begin the eighth and seventh months a day late. Any other difference would be a regression the lunar row would show users.
1148    #[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        // Runs of consecutive disagreeing days, as (first day, length).
1178        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        // 星期故意不与日期同步,相对日必须从公历重新求星期。
1249        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}