Files
Unlucky-Launcher/crates/app-iced/src/pages/agent/tests.rs
T
AskaEth 0e48ea15b8 fix(app-iced): M119 r2 —— 四处「模拟」文案全由 simulated 驱动;确认条不再替用户 arm 键盘
复审 r2 的两条 p2 都是「上一轮各差一步」:字段/焦点都真的做了,但**另有三处没接上**、
**键盘闸是代码自己打开的**。

## P2-1:四处文案只认一个开关

r1 只有标题胶囊由 `simulated` 驱动;副标题 / 输入框占位符 / 空态正文是**无条件取词**,
而它们的译文里写着「模拟 / 没接真实模型」——接上真 transport 之后这三句会**反向**留着
(`simulated == false` 时界面还在声明「没接真实模型」)。而且没有一条测试钉得住那个声明。

现在:

- 新增 `pages/agent::Disclaimer { simulated, subtitle, placeholder, empty_body }`,
  `Disclaimer::of(catalog, simulated)` 一次算好;`Conversation.simulated: bool` 换成
  `Conversation.note: Disclaimer`,header / composer / transcript 三处各自从它取词——
  **视图里没有第二种取词路径**;
- i18n 配成对:模拟态用既有的 `AgentSubtitle` / `AgentPlaceholder` / `AgentEmptyBody`,
  真实态新增 `AgentSubtitleReal` / `AgentPlaceholderReal` / `AgentEmptyBodyReal`(中性话)。
  (没有把三句改成中性话:那句告知本身有价值,接真模型后它才该消失。)
- 证据:`crates/app-iced/src/pages/agent/tests/disclaimer.rs`
  —— `all_four_disclaimers_appear_when_the_backend_is_simulated`(四处都提模拟)、
  `no_disclaimer_survives_when_a_real_backend_is_wired`(四处都不提,且不许用空串糊)、
  `the_two_states_use_different_keys`(两态不能共用同一个 key)。

**golden.rs 的 400 行边界**:+3 条会破线。按「按功能拆、不压注释」处理——把这张表的
**说明文档**(35 行)原样搬到 `catalog.rs` 里 `mod golden;` 的文档上(一个字的说明都没删),
`golden.rs` 只留表数据 + 3 行指针。现在 `golden.rs` 374 行、`catalog.rs` 328 行。

## P2-2:键盘闸不许由代码替用户打开

r1 在 `sync_confirm_focus`(`on_agent` 的无条件尾巴,`Events` 又由心跳驱动)里顺手
`keyboard_mode = true`——于是**只要冒出待确认,全局键盘闸就开了**;用户发完那句话接着打下一句,
第一个空格就成了「批准写操作」。复审用临时用例复现过(`rail.confirm` 变 `None`)。

现在:

- `sync_confirm_focus` **只挪焦点**到「允许」,不再碰 `keyboard_mode`(字段文档里写清了为什么);
- 键盘用户的表态是 `Tab`:`AgentUi::step_focus` 加了一条 **arming 规则**——待确认、焦点已在
  确认条上、但键盘闸还关着时,这一次 `Tab` **只点亮焦点环、不移动焦点**(否则第一次 Tab 会把
  「允许」挪到「拒绝」,用户按 Enter 就是反着裁决);之后再 Tab 才逐格走;
- 反向回归:`a_stray_space_or_enter_never_approves_a_pending_write`(打字 → 空格 / 回车都不得裁决,
  审计也不许留);`a_blocking_confirm_can_be_decided_with_the_keyboard_alone` 改成
  「确认到达时未 armed → Tab 点亮 → Enter 允许;第二轮 Tab·Tab 到拒绝 → Space」。

## 出图

`DevDocs/artifacts/m119/` 18 张全部重出(新增取证夹具 `ULL_AGENT_FIXTURE=armed`:
跑一轮真提交后再按一次 Tab,专供「焦点环可见」那张图):

- `08-agent-simulated-{light,dark}`:`simulated = true` 下四处「模拟」同时可见;
- `09-agent-confirm-focus-{light,dark}`:**按过 Tab 之后**的确认条(焦点环在「允许」上);
- `05-agent-confirm-{light,dark}`:确认条刚出现、**还没 arm**(无焦点环)——与 09 成对,
  正好把 r2 的行为差异拍下来。

## 门禁(真实退出码,最终树)

| 命令 | 退出码 |
|---|---|
| `cargo fmt --all --check` | 0 |
| `cargo clippy -p unlucky-app-iced -p unlucky-presenter -p unlucky-agent -p unlucky-i18n --all-targets -- -D warnings` | 0(0 warning)|
| `cargo test -p unlucky-app-iced --lib` | 0(605 passed / 0 failed / 2 ignored)|
| `cargo test --workspace --exclude unlucky-app` | 0(58 套 2281 passed / 0 failed / 11 ignored)|

全树受检文件无 >400(`golden.rs` 374、`catalog.rs` 328、`pages/agent/mod.rs` 371)。
2026-11-05 10:43:22 +08:00

399 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 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() {
let hosts = include_str!("mod.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);
}
}
}
}
}