主任务(逐文件覆盖/保留)
- core: 新增 import/scan.rs —— 只读预检产出「冲突清单」(目标已存在且 SHA-1 不同才进),
逐条给路径/来源/两侧体积与哈希;用户删过的、内容相同的、被叠层遮蔽的一律不打扰。
- core: 新增 import/decision.rs —— ConflictDecisions(整批档 + 逐项表,逐项优先),
缺省档恒为 KeepUser;ImportRequest.conflict_policy 改由它承载,CLI 调用点不变。
- core: phase.rs 的 settle 按 path 取决策;非冲突路径不受决策影响。
- presenter: ImportScanView / ImportConflictView / ConflictDecisionsView(全部保留 /
全部覆盖 / 逐项{default,items})+ PackCommand::Scan / PackEvent::Scan;host 新增只读
spawn_scan,且新实例路径不再提前建目录(target_of 只算路径,prepare_target 才建)。
- app-iced: 导入前冲突清单页(分页 40 条/页、三入口、计数摘要、确认/取消、两行式行布局);
预检干净时直接往下导,不打扰用户。出图见 C:/Temp/m160-shots/{pick-light,pick-dark}.png。
- i18n: 新增 18 条 en/zh 文案 + 更新「两者都留」那条口径说明。
限额口径统一
- pack/archive/limits.rs 成为唯一来源:PACK_MAX_ENTRIES/UNCOMPRESSED_BYTES/ENTRY_BYTES
(20k / 512 MiB / 64 MiB);读侧 PackLimits::default 与写侧 WriteLimits::production 同源。
- 写侧不再直接用 zip::ZIP64_BYTES_THR;新增单条目闸,于是「能导出的包一定能导入」。
不等式在 pack/tests/limits.rs 里用 const 断言钉死。
测试临时物泄漏(塔台追加项)
- addon/temp.rs:TempRoot —— Drop 即删(panic 展开同样删),Deref/AsRef 到落点路径,
under()/at() 支持"子树落点、删父根"。形状照 core::update::tests::support::TempDir。
- addon::pack 与 addon::tests 两族全部改用 TempRoot(含 fixtures::Fixture 持守卫、
multimc/fabric_instance 等返回守卫);4 GiB 稀疏文件的语义一字未改,只改"谁负责删"。
- 自证:pack/tests/mod.rs 三条用例(drop 删 / panic 也删 / 子树删父根)。
测试
- import_decisions.rs(11 例):预检清单、不打扰、逐项决策后的最终文件集、基线记包内哈希。
- roundtrip_three.rs(2 例):叠层 + 冲突夹具 import→export→import 三轮文件集逐字节一致,
以及「选保留就按设计分叉」的反向证据。
- pack/tests/limits.rs(5 例):单条/总量边界、读侧用同一批常量、写出即可读回。
证据:cargo fmt --check 干净;cargo clippy --workspace --all-targets -D warnings 干净;
cargo test --workspace --all-targets -j 4 → 2507 passed / 0 failed(51 个 test binary)。
135 lines
5.0 KiB
Rust
135 lines
5.0 KiB
Rust
//! 导入/导出区的守门(M113):四种出图态都装得满、入口文案都在。
|
||
//!
|
||
//! 出图(D30)靠这几条夹具:任何一态装不满,截图就会是一块空容器,而不是「有话说」的界面。
|
||
//! 视图模型的判定在 `model/tests.rs`,这里只管**夹具本身**。
|
||
|
||
use unlucky_i18n::Lang;
|
||
|
||
use super::demo::pack_in;
|
||
use super::model::Pack;
|
||
|
||
/// 出图态:导入中 / **导入前冲突清单(M160)** / 部分成功 / 导入后冲突账 / 导出预检。
|
||
const STATES: [&str; 5] = ["importing", "pick", "partial", "conflict", "export"];
|
||
|
||
#[test]
|
||
fn every_screenshot_state_fills_something() {
|
||
for state in STATES {
|
||
let (model, _page) = pack_in(state, Lang::En);
|
||
assert!(
|
||
!model.is_idle(),
|
||
"{state} 这一态什么都没画(截图会是一块空容器)"
|
||
);
|
||
assert_eq!(model.targets.len(), 2, "{state} 的实例候选不足");
|
||
assert!(!model.drop_hint.is_empty());
|
||
assert!(
|
||
!model.no_dialog_note.is_empty(),
|
||
"没有系统文件框这件事必须写在界面上"
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn the_pick_state_shows_a_paginated_conflict_list() {
|
||
// M160 的那一屏:只画一页、三个入口都在、计数与逐项状态一致。
|
||
let (model, page) = pack_in("pick", Lang::En);
|
||
let pick = model.pick.expect("pick 态必须有冲突清单");
|
||
// 一屏只画一页:行数上限来自 `model::pick::PAGE_SIZE`(40)。
|
||
assert_eq!(pick.rows.len(), 40, "一屏一页(PAGE_SIZE)");
|
||
assert_eq!(pick.pages, 8, "312 条 / 40 条一页");
|
||
assert_eq!(pick.page, 1, "夹具停在第 2 页(0 起)");
|
||
assert!(pick.can_prev && pick.can_next);
|
||
assert_eq!(pick.overwrite_count, 2, "夹具逐项点了两条覆盖");
|
||
assert!(
|
||
pick.keep_all_active && !pick.overwrite_all_active,
|
||
"整批档停在缺省那一档(全部保留)"
|
||
);
|
||
assert_eq!(
|
||
pick.rows[0].choice,
|
||
unlucky_presenter::dto::ConflictChoiceView::KeepUser,
|
||
"没点过的那一行必须是保留"
|
||
);
|
||
// 页面自留的选择与模型一致(逐项那条显示成覆盖)
|
||
assert_eq!(
|
||
page.pick_choice("config/generated/module-041.toml"),
|
||
unlucky_presenter::dto::ConflictChoiceView::Overwrite
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn importing_shows_the_shared_install_panel() {
|
||
let (model, _page) = pack_in("importing", Lang::En);
|
||
let running = model.running.expect("导入中要有进度");
|
||
// 进度形状来自下载页那块面板(`Download.install`),不是本区另造的
|
||
assert!(
|
||
running
|
||
.panel
|
||
.as_ref()
|
||
.is_some_and(|panel| panel.install.is_some()),
|
||
"进度必须走下载页的安装面板视图模型"
|
||
);
|
||
assert!(
|
||
running.stage_line.contains("2/5"),
|
||
"解包阶段是第 2/5 步:{}",
|
||
running.stage_line
|
||
);
|
||
assert!(model.report.is_none(), "导入中不该同时有结果");
|
||
}
|
||
|
||
#[test]
|
||
fn partial_keeps_the_tolerable_rows_one_by_one() {
|
||
let (model, _page) = pack_in("partial", Lang::En);
|
||
let report = model.report.expect("部分成功要有报告");
|
||
assert!(report.installed(), "Partial 仍是「装上了」");
|
||
assert!(report.fatal.is_none(), "部分成功不是致命失败");
|
||
assert_eq!(report.missing.len(), 3, "缺文件要逐条列出");
|
||
assert!(report.missing_title.is_some());
|
||
assert!(!report.rejected.is_empty(), "被拒 component 也要逐条");
|
||
}
|
||
|
||
#[test]
|
||
fn conflict_state_defaults_to_keeping_the_user_copy_and_expands_one_row() {
|
||
let (model, page) = pack_in("conflict", Lang::En);
|
||
let report = model.report.expect("冲突态要有报告");
|
||
assert_eq!(report.conflicts.len(), 3);
|
||
assert!(
|
||
report.conflicts.iter().all(|row| !row.expanded)
|
||
|| report.conflicts.iter().filter(|row| row.expanded).count() == 1,
|
||
"只展开用户点过的那一条"
|
||
);
|
||
assert_eq!(
|
||
report.conflicts.iter().filter(|row| row.expanded).count(),
|
||
1,
|
||
"夹具要展开一条,截图才看得见三方哈希"
|
||
);
|
||
assert!(page.expanded.is_some());
|
||
assert!(report.can_overwrite);
|
||
assert!(report.overwrite_note.is_some());
|
||
}
|
||
|
||
#[test]
|
||
fn export_state_has_a_preview_but_has_not_written_anything() {
|
||
let (model, _page) = pack_in("export", Lang::En);
|
||
assert!(model.export.open);
|
||
let preview = model.export.preview.expect("预检要有结果");
|
||
assert!(
|
||
preview.head.contains('2'),
|
||
"内联数要进主结论:{}",
|
||
preview.head
|
||
);
|
||
assert!(!preview.skipped.is_empty(), "无法导出的项要逐条");
|
||
assert!(!preview.complete, "有跳过项 ⇒ 包不完整");
|
||
assert!(!preview.notes.is_empty(), "已知损失要列出来");
|
||
assert!(
|
||
model.export.done.is_none(),
|
||
"预检阶段不许出现「已写出」(那是写盘之后的事)"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn default_state_offers_the_entry_with_nothing_else() {
|
||
let (model, page): (Pack, _) = pack_in("", Lang::En);
|
||
assert!(model.is_idle());
|
||
assert!(!model.export.open);
|
||
assert!(page.draft.is_empty());
|
||
}
|