跳转至

crates\nfarms-core\src\weather.rs

源码路径:crates\nfarms-core\src\weather.rs · 601 行

//! 天气数据(数据源 `https://wttr.in`,默认上海)。对应 C# `src\Weather.cs`。
//!
//! 这一层刻意拆成两半:
//!
//! * 纯解析 `parse(&str)` —— **不联网**,可以用离线样本
//!   `tests\weather-sample.json` 做回归;
//! * 联网获取 `fetch()` —— 已在本文件内用 ureq 直连(和拓扑验证器等模块同一个
//!   客户端选型),**必须带超时**:ureq 不显式设超时就是永不超时,详见 `TIMEOUT_SECS`。
//!
//! 解析里全是 wttr.in 的怪癖,照抄原逻辑最省事:
//! `current_condition` 是数组但只取第一个,`weatherDesc` 是 `[{"value":"Clear "}]`
//! 这种嵌套,值还带尾随空格必须先 trim。

use serde_json::Value;
use std::time::Duration;

/// 默认地点(可在天气页改成任意城市)。
pub const DEFAULT_LOCATION: &str = "shanghai";

/// 请求 UA —— 原版带着自己的站点地址,保持一样,别让 wttr.in 那边当成匿名爬虫。
pub const USER_AGENT: &str = "NFarmsTools/1.2 (+https://tools.nfarms.cn)";

#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct WeatherNow {
    pub city: String,
    pub region: String,
    pub country: String,
    pub temp: String,
    pub feels: String,
    pub desc: String,
    pub humidity: String,
    pub wind: String,
    pub obs_time: String,
    pub uv: String,
    pub visibility: String,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct WeatherDay {
    pub date: String,
    pub week: String,
    pub min: String,
    pub max: String,
    pub desc: String,
    pub icon: String,
}

impl Default for WeatherDay {
    fn default() -> Self {
        // C# 里 `public string Icon = "☁";` —— 兜底图标是阴天,不是空串。
        Self {
            date: String::new(),
            week: String::new(),
            min: String::new(),
            max: String::new(),
            desc: String::new(),
            icon: "☁".to_string(),
        }
    }
}

/// 一次天气查询的结果。
///
/// C# 用的是 `out WeatherNow now, out WeatherDay[] days`;Rust 用返回值代替 out 参数,
/// 好处是「解析失败时 days 是空数组」这种约定不再靠调用方自觉。
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct WeatherReport {
    pub now: WeatherNow,
    pub days: Vec<WeatherDay>,
}

/// wttr.in 的 JSON 接口地址。
pub fn json_url(location: &str) -> String {
    format!("https://wttr.in/{}?format=j1", escape_data_string(location))
}

/// 联网获取天气。
///
/// 走 ureq 默认的 **rustls**,不经过 Windows 的 Schannel —— 这点很关键:
/// C# 版因为走 Schannel,在 DSH 沙箱里会因为拿不到密钥容器而全部失败
/// (README 236-242 行记录的那个问题)。Rust 这边从根上没有这个约束,
/// 所以在沙箱里也能真的联网。
pub fn fetch(location: &str) -> Result<WeatherReport, String> {
    let url = json_url(location);
    let body = get_body(&url)?;
    parse(&body)
}

/// 一次请求最多等多久。
///
/// **这个超时不能省,不是「保险起见」那种可选项。** ureq 3.x 的
/// `Timeouts::default()` 里 `global / per_call / resolve / connect /
/// recv_response / recv_body` 全是 `None`(见 vendored 依赖
/// `.cargo-home/.../ureq-3.4.2/src/config.rs` 的 `impl Default for Timeouts`),
/// 而 `Timeout::timeouts_to_check()` 会把没配的项直接过滤掉、回落成
/// `Instant::NotHappening` —— 也就是**不显式设就是永远不超时**。
/// ureq 自己的 `error.rs` 也写着「默认不设超时,所以这个错误不可能发生」。
///
/// 之前这里直接调 `ureq::get()`,于是「TCP 连上了但对端不回响应」是无限阻塞:
/// 后台抓取线程不返回 → GUI 的 `weather_rx` 永远是 `Some` →
/// `core_pages.rs` 里刷新按钮被 `weather_rx.is_none()` 挡着一直灰、转圈动画停不下来,
/// 用户只能重启程序。
///
/// 选 `timeout_global` 而不是只设 `timeout_connect`:对端「连上但不回话」时
/// connect 早就成功了,只有 global 能兜住;而且 ureq 的每个阶段
/// (`timeouts_to_check` 恒定带上 `Global`)都会检查它,所以
/// `read_to_string()` 读 body 这处阻塞点也一并被覆盖。
///
/// 20 秒与同 crate 的 `today.rs` / `scripts.rs` / `tools.rs` / `videoparse.rs`
/// 一致。代价是「很慢但最终能成」的请求会被判失败,这是刻意的:
/// 宁可给用户一句明确的报错,也不要挂着转圈。
const TIMEOUT_SECS: u64 = 20;

