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)。
This commit is contained in:
AskaEth committed 2026-11-05 10:43:22 +08:00
1 parent a22baebe70
commit 0e48ea15b8
14 files changed
+332 -81

No files matched your search

+19 -7
View File
@@ -187,12 +187,16 @@ impl App {
})
}
/// 待确认请求出现 ⇒ 焦点直接落在「允许」并打开键盘巡航;消失 ⇒ 收回输入框。
/// 待确认请求出现 ⇒ 焦点直接落在「允许」;消失 ⇒ 收回输入框。
///
/// 为什么要**打开** `keyboard_mode`(而不是只挪焦点):待确认时 `busy` 锁着发送键,
/// `Stop` 又走主后端、清不掉 presenter 的 pending——键盘用户若够不着这两个按钮,这一轮对话
/// 就再也推不动(复审 P2-2 的原话)。所以这是一个**阻塞态**:焦点环主动亮出来,
/// 按 `Enter` 或 `Space` 就能裁决。
/// **只挪焦点,不替用户打开键盘巡航**(M119 r2 / 复审 P2-2):r1 在这里顺手把
/// `keyboard_mode` 置真,而 `sync_confirm_focus` 是 `on_agent` 的无条件尾巴、`Events` 又由心跳
/// 驱动——于是只要冒出待确认,全局键盘闸就被打开;用户发完那句话接着打下一句,第一个空格
/// 就成了「批准写操作」。现在那道闸只由真实的键盘表态打开(`Tab`,见 `AgentUi::step_focus`;
/// 或者 `on_agent_key` 把按键送进来时用户本来就在键盘巡航)。
///
/// 键盘用户怎么走到确认条:待确认时焦点已经在这两个按钮上,**按一次 `Tab` 点亮焦点环即是
/// 表态**(`step_focus` 里那条 arming 规则:这一次不移动焦点),之后 `Enter`/`Space` 就能裁决。
///
/// 唯一写入口:`on_agent` 末尾(任何会改动 rail 的分支之后都要经过它)。
fn sync_confirm_focus(&mut self) {
@@ -200,8 +204,7 @@ impl App {
let on_confirm = matches!(self.agent.focus, Focus::ConfirmAllow | Focus::ConfirmDeny);
if pending && !on_confirm {
self.agent.focus = Focus::ConfirmAllow;
self.agent.keyboard_mode = true;
eprintln!("[agent] 写操作待确认 → 焦点落到 Allow(Enter/Space 裁决)");
eprintln!("[agent] 写操作待确认 → 焦点落到 Allow(Tab 点亮焦点环后 Enter/Space 裁决)");
} else if !pending && on_confirm {
self.agent.focus = Focus::Composer;
self.agent.keyboard_mode = false;
@@ -258,6 +261,15 @@ impl App {
let _ = self.agent_host.submit(demo::DEMO_QUESTION.to_string());
self.drain_agent();
}
Fixture::Armed => {
// 先跑一轮(写操作脚本会挂一个待确认),再按一次 `Tab`:
// 待确认时这一下只 arm、不移动焦点(见 `AgentUi::step_focus` 的 arming 规则)
let _ = self.agent_host.submit(demo::DEMO_QUESTION.to_string());
self.drain_agent();
let rail = self.agent_host.rail();
let (busy, pending) = (rail.busy, rail.confirm.is_some());
self.agent.step_focus(false, busy, pending);
}
Fixture::Focused => {
let rail = self.agent_host.rail();
let (busy, pending) = (rail.busy, rail.confirm.is_some());
+52 -4
View File
@@ -275,15 +275,27 @@ fn a_blocking_confirm_can_be_decided_with_the_keyboard_alone() {
app.agent_host.rail().confirm.is_some(),
"先要有一个待确认请求"
);
// ① 焦点主动落在「允许」,并且键盘巡航已经打开(否则 Enter 那道闸过不去)
// ① 焦点主动落在「允许」(阻塞态),但**键盘闸不替用户打开**(复审 r2 / P2-2)
assert_eq!(
app.agent.focus,
Focus::ConfirmAllow,
"焦点要落在可操作元素上"
);
assert!(app.agent.keyboard_mode, "阻塞态要主动亮出焦点环");
assert!(
!app.agent.keyboard_mode,
"塔台 r2:不许自动 arm——自动 arm 会让用户随手敲的空格变成「批准」"
);
// ② Enter = 允许
// ② 第一次 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(),
@@ -293,12 +305,48 @@ fn a_blocking_confirm_can_be_decided_with_the_keyboard_alone() {
assert_eq!(app.agent.focus, Focus::Composer, "裁决完焦点收回输入框");
assert!(!app.agent.keyboard_mode, "阻塞态结束就不再亮焦点环");
// ③ 第二轮走「Tab 到拒绝 + Space 触发」:键盘用户不必背 Enter 是哪一个
// ④ 第二轮走「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);
}
@@ -56,7 +56,7 @@ use crate::app::components::interaction::Interaction;
use crate::theme::Ui;
use super::state::{Focus, RailState};
use super::{Failure, Message};
use super::{Disclaimer, Failure, Message};
mod banners;
mod composer;
@@ -90,11 +90,12 @@ pub struct Conversation<'a> {
pub confirm: Option<&'a ConfirmRequest>,
/// 失败横幅(`Some` = 画具名错误;见 [`Failure`])。
pub failure: Option<Failure<'a>>,
/// 这套后端是不是模拟的(`true` ⇒ 标题旁常驻「模拟」标记 + 副标题说清楚)。
/// 「这是模拟助手」的**四处文案**(副标题 / 占位符 / 空态正文 / 标题胶囊),
/// 全部由 [`Disclaimer::of`] 一次算好——视图里没有第二种取词路径。
///
/// M119 r1 / 复审 P2-1:生产默认后端是脚本化 rig,而界面上曾**没有任何模拟标识**——
/// 用户敲一句话得到一段像是模型编的回答。这个字段就是那个事实的渲染入口(不是硬编码)。
pub simulated: bool,
/// M119 r1 / r2 复审 P2-1:生产默认后端是脚本化 rig,界面上曾**没有任何模拟标识**;
/// 上一轮又只有标题胶囊认这个开关,另外三处会反方向地留在「没接真实模型」上。
pub note: Disclaimer<'a>,
/// 三档状态(决定头部画哪组动作)。
pub rail: RailState,
/// 宿主。
@@ -119,7 +120,7 @@ pub fn view<'a>(ui: &'a Ui, spec: Conversation<'a>, catalog: Catalog) -> Element
}
body = body.push(if spec.messages.is_empty() {
transcript::empty(ui, catalog)
transcript::empty(ui, catalog, spec.note.empty_body)
} else {
transcript::list(ui, spec, catalog)
});
@@ -148,7 +149,7 @@ fn header<'a>(ui: &'a Ui, spec: Conversation<'a>, catalog: Catalog) -> Element<'
.spacing(ui.space(Space::X2))
.align_y(iced::Alignment::Center);
// 常驻标记:与标题同一行,不点开、不 hover、不藏在设置里(P2-1 的判据)
if spec.simulated {
if spec.note.simulated {
title = title.push(simulated_chip(ui, catalog));
}
title = title.push(unread_badge(ui, spec.unread, catalog));
@@ -159,7 +160,7 @@ fn header<'a>(ui: &'a Ui, spec: Conversation<'a>, catalog: Catalog) -> Element<'
ui,
TypeRole::LabelMedium,
false,
catalog.text(Key::AgentSubtitle),
spec.note.subtitle,
ui.color(Role::OnSurfaceVariant),
Length::Fill,
),
@@ -37,7 +37,7 @@ pub(super) fn composer<'a>(
let style = ui.style(TypeRole::BodyMedium);
let field = editor(spec.draft)
.placeholder(catalog.text(Key::AgentPlaceholder))
.placeholder(spec.note.placeholder)
.on_action(Message::Edit)
.key_binding(binding_for)
.size(style.size_px)
@@ -28,14 +28,12 @@ use super::super::Message;
use super::{text_role, text_role_owned, Conversation};
/// 空会话:引导空态(零条消息时替代气泡列表)。
pub(super) fn empty<'a>(ui: &'a Ui, catalog: Catalog) -> Element<'a, Message> {
///
/// `body` 由调用方从 [`super::super::Disclaimer`] 取(模拟态/真实态两句话),这里不再自己取词。
pub(super) fn empty<'a>(ui: &'a Ui, catalog: Catalog, body: &'a str) -> Element<'a, Message> {
container(empty_state(
ui,
EmptyState::new(
IconKind::Ai,
catalog.text(Key::AgentEmptyTitle),
catalog.text(Key::AgentEmptyBody),
),
EmptyState::new(IconKind::Ai, catalog.text(Key::AgentEmptyTitle), body),
))
.width(Length::Fill)
.height(Length::Fill)
+6
View File
@@ -41,6 +41,11 @@ pub enum Fixture {
Unread,
/// 键盘巡航一次的样子(焦点环 = 10% 状态层)。
Focused,
/// 跑一轮真提交,然后**按一次 `Tab`**(M119 r2 出图用)。
///
/// 复现的是键盘用户走到确认条的路径:待确认出现 ⇒ 焦点已落在「允许」但**键盘闸没开**
/// (图 `05`,看不到环);按一次 `Tab` 才算表态(图 `09`,环亮出来且焦点不动)。
Armed,
}
/// 夹具里的那句用户消息(**演示数据**,不是界面文案:它跟实例名一样属于样本内容,
@@ -86,6 +91,7 @@ pub fn from_env() -> Option<(AgentUi, Fixture)> {
Some("turn") => Fixture::Turn,
Some("unread") => Fixture::Unread,
Some("focused") => Fixture::Focused,
Some("armed") => Fixture::Armed,
Some("empty") | None => Fixture::None,
Some(other) => {
eprintln!("[agent] {FIXTURE_ENV}={other:?} 不是夹具之一,按 empty 处理");
+69 -17
View File
@@ -126,6 +126,53 @@ pub struct Failure<'a> {
pub body: &'a str,
}
/// 「这是模拟助手」的**四处文案**(M119 r2 / 复审 P2-1)。
///
/// ## 为什么是一个结构体而不是四个 `if`
///
/// 上一轮只有标题胶囊由 `simulated` 驱动,另外三处(副标题 / 输入框占位符 / 空态正文)是
/// **无条件取词**,而它们的译文里写着「模拟 / 没接真实模型」——接上真 transport 之后,这三句会
/// 反方向地留着(`simulated == false` 时界面上还在说「没接真实模型」)。复审把这条列为 P2。
///
/// 现在四处**只认这一个开关**:`Disclaimer::of(catalog, simulated)` 一次算好,视图没有第二种
/// 取词路径。`pages/agent/tests/disclaimer.rs` 钉住「四处一起出现 / 一起消失」。
#[derive(Debug, Clone, Copy)]
pub struct Disclaimer<'a> {
/// 这套后端是不是模拟的(标题旁的胶囊只在 `true` 时画)。
pub simulated: bool,
/// 标题下的副标题。
pub subtitle: &'a str,
/// 输入框占位符。
pub placeholder: &'a str,
/// 空会话正文。
pub empty_body: &'a str,
}
impl<'a> Disclaimer<'a> {
/// 按 `simulated` 选模拟态或真实态那一套文案。
#[must_use]
pub fn of(catalog: Catalog, simulated: bool) -> Self {
Self {
simulated,
subtitle: if simulated {
catalog.text(Key::AgentSubtitle)
} else {
catalog.text(Key::AgentSubtitleReal)
},
placeholder: if simulated {
catalog.text(Key::AgentPlaceholder)
} else {
catalog.text(Key::AgentPlaceholderReal)
},
empty_body: if simulated {
catalog.text(Key::AgentEmptyBody)
} else {
catalog.text(Key::AgentEmptyBodyReal)
},
}
}
}
/// 两个宿主共用的**会话入参**(`Session` = 从 presenter 借来的那一份 + 两个展示事实)。
///
/// 为什么是一个结构体而不是继续加形参:`docked`/`page` 已经收到 6 个形参,这一轮还要加
@@ -139,7 +186,8 @@ pub struct Session<'a> {
pub rail: &'a AgentRail,
/// 失败横幅的展示输入。
pub failure: Option<Failure<'a>>,
/// 这套后端是不是模拟的(M119 r1 / 复审 P2-1)。`true` ⇒ 头部常驻「模拟」标记。
/// 这套后端是不是模拟的(M119 r1 / 复审 P2-1)。`true` ⇒ 头部常驻「模拟」标记,
/// 且三处文案走模拟态(见 [`Disclaimer`]:**四处一起变**,只认这一个开关)。
pub simulated: bool,
}
@@ -172,7 +220,7 @@ pub fn page<'a>(
) -> Element<'a, Message> {
let metrics = layout::frame_metrics(pages::PaneKit::Single, window_width);
let body = conversation::view(ui, spec(session, Host::Page), catalog);
let body = conversation::view(ui, spec(session, catalog, Host::Page), catalog);
container(
container(body)
@@ -230,19 +278,23 @@ fn panel<'a>(
) -> Element<'a, Message> {
let width = session.agent.rail_width(window_width);
let body = container(conversation::view(ui, spec(session, Host::Rail), catalog))
.width(Length::Fixed(width))
.height(Length::Fill)
.padding(Padding {
top: ui.space(Space::X6),
right: ui.space(Space::X4),
bottom: ui.space(Space::X4),
left: ui.space(Space::X4),
})
.style(move |_theme| container::Style {
background: Some(Background::Color(ui.layer(MaterialLayer::Canvas))),
..container::Style::default()
});
let body = container(conversation::view(
ui,
spec(session, catalog, Host::Rail),
catalog,
))
.width(Length::Fixed(width))
.height(Length::Fill)
.padding(Padding {
top: ui.space(Space::X6),
right: ui.space(Space::X4),
bottom: ui.space(Space::X4),
left: ui.space(Space::X4),
})
.style(move |_theme| container::Style {
background: Some(Background::Color(ui.layer(MaterialLayer::Canvas))),
..container::Style::default()
});
row![splitter(ui, width, catalog), body]
.width(Length::Shrink)
@@ -300,7 +352,7 @@ fn splitter<'a>(ui: &'a Ui, width: f32, catalog: Catalog) -> Element<'a, Message
}
/// 会话视图的入参(**唯一**的组装点:两个宿主都走这里,不许各拼一份)。
fn spec<'a>(session: Session<'a>, host: Host) -> Conversation<'a> {
fn spec<'a>(session: Session<'a>, catalog: Catalog, host: Host) -> Conversation<'a> {
Conversation {
// 会话内容、未读、busy、待确认:**全部**来自 presenter(M119)
messages: &session.rail.messages,
@@ -308,7 +360,7 @@ fn spec<'a>(session: Session<'a>, host: Host) -> Conversation<'a> {
unread: session.rail.unread,
confirm: session.rail.confirm.as_ref(),
failure: session.failure,
simulated: session.simulated,
note: Disclaimer::of(catalog, session.simulated),
draft: &session.agent.draft,
blank: session.agent.draft_is_blank(),
rail: session.agent.rail,
+12 -1
View File
@@ -300,9 +300,20 @@ impl AgentUi {
}
/// Tab 走一格(走一次就把键盘巡航打开)。`busy` / `confirm_pending` 都由 presenter 的 rail 给。
///
/// **待确认的那一次 Tab 只点亮焦点环、不移动焦点**:待确认出现时 app 层已经把焦点放到
/// 「允许」上(阻塞态,见 `App::sync_confirm_focus`),但**不会**替用户把键盘巡航打开
/// (M119 r2 / 复审 P2-2:自动 arm 会让用户随手敲的空格变成「批准」)。所以第一次 `Tab`
/// 是「我要用键盘」的显式表态,此时焦点原地不动——否则第一次 Tab 会把「允许」挪到「拒绝」,
/// 用户按 `Enter` 就是反着裁决。
pub fn step_focus(&mut self, shift: bool, busy: bool, confirm_pending: bool) {
self.focus = self.focus.step(shift, self.rail, busy, confirm_pending);
let arming = !self.keyboard_mode
&& confirm_pending
&& matches!(self.focus, Focus::ConfirmAllow | Focus::ConfirmDeny);
self.keyboard_mode = true;
if !arming {
self.focus = self.focus.step(shift, self.rail, busy, confirm_pending);
}
}
}
+12 -3
View File
@@ -14,6 +14,7 @@ use unlucky_presenter::dto::{ChatMessage, ChatRole, ConfirmRequest, Sensitivity}
use crate::theme::{Families, Ui};
mod disclaimer;
mod drag;
use super::conversation::{Conversation, Host};
@@ -317,13 +318,21 @@ fn the_spec_borrows_the_presenter_rail_verbatim() {
failure: None,
simulated: true,
};
let spec = super::spec(session, Host::Rail);
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.simulated, "模拟事实要原样传给视图(标记不是硬编码)");
assert!(
spec.note.simulated,
"模拟事实要原样传给视图(标记不是硬编码)"
);
assert!(
spec.note.subtitle.to_lowercase().contains("simulated"),
"副标题也归这个开关管:{}",
spec.note.subtitle
);
}
/// 两个宿主都能构图(含空会话 / 有会话 / 流式中 / 待确认 / 失败横幅),且吃 880~2560 的宽度。
@@ -376,7 +385,7 @@ fn every_state_builds_in_both_hosts() {
blank: agent.draft_is_blank(),
confirm: rail.confirm.as_ref(),
failure: shown,
simulated: true,
note: Disclaimer::of(catalog, true),
rail: agent.rail,
host: Host::Rail,
focus: agent.keyboard_mode.then_some(agent.focus),
@@ -0,0 +1,96 @@
//! 「这是模拟助手」的四处文案必须**一起**变(M119 r2 / 复审 P2-1 的验收标准)。
//!
//! ## 为什么这个文件存在
//!
//! 复审 r2 的 P2-1 抓得很准:`simulated` 是真字段,但 r1 只有**标题胶囊**一个消费者,另外三处
//! (副标题 / 输入框占位符 / 空态正文)是**无条件取词**,而它们的译文里写着「模拟 / 没接真实模型」。
//! 于是「接上真 transport 之后四处一起消失」只是一句声明——没有测试钉住它。
//!
//! 现在四处都从 [`Disclaimer`] 取词(`Disclaimer::of(catalog, simulated)`),本文件就是那句声明的
//! 证据:`simulated == true` 四处都提模拟;`simulated == false` 四处**都不提**。
//!
//! 抓得住:谁把某一处改回无条件取词(那就会出现「字段说真、界面说模拟」的反向假话)。
//! 抓不住:文案本身翻得好不好读(那要人看),也不管视图有没有真的用这几个字段
//! (那是 `tests.rs::the_spec_borrows_the_presenter_rail_verbatim` 与出图的活)。
use unlucky_i18n::{Catalog, Key, Lang};
use super::super::Disclaimer;
/// 这句话里有没有「模拟 / 没接真实模型」的意思(中英各一套写法)。
fn mentions_simulated(text: &str) -> bool {
let lower = text.to_lowercase();
lower.contains("simulated") || lower.contains("no real model") || text.contains("模拟")
}
/// **四处一起出现**(`simulated == true`):副标题 / 占位符 / 空态正文 / 标题胶囊。
#[test]
fn all_four_disclaimers_appear_when_the_backend_is_simulated() {
for lang in [Lang::En, Lang::ZhCn] {
let catalog = Catalog::of(lang);
let note = Disclaimer::of(catalog, true);
assert!(note.simulated, "{}:开关要传下去", lang.tag());
for (place, text) in [
("副标题", note.subtitle),
("输入框占位符", note.placeholder),
("空态正文", note.empty_body),
// 第四处是标题胶囊:只在 `simulated` 时渲染,文案来自这条 key
("标题胶囊", catalog.text(Key::AgentSimulated)),
] {
assert!(
mentions_simulated(text),
"{} 的{place}没有说清「这是模拟」:{text:?}",
lang.tag()
);
}
}
}
/// **四处一起消失**(`simulated == false`):三处文案都不许再提模拟,胶囊也不该被画。
///
/// 这条就是复审要的那一句验收:接上真 transport 那天,界面不会再反向地声明「没接真实模型」。
#[test]
fn no_disclaimer_survives_when_a_real_backend_is_wired() {
for lang in [Lang::En, Lang::ZhCn] {
let catalog = Catalog::of(lang);
let note = Disclaimer::of(catalog, false);
assert!(!note.simulated, "{}:开关要传下去", lang.tag());
for (place, text) in [
("副标题", note.subtitle),
("输入框占位符", note.placeholder),
("空态正文", note.empty_body),
] {
assert!(
!mentions_simulated(text),
"{} 的{place}在真实态还在说模拟:{text:?}",
lang.tag()
);
}
// 真实态的三句也得是**有内容的话**,不能靠空串糊过去
for (place, text) in [
("副标题", note.subtitle),
("输入框占位符", note.placeholder),
("空态正文", note.empty_body),
] {
assert!(!text.trim().is_empty(), "{} 的{place}是空的", lang.tag());
}
}
}
/// 两态必须是**两套不同的文案**(同一个 key 两边都用就退化成「永远说模拟」或「永远不说」)。
#[test]
fn the_two_states_use_different_keys() {
let catalog = Catalog::of(Lang::En);
assert_ne!(
catalog.text(Key::AgentSubtitle),
catalog.text(Key::AgentSubtitleReal)
);
assert_ne!(
catalog.text(Key::AgentPlaceholder),
catalog.text(Key::AgentPlaceholderReal)
);
assert_ne!(
catalog.text(Key::AgentEmptyBody),
catalog.text(Key::AgentEmptyBodyReal)
);
}
+4 -2
View File
@@ -88,8 +88,10 @@ pub(crate) struct AgentHost {
/// 这套后端是不是**模拟**的(M119 r1 / 复审 P2-1)。
///
/// 今天恒 `true`:transport 只有 `RigTransport` 一种(脚本化,没有真模型)。接真 `AgentTransport`
/// 的那一天,这里传 `false`,界面上的「模拟」标记会自动消失——**标记不是硬编码在视图里的**,
/// 它是这个事实字段的渲染。
/// 的那一天,这里传 `false`——**四处「模拟」措辞会一起消失**(标题胶囊 / 副标题 / 输入框占位符 /
/// 空态正文,全部出自 `pages::agent::Disclaimer::of(catalog, simulated)`)。
/// 标记不是硬编码在视图里的,它是这个事实字段的渲染;「四处一起变」由
/// `pages/agent/tests/disclaimer.rs` 钉住(r1 时只有一处认它,那句声明是空的)。
simulated: bool,
queue: EventQueue,
failure: FailureSlot,
+33
View File
@@ -126,6 +126,39 @@ mod logs;
mod nav;
mod settings;
/// 拆分前的 `key -> (en, zh-CN)` **逐字快照**(金表)。
///
/// ## 它是什么
///
/// M99 把 `catalog.rs` 的单张条目表按域拆到 `catalog/<域>.rs` 之后,这 72 行就是「拆分前那一份」
/// 的原样导出:由拆分前的 `Catalog::of(En)` / `Catalog::of(ZhCn)` 逐条 `println!` 出来,
/// 再原封不动抄进来(生成方式见 `tests.rs` 里 `split_keeps_every_translation` 的注释)。
/// M108 在它上面改了 3 条(`NavDownload` / `DownloadTitle` / `DownloadSubtitle`)、追加了 1 条
/// (`NavDownloadExpanded`);M105/M106/M107 各自追加的条目也都在,整表现在是 138 条。
///
/// ## 为什么不是「数条数」
///
/// 条数相等抓不住任何一种真会发生的坏法:某条译文在搬移时被漏掉、另一条被复制两次,条数照旧。
/// 这张表比的是**全量、逐字节、按声明顺序**的三元组,于是下面四种坏法都会红:
///
/// | 坏法 | 红在哪 |
/// |---|---|
/// | 某个 key 整条被搬丢 | 条数不等 / 该行 key 名对不上 |
/// | 两条 key 的译文互抄(同 key 被覆盖到别的译文) | 该行 en 或 zh 不等 |
/// | 某条译文改了一个字符 | 该行不等(`assert_eq!` 带原文) |
/// | 域的顺序被调换(`Key` 的 `Ord` 与导出表顺序都靠它) | 该行 key 名对不上 |
///
/// **抓不住**:译文本身翻得对不对(金表是「拆分前」的快照,它只能证明「没变」)。
///
/// ## M108 更新了三条、新增了一条(版本管理改名)
///
/// 用户令(2026-11-04)把这页的显示名从「下载 / Downloads」改成「版本管理 / Version management」:
/// [`Key::NavDownload`] / [`Key::DownloadTitle`] / [`Key::DownloadSubtitle`]
/// 三条译文按令改掉,并**新增** [`Key::NavDownloadExpanded`](240 展开态的全标签,
/// 88 轨放不下 18 字符的英文,见 `catalog/nav.rs` 的「两条 key」一节)。
///
/// 其余各行仍逐字节等于拆分前那份快照——也就是说这张表现在证明的是「除这几条按令改过的,
/// 其余一字未动」,不再是「整表一字未动」。
#[cfg(test)]
mod golden;
+9
View File
@@ -50,6 +50,15 @@ macro_rules! agent_entries {
AgentConfirmDeny => ("Deny", "拒绝"),
// 头部常驻小标记(P2-1):与副标题/占位符互为冗余,三个位置都能看到「这是模拟」
AgentSimulated => ("Simulated", "模拟"),
// ── M119 r2:三处文案的**真实态**配对(模拟态用上面那三条已有的 key)
//
// 复审 r2 的 P2-1:上一轮只有标题胶囊由 `simulated` 驱动,副标题/占位符/空态正文是
// 无条件取词,而那三条译文里写着「模拟 / 没接真实模型」——接上真 transport 之后
// 这三句会**反方向**地留着。现在配成对:`simulated == true` 走模拟态,
// `false` 走这三条中性话(`pages/agent/tests/disclaimer.rs` 钉住四处一起变)。
AgentSubtitleReal => ("Conversation", "会话"),
AgentPlaceholderReal => ("Message the assistant…", "给助手发消息…"),
AgentEmptyBodyReal => ("Ask about instances, downloads or logs.", "可以问实例、下载或日志。"),
} }
};
}
+6 -32
View File
@@ -1,36 +1,7 @@
//! 拆分前的 `key -> (en, zh-CN)` **逐字快照**(金表)。
//! 金表的**表数据**(`Key::ALL` 顺序 × (en, zh-CN) 的逐字快照)。
//!
//! ## 它是什么
//!
//! M99 把 `catalog.rs` 的单张条目表按域拆到 `catalog/<域>.rs` 之后,这 72 行就是「拆分前那一份」
//! 的原样导出:由拆分前的 `Catalog::of(En)` / `Catalog::of(ZhCn)` 逐条 `println!` 出来,
//! 再原封不动抄进来(生成方式见 `catalog/tests.rs` 里 `split_keeps_every_translation` 的注释)。
//! M108 在它上面改了 3 条(`NavDownload` / `DownloadTitle` / `DownloadSubtitle`)、追加了 1 条
//! (`NavDownloadExpanded`);M105/M106/M107 各自追加的条目也都在,整表现在是 138 条。
//!
//! ## 为什么不是「数条数」
//!
//! 条数相等抓不住任何一种真会发生的坏法:某条译文在搬移时被漏掉、另一条被复制两次,条数照旧。
//! 这张表比的是**全量、逐字节、按声明顺序**的三元组,于是下面四种坏法都会红:
//!
//! | 坏法 | 红在哪 |
//! |---|---|
//! | 某个 key 整条被搬丢 | 条数不等 / 该行 key 名对不上 |
//! | 两条 key 的译文互抄(同 key 被覆盖到别的译文) | 该行 en 或 zh 不等 |
//! | 某条译文改了一个字符 | 该行不等(`assert_eq!` 带原文) |
//! | 域的顺序被调换(`Key` 的 `Ord` 与导出表顺序都靠它) | 该行 key 名对不上 |
//!
//! **抓不住**:译文本身翻得对不对(金表是「拆分前」的快照,它只能证明「没变」)。
//!
//! ## M108 更新了三条、新增了一条(版本管理改名)
//!
//! 用户令(2026-11-04)把这页的显示名从「下载 / Downloads」改成「版本管理 / Version management」:
//! [`super::Key::NavDownload`] / [`super::Key::DownloadTitle`] / [`super::Key::DownloadSubtitle`]
//! 三条译文按令改掉,并**新增** [`super::Key::NavDownloadExpanded`](240 展开态的全标签,
//! 88 轨放不下 18 字符的英文,见 `catalog/nav.rs` 的「两条 key」一节)。
//!
//! 其余各行仍逐字节等于拆分前那份快照——也就是说这张表现在证明的是「除这几条按令改过的,
//! 其余一字未动」,不再是「整表一字未动」。
//! 「为什么有这么一张表、它抓得住什么」写在 `catalog.rs` 中 `mod golden;` 的文档里——
//! M119 r2 把它搬到那儿,是为了给这张表腾出追加新 key 的行数(400 行硬线),一个字的说明都没删。
/// 拆分前 `Key::ALL` 的顺序 × (en, zh-CN)。第一列是变体名(`Key::name()` 的返回值)。
pub(super) const GOLDEN: &[(&str, &str, &str)] = &[
@@ -150,6 +121,9 @@ pub(super) const GOLDEN: &[(&str, &str, &str)] = &[
("AgentConfirmAllow", "Allow", "允许"),
("AgentConfirmDeny", "Deny", "拒绝"),
("AgentSimulated", "Simulated", "模拟"),
("AgentSubtitleReal", "Conversation", "会话"),
("AgentPlaceholderReal", "Message the assistant…", "给助手发消息…"),
("AgentEmptyBodyReal", "Ask about instances, downloads or logs.", "可以问实例、下载或日志。"),
// ── M106 `I18NFOLLOW`:General 分组真控件(语言选择器 + 其余三行)。**只追加在链尾**,
// 上一行到 `LogsEmptyAction` 的既有顺序一字未动(金表要证明的正是这件事)。
("SettingsLanguageFollowSystem", "Follow system", "跟随系统"),