refactor(console-ui/W8): split shell.rs into shell/ facade + render/overlays/tests

Break the 1212-line pf-console-ui/src/shell.rs into a facade + shell/ subdir
(shell.rs stays the parent; `mod render;` resolves to shell/render.rs):
  - shell/render.rs   : the per-frame screen compose/transition path (Shell::render
                        + the LayerEnv paint helper)
  - shell/overlays.rs : the modal overlays (Shell::draw_overlays + draw_takeover)
  - shell/tests.rs    : the inline #[cfg(test)] module, extracted verbatim
The Shell struct + its public API + draw_aurora() stay in shell.rs (both children
reach draw_aurora + the private fields as descendants — no bumps). Sole visibility
change: draw_overlays -> pub(in crate::shell) (its caller Shell::render is now a
sibling). Zero re-exports needed (Shell/ConsoleOptions never leave the root). Pure
move; no behavior change.

Verified both platforms: Linux (home-worker-5) clippy -p pf-console-ui
(--all-targets -D warnings) + test; Windows (winbox, ASCII CARGO_HOME) clippy.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-17 14:29:07 +02:00
parent 13b1f36d4a
commit 22a61e0b48
4 changed files with 792 additions and 774 deletions
+303
View File
@@ -0,0 +1,303 @@
use super::*;
use crate::model::WakeStatus;
use crate::screens::home::HomeScreen;
use crate::screens::library::LibraryScreen;
use punktfunk_core::config::GamepadPref;
/// Point the settings/known-hosts stores at a throwaway HOME — the settings screen
/// SAVES on adjust, and a test must never write the developer's real config.
fn fake_home() {
use std::sync::OnceLock;
static HOME: OnceLock<std::path::PathBuf> = OnceLock::new();
let dir = HOME.get_or_init(|| {
let dir = std::env::temp_dir().join(format!("pf-console-test-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
std::env::set_var("HOME", &dir);
dir.clone()
});
std::env::set_var("HOME", dir);
}
fn hosts() -> Vec<HostRow> {
let base = HostRow {
key: String::new(),
name: String::new(),
addr: "10.0.0.20".into(),
port: 9777,
fp_hex: String::new(),
paired: false,
saved: true,
online: false,
mgmt_port: 47990,
can_wake: false,
last_used: None,
};
vec![
HostRow {
key: "aa11".into(),
name: "Living Room PC".into(),
fp_hex: "aa11".into(),
paired: true,
online: true,
last_used: Some(1),
..base.clone()
},
HostRow {
key: "bb22".into(),
name: "Office Tower".into(),
addr: "10.0.0.21".into(),
fp_hex: "bb22".into(),
paired: true,
can_wake: true,
..base.clone()
},
HostRow {
key: "10.0.0.30:9777".into(),
name: "steambox".into(),
addr: "10.0.0.30".into(),
saved: false,
online: true,
..base
},
]
}
fn shell(stack: Vec<Screen>) -> (Shell, ConsoleShared, LibraryShared) {
fake_home();
let console = ConsoleShared::default();
console.set_hosts(hosts());
let library = LibraryShared::default();
let bus = ConsoleBus::default();
let shell = Shell::new(
console.clone(),
library.clone(),
bus,
ConsoleOptions {
device_name: "deck".into(),
deck: false,
},
stack,
)
.unwrap();
(shell, console, library)
}
/// The shell survives a full navigation lap (a smoke test over every screen's
/// input handling — no rendering, no GPU).
#[test]
fn navigation_lap() {
let (mut s, _console, _library) = shell(vec![Screen::Home(HomeScreen::new())]);
s.sync();
// Home → Settings (X), adjust something, back out.
s.handle_menu(MenuEvent::Tertiary);
assert_eq!(s.stack.len(), 2);
finish_motion(&mut s);
s.handle_menu(MenuEvent::Move(MenuDir::Down));
s.handle_menu(MenuEvent::Move(MenuDir::Right));
s.handle_menu(MenuEvent::Back);
finish_motion(&mut s);
assert_eq!(s.stack.len(), 1);
// Home → Library on the paired host (Y), then back.
s.handle_menu(MenuEvent::Secondary);
assert_eq!(s.stack.len(), 2);
finish_motion(&mut s);
s.handle_menu(MenuEvent::Back);
finish_motion(&mut s);
assert_eq!(s.stack.len(), 1);
// B at the root quits.
s.handle_menu(MenuEvent::Back);
assert!(matches!(s.take_action(), Some(OverlayAction::Quit)));
}
#[test]
fn connect_flow_raises_launch_and_cancel() {
let (mut s, _console, _library) = shell(vec![Screen::Home(HomeScreen::new())]);
s.sync();
s.handle_menu(MenuEvent::Confirm); // paired+online host focused first
assert!(matches!(
s.take_action(),
Some(OverlayAction::Launch { launch: None, .. })
));
assert!(s.connecting.is_some());
// While connecting: B cancels exactly once.
s.handle_menu(MenuEvent::Back);
assert!(matches!(
s.take_action(),
Some(OverlayAction::CancelConnect)
));
s.handle_menu(MenuEvent::Back);
assert!(s.take_action().is_none(), "cancel is idempotent");
// The canceled dial ends silently.
s.session_ended(None);
assert!(s.connecting.is_none());
}
fn finish_motion(s: &mut Shell) {
// Transitions block input; tests fast-forward them.
s.motion = Motion::None;
}
#[test]
fn wake_gates_input_in_the_same_press() {
let (mut s, _console, _library) = shell(vec![Screen::Home(HomeScreen::new())]);
s.sync();
// Focus "Office Tower" (offline + wakeable), then A: the wake starts.
s.handle_menu(MenuEvent::Move(MenuDir::Right));
s.handle_menu(MenuEvent::Confirm);
let w = s
.wake
.as_ref()
.expect("Waking card raised in the SAME call as the A press");
assert_eq!(w.name, "Office Tower");
assert!(!w.online);
// The very next input is modal-gated — the cursor can't drift onto Add Host —
// and sync (which runs first in handle_menu) must not clear the placeholder
// before the service thread reports its first real status.
assert!(s.handle_menu(MenuEvent::Move(MenuDir::Right)).is_none());
assert!(
s.wake.is_some(),
"optimistic card survived a sync with no service status"
);
// B cancels: the gate releases and navigation works again.
s.handle_menu(MenuEvent::Back);
assert!(s.wake.is_none());
assert!(s.handle_menu(MenuEvent::Move(MenuDir::Left)).is_some());
}
/// Render every console scene to PNGs for the eyeball pass (ignored; run with
/// `PF_CONSOLE_DUMP=<dir> cargo test -p pf-console-ui --release -- --ignored dump`).
/// CPU raster — the SkSL aurora, layers and text all run without a GPU.
#[test]
#[ignore]
fn dump_console_screens() {
let dir = std::env::var("PF_CONSOLE_DUMP").expect("set PF_CONSOLE_DUMP to an output dir");
let fonts = crate::theme::build_fonts().unwrap();
let (w, h) = (1280, 800);
let pads: Vec<PadInfo> = Vec::new();
let dump = |shell: &mut Shell, frames: usize, sleep_ms: u64, name: &str, pad: bool| {
let mut surface = skia_safe::surfaces::raster_n32_premul((w, h)).unwrap();
for _ in 0..frames {
shell.render(
surface.canvas(),
w as u32,
h as u32,
&fonts,
pad.then_some("Xbox Wireless Controller"),
pad.then_some(GamepadPref::Xbox360),
&pads,
);
std::thread::sleep(std::time::Duration::from_millis(sleep_ms));
}
let png = surface
.image_snapshot()
.encode(None, skia_safe::EncodedImageFormat::PNG, 100)
.unwrap();
std::fs::write(format!("{dir}/{name}.png"), png.as_bytes()).unwrap();
};
// Home, settled, with a pad (Letters glyphs).
let (mut s, console, library) = shell(vec![Screen::Home(HomeScreen::new())]);
dump(&mut s, 40, 8, "01-home", true);
// Mid-push into Settings (the transition still): a couple of fast frames land
// the capture around p ≈ 0.4 — both layers visible.
s.handle_menu(MenuEvent::Tertiary);
dump(&mut s, 3, 25, "02-transition", true);
dump(&mut s, 40, 8, "03-settings", true);
// Add Host with the keyboard tray up (keyboard glyph style: no pad).
s.handle_menu(MenuEvent::Back);
dump(&mut s, 40, 8, "_back", true);
for _ in 0..3 {
s.handle_menu(MenuEvent::Move(MenuDir::Right));
}
s.handle_menu(MenuEvent::Confirm); // Add Host screen
dump(&mut s, 40, 8, "04-addhost", false);
s.handle_menu(MenuEvent::Confirm); // open the Name keyboard
for ev in [
MenuEvent::Move(MenuDir::Down),
MenuEvent::Confirm,
MenuEvent::Confirm,
] {
s.handle_menu(ev);
}
dump(&mut s, 40, 8, "05-addhost-keyboard", false);
// Pair (focused on the unpaired discovered host).
s.handle_menu(MenuEvent::Back); // close keyboard
s.handle_menu(MenuEvent::Back); // leave add-host
dump(&mut s, 40, 8, "_back2", true);
s.handle_menu(MenuEvent::Move(MenuDir::Left)); // onto "steambox"
s.handle_menu(MenuEvent::Confirm);
dump(&mut s, 40, 8, "06-pair", true);
// Library with placeholder posters.
library.set_games(
[
"Hades II",
"Elden Ring",
"Hollow Knight",
"Baldur's Gate 3",
"Celeste",
"Deep Rock Galactic",
"Portal 2",
]
.iter()
.enumerate()
.map(|(i, t)| crate::library::LibraryGame {
id: format!("steam:{i}"),
title: (*t).to_string(),
store: "steam".into(),
})
.collect(),
);
let (mut s2, _c2, _l2) = {
let console2 = ConsoleShared::default();
console2.set_hosts(hosts());
let bus = ConsoleBus::default();
let sh = Shell::new(
console2.clone(),
library.clone(),
bus,
ConsoleOptions {
device_name: "deck".into(),
deck: false,
},
vec![
Screen::Home(HomeScreen::new()),
Screen::Library(LibraryScreen::new(&hosts()[0])),
],
)
.unwrap();
(sh, console2, library.clone())
};
s2.handle_menu(MenuEvent::Move(MenuDir::Right));
s2.handle_menu(MenuEvent::Move(MenuDir::Right));
dump(&mut s2, 40, 8, "07-library", true);
// The wake and connecting overlays + a toast.
console.set_wake(Some(WakeStatus {
key: "bb22".into(),
name: "Office Tower".into(),
seconds: 12,
timed_out: false,
online: false,
then_connect: true,
}));
dump(&mut s, 10, 8, "08-waking", true);
console.set_wake(Some(WakeStatus {
key: "bb22".into(),
name: "Office Tower".into(),
seconds: 90,
timed_out: true,
online: false,
then_connect: true,
}));
dump(&mut s, 10, 8, "08b-wake-timed-out", true);
console.set_wake(None);
s.set_connecting(Some("Elden Ring".into()));
dump(&mut s, 10, 8, "09-connecting", true);
s.set_connecting(None);
s.session_failed("Connection timed out");
dump(&mut s, 10, 8, "10-toast", true);
}