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);
}
}