Compare commits
3 Commits
b7df2de6a5
...
32f8f4c08e
| Author | SHA1 | Date | |
|---|---|---|---|
| 32f8f4c08e | |||
| 6651f026b6 | |||
| 5c4976d476 |
@@ -26,11 +26,12 @@ Never use normal cargo ... commands, use the just ... commands instead.
|
|||||||
- `just fmt` — format the workspace.
|
- `just fmt` — format the workspace.
|
||||||
- `just clippy` — lint the workspace.
|
- `just clippy` — lint the workspace.
|
||||||
- `just test` — run the workspace unit tests.
|
- `just test` — run the workspace unit tests.
|
||||||
|
- `just frontend-test` — run frontend reducer/unit tests.
|
||||||
- `just fix` — auto-apply cargo/clippy fixes, then format.
|
- `just fix` — auto-apply cargo/clippy fixes, then format.
|
||||||
- `just clean` — wipe the build cache.
|
- `just clean` — wipe the build cache.
|
||||||
- `just peer-cli-build` — build the scripted peer harness.
|
- `just peer-cli-build` — build the scripted peer harness.
|
||||||
- `just peer-cli-image` — build the peer harness Docker image.
|
- `just peer-cli-image` — build the peer harness Docker image.
|
||||||
- `just peer-cli-run NAME` — run one named harness container with persistent state under `target/peer-cli/NAME/`.
|
- `just peer-cli-run NAME` — run one named harness container with persistent state under `.lanspread-peer-cli/NAME/`.
|
||||||
|
|
||||||
## Protocol policy
|
## Protocol policy
|
||||||
|
|
||||||
|
|||||||
@@ -92,6 +92,29 @@ until a dedicated fixture or temporary games root is prepared.
|
|||||||
|
|
||||||
## Run Log
|
## Run Log
|
||||||
|
|
||||||
|
### 2026-05-19 - Snapshot Status Fix Docker Matrix Pass
|
||||||
|
|
||||||
|
- Code under test included `5c4976d` (`fix(peer): settle local state before
|
||||||
|
clearing operations`) and `6651f02` (`fix(ui): derive operation status from
|
||||||
|
snapshots`).
|
||||||
|
- Gates before the matrix: `just fmt`, `just test`, `just frontend-test`, and
|
||||||
|
`just build` passed. The peer harness image was rebuilt with
|
||||||
|
`just peer-cli-image`.
|
||||||
|
- Runner: `python3 crates/lanspread-peer-cli/scripts/run_extended_scenarios.py`
|
||||||
|
passed S1-S36 against the rebuilt `lanspread-peer-cli:dev` image.
|
||||||
|
- Auto-install coverage remained good: S5 downloaded and installed `cnctw`, saw
|
||||||
|
the fixture payload under `local/`, and the downloaded root diffed cleanly
|
||||||
|
against `fixture-bravo/cnctw` excluding local metadata.
|
||||||
|
- Large/exact transfer coverage remained good: S13 small and large downloads
|
||||||
|
diffed cleanly; S14 split `alienswarm` between two sources with chunk totals
|
||||||
|
`67,108,864` and `58,721,049` bytes and the final root diffed cleanly.
|
||||||
|
- Failure and mutation coverage remained good: S17 latest-version conflict,
|
||||||
|
S19 sole-source drop, S20 write failure, S26 duplicate operation, and S35
|
||||||
|
unknown catalog filtering all failed safely without advertising bad local
|
||||||
|
state; S21-S23 propagation, S24-S25 concurrency, S29-S31 bootstrapping, S32
|
||||||
|
reinstall, S33 mutation install, S34 many-small-files, and S36 latest
|
||||||
|
singleton all passed.
|
||||||
|
|
||||||
### 2026-05-18 - Full Automated Docker Matrix Pass
|
### 2026-05-18 - Full Automated Docker Matrix Pass
|
||||||
|
|
||||||
- Runner: `python3 crates/lanspread-peer-cli/scripts/run_extended_scenarios.py`
|
- Runner: `python3 crates/lanspread-peer-cli/scripts/run_extended_scenarios.py`
|
||||||
|
|||||||
@@ -508,28 +508,33 @@ async fn run_started_install_operation(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
end_operation(ctx, tx_notify_ui, &id).await;
|
|
||||||
operation_guard.disarm();
|
|
||||||
|
|
||||||
match result {
|
match result {
|
||||||
Ok(()) => {
|
Ok(()) => {
|
||||||
|
if let Err(err) = refresh_local_game_for_ending_operation(ctx, tx_notify_ui, &id).await
|
||||||
|
{
|
||||||
|
log::error!("Failed to refresh local library after install: {err}");
|
||||||
|
}
|
||||||
|
end_operation(ctx, tx_notify_ui, &id).await;
|
||||||
|
operation_guard.disarm();
|
||||||
events::send(
|
events::send(
|
||||||
tx_notify_ui,
|
tx_notify_ui,
|
||||||
PeerEvent::InstallGameFinished { id: id.clone() },
|
PeerEvent::InstallGameFinished { id: id.clone() },
|
||||||
);
|
);
|
||||||
if let Err(err) = refresh_local_game(ctx, tx_notify_ui, &id).await {
|
|
||||||
log::error!("Failed to refresh local library after install: {err}");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
log::error!("Install operation failed for {id}: {err}");
|
log::error!("Install operation failed for {id}: {err}");
|
||||||
|
if let Err(refresh_err) =
|
||||||
|
refresh_local_game_for_ending_operation(ctx, tx_notify_ui, &id).await
|
||||||
|
{
|
||||||
|
log::error!("Failed to refresh local library after install failure: {refresh_err}");
|
||||||
|
}
|
||||||
|
end_operation(ctx, tx_notify_ui, &id).await;
|
||||||
|
operation_guard.disarm();
|
||||||
events::send(
|
events::send(
|
||||||
tx_notify_ui,
|
tx_notify_ui,
|
||||||
PeerEvent::InstallGameFailed { id: id.clone() },
|
PeerEvent::InstallGameFailed { id: id.clone() },
|
||||||
);
|
);
|
||||||
if let Err(refresh_err) = refresh_local_game(ctx, tx_notify_ui, &id).await {
|
|
||||||
log::error!("Failed to refresh local library after install failure: {refresh_err}");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -559,11 +564,15 @@ async fn run_uninstall_operation(ctx: &Ctx, tx_notify_ui: &UnboundedSender<PeerE
|
|||||||
|
|
||||||
install::uninstall(&game_root, &id).await
|
install::uninstall(&game_root, &id).await
|
||||||
};
|
};
|
||||||
end_operation(ctx, tx_notify_ui, &id).await;
|
|
||||||
operation_guard.disarm();
|
|
||||||
|
|
||||||
match result {
|
match result {
|
||||||
Ok(()) => {
|
Ok(()) => {
|
||||||
|
if let Err(err) = refresh_local_game_for_ending_operation(ctx, tx_notify_ui, &id).await
|
||||||
|
{
|
||||||
|
log::error!("Failed to refresh local library after uninstall: {err}");
|
||||||
|
}
|
||||||
|
end_operation(ctx, tx_notify_ui, &id).await;
|
||||||
|
operation_guard.disarm();
|
||||||
events::send(
|
events::send(
|
||||||
tx_notify_ui,
|
tx_notify_ui,
|
||||||
PeerEvent::UninstallGameFinished { id: id.clone() },
|
PeerEvent::UninstallGameFinished { id: id.clone() },
|
||||||
@@ -571,16 +580,21 @@ async fn run_uninstall_operation(ctx: &Ctx, tx_notify_ui: &UnboundedSender<PeerE
|
|||||||
}
|
}
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
log::error!("Uninstall operation failed for {id}: {err}");
|
log::error!("Uninstall operation failed for {id}: {err}");
|
||||||
|
if let Err(refresh_err) =
|
||||||
|
refresh_local_game_for_ending_operation(ctx, tx_notify_ui, &id).await
|
||||||
|
{
|
||||||
|
log::error!(
|
||||||
|
"Failed to refresh local library after uninstall failure: {refresh_err}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
end_operation(ctx, tx_notify_ui, &id).await;
|
||||||
|
operation_guard.disarm();
|
||||||
events::send(
|
events::send(
|
||||||
tx_notify_ui,
|
tx_notify_ui,
|
||||||
PeerEvent::UninstallGameFailed { id: id.clone() },
|
PeerEvent::UninstallGameFailed { id: id.clone() },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Err(err) = refresh_local_game(ctx, tx_notify_ui, &id).await {
|
|
||||||
log::error!("Failed to refresh local library after uninstall: {err}");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn run_remove_downloaded_operation(
|
async fn run_remove_downloaded_operation(
|
||||||
@@ -612,11 +626,15 @@ async fn run_remove_downloaded_operation(
|
|||||||
|
|
||||||
install::remove_downloaded(&game_dir, &id).await
|
install::remove_downloaded(&game_dir, &id).await
|
||||||
};
|
};
|
||||||
end_operation(ctx, tx_notify_ui, &id).await;
|
|
||||||
operation_guard.disarm();
|
|
||||||
|
|
||||||
match result {
|
match result {
|
||||||
Ok(()) => {
|
Ok(()) => {
|
||||||
|
if let Err(err) = refresh_local_game_for_ending_operation(ctx, tx_notify_ui, &id).await
|
||||||
|
{
|
||||||
|
log::error!("Failed to refresh local library after downloaded-file removal: {err}");
|
||||||
|
}
|
||||||
|
end_operation(ctx, tx_notify_ui, &id).await;
|
||||||
|
operation_guard.disarm();
|
||||||
events::send(
|
events::send(
|
||||||
tx_notify_ui,
|
tx_notify_ui,
|
||||||
PeerEvent::RemoveDownloadedGameFinished { id: id.clone() },
|
PeerEvent::RemoveDownloadedGameFinished { id: id.clone() },
|
||||||
@@ -624,16 +642,21 @@ async fn run_remove_downloaded_operation(
|
|||||||
}
|
}
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
log::error!("Downloaded-file removal failed for {id}: {err}");
|
log::error!("Downloaded-file removal failed for {id}: {err}");
|
||||||
|
if let Err(refresh_err) =
|
||||||
|
refresh_local_game_for_ending_operation(ctx, tx_notify_ui, &id).await
|
||||||
|
{
|
||||||
|
log::error!(
|
||||||
|
"Failed to refresh local library after downloaded-file removal failure: {refresh_err}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
end_operation(ctx, tx_notify_ui, &id).await;
|
||||||
|
operation_guard.disarm();
|
||||||
events::send(
|
events::send(
|
||||||
tx_notify_ui,
|
tx_notify_ui,
|
||||||
PeerEvent::RemoveDownloadedGameFailed { id: id.clone() },
|
PeerEvent::RemoveDownloadedGameFailed { id: id.clone() },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Err(err) = refresh_local_game(ctx, tx_notify_ui, &id).await {
|
|
||||||
log::error!("Failed to refresh local library after downloaded-file removal: {err}");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn begin_operation(
|
async fn begin_operation(
|
||||||
@@ -809,7 +832,7 @@ async fn scan_and_announce_local_library(
|
|||||||
) -> eyre::Result<()> {
|
) -> eyre::Result<()> {
|
||||||
let catalog = ctx.catalog.read().await.clone();
|
let catalog = ctx.catalog.read().await.clone();
|
||||||
let scan = scan_local_library(game_dir, &catalog).await?;
|
let scan = scan_local_library(game_dir, &catalog).await?;
|
||||||
update_and_announce_games_with_policy(ctx, tx_notify_ui, scan, event_policy).await;
|
update_and_announce_games_with_policy(ctx, tx_notify_ui, scan, event_policy, None).await;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -826,6 +849,28 @@ async fn refresh_local_game(
|
|||||||
tx_notify_ui,
|
tx_notify_ui,
|
||||||
scan,
|
scan,
|
||||||
LocalLibraryEventPolicy::OnChange,
|
LocalLibraryEventPolicy::OnChange,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Refreshes the game whose operation has completed before clearing its
|
||||||
|
/// active-operation snapshot, while preserving freeze behavior for other games.
|
||||||
|
async fn refresh_local_game_for_ending_operation(
|
||||||
|
ctx: &Ctx,
|
||||||
|
tx_notify_ui: &UnboundedSender<PeerEvent>,
|
||||||
|
id: &str,
|
||||||
|
) -> eyre::Result<()> {
|
||||||
|
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_with_policy(
|
||||||
|
ctx,
|
||||||
|
tx_notify_ui,
|
||||||
|
scan,
|
||||||
|
LocalLibraryEventPolicy::OnChange,
|
||||||
|
Some(id),
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -878,6 +923,7 @@ pub async fn update_and_announce_games(
|
|||||||
tx_notify_ui,
|
tx_notify_ui,
|
||||||
scan,
|
scan,
|
||||||
LocalLibraryEventPolicy::OnChange,
|
LocalLibraryEventPolicy::OnChange,
|
||||||
|
None,
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
@@ -887,6 +933,7 @@ async fn update_and_announce_games_with_policy(
|
|||||||
tx_notify_ui: &UnboundedSender<PeerEvent>,
|
tx_notify_ui: &UnboundedSender<PeerEvent>,
|
||||||
scan: LocalLibraryScan,
|
scan: LocalLibraryScan,
|
||||||
event_policy: LocalLibraryEventPolicy,
|
event_policy: LocalLibraryEventPolicy,
|
||||||
|
ending_operation_id: Option<&str>,
|
||||||
) {
|
) {
|
||||||
let LocalLibraryScan {
|
let LocalLibraryScan {
|
||||||
mut game_db,
|
mut game_db,
|
||||||
@@ -894,7 +941,10 @@ async fn update_and_announce_games_with_policy(
|
|||||||
revision,
|
revision,
|
||||||
} = scan;
|
} = scan;
|
||||||
|
|
||||||
let active_operation_ids = active_operation_ids(ctx).await;
|
let mut active_operation_ids = active_operation_ids(ctx).await;
|
||||||
|
if let Some(id) = ending_operation_id {
|
||||||
|
active_operation_ids.remove(id);
|
||||||
|
}
|
||||||
if !active_operation_ids.is_empty() {
|
if !active_operation_ids.is_empty() {
|
||||||
let previous = ctx.local_library.read().await.games.clone();
|
let previous = ctx.local_library.read().await.games.clone();
|
||||||
for id in &active_operation_ids {
|
for id in &active_operation_ids {
|
||||||
@@ -1361,7 +1411,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn install_refreshes_settled_state_after_guard_release() {
|
async fn install_refreshes_settled_state_before_operation_clear() {
|
||||||
let temp = TempDir::new("lanspread-handler-install");
|
let temp = TempDir::new("lanspread-handler-install");
|
||||||
let root = temp.game_root();
|
let root = temp.game_root();
|
||||||
write_file(&root.join("version.ini"), b"20250101");
|
write_file(&root.join("version.ini"), b"20250101");
|
||||||
@@ -1383,13 +1433,13 @@ mod tests {
|
|||||||
}
|
}
|
||||||
_ => panic!("expected InstallGameBegin"),
|
_ => panic!("expected InstallGameBegin"),
|
||||||
}
|
}
|
||||||
|
assert_local_update(recv_event(&mut rx).await, true, true);
|
||||||
assert_active_update(recv_event(&mut rx).await, Vec::new());
|
assert_active_update(recv_event(&mut rx).await, Vec::new());
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
recv_event(&mut rx).await,
|
recv_event(&mut rx).await,
|
||||||
PeerEvent::InstallGameFinished { id } if id == "game"
|
PeerEvent::InstallGameFinished { id } if id == "game"
|
||||||
));
|
));
|
||||||
assert!(ctx.active_operations.read().await.is_empty());
|
assert!(ctx.active_operations.read().await.is_empty());
|
||||||
assert_local_update(recv_event(&mut rx).await, true, true);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -1444,6 +1494,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
_ => panic!("expected InstallGameBegin"),
|
_ => panic!("expected InstallGameBegin"),
|
||||||
}
|
}
|
||||||
|
assert_local_update(recv_event(&mut rx).await, true, true);
|
||||||
assert_active_update(recv_event(&mut rx).await, Vec::new());
|
assert_active_update(recv_event(&mut rx).await, Vec::new());
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
recv_event(&mut rx).await,
|
recv_event(&mut rx).await,
|
||||||
@@ -1451,11 +1502,10 @@ mod tests {
|
|||||||
));
|
));
|
||||||
assert!(ctx.active_operations.read().await.is_empty());
|
assert!(ctx.active_operations.read().await.is_empty());
|
||||||
assert!(ctx.active_downloads.read().await.is_empty());
|
assert!(ctx.active_downloads.read().await.is_empty());
|
||||||
assert_local_update(recv_event(&mut rx).await, true, true);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn update_refreshes_settled_state_after_guard_release() {
|
async fn update_refreshes_settled_state_before_operation_clear() {
|
||||||
let temp = TempDir::new("lanspread-handler-update");
|
let temp = TempDir::new("lanspread-handler-update");
|
||||||
let root = temp.game_root();
|
let root = temp.game_root();
|
||||||
write_file(&root.join("version.ini"), b"20250101");
|
write_file(&root.join("version.ini"), b"20250101");
|
||||||
@@ -1478,13 +1528,13 @@ mod tests {
|
|||||||
}
|
}
|
||||||
_ => panic!("expected InstallGameBegin"),
|
_ => panic!("expected InstallGameBegin"),
|
||||||
}
|
}
|
||||||
|
assert_local_update(recv_event(&mut rx).await, true, true);
|
||||||
assert_active_update(recv_event(&mut rx).await, Vec::new());
|
assert_active_update(recv_event(&mut rx).await, Vec::new());
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
recv_event(&mut rx).await,
|
recv_event(&mut rx).await,
|
||||||
PeerEvent::InstallGameFinished { id } if id == "game"
|
PeerEvent::InstallGameFinished { id } if id == "game"
|
||||||
));
|
));
|
||||||
assert!(ctx.active_operations.read().await.is_empty());
|
assert!(ctx.active_operations.read().await.is_empty());
|
||||||
assert_local_update(recv_event(&mut rx).await, true, true);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -1509,13 +1559,13 @@ mod tests {
|
|||||||
operation: InstallOperation::Installing
|
operation: InstallOperation::Installing
|
||||||
} if id == "game"
|
} if id == "game"
|
||||||
));
|
));
|
||||||
|
let game = local_update_game(recv_event(&mut rx).await, true, true);
|
||||||
|
assert_eq!(game.local_version.as_deref(), Some("20240101"));
|
||||||
assert_active_update(recv_event(&mut rx).await, Vec::new());
|
assert_active_update(recv_event(&mut rx).await, Vec::new());
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
recv_event(&mut rx).await,
|
recv_event(&mut rx).await,
|
||||||
PeerEvent::InstallGameFinished { id } if id == "game"
|
PeerEvent::InstallGameFinished { id } if id == "game"
|
||||||
));
|
));
|
||||||
let game = local_update_game(recv_event(&mut rx).await, true, true);
|
|
||||||
assert_eq!(game.local_version.as_deref(), Some("20240101"));
|
|
||||||
|
|
||||||
write_file(&root.join("version.ini"), b"20250101");
|
write_file(&root.join("version.ini"), b"20250101");
|
||||||
write_file(&root.join("game.eti"), b"new archive");
|
write_file(&root.join("game.eti"), b"new archive");
|
||||||
@@ -1532,13 +1582,13 @@ mod tests {
|
|||||||
operation: InstallOperation::Updating
|
operation: InstallOperation::Updating
|
||||||
} if id == "game"
|
} if id == "game"
|
||||||
));
|
));
|
||||||
|
let game = local_update_game(recv_event(&mut rx).await, true, true);
|
||||||
|
assert_eq!(game.local_version.as_deref(), Some("20250101"));
|
||||||
assert_active_update(recv_event(&mut rx).await, Vec::new());
|
assert_active_update(recv_event(&mut rx).await, Vec::new());
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
recv_event(&mut rx).await,
|
recv_event(&mut rx).await,
|
||||||
PeerEvent::InstallGameFinished { id } if id == "game"
|
PeerEvent::InstallGameFinished { id } if id == "game"
|
||||||
));
|
));
|
||||||
let game = local_update_game(recv_event(&mut rx).await, true, true);
|
|
||||||
assert_eq!(game.local_version.as_deref(), Some("20250101"));
|
|
||||||
|
|
||||||
run_uninstall_operation(&ctx, &tx, "game".to_string()).await;
|
run_uninstall_operation(&ctx, &tx, "game".to_string()).await;
|
||||||
assert_active_update(
|
assert_active_update(
|
||||||
@@ -1549,18 +1599,18 @@ mod tests {
|
|||||||
recv_event(&mut rx).await,
|
recv_event(&mut rx).await,
|
||||||
PeerEvent::UninstallGameBegin { id } if id == "game"
|
PeerEvent::UninstallGameBegin { id } if id == "game"
|
||||||
));
|
));
|
||||||
|
let game = local_update_game(recv_event(&mut rx).await, false, true);
|
||||||
|
assert_eq!(game.local_version.as_deref(), Some("20250101"));
|
||||||
assert_active_update(recv_event(&mut rx).await, Vec::new());
|
assert_active_update(recv_event(&mut rx).await, Vec::new());
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
recv_event(&mut rx).await,
|
recv_event(&mut rx).await,
|
||||||
PeerEvent::UninstallGameFinished { id } if id == "game"
|
PeerEvent::UninstallGameFinished { id } if id == "game"
|
||||||
));
|
));
|
||||||
let game = local_update_game(recv_event(&mut rx).await, false, true);
|
|
||||||
assert_eq!(game.local_version.as_deref(), Some("20250101"));
|
|
||||||
assert!(ctx.active_operations.read().await.is_empty());
|
assert!(ctx.active_operations.read().await.is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn uninstall_refreshes_settled_state_after_guard_release() {
|
async fn uninstall_refreshes_settled_state_before_operation_clear() {
|
||||||
let temp = TempDir::new("lanspread-handler-uninstall");
|
let temp = TempDir::new("lanspread-handler-uninstall");
|
||||||
let root = temp.game_root();
|
let root = temp.game_root();
|
||||||
write_file(&root.join("version.ini"), b"20250101");
|
write_file(&root.join("version.ini"), b"20250101");
|
||||||
@@ -1580,17 +1630,17 @@ mod tests {
|
|||||||
recv_event(&mut rx).await,
|
recv_event(&mut rx).await,
|
||||||
PeerEvent::UninstallGameBegin { id } if id == "game"
|
PeerEvent::UninstallGameBegin { id } if id == "game"
|
||||||
));
|
));
|
||||||
|
assert_local_update(recv_event(&mut rx).await, false, true);
|
||||||
assert_active_update(recv_event(&mut rx).await, Vec::new());
|
assert_active_update(recv_event(&mut rx).await, Vec::new());
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
recv_event(&mut rx).await,
|
recv_event(&mut rx).await,
|
||||||
PeerEvent::UninstallGameFinished { id } if id == "game"
|
PeerEvent::UninstallGameFinished { id } if id == "game"
|
||||||
));
|
));
|
||||||
assert!(ctx.active_operations.read().await.is_empty());
|
assert!(ctx.active_operations.read().await.is_empty());
|
||||||
assert_local_update(recv_event(&mut rx).await, false, true);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn remove_downloaded_refreshes_settled_state_after_guard_release() {
|
async fn remove_downloaded_refreshes_settled_state_before_operation_clear() {
|
||||||
let temp = TempDir::new("lanspread-handler-remove-downloaded");
|
let temp = TempDir::new("lanspread-handler-remove-downloaded");
|
||||||
let root = temp.game_root();
|
let root = temp.game_root();
|
||||||
write_file(&root.join("version.ini"), b"20250101");
|
write_file(&root.join("version.ini"), b"20250101");
|
||||||
@@ -1615,15 +1665,15 @@ mod tests {
|
|||||||
recv_event(&mut rx).await,
|
recv_event(&mut rx).await,
|
||||||
PeerEvent::RemoveDownloadedGameBegin { id } if id == "game"
|
PeerEvent::RemoveDownloadedGameBegin { id } if id == "game"
|
||||||
));
|
));
|
||||||
|
let PeerEvent::LocalLibraryChanged { games } = recv_event(&mut rx).await else {
|
||||||
|
panic!("expected LocalLibraryChanged");
|
||||||
|
};
|
||||||
|
assert!(games.is_empty());
|
||||||
assert_active_update(recv_event(&mut rx).await, Vec::new());
|
assert_active_update(recv_event(&mut rx).await, Vec::new());
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
recv_event(&mut rx).await,
|
recv_event(&mut rx).await,
|
||||||
PeerEvent::RemoveDownloadedGameFinished { id } if id == "game"
|
PeerEvent::RemoveDownloadedGameFinished { id } if id == "game"
|
||||||
));
|
));
|
||||||
let PeerEvent::LocalLibraryChanged { games } = recv_event(&mut rx).await else {
|
|
||||||
panic!("expected LocalLibraryChanged");
|
|
||||||
};
|
|
||||||
assert!(games.is_empty());
|
|
||||||
assert!(!root.exists());
|
assert!(!root.exists());
|
||||||
assert!(ctx.active_operations.read().await.is_empty());
|
assert!(ctx.active_operations.read().await.is_empty());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,9 +13,9 @@ export interface GameActions {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Thin wrappers over the backend `run_game` / `install_game` / `update_game`
|
* Thin wrappers over the backend `run_game` / `install_game` / `update_game`
|
||||||
* / `uninstall_game` / `remove_downloaded_game` commands. We mark peer-backed
|
* / `uninstall_game` / `remove_downloaded_game` commands. Peer-backed downloads
|
||||||
* downloads as "checking peers" and already-downloaded installs as "installing"
|
* are marked as "checking peers" until the backend emits an authoritative
|
||||||
* up-front so the UI doesn't have to wait for the first backend event.
|
* operation snapshot.
|
||||||
*/
|
*/
|
||||||
export const useGameActions = (games: UseGamesResult): GameActions => {
|
export const useGameActions = (games: UseGamesResult): GameActions => {
|
||||||
const play = useCallback(async (id: string) => {
|
const play = useCallback(async (id: string) => {
|
||||||
@@ -32,9 +32,7 @@ export const useGameActions = (games: UseGamesResult): GameActions => {
|
|||||||
if (!success) return;
|
if (!success) return;
|
||||||
|
|
||||||
const game = games.games.find(item => item.id === id);
|
const game = games.games.find(item => item.id === id);
|
||||||
if (game?.downloaded && !game.installed) {
|
if (!game?.downloaded) {
|
||||||
games.markInstalling(id);
|
|
||||||
} else {
|
|
||||||
games.markChecking(id);
|
games.markChecking(id);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import {
|
|||||||
Game,
|
Game,
|
||||||
GamesListPayload,
|
GamesListPayload,
|
||||||
InstallStatus,
|
InstallStatus,
|
||||||
StatusLevel,
|
|
||||||
} from '../lib/types';
|
} from '../lib/types';
|
||||||
import {
|
import {
|
||||||
activeStatusById,
|
activeStatusById,
|
||||||
@@ -17,36 +16,23 @@ import {
|
|||||||
|
|
||||||
interface PendingPatch {
|
interface PendingPatch {
|
||||||
install_status?: InstallStatus;
|
install_status?: InstallStatus;
|
||||||
downloaded?: boolean;
|
|
||||||
installed?: boolean;
|
|
||||||
local_version?: string | null;
|
|
||||||
status_message?: string;
|
|
||||||
status_level?: StatusLevel | undefined;
|
|
||||||
clearStatus?: boolean;
|
clearStatus?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const applyPatch = (game: Game, patch: PendingPatch): Game => {
|
const applyPatch = (game: Game, patch: PendingPatch): Game => {
|
||||||
let next: Game = { ...game };
|
let next: Game = { ...game };
|
||||||
if (patch.install_status !== undefined) next.install_status = patch.install_status;
|
if (patch.install_status !== undefined) next.install_status = patch.install_status;
|
||||||
if (patch.downloaded !== undefined) next.downloaded = patch.downloaded;
|
|
||||||
if (patch.installed !== undefined) next.installed = patch.installed;
|
|
||||||
if (patch.local_version !== undefined) next.local_version = patch.local_version ?? undefined;
|
|
||||||
if (patch.clearStatus) {
|
if (patch.clearStatus) {
|
||||||
next.status_message = undefined;
|
next.status_message = undefined;
|
||||||
next.status_level = undefined;
|
next.status_level = undefined;
|
||||||
}
|
}
|
||||||
if (patch.status_message !== undefined) {
|
|
||||||
next.status_message = patch.status_message;
|
|
||||||
next.status_level = patch.status_level;
|
|
||||||
}
|
|
||||||
return next;
|
return next;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Owns the games list and reflects every backend event (download/install/
|
* Owns the games list and derives card status from backend snapshots. Returns
|
||||||
* uninstall/remove lifecycle, peer count) into local React state. Returns a
|
* a fire-and-forget `markChecking` helper so action calls can immediately show
|
||||||
* fire-and-forget `markChecking` helper so action calls can immediately show a
|
* a "Checking peers…" state until the next backend snapshot arrives.
|
||||||
* "Checking peers…" state until the backend emits the authoritative outcome.
|
|
||||||
*/
|
*/
|
||||||
export interface UseGamesResult {
|
export interface UseGamesResult {
|
||||||
games: Game[];
|
games: Game[];
|
||||||
@@ -54,7 +40,6 @@ export interface UseGamesResult {
|
|||||||
totalPeerCount: number;
|
totalPeerCount: number;
|
||||||
requestGames: () => Promise<void>;
|
requestGames: () => Promise<void>;
|
||||||
markChecking: (id: string) => void;
|
markChecking: (id: string) => void;
|
||||||
markInstalling: (id: string) => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useGames = (rescanGameDir: () => void): UseGamesResult => {
|
export const useGames = (rescanGameDir: () => void): UseGamesResult => {
|
||||||
@@ -71,14 +56,6 @@ export const useGames = (rescanGameDir: () => void): UseGamesResult => {
|
|||||||
));
|
));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const markInstalling = useCallback((id: string) => {
|
|
||||||
setGames(prev => prev.map(item =>
|
|
||||||
item.id === id
|
|
||||||
? applyPatch(item, { install_status: InstallStatus.Installing, clearStatus: true })
|
|
||||||
: item,
|
|
||||||
));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const requestGames = useCallback(async () => {
|
const requestGames = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
await invoke('request_games');
|
await invoke('request_games');
|
||||||
@@ -91,10 +68,6 @@ export const useGames = (rescanGameDir: () => void): UseGamesResult => {
|
|||||||
const unlisteners: UnlistenFn[] = [];
|
const unlisteners: UnlistenFn[] = [];
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
|
||||||
const updateById = (id: string, patch: PendingPatch) => {
|
|
||||||
setGames(prev => prev.map(item => item.id === id ? applyPatch(item, patch) : item));
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleErrorEvent = (
|
const handleErrorEvent = (
|
||||||
id: string,
|
id: string,
|
||||||
message: string,
|
message: string,
|
||||||
@@ -120,37 +93,16 @@ export const useGames = (rescanGameDir: () => void): UseGamesResult => {
|
|||||||
event.payload as GamesListPayload | Game[],
|
event.payload as GamesListPayload | Game[],
|
||||||
);
|
);
|
||||||
const activeStatuses = activeStatusById(payload.active_operations);
|
const activeStatuses = activeStatusById(payload.active_operations);
|
||||||
const hasAuthoritative = payload.active_operations !== undefined;
|
|
||||||
setGames(prev => {
|
setGames(prev => {
|
||||||
const previousById = new Map(prev.map(item => [item.id, item]));
|
const previousById = new Map(prev.map(item => [item.id, item]));
|
||||||
return payload.games.map(game => mergeGameUpdate(
|
return payload.games.map(game => mergeGameUpdate(
|
||||||
game,
|
game,
|
||||||
previousById.get(game.id),
|
previousById.get(game.id),
|
||||||
activeStatuses.get(game.id),
|
activeStatuses.get(game.id),
|
||||||
hasAuthoritative,
|
|
||||||
));
|
));
|
||||||
});
|
});
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// 'game-download-pre' confirms peer metadata was found. The backend may still
|
|
||||||
// reject the download during majority validation (which now emits a terminal fail event),
|
|
||||||
// so keep showing CheckingPeers until 'game-download-begin' reports that transfer started.
|
|
||||||
unlisteners.push(await listen('game-download-pre', (e) => {
|
|
||||||
const id = e.payload as string;
|
|
||||||
updateById(id, { install_status: InstallStatus.CheckingPeers, clearStatus: true });
|
|
||||||
}));
|
|
||||||
|
|
||||||
// 'game-download-begin' signals consensus size validation has completed and file transfer has started.
|
|
||||||
unlisteners.push(await listen('game-download-begin', (e) => {
|
|
||||||
const id = e.payload as string;
|
|
||||||
updateById(id, { install_status: InstallStatus.Downloading, clearStatus: true });
|
|
||||||
}));
|
|
||||||
|
|
||||||
unlisteners.push(await listen('game-download-finished', (e) => {
|
|
||||||
const id = e.payload as string;
|
|
||||||
updateById(id, { install_status: InstallStatus.Installing, clearStatus: true });
|
|
||||||
}));
|
|
||||||
|
|
||||||
unlisteners.push(await listen('game-download-failed', (e) => {
|
unlisteners.push(await listen('game-download-failed', (e) => {
|
||||||
handleErrorEvent(e.payload as string, 'Download failed. Please try again.', {
|
handleErrorEvent(e.payload as string, 'Download failed. Please try again.', {
|
||||||
triggerRescan: true,
|
triggerRescan: true,
|
||||||
@@ -167,18 +119,7 @@ export const useGames = (rescanGameDir: () => void): UseGamesResult => {
|
|||||||
handleErrorEvent(e.payload as string, 'No peers currently have this game.');
|
handleErrorEvent(e.payload as string, 'No peers currently have this game.');
|
||||||
}));
|
}));
|
||||||
|
|
||||||
unlisteners.push(await listen('game-install-begin', (e) => {
|
unlisteners.push(await listen('game-install-finished', () => {
|
||||||
const id = e.payload as string;
|
|
||||||
updateById(id, { install_status: InstallStatus.Installing, clearStatus: true });
|
|
||||||
}));
|
|
||||||
|
|
||||||
unlisteners.push(await listen('game-install-finished', (e) => {
|
|
||||||
const id = e.payload as string;
|
|
||||||
updateById(id, {
|
|
||||||
install_status: InstallStatus.Installed,
|
|
||||||
installed: true,
|
|
||||||
clearStatus: true,
|
|
||||||
});
|
|
||||||
rescanRef.current();
|
rescanRef.current();
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -186,40 +127,11 @@ export const useGames = (rescanGameDir: () => void): UseGamesResult => {
|
|||||||
handleErrorEvent(e.payload as string, 'Install failed. Please try again.');
|
handleErrorEvent(e.payload as string, 'Install failed. Please try again.');
|
||||||
}));
|
}));
|
||||||
|
|
||||||
unlisteners.push(await listen('game-uninstall-begin', (e) => {
|
|
||||||
updateById(e.payload as string, {
|
|
||||||
install_status: InstallStatus.Uninstalling,
|
|
||||||
clearStatus: true,
|
|
||||||
});
|
|
||||||
}));
|
|
||||||
|
|
||||||
unlisteners.push(await listen('game-uninstall-finished', (e) => {
|
|
||||||
updateById(e.payload as string, {
|
|
||||||
install_status: InstallStatus.NotInstalled,
|
|
||||||
installed: false,
|
|
||||||
clearStatus: true,
|
|
||||||
});
|
|
||||||
}));
|
|
||||||
|
|
||||||
unlisteners.push(await listen('game-uninstall-failed', (e) => {
|
unlisteners.push(await listen('game-uninstall-failed', (e) => {
|
||||||
handleErrorEvent(e.payload as string, 'Uninstall failed. Please try again.');
|
handleErrorEvent(e.payload as string, 'Uninstall failed. Please try again.');
|
||||||
}));
|
}));
|
||||||
|
|
||||||
unlisteners.push(await listen('game-remove-download-begin', (e) => {
|
unlisteners.push(await listen('game-remove-download-finished', () => {
|
||||||
updateById(e.payload as string, {
|
|
||||||
install_status: InstallStatus.Removing,
|
|
||||||
clearStatus: true,
|
|
||||||
});
|
|
||||||
}));
|
|
||||||
|
|
||||||
unlisteners.push(await listen('game-remove-download-finished', (e) => {
|
|
||||||
updateById(e.payload as string, {
|
|
||||||
install_status: InstallStatus.NotInstalled,
|
|
||||||
downloaded: false,
|
|
||||||
installed: false,
|
|
||||||
local_version: null,
|
|
||||||
clearStatus: true,
|
|
||||||
});
|
|
||||||
rescanRef.current();
|
rescanRef.current();
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -257,6 +169,5 @@ export const useGames = (rescanGameDir: () => void): UseGamesResult => {
|
|||||||
totalPeerCount,
|
totalPeerCount,
|
||||||
requestGames,
|
requestGames,
|
||||||
markChecking,
|
markChecking,
|
||||||
markInstalling,
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -17,19 +17,9 @@ const IN_PROGRESS_INSTALL_STATUSES = new Set<InstallStatus>([
|
|||||||
InstallStatus.Removing,
|
InstallStatus.Removing,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const RECONCILED_OPERATION_STATUSES = new Set<InstallStatus>([
|
|
||||||
InstallStatus.Downloading,
|
|
||||||
InstallStatus.Installing,
|
|
||||||
InstallStatus.Uninstalling,
|
|
||||||
InstallStatus.Removing,
|
|
||||||
]);
|
|
||||||
|
|
||||||
export const isInProgress = (status: InstallStatus): boolean =>
|
export const isInProgress = (status: InstallStatus): boolean =>
|
||||||
IN_PROGRESS_INSTALL_STATUSES.has(status);
|
IN_PROGRESS_INSTALL_STATUSES.has(status);
|
||||||
|
|
||||||
const isReconciledOperationStatus = (status: InstallStatus): boolean =>
|
|
||||||
RECONCILED_OPERATION_STATUSES.has(status);
|
|
||||||
|
|
||||||
export const installStatusFromActiveOperation = (op: ActiveOperationKind): InstallStatus => {
|
export const installStatusFromActiveOperation = (op: ActiveOperationKind): InstallStatus => {
|
||||||
switch (op) {
|
switch (op) {
|
||||||
case ActiveOperationKind.Downloading:
|
case ActiveOperationKind.Downloading:
|
||||||
@@ -52,35 +42,23 @@ export const normalizeGamesListPayload = (
|
|||||||
): GamesListPayload => Array.isArray(payload) ? { games: payload } : payload;
|
): GamesListPayload => Array.isArray(payload) ? { games: payload } : payload;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reconcile a freshly received backend snapshot of a game with our prior
|
* Reconcile a freshly received backend snapshot. Core operation status is
|
||||||
* locally-tracked install status. Keeps in-progress operations visible across
|
* derived only from the backend active-operation snapshot plus installed state.
|
||||||
* snapshots that don't yet reflect the running operation.
|
|
||||||
*/
|
*/
|
||||||
export const mergeGameUpdate = (
|
export const mergeGameUpdate = (
|
||||||
incoming: Game,
|
incoming: Game,
|
||||||
previous?: Game,
|
previous?: Game,
|
||||||
activeStatus?: InstallStatus,
|
activeStatus?: InstallStatus,
|
||||||
hasAuthoritativeSnapshot = false,
|
|
||||||
): Game => {
|
): Game => {
|
||||||
let installStatus = InstallStatus.NotInstalled;
|
const installStatus = activeStatus
|
||||||
if (activeStatus !== undefined) {
|
?? (incoming.installed ? InstallStatus.Installed : InstallStatus.NotInstalled);
|
||||||
installStatus = activeStatus;
|
|
||||||
} else if (incoming.installed) {
|
|
||||||
installStatus = InstallStatus.Installed;
|
|
||||||
} else if (
|
|
||||||
previous
|
|
||||||
&& isInProgress(previous.install_status)
|
|
||||||
&& (!hasAuthoritativeSnapshot || previous.install_status === InstallStatus.CheckingPeers)
|
|
||||||
) {
|
|
||||||
installStatus = previous.install_status;
|
|
||||||
}
|
|
||||||
|
|
||||||
const localStateChanged = previous !== undefined
|
const localStateChanged = previous !== undefined
|
||||||
&& (previous.installed !== incoming.installed || previous.downloaded !== incoming.downloaded);
|
&& (previous.installed !== incoming.installed || previous.downloaded !== incoming.downloaded);
|
||||||
const activeStateReconciled = hasAuthoritativeSnapshot
|
const statusChanged = previous !== undefined
|
||||||
&& (activeStatus !== undefined
|
&& previous.install_status !== installStatus;
|
||||||
|| (previous !== undefined && isReconciledOperationStatus(previous.install_status)));
|
const clearStatus = localStateChanged
|
||||||
const clearStatus = localStateChanged || activeStateReconciled;
|
|| (statusChanged && (activeStatus !== undefined || isInProgress(previous.install_status)));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...incoming,
|
...incoming,
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import {
|
||||||
|
activeStatusById,
|
||||||
|
mergeGameUpdate,
|
||||||
|
} from '../src/lib/gameState.ts';
|
||||||
|
import {
|
||||||
|
ActiveOperationKind,
|
||||||
|
GameAvailability,
|
||||||
|
InstallStatus,
|
||||||
|
type Game,
|
||||||
|
} from '../src/lib/types.ts';
|
||||||
|
|
||||||
|
const assertEquals = <T>(actual: T, expected: T, message: string) => {
|
||||||
|
if (actual !== expected) {
|
||||||
|
throw new Error(`${message}: expected ${expected}, got ${actual}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const game = (overrides: Partial<Game> = {}): Game => ({
|
||||||
|
id: 'game',
|
||||||
|
name: 'Game',
|
||||||
|
description: '',
|
||||||
|
size: 0,
|
||||||
|
downloaded: false,
|
||||||
|
installed: false,
|
||||||
|
availability: GameAvailability.LocalOnly,
|
||||||
|
install_status: InstallStatus.NotInstalled,
|
||||||
|
peer_count: 1,
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test('snapshot keeps installing visible until installed state settles', () => {
|
||||||
|
const fromDownloading = game({
|
||||||
|
install_status: InstallStatus.Downloading,
|
||||||
|
});
|
||||||
|
const installing = mergeGameUpdate(
|
||||||
|
game({ downloaded: true }),
|
||||||
|
fromDownloading,
|
||||||
|
InstallStatus.Installing,
|
||||||
|
);
|
||||||
|
const installedWhileActive = mergeGameUpdate(
|
||||||
|
game({ downloaded: true, installed: true }),
|
||||||
|
installing,
|
||||||
|
InstallStatus.Installing,
|
||||||
|
);
|
||||||
|
const settled = mergeGameUpdate(
|
||||||
|
game({ downloaded: true, installed: true }),
|
||||||
|
installedWhileActive,
|
||||||
|
);
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
installing.install_status,
|
||||||
|
InstallStatus.Installing,
|
||||||
|
'active install snapshot should render Installing',
|
||||||
|
);
|
||||||
|
assertEquals(
|
||||||
|
installedWhileActive.install_status,
|
||||||
|
InstallStatus.Installing,
|
||||||
|
'installed local state should not override an active install snapshot',
|
||||||
|
);
|
||||||
|
assertEquals(
|
||||||
|
settled.install_status,
|
||||||
|
InstallStatus.Installed,
|
||||||
|
'cleared active snapshot with installed local state should render Installed',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test('active operation snapshot is the source of busy status', () => {
|
||||||
|
const statuses = activeStatusById([
|
||||||
|
{ id: 'game', operation: ActiveOperationKind.Downloading },
|
||||||
|
{ id: 'other', operation: ActiveOperationKind.Updating },
|
||||||
|
]);
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
statuses.get('game'),
|
||||||
|
InstallStatus.Downloading,
|
||||||
|
'download operation should render Downloading',
|
||||||
|
);
|
||||||
|
assertEquals(
|
||||||
|
statuses.get('other'),
|
||||||
|
InstallStatus.Installing,
|
||||||
|
'update operation should render Installing',
|
||||||
|
);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user