22a61e0b48
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>
304 lines
9.9 KiB
Rust
304 lines
9.9 KiB
Rust
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);
|
|
}
|