复审 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)。
353 lines
16 KiB
Rust
353 lines
16 KiB
Rust
//! **树测试①(M119 验收核心)**:AI 右栏在**真实 presenter 状态**下渲染。
|
||
//!
|
||
//! 走的是同一条用户路径:`Message::Agent(Submit)` → `AgentHost::submit` → `Presenter::agent_submit`
|
||
//! → transport → 事件队列 → `Message::Agent(Events)` → `Presenter::apply` → `AgentRail`。
|
||
//! **没有一条断言看本地数组**:`transcript` 这类壳自己的会话副本 M119 已经删了,能断言的只剩
|
||
//! presenter 的 rail,而 rail 正是右栏画的东西(`pages/agent::spec` 从它借)。
|
||
//!
|
||
//! 覆盖任务书点名的四件事:
|
||
//!
|
||
//! | 断言 | 抓得住什么 |
|
||
//! |---|---|
|
||
//! | 提交后 rail 出现「你 + 助手」两条、正文来自事件的流式增量 | 提交没接到 presenter、或右栏还在画样本会话 |
|
||
//! | `busy` 翻真 → 收敛后翻假 | 事件没回流、或流式标记没收敛(输入框会永远禁用) |
|
||
//! | 标记已读后未读归零 | `agent_mark_read` 没接、或未读口径没跟着 rail |
|
||
//! | 三种失败都给**具名**横幅 | 失败被吞掉(M104 的「静默无语」)|
|
||
|
||
use iced::widget::text_editor;
|
||
|
||
use unlucky_design::color::SchemeFlavor;
|
||
use unlucky_i18n::{Catalog, Key, Lang};
|
||
use unlucky_presenter::dto::{AgentEvent, AppEvent, ChatRole};
|
||
use unlucky_presenter::mock::MockBackend;
|
||
use unlucky_presenter::settings_bridge::SettingsBridge;
|
||
|
||
use iced::keyboard::key::{Code, Named, Physical};
|
||
use iced::keyboard::{key, Location, Modifiers};
|
||
|
||
use crate::app::state::presenter::transport::Script;
|
||
use crate::app::state::presenter::AgentHost;
|
||
use crate::app::Message;
|
||
use crate::cli::Options;
|
||
use crate::shell::agent::{Focus, Message as AgentMessage, RailState};
|
||
use crate::theme::Families;
|
||
|
||
use super::App;
|
||
|
||
const FAMILIES: Families = Families {
|
||
text: "Segoe UI",
|
||
display: "Segoe UI",
|
||
mono: "Consolas",
|
||
};
|
||
|
||
/// 不碰磁盘的 App(mock 后端 + 内存设置桥 + **本脚本的** presenter 句柄)。
|
||
fn app_with(script: Script) -> App {
|
||
let mut app = App::with_presenter(
|
||
Options::default(),
|
||
FAMILIES,
|
||
SchemeFlavor::Light,
|
||
Box::new(MockBackend::new()),
|
||
SettingsBridge::memory_only(),
|
||
);
|
||
app.agent_host = AgentHost::with_script(script);
|
||
// 换句柄 = 可见性还没写回去;`refresh_view` 是那条单点同步
|
||
app.refresh_view();
|
||
app
|
||
}
|
||
|
||
/// 走真消息提交(用户路径:填草稿 → 点发送)。
|
||
fn submit(app: &mut App, text: &str) {
|
||
app.agent.draft = text_editor::Content::with_text(text);
|
||
let _ = app.update(Message::Agent(AgentMessage::Submit));
|
||
}
|
||
|
||
/// **提交 → 事件回流 → 右栏出现这一轮 → 标记已读归零**(任务书第 2 条)。
|
||
///
|
||
/// 抓得住:①`Submit` 没接 `agent_submit`(rail 空);②事件没经 Message 通道回流(rail 只有半截);
|
||
/// ③`expand` 没调 `agent_mark_read`(未读不清);④右栏读的还是壳自己的副本。
|
||
/// 抓不住:气泡在像素上长什么样(那要出图,见 `DevDocs/artifacts/m119/`)。
|
||
#[test]
|
||
fn a_submitted_turn_lands_in_the_rail_and_reading_it_zeroes_unread() {
|
||
let mut app = app_with(Script::Qa);
|
||
// 出厂是折叠档 ⇒ presenter 认为「用户没在看着」,到达的助手消息应计未读
|
||
assert_eq!(app.agent.rail, RailState::Collapsed);
|
||
assert_eq!(app.agent_host.rail().unread, 0, "空会话没有未读");
|
||
|
||
submit(&mut app, "Which instance should I start?");
|
||
|
||
let rail = app.agent_host.rail();
|
||
assert_eq!(
|
||
rail.messages.len(),
|
||
2,
|
||
"一条「你」+ 一条助手(事件产生,不是本地回显)"
|
||
);
|
||
assert_eq!(rail.messages[0].role, ChatRole::User);
|
||
assert_eq!(rail.messages[0].text, "Which instance should I start?");
|
||
assert_eq!(rail.messages[1].role, ChatRole::Assistant);
|
||
assert!(
|
||
rail.messages[1].text.contains("Fabric"),
|
||
"正文来自流式增量:{:?}",
|
||
rail.messages[1].text
|
||
);
|
||
assert!(!rail.messages[1].streaming, "Qa 脚本会收敛");
|
||
assert!(!rail.busy, "收敛之后不忙(发送键要能再按)");
|
||
assert_eq!(rail.unread, 1, "折叠态到达的一条助手消息 = 一条未读");
|
||
|
||
// 右栏画的正是这份 rail(`pages/agent::spec` 直接借它)——构图不 panic
|
||
let _ = app.view();
|
||
|
||
// 打开栏 = 读过:`agent_mark_read`
|
||
let _ = app.update(Message::Agent(AgentMessage::Expand));
|
||
assert_eq!(app.agent_host.rail().unread, 0, "标记已读后未读归零");
|
||
assert_eq!(app.agent_host.rail().messages.len(), 2, "已读不改会话");
|
||
}
|
||
|
||
/// **busy 正确翻转**:流式中忙 → 后端收敛后不忙。
|
||
///
|
||
/// 抓得住:`busy` 还由壳自己算(那它不会跟 rail 走);`Events` 通道被当成「只追加消息」而丢掉
|
||
/// `MessageFinished`(输入框会永远禁用——这是最要命的坏法之一)。
|
||
#[test]
|
||
fn busy_flips_with_the_backend_events() {
|
||
let mut app = app_with(Script::Streaming);
|
||
submit(&mut app, "hello");
|
||
assert!(app.agent_host.rail().busy, "流式没收敛前输入要禁用");
|
||
|
||
// 后端收敛:一条 `MessageFinished` 经同一条 Message 通道回流
|
||
let index = app.agent_host.rail().messages.len() - 1;
|
||
let _ = app.update(Message::Agent(AgentMessage::Events(vec![AppEvent::Agent(
|
||
AgentEvent::MessageFinished { index },
|
||
)])));
|
||
assert!(!app.agent_host.rail().busy, "收敛之后必须能再发");
|
||
}
|
||
|
||
/// 写操作确认:rail 上有待确认 ⇒ 忙;裁决后收敛并记一条审计。
|
||
#[test]
|
||
fn a_pending_write_op_is_visible_and_decidable() {
|
||
let mut app = app_with(Script::WriteOp);
|
||
submit(&mut app, "把下载并发改小");
|
||
|
||
let rail = app.agent_host.rail();
|
||
let request = rail.confirm.as_ref().expect("要挂一个待确认请求");
|
||
assert_eq!(request.tool, "set_config");
|
||
assert!(
|
||
rail.busy,
|
||
"等确认也算忙(否则用户能在一轮写操作中间再发一句)"
|
||
);
|
||
let _ = app.view();
|
||
|
||
let _ = app.update(Message::Agent(AgentMessage::Confirm(true)));
|
||
assert!(app.agent_host.rail().confirm.is_none(), "裁决后清空");
|
||
assert!(!app.agent_host.rail().busy);
|
||
assert_eq!(app.agent_host.audit_len(), 1, "裁决要进审计(YOLO 下也是)");
|
||
}
|
||
|
||
/// 失败态①:**AI 端点不可达** ⇒ 具名横幅(不是静默,也不是把后端原文糊上屏)。
|
||
#[test]
|
||
fn an_unreachable_endpoint_shows_a_named_error() {
|
||
let mut app = app_with(Script::Unreachable);
|
||
submit(&mut app, "hello");
|
||
|
||
let failure = app.agent_failure().expect("提交失败必须给横幅");
|
||
// 文案跟着**生效语言**走(`LangState` 在这台机器上探到 zh-CN,硬编码英文会假红)
|
||
let effective = app.lang().catalog();
|
||
assert_eq!(failure.title, Key::AgentErrorTitle, "标题是 catalog 键");
|
||
assert_eq!(failure.body, effective.text(Key::AgentErrorUnavailable));
|
||
assert!(!failure.body.trim().is_empty());
|
||
assert!(
|
||
app.agent_host.rail().messages.is_empty(),
|
||
"失败不产生消息:不许假装有回复"
|
||
);
|
||
let _ = app.view();
|
||
}
|
||
|
||
/// 失败态②:**未实现路由** ⇒ 另一句具名错误(两种失败不能糊成一句)。
|
||
#[test]
|
||
fn an_unimplemented_route_shows_its_own_named_error() {
|
||
let mut app = app_with(Script::NotImplemented);
|
||
submit(&mut app, "hello");
|
||
|
||
let failure = app.agent_failure().expect("必须给横幅");
|
||
let effective = app.lang().catalog();
|
||
assert_eq!(failure.body, effective.text(Key::AgentErrorNotImplemented));
|
||
// 两条原因码要两句话(中英两套都得分开),不是一句万能错误
|
||
for lang in [Lang::En, Lang::ZhCn] {
|
||
let catalog = Catalog::of(lang);
|
||
assert_ne!(
|
||
catalog.text(Key::AgentErrorNotImplemented),
|
||
catalog.text(Key::AgentErrorUnavailable),
|
||
"{} 下两条原因码糊成一句了",
|
||
lang.tag()
|
||
);
|
||
}
|
||
}
|
||
|
||
/// 失败态③:**这一轮回复中途断流** ⇒ 横幅用 presenter 已脱敏的那句原因,已到的增量保留。
|
||
#[test]
|
||
fn a_turn_level_failure_keeps_the_partial_answer_and_the_reason() {
|
||
let mut app = app_with(Script::FailsMidStream);
|
||
submit(&mut app, "读一下日志");
|
||
|
||
let rail = app.agent_host.rail();
|
||
// 用户 + 助手(流式占位)+ 系统条目(`Failed` 会额外追加一条,错误也要能回翻)
|
||
assert_eq!(rail.messages.len(), 3, "断流不撤销已到的增量");
|
||
assert!(
|
||
rail.messages[1].text.contains("日志"),
|
||
"已到的增量要留着:{:?}",
|
||
rail.messages[1].text
|
||
);
|
||
assert!(!rail.busy, "断流要收敛掉流式标记");
|
||
let failure = app.agent_failure().expect("`Failed` 事件要立横幅");
|
||
assert!(
|
||
failure.body.contains("read timeout"),
|
||
"横幅正文是 presenter 脱敏后的原因:{}",
|
||
failure.body
|
||
);
|
||
}
|
||
|
||
/// 输入框的草稿会同步给 presenter(切形态不丢的语义在 presenter 那边)。
|
||
/// **P2-1**:默认配置下后端是脚本化 rig ⇒ 视图必须知道「这是模拟」(界面据此常驻标记)。
|
||
///
|
||
/// 抓得住:`is_simulated` 被写死成 `false`、或者视图拿不到这个事实(用户就会以为模型接上了)。
|
||
/// 抓不住:标记在像素上长什么样(那要出图,见 `DevDocs/artifacts/m119/08-*`)。
|
||
#[test]
|
||
fn the_default_backend_reports_itself_as_simulated() {
|
||
let app = app_with(Script::Qa);
|
||
assert!(
|
||
app.agent_host.is_simulated(),
|
||
"脚本化 rig 必须自报「模拟」,否则界面就能假装模型接上了"
|
||
);
|
||
// 空会话(最容易被误读成一屏)也要能拿到:视图入参 `Session.simulated` 就是它
|
||
let session = crate::shell::agent::Session {
|
||
agent: &app.agent,
|
||
rail: app.agent_host.rail(),
|
||
failure: app.agent_failure(),
|
||
simulated: app.agent_host.is_simulated(),
|
||
};
|
||
assert!(session.simulated);
|
||
}
|
||
|
||
#[test]
|
||
fn typing_syncs_the_draft_into_the_presenter() {
|
||
let mut app = app_with(Script::Qa);
|
||
app.agent.draft = text_editor::Content::with_text("half typed");
|
||
let _ = app.update(Message::Agent(AgentMessage::Edit(
|
||
text_editor::Action::Edit(text_editor::Edit::Insert('!')),
|
||
)));
|
||
assert_eq!(
|
||
app.agent_host.rail().draft,
|
||
app.agent.draft.text(),
|
||
"编辑器里是什么,rail 上就该是什么(同一个串,不是它自己的副本)"
|
||
);
|
||
assert!(app.agent_host.rail().draft.contains('!'), "改动真的进去了");
|
||
// 发送成功后草稿两边都清空
|
||
let _ = app.update(Message::Agent(AgentMessage::Submit));
|
||
assert!(app.agent.draft_is_blank(), "编辑器清空");
|
||
assert!(app.agent_host.rail().draft.is_empty(), "presenter 侧也清空");
|
||
}
|
||
|
||
/// 手搓一条按键事件(订阅层搬过来的就是它;`on_agent_key` 只看 `KeyPressed`)。
|
||
fn press(named: key::Named) -> iced::keyboard::Event {
|
||
let key = key::Key::Named(named);
|
||
iced::keyboard::Event::KeyPressed {
|
||
key: key.clone(),
|
||
modified_key: key,
|
||
physical_key: Physical::Code(Code::Enter),
|
||
location: Location::Standard,
|
||
modifiers: Modifiers::default(),
|
||
text: None,
|
||
repeat: false,
|
||
}
|
||
}
|
||
|
||
/// **P2-2 的回归闸**:不碰鼠标、只用键盘消息,能完成一次写操作确认。
|
||
///
|
||
/// 为什么这条重要:确认条原本只有鼠标路径,而待确认时 `busy` 锁着发送、`Stop` 又走主后端
|
||
/// (清不掉 presenter 的 pending)——键盘用户会在这一步**永久卡死**。
|
||
///
|
||
/// 抓得住:①焦点没主动落到确认条(`Enter` 够不着);②`Enter` / `Space` 没映射成 `Activate`;
|
||
/// ③`Tab` 环里没有确认条那两格;④裁决完焦点没收回输入框(下一句话打不进去)。
|
||
/// 抓不住:焦点环在像素上的样子(那要出图,见 `DevDocs/artifacts/m119/09-*`)。
|
||
#[test]
|
||
fn a_blocking_confirm_can_be_decided_with_the_keyboard_alone() {
|
||
let mut app = app_with(Script::WriteOp);
|
||
submit(&mut app, "把下载并发改小");
|
||
assert!(
|
||
app.agent_host.rail().confirm.is_some(),
|
||
"先要有一个待确认请求"
|
||
);
|
||
// ① 焦点主动落在「允许」(阻塞态),但**键盘闸不替用户打开**(复审 r2 / P2-2)
|
||
assert_eq!(
|
||
app.agent.focus,
|
||
Focus::ConfirmAllow,
|
||
"焦点要落在可操作元素上"
|
||
);
|
||
assert!(
|
||
!app.agent.keyboard_mode,
|
||
"塔台 r2:不许自动 arm——自动 arm 会让用户随手敲的空格变成「批准」"
|
||
);
|
||
|
||
// ② 第一次 Tab = 「我要用键盘」的表态:只点亮焦点环,**不移动**焦点
|
||
let _ = app.update(Message::Agent(AgentMessage::Tab { shift: false }));
|
||
assert!(app.agent.keyboard_mode, "Tab 之后才算 armed");
|
||
assert_eq!(
|
||
app.agent.focus,
|
||
Focus::ConfirmAllow,
|
||
"arming 的那一次不移动焦点(否则 Enter 会反着裁决)"
|
||
);
|
||
|
||
// ③ Enter = 允许
|
||
let _ = app.update(Message::Agent(AgentMessage::Key(press(Named::Enter))));
|
||
assert!(
|
||
app.agent_host.rail().confirm.is_none(),
|
||
"Enter 必须真的裁决掉"
|
||
);
|
||
assert_eq!(app.agent_host.audit_len(), 1, "裁决进审计");
|
||
assert_eq!(app.agent.focus, Focus::Composer, "裁决完焦点收回输入框");
|
||
assert!(!app.agent.keyboard_mode, "阻塞态结束就不再亮焦点环");
|
||
|
||
// ④ 第二轮走「Tab 点环 → Tab 走到拒绝 → Space 触发」:键盘用户不必背 Enter 是哪一个
|
||
submit(&mut app, "那把并发改回来");
|
||
assert_eq!(app.agent.focus, Focus::ConfirmAllow);
|
||
let _ = app.update(Message::Agent(AgentMessage::Tab { shift: false }));
|
||
let _ = app.update(Message::Agent(AgentMessage::Tab { shift: false }));
|
||
assert_eq!(app.agent.focus, Focus::ConfirmDeny, "Tab 要能走到「拒绝」");
|
||
let _ = app.update(Message::Agent(AgentMessage::Key(press(Named::Space))));
|
||
assert!(app.agent_host.rail().confirm.is_none(), "Space 也要能裁决");
|
||
assert_eq!(app.agent_host.audit_len(), 2);
|
||
}
|
||
|
||
/// **复审 r2 / P2-2 的反向回归**:没 armed 的时候,空格与回车都不许裁决写操作。
|
||
///
|
||
/// 复审用临时用例复现的路径:WriteOp 提交后(r1 无条件 arm),用户接着打下一句,第一个空格就成了
|
||
/// 「允许」。真实场景里编辑器仍持 iced 焦点,所以这条不是理论问题。
|
||
///
|
||
/// 抓得住:谁把 `sync_confirm_focus` 里的 `keyboard_mode = true` 加回来;谁把 `Space`/`Enter` 的
|
||
/// `Activate` 绕到 `keyboard_mode` 闸之外。
|
||
/// 抓不住:iced 组件树里空格到底有没有进输入框(那要真窗口,出图另算)——这里只断言「没裁决」。
|
||
#[test]
|
||
fn a_stray_space_or_enter_never_approves_a_pending_write() {
|
||
let mut app = app_with(Script::WriteOp);
|
||
submit(&mut app, "把下载并发改小");
|
||
assert!(app.agent_host.rail().confirm.is_some());
|
||
assert!(!app.agent.keyboard_mode, "待确认不该自动 arm");
|
||
|
||
// 用户接着打下一句:先敲一个字符(编辑器自己消化),再敲空格
|
||
let _ = app.update(Message::Agent(AgentMessage::Edit(
|
||
text_editor::Action::Edit(text_editor::Edit::Insert('a')),
|
||
)));
|
||
let _ = app.update(Message::Agent(AgentMessage::Key(press(Named::Space))));
|
||
assert!(
|
||
app.agent_host.rail().confirm.is_some(),
|
||
"未 armed 时空格不许批准写操作"
|
||
);
|
||
assert_eq!(app.agent_host.audit_len(), 0, "也不许留下审计");
|
||
|
||
// 回车同理:未 armed 时它归编辑器(发送键),不归确认条
|
||
let _ = app.update(Message::Agent(AgentMessage::Key(press(Named::Enter))));
|
||
assert!(
|
||
app.agent_host.rail().confirm.is_some(),
|
||
"未 armed 时回车不许批准写操作"
|
||
);
|
||
assert_eq!(app.agent_host.audit_len(), 0);
|
||
}
|