复审 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)。
399 lines
14 KiB
Rust
399 lines
14 KiB
Rust
//! 三档状态机、两个宿主、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);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|