/// 组装带超时的 agent。
///
/// 从 `Config::builder()`(内部就是 `Config::default()`)起步,所以除超时之外的
/// 行为 —— 代理环境变量、重定向次数、TLS 配置 —— 和原来的 `ureq::get()` 完全一致,
/// 这里只补上缺的那个超时。
///
/// 超时做成参数而不是写死常量,是为了让回归测试能用 1 秒复现「对端挂起」,
/// 否则一条测试要干等 20 秒。
fn agent_with_timeout(timeout_secs: u64) -> ureq::Agent {
    ureq::Agent::config_builder()
        .timeout_global(Some(Duration::from_secs(timeout_secs)))
        .build()
        .into()
}

/// 生产用 agent:超时固定为 `TIMEOUT_SECS`。
fn agent() -> ureq::Agent {
    agent_with_timeout(TIMEOUT_SECS)
}

/// 用指定 agent 发请求并读回 body。
///
/// 请求/读 body 的逻辑和 agent 的组装分开,是为了让测试能拿本地回环地址 +
/// 1 秒超时的 agent 跑**完全相同的这段代码**,不必去改动生产签名
/// (见 `tests::a_hung_connection_times_out_instead_of_blocking_forever`)。
fn get_body_with(agent: &ureq::Agent, url: &str) -> Result<String, String> {
    let mut resp = agent
        .get(url)
        // User-Agent 仍按请求设置,和原实现保持一致(wttr.in 会看这个头)。
        .header("User-Agent", USER_AGENT)
        .call()
        .map_err(|e| e.to_string())?;
    resp.body_mut().read_to_string().map_err(|e| e.to_string())
}

/// 生产入口:带 `TIMEOUT_SECS` 超时地取回 body。
fn get_body(url: &str) -> Result<String, String> {
    get_body_with(&agent(), url)
}

/// 纯解析(不联网)。失败时返回给用户看的错误文本。
pub fn parse(json: &str) -> Result<WeatherReport, String> {
    // BOM 在入口处统一吞掉。CLI 那条路在外面补了 `strip_bom`(`main.rs` 的
    // `--weather`),而 `fetch` 是直接把 HTTP body 递进来的 —— 响应带 BOM 时
    // `serde_json` 会报 "expected value",用户看到的就成了"天气源坏了"。
    // 放在 `parse` 里两条路一起覆盖,也免得以后新增调用方还要记得这件事。
    let json = crate::strip_bom(json);
    if json.trim().is_empty() {
        return Err("响应为空".to_string());
    }

    let root: Value = serde_json::from_str(json).map_err(|e| e.to_string())?;
    if root.is_null() {
        return Err("JSON 解析失败".to_string());
    }

    let mut now = WeatherNow::default();

    if let Some(cc) = first_dict(&root, "current_condition") {
        now.temp = str_of(cc, "temp_C");
        now.feels = str_of(cc, "FeelsLikeC");
        now.humidity = str_of(cc, "humidity");
        now.wind = format!(
            "{} km/h {}",
            str_of(cc, "windspeedKmph"),
            str_of(cc, "winddir16Point")
        )
        .trim()
        .to_string();
        now.obs_time = str_of(cc, "observation_time");
        now.uv = str_of(cc, "uvIndex");
        now.visibility = str_of(cc, "visibility");
        now.desc = value_of(cc, "weatherDesc");
    }

    if let Some(area) = first_dict(&root, "nearest_area") {
        now.city = value_of(area, "areaName");
        now.region = value_of(area, "region");
        now.country = value_of(area, "country");
    }

    let mut days = Vec::new();
    if let Some(arr) = root.get("weather").and_then(Value::as_array) {
        for item in arr {
            let Some(d) = item.as_object() else { continue };
            let mut day = WeatherDay {
                date: str_of(d, "date"),
                max: str_of(d, "maxtempC"),
                min: str_of(d, "mintempC"),
                ..WeatherDay::default()
            };
            day.week = week_of(&day.date);
            day.desc = noon_desc(d);
            day.icon = icon_for(&day.desc).to_string();
            days.push(day);
        }
    }

    // 成败判据只看 `now.temp`,不再要求 `days` 也为空。
    //
    // 界面主卡片画的是 `format!("{}°C", now.temp)`(`core_pages.rs`),`current_condition`
    // 缺失/为空、或 `temp_C` 被改名时 `str_of` 只会返回空串而不报错,于是旧写法
    // (`now.temp.is_empty() && days.is_empty()`)会把「有三天预报、没有当前天气」的响应
    // 判成成功,界面画出一个孤零零的 `°C`。不这么改:用户以为数据正常,只是少了一行。
    //
    // 分两句报错是为了让「整份响应没数据」和「只有预报」在界面上可区分 ——
    // 前者的期望文案沿用原值,自检/回归里已有的断言不用跟着动。
    if now.temp.is_empty() {
        return Err(if days.is_empty() {
            "响应中没有天气数据".to_string()
        } else {
            "响应中没有当前天气(只有预报数据)".to_string()
        });
    }
    Ok(WeatherReport { now, days })
}

