Files
Unlucky-Launcher/crates/app-iced/src/pages/content/packui/model/tests.rs
T
AskaEth fbeff94e55 feat(packui): M113 第一批 —— 内容库页的导入/导出区(界面 + DTO + i18n + 拖拽落点)
导入入口(拖拽 + 路径输入 + 目标实例)与导出(格式选择 + 预检)的**界面层**,
数据面镜像 M111/M112 的管线报告(presenter DTO),进度整块复用下载页的安装面板。

## 落了什么

- pages/content/packui*:入口 / 进度块 / 结果块 / 导出预检块(每文件 ≤400)
  - 拖拽区(hover 变主色描边)+ 路径输入框 + 目标实例 chips
  - 进度:一行「步骤 n/5 · 解包覆盖内容」+ **下载页那块安装面板**(不另画进度条)
  - 结果:三态(Success/Partial/Failed)+ 缺文件逐条(Warn/Tertiary)+ 冲突逐条
    (成因 + 本次处置 + 可展开三方 SHA-1)+ 致命块(Error/ErrorContainer)
  - 导出:mrpack / CF 二选一 + 包名/版本/路径 + 预检「将内联 N 个文件 · 无法导出 M 项」
    + 逐条无法导出项 + 已知损失 + 确认写盘
- presenter/src/dto/pack{,/format,/import,/conflict,/export}.rs:镜像 core 报告的 DTO
  (三态、五阶段、三分类、十一条原因,全部 from_slug 与 core 稳定码逐字对齐)
- i18n:81 条 Pack* 键(content_entries! 尾部追加;golden.rs 同序同文)
- app/update.rs:FileDropped / FileHovered / FilesHoveredLeft → 内容库页(本版唯一的选文件通道)
- M109 的四个文件各一处插入:mod 声明 + Pack(..) 变体 + pack 字段 + 正列插入点

## 边界(如实)

- 没有系统文件选择框:依赖树里无对话框依赖,加它要动根 Cargo.toml + 许可门禁 +
  Linux 后端选型(影响单文件产物)。入口是拖拽 + 路径输入,原因写在界面上。
- 冲突没有逐文件决策:core 的 ConflictPolicy 是整次导入一个全局档,ConflictFile 是事后账。
  本批给「逐文件看 + 整体翻档(以包为准重新导入)」,文案明写「两者都留(改名)暂不支持」。
  默认一律 KeepUser——绝不默认覆盖用户改过的文件。
- 本批按钮半通:拖拽/路径/格式/实例/展开都真生效;「点导入真的开跑」「预检/确认写盘」
  要等第二批的 AppCommand 与事件通道(M109 rebase 到新 main 后按新形状写一次)。
2026-11-05 04:06:37 +08:00

