| 1 |
|
| 2 |
|
| 3 |
|
| 4 |
|
| 5 |
|
| 6 |
|
| 7 |
|
| 8 |
|
| 9 |
|
| 10 |
|
| 11 |
|
| 12 |
|
| 13 |
|
| 14 |
|
| 15 |
|
| 16 |
|
| 17 |
|
| 18 |
|
| 19 |
|
| 20 |
|
| 21 |
|
| 22 |
|
| 23 |
|
| 24 |
|
| 25 |
use std::fmt::Write as _; |
| 26 |
use std::io::{BufRead, BufReader, Write}; |
| 27 |
use std::process::Stdio; |
| 28 |
use std::sync::Arc; |
| 29 |
use std::sync::atomic::{AtomicBool, Ordering}; |
| 30 |
use std::time::Duration; |
| 31 |
|
| 32 |
use anyhow::Result; |
| 33 |
use makeover::SemanticTokens; |
| 34 |
|
| 35 |
use crate::audio::{self, Device}; |
| 36 |
use crate::cli::{CommandLog, child_command}; |
| 37 |
use crate::net::{self, Interface, Kind, State}; |
| 38 |
use crate::theme; |
| 39 |
|
| 40 |
|
| 41 |
|
| 42 |
|
| 43 |
|
| 44 |
|
| 45 |
const TICK: Duration = Duration::from_secs(1); |
| 46 |
|
| 47 |
|
| 48 |
|
| 49 |
|
| 50 |
const NET_POLL_TICKS: u64 = 5; |
| 51 |
|
| 52 |
|
| 53 |
|
| 54 |
|
| 55 |
const POWER_POLL_TICKS: u64 = 5; |
| 56 |
|
| 57 |
|
| 58 |
|
| 59 |
|
| 60 |
|
| 61 |
|
| 62 |
|
| 63 |
const THEME_POLL_TICKS: u64 = 10; |
| 64 |
|
| 65 |
|
| 66 |
|
| 67 |
|
| 68 |
|
| 69 |
|
| 70 |
const AUDIO_POLL_TICKS: u64 = 10; |
| 71 |
|
| 72 |
|
| 73 |
|
| 74 |
|
| 75 |
|
| 76 |
|
| 77 |
|
| 78 |
const POWER_SUPPLY: &str = "/sys/class/power_supply"; |
| 79 |
|
| 80 |
|
| 81 |
|
| 82 |
|
| 83 |
|
| 84 |
|
| 85 |
|
| 86 |
pub(crate) fn run_bar() -> Result<()> { |
| 87 |
let mut log = CommandLog::new(); |
| 88 |
let audio_backend = audio::detect(); |
| 89 |
let net_backend = net::detect(); |
| 90 |
let audio_changed = subscribe_audio(); |
| 91 |
|
| 92 |
let mut stdout = std::io::stdout().lock(); |
| 93 |
|
| 94 |
|
| 95 |
|
| 96 |
writeln!(stdout, "{{\"version\":1}}")?; |
| 97 |
writeln!(stdout, "[")?; |
| 98 |
|
| 99 |
let mut tokens = theme::semantic(None).ok(); |
| 100 |
let mut power = read_power(); |
| 101 |
let mut network = read_network(net_backend.as_ref(), &mut log); |
| 102 |
let mut volume = read_volume(audio_backend.as_ref(), &mut log); |
| 103 |
|
| 104 |
for tick in 0u64.. { |
| 105 |
if tick > 0 { |
| 106 |
if tick % THEME_POLL_TICKS == 0 { |
| 107 |
|
| 108 |
|
| 109 |
|
| 110 |
if let Ok(fresh) = theme::semantic(None) { |
| 111 |
tokens = Some(fresh); |
| 112 |
} |
| 113 |
} |
| 114 |
if tick % POWER_POLL_TICKS == 0 { |
| 115 |
power = read_power(); |
| 116 |
} |
| 117 |
if tick % NET_POLL_TICKS == 0 { |
| 118 |
network = read_network(net_backend.as_ref(), &mut log); |
| 119 |
} |
| 120 |
let audio_due = audio_changed |
| 121 |
.as_ref() |
| 122 |
.is_some_and(|flag| flag.swap(false, Ordering::Relaxed)) |
| 123 |
|| tick % AUDIO_POLL_TICKS == 0; |
| 124 |
if audio_due { |
| 125 |
volume = read_volume(audio_backend.as_ref(), &mut log); |
| 126 |
} |
| 127 |
} |
| 128 |
|
| 129 |
let line = render( |
| 130 |
tokens.as_ref(), |
| 131 |
power.as_ref(), |
| 132 |
network.as_ref(), |
| 133 |
volume.as_ref(), |
| 134 |
); |
| 135 |
if writeln!(stdout, "{line},").is_err() || stdout.flush().is_err() { |
| 136 |
return Ok(()); |
| 137 |
} |
| 138 |
|
| 139 |
std::thread::sleep(TICK); |
| 140 |
} |
| 141 |
|
| 142 |
Ok(()) |
| 143 |
} |
| 144 |
|
| 145 |
|
| 146 |
|
| 147 |
|
| 148 |
|
| 149 |
|
| 150 |
|
| 151 |
|
| 152 |
|
| 153 |
|
| 154 |
|
| 155 |
|
| 156 |
|
| 157 |
fn subscribe_audio() -> Option<Arc<AtomicBool>> { |
| 158 |
let mut child = child_command("pactl") |
| 159 |
.arg("subscribe") |
| 160 |
.stdin(Stdio::null()) |
| 161 |
.stdout(Stdio::piped()) |
| 162 |
.stderr(Stdio::null()) |
| 163 |
.spawn() |
| 164 |
.ok()?; |
| 165 |
let stdout = child.stdout.take()?; |
| 166 |
|
| 167 |
let changed = Arc::new(AtomicBool::new(false)); |
| 168 |
let flag = Arc::clone(&changed); |
| 169 |
std::thread::spawn(move || { |
| 170 |
for line in BufReader::new(stdout).lines().map_while(Result::ok) { |
| 171 |
|
| 172 |
|
| 173 |
|
| 174 |
|
| 175 |
if line.contains("on sink ") || line.contains("on server ") { |
| 176 |
flag.store(true, Ordering::Relaxed); |
| 177 |
} |
| 178 |
} |
| 179 |
|
| 180 |
|
| 181 |
|
| 182 |
let _ = child.wait(); |
| 183 |
}); |
| 184 |
|
| 185 |
Some(changed) |
| 186 |
} |
| 187 |
|
| 188 |
|
| 189 |
struct Power { |
| 190 |
percent: u8, |
| 191 |
charging: bool, |
| 192 |
|
| 193 |
|
| 194 |
|
| 195 |
full: bool, |
| 196 |
} |
| 197 |
|
| 198 |
|
| 199 |
|
| 200 |
|
| 201 |
|
| 202 |
|
| 203 |
|
| 204 |
|
| 205 |
|
| 206 |
|
| 207 |
fn read_power() -> Option<Power> { |
| 208 |
let mut entries: Vec<_> = std::fs::read_dir(POWER_SUPPLY) |
| 209 |
.ok()? |
| 210 |
.filter_map(Result::ok) |
| 211 |
.map(|entry| entry.path()) |
| 212 |
.collect(); |
| 213 |
|
| 214 |
|
| 215 |
entries.sort(); |
| 216 |
|
| 217 |
for path in entries { |
| 218 |
let kind = std::fs::read_to_string(path.join("type")).ok()?; |
| 219 |
if kind.trim() != "Battery" { |
| 220 |
continue; |
| 221 |
} |
| 222 |
let percent = std::fs::read_to_string(path.join("capacity")) |
| 223 |
.ok() |
| 224 |
.and_then(|raw| raw.trim().parse::<u8>().ok())?; |
| 225 |
|
| 226 |
|
| 227 |
|
| 228 |
let status = std::fs::read_to_string(path.join("status")).unwrap_or_default(); |
| 229 |
let status = status.trim(); |
| 230 |
return Some(Power { |
| 231 |
percent, |
| 232 |
charging: status == "Charging", |
| 233 |
full: status == "Full" || status == "Not charging", |
| 234 |
}); |
| 235 |
} |
| 236 |
None |
| 237 |
} |
| 238 |
|
| 239 |
|
| 240 |
|
| 241 |
|
| 242 |
|
| 243 |
|
| 244 |
|
| 245 |
fn read_network(backend: &dyn net::Backend, log: &mut CommandLog) -> Option<Interface> { |
| 246 |
let interfaces = log.quiet(|log| backend.list(log)).ok()?; |
| 247 |
let candidates = interfaces |
| 248 |
.into_iter() |
| 249 |
.filter(|iface| iface.kind != Kind::Loopback && iface.state == State::Connected); |
| 250 |
let mut best: Option<Interface> = None; |
| 251 |
for iface in candidates { |
| 252 |
let wins = best |
| 253 |
.as_ref() |
| 254 |
.is_none_or(|held| held.kind != Kind::Wireless && iface.kind == Kind::Wireless); |
| 255 |
if wins { |
| 256 |
best = Some(iface); |
| 257 |
} |
| 258 |
} |
| 259 |
best |
| 260 |
} |
| 261 |
|
| 262 |
|
| 263 |
|
| 264 |
|
| 265 |
|
| 266 |
|
| 267 |
fn read_volume(backend: &dyn audio::Backend, log: &mut CommandLog) -> Option<Device> { |
| 268 |
log.quiet(|log| backend.default_output(log)).ok().flatten() |
| 269 |
} |
| 270 |
|
| 271 |
|
| 272 |
|
| 273 |
|
| 274 |
|
| 275 |
|
| 276 |
fn render( |
| 277 |
tokens: Option<&SemanticTokens>, |
| 278 |
power: Option<&Power>, |
| 279 |
network: Option<&Interface>, |
| 280 |
volume: Option<&Device>, |
| 281 |
) -> String { |
| 282 |
let mut blocks: Vec<String> = Vec::new(); |
| 283 |
|
| 284 |
if let Some(power) = power { |
| 285 |
let text = if power.charging { |
| 286 |
format!("{}% charging", power.percent) |
| 287 |
} else if power.full { |
| 288 |
format!("{}% AC", power.percent) |
| 289 |
} else { |
| 290 |
format!("{}%", power.percent) |
| 291 |
}; |
| 292 |
|
| 293 |
|
| 294 |
|
| 295 |
|
| 296 |
|
| 297 |
|
| 298 |
let color = match () { |
| 299 |
() if power.charging || power.full => "content", |
| 300 |
() if power.percent <= 10 => "danger", |
| 301 |
() if power.percent <= 20 => "warning", |
| 302 |
() => "content", |
| 303 |
}; |
| 304 |
blocks.push(block("battery", &text, color, tokens)); |
| 305 |
} |
| 306 |
|
| 307 |
if let Some(iface) = network { |
| 308 |
|
| 309 |
|
| 310 |
|
| 311 |
|
| 312 |
let text = iface.connection.as_deref().unwrap_or(&iface.name); |
| 313 |
blocks.push(block("network", text, "content", tokens)); |
| 314 |
} |
| 315 |
|
| 316 |
if let Some(device) = volume { |
| 317 |
let (text, color) = if device.muted { |
| 318 |
(format!("vol {}% muted", device.volume), "content-muted") |
| 319 |
} else { |
| 320 |
(format!("vol {}%", device.volume), "content") |
| 321 |
}; |
| 322 |
blocks.push(block("volume", &text, color, tokens)); |
| 323 |
} |
| 324 |
|
| 325 |
blocks.push(block("clock", &clock(), "content", tokens)); |
| 326 |
|
| 327 |
format!("[{}]", blocks.join(",")) |
| 328 |
} |
| 329 |
|
| 330 |
|
| 331 |
|
| 332 |
|
| 333 |
|
| 334 |
|
| 335 |
|
| 336 |
|
| 337 |
|
| 338 |
|
| 339 |
|
| 340 |
fn clock() -> String { |
| 341 |
let now = std::time::SystemTime::now() |
| 342 |
.duration_since(std::time::UNIX_EPOCH) |
| 343 |
.map_or(0, |since| since.as_secs()) as i64; |
| 344 |
let (year, month, day, hour, minute) = civil_from_unix(now + local_offset()); |
| 345 |
format!("{year:04}-{month:02}-{day:02} {hour:02}:{minute:02}") |
| 346 |
} |
| 347 |
|
| 348 |
|
| 349 |
|
| 350 |
|
| 351 |
|
| 352 |
|
| 353 |
|
| 354 |
|
| 355 |
|
| 356 |
|
| 357 |
|
| 358 |
|
| 359 |
fn civil_from_unix(seconds: i64) -> (i64, u32, u32, u32, u32) { |
| 360 |
let days = seconds.div_euclid(86_400); |
| 361 |
let secs = seconds.rem_euclid(86_400); |
| 362 |
|
| 363 |
let z = days + 719_468; |
| 364 |
let era = z.div_euclid(146_097); |
| 365 |
let doe = z.rem_euclid(146_097); |
| 366 |
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; |
| 367 |
let y = yoe + era * 400; |
| 368 |
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); |
| 369 |
let mp = (5 * doy + 2) / 153; |
| 370 |
let d = doy - (153 * mp + 2) / 5 + 1; |
| 371 |
let m = if mp < 10 { mp + 3 } else { mp - 9 }; |
| 372 |
let y = if m <= 2 { y + 1 } else { y }; |
| 373 |
|
| 374 |
( |
| 375 |
y, |
| 376 |
m as u32, |
| 377 |
d as u32, |
| 378 |
(secs / 3600) as u32, |
| 379 |
(secs / 60 % 60) as u32, |
| 380 |
) |
| 381 |
} |
| 382 |
|
| 383 |
|
| 384 |
|
| 385 |
|
| 386 |
|
| 387 |
|
| 388 |
|
| 389 |
|
| 390 |
|
| 391 |
|
| 392 |
|
| 393 |
fn local_offset() -> i64 { |
| 394 |
tzif_offset().unwrap_or(0) |
| 395 |
} |
| 396 |
|
| 397 |
|
| 398 |
|
| 399 |
|
| 400 |
|
| 401 |
|
| 402 |
|
| 403 |
|
| 404 |
fn tzif_offset() -> Option<i64> { |
| 405 |
let data = std::fs::read("/etc/localtime").ok()?; |
| 406 |
if data.len() < 44 || &data[..4] != b"TZif" { |
| 407 |
return None; |
| 408 |
} |
| 409 |
let counts: Vec<u32> = (0..6) |
| 410 |
.map(|i| { |
| 411 |
let at = 20 + i * 4; |
| 412 |
u32::from_be_bytes([data[at], data[at + 1], data[at + 2], data[at + 3]]) |
| 413 |
}) |
| 414 |
.collect(); |
| 415 |
let (timecnt, typecnt) = (counts[3] as usize, counts[4] as usize); |
| 416 |
if typecnt == 0 { |
| 417 |
return None; |
| 418 |
} |
| 419 |
|
| 420 |
let times = 44; |
| 421 |
let indices = times + timecnt * 4; |
| 422 |
let types = indices + timecnt; |
| 423 |
|
| 424 |
let now = std::time::SystemTime::now() |
| 425 |
.duration_since(std::time::UNIX_EPOCH) |
| 426 |
.ok()? |
| 427 |
.as_secs() as i64; |
| 428 |
|
| 429 |
|
| 430 |
|
| 431 |
|
| 432 |
let mut chosen = 0usize; |
| 433 |
for i in 0..timecnt { |
| 434 |
let at = times + i * 4; |
| 435 |
let when = i32::from_be_bytes([data[at], data[at + 1], data[at + 2], data[at + 3]]) as i64; |
| 436 |
if when > now { |
| 437 |
break; |
| 438 |
} |
| 439 |
chosen = *data.get(indices + i)? as usize; |
| 440 |
} |
| 441 |
if chosen >= typecnt { |
| 442 |
return None; |
| 443 |
} |
| 444 |
|
| 445 |
let at = types + chosen * 6; |
| 446 |
let raw = data.get(at..at + 4)?; |
| 447 |
Some(i32::from_be_bytes([raw[0], raw[1], raw[2], raw[3]]) as i64) |
| 448 |
} |
| 449 |
|
| 450 |
|
| 451 |
|
| 452 |
|
| 453 |
|
| 454 |
|
| 455 |
|
| 456 |
fn block(name: &str, text: &str, token: &str, tokens: Option<&SemanticTokens>) -> String { |
| 457 |
let mut out = String::from("{\"name\":\""); |
| 458 |
out.push_str(name); |
| 459 |
out.push_str("\",\"full_text\":\""); |
| 460 |
escape_into(&mut out, text); |
| 461 |
out.push('"'); |
| 462 |
if let Some(hex) = tokens.and_then(|tokens| tokens.hex(token)) { |
| 463 |
let _ = write!(out, ",\"color\":\"{hex}\""); |
| 464 |
} |
| 465 |
out.push_str(",\"separator_block_width\":14}"); |
| 466 |
out |
| 467 |
} |
| 468 |
|
| 469 |
|
| 470 |
|
| 471 |
|
| 472 |
|
| 473 |
|
| 474 |
|
| 475 |
|
| 476 |
fn escape_into(out: &mut String, text: &str) { |
| 477 |
for ch in text.chars() { |
| 478 |
match ch { |
| 479 |
'"' => out.push_str("\\\""), |
| 480 |
'\\' => out.push_str("\\\\"), |
| 481 |
|
| 482 |
|
| 483 |
c if (c as u32) < 0x20 => {} |
| 484 |
c => out.push(c), |
| 485 |
} |
| 486 |
} |
| 487 |
} |
| 488 |
|
| 489 |
#[cfg(test)] |
| 490 |
mod tests { |
| 491 |
use super::*; |
| 492 |
use crate::audio::Direction; |
| 493 |
|
| 494 |
fn power(percent: u8, charging: bool, full: bool) -> Power { |
| 495 |
Power { |
| 496 |
percent, |
| 497 |
charging, |
| 498 |
full, |
| 499 |
} |
| 500 |
} |
| 501 |
|
| 502 |
fn tokens() -> SemanticTokens { |
| 503 |
let dirs = crate::theme::search_path(); |
| 504 |
makeover::load_semantic(&dirs, crate::theme::DEFAULT_LIGHT).expect("a shipped theme loads") |
| 505 |
} |
| 506 |
|
| 507 |
|
| 508 |
|
| 509 |
#[test] |
| 510 |
fn the_battery_block_says_whether_it_is_charging() { |
| 511 |
let line = render(None, Some(&power(64, true, false)), None, None); |
| 512 |
assert!(line.contains("64% charging"), "{line}"); |
| 513 |
|
| 514 |
let line = render(None, Some(&power(100, false, true)), None, None); |
| 515 |
assert!(line.contains("100% AC"), "{line}"); |
| 516 |
|
| 517 |
let line = render(None, Some(&power(64, false, false)), None, None); |
| 518 |
assert!(line.contains("\"64%\""), "{line}"); |
| 519 |
} |
| 520 |
|
| 521 |
|
| 522 |
|
| 523 |
#[test] |
| 524 |
fn a_machine_with_no_battery_has_no_battery_block() { |
| 525 |
let line = render(None, None, None, None); |
| 526 |
assert!(!line.contains("battery"), "{line}"); |
| 527 |
assert!(line.contains("clock"), "the clock is unconditional: {line}"); |
| 528 |
} |
| 529 |
|
| 530 |
|
| 531 |
|
| 532 |
#[test] |
| 533 |
fn a_low_battery_takes_the_status_colors() { |
| 534 |
let tokens = tokens(); |
| 535 |
let danger = tokens.hex("danger").expect("a theme has a danger token"); |
| 536 |
let warning = tokens.hex("warning").expect("a theme has a warning token"); |
| 537 |
|
| 538 |
let line = render(Some(&tokens), Some(&power(8, false, false)), None, None); |
| 539 |
assert!(line.contains(danger), "8% is danger: {line}"); |
| 540 |
|
| 541 |
let line = render(Some(&tokens), Some(&power(18, false, false)), None, None); |
| 542 |
assert!(line.contains(warning), "18% is warning: {line}"); |
| 543 |
|
| 544 |
|
| 545 |
let line = render(Some(&tokens), Some(&power(8, true, false)), None, None); |
| 546 |
assert!(!line.contains(danger), "charging is not danger: {line}"); |
| 547 |
} |
| 548 |
|
| 549 |
|
| 550 |
|
| 551 |
#[test] |
| 552 |
fn no_theme_means_no_color_rather_than_a_hard_coded_one() { |
| 553 |
let line = render(None, Some(&power(50, false, false)), None, None); |
| 554 |
assert!(!line.contains("color"), "{line}"); |
| 555 |
assert!(!line.contains('#'), "{line}"); |
| 556 |
} |
| 557 |
|
| 558 |
|
| 559 |
|
| 560 |
#[test] |
| 561 |
fn an_ssid_cannot_break_the_json() { |
| 562 |
let iface = Interface { |
| 563 |
name: "wlp166s0".into(), |
| 564 |
kind: Kind::Wireless, |
| 565 |
state: State::Connected, |
| 566 |
connection: Some("say \"hi\"\\ then\nnewline".into()), |
| 567 |
addresses: Vec::new(), |
| 568 |
}; |
| 569 |
let line = render(None, None, Some(&iface), None); |
| 570 |
let parsed: serde_json::Value = |
| 571 |
serde_json::from_str(&line).unwrap_or_else(|e| panic!("{e} in {line}")); |
| 572 |
let text = parsed[0]["full_text"].as_str().expect("a string survived"); |
| 573 |
|
| 574 |
|
| 575 |
assert_eq!(text, "say \"hi\"\\ thennewline"); |
| 576 |
} |
| 577 |
|
| 578 |
|
| 579 |
|
| 580 |
#[test] |
| 581 |
fn an_update_is_a_json_array() { |
| 582 |
let device = Device { |
| 583 |
index: 1, |
| 584 |
name: "sink".into(), |
| 585 |
description: "Analog".into(), |
| 586 |
direction: Direction::Output, |
| 587 |
volume: 40, |
| 588 |
muted: true, |
| 589 |
is_default: true, |
| 590 |
}; |
| 591 |
let iface = Interface { |
| 592 |
name: "wlp166s0".into(), |
| 593 |
kind: Kind::Wireless, |
| 594 |
state: State::Connected, |
| 595 |
connection: Some("home".into()), |
| 596 |
addresses: Vec::new(), |
| 597 |
}; |
| 598 |
let line = render( |
| 599 |
Some(&tokens()), |
| 600 |
Some(&power(64, false, false)), |
| 601 |
Some(&iface), |
| 602 |
Some(&device), |
| 603 |
); |
| 604 |
let parsed: serde_json::Value = serde_json::from_str(&line).expect("valid JSON"); |
| 605 |
let blocks = parsed.as_array().expect("an array"); |
| 606 |
assert_eq!(blocks.len(), 4, "battery, network, volume, clock"); |
| 607 |
for block in blocks { |
| 608 |
assert!(block["name"].is_string(), "{block}"); |
| 609 |
assert!(block["full_text"].is_string(), "{block}"); |
| 610 |
} |
| 611 |
} |
| 612 |
|
| 613 |
|
| 614 |
|
| 615 |
#[test] |
| 616 |
fn wireless_wins_when_both_are_connected() { |
| 617 |
let wired = Interface { |
| 618 |
name: "enp0s13f0u1".into(), |
| 619 |
kind: Kind::Wired, |
| 620 |
state: State::Connected, |
| 621 |
connection: Some("dock".into()), |
| 622 |
addresses: Vec::new(), |
| 623 |
}; |
| 624 |
let wireless = Interface { |
| 625 |
name: "wlp166s0".into(), |
| 626 |
kind: Kind::Wireless, |
| 627 |
state: State::Connected, |
| 628 |
connection: Some("home".into()), |
| 629 |
addresses: Vec::new(), |
| 630 |
}; |
| 631 |
let mut log = CommandLog::new(); |
| 632 |
let backend = Fixture(vec![wired, wireless]); |
| 633 |
let picked = read_network(&backend, &mut log).expect("one is connected"); |
| 634 |
assert_eq!(picked.kind, Kind::Wireless); |
| 635 |
|
| 636 |
|
| 637 |
let backend = Fixture(vec![ |
| 638 |
Interface { |
| 639 |
name: "wlp166s0".into(), |
| 640 |
kind: Kind::Wireless, |
| 641 |
state: State::Connected, |
| 642 |
connection: Some("home".into()), |
| 643 |
addresses: Vec::new(), |
| 644 |
}, |
| 645 |
Interface { |
| 646 |
name: "enp0s13f0u1".into(), |
| 647 |
kind: Kind::Wired, |
| 648 |
state: State::Connected, |
| 649 |
connection: Some("dock".into()), |
| 650 |
addresses: Vec::new(), |
| 651 |
}, |
| 652 |
]); |
| 653 |
let picked = read_network(&backend, &mut log).expect("one is connected"); |
| 654 |
assert_eq!(picked.kind, Kind::Wireless); |
| 655 |
} |
| 656 |
|
| 657 |
|
| 658 |
|
| 659 |
#[test] |
| 660 |
fn loopback_is_not_a_network() { |
| 661 |
let backend = Fixture(vec![Interface { |
| 662 |
name: "lo".into(), |
| 663 |
kind: Kind::Loopback, |
| 664 |
state: State::Connected, |
| 665 |
connection: None, |
| 666 |
addresses: Vec::new(), |
| 667 |
}]); |
| 668 |
let mut log = CommandLog::new(); |
| 669 |
assert!(read_network(&backend, &mut log).is_none()); |
| 670 |
} |
| 671 |
|
| 672 |
struct Fixture(Vec<Interface>); |
| 673 |
|
| 674 |
impl net::Backend for Fixture { |
| 675 |
fn name(&self) -> &'static str { |
| 676 |
"fixture" |
| 677 |
} |
| 678 |
fn list(&self, _log: &mut CommandLog) -> Result<Vec<Interface>> { |
| 679 |
Ok(self.0.clone()) |
| 680 |
} |
| 681 |
} |
| 682 |
|
| 683 |
|
| 684 |
|
| 685 |
|
| 686 |
#[test] |
| 687 |
fn the_clock_converts_fixed_instants() { |
| 688 |
for (seconds, expect) in [ |
| 689 |
(0, (1970, 1, 1, 0, 0)), |
| 690 |
|
| 691 |
(1_709_210_040, (2024, 2, 29, 12, 34)), |
| 692 |
|
| 693 |
(1_735_689_599, (2024, 12, 31, 23, 59)), |
| 694 |
|
| 695 |
(-3600, (1969, 12, 31, 23, 0)), |
| 696 |
] { |
| 697 |
assert_eq!(civil_from_unix(seconds), expect, "at {seconds}"); |
| 698 |
} |
| 699 |
} |
| 700 |
|
| 701 |
|
| 702 |
|
| 703 |
|
| 704 |
#[test] |
| 705 |
fn the_local_offset_is_a_real_zone() { |
| 706 |
let offset = local_offset(); |
| 707 |
assert!( |
| 708 |
(-86_400..=86_400).contains(&offset), |
| 709 |
"offset {offset} is not a zone", |
| 710 |
); |
| 711 |
|
| 712 |
|
| 713 |
assert_eq!(offset % 60, 0, "offset {offset} is not on a minute"); |
| 714 |
} |
| 715 |
} |
| 716 |
|