// ---------------------------------------------------------------- 解析辅助

type Obj = serde_json::Map<String, Value>;

/// `root[key]` 取第一个元素并当作对象。
///
/// wttr.in 把 `current_condition` / `nearest_area` 都包成数组,但只用第一个。
fn first_dict<'a>(root: &'a Value, key: &str) -> Option<&'a Obj> {
    let v = root.get(key)?;
    match v.as_array() {
        Some(arr) => arr.first()?.as_object(),
        None => v.as_object(),
    }
}

/// 形如 `[{ "value": "Sunny" }]` 的嵌套值取 `value`;不是嵌套结构就按标量转字符串。
fn value_of(map: &Obj, key: &str) -> String {
    let Some(v) = map.get(key) else {
        return String::new();
    };
    if let Some(arr) = v.as_array() {
        if let Some(inner) = arr.first().and_then(Value::as_object) {
            return str_of(inner, "value");
        }
    }
    dotnet_string(v).trim().to_string()
}

fn str_of(map: &Obj, key: &str) -> String {
    map.get(key)
        .map(|v| dotnet_string(v).trim().to_string())
        .unwrap_or_default()
}

/// 模拟 .NET 的 `Convert.ToString(object)`。
///
/// 差异点在数字:JSON 里 `23` 与 `23.0`,Rust 的 `f64` 会显示成 `23.0`,
/// 而 .NET 显示 `23`。wttr.in 目前全用字符串(所以样本里看不出差别),
/// 但换成数字就会立刻表现为「温度多一个 .0」,这里一次性抹平。
fn dotnet_string(v: &Value) -> String {
    match v {
        Value::Null => String::new(),
        Value::Bool(true) => "True".to_string(),
        Value::Bool(false) => "False".to_string(),
        Value::String(s) => s.clone(),
        Value::Number(n) => {
            if let Some(i) = n.as_i64() {
                i.to_string()
            } else if let Some(u) = n.as_u64() {
                u.to_string()
            } else if let Some(f) = n.as_f64() {
                if f.is_finite() && f.fract() == 0.0 {
                    format!("{}", f as i64)
                } else {
                    format!("{f}")
                }
            } else {
                n.to_string()
            }
        }
        // 数组/对象落到 serde_json 自己的 JSON 渲染。这一支和原版
        // `Convert.ToString(object)` 是否等价**没有验证过** —— wttr.in 现在走不到
        // 这里(嵌套的 `[{ "value": … }]` 已被 `value_of` 提前拦下)。
        // 写在这里是为了下次有人碰到非标量取值时知道这是未验证区,而不是"已经对齐过"。
        other => other.to_string(),
    }
}

/// 取当日**正午**(第 5 条 hourly,即 12:00)的天气描述;不足 5 条则退化成第 1 条。
fn noon_desc(day: &Obj) -> String {
    let Some(hours) = day.get("hourly").and_then(Value::as_array) else {
        return String::new();
    };
    if hours.is_empty() {
        return String::new();
    }
    let pick = if hours.len() > 4 { &hours[4] } else { &hours[0] };
    match pick.as_object() {
        Some(o) => value_of(o, "weatherDesc"),
        None => String::new(),
    }
}

/// `2026-09-20` → `周日`;解析不了就原样返回日期(与 C# 一致)。
fn week_of(date: &str) -> String {
    weekday_cn(date)
}

