Files
Unlucky-Launcher/crates/app-iced/src/app/update/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

353 lines
16 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.
//! **树测试①(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);
}