feat(app-iced): M119 把 presenter 的 agent 四契约与账户窄入口接进 iced 壳
M103 把 AI 会话的投影(`AgentRail` + `submit/mark_read/confirm`)做完了,M114 把账户三轨做完,
但 iced 壳一直没接:M103 复审确认 `Presenter::` 零非测试消费者,M104 的右栏会话数据来自内置样本。
这一个提交就是那一步——**右栏与账户页吃 presenter 的真实状态**。
## 接线层(新增 `crates/app-iced/src/presenter/`)
| 文件 | 是什么 |
|---|---|
| `agent.rs` | `AgentHost`:presenter 句柄(四契约)+ 事件队列 + **具名**失败(`AgentFailure`:`unreachable` / `not_implemented`)|
| `transport.rs` | `AgentTransport` 的 iced 侧实现(脚本化 rig + 五种剧情 + 两种失败注入)|
| `rig.rs`(仅测试) | 账户轨的 `Backend`:`AppCommand` → host 形状的 `CoreEvent` |
| `mod.rs` | 逻辑路径说明 + **线程/通道边界** |
物理目录 `src/presenter/`,逻辑名字 `crate::app::state::presenter`——由 `app/state.rs` 用
`#[path]` 挂载(与 `shell::agent` 挂 `pages/agent/` 同一个先例:scope 不含 `lib.rs`)。
## 事件回流
`transport` 只**入队**、UI 只**出队**:`advance`→`pump` 里同拍抽干,抽出来的这批经
**既有 `Message::Agent(..)` 通道**(新变体 `Events(Vec<AppEvent>)`)回到 `on_agent`,再由
`Presenter::apply` 落地。同一条漏斗,所以用户点击与后端事件不会各改一次状态。
## 会话内容只留一份
`AgentUi` 里的 `transcript/busy/unread` **删掉**,右栏与独立页直接读 `agent_rail()`
(`pages/agent::spec` 零拷贝借)。M104 的样本会话(`demo::transcript()`)一并删除:出厂就是
空会话,取证夹具要内容就**跑一轮真提交**(`ULL_AGENT_FIXTURE=turn`),消息只能由 presenter 产生。
## 视图
- 气泡按 `ChatMessage.role` 画(User/Assistant/Tool/System),没有任何壳自己的会话类型;
- 新增两条横幅(`conversation/banners.rs`):**失败态**(catalog 标题 + 已脱敏正文)与
**写操作确认**(摘要 + Allow/Deny → `agent_confirm`);
- 拖动缝与折叠条按 400 行硬线拆到 `mod.rs` 之外(`strip.rs`、`conversation/{transcript,banners}.rs`)。
## 未读口径
presenter 判「栏可见吗」用的是它自己的 router + `prefs.ai_collapsed`,而这两件的真源在 `App`。
`refresh_view` 用 presenter 的两个**窄入口**(`navigate` / `prefs_mut().ai_collapsed`)单点写回,
带幂等闸(相同就不重复写,避免 `navigate(Route::Agent)` 顺手 mark_read 吃掉用户攒的未读)。
## 账户页
账户列表与登录事件仍走 `AppCommand`/`CoreEvent`(M114 的形状没动),本轮把**登录指令与
列表消费**纳入同一套树测试:添加离线档案 → 列表出现 → 切换 → 删除;设备码「进行中可见 + 可取消」;
失败态(名字不合法 / 凭据失效)都给具名错误。
## 树测试(9 条,`cargo test -p unlucky-app-iced --lib` 全绿:599 passed / 0 failed)
- `app/update/agent/tests.rs`:提交 → 事件回流 → 右栏出现该轮 + `busy` 翻转 + 标记已读归零;
确认条可裁决且记审计;三种失败各给具名横幅;草稿逐字同步。
- `app/update/account/tests.rs`:增/切/删端到端;设备码进行中与取消;两种失败态;
M114 的三条回归闸(原内联测试搬出来)。
- `pages/agent/tests.rs`:视图契约(`spec` 原样借 rail)、a11y 标签、焦点环、两种宿主全态构图。
顺带:`app/tests.rs` 里三条 `agent.unread` 断言跟着 rail 走(出厂 0 未读),`view/account_page.rs`
的 `docked` 调用点补上 rail 与失败横幅。
This commit is contained in:
1 parent
6505493d4e
commit
d4fd675935
25 files changed
+2390
-698
No files matched your search
@@ -68,6 +68,13 @@ mod construct;
|
||||
mod refresh;
|
||||
mod theme;
|
||||
|
||||
/// iced 侧的 presenter 接线层(M119):物理目录 `src/presenter/`,逻辑名字在 `crate::app::state` 下。
|
||||
///
|
||||
/// 为什么用 `#[path]` 而不是在 `lib.rs` 加一行 `mod presenter;`:本 mission 的 scope 不含
|
||||
/// `lib.rs` / `app/mod.rs`,而 `shell::agent` 挂 `pages/agent/` 是同一个先例。没有第二份实现。
|
||||
#[path = "../presenter/mod.rs"]
|
||||
pub(super) mod presenter;
|
||||
|
||||
pub(crate) use boot_probe::{log_first_frame, STARTED};
|
||||
|
||||
/// 应用状态:UI 状态 + presenter 三件 + 展示转换缓存。
|
||||
@@ -138,11 +145,20 @@ pub struct App {
|
||||
/// 本字段与 `app/view.rs` 都不用动(`settings::States` 只看 `&Groups`,不看任何一组的字段)。
|
||||
/// 放在字段列表**末尾**:后续 mission 若也要往这里加字段,冲突面最小。
|
||||
pub(super) settings: pages::settings::Groups,
|
||||
/// AI 助手栏的 UI 状态(M104):右栗三档 + 展开宽度 + 会话/草稿/未读。
|
||||
/// AI 助手栏的 UI 状态(M104):右栗三档 + 展开宽度 + 草稿/焦点。
|
||||
///
|
||||
/// 挂在 `App` 上而不是全局:这棵树在 `view(&self)` 里只读、在 `update(&mut self)` 里改,
|
||||
/// 状态跟着 App 走才能 per-app、可测(tower 明确否掉了 `OnceLock<Mutex<..>>`)。
|
||||
///
|
||||
/// **只留 UI 态**:会话内容 / 未读 / busy 在 `agent_host` 的 rail 上(M119 起)。
|
||||
pub(super) agent: crate::shell::agent::AgentUi,
|
||||
/// AI 助手的 presenter 句柄(M119):四个契约 + 事件队列 + 具名错误。
|
||||
///
|
||||
/// 为什么单独一份 `AppState` 而不是把 `state` 交给它:`Presenter` 的可写状态后门
|
||||
/// (`state_mut`)只在 presenter crate 的 `#[cfg(test)]` 里存在,而 `app/subscription.rs`
|
||||
/// 等处仍要直读 `App.state`。所以口径是——**`App.state` 管除 rail 外的一切,这个句柄管 rail**;
|
||||
/// `AppEvent::Agent` 只喂这里(`App.state.ai` 从此不再被读,也不再被写)。
|
||||
pub(super) agent_host: presenter::AgentHost,
|
||||
/// 下载页的安装草稿(M105:选中版本 + 加载器 + 加载器版本 + 实例名)。
|
||||
///
|
||||
/// 为什么住在 `App` 而不是页面模型:`refresh_view` 每次事件都把 `self.view` 整棵重建,
|
||||
|
||||
@@ -118,8 +118,10 @@ impl App {
|
||||
appearance_prefs,
|
||||
hwnd: None,
|
||||
settings: pages::settings::Groups::default(),
|
||||
// 取证开关 `ULL_AGENT_*`(三态矩阵 / 空会话)在这里落地;不设变量 = 出厂态
|
||||
agent: crate::shell::agent::demo::from_env().unwrap_or_default(),
|
||||
// 出厂是**空会话**(M119 删了 M104 的样本会话);取证开关 `ULL_AGENT_*` 的档位
|
||||
// 与夹具在 `boot_agent` 里落地(它要先让 presenter 知道栏可见性,见那里)。
|
||||
agent: crate::shell::agent::AgentUi::new(),
|
||||
agent_host: super::presenter::AgentHost::from_env(),
|
||||
// 下载页安装草稿(M105):出厂态 = 没选中、原版、两个空串
|
||||
download_draft: pages::InstallDraft::default(),
|
||||
install_cancelled_seen: 0,
|
||||
|
||||
@@ -19,6 +19,9 @@ impl App {
|
||||
// 每一次路由变化都会走到这里,所以它是「档位 ↔ 路由」的唯一点;放在算宽度之前,
|
||||
// 是为了让模型拿到的 `content_width` 与页面头部读到的档位来自同一份状态。
|
||||
self.sync_agent_route();
|
||||
// M119:把「栏可见吗」写回 presenter(未读口径的输入,两个窄入口:`navigate` + `prefs_mut`)。
|
||||
// 必须在 `sync_agent_route` 之后:档位刚被它对齐过,这里读到的才是这一帧的真值。
|
||||
self.sync_agent_presenter();
|
||||
|
||||
// M105:取消安装的提示语在**这里**弹(`refresh_view` 是事件 apply 之后必经的那一处)。
|
||||
// 文案由页面层求(`pages::cancelled_toast`),presenter 只发信号 ——
|
||||
|
||||
@@ -280,7 +280,8 @@ fn entering_the_agent_route_detaches_the_rail() {
|
||||
let mut app = headless(Options::default(), SchemeFlavor::Light);
|
||||
let _ = app.update(Message::Boot);
|
||||
assert_eq!(app.agent.rail, RailState::Collapsed, "出厂是折叠档");
|
||||
assert_eq!(app.agent.unread, 2, "出厂带两条未读");
|
||||
// M119:出厂没有样本会话,未读由 presenter 按真事件累加 ⇒ 出厂就是 0
|
||||
assert_eq!(app.agent_host.rail().unread, 0, "出厂 0 条未读");
|
||||
|
||||
// ① 外壳导航第五项(真实入口)
|
||||
let _ = app.update(Message::Select(NavItem::Ai));
|
||||
@@ -290,7 +291,7 @@ fn entering_the_agent_route_detaches_the_rail() {
|
||||
RailState::Detached,
|
||||
"进了独立页,档位必须是 Detached——否则页头会画右栗才有的动作"
|
||||
);
|
||||
assert_eq!(app.agent.unread, 0, "进独立页即读");
|
||||
assert_eq!(app.agent_host.rail().unread, 0, "进独立页即读");
|
||||
|
||||
// ③ Esc 真的离开这一页
|
||||
let _ = app.update(Message::Agent(crate::shell::agent::Message::Escape));
|
||||
@@ -314,13 +315,14 @@ fn entering_the_agent_route_detaches_the_rail() {
|
||||
fn the_page_navigation_entry_also_detaches() {
|
||||
let mut app = headless(Options::default(), SchemeFlavor::Light);
|
||||
let _ = app.update(Message::Boot);
|
||||
app.agent.expand();
|
||||
// 走真消息(不是直接改字段):这样「展开即读」的 `agent_mark_read` 也会真的发生
|
||||
let _ = app.update(Message::Agent(crate::shell::agent::Message::Expand));
|
||||
assert_eq!(app.agent.rail, RailState::Expanded);
|
||||
|
||||
let _ = app.update(Message::Page(pages::Message::Navigate(Route::Agent)));
|
||||
assert_eq!(app.state.route(), &Route::Agent);
|
||||
assert_eq!(app.agent.rail, RailState::Detached);
|
||||
assert_eq!(app.agent.unread, 0);
|
||||
assert_eq!(app.agent_host.rail().unread, 0);
|
||||
|
||||
// 页面层回退(不是 Dock 消息)也要把档位收回来
|
||||
let _ = app.update(Message::Page(pages::Message::Navigate(Route::Instances)));
|
||||
|
||||
@@ -207,123 +207,4 @@ pub(super) fn force_account_state(app: &mut App) {
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use unlucky_design::color::SchemeFlavor;
|
||||
use unlucky_presenter::dto::{
|
||||
AppEvent, AuthlibLoginView, CoreEvent, DeviceLoginPhase, DeviceLoginView, LoginFailure,
|
||||
};
|
||||
use unlucky_presenter::mock::MockBackend;
|
||||
use unlucky_presenter::settings_bridge::SettingsBridge;
|
||||
|
||||
use crate::pages::account::{Form, Message as Account, Panel, Track};
|
||||
use crate::theme::Families;
|
||||
|
||||
use super::App;
|
||||
|
||||
/// 不碰磁盘的 App(mock 后端 + 内存设置桥),与 `app/tests.rs` 的 `headless` 同源。
|
||||
fn headless() -> App {
|
||||
App::with_presenter(
|
||||
crate::cli::Options::default(),
|
||||
Families {
|
||||
text: "Segoe UI",
|
||||
display: "Segoe UI",
|
||||
mono: "Consolas",
|
||||
},
|
||||
SchemeFlavor::Light,
|
||||
Box::new(MockBackend::new()),
|
||||
SettingsBridge::memory_only(),
|
||||
)
|
||||
}
|
||||
|
||||
fn seed(app: &mut App, event: CoreEvent) {
|
||||
app.state.apply(AppEvent::Core(event));
|
||||
app.refresh_view();
|
||||
}
|
||||
|
||||
/// **复审 r2 P2-1 的回归闸**:登录失败/成功态下按 `Close`,面板必须真的关掉。
|
||||
///
|
||||
/// 为什么这条以前是死按钮:`Page::build` 只要 `device_login.phase != Idle` 就把面板强制
|
||||
/// 回 Microsoft,而 `Close` 只在 `busy()`(Requesting/Waiting)时才派 `CancelDeviceLogin`
|
||||
/// ⇒ Failed/Succeeded 下点了没反应。现在判据改成「非 Idle」,两条轨都覆盖。
|
||||
///
|
||||
/// 抓得住:判据退回 `busy()`;`Close` 忘了 dispatch;`CancelDeviceLogin` 没有把状态收回 Idle。
|
||||
/// 抓不住:`CancelDeviceLogin` 在**真 host** 上的副作用(这里跑的是 mock,但 mock 已按
|
||||
/// 真 host 的契约发 Idle,见 `presenter/src/mock.rs`)。
|
||||
#[test]
|
||||
fn close_really_closes_the_login_panel_on_both_tracks() {
|
||||
// ① 微软轨:失败态
|
||||
let mut app = headless();
|
||||
seed(
|
||||
&mut app,
|
||||
CoreEvent::DeviceLoginChanged(DeviceLoginView {
|
||||
phase: DeviceLoginPhase::Failed,
|
||||
message: "微软拒绝了这次登录:AADSTS7000218(HTTP 403)".to_string(),
|
||||
failure: Some(LoginFailure::NotWhitelisted),
|
||||
..DeviceLoginView::new()
|
||||
}),
|
||||
);
|
||||
assert_eq!(app.account.panel, Panel::Microsoft, "失败态面板要显示");
|
||||
let _ = app.on_account(Account::Close);
|
||||
assert_eq!(app.account.panel, Panel::None, "Close 之后面板必须关掉");
|
||||
assert_eq!(app.state.device_login.phase, DeviceLoginPhase::Idle);
|
||||
|
||||
// ② 微软轨:成功态(同样不在 busy 里)
|
||||
let mut app = headless();
|
||||
seed(
|
||||
&mut app,
|
||||
CoreEvent::DeviceLoginChanged(DeviceLoginView::succeeded("Steve")),
|
||||
);
|
||||
assert_eq!(app.account.panel, Panel::Microsoft);
|
||||
let _ = app.on_account(Account::Close);
|
||||
assert_eq!(app.account.panel, Panel::None, "成功提示也要能关掉");
|
||||
|
||||
// ③ 第三方轨:失败态
|
||||
let mut app = headless();
|
||||
seed(
|
||||
&mut app,
|
||||
CoreEvent::AuthlibLoginChanged(AuthlibLoginView {
|
||||
phase: DeviceLoginPhase::Failed,
|
||||
server: "https://example.invalid/api/yggdrasil/".to_string(),
|
||||
server_name: String::new(),
|
||||
message: "服务器返回 HTTP 404".to_string(),
|
||||
failure: Some(LoginFailure::NotAuthlibServer),
|
||||
account_name: String::new(),
|
||||
}),
|
||||
);
|
||||
app.account_form.open(Track::Authlib);
|
||||
app.refresh_view();
|
||||
assert_eq!(
|
||||
app.account.panel,
|
||||
Panel::Authlib,
|
||||
"第三方轨失败态面板要显示"
|
||||
);
|
||||
let _ = app.on_account(Account::Close);
|
||||
assert_eq!(app.account.panel, Panel::None, "第三方轨也要能关掉");
|
||||
assert_eq!(app.state.authlib_login.phase, DeviceLoginPhase::Idle);
|
||||
}
|
||||
|
||||
/// 表单态是纯 UI 状态:打开/关闭面板不派命令,但会把面板切对。
|
||||
///
|
||||
/// 抓得住:`Open` 忘了写 `panel`(点了轨道卡什么都不出现)、`close()` 不清密码。
|
||||
/// 抓不住:真实 dispatch 的副作用(那要真后端)。
|
||||
#[test]
|
||||
fn the_form_state_follows_the_page_messages() {
|
||||
let mut form = Form::new();
|
||||
assert_eq!(form.panel, Panel::None);
|
||||
form.open(Track::Authlib);
|
||||
assert_eq!(form.panel, Panel::Authlib);
|
||||
form.password = "hunter2".to_string();
|
||||
form.close();
|
||||
assert_eq!(form.panel, Panel::None);
|
||||
assert!(form.password.is_empty(), "关闭面板必须把一次性密码清掉");
|
||||
}
|
||||
|
||||
/// 三张轨道卡默认收起(有账号时),`ToggleAdd` 展开(复审 r1 P2-4)。
|
||||
#[test]
|
||||
fn the_add_section_starts_collapsed() {
|
||||
let mut form = Form::new();
|
||||
assert!(!form.section_open);
|
||||
form.section_open = true;
|
||||
assert!(form.section_open);
|
||||
}
|
||||
}
|
||||
mod tests;
|
||||
@@ -0,0 +1,303 @@
|
||||
//! **树测试②(M119 验收核心)**:账户页消费 presenter 的账户列表与登录事件。
|
||||
//!
|
||||
//! 后端是 UI 侧的 [`AccountRig`]:它把 `AppCommand` 翻成**与真 host 同形状**的 `CoreEvent`
|
||||
//! (`AccountsChanged` / `DeviceLoginChanged` / `AccountNoticeChanged`),数据在内存里。
|
||||
//! 于是这条测试钉住的是「**接线**」——用户点一下之后,命令出去、事件回来、页面重算、横幅出现;
|
||||
//! 抓不住的是 `core::auth::AccountStore` 的落盘(那条由 presenter 自己的
|
||||
//! `host::accounts::offline_add_list_remove_round_trips` 覆盖,原因见 `presenter/rig.rs` 的模块文档)。
|
||||
//!
|
||||
//! 覆盖任务书第 3 条与第 4 条的账户那一半:
|
||||
//!
|
||||
//! | 断言 | 抓得住什么 |
|
||||
//! |---|---|
|
||||
//! | 添加离线档案 → 列表出现一行 | `AddOfflineAccount` 没派发 / 事件没回流 / 页面没重建 |
|
||||
//! | 切换当前账号 → 「当前」标记换人 | `SwitchAccount` 没派发(M114 的 `active` 标记是页面唯一来源)|
|
||||
//! | 删除 → 列表回到空态 | `RemoveAccount` 没派发 |
|
||||
//! | 设备码「进行中可见 + 可取消」 | `StartDeviceLogin` / `CancelDeviceLogin` 没接线(只剩转圈)|
|
||||
//! | 失败态:名字不合法 / 凭据失效都给**具名**错误 | 失败被吞掉(复审 r1 P2-2 的老毛病)|
|
||||
|
||||
use unlucky_design::color::SchemeFlavor;
|
||||
use unlucky_i18n::{Catalog, Key, Lang};
|
||||
use unlucky_presenter::dto::{
|
||||
AccountNoticeCode, AppEvent, AuthlibLoginView, CoreEvent, DeviceLoginView, LoginFailure,
|
||||
};
|
||||
use unlucky_presenter::mock::MockBackend;
|
||||
use unlucky_presenter::settings_bridge::SettingsBridge;
|
||||
|
||||
use crate::app::state::presenter::rig::AccountRig;
|
||||
use crate::pages::account::model::{AuthlibPanel, DevicePanel};
|
||||
use crate::pages::account::{Form, Message as Account, Panel, Track};
|
||||
use crate::theme::Families;
|
||||
|
||||
use super::App;
|
||||
|
||||
const FAMILIES: Families = Families {
|
||||
text: "Segoe UI",
|
||||
display: "Segoe UI",
|
||||
mono: "Consolas",
|
||||
};
|
||||
|
||||
/// 不碰磁盘的 App,后端换成**账户 rig**(其余分支照旧走 `MockBackend` 的能力就够)。
|
||||
fn with_rig() -> App {
|
||||
App::with_presenter(
|
||||
crate::cli::Options::default(),
|
||||
FAMILIES,
|
||||
SchemeFlavor::Light,
|
||||
Box::new(AccountRig::new()),
|
||||
SettingsBridge::memory_only(),
|
||||
)
|
||||
}
|
||||
|
||||
/// 不碰磁盘的 App(mock 后端 + 内存设置桥),与 `app/tests.rs` 的 `headless` 同源。
|
||||
fn headless() -> App {
|
||||
App::with_presenter(
|
||||
crate::cli::Options::default(),
|
||||
FAMILIES,
|
||||
SchemeFlavor::Light,
|
||||
Box::new(MockBackend::new()),
|
||||
SettingsBridge::memory_only(),
|
||||
)
|
||||
}
|
||||
|
||||
fn seed(app: &mut App, event: CoreEvent) {
|
||||
app.state.apply(AppEvent::Core(event));
|
||||
app.refresh_view();
|
||||
}
|
||||
|
||||
/// **添加 → 切换 → 删除**(任务书第 3 条,一条端到端)。
|
||||
///
|
||||
/// 抓得住:三条命令任何一条没派发 / 事件没回流 / `refresh_view` 没带上账户页。
|
||||
/// 抓不住:真实账户库的落盘(见模块文档)。
|
||||
#[test]
|
||||
fn an_offline_profile_is_added_switched_and_removed_end_to_end() {
|
||||
let mut app = with_rig();
|
||||
assert!(app.account.is_empty(), "出厂是空态");
|
||||
|
||||
// ① 添加离线档案:走真消息(点轨道卡 → 填名字 → 提交)
|
||||
let _ = app.on_account(Account::Open(Track::Offline));
|
||||
assert_eq!(app.account_form.panel, Panel::Offline);
|
||||
// 表单输入也走真消息(纯 UI 态,只写 `App::account_form` + 置脏)
|
||||
let _ = app.on_account(Account::OfflineName("Steve".to_string()));
|
||||
assert!(
|
||||
app.account_form.offline_ready(),
|
||||
"3–16 位字母:按钮该是亮的"
|
||||
);
|
||||
let _ = app.on_account(Account::Submit);
|
||||
|
||||
assert_eq!(app.account.rows.len(), 1, "列表出现一行(不是静默)");
|
||||
assert_eq!(app.account.rows[0].name, "Steve");
|
||||
assert_eq!(
|
||||
app.account.notice.as_ref().map(|notice| notice.code),
|
||||
Some(AccountNoticeCode::Added),
|
||||
"页面横幅给出的是一次性结果"
|
||||
);
|
||||
let id = app.account.rows[0].id.clone();
|
||||
|
||||
// ② 切换当前账号
|
||||
let _ = app.on_account(Account::Switch(id.clone()));
|
||||
assert_eq!(
|
||||
app.account.active_row().map(|row| row.name.as_str()),
|
||||
Some("Steve"),
|
||||
"「当前」标记跟着切换走"
|
||||
);
|
||||
|
||||
// ③ 删除
|
||||
let _ = app.on_account(Account::Remove(id));
|
||||
assert!(app.account.rows.is_empty(), "删除后回到空态");
|
||||
assert_eq!(
|
||||
app.account.notice.as_ref().map(|notice| notice.code),
|
||||
Some(AccountNoticeCode::Removed)
|
||||
);
|
||||
// 两个横幅文案都有词(中英各一条,不是硬编码)
|
||||
for lang in [Lang::En, Lang::ZhCn] {
|
||||
assert!(!Catalog::of(lang).text(Key::AccountNoticeRemoved).is_empty());
|
||||
}
|
||||
// 整棵树在三种状态下都能构图
|
||||
let _ = app.view();
|
||||
}
|
||||
|
||||
/// 设备码流程:**指令发出 + 进行中可见 + 可取消**(任务书第 3 条的后半,真实网络不要求)。
|
||||
#[test]
|
||||
fn the_device_code_flow_is_started_visible_and_cancellable() {
|
||||
let mut app = with_rig();
|
||||
assert_eq!(app.account.device, DevicePanel::Hidden);
|
||||
|
||||
let _ = app.on_account(Account::StartDevice);
|
||||
match &app.account.device {
|
||||
DevicePanel::Code {
|
||||
code, remaining, ..
|
||||
} => {
|
||||
assert_eq!(code, "ABCD-EFGH", "用户码要上屏");
|
||||
assert!(remaining.is_some_and(|left| left > 0), "倒计时在跑");
|
||||
}
|
||||
other => panic!("进行中状态必须可见,实际 {other:?}"),
|
||||
}
|
||||
assert_eq!(app.account.panel, Panel::Microsoft, "面板要切到微软那条轨");
|
||||
let _ = app.view();
|
||||
|
||||
let _ = app.on_account(Account::Cancel);
|
||||
assert_eq!(app.account.device, DevicePanel::Hidden, "取消要真的收掉");
|
||||
assert_eq!(
|
||||
app.state.device_login.phase,
|
||||
unlucky_presenter::dto::DeviceLoginPhase::Idle
|
||||
);
|
||||
}
|
||||
|
||||
/// 失败态①:**未实现 / 名字不合法** ⇒ 页面横幅给出结构化码(不是静默无语)。
|
||||
#[test]
|
||||
fn an_invalid_profile_name_surfaces_a_named_failure() {
|
||||
let mut app = with_rig();
|
||||
let _ = app.on_account(Account::Open(Track::Offline));
|
||||
// 名字不合法时**发送键是灰的**(与 host 同一份判据),所以这里直接派命令:
|
||||
// 抓的是「host 真的回了一个结构化失败码」这条链路,而不是按钮的禁用态。
|
||||
assert!(!app.account_form.offline_ready());
|
||||
app.dispatch(unlucky_presenter::dto::AppCommand::AddOfflineAccount(
|
||||
"ab".to_string(),
|
||||
));
|
||||
|
||||
assert!(app.account.rows.is_empty(), "不合法的名字不入列表");
|
||||
let notice = app.account.notice.as_ref().expect("必须有横幅");
|
||||
assert_eq!(notice.code, AccountNoticeCode::OfflineNameInvalid);
|
||||
assert!(notice.failure, "失败档(配色不同)");
|
||||
assert_eq!(notice.subject, "ab", "横幅要带上是哪个名字");
|
||||
// 文案走 catalog,中英各一条
|
||||
for lang in [Lang::En, Lang::ZhCn] {
|
||||
assert!(!Catalog::of(lang)
|
||||
.text(Key::AccountNoticeOfflineNameInvalid)
|
||||
.is_empty());
|
||||
}
|
||||
let _ = app.view();
|
||||
}
|
||||
|
||||
/// 失败态②:**账户凭据失效** ⇒ 第三方轨面板进入 `Failed(BadCredentials)`(只带原因码)。
|
||||
///
|
||||
/// 为什么只断言原因码:M114 r3 起面板类型里**没有** `message`/`detail` 字段(原文只进 presenter
|
||||
/// 日志),页面据码选 catalog 文案。这条测试与「页面画得出文案」之间的桥梁是
|
||||
/// `pages::account::login::failure_body_key`(同一 crate 内的穷尽 match,缺一条编译不过)。
|
||||
#[test]
|
||||
fn expired_credentials_surface_a_named_failure_code() {
|
||||
let mut app = headless();
|
||||
seed(
|
||||
&mut app,
|
||||
CoreEvent::AuthlibLoginChanged(AuthlibLoginView::failed_as(
|
||||
LoginFailure::BadCredentials,
|
||||
"服务器返回 401:凭据已失效(token=…已脱敏)",
|
||||
)),
|
||||
);
|
||||
app.account_form.open(Track::Authlib);
|
||||
app.refresh_view();
|
||||
|
||||
assert_eq!(
|
||||
app.account.authlib,
|
||||
AuthlibPanel::Failed {
|
||||
failure: LoginFailure::BadCredentials
|
||||
},
|
||||
"凭据失效要落成具名原因码,而不是静默回 Idle"
|
||||
);
|
||||
// 原因码对应的那句译文两套语言都得有
|
||||
for lang in [Lang::En, Lang::ZhCn] {
|
||||
let text = Catalog::of(lang).text(Key::AccountFailureBadCredentials);
|
||||
assert!(!text.trim().is_empty(), "{} 缺这条失败文案", lang.tag());
|
||||
}
|
||||
// 原文不许出现在页面的任何可视文本里(M114 的凭据红线)
|
||||
assert!(
|
||||
!format!("{:?}", app.account).contains("服务器返回 401"),
|
||||
"后端原文不许进页面模型"
|
||||
);
|
||||
let _ = app.view();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- M114 的既有回归闸
|
||||
|
||||
/// **复审 r2 P2-1 的回归闸**:登录失败/成功态下按 `Close`,面板必须真的关掉。
|
||||
///
|
||||
/// 为什么这条以前是死按钮:`Page::build` 只要 `device_login.phase != Idle` 就把面板强制
|
||||
/// 回 Microsoft,而 `Close` 只在 `busy()`(Requesting/Waiting)时才派 `CancelDeviceLogin`
|
||||
/// ⇒ Failed/Succeeded 下点了没反应。现在判据改成「非 Idle」,两条轨都覆盖。
|
||||
///
|
||||
/// 抓得住:判据退回 `busy()`;`Close` 忘了 dispatch;`CancelDeviceLogin` 没有把状态收回 Idle。
|
||||
/// 抓不住:`CancelDeviceLogin` 在**真 host** 上的副作用(这里跑的是 mock,但 mock 已按
|
||||
/// 真 host 的契约发 Idle,见 `presenter/src/mock.rs`)。
|
||||
#[test]
|
||||
fn close_really_closes_the_login_panel_on_both_tracks() {
|
||||
// ① 微软轨:失败态
|
||||
let mut app = headless();
|
||||
seed(
|
||||
&mut app,
|
||||
CoreEvent::DeviceLoginChanged(DeviceLoginView {
|
||||
phase: unlucky_presenter::dto::DeviceLoginPhase::Failed,
|
||||
message: "微软拒绝了这次登录:AADSTS7000218(HTTP 403)".to_string(),
|
||||
failure: Some(LoginFailure::NotWhitelisted),
|
||||
..DeviceLoginView::new()
|
||||
}),
|
||||
);
|
||||
assert_eq!(app.account.panel, Panel::Microsoft, "失败态面板要显示");
|
||||
let _ = app.on_account(Account::Close);
|
||||
assert_eq!(app.account.panel, Panel::None, "Close 之后面板必须关掉");
|
||||
assert_eq!(
|
||||
app.state.device_login.phase,
|
||||
unlucky_presenter::dto::DeviceLoginPhase::Idle
|
||||
);
|
||||
|
||||
// ② 微软轨:成功态(同样不在 busy 里)
|
||||
let mut app = headless();
|
||||
seed(
|
||||
&mut app,
|
||||
CoreEvent::DeviceLoginChanged(DeviceLoginView::succeeded("Steve")),
|
||||
);
|
||||
assert_eq!(app.account.panel, Panel::Microsoft);
|
||||
let _ = app.on_account(Account::Close);
|
||||
assert_eq!(app.account.panel, Panel::None, "成功提示也要能关掉");
|
||||
|
||||
// ③ 第三方轨:失败态
|
||||
let mut app = headless();
|
||||
seed(
|
||||
&mut app,
|
||||
CoreEvent::AuthlibLoginChanged(AuthlibLoginView {
|
||||
phase: unlucky_presenter::dto::DeviceLoginPhase::Failed,
|
||||
server: "https://example.invalid/api/yggdrasil/".to_string(),
|
||||
server_name: String::new(),
|
||||
message: "服务器返回 HTTP 404".to_string(),
|
||||
failure: Some(LoginFailure::NotAuthlibServer),
|
||||
account_name: String::new(),
|
||||
}),
|
||||
);
|
||||
app.account_form.open(Track::Authlib);
|
||||
app.refresh_view();
|
||||
assert_eq!(
|
||||
app.account.panel,
|
||||
Panel::Authlib,
|
||||
"第三方轨失败态面板要显示"
|
||||
);
|
||||
let _ = app.on_account(Account::Close);
|
||||
assert_eq!(app.account.panel, Panel::None, "第三方轨也要能关掉");
|
||||
assert_eq!(
|
||||
app.state.authlib_login.phase,
|
||||
unlucky_presenter::dto::DeviceLoginPhase::Idle
|
||||
);
|
||||
}
|
||||
|
||||
/// 表单态是纯 UI 状态:打开/关闭面板不派命令,但会把面板切对。
|
||||
///
|
||||
/// 抓得住:`Open` 忘了写 `panel`(点了轨道卡什么都不出现)、`close()` 不清密码。
|
||||
/// 抓不住:真实 dispatch 的副作用(那要真后端)。
|
||||
#[test]
|
||||
fn the_form_state_follows_the_page_messages() {
|
||||
let mut form = Form::new();
|
||||
assert_eq!(form.panel, Panel::None);
|
||||
form.open(Track::Authlib);
|
||||
assert_eq!(form.panel, Panel::Authlib);
|
||||
form.password = "hunter2".to_string();
|
||||
form.close();
|
||||
assert_eq!(form.panel, Panel::None);
|
||||
assert!(form.password.is_empty(), "关闭面板必须把一次性密码清掉");
|
||||
}
|
||||
|
||||
/// 三张轨道卡默认收起(有账号时),`ToggleAdd` 展开(复审 r1 P2-4)。
|
||||
#[test]
|
||||
fn the_add_section_starts_collapsed() {
|
||||
let mut form = Form::new();
|
||||
assert!(!form.section_open);
|
||||
form.section_open = true;
|
||||
assert!(form.section_open);
|
||||
}
|
||||
@@ -1,42 +1,60 @@
|
||||
//! 助手栏消息的落点(M104):三档状态迁移 / 输入 / 拖拽 / 键盘。
|
||||
//! 助手栏消息的落点(M104 建,M119 接线):三档状态迁移 / 输入 / 键盘 / **presenter 四契约**。
|
||||
//!
|
||||
//! ## 为什么单独一个文件
|
||||
//!
|
||||
//! `app/update.rs` 已经贴着 400 行硬上限(M94 也因此把下载页四支拆到 `update/download.rs`)。
|
||||
//! 助手栏有 11 个变体,塞回主文件必破线。任务也只允许主文件加一行 `mod agent;`。
|
||||
//! 助手栏有十几个变体,塞回主文件必破线。
|
||||
//!
|
||||
//! ## 分工:视图态 vs 业务命令
|
||||
//! ## 分工:视图态 vs presenter 契约(M119 之后的分界线)
|
||||
//!
|
||||
//! | 消息 | 落到哪 |
|
||||
//! |---|---|
|
||||
//! | `Expand` / `Collapse` / `Dock` / `Escape` | **只改视图态**(`AgentUi`)+ 路由栈(离开/进入 `Route::Agent`) |
|
||||
//! | `Detach` | 视图态 + `Route::Agent`(压栈,`back()` 能回去) |
|
||||
//! | `Edit` / `Submit` / `Stop` | 改视图态:草稿、追加一条占位提示 |
|
||||
//! | `Expand` / `Collapse` / `Dock` / `Escape` | **只改视图态**(`AgentUi`)+ 路由栈(离开/进入 `Route::Agent`)+ `agent_mark_read` |
|
||||
//! | `Detach` | 视图态 + `Route::Agent`(压栈,`back()` 能回去)+ `agent_mark_read` |
|
||||
//! | `Edit` | 视图态(草稿),并同步进 presenter 的草稿(`agent_set_draft`) |
|
||||
//! | `Submit` | [`AgentHost::submit`] → `agent_submit`;失败**不留白**(具名错误进横幅) |
|
||||
//! | `Stop` | `AppCommand::StopAgent`(真后端才收敛得出结果;同步 rig 下是 no-op) |
|
||||
//! | `Confirm(accept)` | [`AgentHost::confirm`] → `agent_confirm`(YOLO 下 presenter 自己放行并记审计) |
|
||||
//! | `Events(..)` | [`AgentHost::apply`] → `AppState::apply`(**事件回流的唯一落点**) |
|
||||
//! | `DragStart` / `DragTo` / `DragEnd` | 视图态:吸附宽度 |
|
||||
//! | `Key` / `Tab` / `Activate` | 键盘路径 |
|
||||
//! | `Key` / `Tab` / `Activate` | 键盘路径(`Tab` 的环要在流式中多一格,`busy` 从 rail 取) |
|
||||
//!
|
||||
//! **没有任何 `AppCommand` 派发**:本 mission 不接 AI 后端(M103 的四个签名一行都不调)。
|
||||
//! `Submit` 不假装有回复——它只把草稿记进会话 + 追加一条「占位外壳、没发给模型」的提示,
|
||||
//! `Stop` 同理。真接线时这两个分支各加一次 `dispatch`,视图不用动。
|
||||
//! **会话内容一行都不在这里攒**:`transcript` / `busy` / `unread` / `confirm` 全在 presenter 的
|
||||
//! `AgentRail` 上(M119 删掉了 M104 的自持副本)。
|
||||
//!
|
||||
//! ## 事件为什么绕一圈消息再回来
|
||||
//!
|
||||
//! `agent_submit` / `agent_confirm` 的端口是同步的,但**事件走队列**(见
|
||||
//! `app/state/presenter/transport.rs`):抽队列的那一步必须和用户点击走同一条漏斗,否则两处各改
|
||||
//! 一次状态,流式标记与未读会打架。所以 [`App::drain_agent`] 抽出来之后立刻
|
||||
//! `on_agent(agent::Message::Events(..))` —— 同一条 `Message::Agent(..)` 通道。
|
||||
|
||||
use iced::keyboard::{key, Key};
|
||||
use iced::Task;
|
||||
|
||||
use unlucky_i18n::{Catalog, Lang};
|
||||
use unlucky_i18n::{Catalog, Key as TextKey};
|
||||
use unlucky_presenter::dto::AppCommand;
|
||||
|
||||
use crate::app::{MIN_SIZE, WINDOW_SIZE};
|
||||
use crate::app::MIN_SIZE;
|
||||
use crate::app::WINDOW_SIZE;
|
||||
use crate::shell::agent::demo::{self, Fixture};
|
||||
use crate::shell::agent::{self, Focus, RailState};
|
||||
|
||||
use super::super::state::presenter::agent::SubmitOutcome;
|
||||
use super::*;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
impl App {
|
||||
/// 助手栏消息(`app::Message::Agent(..)` 的唯一落点)。
|
||||
pub(super) fn on_agent(&mut self, message: agent::Message) -> Task<Message> {
|
||||
let catalog = Catalog::of(Lang::En);
|
||||
// M106 口径:文案走**生效语言**的目录,不再恒英文
|
||||
let catalog = Catalog::of(self.lang());
|
||||
|
||||
// 只有**几何/路由**会变的几支才需要重建视图模型(`pages::Model` 里没有会话内容:
|
||||
// transcript / draft 全在 `App.agent` 上)。复审 P3-9:以前每个 `Edit`(= 每敲一个字符)
|
||||
// 和每个 `DragTo`(= 指针频率)都重建一次整张模型,白白克隆 N 个实例的名字。
|
||||
// 只有**几何**会变的几支才需要重建页面模型(`pages::Model` 里没有会话内容:会话在
|
||||
// presenter 的 rail 上,右栗自己那棵树每次 `view()` 都从 rail 现读)。复审 P3-9 的教训:
|
||||
// 每敲一个字符都重建整张模型是白克隆。
|
||||
let geometry_changed = matches!(
|
||||
message,
|
||||
agent::Message::Expand
|
||||
@@ -51,31 +69,52 @@ impl App {
|
||||
);
|
||||
|
||||
match message {
|
||||
agent::Message::Expand => self.agent.expand(),
|
||||
agent::Message::Expand => {
|
||||
self.agent.expand();
|
||||
// 「展开即读」是 presenter 的语义(`agent_mark_read`),壳只负责在正确的时刻调它
|
||||
self.agent_host.mark_read();
|
||||
}
|
||||
agent::Message::Collapse | agent::Message::Escape => self.collapse_agent(),
|
||||
agent::Message::Detach => {
|
||||
self.agent.detach();
|
||||
self.agent_host.mark_read();
|
||||
self.state.navigate(Route::Agent);
|
||||
}
|
||||
agent::Message::Dock => {
|
||||
self.agent.dock();
|
||||
self.agent_host.mark_read();
|
||||
self.leave_agent_page();
|
||||
}
|
||||
agent::Message::DragStart => self.agent.drag_start(),
|
||||
agent::Message::DragTo(x) => self.agent.drag_to(self.width, x),
|
||||
agent::Message::DragEnd => self.agent.drag_end(),
|
||||
agent::Message::Edit(action) => self.agent.edit(action),
|
||||
agent::Message::Submit => {
|
||||
if self.agent.submit(catalog) {
|
||||
eprintln!("[agent] submit → 占位(没有接后端)");
|
||||
agent::Message::Edit(action) => {
|
||||
self.agent.edit(action);
|
||||
// 草稿同步给 presenter(切形态不丢;它也是 rail 上唯一允许躺原文的字段)
|
||||
self.agent_host.set_draft(self.agent.draft.text());
|
||||
}
|
||||
agent::Message::Submit => self.submit_agent(),
|
||||
agent::Message::Stop => self.dispatch(AppCommand::StopAgent),
|
||||
agent::Message::Confirm(accept) => {
|
||||
self.agent_host.confirm(accept);
|
||||
self.drain_agent();
|
||||
}
|
||||
agent::Message::Events(events) => {
|
||||
for event in events {
|
||||
self.agent_host.apply(event);
|
||||
}
|
||||
self.view_dirty = true;
|
||||
}
|
||||
agent::Message::Tab { shift } => {
|
||||
let busy = self.agent_host.rail().busy;
|
||||
self.agent.step_focus(shift, busy);
|
||||
}
|
||||
agent::Message::Stop => self.agent.stop(catalog),
|
||||
agent::Message::Tab { shift } => self.agent.step_focus(shift),
|
||||
agent::Message::Activate => self.activate_focused(catalog),
|
||||
agent::Message::Key(event) => return self.on_agent_key(event),
|
||||
}
|
||||
|
||||
// 档位 / 路由变了就要把「栏可见吗」写回 presenter(未读口径的输入)
|
||||
self.sync_agent_presenter();
|
||||
if geometry_changed {
|
||||
self.view_dirty = true;
|
||||
self.refresh_view();
|
||||
@@ -83,6 +122,68 @@ impl App {
|
||||
Task::none()
|
||||
}
|
||||
|
||||
/// 发送:草稿 → `agent_submit` → 抽事件。
|
||||
///
|
||||
/// 三种结局各自有落点:发出去(清草稿)/ 后端失败(具名横幅,见 [`App::agent_failure`])/
|
||||
/// presenter 拒收(日志一行——这三种情形正常情况下被禁用态挡住,够不着)。
|
||||
fn submit_agent(&mut self) {
|
||||
let text = self.agent.draft.text();
|
||||
if text.trim().is_empty() {
|
||||
return;
|
||||
}
|
||||
match self.agent_host.submit(text) {
|
||||
SubmitOutcome::Sent => {
|
||||
self.agent.clear_draft();
|
||||
self.agent_host.set_draft(String::new());
|
||||
self.drain_agent();
|
||||
}
|
||||
SubmitOutcome::Failed(failure) => {
|
||||
eprintln!("[agent] 提交失败({})", failure.code());
|
||||
}
|
||||
SubmitOutcome::Rejected => {
|
||||
eprintln!("[agent] presenter 拒收:空文本 / 上一轮未完 / 待确认 三闸该先挡住");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 抽干 presenter 的事件队列,**经既有 Message 通道**回流(见模块文档末节)。
|
||||
pub(crate) fn drain_agent(&mut self) {
|
||||
let events = self.agent_host.drain();
|
||||
if events.is_empty() {
|
||||
return;
|
||||
}
|
||||
let _ = self.on_agent(agent::Message::Events(events));
|
||||
}
|
||||
|
||||
/// 把「栏可见吗」写回 presenter(`navigate` + `prefs_mut` 两个窄入口,见 `AgentHost`)。
|
||||
pub(crate) fn sync_agent_presenter(&mut self) {
|
||||
let on_page = self.state.route() == &Route::Agent;
|
||||
let collapsed = self.agent.rail == RailState::Collapsed;
|
||||
self.agent_host.sync_visibility(on_page, collapsed);
|
||||
}
|
||||
|
||||
/// 失败横幅要显示的东西(`None` = 没有错误)。
|
||||
///
|
||||
/// 两个来源都取**展示输入**:具名原因码 → catalog 译文;或 presenter 已脱敏的这一轮原因。
|
||||
/// 壳在这一层不做任何脱敏(那是 presenter 的`scrub_text`),也不拼中英(那是 catalog 的事)。
|
||||
pub(crate) fn agent_failure(&self) -> Option<agent::Failure<'_>> {
|
||||
let catalog = self.lang().catalog();
|
||||
if let Some(failure) = self.agent_host.last_failure() {
|
||||
return Some(agent::Failure {
|
||||
title: TextKey::AgentErrorTitle,
|
||||
body: catalog.text(failure.body_key()),
|
||||
});
|
||||
}
|
||||
self.agent_host
|
||||
.rail()
|
||||
.error
|
||||
.as_deref()
|
||||
.map(|body| agent::Failure {
|
||||
title: TextKey::AgentErrorTitle,
|
||||
body,
|
||||
})
|
||||
}
|
||||
|
||||
/// **不变式**:`route() == Route::Agent` ⟺ `agent.rail == RailState::Detached`(唯一同步点)。
|
||||
///
|
||||
/// 为什么需要它(复审 r1 的 P2-1,实测复现):`agent.rail` 只在自己发 `Detach`/`Dock` 时变,
|
||||
@@ -106,12 +207,14 @@ impl App {
|
||||
}
|
||||
}
|
||||
|
||||
/// 启动时助手栏要做的两件事(从 `on_boot` 搬出来:那边只许留一行调用,复审 P3-6)。
|
||||
/// 启动时助手栏要做的三件事(从 `on_boot` 搬出来:那边只许留一行调用,复审 P3-6)。
|
||||
///
|
||||
/// 1. **窗口尺寸 adopt**:唯一真源是 `boot.rs` 开窗时用的那一个(`ULL_WINDOW_SIZE` + 最小尺寸
|
||||
/// 钳位)。`App::with_presenter` 只认识常量 1180×760,不 adopt 的话验收矩阵开 900 / 1520 的窗
|
||||
/// 时页面还按 1180 排版(窄窗里内容被裁)。
|
||||
/// 2. **拆出档直接落到独立页**:`ULL_AGENT_RAIL=detached`(以及将来持久化的偏好)时,会话就在
|
||||
/// 2. **取证夹具**(`ULL_AGENT_*`):先落档位,再**跑一轮真提交**——夹具不造消息,消息只能
|
||||
/// 由 presenter 产生(M119 的口径,见 `pages/agent/demo.rs`)。
|
||||
/// 3. **拆出档直接落到独立页**:`ULL_AGENT_RAIL=detached`(以及将来持久化的偏好)时,会话就在
|
||||
/// 独立页里,启动后不该停在实例页。
|
||||
pub(super) fn boot_agent(&mut self) {
|
||||
let (window_size, _source) = crate::renderer::acceptance::window::window_size(
|
||||
@@ -121,6 +224,23 @@ impl App {
|
||||
self.width = window_size.0;
|
||||
self.height = window_size.1;
|
||||
|
||||
if let Some((ui, fixture)) = demo::from_env() {
|
||||
self.agent = ui;
|
||||
// 先让 presenter 知道「栏现在看得见吗」,再跑提交,未读口径才对
|
||||
self.sync_agent_presenter();
|
||||
match fixture {
|
||||
Fixture::None => {}
|
||||
Fixture::Turn | Fixture::Unread => {
|
||||
let _ = self.agent_host.submit(demo::DEMO_QUESTION.to_string());
|
||||
self.drain_agent();
|
||||
}
|
||||
Fixture::Focused => {
|
||||
let busy = self.agent_host.rail().busy;
|
||||
self.agent.step_focus(false, busy);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if self.agent.rail == RailState::Detached && self.state.route() != &Route::Agent {
|
||||
eprintln!("[agent] rail=detached → Route::Agent");
|
||||
self.state.navigate(Route::Agent);
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
//! **树测试①(M119 验收核心)**:AI 右栏在**真实 presenter 状态**下渲染。
|
||||
//!
|
||||
//! 走的是同一条用户路径:`Message::Agent(Submit)` → `AgentHost::submit` → `Presenter::agent_submit`
|
||||
//! → transport → 事件队列 → `Message::Agent(Events)` → `Presenter::apply` → `AgentRail`。
|
||||
//! **没有一条断言看本地数组**:`transcript` 这类壳自己的会话副本 M119 已经删了,能断言的只剩
|
||||
//! presenter 的 rail,而 rail 正是右栏画的东西(`pages/agent::spec` 从它借)。
|
||||
//!
|
||||
//! 覆盖任务书点名的四件事:
|
||||
//!
|
||||
//! | 断言 | 抓得住什么 |
|
||||
//! |---|---|
|
||||
//! | 提交后 rail 出现「你 + 助手」两条、正文来自事件的流式增量 | 提交没接到 presenter、或右栏还在画样本会话 |
|
||||
//! | `busy` 翻真 → 收敛后翻假 | 事件没回流、或流式标记没收敛(输入框会永远禁用) |
|
||||
//! | 标记已读后未读归零 | `agent_mark_read` 没接、或未读口径没跟着 rail |
|
||||
//! | 三种失败都给**具名**横幅 | 失败被吞掉(M104 的「静默无语」)|
|
||||
|
||||
use iced::widget::text_editor;
|
||||
|
||||
use unlucky_design::color::SchemeFlavor;
|
||||
use unlucky_i18n::{Catalog, Key, Lang};
|
||||
use unlucky_presenter::dto::{AgentEvent, AppEvent, ChatRole};
|
||||
use unlucky_presenter::mock::MockBackend;
|
||||
use unlucky_presenter::settings_bridge::SettingsBridge;
|
||||
|
||||
use crate::app::state::presenter::transport::Script;
|
||||
use crate::app::state::presenter::AgentHost;
|
||||
use crate::app::Message;
|
||||
use crate::cli::Options;
|
||||
use crate::shell::agent::{Message as AgentMessage, RailState};
|
||||
use crate::theme::Families;
|
||||
|
||||
use super::App;
|
||||
|
||||
const FAMILIES: Families = Families {
|
||||
text: "Segoe UI",
|
||||
display: "Segoe UI",
|
||||
mono: "Consolas",
|
||||
};
|
||||
|
||||
/// 不碰磁盘的 App(mock 后端 + 内存设置桥 + **本脚本的** presenter 句柄)。
|
||||
fn app_with(script: Script) -> App {
|
||||
let mut app = App::with_presenter(
|
||||
Options::default(),
|
||||
FAMILIES,
|
||||
SchemeFlavor::Light,
|
||||
Box::new(MockBackend::new()),
|
||||
SettingsBridge::memory_only(),
|
||||
);
|
||||
app.agent_host = AgentHost::with_script(script);
|
||||
// 换句柄 = 可见性还没写回去;`refresh_view` 是那条单点同步
|
||||
app.refresh_view();
|
||||
app
|
||||
}
|
||||
|
||||
/// 走真消息提交(用户路径:填草稿 → 点发送)。
|
||||
fn submit(app: &mut App, text: &str) {
|
||||
app.agent.draft = text_editor::Content::with_text(text);
|
||||
let _ = app.update(Message::Agent(AgentMessage::Submit));
|
||||
}
|
||||
|
||||
/// **提交 → 事件回流 → 右栏出现这一轮 → 标记已读归零**(任务书第 2 条)。
|
||||
///
|
||||
/// 抓得住:①`Submit` 没接 `agent_submit`(rail 空);②事件没经 Message 通道回流(rail 只有半截);
|
||||
/// ③`expand` 没调 `agent_mark_read`(未读不清);④右栏读的还是壳自己的副本。
|
||||
/// 抓不住:气泡在像素上长什么样(那要出图,见 `DevDocs/artifacts/m119/`)。
|
||||
#[test]
|
||||
fn a_submitted_turn_lands_in_the_rail_and_reading_it_zeroes_unread() {
|
||||
let mut app = app_with(Script::Qa);
|
||||
// 出厂是折叠档 ⇒ presenter 认为「用户没在看着」,到达的助手消息应计未读
|
||||
assert_eq!(app.agent.rail, RailState::Collapsed);
|
||||
assert_eq!(app.agent_host.rail().unread, 0, "空会话没有未读");
|
||||
|
||||
submit(&mut app, "Which instance should I start?");
|
||||
|
||||
let rail = app.agent_host.rail();
|
||||
assert_eq!(
|
||||
rail.messages.len(),
|
||||
2,
|
||||
"一条「你」+ 一条助手(事件产生,不是本地回显)"
|
||||
);
|
||||
assert_eq!(rail.messages[0].role, ChatRole::User);
|
||||
assert_eq!(rail.messages[0].text, "Which instance should I start?");
|
||||
assert_eq!(rail.messages[1].role, ChatRole::Assistant);
|
||||
assert!(
|
||||
rail.messages[1].text.contains("Fabric"),
|
||||
"正文来自流式增量:{:?}",
|
||||
rail.messages[1].text
|
||||
);
|
||||
assert!(!rail.messages[1].streaming, "Qa 脚本会收敛");
|
||||
assert!(!rail.busy, "收敛之后不忙(发送键要能再按)");
|
||||
assert_eq!(rail.unread, 1, "折叠态到达的一条助手消息 = 一条未读");
|
||||
|
||||
// 右栏画的正是这份 rail(`pages/agent::spec` 直接借它)——构图不 panic
|
||||
let _ = app.view();
|
||||
|
||||
// 打开栏 = 读过:`agent_mark_read`
|
||||
let _ = app.update(Message::Agent(AgentMessage::Expand));
|
||||
assert_eq!(app.agent_host.rail().unread, 0, "标记已读后未读归零");
|
||||
assert_eq!(app.agent_host.rail().messages.len(), 2, "已读不改会话");
|
||||
}
|
||||
|
||||
/// **busy 正确翻转**:流式中忙 → 后端收敛后不忙。
|
||||
///
|
||||
/// 抓得住:`busy` 还由壳自己算(那它不会跟 rail 走);`Events` 通道被当成「只追加消息」而丢掉
|
||||
/// `MessageFinished`(输入框会永远禁用——这是最要命的坏法之一)。
|
||||
#[test]
|
||||
fn busy_flips_with_the_backend_events() {
|
||||
let mut app = app_with(Script::Streaming);
|
||||
submit(&mut app, "hello");
|
||||
assert!(app.agent_host.rail().busy, "流式没收敛前输入要禁用");
|
||||
|
||||
// 后端收敛:一条 `MessageFinished` 经同一条 Message 通道回流
|
||||
let index = app.agent_host.rail().messages.len() - 1;
|
||||
let _ = app.update(Message::Agent(AgentMessage::Events(vec![AppEvent::Agent(
|
||||
AgentEvent::MessageFinished { index },
|
||||
)])));
|
||||
assert!(!app.agent_host.rail().busy, "收敛之后必须能再发");
|
||||
}
|
||||
|
||||
/// 写操作确认:rail 上有待确认 ⇒ 忙;裁决后收敛并记一条审计。
|
||||
#[test]
|
||||
fn a_pending_write_op_is_visible_and_decidable() {
|
||||
let mut app = app_with(Script::WriteOp);
|
||||
submit(&mut app, "把下载并发改小");
|
||||
|
||||
let rail = app.agent_host.rail();
|
||||
let request = rail.confirm.as_ref().expect("要挂一个待确认请求");
|
||||
assert_eq!(request.tool, "set_config");
|
||||
assert!(
|
||||
rail.busy,
|
||||
"等确认也算忙(否则用户能在一轮写操作中间再发一句)"
|
||||
);
|
||||
let _ = app.view();
|
||||
|
||||
let _ = app.update(Message::Agent(AgentMessage::Confirm(true)));
|
||||
assert!(app.agent_host.rail().confirm.is_none(), "裁决后清空");
|
||||
assert!(!app.agent_host.rail().busy);
|
||||
assert_eq!(app.agent_host.audit_len(), 1, "裁决要进审计(YOLO 下也是)");
|
||||
}
|
||||
|
||||
/// 失败态①:**AI 端点不可达** ⇒ 具名横幅(不是静默,也不是把后端原文糊上屏)。
|
||||
#[test]
|
||||
fn an_unreachable_endpoint_shows_a_named_error() {
|
||||
let mut app = app_with(Script::Unreachable);
|
||||
submit(&mut app, "hello");
|
||||
|
||||
let failure = app.agent_failure().expect("提交失败必须给横幅");
|
||||
// 文案跟着**生效语言**走(`LangState` 在这台机器上探到 zh-CN,硬编码英文会假红)
|
||||
let effective = app.lang().catalog();
|
||||
assert_eq!(failure.title, Key::AgentErrorTitle, "标题是 catalog 键");
|
||||
assert_eq!(failure.body, effective.text(Key::AgentErrorUnavailable));
|
||||
assert!(!failure.body.trim().is_empty());
|
||||
assert!(
|
||||
app.agent_host.rail().messages.is_empty(),
|
||||
"失败不产生消息:不许假装有回复"
|
||||
);
|
||||
let _ = app.view();
|
||||
}
|
||||
|
||||
/// 失败态②:**未实现路由** ⇒ 另一句具名错误(两种失败不能糊成一句)。
|
||||
#[test]
|
||||
fn an_unimplemented_route_shows_its_own_named_error() {
|
||||
let mut app = app_with(Script::NotImplemented);
|
||||
submit(&mut app, "hello");
|
||||
|
||||
let failure = app.agent_failure().expect("必须给横幅");
|
||||
let effective = app.lang().catalog();
|
||||
assert_eq!(failure.body, effective.text(Key::AgentErrorNotImplemented));
|
||||
// 两条原因码要两句话(中英两套都得分开),不是一句万能错误
|
||||
for lang in [Lang::En, Lang::ZhCn] {
|
||||
let catalog = Catalog::of(lang);
|
||||
assert_ne!(
|
||||
catalog.text(Key::AgentErrorNotImplemented),
|
||||
catalog.text(Key::AgentErrorUnavailable),
|
||||
"{} 下两条原因码糊成一句了",
|
||||
lang.tag()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 失败态③:**这一轮回复中途断流** ⇒ 横幅用 presenter 已脱敏的那句原因,已到的增量保留。
|
||||
#[test]
|
||||
fn a_turn_level_failure_keeps_the_partial_answer_and_the_reason() {
|
||||
let mut app = app_with(Script::FailsMidStream);
|
||||
submit(&mut app, "读一下日志");
|
||||
|
||||
let rail = app.agent_host.rail();
|
||||
// 用户 + 助手(流式占位)+ 系统条目(`Failed` 会额外追加一条,错误也要能回翻)
|
||||
assert_eq!(rail.messages.len(), 3, "断流不撤销已到的增量");
|
||||
assert!(
|
||||
rail.messages[1].text.contains("日志"),
|
||||
"已到的增量要留着:{:?}",
|
||||
rail.messages[1].text
|
||||
);
|
||||
assert!(!rail.busy, "断流要收敛掉流式标记");
|
||||
let failure = app.agent_failure().expect("`Failed` 事件要立横幅");
|
||||
assert!(
|
||||
failure.body.contains("read timeout"),
|
||||
"横幅正文是 presenter 脱敏后的原因:{}",
|
||||
failure.body
|
||||
);
|
||||
}
|
||||
|
||||
/// 输入框的草稿会同步给 presenter(切形态不丢的语义在 presenter 那边)。
|
||||
#[test]
|
||||
fn typing_syncs_the_draft_into_the_presenter() {
|
||||
let mut app = app_with(Script::Qa);
|
||||
app.agent.draft = text_editor::Content::with_text("half typed");
|
||||
let _ = app.update(Message::Agent(AgentMessage::Edit(
|
||||
text_editor::Action::Edit(text_editor::Edit::Insert('!')),
|
||||
)));
|
||||
assert_eq!(
|
||||
app.agent_host.rail().draft,
|
||||
app.agent.draft.text(),
|
||||
"编辑器里是什么,rail 上就该是什么(同一个串,不是它自己的副本)"
|
||||
);
|
||||
assert!(app.agent_host.rail().draft.contains('!'), "改动真的进去了");
|
||||
// 发送成功后草稿两边都清空
|
||||
let _ = app.update(Message::Agent(AgentMessage::Submit));
|
||||
assert!(app.agent.draft_is_blank(), "编辑器清空");
|
||||
assert!(app.agent_host.rail().draft.is_empty(), "presenter 侧也清空");
|
||||
}
|
||||
@@ -12,6 +12,11 @@
|
||||
//! | 截图 / 有 Busy-Running 实例 / 有 toast / 派发后 3 s 观察窗 | `window::frames()` + 闸门 | 33 ms |
|
||||
//! | 其余(空闲) | `Heartbeat` 自续链 | 1 s |
|
||||
//!
|
||||
//! ## M119 增加了什么
|
||||
//!
|
||||
//! [`App::pump`] 里多了一行 [`App::drain_agent`]:AI 助手的事件队列和 core 的事件队列同拍抽干,
|
||||
//! 于是「右栗的未读/流式状态」和页面快照共享同一个刷新节奏(不会一个 30 Hz、一个 1 Hz)。
|
||||
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use unlucky_presenter::dto::AppCommand;
|
||||
@@ -98,6 +103,10 @@ impl App {
|
||||
|
||||
/// 抽干事件队列并逐条 `apply`(**唯一**的状态写入口)。
|
||||
pub(in crate::app) fn pump(&mut self) {
|
||||
// M119:AI 助手的事件队列与 core 的事件队列**同拍**抽干(顺序无关:两个队列互不依赖)。
|
||||
// 抽出来的这批事件经 `Message::Agent(Events)` 回流,见 `update/agent.rs`。
|
||||
self.drain_agent();
|
||||
|
||||
let events = self.backend.drain_events();
|
||||
if events.is_empty() {
|
||||
return;
|
||||
|
||||
@@ -56,6 +56,10 @@ impl App {
|
||||
.map(|element| element.map(Message::Page));
|
||||
// 右栗(M104):拆出档返回 `None`(会话已在独立页里);拖拽期间另有铺满窗口的取指针层。
|
||||
let overlay = agent::drag_overlay(&self.agent).map(|element| element.map(Message::Agent));
|
||||
// M119:会话内容 / 未读 / busy / 待确认全部来自 presenter 的 rail,失败横幅是 app 层
|
||||
// 备好的**展示输入**(标题 catalog 键 + 已脱敏正文)。壳在这里不做任何脱敏。
|
||||
let rail = self.agent_host.rail();
|
||||
let failure = self.agent_failure();
|
||||
|
||||
let account = self.rail_account();
|
||||
match pages::Screen::of(self.state.route()) {
|
||||
@@ -79,7 +83,7 @@ impl App {
|
||||
self.language(),
|
||||
)
|
||||
.map(Message::Page),
|
||||
agent::docked(&self.ui, &self.agent, self.width)
|
||||
agent::docked(&self.ui, &self.agent, rail, failure, self.width)
|
||||
.map(|element| element.map(Message::Agent)),
|
||||
overlay,
|
||||
self.options.force_state,
|
||||
@@ -99,7 +103,7 @@ impl App {
|
||||
},
|
||||
APP_TITLE,
|
||||
background,
|
||||
agent::page(&self.ui, &self.agent, self.width).map(Message::Agent),
|
||||
agent::page(&self.ui, &self.agent, rail, failure, self.width).map(Message::Agent),
|
||||
None,
|
||||
overlay,
|
||||
self.options.force_state,
|
||||
|
||||
@@ -52,8 +52,15 @@ impl App {
|
||||
// M106:账户页的文案跟着**生效语言**走(`LangState::boot` 自己读 `ULL_LANG`,
|
||||
// 所以 M114 早期那个「截图专用语言开关」已删——两套开关必然漂)。
|
||||
pages::account::view(&self.ui, metrics, &self.account, self.lang()).map(Message::Page),
|
||||
agent::docked(&self.ui, &self.agent, self.width)
|
||||
.map(|element| element.map(Message::Agent)),
|
||||
// M119:右栗的会话内容 / 未读 / busy 全部来自 presenter 的 rail(`agent_host`)
|
||||
agent::docked(
|
||||
&self.ui,
|
||||
&self.agent,
|
||||
self.agent_host.rail(),
|
||||
self.agent_failure(),
|
||||
self.width,
|
||||
)
|
||||
.map(|element| element.map(Message::Agent)),
|
||||
overlay,
|
||||
self.options.force_state,
|
||||
self.lang(),
|
||||
|
||||
Reference in new issue
Block a user