导入入口(拖拽 + 路径输入 + 目标实例)与导出(格式选择 + 预检)的**界面层**,
数据面镜像 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 后按新形状写一次)。
297 lines
9.6 KiB
Rust
297 lines
9.6 KiB
Rust
//! 导入/导出区视图模型的守门(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());
|
||
}
|