Files
Unlucky-Launcher/crates/app-iced/src/pages/agent/tests.rs
T
AskaEth b61f342431 feat(motion2): 右栗折叠过渡 + 状态层 hover/pressed 过渡
A2 侧边栏(右栗 AI 栏)折叠/展开:
- 帧泵第三条轨道(pump/rail):展开 medium1 250ms + Emphasized、收起 short4 200ms +
  EmphasizedAccelerate;拆出档走 snap(不做过渡);MotionScale::NONE 下不插轨道
- 宽度在 metrics::rail_width 逐帧插值;面板内容按目标宽排版 + 按动画宽裁切
  (不重排 / 不溢出),宽度回到 48 时换成图标条(图标贴右端,位置不跳)
- pages/agent/mod.rs 的展开档面板纯搬移到 panel.rs(371 → 289 行)

A8 状态层过渡:
- interaction/layer:令牌化的进度时钟(short1 50ms),NONE 当帧落终值
- interaction/hover:取样控件把起跑搬到 Widget::update(早于订阅表求值)
- 按钮五式 + IconButton + 导航项药丸统一走 interaction::layer_color
  (没有取样控件时逐位退回旧口径,出图基线不变)
- components/motion/mod.rs 的 needs_frames / advance 各加一句委托,
  帧订阅仍然只有一处判断点(app/subscription.rs 未改)
2026-11-12 20:05:58 +08:00