/// 中文星期;`date` 不是合法 ISO 短日期时原样返回。
pub fn weekday_cn(date: &str) -> String {
    match crate::time::parse_iso_date(date)
        .and_then(|(y, m, d)| crate::time::weekday_index(y, m, d))
    {
        Some(idx) => crate::time::WEEKDAY_CN[idx].to_string(),
        None => date.to_string(),
    }
}

/// 天气描述 → 符号(用系统符号字体渲染)。
///
/// **判定顺序不能动** —— 先命中的赢。比如 `"Partly cloudy"` 同时含 `partly`
/// 和 `cloud`,`"Smoky haze"` 含 `haze`,顺序一变图标就变。
pub fn icon_for(desc: &str) -> &'static str {
    let d = desc.to_lowercase();
    if d.contains("thunder") {
        return "⚡";
    }
    if d.contains("snow") || d.contains("sleet") || d.contains("blizzard") {
        return "❄";
    }
    if d.contains("rain") || d.contains("drizzle") || d.contains("shower") {
        return "☔";
    }
    if d.contains("mist") || d.contains("fog") || d.contains("haze") {
        return "≡";
    }
    if d.contains("overcast") {
        return "☁";
    }
    if d.contains("cloud") || d.contains("partly") {
        return "⛅";
    }
    if d.contains("sunny") || d.contains("clear") {
        return "☀";
    }
    "☁"
}

/// 等价于 .NET 的 `Uri.EscapeDataString`:只保留 `A-Za-z0-9-._~`,其余按 UTF-8 百分号编码。
fn escape_data_string(s: &str) -> String {
    const UNRESERVED: &[u8] = b"-._~";
    let mut out = String::with_capacity(s.len());
    for b in s.as_bytes() {
        if b.is_ascii_alphanumeric() || UNRESERVED.contains(b) {
            out.push(*b as char);
        } else {
            out.push_str(&format!("%{b:02X}"));
        }
    }
    out
}

#[cfg(test)]
mod tests {
    use super::*;

    /// 离线回归样本(从 C# 版 `tests\weather-sample.json` 原样拷来,随本 crate 一起进版本库)。
    const SAMPLE: &str = include_str!("../tests/data/weather-sample.json");

    #[test]
    fn parses_the_offline_sample_exactly_like_the_csharp_version() {
        let r = parse(SAMPLE).expect("样本必须能解析");
        assert_eq!(r.now.city, "Pootung");
        assert_eq!(r.now.region, "Shanghai");
        assert_eq!(r.now.country, "China");
        assert_eq!(r.now.temp, "23");
        assert_eq!(r.now.feels, "26");
        assert_eq!(r.now.desc, "Clear");
        assert_eq!(r.now.humidity, "79");
        assert_eq!(r.now.wind, "5 km/h ENE");
        assert_eq!(r.now.obs_time, "07:00 PM");
        assert_eq!(r.now.uv, "0");
        assert_eq!(r.now.visibility, "10");

        assert_eq!(r.days.len(), 3);
        assert_eq!(r.days[0].date, "2026-09-20");
        assert_eq!(r.days[0].week, "周日");
        assert_eq!((r.days[0].min.as_str(), r.days[0].max.as_str()), ("23", "29"));
        // hourly 的下标 4(index 4 = 中午 12:00)才是判据,不是每天第一条。
        assert_eq!(r.days[0].desc, "Cloudy");
        assert_eq!(r.days[0].icon, "⛅");
        assert_eq!(r.days[1].week, "周一");
        assert_eq!(r.days[1].desc, "Sunny");
        assert_eq!(r.days[1].icon, "☀");
        assert_eq!(r.days[2].week, "周二");
    }

    #[test]
    fn url_matches_uri_escapedatastring() {
        assert_eq!(json_url("shanghai"), "https://wttr.in/shanghai?format=j1");
        // ASCII 之外要按 UTF-8 百分号编码
        assert_eq!(json_url("埇桥区"), "https://wttr.in/%E5%9F%87%E6%A1%A5%E5%8C%BA?format=j1");
        // 空格、斜杠都要转义(unreserved 之外一律转)
        assert_eq!(json_url("new york/a"), "https://wttr.in/new%20york%2Fa?format=j1");
    }

