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
@@ -1,17 +1,23 @@
|
||||
//! 三档状态机、两个宿主、a11y 文案的判据(数值表与磁铁在 `metrics/tests.rs`)。
|
||||
//!
|
||||
//! 每条断言都写清「抓得住什么 / 抓不住什么」——纯函数与源码扫描能钉住语义,**钉不住**像素。
|
||||
//!
|
||||
//! M119 起这里的重心变了:会话内容不再由本模块持有,所以「提交会怎样」这类端到端断言搬到了
|
||||
//! `app/update/agent/tests.rs`(那里能拿到 presenter 句柄)。这里留的是**纯 UI 态**与**视图契约**。
|
||||
|
||||
use unlucky_design::color::SchemeFlavor;
|
||||
use unlucky_design::typography::Platform;
|
||||
use unlucky_i18n::{Catalog, Key, Lang};
|
||||
|
||||
use unlucky_presenter::agent::AgentRail;
|
||||
use unlucky_presenter::dto::{ChatMessage, ChatRole, ConfirmRequest, Sensitivity};
|
||||
|
||||
use crate::theme::{Families, Ui};
|
||||
|
||||
mod drag;
|
||||
|
||||
use super::conversation::{Conversation, Host};
|
||||
use super::state::{AgentUi, Focus, RailState, Speaker, Turn};
|
||||
use super::state::{AgentUi, Focus, RailState};
|
||||
use super::*;
|
||||
|
||||
const FAMILIES: Families = Families {
|
||||
@@ -24,24 +30,47 @@ pub(super) fn ui() -> Ui {
|
||||
Ui::with_platform(SchemeFlavor::Light, Platform::Windows, FAMILIES)
|
||||
}
|
||||
|
||||
/// 三档迁移:会话与草稿跟着走、未读按「打开即读」清零。
|
||||
/// 一条只给视图用的 rail(会话内容由 presenter 生产,这里只造**渲染输入**)。
|
||||
fn rail(messages: Vec<ChatMessage>, unread: usize, busy: bool) -> AgentRail {
|
||||
AgentRail {
|
||||
messages,
|
||||
unread,
|
||||
busy,
|
||||
..AgentRail::new()
|
||||
}
|
||||
}
|
||||
|
||||
fn conversation_rail() -> AgentRail {
|
||||
rail(
|
||||
vec![
|
||||
ChatMessage::user("Which instance should I start?"),
|
||||
ChatMessage {
|
||||
role: ChatRole::Assistant,
|
||||
text: "Survival is on 1.20.4.".to_string(),
|
||||
tool: None,
|
||||
streaming: false,
|
||||
},
|
||||
],
|
||||
2,
|
||||
false,
|
||||
)
|
||||
}
|
||||
|
||||
/// 三档迁移:**草稿与焦点**跟着走(会话在 presenter 上,本来就不归这里管)。
|
||||
///
|
||||
/// 抓得住:拆出去再收回,**transcript 与 draft** 不丢(`busy` 不在这条测试的断言里);
|
||||
/// `expand()/detach()/dock()` 都把 `unread` 清零;`collapse()` 不清(收起后新消息还要能点亮)。
|
||||
/// 抓不住:切换时有没有一次「闪一下」的重排(那要出图看),也不测 `busy` 的迁移。
|
||||
/// 抓得住:`expand()/detach()/dock()` 把档位改对且不清草稿;`collapse()` 对 Detached 等价于 `dock()`。
|
||||
/// 抓不住:切换时有没有一次「闪一下」的重排(那要出图看)。
|
||||
#[test]
|
||||
fn the_three_states_share_one_conversation() {
|
||||
fn the_three_states_share_one_draft() {
|
||||
let mut agent = AgentUi::new();
|
||||
assert_eq!(agent.rail, RailState::Collapsed, "出厂是折叠档");
|
||||
assert_eq!(agent.unread, 2, "出厂带两条未读(折叠条上要有点)");
|
||||
|
||||
agent.expand();
|
||||
assert_eq!(agent.rail, RailState::Expanded);
|
||||
assert_eq!(agent.unread, 0, "展开即读");
|
||||
|
||||
// 走 `AgentUi::edit`(= app 层真正的落点),而不是直接操作 iced 的 `Content`:
|
||||
// 走到这里的 `Edit(Enter)` 只可能是 Shift+Enter(非 shift 的 Enter 在 composer 的
|
||||
// `key_binding` 里就变成 `Message::Submit` 了,见 composer 的 `enter_sends_and_shift_enter_breaks_the_line`)。
|
||||
// `key_binding` 里就变成 `Message::Submit` 了)。
|
||||
agent.draft = iced::widget::text_editor::Content::with_text("kick off Survival");
|
||||
agent.edit(iced::widget::text_editor::Action::Edit(
|
||||
iced::widget::text_editor::Edit::Enter,
|
||||
@@ -49,21 +78,19 @@ fn the_three_states_share_one_conversation() {
|
||||
assert_eq!(
|
||||
agent.draft.line_count(),
|
||||
2,
|
||||
"多行:一条 Edit(Enter) 经过 `edit()` 之后草稿变成两行"
|
||||
"多行:一条 Edit(Enter) 之后成两行"
|
||||
);
|
||||
|
||||
let turns = agent.transcript.len();
|
||||
let draft = agent.draft.text();
|
||||
agent.detach();
|
||||
assert_eq!(agent.rail, RailState::Detached);
|
||||
assert_eq!(agent.transcript.len(), turns, "拆出去不丢会话");
|
||||
assert_eq!(agent.draft.text(), draft, "拆出去不丢草稿");
|
||||
|
||||
agent.dock();
|
||||
assert_eq!(agent.rail, RailState::Expanded);
|
||||
assert_eq!(agent.draft.text(), draft);
|
||||
|
||||
// 收起 / Esc:只改档位,不清未读记录(下一批消息仍会点亮)
|
||||
// 收起 / Esc:只改档位
|
||||
agent.collapse();
|
||||
assert_eq!(agent.rail, RailState::Collapsed);
|
||||
// 独立页上的 Esc = 收回(`collapse` 对 Detached 等价于 dock)
|
||||
@@ -72,59 +99,22 @@ fn the_three_states_share_one_conversation() {
|
||||
assert_eq!(agent.rail, RailState::Expanded);
|
||||
}
|
||||
|
||||
/// 发送 / 中断:**只做视图态**(没有后端),但绝不静默——两条都会在会话里留一条提示。
|
||||
/// 草稿只有两个写口:`edit`(打字)与 `clear_draft`(发送成功之后)。
|
||||
#[test]
|
||||
fn submit_and_stop_leave_a_visible_notice() {
|
||||
let catalog = Catalog::of(Lang::En);
|
||||
fn sending_clears_the_draft_and_blank_is_trimmed() {
|
||||
let mut agent = AgentUi::new();
|
||||
agent.expand();
|
||||
|
||||
assert!(agent.draft_is_blank());
|
||||
assert!(
|
||||
!agent.submit(catalog),
|
||||
"空草稿发不出去(发送键同时是禁用态)"
|
||||
);
|
||||
|
||||
agent.draft = iced::widget::text_editor::Content::with_text(" ");
|
||||
assert!(agent.draft_is_blank(), "只有空白也算空(发送键是禁用态)");
|
||||
agent.draft = iced::widget::text_editor::Content::with_text(" hello ");
|
||||
let before = agent.transcript.len();
|
||||
assert!(agent.submit(catalog));
|
||||
assert_eq!(agent.transcript.len(), before + 2, "一条「你」+ 一条提示");
|
||||
assert_eq!(agent.transcript[before].speaker, Speaker::You);
|
||||
assert_eq!(agent.transcript[before].text, "hello", "两侧空白被剪掉");
|
||||
assert_eq!(agent.transcript[before + 1].speaker, Speaker::Notice);
|
||||
assert_eq!(
|
||||
agent.transcript[before + 1].text,
|
||||
catalog.text(Key::AgentNotePending)
|
||||
);
|
||||
assert!(!agent.draft_is_blank());
|
||||
agent.clear_draft();
|
||||
assert!(agent.draft_is_blank(), "发出去后草稿清空");
|
||||
|
||||
// 流式中:发送键禁用、中断键出现;中断也留一条提示
|
||||
agent.pretend_streaming();
|
||||
assert!(agent.busy);
|
||||
agent.draft = iced::widget::text_editor::Content::with_text("second");
|
||||
assert!(!agent.submit(catalog), "流式中不许再发");
|
||||
let before = agent.transcript.len();
|
||||
agent.stop(catalog);
|
||||
assert!(!agent.busy);
|
||||
assert_eq!(agent.transcript.len(), before + 1);
|
||||
assert_eq!(
|
||||
agent.transcript[before].text,
|
||||
catalog.text(Key::AgentNoteStopped)
|
||||
);
|
||||
|
||||
// 没在流式时按中断是 no-op(不刷屏)
|
||||
let before = agent.transcript.len();
|
||||
agent.stop(catalog);
|
||||
assert_eq!(agent.transcript.len(), before);
|
||||
}
|
||||
|
||||
/// 空会话:零消息 + 一句引导文案(任务第 7 条)。
|
||||
#[test]
|
||||
fn the_empty_session_has_a_guidance_line() {
|
||||
let empty = AgentUi::empty();
|
||||
assert!(empty.transcript.is_empty(), "空会话 = 零消息");
|
||||
assert_eq!(empty.unread, 0);
|
||||
|
||||
let catalog = Catalog::of(Lang::En);
|
||||
assert!(!catalog.text(Key::AgentEmptyTitle).is_empty());
|
||||
assert!(
|
||||
@@ -165,12 +155,17 @@ fn both_hosts_render_the_same_conversation() {
|
||||
);
|
||||
assert!(
|
||||
!hosts.contains("scrollable("),
|
||||
"滚动区的装配只许在 conversation.rs 里"
|
||||
"滚动区的装配只许在 conversation/transcript.rs 里"
|
||||
);
|
||||
assert!(
|
||||
composer.contains("text_editor(") || composer.contains("editor(spec.draft)"),
|
||||
"输入框就在 composer 里"
|
||||
);
|
||||
// M119:会话内容的唯一来源是 presenter 的 rail ⇒ 宿主把 rail 借进来,而不是自己造
|
||||
assert!(
|
||||
!hosts.contains("Vec<ChatMessage>") && !hosts.contains("Vec<Turn>"),
|
||||
"宿主里不许出现自持的会话容器"
|
||||
);
|
||||
}
|
||||
|
||||
/// a11y:所有动作按钮的标签都来自词表,折叠条也不是「只有图标」。
|
||||
@@ -190,6 +185,13 @@ fn every_action_has_a_readable_label() {
|
||||
Key::AgentStripLabel,
|
||||
Key::AgentResize,
|
||||
Key::AgentWidthLabel,
|
||||
// M119 新增:确认条的两个动作 + 失败横幅的标题
|
||||
Key::AgentConfirmAllow,
|
||||
Key::AgentConfirmDeny,
|
||||
Key::AgentConfirmRequest,
|
||||
Key::AgentErrorTitle,
|
||||
Key::AgentErrorUnavailable,
|
||||
Key::AgentErrorNotImplemented,
|
||||
] {
|
||||
assert!(
|
||||
!catalog.text(key).trim().is_empty(),
|
||||
@@ -201,6 +203,9 @@ fn every_action_has_a_readable_label() {
|
||||
for source in [
|
||||
include_str!("conversation.rs"),
|
||||
include_str!("conversation/composer.rs"),
|
||||
include_str!("conversation/banners.rs"),
|
||||
include_str!("conversation/transcript.rs"),
|
||||
include_str!("strip.rs"),
|
||||
include_str!("mod.rs"),
|
||||
] {
|
||||
for literal in ["Button::new(\"", "Button::disabled(\""] {
|
||||
@@ -217,7 +222,7 @@ fn every_action_has_a_readable_label() {
|
||||
|
||||
// 折叠条的 48px 条上,图标下面必须有文字标签(否则就是「只有图标」)
|
||||
assert!(
|
||||
include_str!("mod.rs").contains("Key::AgentStripLabel"),
|
||||
include_str!("strip.rs").contains("Key::AgentStripLabel"),
|
||||
"折叠条必须有可读的条上标签"
|
||||
);
|
||||
}
|
||||
@@ -249,7 +254,7 @@ fn the_focus_ring_walks_the_controls_that_exist() {
|
||||
Focus::Composer
|
||||
);
|
||||
|
||||
// 流式中多一档中断键
|
||||
// 流式中多一档中断键(`busy` 由 presenter 的 rail 给)
|
||||
assert_eq!(
|
||||
Focus::Send.step(false, RailState::Expanded, true),
|
||||
Focus::Stop
|
||||
@@ -270,50 +275,78 @@ fn the_focus_ring_walks_the_controls_that_exist() {
|
||||
// `Tab` 真的把键盘巡航打开(`Enter` 的闸门)
|
||||
let mut agent = AgentUi::new();
|
||||
assert!(!agent.keyboard_mode);
|
||||
agent.step_focus(false);
|
||||
agent.step_focus(false, false);
|
||||
assert!(agent.keyboard_mode);
|
||||
}
|
||||
|
||||
/// 两个宿主都能构图(含空会话 / 折叠 / 流式中),且能吃 880~2560 的窗口宽度。
|
||||
/// 视图契约:`spec()` 把 presenter 的三件事**原样借**给视图(零拷贝、不复制)。
|
||||
///
|
||||
/// 抓得住:某档位下 `view` 的某条分支 panic(例如空 transcript + 空未读)。
|
||||
/// 抓得住:有人在 `spec()` 里换掉/派生一份会话(那就会有两份真相)。
|
||||
/// 抓不住:`Conversation` 字段在视图里怎么用(那要人读 conversation.rs)。
|
||||
#[test]
|
||||
fn the_spec_borrows_the_presenter_rail_verbatim() {
|
||||
let mut agent = AgentUi::new();
|
||||
agent.expand();
|
||||
let rail = conversation_rail();
|
||||
let spec = super::spec(&agent, &rail, None, Host::Rail);
|
||||
assert_eq!(spec.messages.len(), rail.messages.len());
|
||||
assert_eq!(spec.messages[0].text, rail.messages[0].text, "同一条消息");
|
||||
assert_eq!(spec.unread, rail.unread);
|
||||
assert_eq!(spec.busy, rail.busy);
|
||||
assert!(spec.failure.is_none());
|
||||
}
|
||||
|
||||
/// 两个宿主都能构图(含空会话 / 有会话 / 流式中 / 待确认 / 失败横幅),且吃 880~2560 的宽度。
|
||||
///
|
||||
/// 抓得住:某档位下 `view` 的某条分支 panic(例如空消息 + 非零未读、确认条 + 失败横幅同时出现)。
|
||||
/// 抓不住:像素级观感(出图另算)。
|
||||
#[test]
|
||||
fn every_state_builds_in_both_hosts() {
|
||||
let ui = ui();
|
||||
let catalog = Catalog::of(Lang::En);
|
||||
let empty = AgentRail::new();
|
||||
let full = conversation_rail();
|
||||
let busy = rail(conversation_rail().messages, 0, true);
|
||||
let failed = AgentRail {
|
||||
error: Some("provider 断流:read timeout".to_string()),
|
||||
..conversation_rail()
|
||||
};
|
||||
let mut confirm_rail = conversation_rail();
|
||||
confirm_rail.confirm = Some(ConfirmRequest {
|
||||
id: "req-1".to_string(),
|
||||
tool: "set_config".to_string(),
|
||||
summary: "把下载并发 64 → 16".to_string(),
|
||||
details: Default::default(),
|
||||
sensitivity: Sensitivity::High,
|
||||
});
|
||||
let failure = Failure {
|
||||
title: Key::AgentErrorTitle,
|
||||
body: "The assistant backend is unreachable.",
|
||||
};
|
||||
|
||||
for window in [880.0, 900.0, 1180.0, 2560.0] {
|
||||
for rail in RailState::ALL {
|
||||
for empty in [false, true] {
|
||||
let mut agent = if empty {
|
||||
AgentUi::empty()
|
||||
} else {
|
||||
AgentUi::new()
|
||||
};
|
||||
agent.rail = rail;
|
||||
let _: iced::Element<'_, Message> = page(&ui, &agent, window);
|
||||
|
||||
let spec = Conversation {
|
||||
transcript: &agent.transcript,
|
||||
draft: &agent.draft,
|
||||
busy: agent.busy,
|
||||
unread: agent.unread,
|
||||
blank: agent.draft_is_blank(),
|
||||
rail: agent.rail,
|
||||
host: Host::Rail,
|
||||
focus: agent.keyboard_mode.then_some(agent.focus),
|
||||
};
|
||||
let _: iced::Element<'_, Message> = conversation::view(&ui, spec, catalog);
|
||||
let _ = docked(&ui, &agent, window);
|
||||
for rail in [&empty, &full, &busy, &failed, &confirm_rail] {
|
||||
for state in RailState::ALL {
|
||||
let mut agent = AgentUi::new();
|
||||
agent.rail = state;
|
||||
for shown in [None, Some(failure)] {
|
||||
let _: iced::Element<'_, Message> = page(&ui, &agent, rail, shown, window);
|
||||
let _ = docked(&ui, &agent, rail, shown, window);
|
||||
let spec = Conversation {
|
||||
messages: &rail.messages,
|
||||
draft: &agent.draft,
|
||||
busy: rail.busy,
|
||||
unread: rail.unread,
|
||||
blank: agent.draft_is_blank(),
|
||||
confirm: rail.confirm.as_ref(),
|
||||
failure: shown,
|
||||
rail: agent.rail,
|
||||
host: Host::Rail,
|
||||
focus: agent.keyboard_mode.then_some(agent.focus),
|
||||
};
|
||||
let _: iced::Element<'_, Message> = conversation::view(&ui, spec, catalog);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 会话数据结构不许在手边偷偷长字段(`Turn` 只有说话人 + 正文)。
|
||||
#[test]
|
||||
fn a_turn_is_only_a_speaker_and_a_body() {
|
||||
let turn = Turn::new(Speaker::You, "hi");
|
||||
assert_eq!((turn.speaker, turn.text.as_str()), (Speaker::You, "hi"));
|
||||
}
|
||||
Reference in new issue
Block a user