401 lines
14 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 三档状态机、两个宿主、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 collapse;
mod disclaimer;
mod drag;
use super::conversation::{Conversation, Host};
use super::state::{AgentUi, Focus, RailState};
use super::*;
const FAMILIES: Families = Families {
text: "Segoe UI",
display: "Segoe UI",
mono: "Consolas",
};
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 上,本来就不归这里管)。
///
/// 抓得住:`expand()/detach()/dock()` 把档位改对且不清草稿;`collapse()` 对 Detached 等价于 `dock()`。
/// 抓不住:切换时有没有一次「闪一下」的重排(那要出图看)。
#[test]
fn the_three_states_share_one_draft() {
let mut agent = AgentUi::new();
assert_eq!(agent.rail, RailState::Collapsed, "出厂是折叠档");
agent.expand();
assert_eq!(agent.rail, RailState::Expanded);
// 走 `AgentUi::edit`(= app 层真正的落点),而不是直接操作 iced 的 `Content`:
// 走到这里的 `Edit(Enter)` 只可能是 Shift+Enter(非 shift 的 Enter 在 composer 的
// `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,
));
assert_eq!(
agent.draft.line_count(),
2,
"多行:一条 Edit(Enter) 之后成两行"
);
let draft = agent.draft.text();
agent.detach();
assert_eq!(agent.rail, RailState::Detached);
assert_eq!(agent.draft.text(), draft, "拆出去不丢草稿");
agent.dock();
assert_eq!(agent.rail, RailState::Expanded);
assert_eq!(agent.draft.text(), draft);
// 收起 / Esc:只改档位
agent.collapse();
assert_eq!(agent.rail, RailState::Collapsed);
// 独立页上的 Esc = 收回(`collapse` 对 Detached 等价于 dock)
agent.rail = RailState::Detached;
agent.collapse();
assert_eq!(agent.rail, RailState::Expanded);
}
/// 草稿只有两个写口:`edit`(打字)与 `clear_draft`(发送成功之后)。
#[test]
fn sending_clears_the_draft_and_blank_is_trimmed() {
let mut agent = AgentUi::new();
assert!(agent.draft_is_blank());
agent.draft = iced::widget::text_editor::Content::with_text(" ");
assert!(agent.draft_is_blank(), "只有空白也算空(发送键是禁用态)");
agent.draft = iced::widget::text_editor::Content::with_text(" hello ");
assert!(!agent.draft_is_blank());
agent.clear_draft();
assert!(agent.draft_is_blank(), "发出去后草稿清空");
}
/// 空会话:零消息 + 一句引导文案(任务第 7 条)。
#[test]
fn the_empty_session_has_a_guidance_line() {
let catalog = Catalog::of(Lang::En);
assert!(!catalog.text(Key::AgentEmptyTitle).is_empty());
assert!(
catalog.text(Key::AgentEmptyBody).split_whitespace().count() >= 5,
"空态要有一句能读懂下一步的引导,而不是一个词"
);
// 中文也得有且不同(D28:两套目录一行一个 key,缺译会红)
let zh = Catalog::of(Lang::ZhCn);
assert_ne!(
zh.text(Key::AgentEmptyBody),
catalog.text(Key::AgentEmptyBody)
);
}
/// **单实现判据**:右栗与独立页都只通过 `conversation::view` 画对话。
///
/// 抓得住:有人把「独立页要更宽」当成理由另写一份气泡/输入框装配(复制粘贴是这类任务最常见的坏法)。
/// 抓不住:`conversation::view` **内部**再分叉成两套(那要人读那个文件)。
#[test]
fn both_hosts_render_the_same_conversation() {
// M257:右栗面板整段搬去了 `panel.rs`,所以「两个宿主各调一次」的扫描面是两个文件。
let hosts = concat!(include_str!("mod.rs"), include_str!("panel.rs"));
let conversation = include_str!("conversation.rs");
let composer = include_str!("conversation/composer.rs");
assert_eq!(
conversation.matches("pub fn view<").count(),
1,
"会话视图只能有一个 `view`"
);
assert_eq!(
hosts.matches("conversation::view(").count(),
2,
"右栗与独立页各调用一次同一个 `conversation::view`(不多不少)"
);
assert!(
!hosts.contains("text_editor("),
"输入框的装配只许在 conversation/composer.rs 里(宿主不许自己拼一份)"
);
assert!(
!hosts.contains("scrollable("),
"滚动区的装配只许在 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:所有动作按钮的标签都来自词表,折叠条也不是「只有图标」。
///
/// 抓得住:有人写死一句英文/中文在视图里("Cancel"/"发送"),或给折叠条只留一个图标按钮。
/// 抓不住:译文本身好不好读(那要人看)。
#[test]
fn every_action_has_a_readable_label() {
let catalog = Catalog::of(Lang::En);
for key in [
Key::AgentCollapse,
Key::AgentDetach,
Key::AgentDock,
Key::AgentOpen,
Key::AgentSend,
Key::AgentStop,
Key::AgentStripLabel,
Key::AgentResize,
Key::AgentWidthLabel,
// M119 新增:确认条的两个动作 + 失败横幅的标题
Key::AgentConfirmAllow,
Key::AgentConfirmDeny,
Key::AgentConfirmRequest,
Key::AgentErrorTitle,
Key::AgentErrorUnavailable,
Key::AgentErrorNotImplemented,
// M119 r1 / P2-1:「这是模拟」的常驻标记也必须有词(用户不点就要能看到)
Key::AgentSimulated,
Key::AgentSubtitle,
] {
assert!(
!catalog.text(key).trim().is_empty(),
"{} 没有可读标签",
key.name()
);
}
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(\""] {
assert!(
!source.contains(literal),
"按钮标签不许写死在视图里:{literal}"
);
}
assert!(
!source.contains("text(\"Assistant\""),
"文案不许写死在视图里(走 i18n 的 Key)"
);
}
// 折叠条的 48px 条上,图标下面必须有文字标签(否则就是「只有图标」)
assert!(
include_str!("strip.rs").contains("Key::AgentStripLabel"),
"折叠条必须有可读的条上标签"
);
}
/// Tab 环:只在**真正存在**的控件之间走,且顺序自左向右可预期。
#[test]
fn the_focus_ring_walks_the_controls_that_exist() {
// 展开且空闲:收起 → 拆出 → 发送 → 输入框(没有中断键)
let mut focus = Focus::Collapse;
let mut walked = vec![focus];
for _ in 0..4 {
focus = focus.step(false, RailState::Expanded, false, false);
walked.push(focus);
}
assert_eq!(
walked,
vec![
Focus::Collapse,
Focus::Detach,
Focus::Send,
Focus::Composer,
Focus::Collapse,
]
);
// 反向:Collapse 往回走是 Composer
assert_eq!(
Focus::Collapse.step(true, RailState::Expanded, false, false),
Focus::Composer
);
// 流式中多一档中断键(`busy` 由 presenter 的 rail 给)
assert_eq!(
Focus::Send.step(false, RailState::Expanded, true, false),
Focus::Stop
);
// 独立页只有「收回 + 输入区」
assert_eq!(
Focus::Detach.step(false, RailState::Detached, false, false),
Focus::Send
);
// 折叠档没有会话动作:环上只有「展开」一格
assert_eq!(
Focus::Collapse.step(false, RailState::Collapsed, false, false),
Focus::Collapse
);
// `Tab` 真的把键盘巡航打开(`Enter` 的闸门)
let mut agent = AgentUi::new();
assert!(!agent.keyboard_mode);
agent.step_focus(false, false, false);
assert!(agent.keyboard_mode);
// M119 r1 / P2-2:有待确认写操作时,环上多两格(允许 / 拒绝),且**排在发送之前**——
// 阻塞态该先被看见。这也是键盘用户唯一能推进这一轮的路径。
assert_eq!(
Focus::Detach.step(false, RailState::Expanded, true, true),
Focus::ConfirmAllow
);
assert_eq!(
Focus::ConfirmAllow.step(false, RailState::Expanded, true, true),
Focus::ConfirmDeny
);
assert_eq!(
Focus::ConfirmDeny.step(false, RailState::Expanded, true, true),
Focus::Send
);
// 没有待确认时这两格不在环上(鼠标用户不该 Tab 到不存在的按钮)
assert_eq!(
Focus::Detach.step(false, RailState::Expanded, false, false),
Focus::Send
);
}
/// 视图契约:`spec()` 把 presenter 的三件事**原样借**给视图(零拷贝、不复制)。
///
/// 抓得住:有人在 `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 session = Session {
agent: &agent,
rail: &rail,
failure: None,
simulated: true,
};
let spec = super::spec(session, Catalog::of(Lang::En), 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());
assert!(
spec.note.simulated,
"模拟事实要原样传给视图(标记不是硬编码)"
);
assert!(
spec.note.subtitle.to_lowercase().contains("simulated"),
"副标题也归这个开关管:{}",
spec.note.subtitle
);
}
/// 两个宿主都能构图(含空会话 / 有会话 / 流式中 / 待确认 / 失败横幅),且吃 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 [&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 session = Session {
agent: &agent,
rail,
failure: shown,
simulated: true,
};
let _: iced::Element<'_, Message> = page(&ui, session, catalog, window);
let _ = docked(&ui, session, catalog, 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,
note: Disclaimer::of(catalog, true),
rail: agent.rail,
host: Host::Rail,
focus: agent.keyboard_mode.then_some(agent.focus),
};
let _: iced::Element<'_, Message> = conversation::view(&ui, spec, catalog);
}
}
}
}
}