Files
Unlucky-Launcher/crates/app-iced/src/pages/content/packui/tests.rs
T
worker-modpackpick 4defc41e8d feat(m160): 整合包逐文件覆盖/保留决策 + 导入前冲突清单 + 限额口径统一
主任务(逐文件覆盖/保留)
- 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)。
2026-11-07 13:52:43 +08:00

135 lines
5.0 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.
//! 导入/导出区的守门(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());
}