297 lines
9.6 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):阶段映射、预检计数、三方哈希、容忍/致命分野。
//!
//! 这里**不测画法**(画法靠出图),测的是「决策」:哪些数字进了哪一行、哪些字段被截短、
//! 哪一态该出现哪一块。所有断言都能在没有图形环境的情况下跑。
use std::path::PathBuf;
use unlucky_i18n::Lang;
use unlucky_presenter::dto::{
ConflictChoiceView, ConflictFileView, ConflictReasonView, ExportClassView, ExportFileView,
ExportPreviewView, ImportErrorView, ImportProgressView, ImportReportView, ImportStageView,
ImportStatusView, InstallStage, MissingFileView, MissingReasonView, PackFormatView,
};
use super::progress::{panel_progress, panel_stage, stage_line};
use super::report::report_of;
use super::report::short_sha;
use super::{Pack, PackPageState};
fn catalog() -> unlucky_i18n::Catalog {
Lang::En.catalog()
}
#[test]
fn stage_line_counts_the_five_steps_in_order() {
let expected = [
(ImportStageView::Probe, "1/5"),
(ImportStageView::Extract, "2/5"),
(ImportStageView::Fetch, "3/5"),
(ImportStageView::Commit, "4/5"),
(ImportStageView::Register, "5/5"),
];
for (stage, position) in expected {
let line = stage_line(stage, catalog());
assert!(line.contains(position), "{stage:?} 的步号不对:{line}");
}
// 阶段名要真的是「那一档」,不能五个阶段都显示同一句
let lines: Vec<String> = ImportStageView::ALL
.iter()
.map(|stage| stage_line(*stage, catalog()))
.collect();
for (index, line) in lines.iter().enumerate() {
for other in lines.iter().skip(index + 1) {
assert_ne!(line, other, "两个阶段的说明撞了:{line}");
}
}
}
#[test]
fn no_progress_stage_maps_to_a_terminal_install_stage() {
// 进度面板只该画「在跑」的形态:映射到 Done/Failed/Cancelled 会让面板提前画成收尾/失败
for stage in ImportStageView::ALL {
let mapped = panel_stage(stage);
assert!(
!mapped.is_terminal(),
"{stage:?} 落到了终态 {mapped:?}(面板会画成收尾)"
);
}
}
#[test]
fn panel_progress_carries_the_counts_and_the_pack_name() {
let progress = ImportProgressView {
stage: ImportStageView::Fetch,
done: 41,
total: 90,
current: Some("mods/sodium.jar".to_string()),
pack: "Better MC".to_string(),
};
let panel = panel_progress(&progress);
assert_eq!(panel.files_done, 41);
assert_eq!(panel.files_total, 90);
assert_eq!(panel.stage, InstallStage::Downloading);
// 面板第一行显示的就是包名(导入的对象是包,不是某个版本 id)
assert_eq!(panel.version, "Better MC");
}
#[test]
fn unknown_counts_keep_the_bar_unknown() {
// total == 0 时不允许画确定态进度条(那是「静止的假进度」的来源)
let progress = ImportProgressView {
stage: ImportStageView::Probe,
done: 0,
total: 0,
current: None,
pack: "Pack".to_string(),
};
let panel = panel_progress(&progress);
assert_eq!(panel.files_total, 0);
assert_eq!(panel.speed_bps, 0, "导入没有字节速率,不许编");
}
#[test]
fn sha_is_shortened_to_eight_chars() {
assert_eq!(
short_sha("9f2c41ab77de3350aa1b8c0d5e6f708192a3b4c5"),
"9f2c41ab"
);
assert_eq!(short_sha("abc"), "abc");
assert_eq!(short_sha(""), "");
}
fn preview(files: Vec<ExportFileView>) -> ExportPreviewView {
ExportPreviewView {
format: PackFormatView::Modrinth,
instance: "survival".to_string(),
name: "Survival".to_string(),
version: "1.0.0".to_string(),
target: PathBuf::from("C:/tmp/out.mrpack"),
files,
notes: Vec::new(),
}
}
fn export_file(
path: &str,
class: ExportClassView,
reason: unlucky_presenter::dto::ExportReasonView,
) -> ExportFileView {
ExportFileView {
path: path.to_string(),
class,
reason,
bytes: 1000,
disabled: false,
}
}
#[test]
fn preview_counts_match_the_three_classes() {
use unlucky_presenter::dto::ExportReasonView;
let view = preview(vec![
export_file(
"a.jar",
ExportClassView::Remote,
ExportReasonView::ModrinthSha1,
),
export_file(
"b.jar",
ExportClassView::Inline,
ExportReasonView::NoRemoteMatch,
),
export_file(
"c.jar",
ExportClassView::Inline,
ExportReasonView::MissingSha512,
),
export_file(
"d.jar",
ExportClassView::Skipped,
ExportReasonView::SymbolicLink,
),
]);
assert_eq!(view.remote_count(), 1);
assert_eq!(view.inline_count(), 2);
assert_eq!(view.skipped_count(), 1);
assert_eq!(view.inline_bytes(), 2000);
assert!(!view.is_complete(), "有跳过项 ⇒ 包不完整");
}
#[test]
fn the_head_line_carries_the_two_numbers_the_task_asks_for() {
use unlucky_presenter::dto::ExportReasonView;
let view = preview(vec![
export_file(
"b.jar",
ExportClassView::Inline,
ExportReasonView::NoRemoteMatch,
),
export_file(
"c.jar",
ExportClassView::Inline,
ExportReasonView::MissingSha512,
),
export_file(
"d.jar",
ExportClassView::Skipped,
ExportReasonView::UnsafePath,
),
]);
let block = super::export::preview_of(&view, catalog());
// 「将内联 N 个文件 · 无法导出 M 项」两个数字必须来自同一份 files,不许另算
assert!(
block.head.contains('2'),
"内联数没进 headline:{}",
block.head
);
assert!(
block.head.contains('1'),
"跳过数没进 headline:{}",
block.head
);
assert_eq!(block.skipped.len(), 1);
assert!(!block.complete);
}
#[test]
fn report_keeps_tolerable_and_fatal_apart() {
let report = ImportReportView {
status: ImportStatusView::Failed,
format: Some(PackFormatView::Curseforge),
pack_name: Some("Broken".to_string()),
pack_version: None,
game_version: None,
loader: None,
version_id: None,
target: PathBuf::from("C:/tmp"),
files: Vec::new(),
conflicts: Vec::new(),
missing: vec![MissingFileView {
path: Some("mods/a.jar".to_string()),
display: "mods/a.jar".to_string(),
reason: MissingReasonView::Offline,
detail: String::new(),
}],
rejected: Vec::new(),
warnings: Vec::new(),
error: Some(ImportErrorView {
code: "pack_traversal".to_string(),
label: "路径穿越".to_string(),
message: "../etc/passwd".to_string(),
hint: Some("换一个来源".to_string()),
}),
registered: None,
bytes_written: 0,
rolled_back: true,
};
let block = report_of(&report, &PackPageState::default(), catalog());
assert!(block.fatal.is_some(), "致命块必须单独存在");
assert_eq!(block.missing.len(), 1, "容忍域仍要逐条列出");
assert!(!block.installed(), "Failed 不是「装上了」");
assert!(!block.can_overwrite, "没有冲突时不给翻档动作");
}
#[test]
fn conflicts_default_to_keeping_the_user_copy() {
let report = ImportReportView {
status: ImportStatusView::Partial,
format: Some(PackFormatView::Modrinth),
pack_name: Some("Pack".to_string()),
pack_version: None,
game_version: None,
loader: None,
version_id: None,
target: PathBuf::from("C:/tmp"),
files: Vec::new(),
conflicts: vec![ConflictFileView {
path: "config/x.json".to_string(),
reason: ConflictReasonView::UserModified,
choice: ConflictChoiceView::KeepUser,
pack_sha1: "fixture".to_string(),
base_sha1: None,
disk_sha1: Some("disk".to_string()),
pack_size: 12,
}],
missing: Vec::new(),
rejected: Vec::new(),
warnings: Vec::new(),
error: None,
registered: None,
bytes_written: 0,
rolled_back: true,
};
let page = PackPageState::default();
let block = report_of(&report, &page, catalog());
assert_eq!(block.conflicts.len(), 1);
assert!(!block.conflicts[0].expanded, "默认收起");
assert_eq!(
block.conflicts[0].choice,
catalog().text(unlucky_i18n::Key::PackChoiceKeepUser),
"默认档必须是「保留我的」"
);
assert!(block.can_overwrite, "有冲突要能整体翻档");
assert!(block.overwrite_note.is_some(), "不支持「两者都留」要如实写");
// 展开的那一条才画三方哈希
let expanded = PackPageState {
expanded: Some("config/x.json".to_string()),
..PackPageState::default()
};
let block = report_of(&report, &expanded, catalog());
assert!(block.conflicts[0].expanded);
}
#[test]
fn an_idle_pack_has_nothing_to_draw_but_the_entry() {
let import = unlucky_presenter::dto::ImportState::default();
let export = unlucky_presenter::dto::ExportState::default();
let model = Pack::of(&import, &export, &PackPageState::default(), &[], catalog());
assert!(model.is_idle());
assert!(model.running.is_none());
assert!(model.report.is_none());
// 入口说明永远在(用户得知道可以拖什么、为什么没有「选择文件」按钮)
assert!(!model.drop_hint.is_empty());
assert!(!model.no_dialog_note.is_empty());
}