OneLauncher-Cracked/packages/oneclient_app/src/view/onboarding/downloading/mod.rs

1089 lines
35 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.

use std::collections::{BTreeMap, HashMap};
use std::time::{Duration, Instant};
use freya::animation::*;
use freya::prelude::*;
use freya::router::RouterContext;
use oneclient_core::notification::{
GroupedProgressEvent, GroupedProgressSession, Notification, NotificationService,
};
use oneclient_core::{
BundleArchive, BundleFile, Cluster, ImportTarget, MigrationSource, VersionMetadata,
};
use oneclient_db::models::OverrideType;
use tokio::sync::mpsc;
use uuid::Uuid;
use crate::hooks::{
BridgeDispatch, ClusterBundles, invalidate_cluster_queries, migration_detection,
onboarding_bundles_items, try_default_account, use_current_account, use_dispatch,
use_migration, use_onboarding_bundles, use_onboarding_selection, use_settings_snapshot,
use_versions, versions_metadata,
};
use crate::routes::Route;
use crate::theme::colors;
use crate::view::onboarding::{matching_new_cluster_id, pkg_key};
mod backdrop;
mod progress;
mod summary;
mod tips;
pub use backdrop::LoadingBackdrop;
use progress::{ProgressView, failure_panel, progress_panel};
use summary::summary_view;
use tips::{tip_panel, use_onboarding_tip};
const FADE_DURATION_MS: u64 = 700;
const MAX_TASK_ROWS: usize = 6;
#[derive(Clone, PartialEq)]
struct ClusterPlan {
cluster_id: i64,
mc_version: String,
/// `(bundle_name, package_id, override)` for every file whose fate differs
/// from the bundle manifest's default.
overrides: Vec<(String, String, OverrideType)>,
/// The manifest's `predownload` flag for this version. Versions without it
/// only ever record their package choices here and fetch on first launch.
predownload: bool,
}
#[derive(Clone, PartialEq)]
struct InstallFailure {
plan: ClusterPlan,
reason: String,
}
#[derive(Clone, PartialEq)]
struct TaskLine {
label: String,
phase: &'static str,
current: u64,
total: u64,
}
#[derive(Clone, PartialEq, Default)]
struct GroupedAgg {
children: HashMap<Uuid, TaskLine>,
done_units: u64,
carried: u64,
}
impl GroupedAgg {
fn fraction(&self) -> f32 {
let completed: u64 = self.done_units
+ self
.children
.values()
.map(|t| t.current.min(t.total))
.sum::<u64>();
let total: u64 = self.done_units + self.children.values().map(|t| t.total).sum::<u64>();
if total == 0 {
0.0
} else {
(completed as f32 / total as f32).clamp(0.0, 1.0)
}
}
fn task_list(&self) -> Vec<TaskLine> {
let mut tasks: Vec<TaskLine> = self.children.values().cloned().collect();
tasks.sort_by(|a, b| a.label.cmp(&b.label));
tasks
}
fn downloaded_bytes(&self) -> u64 {
self.carried
+ self.done_units
+ self
.children
.values()
.map(|t| t.current.min(t.total))
.sum::<u64>()
}
fn summary(&self) -> Option<String> {
if self.children.is_empty() {
return None;
}
let mut kinds: BTreeMap<&'static str, usize> = BTreeMap::new();
let mut phases: BTreeMap<&'static str, usize> = BTreeMap::new();
for task in self.children.values() {
*kinds.entry(categorize(&task.label)).or_default() += 1;
*phases.entry(task.phase).or_default() += 1;
}
let verb = phases
.iter()
.max_by_key(|(_, n)| **n)
.map(|(p, _)| *p)
.unwrap_or("Downloading");
let mut ordered: Vec<(&str, usize)> = kinds.into_iter().collect();
ordered.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(b.0)));
let parts: Vec<String> = ordered
.into_iter()
.take(3)
.map(|(kind, count)| {
if count > 1 {
format!("{kind} ×{count}")
} else {
kind.to_string()
}
})
.collect();
Some(format!("{verb} {}", parts.join(", ")))
}
}
#[derive(Clone, Copy, PartialEq, Default)]
struct Meter {
speed_bps: f64,
}
fn categorize(label: &str) -> &'static str {
if label.starts_with("Assets index") {
"asset index"
} else if label.starts_with("Asset") {
"assets"
} else if label.starts_with("Library") {
"libraries"
} else if label.starts_with("Natives") {
"natives"
} else if label.starts_with("Client") {
"game client"
} else if label.starts_with("Version metadata") || label.starts_with("Loader metadata") {
"metadata"
} else if label.starts_with("Mod") {
"mods"
} else {
"content"
}
}
#[derive(Clone, PartialEq, Default)]
struct DownloadStage {
index: usize,
total: usize,
label: String,
}
enum InstallUiEvent {
Progress(usize, usize),
Stage(DownloadStage),
Activity(String),
TotalEstimate(u64),
Finished(Vec<InstallFailure>),
}
#[derive(Clone, Copy)]
struct InstallHandles {
progress: State<(usize, usize)>,
agg: State<GroupedAgg>,
stage: State<DownloadStage>,
activity: State<String>,
started_at: State<Option<Instant>>,
meter: State<Meter>,
total_estimate: State<u64>,
failures: State<Vec<InstallFailure>>,
running: State<bool>,
complete: State<bool>,
}
#[derive(PartialEq)]
pub struct OnboardingDownloading;
impl Component for OnboardingDownloading {
fn render(&self) -> impl IntoElement {
let dispatch = use_dispatch();
let bundles_query = use_onboarding_bundles();
let migration_query = use_migration();
let versions_query = use_versions();
let selection = use_onboarding_selection();
let settings = use_settings_snapshot().settings;
let account_query = use_current_account();
let selected_state = selection.selected;
let language_state = selection.language;
let reduce_motion_state = selection.reduce_motion;
let predownload_state = selection.predownload;
let mut setup_started = selection.setup_started;
let mut confirmed = use_state(|| false);
let mut started = use_state(|| false);
let progress = use_state(|| (0usize, 0usize));
let agg = use_state(GroupedAgg::default);
let stage = use_state(DownloadStage::default);
let activity = use_state(String::new);
let started_at = use_state(|| None::<Instant>);
let meter = use_state(Meter::default);
let total_estimate = use_state(|| 0u64);
let failures = use_state(Vec::<InstallFailure>::new);
let running = use_state(|| false);
let complete = use_state(|| false);
let mut leaving = use_state(|| false);
let handles = InstallHandles {
progress,
agg,
stage,
activity,
started_at,
meter,
total_estimate,
failures,
running,
complete,
};
use_side_effect(move || {
if !*confirmed.read() || *started.peek() {
return;
}
let Some(items) = onboarding_bundles_items(&bundles_query) else {
return;
};
let Some(versions) = versions_metadata(&versions_query) else {
return;
};
let selected = selected_state.peek().clone();
let plans = build_plans(&items, &selected, &versions);
let predownload = *predownload_state.peek();
started.set(true);
run_install_batch(plans, predownload, handles);
});
let dispatch_finish = dispatch.clone();
use_side_effect(move || {
if !*complete.read() || !failures.read().is_empty() || *leaving.peek() {
return;
}
leaving.set(true);
finish_onboarding(dispatch_finish.clone(), &bundles_query);
});
let (done, total) = *progress.read();
let stage_now = stage.read().clone();
let agg_now = agg.read().clone();
let activity_now = activity.read().clone();
let meter_now = *meter.read();
let total_estimate_now = *total_estimate.read();
let elapsed_secs = (*started_at.read()).map(|s| s.elapsed().as_secs());
let predownload = *predownload_state.read();
let is_running = *running.read();
let is_complete = *complete.read();
let global = if is_complete {
100.0
} else if total == 0 {
0.0
} else if total_estimate_now > 0 {
(agg_now.downloaded_bytes() as f32 / total_estimate_now as f32 * 100.0).clamp(0.0, 99.0)
} else {
((done as f32 + agg_now.fraction()) / total as f32 * 100.0).clamp(0.0, 100.0)
};
let has_failures = !failures.read().is_empty();
let heading = if has_failures && is_complete {
"Download issues found"
} else if predownload {
"Downloading..."
} else {
"Finishing up..."
};
let is_leaving = *leaving.read();
let fade = use_animation_with_dependencies(&is_leaving, |conf, _| {
conf.on_creation(OnCreation::Run);
conf.on_change(OnChange::Rerun);
AnimNum::new(0., 1.)
.time(FADE_DURATION_MS)
.ease(Ease::Out)
.function(Function::Cubic)
});
let opacity = if is_leaving {
1.0 - fade.get().value()
} else {
1.0
};
let tip = use_onboarding_tip();
if !*confirmed.read() {
let items = onboarding_bundles_items(&bundles_query).unwrap_or_default();
let selected = selected_state.read().clone();
let language = language_state.read().clone();
let reduce_motion = *reduce_motion_state.read();
let account_name = try_default_account(&account_query)
.map(|account| account.username.clone())
.unwrap_or_else(|| "Not signed in".to_string());
let import_dispatch = dispatch.clone();
let import_folder = selection.import_folder;
let import_dedicated = selection.import_dedicated;
let import_detection = migration_detection(&migration_query);
let import_items = items.clone();
let migration_summary = import_detection.as_ref().map(|detection| {
let source_name = detection.source.display_name().to_string();
let is_vanilla = detection.source == MigrationSource::Vanilla;
match import_folder.read().clone() {
Some(folder) => {
let dedicated = !is_vanilla
&& *import_dedicated.read()
&& detection
.instances
.iter()
.find(|c| c.folder_name == folder)
.and_then(|inst| matching_new_cluster_id(inst, &items))
.is_some();
let target = if dedicated {
"This version only"
} else {
"Shared game directory"
};
let files = if is_vanilla {
"Your Minecraft folder".to_string()
} else {
folder
};
(source_name, files, target.to_string())
}
None => (source_name, "No files imported".to_string(), String::new()),
}
});
return summary_view(
&items,
&selected,
&versions_metadata(&versions_query).unwrap_or_default(),
&language,
reduce_motion,
settings.dynamic_background_enabled,
account_name,
migration_summary,
predownload_state,
move |_| {
if let (Some(detection), Some(folder)) =
(import_detection.as_ref(), import_folder.peek().clone())
{
let target = if detection.source != MigrationSource::Vanilla
&& *import_dedicated.peek()
{
detection
.instances
.iter()
.find(|c| c.folder_name == folder)
.and_then(|inst| matching_new_cluster_id(inst, &import_items))
.map(|new_cluster_id| ImportTarget::Dedicated { new_cluster_id })
.unwrap_or(ImportTarget::Shared)
} else {
ImportTarget::Shared
};
import_dispatch.import_launcher(detection.source, folder, target);
}
setup_started.set(true);
confirmed.set(true);
},
)
.into_element();
}
let dispatch_continue = dispatch.clone();
let continue_query = bundles_query;
let mut continue_leaving = leaving;
rect()
.vertical()
.width(Size::fill())
.height(Size::fill())
.content(Content::Flex)
.padding(Gaps::new(16., 48., 48., 48.))
.opacity(opacity)
.child(
label()
.text(heading)
.font_size(36.)
.font_weight(FontWeight::BOLD)
.color(colors::fg_primary()),
)
.maybe_child((has_failures && is_complete).then(|| {
failure_panel(
&failures.read(),
done,
total,
is_running,
move |_| {
let retry_plans: Vec<ClusterPlan> =
failures.peek().iter().map(|f| f.plan.clone()).collect();
if retry_plans.is_empty() || *running.peek() {
return;
}
let predownload = *predownload_state.peek();
run_install_batch(retry_plans, predownload, handles);
},
move |_| {
if *continue_leaving.peek() {
return;
}
continue_leaving.set(true);
finish_onboarding(dispatch_continue.clone(), &continue_query);
},
)
.into_element()
}))
.child(rect().width(Size::fill()).height(Size::flex(1.0)))
.child(
rect()
.horizontal()
.width(Size::fill())
.content(Content::Flex)
.cross_align(Alignment::End)
.child(progress_panel(ProgressView {
global,
stage: &stage_now,
agg: &agg_now,
activity: &activity_now,
speed_bps: meter_now.speed_bps,
total_estimate: total_estimate_now,
elapsed_secs,
done,
total,
predownload,
running: is_running,
}))
.child(rect().width(Size::flex(1.0)))
.child(tip_panel(tip)),
)
.into_element()
}
}
fn build_plans(
items: &[ClusterBundles],
selected: &std::collections::HashSet<String>,
versions: &[VersionMetadata],
) -> Vec<ClusterPlan> {
items
.iter()
.map(|cb| {
let mut overrides = Vec::new();
for archive in &cb.archives {
overrides.extend(archive_overrides(cb.cluster.id, archive, selected));
}
ClusterPlan {
cluster_id: cb.cluster.id,
mc_version: cb.cluster.mc_version.clone(),
overrides,
predownload: cluster_predownloads(versions, &cb.cluster),
}
})
.collect()
}
/// Whether the manifest asks for this cluster's version to be fetched up front.
/// Clusters with no manifest row (e.g. seeded from the bundle catalog alone)
/// are not predownloaded.
fn cluster_predownloads(versions: &[VersionMetadata], cluster: &Cluster) -> bool {
let loader = cluster.mc_loader.to_string();
versions.iter().any(|m| {
m.predownload
&& m.mc_version().as_deref() == Some(cluster.mc_version.as_str())
&& m.loader
.as_deref()
.is_none_or(|l| l.eq_ignore_ascii_case(&loader))
})
}
fn archive_overrides(
cluster_id: i64,
archive: &BundleArchive,
selected: &std::collections::HashSet<String>,
) -> Vec<(String, String, OverrideType)> {
let bundle_name = &archive.manifest.name;
let wants = |file: &BundleFile| {
selected.contains(&pkg_key(cluster_id, bundle_name, &file.kind.package_id()))
};
// Hidden files are dependencies the user never sees, so they follow the
// bundle: kept if anything from it was taken (including a single opted-in
// extra, which would otherwise lose its dependencies), dropped if not.
let bundle_taken = archive
.manifest
.files
.iter()
.filter(|f| !f.hidden)
.any(wants);
let mut overrides = Vec::new();
for file in &archive.manifest.files {
let wanted = if file.hidden {
bundle_taken
} else {
wants(file)
};
let override_type = match (wanted, file.enabled) {
// Matches the manifest default; nothing to record.
(true, true) | (false, false) => continue,
// Opting into a mod the bundle ships turned off.
(true, false) => OverrideType::Enabled,
// Declined: don't fetch it at all.
(false, true) => OverrideType::Removed,
};
overrides.push((bundle_name.clone(), file.kind.package_id(), override_type));
}
overrides
}
const GAME_SIZE_GUESS: u64 = 180_000_000;
const JRE_SIZE_GUESS: u64 = 45_000_000;
fn rough_download_estimate(
items: &[ClusterBundles],
selected: &std::collections::HashSet<String>,
versions: &[VersionMetadata],
) -> u64 {
let mut total: u64 = 0;
for cb in items {
if !cluster_predownloads(versions, &cb.cluster) {
continue;
}
for archive in &cb.archives {
let dropped: std::collections::HashSet<String> =
archive_overrides(cb.cluster.id, archive, selected)
.into_iter()
.filter(|(_, _, ty)| *ty == OverrideType::Removed)
.map(|(_, pid, _)| pid)
.collect();
let opted_in: std::collections::HashSet<String> =
archive_overrides(cb.cluster.id, archive, selected)
.into_iter()
.filter(|(_, _, ty)| *ty == OverrideType::Enabled)
.map(|(_, pid, _)| pid)
.collect();
for file in &archive.manifest.files {
let pid = file.kind.package_id();
let installing = if opted_in.contains(&pid) {
true
} else if dropped.contains(&pid) {
false
} else {
file.enabled
};
if installing {
total += file.size;
}
}
}
total += GAME_SIZE_GUESS + JRE_SIZE_GUESS;
}
total
}
fn run_install_batch(plans: Vec<ClusterPlan>, predownload: bool, handles: InstallHandles) {
let InstallHandles {
mut progress,
mut agg,
mut stage,
mut activity,
mut started_at,
mut meter,
mut total_estimate,
mut failures,
mut running,
mut complete,
} = handles;
running.set(true);
complete.set(false);
failures.set(Vec::new());
agg.set(GroupedAgg::default());
meter.set(Meter::default());
total_estimate.set(0);
activity.set("Getting started...".to_string());
started_at.set(Some(Instant::now()));
let total = plans.len();
progress.set((0, total));
let (notif_tx, mut notif_rx) = mpsc::unbounded_channel::<Notification>();
let notifier = NotificationService::new(notif_tx);
let (ui_tx, mut ui_rx) = mpsc::unbounded_channel::<InstallUiEvent>();
spawn(async move {
let mut prev = 0u64;
let mut speed = 0f64;
loop {
tokio::time::sleep(Duration::from_millis(1000)).await;
let downloaded = agg.peek().downloaded_bytes();
let delta = downloaded.saturating_sub(prev);
let inst = delta as f64;
speed = if speed <= 0.0 {
inst
} else {
speed * 0.6 + inst * 0.4
};
prev = downloaded;
meter.set(Meter { speed_bps: speed });
if !*running.peek() {
break;
}
}
});
spawn(async move {
let mut local = GroupedAgg::default();
loop {
let Some(first) = notif_rx.recv().await else {
break;
};
if let Notification::GroupedProgress(event) = first {
apply_grouped(&mut local, event);
}
// Drain everything already queued in one pass.
while let Ok(notification) = notif_rx.try_recv() {
if let Notification::GroupedProgress(event) = notification {
apply_grouped(&mut local, event);
}
}
agg.set(local.clone());
tokio::time::sleep(Duration::from_millis(50)).await;
}
});
spawn(async move {
while let Some(event) = ui_rx.recv().await {
match event {
InstallUiEvent::Progress(done, total) => progress.set((done, total)),
InstallUiEvent::Stage(next) => stage.set(next),
InstallUiEvent::Activity(text) => activity.set(text),
InstallUiEvent::TotalEstimate(bytes) => total_estimate.set(bytes),
InstallUiEvent::Finished(failed) => {
agg.set(GroupedAgg::default());
activity.set("Wrapping up...".to_string());
invalidate_cluster_queries().await;
failures.set(failed);
running.set(false);
complete.set(true);
break;
}
}
}
});
tokio::spawn(async move {
let mut failed = Vec::new();
if predownload {
let _ = ui_tx.send(InstallUiEvent::Activity(
"Calculating download size...".to_string(),
));
if let Ok(state) = oneclient_core::LauncherState::get() {
let mut sum = 0u64;
for plan in plans.iter().filter(|p| p.predownload) {
match oneclient_core::estimate_cluster_download(
&state,
plan.cluster_id,
state.bundles.as_ref(),
)
.await
{
Ok(bytes) => sum += bytes,
Err(err) => tracing::warn!(
cluster_id = plan.cluster_id,
"download size estimate failed: {err}"
),
}
let _ = ui_tx.send(InstallUiEvent::TotalEstimate(sum));
}
}
}
for (index, plan) in plans.into_iter().enumerate() {
let _ = ui_tx.send(InstallUiEvent::Stage(DownloadStage {
index,
total,
label: plan.mc_version.clone(),
}));
let fetch_now = predownload && plan.predownload;
if let Err(reason) = install_one(&plan, fetch_now, &notifier, &ui_tx).await {
tracing::error!(
cluster_id = plan.cluster_id,
mc_version = %plan.mc_version,
"onboarding install failed: {reason}"
);
failed.push(InstallFailure { plan, reason });
}
let _ = ui_tx.send(InstallUiEvent::Progress(index + 1, total));
}
drop(notifier);
let _ = ui_tx.send(InstallUiEvent::Finished(failed));
});
}
async fn install_one(
plan: &ClusterPlan,
predownload: bool,
notifier: &NotificationService,
ui_tx: &mpsc::UnboundedSender<InstallUiEvent>,
) -> Result<(), String> {
let state = oneclient_core::LauncherState::get().map_err(|err| err.to_string())?;
if !plan.overrides.is_empty() {
let _ = ui_tx.send(InstallUiEvent::Activity(
"Saving your package choices...".to_string(),
));
}
oneclient_core::set_bundle_package_overrides(plan.cluster_id, &plan.overrides, &state.services)
.await
.map_err(|err| err.to_string())?;
if !predownload {
return Ok(());
}
let session =
GroupedProgressSession::start(notifier, format!("Downloading {}", plan.mc_version));
let _ = ui_tx.send(InstallUiEvent::Activity(format!(
"Downloading Minecraft {}...",
plan.mc_version
)));
let prepared = oneclient_core::ClusterManager::prepare(
&state,
plan.cluster_id,
false,
true,
true,
Some(&session),
)
.await;
let bundles_result = if prepared.is_ok() {
let _ = ui_tx.send(InstallUiEvent::Activity(format!(
"Installing mods & content for {}...",
plan.mc_version
)));
oneclient_core::install_cluster_bundles(
plan.cluster_id,
state.bundles.as_ref(),
Some(&session),
&state.services,
)
.await
} else {
Ok(())
};
session.finish();
prepared.map_err(|err| err.to_string())?;
bundles_result.map_err(|err| err.to_string())?;
Ok(())
}
fn apply_grouped(agg: &mut GroupedAgg, event: GroupedProgressEvent) {
match event {
GroupedProgressEvent::Start { .. } => {
agg.children.clear();
agg.done_units = 0;
}
GroupedProgressEvent::Expect { .. } => {}
GroupedProgressEvent::AddChild {
child_id,
label,
total,
..
} => {
agg.children.insert(
child_id,
TaskLine {
label,
phase: "Downloading",
current: 0,
total: total.max(1),
},
);
}
GroupedProgressEvent::UpdateChild {
child_id,
current,
total,
..
} => {
if let Some(task) = agg.children.get_mut(&child_id) {
task.current = current;
task.total = total.max(1);
}
}
GroupedProgressEvent::SetChildPhase {
child_id, phase, ..
} => {
if let Some(task) = agg.children.get_mut(&child_id) {
task.phase = phase.label();
}
}
GroupedProgressEvent::FinishChild { child_id, .. } => {
if let Some(task) = agg.children.remove(&child_id) {
agg.done_units += task.total;
}
}
GroupedProgressEvent::End { .. } => {
agg.carried += agg.done_units
+ agg
.children
.values()
.map(|t| t.current.min(t.total))
.sum::<u64>();
agg.children.clear();
agg.done_units = 0;
}
}
}
fn finish_onboarding(
dispatch: BridgeDispatch,
bundles_query: &freya::query::UseQuery<crate::hooks::OnboardingBundlesQuery>,
) {
let versions: Vec<String> = onboarding_bundles_items(bundles_query)
.map(|items| {
let mut versions: Vec<String> = items
.iter()
.map(|cb| cb.cluster.mc_version.clone())
.collect();
versions.sort();
versions.dedup();
versions
})
.unwrap_or_default();
spawn(async move {
tokio::time::sleep(std::time::Duration::from_millis(FADE_DURATION_MS)).await;
for version in versions {
dispatch.record_seen_version(version);
}
dispatch.mark_onboarding_seen();
let _ = RouterContext::get().replace(Route::Home {});
});
}
#[cfg(test)]
mod tests {
use super::*;
use crate::view::onboarding::test_support::{archive, cluster, file};
use std::collections::HashSet;
fn skyblock() -> BundleArchive {
archive(
"SkyBlock",
false,
vec![
file("skyblock-main", true, false),
file("skycubed", false, false),
file("sb-dep", true, true),
],
)
}
fn items(archives: Vec<BundleArchive>) -> Vec<ClusterBundles> {
vec![ClusterBundles {
cluster: cluster(1),
archives,
}]
}
fn keys(archive: &BundleArchive, pids: &[&str]) -> HashSet<String> {
pids.iter()
.map(|p| pkg_key(1, &archive.manifest.name, p))
.collect()
}
/// Manifest row matching `test_support::cluster` (1.21.11 fabric).
fn version_row(predownload: bool) -> VersionMetadata {
VersionMetadata {
major_version: 21,
minor_version: Some(11),
patch_version: None,
loader: Some("fabric".to_string()),
name: "Mounts of Mayhem".to_string(),
art_url: None,
long_description: None,
tags: Vec::new(),
predownload,
}
}
fn versions() -> Vec<VersionMetadata> {
vec![version_row(true)]
}
#[test]
fn declining_a_bundle_removes_its_visible_and_hidden_files() {
let sb = skyblock();
let plans = build_plans(&items(vec![sb.clone()]), &HashSet::new(), &versions());
let ov = &plans[0].overrides;
// The hidden dependency must be dropped too, or it installs anyway.
assert!(ov.contains(&(
sb.manifest.name.clone(),
"sb-dep".to_string(),
OverrideType::Removed
)));
assert!(ov.contains(&(
sb.manifest.name.clone(),
"skyblock-main".to_string(),
OverrideType::Removed
)));
assert!(!ov.iter().any(|(_, pid, _)| pid == "skycubed"));
}
#[test]
fn accepting_a_bundle_records_nothing_for_its_defaults() {
let sb = skyblock();
let selected = keys(&sb, &["skyblock-main"]);
let plans = build_plans(&items(vec![sb.clone()]), &selected, &versions());
assert!(plans[0].overrides.is_empty());
}
#[test]
fn opting_into_an_extra_writes_enabled() {
let sb = skyblock();
let selected = keys(&sb, &["skyblock-main", "skycubed"]);
let plans = build_plans(&items(vec![sb.clone()]), &selected, &versions());
assert_eq!(
plans[0].overrides,
vec![(
sb.manifest.name.clone(),
"skycubed".to_string(),
OverrideType::Enabled
)]
);
}
#[test]
fn an_extra_alone_still_keeps_hidden_dependencies() {
let sb = skyblock();
let selected = keys(&sb, &["skycubed"]);
let plans = build_plans(&items(vec![sb.clone()]), &selected, &versions());
let ov = &plans[0].overrides;
assert!(ov.contains(&(
sb.manifest.name.clone(),
"skycubed".to_string(),
OverrideType::Enabled
)));
assert!(!ov.iter().any(|(_, pid, _)| pid == "sb-dep"));
assert!(ov.contains(&(
sb.manifest.name.clone(),
"skyblock-main".to_string(),
OverrideType::Removed
)));
}
#[test]
fn rejections_are_removed_never_disabled() {
let plans = build_plans(&items(vec![skyblock()]), &HashSet::new(), &versions());
assert!(
!plans[0]
.overrides
.iter()
.any(|(_, _, ty)| *ty == OverrideType::Disabled)
);
}
#[test]
fn per_cluster_choices_are_independent() {
let sb = skyblock();
let both = vec![
ClusterBundles {
cluster: cluster(1),
archives: vec![sb.clone()],
},
ClusterBundles {
cluster: cluster(2),
archives: vec![sb.clone()],
},
];
let selected: HashSet<String> = [pkg_key(1, &sb.manifest.name, "skyblock-main")].into();
let plans = build_plans(&both, &selected, &versions());
assert!(plans[0].overrides.is_empty());
assert!(
plans[1]
.overrides
.iter()
.any(|(_, pid, ty)| pid == "skyblock-main" && *ty == OverrideType::Removed)
);
}
#[test]
fn estimate_skips_declined_bundles_and_counts_opted_in_extras() {
let sb = skyblock();
let all = items(vec![sb.clone()]);
let baseline = GAME_SIZE_GUESS + JRE_SIZE_GUESS;
let declined = rough_download_estimate(&all, &HashSet::new(), &versions());
assert_eq!(declined, baseline, "declined bundle should cost nothing");
let accepted = rough_download_estimate(&all, &keys(&sb, &["skyblock-main"]), &versions());
assert_eq!(accepted, baseline + 2);
let with_extra = rough_download_estimate(
&all,
&keys(&sb, &["skyblock-main", "skycubed"]),
&versions(),
);
assert_eq!(with_extra, baseline + 3);
}
#[test]
fn plans_take_predownload_from_the_manifest() {
let all = items(vec![skyblock()]);
assert!(build_plans(&all, &HashSet::new(), &versions())[0].predownload);
assert!(!build_plans(&all, &HashSet::new(), &[version_row(false)])[0].predownload);
// A cluster with no manifest row at all is never predownloaded.
assert!(!build_plans(&all, &HashSet::new(), &[])[0].predownload);
}
#[test]
fn a_predownload_row_for_another_version_does_not_match() {
let mut other = version_row(true);
other.minor_version = Some(10);
assert!(!build_plans(&items(vec![skyblock()]), &HashSet::new(), &[other])[0].predownload);
}
#[test]
fn a_predownload_row_for_another_loader_does_not_match() {
let mut other = version_row(true);
other.loader = Some("forge".to_string());
assert!(!build_plans(&items(vec![skyblock()]), &HashSet::new(), &[other])[0].predownload);
}
#[test]
fn the_estimate_ignores_versions_that_are_not_predownloaded() {
let all = items(vec![skyblock()]);
let selected = keys(&skyblock(), &["skyblock-main"]);
assert_eq!(rough_download_estimate(&all, &selected, &[]), 0);
}
}