    #[test]
    fn empty_and_broken_input_are_reported_not_panicked() {
        assert_eq!(parse(""), Err("响应为空".to_string()));
        assert_eq!(parse("   "), Err("响应为空".to_string()));
        assert!(parse("{ not json").is_err());
        assert_eq!(
            parse(r#"{"current_condition":[],"weather":[]}"#),
            Err("响应中没有天气数据".to_string())
        );
    }

    /// 守「有预报、没有当前天气」这条线。
    ///
    /// 现场:`current_condition` 缺失、为空,或 `temp_C` 被改名,而 `weather` 还有数据。
    /// 旧判据 `now.temp.is_empty() && days.is_empty()` 要求两者同时为空才报错,这种响应
    /// 会返回 `Ok`,界面主卡片随即画出孤零零的 `°C`(`core_pages.rs` 的 `{}°C`)。
    /// 谁把判据改回 `&&`,这条立刻红。
    #[test]
    fn a_report_without_current_weather_fails_instead_of_drawing_a_lone_degree_sign() {
        // 预报齐全,但整份响应里没有 current_condition。
        let only_forecast = r#"{
            "weather": [
                {"date":"2026-09-20","maxtempC":"29","mintempC":"23",
                 "hourly":[{"weatherDesc":[{"value":"Cloudy"}]}]}
            ]
        }"#;
        let err = parse(only_forecast).expect_err("没有当前天气不能算成功");
        assert!(
            err.contains("当前天气"),
            "错误文本要说清缺的是当前天气(界面直接贴这行字给用户),实际:{err}"
        );

        // 同一后果的另一条触发路径:温度字段被改名 → `str_of` 只给空串、不报错。
        let renamed = r#"{"current_condition":[{"TempC":"23"}],"weather":[]}"#;
        assert!(parse(renamed).is_err(), "温度字段改名后不能静默成功");
    }

    /// `parse` 自己吞 BOM。CLI 那条路在 `main.rs` 外面补了 `strip_bom`,`fetch` 没有 ——
    /// 少了这一层,带 BOM 的 HTTP body 会以 serde 的 "expected value" 收场,
    /// 用户看到的就成了"天气源坏了"。
    #[test]
    fn parse_tolerates_a_leading_utf8_bom() {
        let with_bom = format!("\u{feff}{SAMPLE}");
        assert_eq!(parse(&with_bom), parse(SAMPLE));
        // 只有开头的 BOM 被吃掉;整串只有 BOM 时退化成"响应为空",不是解析报错。
        assert_eq!(parse("\u{feff}"), Err("响应为空".to_string()));
    }

    #[test]
    fn icon_rules_keep_the_csharp_evaluation_order() {
        assert_eq!(icon_for("Thundery outbreaks"), "⚡");
        assert_eq!(icon_for("Blizzard"), "❄");
        assert_eq!(icon_for("Light drizzle"), "☔");
        assert_eq!(icon_for("Smoky haze"), "≡");
        assert_eq!(icon_for("Overcast"), "☁");
        // "Partly cloudy" 同时含 partly/cloud,两者都指向 ⛅
        assert_eq!(icon_for("Partly cloudy"), "⛅");
        assert_eq!(icon_for("Cloudy"), "⛅");
        assert_eq!(icon_for("Sunny"), "☀");
        assert_eq!(icon_for("Clear"), "☀");
        assert_eq!(icon_for(""), "☁");
        // 大小写不敏感(原版先 ToLowerInvariant)
        assert_eq!(icon_for("SUNNY"), "☀");
    }

    #[test]
    fn numbers_are_rendered_like_dotnet_convert_tostring() {
        assert_eq!(dotnet_string(&serde_json::json!(23)), "23");
        assert_eq!(dotnet_string(&serde_json::json!(23.0)), "23");
        assert_eq!(dotnet_string(&serde_json::json!(23.5)), "23.5");
        assert_eq!(dotnet_string(&serde_json::json!(true)), "True");
    }

    #[test]
    fn weekday_is_chinese_and_handles_unparsable_dates() {
        assert_eq!(week_of("2026-09-20"), "周日");
        assert_eq!(week_of("2026-09-21"), "周一");
        assert_eq!(week_of("不是日期"), "不是日期");
    }

    /// 守「fetch 的 agent 必须带有限超时」这条线。
    ///
    /// ureq 的 `Timeouts::default()` 全是 `None`,而 `None` 就是**永不超时** ——
    /// 所以只要谁把 `timeout_global` 删掉(或把 `fetch` 改回 `ureq::get()`),
    /// 这条会立刻红,而不是等用户报「天气页转圈转不停」。
    #[test]
    fn fetch_agent_carries_a_finite_global_timeout() {
        let ag = agent();
        assert_eq!(
            ag.config().timeouts().global,
            Some(Duration::from_secs(TIMEOUT_SECS)),
            "fetch 用的 agent 必须显式设 timeout_global:ureq 默认 None 等于永不超时"
        );
        // 顺带把「常量本身得是个正数」也钉住(0 秒会让所有请求立刻失败)。
        // **编译期断言**(const block),不是运行期。
        // 这条守的是"常量被改成 0 → 超时形同没有"。放运行期测等于
        // 每次测试都验一遍同一个常量;放编译期则**改错的那一刻就编不过**。
        const { assert!(TIMEOUT_SECS > 0, "TIMEOUT_SECS 不能是 0") };
    }

