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
@@ -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;
|
||||
Reference in new issue
Block a user