refactor(peer): split local library and operation UI events
Replace the `a9f9845` local-update dedup cache with explicit peer event semantics. Local scans now emit `LocalLibraryChanged` when the library changes, while operation mutations emit `ActiveOperationsChanged` from the mutation path. Tauri keeps joining those facts into the existing `games-list-updated` payload, so the frontend contract stays stable. This removes the cache/invalidation coupling between scan emission and operation state. The remaining forced local snapshot is explicit: accepted game directory changes can refresh the UI for an equivalent new path without sending a peer library delta. Operation guard cleanup and liveness cancellation now publish the same active operation snapshot as normal command-handler transitions. The peer CLI JSONL events follow the same split with `local-library-changed` and `active-operations-changed`. Test Plan: - `just fmt` - `CARGO_BUILD_RUSTC_WRAPPER= just test` - `CARGO_BUILD_RUSTC_WRAPPER= just clippy` - `git diff --check` Refs: CLEAN_CODE_PLAN_1.md
This commit is contained in:
@@ -12,8 +12,6 @@ use lanspread_db::db::{GameDB, GameFileDescription};
|
||||
use tokio::sync::{RwLock, mpsc::UnboundedSender};
|
||||
|
||||
use crate::{
|
||||
ActiveOperation,
|
||||
ActiveOperationKind,
|
||||
InstallOperation,
|
||||
PeerEvent,
|
||||
context::{Ctx, OperationGuard, OperationKind},
|
||||
@@ -272,17 +270,9 @@ pub async fn handle_download_game_files_command(
|
||||
return;
|
||||
}
|
||||
|
||||
{
|
||||
let mut in_progress = ctx.active_operations.write().await;
|
||||
match in_progress.entry(id.clone()) {
|
||||
Entry::Vacant(entry) => {
|
||||
entry.insert(OperationKind::Downloading);
|
||||
}
|
||||
Entry::Occupied(_) => {
|
||||
log::warn!("Operation for {id} already in progress; ignoring new download request");
|
||||
return;
|
||||
}
|
||||
}
|
||||
if !begin_operation(ctx, tx_notify_ui, &id, OperationKind::Downloading).await {
|
||||
log::warn!("Operation for {id} already in progress; ignoring new download request");
|
||||
return;
|
||||
}
|
||||
|
||||
let active_operations = ctx.active_operations.clone();
|
||||
@@ -298,8 +288,12 @@ pub async fn handle_download_game_files_command(
|
||||
.insert(id, cancel_token.clone());
|
||||
|
||||
ctx.task_tracker.spawn(async move {
|
||||
let download_state_guard =
|
||||
OperationGuard::download(download_id.clone(), active_operations, active_downloads);
|
||||
let download_state_guard = OperationGuard::download(
|
||||
download_id.clone(),
|
||||
active_operations,
|
||||
active_downloads,
|
||||
tx_notify_ui_clone.clone(),
|
||||
);
|
||||
|
||||
let result = download_game_files(
|
||||
&download_id,
|
||||
@@ -317,7 +311,7 @@ pub async fn handle_download_game_files_command(
|
||||
let Some(prepared) =
|
||||
prepare_install_operation(&ctx_clone, &tx_notify_ui_clone, &download_id).await
|
||||
else {
|
||||
end_download_operation(&ctx_clone, &download_id).await;
|
||||
end_download_operation(&ctx_clone, &tx_notify_ui_clone, &download_id).await;
|
||||
download_state_guard.disarm();
|
||||
return;
|
||||
};
|
||||
@@ -325,6 +319,7 @@ pub async fn handle_download_game_files_command(
|
||||
if install_after_download {
|
||||
if transition_download_to_install(
|
||||
&ctx_clone,
|
||||
&tx_notify_ui_clone,
|
||||
&download_id,
|
||||
prepared.operation_kind,
|
||||
)
|
||||
@@ -342,7 +337,7 @@ pub async fn handle_download_game_files_command(
|
||||
clear_active_download(&ctx_clone, &download_id).await;
|
||||
}
|
||||
} else {
|
||||
end_download_operation(&ctx_clone, &download_id).await;
|
||||
end_download_operation(&ctx_clone, &tx_notify_ui_clone, &download_id).await;
|
||||
if let Err(err) =
|
||||
refresh_local_game(&ctx_clone, &tx_notify_ui_clone, &download_id).await
|
||||
{
|
||||
@@ -352,7 +347,7 @@ pub async fn handle_download_game_files_command(
|
||||
download_state_guard.disarm();
|
||||
}
|
||||
Err(e) => {
|
||||
end_download_operation(&ctx_clone, &download_id).await;
|
||||
end_download_operation(&ctx_clone, &tx_notify_ui_clone, &download_id).await;
|
||||
download_state_guard.disarm();
|
||||
log::error!("Download failed for {download_id}: {e}");
|
||||
}
|
||||
@@ -395,7 +390,7 @@ async fn run_install_operation(ctx: &Ctx, tx_notify_ui: &UnboundedSender<PeerEve
|
||||
return;
|
||||
};
|
||||
|
||||
if !begin_operation(ctx, &id, prepared.operation_kind).await {
|
||||
if !begin_operation(ctx, tx_notify_ui, &id, prepared.operation_kind).await {
|
||||
log::warn!("Operation for {id} already in progress; ignoring install command");
|
||||
return;
|
||||
}
|
||||
@@ -459,7 +454,11 @@ async fn run_started_install_operation(
|
||||
..
|
||||
} = prepared;
|
||||
|
||||
let operation_guard = OperationGuard::new(id.clone(), ctx.active_operations.clone());
|
||||
let operation_guard = OperationGuard::new(
|
||||
id.clone(),
|
||||
ctx.active_operations.clone(),
|
||||
tx_notify_ui.clone(),
|
||||
);
|
||||
let result = {
|
||||
events::send(
|
||||
tx_notify_ui,
|
||||
@@ -478,7 +477,7 @@ async fn run_started_install_operation(
|
||||
}
|
||||
}
|
||||
};
|
||||
end_operation(ctx, &id).await;
|
||||
end_operation(ctx, tx_notify_ui, &id).await;
|
||||
operation_guard.disarm();
|
||||
|
||||
match result {
|
||||
@@ -510,13 +509,17 @@ async fn run_uninstall_operation(ctx: &Ctx, tx_notify_ui: &UnboundedSender<PeerE
|
||||
return;
|
||||
}
|
||||
|
||||
if !begin_operation(ctx, &id, OperationKind::Uninstalling).await {
|
||||
if !begin_operation(ctx, tx_notify_ui, &id, OperationKind::Uninstalling).await {
|
||||
log::warn!("Operation for {id} already in progress; ignoring uninstall command");
|
||||
return;
|
||||
}
|
||||
|
||||
let game_root = { ctx.game_dir.read().await.join(&id) };
|
||||
let operation_guard = OperationGuard::new(id.clone(), ctx.active_operations.clone());
|
||||
let operation_guard = OperationGuard::new(
|
||||
id.clone(),
|
||||
ctx.active_operations.clone(),
|
||||
tx_notify_ui.clone(),
|
||||
);
|
||||
let result = {
|
||||
events::send(
|
||||
tx_notify_ui,
|
||||
@@ -525,7 +528,7 @@ async fn run_uninstall_operation(ctx: &Ctx, tx_notify_ui: &UnboundedSender<PeerE
|
||||
|
||||
install::uninstall(&game_root, &id).await
|
||||
};
|
||||
end_operation(ctx, &id).await;
|
||||
end_operation(ctx, tx_notify_ui, &id).await;
|
||||
operation_guard.disarm();
|
||||
|
||||
match result {
|
||||
@@ -549,47 +552,77 @@ async fn run_uninstall_operation(ctx: &Ctx, tx_notify_ui: &UnboundedSender<PeerE
|
||||
}
|
||||
}
|
||||
|
||||
async fn begin_operation(ctx: &Ctx, id: &str, operation: OperationKind) -> bool {
|
||||
let mut active_operations = ctx.active_operations.write().await;
|
||||
match active_operations.entry(id.to_string()) {
|
||||
Entry::Vacant(entry) => {
|
||||
entry.insert(operation);
|
||||
true
|
||||
async fn begin_operation(
|
||||
ctx: &Ctx,
|
||||
tx_notify_ui: &UnboundedSender<PeerEvent>,
|
||||
id: &str,
|
||||
operation: OperationKind,
|
||||
) -> bool {
|
||||
let started = {
|
||||
let mut active_operations = ctx.active_operations.write().await;
|
||||
match active_operations.entry(id.to_string()) {
|
||||
Entry::Vacant(entry) => {
|
||||
entry.insert(operation);
|
||||
true
|
||||
}
|
||||
Entry::Occupied(_) => false,
|
||||
}
|
||||
Entry::Occupied(_) => false,
|
||||
};
|
||||
|
||||
if started {
|
||||
events::emit_active_operations(&ctx.active_operations, tx_notify_ui).await;
|
||||
}
|
||||
|
||||
started
|
||||
}
|
||||
|
||||
async fn transition_download_to_install(ctx: &Ctx, id: &str, operation: OperationKind) -> bool {
|
||||
let mut active_operations = ctx.active_operations.write().await;
|
||||
match active_operations.get_mut(id) {
|
||||
Some(current) if *current == OperationKind::Downloading => {
|
||||
*current = operation;
|
||||
true
|
||||
}
|
||||
Some(current) => {
|
||||
log::warn!(
|
||||
"Cannot transition {id} from download to install; current operation is {current:?}"
|
||||
);
|
||||
false
|
||||
}
|
||||
None => {
|
||||
log::warn!("Cannot transition {id} from download to install; operation is not active");
|
||||
false
|
||||
async fn transition_download_to_install(
|
||||
ctx: &Ctx,
|
||||
tx_notify_ui: &UnboundedSender<PeerEvent>,
|
||||
id: &str,
|
||||
operation: OperationKind,
|
||||
) -> bool {
|
||||
let transitioned = {
|
||||
let mut active_operations = ctx.active_operations.write().await;
|
||||
match active_operations.get_mut(id) {
|
||||
Some(current) if *current == OperationKind::Downloading => {
|
||||
*current = operation;
|
||||
true
|
||||
}
|
||||
Some(current) => {
|
||||
log::warn!(
|
||||
"Cannot transition {id} from download to install; current operation is {current:?}"
|
||||
);
|
||||
false
|
||||
}
|
||||
None => {
|
||||
log::warn!(
|
||||
"Cannot transition {id} from download to install; operation is not active"
|
||||
);
|
||||
false
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if transitioned {
|
||||
events::emit_active_operations(&ctx.active_operations, tx_notify_ui).await;
|
||||
}
|
||||
|
||||
transitioned
|
||||
}
|
||||
|
||||
async fn end_operation(ctx: &Ctx, id: &str) {
|
||||
ctx.active_operations.write().await.remove(id);
|
||||
async fn end_operation(ctx: &Ctx, tx_notify_ui: &UnboundedSender<PeerEvent>, id: &str) {
|
||||
if ctx.active_operations.write().await.remove(id).is_some() {
|
||||
events::emit_active_operations(&ctx.active_operations, tx_notify_ui).await;
|
||||
}
|
||||
}
|
||||
|
||||
async fn clear_active_download(ctx: &Ctx, id: &str) {
|
||||
ctx.active_downloads.write().await.remove(id);
|
||||
}
|
||||
|
||||
async fn end_download_operation(ctx: &Ctx, id: &str) {
|
||||
end_operation(ctx, id).await;
|
||||
async fn end_download_operation(ctx: &Ctx, tx_notify_ui: &UnboundedSender<PeerEvent>, id: &str) {
|
||||
end_operation(ctx, tx_notify_ui, id).await;
|
||||
clear_active_download(ctx, id).await;
|
||||
}
|
||||
|
||||
@@ -636,7 +669,13 @@ pub async fn handle_set_game_dir_command(
|
||||
let ctx_clone = ctx.clone();
|
||||
|
||||
ctx.task_tracker.spawn(async move {
|
||||
match load_local_library(&ctx_clone, &tx_notify_ui).await {
|
||||
match load_local_library_with_policy(
|
||||
&ctx_clone,
|
||||
&tx_notify_ui,
|
||||
LocalLibraryEventPolicy::ForceSnapshot,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(()) => log::info!("Local game database loaded successfully"),
|
||||
Err(e) => {
|
||||
log::error!("Failed to load local game database: {e}");
|
||||
@@ -649,11 +688,19 @@ pub async fn handle_set_game_dir_command(
|
||||
pub async fn load_local_library(
|
||||
ctx: &Ctx,
|
||||
tx_notify_ui: &UnboundedSender<PeerEvent>,
|
||||
) -> eyre::Result<()> {
|
||||
load_local_library_with_policy(ctx, tx_notify_ui, LocalLibraryEventPolicy::OnChange).await
|
||||
}
|
||||
|
||||
async fn load_local_library_with_policy(
|
||||
ctx: &Ctx,
|
||||
tx_notify_ui: &UnboundedSender<PeerEvent>,
|
||||
event_policy: LocalLibraryEventPolicy,
|
||||
) -> eyre::Result<()> {
|
||||
let game_dir = { ctx.game_dir.read().await.clone() };
|
||||
let active_ids = active_operation_ids(ctx).await;
|
||||
install::recover_on_startup(&game_dir, &active_ids).await?;
|
||||
scan_and_announce_local_library(ctx, tx_notify_ui, &game_dir).await
|
||||
scan_and_announce_local_library(ctx, tx_notify_ui, &game_dir, event_policy).await
|
||||
}
|
||||
|
||||
async fn refresh_local_library(
|
||||
@@ -661,17 +708,24 @@ async fn refresh_local_library(
|
||||
tx_notify_ui: &UnboundedSender<PeerEvent>,
|
||||
) -> eyre::Result<()> {
|
||||
let game_dir = { ctx.game_dir.read().await.clone() };
|
||||
scan_and_announce_local_library(ctx, tx_notify_ui, &game_dir).await
|
||||
scan_and_announce_local_library(
|
||||
ctx,
|
||||
tx_notify_ui,
|
||||
&game_dir,
|
||||
LocalLibraryEventPolicy::OnChange,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn scan_and_announce_local_library(
|
||||
ctx: &Ctx,
|
||||
tx_notify_ui: &UnboundedSender<PeerEvent>,
|
||||
game_dir: &Path,
|
||||
event_policy: LocalLibraryEventPolicy,
|
||||
) -> eyre::Result<()> {
|
||||
let catalog = ctx.catalog.read().await.clone();
|
||||
let scan = scan_local_library(game_dir, &catalog).await?;
|
||||
update_and_announce_games(ctx, tx_notify_ui, scan).await;
|
||||
update_and_announce_games_with_policy(ctx, tx_notify_ui, scan, event_policy).await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -683,10 +737,22 @@ async fn refresh_local_game(
|
||||
let game_dir = { ctx.game_dir.read().await.clone() };
|
||||
let catalog = ctx.catalog.read().await.clone();
|
||||
let scan = rescan_local_game(&game_dir, &catalog, id).await?;
|
||||
update_and_announce_games(ctx, tx_notify_ui, scan).await;
|
||||
update_and_announce_games_with_policy(
|
||||
ctx,
|
||||
tx_notify_ui,
|
||||
scan,
|
||||
LocalLibraryEventPolicy::OnChange,
|
||||
)
|
||||
.await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum LocalLibraryEventPolicy {
|
||||
OnChange,
|
||||
ForceSnapshot,
|
||||
}
|
||||
|
||||
async fn active_operation_ids(ctx: &Ctx) -> HashSet<String> {
|
||||
ctx.active_operations.read().await.keys().cloned().collect()
|
||||
}
|
||||
@@ -722,6 +788,21 @@ pub async fn update_and_announce_games(
|
||||
ctx: &Ctx,
|
||||
tx_notify_ui: &UnboundedSender<PeerEvent>,
|
||||
scan: LocalLibraryScan,
|
||||
) {
|
||||
update_and_announce_games_with_policy(
|
||||
ctx,
|
||||
tx_notify_ui,
|
||||
scan,
|
||||
LocalLibraryEventPolicy::OnChange,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
async fn update_and_announce_games_with_policy(
|
||||
ctx: &Ctx,
|
||||
tx_notify_ui: &UnboundedSender<PeerEvent>,
|
||||
scan: LocalLibraryScan,
|
||||
event_policy: LocalLibraryEventPolicy,
|
||||
) {
|
||||
let LocalLibraryScan {
|
||||
mut game_db,
|
||||
@@ -729,11 +810,11 @@ pub async fn update_and_announce_games(
|
||||
revision,
|
||||
} = scan;
|
||||
|
||||
let active_operations = active_operation_snapshot(ctx).await;
|
||||
if !active_operations.is_empty() {
|
||||
let active_operation_ids = active_operation_ids(ctx).await;
|
||||
if !active_operation_ids.is_empty() {
|
||||
let previous = ctx.local_library.read().await.games.clone();
|
||||
for id in active_operations.iter().map(|operation| &operation.id) {
|
||||
if let Some(summary) = previous.get(id) {
|
||||
for id in &active_operation_ids {
|
||||
if let Some(summary) = previous.get(id.as_str()) {
|
||||
summaries.insert(id.clone(), summary.clone());
|
||||
} else {
|
||||
summaries.remove(id);
|
||||
@@ -754,11 +835,15 @@ pub async fn update_and_announce_games(
|
||||
|
||||
let all_games = game_db.all_games().into_iter().cloned().collect::<Vec<_>>();
|
||||
|
||||
if let Err(e) = tx_notify_ui.send(PeerEvent::LocalGamesUpdated {
|
||||
games: all_games.clone(),
|
||||
active_operations,
|
||||
}) {
|
||||
log::error!("Failed to send LocalGamesUpdated event: {e}");
|
||||
if delta.is_some() || event_policy == LocalLibraryEventPolicy::ForceSnapshot {
|
||||
events::send(
|
||||
tx_notify_ui,
|
||||
PeerEvent::LocalLibraryChanged {
|
||||
games: all_games.clone(),
|
||||
},
|
||||
);
|
||||
} else {
|
||||
log::debug!("Skipping unchanged local library event");
|
||||
}
|
||||
|
||||
let Some(delta) = delta else {
|
||||
@@ -784,28 +869,6 @@ pub async fn update_and_announce_games(
|
||||
}
|
||||
}
|
||||
|
||||
async fn active_operation_snapshot(ctx: &Ctx) -> Vec<ActiveOperation> {
|
||||
let active_operations = ctx.active_operations.read().await;
|
||||
let mut snapshot = active_operations
|
||||
.iter()
|
||||
.map(|(id, operation)| ActiveOperation {
|
||||
id: id.clone(),
|
||||
operation: active_operation_kind(*operation),
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
snapshot.sort_by(|left, right| left.id.cmp(&right.id));
|
||||
snapshot
|
||||
}
|
||||
|
||||
fn active_operation_kind(operation: OperationKind) -> ActiveOperationKind {
|
||||
match operation {
|
||||
OperationKind::Downloading => ActiveOperationKind::Downloading,
|
||||
OperationKind::Installing => ActiveOperationKind::Installing,
|
||||
OperationKind::Updating => ActiveOperationKind::Updating,
|
||||
OperationKind::Uninstalling => ActiveOperationKind::Uninstalling,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::{
|
||||
@@ -821,7 +884,13 @@ mod tests {
|
||||
use tokio_util::{sync::CancellationToken, task::TaskTracker};
|
||||
|
||||
use super::*;
|
||||
use crate::{UnpackFuture, Unpacker, test_support::TempDir};
|
||||
use crate::{
|
||||
ActiveOperation,
|
||||
ActiveOperationKind,
|
||||
UnpackFuture,
|
||||
Unpacker,
|
||||
test_support::TempDir,
|
||||
};
|
||||
|
||||
struct FakeUnpacker;
|
||||
|
||||
@@ -860,6 +929,15 @@ mod tests {
|
||||
.expect("event channel should remain open")
|
||||
}
|
||||
|
||||
async fn assert_no_event(rx: &mut mpsc::UnboundedReceiver<PeerEvent>) {
|
||||
assert!(
|
||||
tokio::time::timeout(Duration::from_millis(50), rx.recv())
|
||||
.await
|
||||
.is_err(),
|
||||
"event channel should stay quiet"
|
||||
);
|
||||
}
|
||||
|
||||
fn addr(port: u16) -> SocketAddr {
|
||||
SocketAddr::from(([127, 0, 0, 1], port))
|
||||
}
|
||||
@@ -895,17 +973,9 @@ mod tests {
|
||||
installed: bool,
|
||||
downloaded: bool,
|
||||
) -> lanspread_db::db::Game {
|
||||
let PeerEvent::LocalGamesUpdated {
|
||||
games,
|
||||
active_operations,
|
||||
} = event
|
||||
else {
|
||||
panic!("expected LocalGamesUpdated");
|
||||
let PeerEvent::LocalLibraryChanged { games } = event else {
|
||||
panic!("expected LocalLibraryChanged");
|
||||
};
|
||||
assert!(
|
||||
active_operations.is_empty(),
|
||||
"settled local update should not report active operations"
|
||||
);
|
||||
let game = games
|
||||
.into_iter()
|
||||
.find(|game| game.id == "game")
|
||||
@@ -915,6 +985,20 @@ mod tests {
|
||||
game
|
||||
}
|
||||
|
||||
fn assert_active_update(event: PeerEvent, expected: Vec<ActiveOperation>) {
|
||||
let PeerEvent::ActiveOperationsChanged { active_operations } = event else {
|
||||
panic!("expected ActiveOperationsChanged");
|
||||
};
|
||||
assert_eq!(active_operations, expected);
|
||||
}
|
||||
|
||||
fn active_update(id: &str, operation: ActiveOperationKind) -> Vec<ActiveOperation> {
|
||||
vec![ActiveOperation {
|
||||
id: id.to_string(),
|
||||
operation,
|
||||
}]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_source_selects_latest_ready_peer_manifest() {
|
||||
let old_addr = addr(12_000);
|
||||
@@ -1039,8 +1123,8 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn local_games_update_reports_authoritative_active_operations() {
|
||||
let temp = TempDir::new("lanspread-handler-active-snapshot");
|
||||
async fn local_library_scan_freezes_active_game_state() {
|
||||
let temp = TempDir::new("lanspread-handler-active-freeze");
|
||||
let root = temp.game_root();
|
||||
write_file(&root.join("version.ini"), b"20250101");
|
||||
write_file(&root.join("game.eti"), b"archive");
|
||||
@@ -1058,29 +1142,38 @@ mod tests {
|
||||
|
||||
update_and_announce_games(&ctx, &tx, scan).await;
|
||||
|
||||
let PeerEvent::LocalGamesUpdated {
|
||||
games,
|
||||
active_operations,
|
||||
} = recv_event(&mut rx).await
|
||||
else {
|
||||
panic!("expected LocalGamesUpdated");
|
||||
let PeerEvent::LocalLibraryChanged { games } = recv_event(&mut rx).await else {
|
||||
panic!("expected LocalLibraryChanged");
|
||||
};
|
||||
assert!(
|
||||
games.is_empty(),
|
||||
"active game should keep its previous announced state"
|
||||
);
|
||||
assert_eq!(
|
||||
active_operations,
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn begin_operation_reports_authoritative_active_operation_snapshot() {
|
||||
let temp = TempDir::new("lanspread-handler-active-begin");
|
||||
let root = temp.game_root();
|
||||
write_file(&root.join("version.ini"), b"20250101");
|
||||
write_file(&root.join("game.eti"), b"archive");
|
||||
|
||||
let ctx = test_ctx(temp.path().to_path_buf());
|
||||
let (tx, mut rx) = mpsc::unbounded_channel();
|
||||
|
||||
assert!(begin_operation(&ctx, &tx, "game", OperationKind::Updating).await);
|
||||
assert_active_update(
|
||||
recv_event(&mut rx).await,
|
||||
vec![ActiveOperation {
|
||||
id: "game".to_string(),
|
||||
operation: ActiveOperationKind::Installing,
|
||||
}]
|
||||
operation: ActiveOperationKind::Updating,
|
||||
}],
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unchanged_scan_still_reports_active_operation_snapshot() {
|
||||
let temp = TempDir::new("lanspread-handler-active-unchanged");
|
||||
async fn unchanged_settled_scan_is_not_reemitted() {
|
||||
let temp = TempDir::new("lanspread-handler-settled-unchanged");
|
||||
let root = temp.game_root();
|
||||
write_file(&root.join("version.ini"), b"20250101");
|
||||
write_file(&root.join("game.eti"), b"archive");
|
||||
@@ -1095,28 +1188,50 @@ mod tests {
|
||||
update_and_announce_games(&ctx, &tx, scan).await;
|
||||
assert_local_update(recv_event(&mut rx).await, false, true);
|
||||
|
||||
ctx.active_operations
|
||||
.write()
|
||||
.await
|
||||
.insert("game".to_string(), OperationKind::Updating);
|
||||
let scan = scan_local_library(temp.path(), &catalog)
|
||||
.await
|
||||
.expect("second scan should succeed");
|
||||
update_and_announce_games(&ctx, &tx, scan).await;
|
||||
|
||||
let PeerEvent::LocalGamesUpdated {
|
||||
active_operations, ..
|
||||
} = recv_event(&mut rx).await
|
||||
else {
|
||||
panic!("expected LocalGamesUpdated");
|
||||
};
|
||||
assert_eq!(
|
||||
active_operations,
|
||||
vec![ActiveOperation {
|
||||
id: "game".to_string(),
|
||||
operation: ActiveOperationKind::Updating,
|
||||
}]
|
||||
assert_no_event(&mut rx).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unchanged_operation_refresh_still_reports_settled_snapshot() {
|
||||
let temp = TempDir::new("lanspread-handler-operation-unchanged");
|
||||
let root = temp.game_root();
|
||||
write_file(&root.join("version.ini"), b"20250101");
|
||||
write_file(&root.join("game.eti"), b"archive");
|
||||
write_file(&root.join("local").join("old.txt"), b"old");
|
||||
|
||||
let ctx = test_ctx(temp.path().to_path_buf());
|
||||
let (tx, mut rx) = mpsc::unbounded_channel();
|
||||
let catalog = ctx.catalog.read().await.clone();
|
||||
let scan = scan_local_library(temp.path(), &catalog)
|
||||
.await
|
||||
.expect("initial scan should succeed");
|
||||
update_and_announce_games(&ctx, &tx, scan).await;
|
||||
assert_local_update(recv_event(&mut rx).await, true, true);
|
||||
|
||||
run_install_operation(&ctx, &tx, "game".to_string()).await;
|
||||
|
||||
assert_active_update(
|
||||
recv_event(&mut rx).await,
|
||||
active_update("game", ActiveOperationKind::Updating),
|
||||
);
|
||||
assert!(matches!(
|
||||
recv_event(&mut rx).await,
|
||||
PeerEvent::InstallGameBegin {
|
||||
id,
|
||||
operation: InstallOperation::Updating
|
||||
} if id == "game"
|
||||
));
|
||||
assert_active_update(recv_event(&mut rx).await, Vec::new());
|
||||
assert!(matches!(
|
||||
recv_event(&mut rx).await,
|
||||
PeerEvent::InstallGameFinished { id } if id == "game"
|
||||
));
|
||||
assert_no_event(&mut rx).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -1131,6 +1246,10 @@ mod tests {
|
||||
|
||||
run_install_operation(&ctx, &tx, "game".to_string()).await;
|
||||
|
||||
assert_active_update(
|
||||
recv_event(&mut rx).await,
|
||||
active_update("game", ActiveOperationKind::Installing),
|
||||
);
|
||||
match recv_event(&mut rx).await {
|
||||
PeerEvent::InstallGameBegin { id, operation } => {
|
||||
assert_eq!(id, "game");
|
||||
@@ -1138,6 +1257,7 @@ mod tests {
|
||||
}
|
||||
_ => panic!("expected InstallGameBegin"),
|
||||
}
|
||||
assert_active_update(recv_event(&mut rx).await, Vec::new());
|
||||
assert!(matches!(
|
||||
recv_event(&mut rx).await,
|
||||
PeerEvent::InstallGameFinished { id } if id == "game"
|
||||
@@ -1174,7 +1294,8 @@ mod tests {
|
||||
let tx = tx.clone();
|
||||
async move {
|
||||
assert!(
|
||||
transition_download_to_install(&ctx, "game", prepared.operation_kind).await
|
||||
transition_download_to_install(&ctx, &tx, "game", prepared.operation_kind)
|
||||
.await
|
||||
);
|
||||
clear_active_download(&ctx, "game").await;
|
||||
run_started_install_operation(&ctx, &tx, "game".to_string(), prepared).await;
|
||||
@@ -1186,6 +1307,10 @@ mod tests {
|
||||
drop(read_guard);
|
||||
install_task.await.expect("handoff task should finish");
|
||||
|
||||
assert_active_update(
|
||||
recv_event(&mut rx).await,
|
||||
active_update("game", ActiveOperationKind::Installing),
|
||||
);
|
||||
match recv_event(&mut rx).await {
|
||||
PeerEvent::InstallGameBegin { id, operation } => {
|
||||
assert_eq!(id, "game");
|
||||
@@ -1193,6 +1318,7 @@ mod tests {
|
||||
}
|
||||
_ => panic!("expected InstallGameBegin"),
|
||||
}
|
||||
assert_active_update(recv_event(&mut rx).await, Vec::new());
|
||||
assert!(matches!(
|
||||
recv_event(&mut rx).await,
|
||||
PeerEvent::InstallGameFinished { id } if id == "game"
|
||||
@@ -1215,6 +1341,10 @@ mod tests {
|
||||
|
||||
run_install_operation(&ctx, &tx, "game".to_string()).await;
|
||||
|
||||
assert_active_update(
|
||||
recv_event(&mut rx).await,
|
||||
active_update("game", ActiveOperationKind::Updating),
|
||||
);
|
||||
match recv_event(&mut rx).await {
|
||||
PeerEvent::InstallGameBegin { id, operation } => {
|
||||
assert_eq!(id, "game");
|
||||
@@ -1222,6 +1352,7 @@ mod tests {
|
||||
}
|
||||
_ => panic!("expected InstallGameBegin"),
|
||||
}
|
||||
assert_active_update(recv_event(&mut rx).await, Vec::new());
|
||||
assert!(matches!(
|
||||
recv_event(&mut rx).await,
|
||||
PeerEvent::InstallGameFinished { id } if id == "game"
|
||||
@@ -1241,6 +1372,10 @@ mod tests {
|
||||
let (tx, mut rx) = mpsc::unbounded_channel();
|
||||
|
||||
run_install_operation(&ctx, &tx, "game".to_string()).await;
|
||||
assert_active_update(
|
||||
recv_event(&mut rx).await,
|
||||
active_update("game", ActiveOperationKind::Installing),
|
||||
);
|
||||
assert!(matches!(
|
||||
recv_event(&mut rx).await,
|
||||
PeerEvent::InstallGameBegin {
|
||||
@@ -1248,6 +1383,7 @@ mod tests {
|
||||
operation: InstallOperation::Installing
|
||||
} if id == "game"
|
||||
));
|
||||
assert_active_update(recv_event(&mut rx).await, Vec::new());
|
||||
assert!(matches!(
|
||||
recv_event(&mut rx).await,
|
||||
PeerEvent::InstallGameFinished { id } if id == "game"
|
||||
@@ -1259,6 +1395,10 @@ mod tests {
|
||||
write_file(&root.join("game.eti"), b"new archive");
|
||||
|
||||
run_install_operation(&ctx, &tx, "game".to_string()).await;
|
||||
assert_active_update(
|
||||
recv_event(&mut rx).await,
|
||||
active_update("game", ActiveOperationKind::Updating),
|
||||
);
|
||||
assert!(matches!(
|
||||
recv_event(&mut rx).await,
|
||||
PeerEvent::InstallGameBegin {
|
||||
@@ -1266,6 +1406,7 @@ mod tests {
|
||||
operation: InstallOperation::Updating
|
||||
} if id == "game"
|
||||
));
|
||||
assert_active_update(recv_event(&mut rx).await, Vec::new());
|
||||
assert!(matches!(
|
||||
recv_event(&mut rx).await,
|
||||
PeerEvent::InstallGameFinished { id } if id == "game"
|
||||
@@ -1274,10 +1415,15 @@ mod tests {
|
||||
assert_eq!(game.local_version.as_deref(), Some("20250101"));
|
||||
|
||||
run_uninstall_operation(&ctx, &tx, "game".to_string()).await;
|
||||
assert_active_update(
|
||||
recv_event(&mut rx).await,
|
||||
active_update("game", ActiveOperationKind::Uninstalling),
|
||||
);
|
||||
assert!(matches!(
|
||||
recv_event(&mut rx).await,
|
||||
PeerEvent::UninstallGameBegin { id } if id == "game"
|
||||
));
|
||||
assert_active_update(recv_event(&mut rx).await, Vec::new());
|
||||
assert!(matches!(
|
||||
recv_event(&mut rx).await,
|
||||
PeerEvent::UninstallGameFinished { id } if id == "game"
|
||||
@@ -1300,10 +1446,15 @@ mod tests {
|
||||
|
||||
run_uninstall_operation(&ctx, &tx, "game".to_string()).await;
|
||||
|
||||
assert_active_update(
|
||||
recv_event(&mut rx).await,
|
||||
active_update("game", ActiveOperationKind::Uninstalling),
|
||||
);
|
||||
assert!(matches!(
|
||||
recv_event(&mut rx).await,
|
||||
PeerEvent::UninstallGameBegin { id } if id == "game"
|
||||
));
|
||||
assert_active_update(recv_event(&mut rx).await, Vec::new());
|
||||
assert!(matches!(
|
||||
recv_event(&mut rx).await,
|
||||
PeerEvent::UninstallGameFinished { id } if id == "game"
|
||||
@@ -1356,4 +1507,29 @@ mod tests {
|
||||
|
||||
assert!(!next.game_root().join(".version.ini.tmp").exists());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn path_changing_set_game_dir_emits_equivalent_snapshot() {
|
||||
let current = TempDir::new("lanspread-handler-old-equivalent-dir");
|
||||
let next = TempDir::new("lanspread-handler-new-equivalent-dir");
|
||||
for root in [current.game_root(), next.game_root()] {
|
||||
write_file(&root.join("version.ini"), b"20250101");
|
||||
write_file(&root.join("game.eti"), b"archive");
|
||||
}
|
||||
|
||||
let ctx = test_ctx(current.path().to_path_buf());
|
||||
let (tx, mut rx) = mpsc::unbounded_channel();
|
||||
let catalog = ctx.catalog.read().await.clone();
|
||||
let scan = scan_local_library(current.path(), &catalog)
|
||||
.await
|
||||
.expect("initial scan should succeed");
|
||||
update_and_announce_games(&ctx, &tx, scan).await;
|
||||
assert_local_update(recv_event(&mut rx).await, false, true);
|
||||
|
||||
handle_set_game_dir_command(&ctx, &tx, next.path().to_path_buf()).await;
|
||||
ctx.task_tracker.close();
|
||||
ctx.task_tracker.wait().await;
|
||||
|
||||
assert_local_update(recv_event(&mut rx).await, false, true);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user