    /// 复现缺陷现场:TCP 连上了,但对端永不回话。
    ///
    /// 这正是「GUI 永远转圈、刷新按钮全灰」的触发条件 —— 原来 `fetch` 走
    /// `ureq::get()`,超时全 `None`,这段代码在这种对端上**永不返回**。
    /// 为了让「复发」表现为断言失败、而不是把测试进程一起挂死,这里把请求放进
    /// 子线程、用 `recv_timeout` 卡一个上界。
    ///
    /// 不碰外网,只连本机回环。
    #[test]
    fn a_hung_connection_times_out_instead_of_blocking_forever() {
        use std::io::Read;
        use std::net::TcpListener;
        use std::sync::mpsc;

        let listener = TcpListener::bind("127.0.0.1:0").expect("绑定回环临时端口");
        let addr = listener.local_addr().expect("取回环端口");

        // 接住连接后既不写响应、也不关闭 —— 就是对端挂起的样子。
        // 这个线程故意不 join:它要一直占着连接,直到测试进程退出。
        std::thread::spawn(move || {
            if let Ok((mut sock, _)) = listener.accept() {
                let mut buf = [0u8; 1024];
                let _ = sock.read(&mut buf); // 收掉请求,免得对端卡在写缓冲上
                std::thread::sleep(Duration::from_secs(10));
            }
        });

        let (tx, rx) = mpsc::channel();
        std::thread::spawn(move || {
            // 走的是生产那条请求/读 body 的代码,只把等待从 TIMEOUT_SECS 缩到 1 秒。
            let r = get_body_with(&agent_with_timeout(1), &format!("http://{addr}/"));
            let _ = tx.send(r.err());
        });

        match rx.recv_timeout(Duration::from_secs(5)) {
            Ok(Some(msg)) => assert!(
                msg.to_lowercase().contains("timeout"),
                "挂起的连接应当以超时收场,实际报的是:{msg}"
            ),
            Ok(None) => panic!("对端从不回话,请求却成功了 —— 本地 server 没起到作用"),
            Err(_) => panic!(
                "5 秒内没返回:agent 的超时没生效,\
                 fetch 会永久阻塞、GUI 刷新按钮会一直灰(缺陷复发)"
            ),
        }
    }

    /// 成功路径也要有自动化覆盖:本机回环起一个只会吐 `SAMPLE` 的假 wttr.in,
    /// 走 `get_body_with` + `parse` —— 与生产 `fetch` 除 URL/超时外完全是同一条代码。
    ///
    /// 不触外网,所以能进 CI;断言的是"成功时 `now.temp` 非空",不是"能连上 wttr.in"。
    /// 和上面的超时用例合起来,审计点名的「成功时有温度 / 失败时是人话」两头都有了。
    #[test]
    fn a_successful_response_yields_a_current_temperature() {
        use std::io::{Read, Write};
        use std::net::TcpListener;

        let listener = TcpListener::bind("127.0.0.1:0").expect("绑定回环临时端口");
        let addr = listener.local_addr().expect("取回环端口");

        let body = SAMPLE.to_string();
        std::thread::spawn(move || {
            if let Ok((mut sock, _)) = listener.accept() {
                let mut buf = [0u8; 4096];
                let _ = sock.read(&mut buf); // 收掉请求,免得对端卡在写缓冲上
                let resp = format!(
                    "HTTP/1.1 200 OK\r\n\
                     Content-Type: application/json\r\n\
                     Content-Length: {}\r\n\
                     Connection: close\r\n\r\n{}",
                    body.len(),
                    body
                );
                let _ = sock.write_all(resp.as_bytes());
            }
        });

        let text = get_body_with(&agent_with_timeout(5), &format!("http://{addr}/"))
            .expect("假 wttr.in 应当回一个完整响应");
        let r = parse(&text).expect("样本必须能解析");
        assert_eq!(r.now.temp, "23", "成功路径必须给出当前温度");
        assert_eq!(r.days.len(), 3);
    }
}