release: v0.2.4 里程碑快照(孤儿提交,无父;树 = dev 68362e5)
CI / fmt (rustfmt --check) (push) Waiting to run
CI / clippy (ubuntu-latest) (push) Waiting to run
CI / clippy (windows-latest) (push) Waiting to run
CI / test (ubuntu-latest) (push) Waiting to run
CI / test (windows-latest) (push) Waiting to run
CI / build release (ubuntu-latest) (push) Waiting to run
CI / build release (windows-latest) (push) Waiting to run
CI / runtime smoke (ubuntu x64, xvfb) (push) Waiting to run
CI / runtime smoke (macos arm64, probe) (push) Skipped
CI / update-e2e (windows x64) (push) Waiting to run
GPU evidence / GPU 判据自测(不需 GPU;跳过 ≠ 已验证) (push) Waiting to run
GPU evidence / GPU 证据 — 探测可用产物与硬件适配器 (push) Waiting to run
Guards / core 零 UI 守门 (push) Waiting to run
Guards / presenter 零 UI 守门 (push) Waiting to run
Guards / design 零第三方依赖守门 (push) Waiting to run
Guards / unsafe 落点守门(集合必须恰好等于期望) (push) Waiting to run
Guards / 文件行数 ≤400 守门 (push) Waiting to run
Guards / 依赖许可与来源(cargo-deny) (push) Waiting to run
Release guard / 发布标记语义守门(push tag → 稳定版) (push) Waiting to run
Signing status(播报,不是门禁) / 判据自测(三行/退出码/变异重放/接线) (push) Waiting to run
Signing status(播报,不是门禁) / 签名就绪度(播报) (push) Waiting to run
Release / 选槽位(默认四个:Windows 两档 + Linux 两档) (push) Waiting to run
CI / fmt (rustfmt --check) (push) Waiting to run
CI / clippy (ubuntu-latest) (push) Waiting to run
CI / clippy (windows-latest) (push) Waiting to run
CI / test (ubuntu-latest) (push) Waiting to run
CI / test (windows-latest) (push) Waiting to run
CI / build release (ubuntu-latest) (push) Waiting to run
CI / build release (windows-latest) (push) Waiting to run
CI / runtime smoke (ubuntu x64, xvfb) (push) Waiting to run
CI / runtime smoke (macos arm64, probe) (push) Skipped
CI / update-e2e (windows x64) (push) Waiting to run
GPU evidence / GPU 判据自测(不需 GPU;跳过 ≠ 已验证) (push) Waiting to run
GPU evidence / GPU 证据 — 探测可用产物与硬件适配器 (push) Waiting to run
Guards / core 零 UI 守门 (push) Waiting to run
Guards / presenter 零 UI 守门 (push) Waiting to run
Guards / design 零第三方依赖守门 (push) Waiting to run
Guards / unsafe 落点守门(集合必须恰好等于期望) (push) Waiting to run
Guards / 文件行数 ≤400 守门 (push) Waiting to run
Guards / 依赖许可与来源(cargo-deny) (push) Waiting to run
Release guard / 发布标记语义守门(push tag → 稳定版) (push) Waiting to run
Signing status(播报,不是门禁) / 判据自测(三行/退出码/变异重放/接线) (push) Waiting to run
Signing status(播报,不是门禁) / 签名就绪度(播报) (push) Waiting to run
Release / 选槽位(默认四个:Windows 两档 + Linux 两档) (push) Waiting to run
本批内容见 CHANGELOG.md 的 0.2.4 段。按 D41 用孤儿快照推 main,保留「公开史只有里程碑」的模型。
This commit is contained in:
commit
a5d1dec495
1580 files changed
+320938
No files matched your search
@@ -0,0 +1,352 @@
|
||||
//! **树测试①(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);
|
||||
}
|
||||
Reference in new issue
Block a user