按独立复审 `reviewer-updategui-r1` 的 p1-1 / fix-then-merge 返工。
P1-1(挡门)
- `crates/cli/src/cli/parse.rs` 406 → **382** 行:`fn parse_update` 搬到
`crates/cli/src/cli/parse/update.rs`(与 `parse/{accounts,agent,instances}.rs` 同形状)。
全仓 `crates/**/*.rs` 最大仍是 400(`design/tests/card_neutral.rs` 等三处,未越线)。
上一轮报告里「所有改动文件都在 400 行内」那句是错的,已在 r2 报告里更正。
P3-6 `Job::apply` 不查包形态
- 新增 `self_replaceable(format)`(只有 `PortableExe` 允许)+ `ensure_self_replaceable`,
**挡在取包之前**;错误分类 `InvalidManifest`(发布侧挂错,换源/重试无用)。
比复审建议的 `needs_installer()` 更严:`portable-zip`/`appimage` 同样不能原地当 exe。
- 用例:`crates/core/src/update/tests/job_cases.rs`(msi/zip 被拒 + 目标零改动 + 暂存目录
没被建 + 便携 exe 整环成功 + Nothing 不碰目标 + 闸门的源码锚点)。
P3-7 `ULL_UPDATE_DIR` 生产可用
- `default_source` 改走 `local_dir_override()`:`if !cfg!(debug_assertions) { return None }`,
发布构建里整段被常量折叠掉(这条路径在 release 二进制里不存在)。判据与三档取舍写在
`job.rs` 的文档表里(为什么不用 CLI 参数、为什么不用运行期开关)。
P3-1「忽略这个版本」语义
- `UpdateView.dismissed: bool` → `skipped: Option<String>`(记**版本号**):忽略 0.2.3 之后
同一次运行里冒出 0.2.4 **照常提示**;手动检查清空(`begin_check(manual)`),静默检查不清。
文案保持「忽略这个版本」。presenter / app 侧视图模型 / 两处用例同步。
P3-3 CLI `--json` 失败信封缺结构化分类
- `CliError` 加 `detail`(`Option<Box<Value>>`,装箱避免 `result_large_err`)+ `with_detail`,
`to_json` 输出 `error.detail`;`commands/update.rs` 的 failure 填
`{"kind": core 分类 id, "retryable": 可重试位}`。
- 上一轮报告里引的 `{"kind":"digest_mismatch"}` 形状确实不存在(那是 CLI 的 user/internal),
r2 报告换成了实机 dump。
P3-4 update 命令没有解析测试
- `crates/cli/tests/parse_test.rs`:`update_subcommands`(check / apply / `--dest` 两种写法)
+ `update_usage_errors`(无子命令 / 未知子命令 / 多余位置参数 / 缺少取值 / 未知选项);
`help_text_covers_every_command` 的 topic 列表补 `"update"`。
P3-5 `install.rs` 模块文档与实测矛盾
- 改成「可以改名、不能覆盖/删除」,写明 M159 在 Windows 真机验过运行中的 exe 自替换,
并提醒别再照旧文档去设计「退出自身 → 另一个进程搬运」的双进程方案。
顺手(复审 P3-2/P3-8 里低成本的两条)
- 设置页补「下载中」态(`SettingsUpdateApplying` 不再是死 key,下载期间不画「下载并安装」);
渠道显示改读 `update.channel`(`stable` 走 key,其它原样);删掉从未使用的
`UpdateBannerRestartLater`(catalog + 金表同步,29 条 key 全部有引用)。
- `adopt` 只让**检查**刷新 `checked_at`(应用回报不再冒充「上次检查」);
`UpdateView::default()` 的 `current_version` 取本进程版本号(mock 下不再空白)。
门禁(`CARGO_TARGET_DIR=C:/Temp/ull-target/wt-159`):fmt 绿、clippy -D warnings 绿、
`cargo test --workspace --all-targets --no-fail-fast -j 4` **exit 0**(51 个测试二进制全 ok,
`core::addon::pack` 未被 skip)。该族用例复现了复审记的泄漏:一次全量在 `/tmp` 留 72 个
`ull-pack-*`(`/tmp` 149M → 8853M),跑完已 `rm -rf` 清回 150M。
158 lines
5.7 KiB
Rust
158 lines
5.7 KiB
Rust
//! presenter 的更新快照 → 页面视图模型(M159 的**唯一**展示转换点)。
|
||
//!
|
||
//! 与 `view/instances.rs` 同一条分工:判断(要不要提示、是不是失败)在 presenter / core 已经做完,
|
||
//! 这里只把字段摆成「要画什么」。三条映射必须与 DTO 的判据保持一致:
|
||
//!
|
||
//! | DTO | 页面视图 |
|
||
//! |---|---|
|
||
//! | `UpdateOutcome::UpToDate` | [`UpdateStatus::UpToDate`](**不是**失败) |
|
||
//! | `UpdateOutcome::Failed` | [`UpdateStatus::Failed`](带原因,**不许**画成「已是最新」) |
|
||
//! | `UpdateOutcome::MissingArtifact` | [`UpdateStatus::MissingArtifact`](发布侧漏项,单独一句话) |
|
||
|
||
use unlucky_presenter::dto::{UpdateOutcome, UpdateView};
|
||
|
||
use crate::pages::{Update, UpdateStatus};
|
||
|
||
/// 快照 → 视图模型。
|
||
pub(super) fn update_of(view: &UpdateView) -> Update {
|
||
Update {
|
||
current_version: view.current_version.clone(),
|
||
channel: view.channel.clone(),
|
||
auto_check: view.auto_check,
|
||
status: status_of(&view.outcome),
|
||
checked_at: view.checked_at.clone(),
|
||
applying: view.applying,
|
||
ready: view.ready.as_ref().map(|ready| ready.version.clone()),
|
||
skipped: view.skipped.clone(),
|
||
silent: view.silent,
|
||
}
|
||
}
|
||
|
||
/// 结论 → 页面状态(四种结论一一对应,**不折叠**)。
|
||
fn status_of(outcome: &UpdateOutcome) -> UpdateStatus {
|
||
match outcome {
|
||
UpdateOutcome::Idle => UpdateStatus::Idle,
|
||
UpdateOutcome::Checking => UpdateStatus::Checking,
|
||
UpdateOutcome::UpToDate { latest } => UpdateStatus::UpToDate {
|
||
latest: latest.clone(),
|
||
},
|
||
UpdateOutcome::Available(candidate) => UpdateStatus::Available {
|
||
version: candidate.version.clone(),
|
||
size: candidate.size,
|
||
notes: candidate.notes.clone(),
|
||
forced: candidate.forced,
|
||
},
|
||
UpdateOutcome::MissingArtifact { version } => UpdateStatus::MissingArtifact {
|
||
version: version.clone(),
|
||
},
|
||
UpdateOutcome::Failed(failure) => UpdateStatus::Failed {
|
||
reason: failure.message.clone(),
|
||
retryable: failure.retryable,
|
||
},
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use unlucky_presenter::dto::{ReadyUpdate, UpdateFailureView};
|
||
|
||
fn view(outcome: UpdateOutcome) -> UpdateView {
|
||
let mut view = UpdateView::new("0.2.2", "stable", true);
|
||
view.outcome = outcome;
|
||
view
|
||
}
|
||
|
||
#[test]
|
||
fn failure_is_never_mapped_to_up_to_date() {
|
||
let mapped = update_of(&view(UpdateOutcome::Failed(UpdateFailureView::new(
|
||
"network",
|
||
"取更新清单失败:连接被重置",
|
||
true,
|
||
))));
|
||
assert!(
|
||
matches!(&mapped.status, UpdateStatus::Failed { reason, retryable } if *retryable && !reason.is_empty()),
|
||
"失败必须原样带原因,实际 {:?}",
|
||
mapped.status
|
||
);
|
||
assert!(mapped.banner().is_some(), "主动查出来的失败要弹提示条");
|
||
|
||
// 静默检查的失败:快照里仍是失败,但不弹条(离线是常态)。
|
||
let mut silent = view(UpdateOutcome::Failed(UpdateFailureView::new(
|
||
"network",
|
||
"连不上",
|
||
true,
|
||
)));
|
||
silent.silent = true;
|
||
let mapped = update_of(&silent);
|
||
assert!(matches!(mapped.status, UpdateStatus::Failed { .. }));
|
||
assert!(mapped.banner().is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn the_four_outcomes_stay_distinct() {
|
||
let available = update_of(&view(UpdateOutcome::Available(Box::new(
|
||
unlucky_presenter::dto::UpdateCandidateView {
|
||
version: "0.2.3".to_string(),
|
||
size: 21_000_000,
|
||
notes: "修掉启动崩溃".to_string(),
|
||
forced: false,
|
||
},
|
||
))));
|
||
assert!(available.available().is_some());
|
||
assert!(matches!(
|
||
available.banner(),
|
||
Some(crate::pages::Banner::Available { .. })
|
||
));
|
||
|
||
let up_to_date = update_of(&view(UpdateOutcome::UpToDate {
|
||
latest: Some("0.2.2".to_string()),
|
||
}));
|
||
assert!(matches!(up_to_date.status, UpdateStatus::UpToDate { .. }));
|
||
assert!(up_to_date.banner().is_none());
|
||
|
||
let missing = update_of(&view(UpdateOutcome::MissingArtifact {
|
||
version: "0.2.3".to_string(),
|
||
}));
|
||
assert!(matches!(
|
||
missing.status,
|
||
UpdateStatus::MissingArtifact { .. }
|
||
));
|
||
}
|
||
|
||
#[test]
|
||
fn a_ready_update_turns_the_banner_into_the_restart_prompt() {
|
||
let mut view = view(UpdateOutcome::UpToDate {
|
||
latest: Some("0.2.3".to_string()),
|
||
});
|
||
view.ready = Some(ReadyUpdate {
|
||
version: "0.2.3".to_string(),
|
||
path: "C:/ULL/unlucky-launcher.exe".to_string(),
|
||
backup: None,
|
||
});
|
||
let mapped = update_of(&view);
|
||
assert_eq!(mapped.ready.as_deref(), Some("0.2.3"));
|
||
assert!(matches!(
|
||
mapped.banner(),
|
||
Some(crate::pages::Banner::Ready { .. })
|
||
));
|
||
}
|
||
|
||
#[test]
|
||
fn a_skipped_update_keeps_the_snapshot_but_hides_the_banner() {
|
||
let mut view = view(UpdateOutcome::Available(Box::new(
|
||
unlucky_presenter::dto::UpdateCandidateView {
|
||
version: "0.2.3".to_string(),
|
||
size: 1,
|
||
notes: String::new(),
|
||
forced: false,
|
||
},
|
||
)));
|
||
view.skipped = Some("0.2.3".to_string());
|
||
let mapped = update_of(&view);
|
||
assert_eq!(mapped.skipped.as_deref(), Some("0.2.3"));
|
||
assert!(mapped.available().is_some(), "候选要留着给设置页");
|
||
assert!(mapped.banner().is_none());
|
||
}
|
||
}
|