crates\nfarms-gui\src\plugins\mod.rs¶
源码路径:
crates\nfarms-gui\src\plugins\mod.rs· 760 行
//! 页面插件:把「侧栏里的一项」做成可插拔的卡片。
//!
//! 这是 cordis 那套东西在界面上的用法:
//!
//! * 内核启动时**先**把空的 [`PageRegistry`] 以服务名 `pages` 提供出来;
//! * 每个页面插件 `inject = ["pages"]`,在 `apply` 里把自己那张卡片
//! ([`PageSpec`]:元信息 + 一个绘制函数指针)登记进去;
//! * 侧栏和内容区都只认注册表 —— **装了什么就显示什么**。
//!
//! 于是「给安卓版加一个功能」= 往 [`mobile`] 的列表里加一个 [`Page`],
//! 而不是去 `match` 里加分支、再去手改侧栏常量。
//!
//! 为什么 `draw` 是**函数指针**而不是 `Box<dyn Fn>`:页面都是无状态的
//! (状态全在 [`AppState`] 里),函数指针可 `Copy`、能跨锁传递、
//! 也不需要 `Arc<dyn Fn>` 那一层间接。
mod core_pages;
mod stubs;
pub(crate) mod ui;
use std::sync::{Arc, RwLock};
use eframe::egui;
use nfarms_plugin::{Context, Kernel, Plugin, Result};
use crate::AppState;
use crate::pages::{Group, Page, PageInfo, Sub, PAGES};
pub use ui::card;
/// 去掉 Markdown 粗体标记。弹出窗口的标题栏也要用,所以在模块外再导出一份。
pub use ui::plain as plain_text;
/// 页面注册表在服务容器里的名字。
pub const SERVICE_PAGES: &str = "pages";
/// 一张页面卡片:元信息 + 怎么画。
#[derive(Clone, Copy)]
pub struct PageSpec {
pub info: &'static PageInfo,
pub draw: fn(&mut PageCtx<'_>),
}
/// 侧栏上的一行(一级目录的直接内容)。
///
/// 三级结构落在侧栏里的形状:一级目录下面要么是**直接挂着的页**,要么是一个
/// **二级子分组**(它自己再挂几页)。两种可以混在同一个一级目录里
/// (Tools 下面既有 FFmpeg 这样的单页,也有 DS-Harness 这个子分组)。
#[derive(Clone)]
pub enum NavItem {
/// 直接挂在一级目录下的一页
Leaf(PageSpec),
/// 二级子分组及其页面
Subgroup(Sub, Vec<PageSpec>),
}
/// 一棵一级目录:分区本身 + 它下面的行(按侧栏顺序)。
pub struct NavTree {
pub group: Group,
pub items: Vec<NavItem>,
}
impl NavTree {
/// 这一组里一共多少页(**含子分组里的**)—— 侧栏标题右边那个数字。
pub fn page_count(&self) -> usize {
self.items
.iter()
.map(|it| match it {
NavItem::Leaf(_) => 1,
NavItem::Subgroup(_, v) => v.len(),
})
.sum()
}
/// 当前页在不在这棵树里 —— 侧栏用它把「当前所在」标出来。
pub fn holds(&self, page: Page) -> bool {
self.items.iter().any(|it| match it {
NavItem::Leaf(s) => s.info.page == page,
NavItem::Subgroup(_, v) => v.iter().any(|s| s.info.page == page),
})
}
}
/// 注册表。
///
/// `Clone` 是**共享**的(内部 `Arc`),不是拷贝 —— 插件拿到手就能往里登记。
#[derive(Clone, Default)]
pub struct PageRegistry {
slots: Arc<RwLock<Vec<PageSpec>>>,
}
impl std::fmt::Debug for PageRegistry {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let n = self.slots.read().map(|s| s.len()).unwrap_or(0);
write!(f, "PageRegistry({n} 页)")
}
}
impl PageRegistry {
pub fn new() -> Self {
Self::default()
}
/// 取读锁。**所有**访问器都从这里进 —— 锁中毒的策略只有这一份。
///
/// 中毒时**直接崩,不降级成空表**,这是有意的取舍:写只发生在启动装载那几次
/// ([`install`](Self::install)),中毒意味着某个线程把这张表改到一半就崩了,
/// 表里剩下的内容已经不可信。降级成空表的话,界面上的表现是「侧栏一页都没有」,
/// 和「插件根本没装」长得一模一样 —— 排查会一路去怀疑页面清单和装载流程;
/// 直接崩则把现场指向真正出问题的那次 panic。
/// (`Debug` 实现是唯一例外:它可能在 panic 展开期间被格式化,再崩一次会把
/// 原始的 panic 盖掉,所以那里用 `unwrap_or(0)`。)
fn read_slots(&self) -> std::sync::RwLockReadGuard<'_, Vec<PageSpec>> {
self.slots.read().expect("页面表被毒化")
}
/// 取写锁,中毒策略同 [`read_slots`](Self::read_slots)。
fn write_slots(&self) -> std::sync::RwLockWriteGuard<'_, Vec<PageSpec>> {
self.slots.write().expect("页面表被毒化")
}
/// 登记一张卡片。同一个页面登记两次只留第一次 —— 重复装载插件时
/// 不该在侧栏里出现两行一样的。
pub fn install(&self, spec: PageSpec) {
let mut slots = self.write_slots();
if slots.iter().any(|s| s.info.page == spec.info.page) {
return;
}
slots.push(spec);
}
pub fn len(&self) -> usize {
self.read_slots().len()
}
/// 注册表里一页都没装上。
///
/// 现在两条装载路径都不会走到这里:桌面版装整张 [`PAGES`],安卓版装 [`mobile()`]
/// 里写死的 8 项(见测试 `mobile_only_registers_the_skeleton`)。
/// (旧注释举的例子是「安卓骨架只剩天气」—— 那个形态早就不存在了。)
///
/// TODO:还没有调用者,`#[allow(dead_code)]` 是因为本模块在 crate 私有
/// (`lib.rs` 的 `mod plugins;`),这个 `pub fn` 在 crate 外不可达,没调用者就算死代码。
/// 打算做的消费方在 `lib.rs` 的侧栏 / 内容区:「注册表为空」时给一句
/// 「页面插件一页都没装上」,而不是画一片空白 —— 挂载点就是那里。
/// 不顺手删掉的理由:它和 [`len`](Self::len) 是成对的查询,有 `len()` 没有
/// `is_empty()` 的小 API 是残的(clippy 的 `len_without_is_empty` 盯的就是这种配对)。
#[allow(dead_code)]
pub fn is_empty(&self) -> bool {
self.len() == 0
}
/// 拷一份出来。侧栏和内容区都在**没有锁**的情况下遍历它 ——
/// 免得绘制过程中某个插件又登记一页,把正在遍历的迭代器搞坏。
///
/// 注意调用频率:这是**每次重绘**都会走一次的路径
/// (`lib.rs` 的 `sidebar` → [`grouped`](Self::grouped) → 这里),一次读锁 +
/// 整份 `Vec` 拷贝。现在三十几项,这点拷贝无所谓;真涨到几百项就得换成
/// 「在 `App` 里缓存一份快照、只在注册表变化时重建」,而不是每帧克隆一遍。
pub fn specs(&self) -> Vec<PageSpec> {
self.read_slots().clone()
}
pub fn get(&self, page: Page) -> Option<PageSpec> {
self.read_slots()
.iter()
.find(|s| s.info.page == page)
.copied()
}
pub fn contains(&self, page: Page) -> bool {
self.get(page).is_some()
}
/// 装载之后的第一页(启动时的兜底)。
pub fn first(&self) -> Option<Page> {
self.read_slots().first().map(|s| s.info.page)
}
/// 按功能树分组,并且**只返回有内容的组**。
///
/// 这是 [`grouped_tree`](Self::grouped_tree) 的**扁平前身**。三级菜单的侧栏
/// 已经改成画那棵树了,所以它现在**只有测试在用**(本模块与 `lib.rs` 的侧栏
/// 测试要的是「按组算页数」这个口径,不需要树)。
///
/// 为什么不干脆删掉:那几条测试里的期望值是按组写的(`["today", "weather"]`),
/// 换成树的形状等于把它们重写一遍,而它们守的东西没变。留着这个「数据库里的
/// 一个视图」比让每条断言都多写一层 `match NavItem::…` 划算。
/// `#[allow(dead_code)]` 只对**非测试**构建生效,所以它盖不住「没人用」以外的问题。
#[allow(dead_code)]
pub fn grouped(&self) -> Vec<(Group, Vec<PageSpec>)> {
// 每帧一次读锁 + 整份拷贝 —— 缘由和扩容条件写在 `specs()` 的文档里
let specs = self.specs();
Group::ALL
.iter()
.filter_map(|g| {
let children: Vec<PageSpec> =
specs.iter().filter(|s| s.info.group == *g).copied().collect();
if children.is_empty() {
None
} else {
Some((*g, children))
}
})
.collect()
}
/// 三级结构下的侧栏树:一级目录 → (直接挂着的页 | 二级子分组 → 页)。
///
/// 两条顺序性质都在这儿落地:
///
/// * 一级目录顺序 = [`Group::ALL`];
/// * 每个容器里的顺序 = **本注册表里的登记顺序**(桌面版就是 `pages.rs`
/// 那张表的顺序,安卓版就是 [`mobile()`] 的顺序)。
///
/// 用户拖出来的 `nav_order` **不在这里**应用 —— 那是绘制层的事
/// (`lib.rs` 的 `sidebar` 按 `rank` 现排),因为这里每帧都会跑一遍。
///
/// 「直接挂着的页」排在子分组**前面**:一级目录自己那一项(如 Tools ›
/// DS-Harness、Player › Music)在功能树里就是排在二级目录之前的。
/// 这条性质由 `grouped_tree_puts_leaves_before_subgroups` 钉住。
pub fn grouped_tree(&self) -> Vec<NavTree> {
let specs = self.specs();
Group::ALL
.iter()
.filter_map(|g| {
let mine: Vec<PageSpec> =
specs.iter().filter(|s| s.info.group == *g).copied().collect();
if mine.is_empty() {
return None;
}
let mut items: Vec<NavItem> = Vec::new();
// 先直接挂着的页
for s in mine.iter().filter(|s| s.info.sub.is_none()) {
items.push(NavItem::Leaf(*s));
}
// 再二级子分组(顺序 = `Sub::of`,也就是各子分组首页在表里的先后)
for sub in Sub::of(*g) {
let kids: Vec<PageSpec> = mine
.iter()
.filter(|s| s.info.sub == Some(sub))
.copied()
.collect();
if !kids.is_empty() {
items.push(NavItem::Subgroup(sub, kids));
}
}
Some(NavTree { group: *g, items })
})
.collect()
}
}
/// 页面绘制时能拿到的东西。
///
/// 插件**不直接摸 `App`**,只摸这个 —— 以后加一页不需要动 `App` 的可见性,
/// 也不需要把 `App` 整块借出去。
pub struct PageCtx<'a> {
pub page: Page,
pub ui: &'a mut egui::Ui,
/// 窗口上下文(要开后台线程、请求重绘、发视口命令时用)
pub egui: &'a egui::Context,
/// 界面共享状态(设置、日志、天气、背景图、本地模型……)
pub state: &'a mut AppState,
/// 插件内核 —— 插件页要列装载表就得看它
pub kernel: &'a Kernel,
/// 页面内部点了「跳到某一页」时往这里塞,由外层统一处理
pub nav: &'a mut Option<Page>,
}
impl PageCtx<'_> {
/// 页面里发起一次跳转。
pub fn goto(&mut self, page: Page) {
*self.nav = Some(page);
}
}
/// 一个页面 = 一个插件。
pub struct PagePlugin {
pub spec: PageSpec,
}
impl Plugin for PagePlugin {
fn name(&self) -> &str {
self.spec.info.id
}
/// 插件版本 = GUI crate 版本,**有意统一**(不是忘了写页面级版本)。
///
/// 页面插件跟 App 在同一个二进制里、同一个发布节奏,单独编版本号没有消费方:
/// 装载表(`core_pages::manager`)每行只画 `id` 和装载状态,不显示页面级版本。
/// 真要页面级版本,得先给 `PageInfo` 加字段**并**让装载表读它 ——
/// 只加字段没人读就是半截契约,不如不加。
fn version(&self) -> &str {
env!("CARGO_PKG_VERSION")
}
fn inject(&self) -> &[&'static str] {
&[SERVICE_PAGES]
}
fn apply(&self, ctx: &Context) -> Result<()> {
let registry = ctx.require::<PageRegistry>(SERVICE_PAGES)?;
registry.install(self.spec);
Ok(())
}
}
/// 页面 → 绘制函数。插件注册表、命令行、侧栏全靠这一处映射,
/// 加一页只要在这儿加一行 + 在 [`PAGES`] 里加一条。
///
/// 这一版把菜单改成三级之后,新登记进来的页大多**只有骨架**:它们的
/// [`PageInfo::note`] 里会写清「要实现什么」,绘制走 [`stubs::placeholder`] ——
/// 见下面那段说明。已经实现的页照旧一行一个真渲染函数。
fn renderer_for(page: Page) -> fn(&mut PageCtx<'_>) {
match page {
// ---- Today's Info ----
// 首页**只有一页**:资讯与天气合成它了(见 pages.rs 里那条说明),
// 所以没有独立的 `weather` 页 —— `--page weather` 现在落到这一页。
Page::TodayInfo => core_pages::today_info,
// ---- Tools ----
Page::Ffmpeg => core_pages::ffmpeg_page,
Page::DsHarness => core_pages::dsh_page,
Page::Tokens => core_pages::tokens,
Page::RecordingBiliGo => core_pages::bilibili_go,
// ---- Player ----
// Music 那一页**已经能用**(曲库 / 播放列表 / 音量),新菜单把它挂在
// Player › Music 下面,于是它从「一级页」变成「二级目录的落点页」,
// 实现一行没动。它下面那五项(收藏 / 列表 / 平台 / 有声书 / 配置)是骨架。
Page::Music => core_pages::music,
Page::ImageViewer => core_pages::image_viewer,
Page::Video => core_pages::video,
// ---- Browser ----
// Tauri:内置浏览器换内核这件事还没做,先把原来的引擎/内核两页归到它名下
Page::Tauri => core_pages::browser_engine,
Page::Grafana => core_pages::grafana,
Page::DshStarter => core_pages::dsh_page,
Page::BiliGo => core_pages::bilibili_go,
Page::BrowserBookmarks => core_pages::bookmarks,
// ---- Zero Trust ----
Page::Topo => core_pages::topo,
Page::Channel => core_pages::channel,
Page::SecureCrt => core_pages::secure_crt,
Page::PacketCapture => core_pages::packet_capture,
// ---- Environment ----
// Environment 与 Language Framework 都是「二级条目里属于页的那种」:
// 前者是这一组的落点,后者是原「运行时环境」的落点(探测结果就是它)。
Page::Environment => core_pages::manager,
Page::Plugins => core_pages::manager,
Page::LanguageFramework => core_pages::environment,
Page::LangRuntime => core_pages::environment,
// 本地模型这一块整体收进了 Plugins › Model 这一页:
// 它内部用四个标签切换原来那四块(见 core_pages::model_page)
Page::Model => core_pages::model_page,
// ---- Setting ----
Page::SettingsBackground => core_pages::settings,
Page::Pet => core_pages::pet,
Page::SettingsPrinter => core_pages::printer,
Page::SettingsCloud => core_pages::cache_path,
Page::SettingsStoragePath => core_pages::storage_path,
Page::SettingsLogPath => core_pages::log_path,
Page::SettingsSync => core_pages::sync,
// ---- 以下这些页**还没实现**:菜单结构先落地,功能按 note 里的口径逐个补 ----
//
// 兜底分支 `_ => stubs::placeholder` 重新挂上是有意的:上一版 34 页全部
// 有真实现,那个分支成了 unreachable pattern,编译器替我们证明「一页都没漏」。
// 这一版一口气加了 **33 页骨架**(52 页里 19 页有真实现),要是逐行写
// `stubs::placeholder` 就得写 33 行;留着兜底则「新加一页 = 只改 pages.rs」
// 依然成立,而且**上面那 19 行才是显式的**:一眼看得出哪些页是真做的。
//
// 想给某一页换成真实现:在上面加一行,它就不再走这里。
// (这一层查不了的:「复制一行忘了改右半边」照样编译过 —— 只能靠人眼 review。)
Page::DshConfig
| Page::IpAddress
| Page::LabelPrinting
| Page::MusicCollect
| Page::MusicListing
| Page::MusicPlatform
| Page::MusicAudiobooks
| Page::MusicConfig
| Page::VideoXiami
| Page::VideoHellotik
| Page::VideoListing
| Page::VideoCollect
| Page::IlSpy
| Page::Fernflower
| Page::Jadx
| Page::Infrastructure
| Page::CordisConfig
| Page::SystemVariable
| Page::LangQt
| Page::LangJdk
| Page::LangDotnet
| Page::LangRust
| Page::LangCmark
| Page::LangPython
| Page::LangNodejs
| Page::LangAndroid
| Page::BgUi
| Page::BgStartM
| Page::BgWallpaper
| Page::SettingsWeather
| Page::SettingsProxy
| Page::SettingsPort
| Page::SettingsLogging => stubs::placeholder,
}
}
fn plugin_for(page: Page) -> PagePlugin {
PagePlugin {
spec: PageSpec {
info: page.info(),
draw: renderer_for(page),
},
}
}
/// 桌面版:整棵功能树都装上。
pub fn desktop() -> Vec<PagePlugin> {
PAGES.iter().map(|info| plugin_for(info.page)).collect()
}
/// 桌面版的**注册表本身**(不经过插件内核)。
///
/// 给**命令行**用的:`nfarms --list-pages` 要打三级菜单,
/// 而它没有也不该建一个窗口 —— 建内核 + 装插件的代价在这个场景里没有意义
/// (插件的 `apply` 只是往注册表里登记一张卡片,见 [`PagePlugin::apply`])。
///
/// 为什么不在 `nfarms-app` 里自己拼一个表:那样命令行看到的菜单和界面上
/// 画的菜单就变成两份实现,迟早对不上 —— 而 `--list-pages` 正是冒烟脚本
/// 用来问「程序一共有哪些页」的入口,它必须与侧栏逐条一致。
pub fn desktop_registry() -> PageRegistry {
let registry = PageRegistry::new();
for info in PAGES {
registry.install(PageSpec {
info,
draw: renderer_for(info.page),
});
}
registry
}
/// 安卓版:只装**真的能在手机上跑**的那些页。
///
/// 清单是照一个硬标准挑的:**不碰任何桌面才有的东西**(cmd.exe、注册表、
/// dumpcap、MCI、ActiveX……)。按这个标准过一遍,剩下这些:
///
/// | 页 | 为什么能上安卓 |
/// |---|---|
/// | Today's Info | 纯 HTTP(少数派 RSS / 百度热搜) |
/// | 天气 | 纯 HTTP |
/// | 拓扑验证器 | TcpStream + HTTP,`/proc` 都不碰 |
/// | Image | 纯 Rust 解码 |
/// | Background | 纯解码 + 文件(启动动图的开关也在这里,同样不碰桌面 API) |
/// | 日志路径 | **纯文件 I/O**,含分页日志查看器 |
/// | 运行时环境 | `capture` 目前走 `cmd.exe`,这一版**先按骨架装**(见下面的说明) |
/// | libs | 读内核自己的状态 |
///
/// **时间同步(原 TimeSync)已经删掉了**:它在新菜单里没有落点,
/// 而安卓上手机时钟本来就靠系统同步,留着这一页只是多一个入口。
///
/// **Environment 那一页的口径变了**:它原来写成「Runtime」且**不装**,
/// 理由是 `capture` 走 `cmd.exe /d /c`,安卓上根本没有 —— 装进去只会让
/// 8 个工具全显示「未安装」,那是**看起来能用其实在骗人**。这一版先把这一组
/// (Language Framework)收进菜单,安卓侧按同一个理由**仍然不装**,等
/// `capture` 有了 `sh -c` 分支再放进来(`cyinfo` 的安卓取数已经在等这个分支,
/// 见 `cyinfo::fill_android`)。
pub fn mobile() -> Vec<PagePlugin> {
[
Page::TodayInfo,
Page::Topo,
Page::ImageViewer,
Page::SettingsBackground,
Page::SettingsLogPath,
Page::Plugins,
]
.iter()
.map(|&p| plugin_for(p))
.collect()
}
/// 把页面插件装进内核,并把注册表以 `pages` 服务提供出去。
///
/// 顺序很重要:**先 provide 空注册表,再装页面插件** ——
/// 反过来的话每个页面插件都会因为 `inject` 没满足而挂起。
///
/// 返回值就是 provide 进去的那**同一个**表(`PageRegistry` 的 `Clone` 是共享内部
/// `Arc`,不是拷贝)。所以调用方拿它当 `App.pages` 之后,和以后从内核服务 `pages`
/// 里取的那份(`core_pages::manager` 取 `specs()` 画装载表)是同一张表 ——
/// 侧栏和装载表看到的永远一致,不会有「两处各说各话」。
pub fn install(kernel: &Kernel, plugins: Vec<PagePlugin>) -> PageRegistry {
let registry = PageRegistry::new();
kernel.provide(SERVICE_PAGES, registry.clone());
kernel.load_all(plugins);
registry
}
#[cfg(test)]
mod tests {
use super::*;
use nfarms_plugin::{Plugin, PluginState};
#[test]
fn desktop_registers_every_page() {
let kernel = Kernel::new();
let registry = install(&kernel, desktop());
kernel.ready().expect("桌面版所有插件都该装载成功");
assert_eq!(registry.len(), PAGES.len());
for info in PAGES {
assert!(registry.contains(info.page), "{} 没登记上", info.id);
}
}
/// 删掉的页面必须从**运行时那一份清单**里也一起消失。
///
/// 路由那一侧由 `renderer_for` 的 match 穷尽性在**编译期**守着 ——
/// 枚举里没有的变体根本写不出分支来(这次删 `SettingsBootAnim` 时,
/// 编译器当场就把漏改的那一行指出来了)。这条测试补的是运行时的另一半:
/// 桌面注册表是从 `PAGES` 反推的,页面只从表里划掉、却还有第二条清单在喂它,
/// 侧栏和装载表就会各说各话。
#[test]
fn a_removed_page_is_gone_from_the_registry_too() {
let kernel = Kernel::new();
let registry = install(&kernel, desktop());
kernel.ready().unwrap();
assert_eq!(Page::from_id("boot-anim"), None, "页面清单里还留着启动动图");
assert_eq!(registry.len(), PAGES.len());
let setting: Vec<&str> = registry
.grouped()
.iter()
.filter(|(g, _)| *g == Group::Setting)
.flat_map(|(_, specs)| specs.iter().map(|s| s.info.id))
.collect();
assert!(!setting.contains(&"boot-anim"), "Setting 组里还挂着启动动图这一页");
assert!(
setting.contains(&"background"),
"BackGround 页还得留在 Setting 组里 —— 启动动图的开关归它管"
);
}
#[test]
fn mobile_only_registers_the_skeleton() {
let kernel = Kernel::new();
let registry = install(&kernel, mobile());
kernel.ready().unwrap();
assert_eq!(registry.len(), 6);
assert!(registry.contains(Page::TodayInfo));
assert!(registry.contains(Page::Topo));
assert!(registry.contains(Page::ImageViewer));
assert!(registry.contains(Page::SettingsLogPath), "日志查看器是纯文件 I/O,手机上有用");
assert!(registry.contains(Page::Plugins));
assert!(
!registry.contains(Page::LangRuntime),
"运行时环境的 capture 走 cmd.exe,安卓上还没适配,不该装进去"
);
assert!(
!registry.contains(Page::PacketCapture),
"抓包要 dumpcap,安卓上没有"
);
// 一级目录:Today's Info(置顶) / Player / Vpn / Environment / Setting
// **没有 Tools**:那底下全是外部可执行文件(ffmpeg / bililive-go / 打印网关),
// 安卓上装着只会显示「未安装」,是看起来能用其实在骗人。
let groups: Vec<Group> = registry.grouped().iter().map(|(g, _)| *g).collect();
assert_eq!(
groups,
vec![
// Today's Info 置顶 —— 顺序跟着 `Group::ALL` 走
Group::TodayInfo,
Group::Player,
Group::Vpn,
Group::Environment,
Group::Setting
]
);
}
#[test]
fn pages_hang_until_the_registry_is_provided() {
// 先装页面插件、后给注册表 —— cordis 的 inject 语义:
// 应该先挂起,等服务出现再自动装上。
let kernel = Kernel::new();
kernel.load(plugin_for(Page::TodayInfo));
assert_eq!(kernel.state_of("today"), Some(PluginState::Pending));
kernel.provide(SERVICE_PAGES, PageRegistry::new());
assert_eq!(kernel.state_of("today"), Some(PluginState::Active));
}
#[test]
fn installing_the_same_page_twice_keeps_one() {
let registry = PageRegistry::new();
let spec = PageSpec {
info: Page::TodayInfo.info(),
draw: stubs::placeholder,
};
registry.install(spec);
registry.install(spec);
assert_eq!(registry.len(), 1);
}
#[test]
fn grouped_follows_the_feature_tree_order() {
let kernel = Kernel::new();
let registry = install(&kernel, desktop());
let groups: Vec<&str> = registry.grouped().iter().map(|(g, _)| g.nav()).collect();
assert_eq!(
groups,
[
"Today's Info",
"Tools",
"Player",
"Browser",
"Zero Trust",
"Environment",
"Setting"
]
);
}
/// 三级树的形状:一级目录下面**先直接挂着的页、再子分组**,
/// 而且每个容器里的页数加起来正好是全部页面。
///
/// 这条和 `grouped()` 那条并存是有意的:老的扁平访问器还在(安卓装载表、
/// 各处测试都在用),但**侧栏画的是这一棵**。两条路要是各说各话,
/// 症状就是「侧栏里少了一页,而页面清单说它在」。
#[test]
fn grouped_tree_puts_leaves_before_subgroups() {
let kernel = Kernel::new();
let registry = install(&kernel, desktop());
let tree = registry.grouped_tree();
assert_eq!(tree.len(), Group::ALL.len(), "一级目录少了");
let mut total = 0usize;
for node in &tree {
total += node.page_count();
let mut seen_sub = false;
for it in &node.items {
match it {
NavItem::Leaf(_) => {
assert!(
!seen_sub,
"{}:直接挂着的页排到子分组后面去了",
node.group.nav()
);
}
NavItem::Subgroup(_, kids) => {
seen_sub = true;
assert!(!kids.is_empty(), "空子分组不该出现在侧栏里");
for s in kids {
assert_eq!(
s.info.group, node.group,
"{}:页面 {} 挂到了别的组下",
node.group.nav(),
s.info.id
);
}
}
}
}
}
assert_eq!(total, PAGES.len(), "三级树里的页数 != 页面总数");
// 扁平那条访问器必须仍然覆盖全部页面(装载表还在用它)
let flat: usize = registry.grouped().iter().map(|(_, v)| v.len()).sum();
assert_eq!(flat, total, "扁平分组与三级树的页数对不上");
}
#[test]
fn empty_registry_is_empty_until_something_is_installed() {
// `is_empty` 的文档说「两条装载路径都不会产生空表」。前半句在这儿钉住
// (空 → 装一页 → 非空),后半句钉住真正那两条名单本身非空。
let registry = PageRegistry::new();
assert!(registry.is_empty());
assert_eq!(registry.len(), 0);
registry.install(PageSpec {
info: Page::TodayInfo.info(),
draw: stubs::placeholder,
});
assert!(!registry.is_empty());
assert_eq!(registry.len(), 1);
assert!(!desktop().is_empty(), "桌面版页面名单不该是空的");
assert!(!mobile().is_empty(), "安卓版页面名单不该是空的");
}
#[test]
fn specs_returns_a_snapshot_not_a_live_view() {
// `specs()` 承诺「拷一份出来」:侧栏每帧拿着它遍历,遍历期间插件再登记一页
// 也不能改到已经取走的那一份(否则就成了边遍历边改)。
let registry = PageRegistry::new();
registry.install(PageSpec {
info: Page::TodayInfo.info(),
draw: stubs::placeholder,
});
let snapshot = registry.specs();
registry.install(PageSpec {
info: Page::Topo.info(),
draw: stubs::placeholder,
});
assert_eq!(
snapshot.len(),
1,
"快照被后来的 install 改到了,说明返回的不是拷贝"
);
assert_eq!(registry.specs().len(), 2);
}
#[test]
fn a_poisoned_table_panics_instead_of_reporting_an_empty_sidebar() {
// 中毒策略(见 `read_slots` 的文档):宁可崩也不降级成空表 ——
// 空表在界面上看起来就是「插件一页都没装」,会把排查引偏。
// 谁把 `expect` 换成 `unwrap_or_default()`,这条测试就会红。
let registry = PageRegistry::new();
let writer = registry.clone();
let _ = std::thread::spawn(move || {
let _guard = writer.write_slots();
panic!("故意在持有写锁时崩,把锁毒掉");
})
.join();
let got = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| registry.len()));
assert!(got.is_err(), "锁中毒之后 len() 该崩,而不是悄悄报 0");
// 唯一必须降级的地方:Debug 可能在 panic 展开期间被调用,不能再崩一次
assert_eq!(format!("{registry:?}"), "PageRegistry(0 页)");
}
#[test]
fn the_returned_registry_is_the_same_table_the_kernel_publishes() {
// `install()` 的返回值与内核服务 `pages` 必须是同一个 `Arc`:
// 侧栏画的是 `App.pages`,装载表画的是内核服务里那份 —— 一旦变成两份拷贝
// 就会各说各话(装上去的页在装载表里查不到)。
let kernel = Kernel::new();
let registry = install(&kernel, Vec::new());
let published = kernel
.context()
.get::<PageRegistry>(SERVICE_PAGES)
.expect("install 之后内核里必须有 pages 服务");
assert_eq!(published.len(), 0);
published.install(PageSpec {
info: Page::TodayInfo.info(),
draw: stubs::placeholder,
});
assert!(registry.contains(Page::TodayInfo), "两条入口不是同一张表");
assert_eq!(registry.len(), 1);
}
#[test]
fn every_page_plugin_reports_the_same_gui_crate_version() {
// `version()` 的文档说「有意统一」:不做页面级版本。这条测试把「统一」钉住 ——
// 哪天有人给某一页单独编了版本,它会红,逼着同时回来改那份文档。
let plugins = desktop();
let mut versions: Vec<&str> = plugins.iter().map(|p| p.version()).collect();
versions.sort_unstable();
versions.dedup();
assert_eq!(versions, vec![env!("CARGO_PKG_VERSION")]);
}
}