diff --git a/README.md b/README.md index 5d52b2a..eeaea2e 100755 --- a/README.md +++ b/README.md @@ -6,6 +6,7 @@ [![Maintainability](https://sonarcloud.io/api/project_badges/measure?project=danielcopper_decky-romm-sync&metric=sqale_rating)](https://sonarcloud.io/summary/new_code?id=danielcopper_decky-romm-sync) [![Reliability](https://sonarcloud.io/api/project_badges/measure?project=danielcopper_decky-romm-sync&metric=reliability_rating)](https://sonarcloud.io/summary/new_code?id=danielcopper_decky-romm-sync) [![Security](https://sonarcloud.io/api/project_badges/measure?project=danielcopper_decky-romm-sync&metric=security_rating)](https://sonarcloud.io/summary/new_code?id=danielcopper_decky-romm-sync) +[![Downloads](https://img.shields.io/github/downloads/danielcopper/decky-romm-sync/total.svg)](https://github.com/danielcopper/decky-romm-sync/releases) [![License](https://img.shields.io/github/license/danielcopper/decky-romm-sync)](LICENSE) # decky-romm-sync diff --git a/main.py b/main.py index db33467..93724e9 100755 --- a/main.py +++ b/main.py @@ -627,6 +627,15 @@ async def delete_local_saves(self, rom_id): async def delete_platform_saves(self, platform_slug): return self._save_sync_service.delete_platform_saves(platform_slug) + async def saves_supports_version_history(self): + return self._save_sync_service.supports_version_history() + + async def saves_list_file_versions(self, rom_id, slot, filename): + return await self._save_sync_service.list_file_versions(rom_id, slot, filename) + + async def saves_rollback_to_version(self, rom_id, slot, filename, save_id, force=False): + return await self._save_sync_service.rollback_to_version(rom_id, slot, filename, save_id, force) + async def record_session_start(self, rom_id): return self._playtime_service.record_session_start(rom_id) diff --git a/py_modules/services/saves.py b/py_modules/services/saves.py index 34f19f5..f6bd642 100644 --- a/py_modules/services/saves.py +++ b/py_modules/services/saves.py @@ -900,6 +900,8 @@ def _build_file_status( "local_mtime": local_mtime, "local_size": local_size, "server_save_id": server.get("id") if server else None, + "server_file_name": server.get("file_name") if server else None, + "server_emulator": server.get("emulator") if server else None, "server_updated_at": server.get("updated_at", "") if server else None, "server_size": server.get("file_size_bytes") if server else None, "last_sync_at": last_sync_at, @@ -917,20 +919,25 @@ def _get_save_status_io(self, rom_id: int, server_saves: list[dict]) -> dict: local_files = self._find_save_files(rom_id) info = self._get_rom_save_info(rom_id) rom_name = info["rom_name"] if info else None + server_device_id = self._get_server_device_id() save_state = self._save_sync_state["saves"].get(rom_id_str, {}) files_state = save_state.get("files", {}) - # Match local files to server saves (same domain logic as _sync_rom_saves) + # Match local files to server saves (same domain logic as _sync_rom_saves). + # device_id is required so _find_newer_in_slot can distinguish foreign + # saves from our own. match_result = match_local_to_server_saves( local_files, server_saves, files_state, save_state.get("active_slot"), rom_name, + device_id=server_device_id, ) file_statuses = [] + newer_in_slot_conflicts: list[SaveConflict | dict] = [] for m in match_result.matched: if m.local_file: local_hash = self._file_md5(m.local_file["path"]) @@ -951,7 +958,7 @@ def _get_save_status_io(self, rom_id: int, server_saves: list[dict]) -> dict: server=server, last_sync_at=files_state.get(m.filename, {}).get("last_sync_at"), status=action, - server_device_id=self._get_server_device_id(), + server_device_id=server_device_id, ) ) elif m.server_save: @@ -966,14 +973,18 @@ def _get_save_status_io(self, rom_id: int, server_saves: list[dict]) -> dict: server=m.server_save, last_sync_at=None, status="download", - server_device_id=self._get_server_device_id(), + server_device_id=server_device_id, ) ) + # Surface newer-in-slot warnings (another device uploaded a newer + # save) alongside the status. Mirrors _sync_rom_saves behaviour + # so the banner appears on tab open, not only after a full sync. + self._check_newer_in_slot(m, files_state, rom_id, save_state, newer_in_slot_conflicts) playtime = self._save_sync_state.get("playtime", {}).get(rom_id_str, {}) save_entry = self._save_sync_state.get("saves", {}).get(rom_id_str, {}) - conflicts = [ + conflicts: list[SaveConflict | dict] = [ { "rom_id": rom_id, "filename": fs["filename"], @@ -989,6 +1000,7 @@ def _get_save_status_io(self, rom_id: int, server_saves: list[dict]) -> dict: for fs in file_statuses if fs["status"] == "conflict" ] + conflicts.extend(newer_in_slot_conflicts) return { "rom_id": rom_id, @@ -1906,6 +1918,212 @@ async def resolve_newer_in_slot(self, rom_id: int, filename: str, resolution: st # keep_current return {"success": True, "message": "Keeping current save"} + # ------------------------------------------------------------------ + # Version History API + # ------------------------------------------------------------------ + + def _find_file_state(self, rom_id_str: str, filename: str, server_saves: list[dict]) -> dict: + """Look up the per-file sync state for *filename*. + + Tries an exact key match first. If that misses (e.g. because the state + key includes RomM timestamp tags while *filename* is the plain local + name), falls back to scanning entries whose ``tracked_save_id`` resolves + to a server save with the same ``file_name_no_tags``. + """ + files_state = self._save_sync_state.get("saves", {}).get(rom_id_str, {}).get("files", {}) + + # Fast path: exact key match + exact = files_state.get(filename) + if exact and exact.get("tracked_save_id") is not None: + return exact + + # Slow path: derive base name from filename, scan for matching entry + fn_base = filename.rsplit(".", 1)[0] if "." in filename else filename + for _key, entry in files_state.items(): + tid = entry.get("tracked_save_id") + if tid is None: + continue + srv = next((s for s in server_saves if s.get("id") == tid), None) + if srv is None: + continue + srv_base = srv.get("file_name_no_tags") or "" + # file_name_no_tags strips region tags too (e.g. "(USA)"), + # so check if the local base starts with it + if srv_base and fn_base.startswith(srv_base): + return entry + + return {} + + def supports_version_history(self) -> bool: + """Return True if the connected RomM server supports version history. + + Version history requires RomM >= 4.7.0 (slot-based saves, per-device + sync tracking, and multiple server-side versions per filename). + """ + return self._romm_api.supports_device_sync() + + async def list_file_versions(self, rom_id: int, slot: str, filename: str) -> list[dict]: + """List older server-side versions of a save file. + + Returns versions strictly older than the currently-tracked save, + sorted newest-first. On v4.6 (no version history support), returns + an empty list. + + Each entry contains: id, updated_at, file_size_bytes, device_syncs. + """ + if not self.supports_version_history(): + return [] + + rom_id = int(rom_id) + rom_id_str = str(rom_id) + device_id = self._get_server_device_id() + + try: + server_saves = await self._loop.run_in_executor( + None, + lambda: self._retry.with_retry( + lambda: self._romm_api.list_saves(rom_id, device_id=device_id, slot=slot if slot else None) + ), + ) + except Exception: + return [] + + # Find the tracked save and its base name (file_name_no_tags) + file_state = self._find_file_state(rom_id_str, filename, server_saves) + tracked_id = file_state.get("tracked_save_id") + + # Resolve the base name from the tracked save on the server. + # Consistent with domain/save_sync.py server-only grouping which also + # uses file_name_no_tags to group saves that belong together. + tracked_save = next((s for s in server_saves if s.get("id") == tracked_id), None) + if tracked_save is None: + # Can't determine base name without a tracked save — no versions to show. + return [] + base_name = tracked_save.get("file_name_no_tags") or tracked_save.get("file_name", "") + + # Filter to saves with the same base name, excluding the tracked one + versions = [ + { + "id": s["id"], + "file_name": s.get("file_name", ""), + "emulator": s.get("emulator"), + "updated_at": s.get("updated_at", ""), + "file_size_bytes": s.get("file_size_bytes"), + "device_syncs": s.get("device_syncs", []), + } + for s in server_saves + if (s.get("file_name_no_tags") or s.get("file_name", "")) == base_name and s.get("id") != tracked_id + ] + + # Sort by updated_at descending (client-side — do not trust server order) + versions.sort(key=lambda v: v["updated_at"], reverse=True) + return versions + + def _rollback_to_version_io( + self, + rom_id_str: str, + filename: str, + save_id: int, + force: bool, + info: dict, + server_saves: list[dict], + ) -> dict: + """Blocking I/O portion of rollback_to_version — runs in executor.""" + # Find target save in server list (match by ID only — the target may + # have a different file_name due to RomM timestamp tagging) + target_save = next( + (s for s in server_saves if s.get("id") == save_id), + None, + ) + if target_save is None: + return {"status": "not_found"} + + saves_dir = info["saves_dir"] + local_path = os.path.join(saves_dir, filename) + system = info["system"] + + # Gate D: check for unsynced local changes + file_state = self._find_file_state(rom_id_str, filename, server_saves) + last_sync_hash = file_state.get("last_sync_hash") + + if not force and last_sync_hash and os.path.isfile(local_path): + current_hash = self._file_md5(local_path) + if current_hash != last_sync_hash: + return { + "status": "unsynced_changes", + "local_hash": current_hash, + "tracked_hash": last_sync_hash, + } + + # Download the target version to replace local file + self._do_download_save(target_save, saves_dir, filename, rom_id_str, system) + return {"status": "ok"} + + async def rollback_to_version( + self, rom_id: int, slot: str, filename: str, save_id: int, force: bool = False + ) -> dict: + """Roll back a save file to a specific older server version. + + Returns a status dict: + - ``{"status": "ok"}`` on success. + - ``{"status": "not_found"}`` if the target save id is not on the server. + - ``{"status": "unsupported"}`` if the server is pre-4.7. + - ``{"status": "unsynced_changes", "local_hash": ..., "tracked_hash": ...}`` + if local file has changed since last sync and ``force`` is False. + - ``{"status": "tracked_missing"}`` if the currently-tracked save no + longer exists on the server and ``force`` is False. + """ + if not self.supports_version_history(): + return {"status": "unsupported"} + + rom_id = int(rom_id) + rom_id_str = str(rom_id) + save_id = int(save_id) + + info = self._get_rom_save_info(rom_id) + if not info: + return {"status": "not_found"} + + device_id = self._get_server_device_id() + + # Fetch fresh server saves + try: + server_saves: list[dict] = await self._loop.run_in_executor( + None, + lambda: self._retry.with_retry( + lambda: self._romm_api.list_saves(rom_id, device_id=device_id, slot=slot if slot else None) + ), + ) + except Exception as e: + self._log_debug(f"rollback_to_version: failed to list saves: {e}") + return {"status": "not_found"} + + # Gate F: verify the currently-tracked save still exists on the server. + # Protects against accidental rollbacks after an unrelated deletion — + # bypassable via ``force`` once the user has acknowledged the warning. + file_state = self._find_file_state(rom_id_str, filename, server_saves) + tracked_id = file_state.get("tracked_save_id") + if not force and tracked_id is not None: + tracked_save = next((s for s in server_saves if s.get("id") == tracked_id), None) + if tracked_save is None: + return {"status": "tracked_missing"} + + result = await self._loop.run_in_executor( + None, + self._rollback_to_version_io, + rom_id_str, + filename, + save_id, + force, + info, + server_saves, + ) + + if result.get("status") == "ok": + self.save_state() + + return result + def get_save_sync_settings(self) -> dict: """Return current save sync settings.""" settings = self._save_sync_state.get("settings", {}) diff --git a/src/api/backend.ts b/src/api/backend.ts index 3f0ba72..3f4e1b6 100755 --- a/src/api/backend.ts +++ b/src/api/backend.ts @@ -192,6 +192,27 @@ export const deleteLocalSaves = callable<[number], { success: boolean; deleted_c export const deletePlatformSaves = callable<[string], { success: boolean; deleted_count: number; message: string }>("delete_platform_saves"); export const deletePlatformBios = callable<[string], { success: boolean; deleted_count: number; message: string }>("delete_platform_bios"); +// Save version history callables (v4.7+ only — gated by supportsVersionHistory) +export interface SaveVersionEntry { + id: number; + file_name: string; + emulator: string | null; + updated_at: string; + file_size_bytes: number | null; + device_syncs: Array<{ device_id: string; device_name: string; is_current: boolean; last_synced_at: string | null }>; +} + +export type RollbackStatus = + | { status: "ok" } + | { status: "not_found" } + | { status: "unsupported" } + | { status: "tracked_missing" } + | { status: "unsynced_changes"; local_hash: string; tracked_hash: string }; + +export const savesSupportsVersionHistory = callable<[], boolean>("saves_supports_version_history"); +export const savesListFileVersions = callable<[number, string, string], SaveVersionEntry[]>("saves_list_file_versions"); +export const savesRollbackToVersion = callable<[number, string, string, number, boolean], RollbackStatus>("saves_rollback_to_version"); + // Achievements callables export const getAchievements = callable<[number], AchievementList>("get_achievements"); export const getAchievementProgress = callable<[number], AchievementProgress>("get_achievement_progress"); diff --git a/src/components/RomMGameInfoPanel.tsx b/src/components/RomMGameInfoPanel.tsx index 9a58ea8..e8b1088 100644 --- a/src/components/RomMGameInfoPanel.tsx +++ b/src/components/RomMGameInfoPanel.tsx @@ -177,6 +177,8 @@ export const RomMGameInfoPanel: FC = ({ appId }) => { local_mtime: null, local_size: null, server_save_id: null, + server_file_name: null, + server_emulator: null, server_updated_at: null, server_size: null, last_sync_at: f.last_sync_at ?? null, diff --git a/src/components/SavesTab.tsx b/src/components/SavesTab.tsx index 2c974a1..7f858a2 100644 --- a/src/components/SavesTab.tsx +++ b/src/components/SavesTab.tsx @@ -12,10 +12,13 @@ import { useState, useEffect, useRef, createElement, FC, ChangeEvent } from "react"; import { ConfirmModal, DialogButton, Focusable, TextField, showModal } from "@decky/ui"; -import { getSlotSaves, switchSlot, debugLog } from "../api/backend"; +import { toaster } from "@decky/api"; +import { getSlotSaves, switchSlot, debugLog, savesSupportsVersionHistory, savesListFileVersions, savesRollbackToVersion } from "../api/backend"; +import type { SaveVersionEntry, RollbackStatus } from "../api/backend"; import { getRommConnectionState } from "../utils/connectionState"; -import type { SaveStatus, PendingConflict, SaveSlotSummary, SaveFileStatus, SlotSaveFile, SwitchSlotResponse } from "../types"; +import type { SaveStatus, PendingConflict, SaveSlotSummary, SaveFileStatus, SlotSaveFile, SwitchSlotResponse, DeviceSyncInfo } from "../types"; import { scrollFocusedToCenter } from "../utils/scrollHelpers"; +import { formatTimestamp } from "../utils/formatters"; // --- Type re-exports needed internally --- @@ -62,6 +65,17 @@ function formatRelativeTime(isoStr: string | null): string { return `${d} ${months[date.getMonth()]}`; } +/** Pick the most recently synced device from a device_syncs array, or null */ +function pickLastSyncer(syncs: DeviceSyncInfo[] | undefined): DeviceSyncInfo | null { + if (!syncs || syncs.length === 0) return null; + return syncs.reduce((latest, ds) => { + if (!latest) return ds; + if (!ds.last_synced_at) return latest; + if (!latest.last_synced_at) return ds; + return ds.last_synced_at > latest.last_synced_at ? ds : latest; + }, null); +} + /** Map a save file status to color and label */ function statusLabel(status: string, lastSyncAt: string | null): { color: string; label: string } { switch (status) { @@ -106,43 +120,243 @@ const NewSlotModal: FC<{ }; // --------------------------------------------------------------------------- -// Device sync info helper +// VersionHistoryPanel — expandable sub-panel below a save file row // --------------------------------------------------------------------------- -function renderDeviceSyncInfo(f: SaveFileStatus): (ReturnType | null)[] { - if (!f.device_syncs || f.device_syncs.length === 0) return []; +interface VersionHistoryPanelProps { + romId: number; + slot: string; + filename: string; + isOffline: boolean; + onRestored: () => void; +} - const lastSyncer = f.device_syncs.reduce((latest, ds) => { - if (!latest) return ds; - if (!ds.last_synced_at) return latest; - if (!latest.last_synced_at) return ds; - return ds.last_synced_at > latest.last_synced_at ? ds : latest; - }, f.device_syncs[0]); +const VersionHistoryPanel: FC = ({ + romId, + slot, + filename, + isOffline, + onRestored, +}) => { + const [expanded, setExpanded] = useState(false); + const [versions, setVersions] = useState(null); + const [loading, setLoading] = useState(false); + const [restoring, setRestoring] = useState(null); - const children: (ReturnType | null)[] = []; + const handleToggle = async () => { + const willExpand = !expanded; + setExpanded(willExpand); + if (willExpand && versions === null && !isOffline) { + setLoading(true); + try { + const result = await savesListFileVersions(romId, slot, filename); + setVersions(result); + } catch (e) { + debugLog(`VersionHistoryPanel: failed to load versions for ${filename}: ${e}`); + setVersions([]); + } finally { + setLoading(false); + } + } + }; - if (lastSyncer?.device_name) { - children.push(createElement("span", { - key: "device-info", - style: { fontSize: "11px", color: "rgba(255,255,255,0.5)" }, - }, `Last sync: ${lastSyncer.device_name} \u2713`)); - } + const handleRestore = async (version: SaveVersionEntry, force: boolean) => { + setRestoring(version.id); + // When the user needs to confirm via modal, we keep `restoring` set so the + // Restore button stays disabled until the modal is resolved (prevents a + // double-submit window between the first call's finally and the onOK + // callback re-entering handleRestore with force=true). + let awaitingModal = false; + try { + const result: RollbackStatus = await savesRollbackToVersion(romId, slot, filename, version.id, force); + if (result.status === "ok") { + toaster.toast({ title: "RomM Sync", body: `Save restored from ${formatRelativeTime(version.updated_at)}` }); + // Invalidate cache so next expand re-fetches + setVersions(null); + setExpanded(false); + onRestored(); + } else if (result.status === "unsynced_changes") { + awaitingModal = true; + showModal(createElement(ConfirmModal, { + strTitle: "Unsynced Local Changes", + strDescription: "Your local save has changes that haven't been synced to the server. Rolling back will discard them. Continue?", + strOKButtonText: "Roll Back", + strCancelButtonText: "Cancel", + onOK: () => { handleRestore(version, true); }, + onCancel: () => { setRestoring(null); }, + })); + } else if (result.status === "tracked_missing") { + awaitingModal = true; + showModal(createElement(ConfirmModal, { + strTitle: "Current save missing on server", + strDescription: "The save currently tracked by this device no longer exists on the server. Rolling back will discard the reference to it. Continue?", + strOKButtonText: "Roll Back", + strCancelButtonText: "Cancel", + onOK: () => { handleRestore(version, true); }, + onCancel: () => { setRestoring(null); }, + })); + } else if (result.status === "not_found") { + toaster.toast({ title: "RomM Sync", body: "This version no longer exists on the server" }); + } else if (result.status === "unsupported") { + toaster.toast({ title: "RomM Sync", body: "Version history requires RomM 4.7+" }); + } + } catch (e) { + debugLog(`VersionHistoryPanel: restore error for save ${version.id}: ${e}`); + } finally { + if (!awaitingModal) setRestoring(null); + } + }; - if (f.is_current === false) { - children.push(createElement("span", { - key: "not-current", - style: { fontSize: "11px", color: "#d4a72c", marginLeft: "8px" }, - }, "Newer version available on server")); - } + const versionCount = versions?.length ?? 0; + + const renderVersionRow = (v: SaveVersionEntry): ReturnType => { + const lastSyncer = pickLastSyncer(v.device_syncs); + const deviceName = lastSyncer?.device_name ?? null; + const isThisRestoring = restoring === v.id; + + // Line 1: #id · emulator · size + const headerParts: string[] = [`#${v.id}`]; + if (v.emulator) headerParts.push(v.emulator); + if (v.file_size_bytes != null) headerParts.push(formatBytes(v.file_size_bytes)); + + // Line 2: Last updated: [ · ] + const lastUpdatedParts: string[] = [formatTimestamp(v.updated_at)]; + if (deviceName) lastUpdatedParts.push(`${deviceName} \u2713`); + + return createElement("div", { + key: `ver-${v.id}`, + style: { + display: "flex", + alignItems: "flex-start", + gap: "8px", + padding: "6px 0", + borderBottom: "1px solid rgba(255,255,255,0.06)", + }, + }, + // Info column (grows) + createElement("div", { style: { flex: 1, minWidth: 0 } }, + // Line 1: #id · emulator · size + createElement("div", { + style: { fontSize: "12px", color: "#c7cdd3", fontWeight: 600 }, + }, headerParts.join(" \u00B7 ")), + // Line 2: last updated + device + createElement("div", { + style: { + fontSize: "11px", + color: "#8f98a0", + marginTop: "2px", + }, + }, + createElement("span", { style: { color: "#697075" } }, "Last updated: "), + lastUpdatedParts.join(" \u00B7 "), + ), + // Line 3: server filename (technical, bottom) + createElement("div", { + style: { + fontSize: "11px", + color: "#8f98a0", + fontFamily: "monospace", + wordBreak: "break-all" as const, + marginTop: "2px", + }, + }, v.file_name), + ), + // Restore button (fixed right, disabled when offline) + createElement(DialogButton as any, { + style: { + padding: "2px 8px", + minWidth: "auto", + fontSize: "11px", + width: "auto", + flexShrink: 0, + }, + noFocusRing: false, + onFocus: scrollFocusedToCenter, + disabled: isThisRestoring || restoring !== null || isOffline, + onClick: () => { handleRestore(v, false); }, + }, isThisRestoring ? "Restoring..." : "Restore"), + ); + }; - if (children.length === 0) return []; - return [createElement("div", { key: "device-sync", style: { marginTop: "2px" } }, ...children)]; -} + const renderBody = (): ReturnType | ReturnType[] => { + if (isOffline) { + return createElement("div", { + style: { fontSize: "11px", color: "#8f98a0", fontStyle: "italic" as const }, + }, "Offline \u2014 versions unavailable"); + } + if (loading) { + return createElement("div", { style: { fontSize: "11px", color: "#8f98a0" } }, "Loading..."); + } + if (versionCount === 0) { + return createElement("div", { + style: { fontSize: "11px", color: "#8f98a0", fontStyle: "italic" as const }, + }, "No older versions available"); + } + return (versions ?? []).map(renderVersionRow); + }; + + return createElement("div", { + key: `history-${filename}`, + style: { marginTop: "4px", marginLeft: "8px" }, + }, + // Expander toggle + createElement(DialogButton as any, { + style: { + background: "transparent", + border: "none", + padding: "2px 0", + textAlign: "left" as const, + width: "100%", + cursor: "pointer", + display: "flex", + alignItems: "center", + gap: "4px", + fontSize: "11px", + color: "#8f98a0", + }, + noFocusRing: false, + onFocus: scrollFocusedToCenter, + onClick: handleToggle, + }, + createElement("span", {}, expanded ? "\u25BE" : "\u25B8"), + createElement("span", {}, expanded && versions !== null + ? `Previous Versions (${versionCount})` + : "Previous Versions"), + ), + + // Version list (lazy-loaded) + expanded + ? createElement("div", { style: { marginTop: "4px" } }, renderBody()) + : null, + ); +}; // --------------------------------------------------------------------------- // SaveFileRow — one row in the active slot body // --------------------------------------------------------------------------- +// Label column width — keeps values aligned vertically across rows +const LABEL_WIDTH = "88px"; + +/** Render a labeled info row (label column + value column) inside the tracked save block */ +function infoRow( + key: string, + label: string, + value: ReturnType | string | null, + valueColor = "#c7cdd3", +): ReturnType | null { + if (value == null || value === "") return null; + return createElement("div", { + key, + style: { display: "flex", alignItems: "flex-start", fontSize: "11px", marginTop: "2px" }, + }, + createElement("span", { + style: { color: "#697075", width: LABEL_WIDTH, flexShrink: 0 }, + }, label), + createElement("div", { style: { color: valueColor, flex: 1, minWidth: 0 } }, value), + ); +} + function renderSaveFileRow( f: SaveFileStatus, conflict: PendingConflict | undefined, @@ -150,17 +364,61 @@ function renderSaveFileRow( ): ReturnType { const { color, label } = statusLabel(f.status, f.last_sync_at); const syncTime = lastSyncCheckAt || f.last_sync_at; - - const details: string[] = []; - if (f.local_size != null) details.push(formatBytes(f.local_size)); - if (f.local_mtime) details.push(`Changed ${formatRelativeTime(f.local_mtime)}`); + const lastSyncer = pickLastSyncer(f.device_syncs); + const conflictActive = f.status === "conflict" || !!conflict; + + // Header value pieces (right-aligned meta: size + status) + const headerMeta: (ReturnType | null)[] = []; + if (f.local_size != null) { + headerMeta.push(createElement("span", { + key: "size", + style: { fontSize: "11px", color: "#8f98a0" }, + }, formatBytes(f.local_size))); + } + headerMeta.push(createElement("span", { + key: "status", + className: "romm-save-status-label", + style: { color, fontSize: "11px", fontWeight: 600 }, + }, label)); + + // Last synced value: "just now · steamdeck ✓" + const lastSyncedPieces: string[] = []; + if (syncTime) { + lastSyncedPieces.push(formatRelativeTime(syncTime) || "Never"); + } else { + lastSyncedPieces.push("Never"); + } + if (lastSyncer?.device_name) { + lastSyncedPieces.push(`${lastSyncer.device_name} \u2713`); + } + if (f.is_current === false) { + lastSyncedPieces.push("Newer version available on server"); + } + const lastSyncedValue = lastSyncedPieces.join(" \u00B7 "); + + // Server save value — two lines: "#18 · retroarch-mgba" / "" + const serverValueLines: ReturnType[] = []; + if (f.server_save_id != null) { + const headerParts: string[] = [`#${f.server_save_id}`]; + if (f.server_emulator) headerParts.push(f.server_emulator); + serverValueLines.push(createElement("div", { + key: "srv-head", + style: { color: "#c7cdd3" }, + }, headerParts.join(" \u00B7 "))); + if (f.server_file_name) { + serverValueLines.push(createElement("div", { + key: "srv-fn", + style: { color: "#8f98a0", fontFamily: "monospace", wordBreak: "break-all" as const, marginTop: "1px" }, + }, f.server_file_name)); + } + } return createElement(DialogButton as any, { key: f.filename, style: { background: "transparent", border: "none", - padding: "6px 0", + padding: "8px 0", textAlign: "left" as const, width: "100%", cursor: "default", @@ -169,45 +427,60 @@ function renderSaveFileRow( noFocusRing: false, onFocus: scrollFocusedToCenter, }, - // Filename + // Header row: filename (left) + size + status badge (right) createElement("div", { - style: { fontSize: "12px", color: "#dcdedf", fontWeight: 500, marginBottom: "3px" }, - }, f.filename), - - // Status label · size · changed time - createElement("div", { - style: { display: "flex", alignItems: "center", gap: "6px", flexWrap: "wrap" as const }, + style: { + display: "flex", + alignItems: "center", + justifyContent: "space-between", + gap: "8px", + marginBottom: "4px", + }, }, - createElement("span", { - className: "romm-save-status-label", - style: { color }, - }, label), - details.length > 0 - ? createElement("span", { style: { fontSize: "11px", color: "#8f98a0" } }, - `\u00B7 ${details.join(" \u00B7 ")}`) - : null, - syncTime - ? createElement("span", { style: { fontSize: "11px", color: "#8f98a0" } }, - `\u00B7 Synced ${formatRelativeTime(syncTime)}`) - : null, + createElement("div", { + style: { + fontSize: "13px", + color: "#dcdedf", + fontWeight: 600, + overflow: "hidden", + textOverflow: "ellipsis", + whiteSpace: "nowrap" as const, + flex: 1, + minWidth: 0, + }, + }, f.filename), + createElement("div", { + style: { display: "flex", alignItems: "center", gap: "8px", flexShrink: 0 }, + }, ...headerMeta), ), - // Device sync info (v4.7+) - ...renderDeviceSyncInfo(f), - - // Conflict detail - (f.status === "conflict" || conflict) + // Conflict banner (prominent) + conflictActive ? createElement("div", { - style: { fontSize: "11px", color: "#d94126", fontWeight: 600, marginTop: "2px" }, - }, "Conflict detected — resolve from the sync action") + style: { fontSize: "11px", color: "#d94126", fontWeight: 600, marginTop: "2px", marginBottom: "2px" }, + }, "Conflict detected \u2014 resolve from the sync action") : null, - // Local path + // Info rows + infoRow("last-synced", "Last synced:", lastSyncedValue), + infoRow( + "last-updated", + "Last updated:", + f.server_updated_at ? formatTimestamp(f.server_updated_at) : null, + "#8f98a0", + ), + serverValueLines.length > 0 + ? infoRow("server", "Server save:", createElement("div", {}, ...serverValueLines)) + : null, f.local_path - ? createElement("div", { - className: "romm-panel-file-path", - style: { marginTop: "3px" }, - }, f.local_path) + ? infoRow( + "path", + "Local path:", + createElement("span", { + style: { fontFamily: "monospace", wordBreak: "break-all" as const }, + }, f.local_path), + "#5a6066", + ) : null, ); } @@ -264,11 +537,28 @@ function computeSyncSummary( function renderActiveSlotBody( saveStatus: SaveStatus | null, conflicts: PendingConflict[], + romId: number, + slot: string, + supportsVersionHistory: boolean, + isOffline: boolean, + onVersionRestored: () => void, ): (ReturnType | null)[] { if (saveStatus && saveStatus.files.length > 0) { return saveStatus.files.map((f) => { const conflict = conflicts.find((c) => c.filename === f.filename); - return renderSaveFileRow(f, conflict, saveStatus.last_sync_check_at); + return createElement("div", { key: f.filename }, + renderSaveFileRow(f, conflict, saveStatus.last_sync_check_at), + supportsVersionHistory + ? createElement(VersionHistoryPanel, { + key: `vhp-${f.filename}`, + romId, + slot, + filename: f.filename, + isOffline, + onRestored: onVersionRestored, + }) + : null, + ); }); } return [createElement("div", { key: "no-files", style: { fontSize: "13px", color: MUTED_COLOR, fontStyle: "italic" } }, @@ -280,6 +570,7 @@ function renderInactiveSlotBody( slotFiles: SlotSaveFile[] | null, switching: boolean, switchError: string | null, + isOffline: boolean, handleActivate: () => void, ): (ReturnType | null)[] { const children: (ReturnType | null)[] = []; @@ -302,9 +593,15 @@ function renderInactiveSlotBody( style: { padding: "4px 12px", minWidth: "auto", fontSize: "12px", width: "auto" }, noFocusRing: false, onFocus: scrollFocusedToCenter, - disabled: switching, + disabled: switching || isOffline, onClick: handleActivate, }, switching ? "Switching..." : "Activate Slot"), + isOffline + ? createElement("div", { + key: "offline-hint", + style: { fontSize: "11px", color: "#8f98a0", fontStyle: "italic" as const, marginTop: "4px" }, + }, "Offline \u2014 slot switching unavailable") + : null, switchError ? createElement("div", { key: "switch-error", @@ -325,8 +622,11 @@ interface SlotPanelProps { // Active slot data (only set when isActive === true) saveStatus: SaveStatus | null; conflicts: PendingConflict[]; + supportsVersionHistory: boolean; + isOffline: boolean; // Callbacks onSlotSwitched: (newSlot: string, newStatus: SaveStatus) => void; + onVersionRestored: () => void; } const SlotPanel: FC = ({ @@ -336,7 +636,10 @@ const SlotPanel: FC = ({ defaultExpanded, saveStatus, conflicts, + supportsVersionHistory, + isOffline, onSlotSwitched, + onVersionRestored, }) => { const [expanded, setExpanded] = useState(defaultExpanded); const [slotFiles, setSlotFiles] = useState(null); @@ -457,8 +760,8 @@ const SlotPanel: FC = ({ let bodyChildren: (ReturnType | null)[] = []; if (expanded) { bodyChildren = isActive - ? renderActiveSlotBody(saveStatus, conflicts) - : renderInactiveSlotBody(loadingSlot, slotFiles, switching, switchError, handleActivate); + ? renderActiveSlotBody(saveStatus, conflicts, romId, slotName, supportsVersionHistory, isOffline, onVersionRestored) + : renderInactiveSlotBody(loadingSlot, slotFiles, switching, switchError, isOffline, handleActivate); } const bodyEl = expanded @@ -490,6 +793,28 @@ export const SavesTab: FC = ({ const [newSlotError, setNewSlotError] = useState(null); const newSlotErrorTimerRef = useRef | null>(null); const [isOffline, setIsOffline] = useState(getRommConnectionState() === "offline"); + const [supportsVersionHistory, setSupportsVersionHistory] = useState(false); + // Bumped to invalidate VersionHistoryPanel caches after a restore + const [versionHistoryKey, setVersionHistoryKey] = useState(0); + + useEffect(() => { + // Skip while offline — preserve last-known capability so we don't flicker + // the UI off if the server is briefly unreachable. Re-fetches on reconnect + // via the isOffline dep. + if (isOffline) return; + savesSupportsVersionHistory() + .then((supported) => setSupportsVersionHistory(!!supported)) + .catch(() => setSupportsVersionHistory(false)); + }, [isOffline]); + + const handleVersionRestored = () => { + setVersionHistoryKey((k) => k + 1); + // Trigger parent refresh of saveStatus so the tracked save row reflects + // the new tracked_save_id / server fields without leaving the page. + globalThis.dispatchEvent(new CustomEvent("romm_data_changed", { + detail: { type: "save_sync", rom_id: romId }, + })); + }; useEffect(() => { const onConnectionChanged = (e: Event) => { @@ -644,14 +969,17 @@ export const SavesTab: FC = ({ .map((slot) => { const isActive = activeSlot !== null && slot.slot === activeSlot; return createElement(SlotPanel, { - key: `panel-${slot.slot}`, + key: `panel-${slot.slot}-${versionHistoryKey}`, romId, slot, isActive, defaultExpanded: isActive, saveStatus: isActive ? saveStatus : null, conflicts: isActive ? conflicts : [], + supportsVersionHistory, + isOffline, onSlotSwitched, + onVersionRestored: handleVersionRestored, }); }), diff --git a/src/types/index.ts b/src/types/index.ts index d0bf4b1..2b968a6 100755 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -305,6 +305,8 @@ export interface SaveFileStatus { local_mtime: string | null; local_size: number | null; server_save_id: number | null; + server_file_name: string | null; + server_emulator: string | null; server_updated_at: string | null; server_size: number | null; last_sync_at: string | null; diff --git a/tests/services/test_saves.py b/tests/services/test_saves.py index 7fab012..0fb40be 100644 --- a/tests/services/test_saves.py +++ b/tests/services/test_saves.py @@ -86,11 +86,16 @@ def _server_save( updated_at="2026-02-17T06:00:00Z", file_size_bytes=1024, slot=_SERVER_SAVE_SENTINEL, + file_name_no_tags=None, ): + if file_name_no_tags is None: + # Strip extension to approximate RomM's file_name_no_tags + file_name_no_tags = filename.rsplit(".", 1)[0] if "." in filename else filename result = { "id": save_id, "rom_id": rom_id, "file_name": filename, + "file_name_no_tags": file_name_no_tags, "updated_at": updated_at, "file_size_bytes": file_size_bytes, "emulator": "retroarch", @@ -3715,3 +3720,390 @@ async def test_server_legacy_save_maps_to_empty_string_not_default(self, tmp_pat # Must be "" (legacy key), NOT "default" assert "" in slot_names assert "default" not in slot_names + + +# --------------------------------------------------------------------------- +# TestSupportsVersionHistory +# --------------------------------------------------------------------------- + + +class TestSupportsVersionHistory: + """Tests for SaveService.supports_version_history — capability check.""" + + def test_returns_false_on_v46(self, tmp_path): + """Returns False when the API adapter does not support device sync (v4.6).""" + svc, fake = make_service(tmp_path) + assert fake._supports_device_sync is False + assert svc.supports_version_history() is False + + def test_returns_true_on_v47(self, tmp_path): + """Returns True when the API adapter supports device sync (v4.7+).""" + svc, fake = make_service(tmp_path) + fake._supports_device_sync = True + assert svc.supports_version_history() is True + + +# --------------------------------------------------------------------------- +# TestListFileVersions +# --------------------------------------------------------------------------- + + +class TestListFileVersions: + """Tests for SaveService.list_file_versions.""" + + def _setup_state(self, svc, tracked_id: int | None) -> None: + """Populate save state with a tracked save id for rom 42, pokemon.srm.""" + svc._save_sync_state["saves"]["42"] = { + "system": "gba", + "active_slot": "default", + "files": { + "pokemon.srm": {"tracked_save_id": tracked_id}, + }, + } + + @pytest.mark.asyncio + async def test_returns_empty_on_v46(self, tmp_path): + """Returns empty list on v4.6 (no version history support).""" + svc, fake = make_service(tmp_path) + # v4.6: _supports_device_sync is False + fake.saves[100] = _server_save(save_id=100, rom_id=42, slot="default") + result = await svc.list_file_versions(42, "default", "pokemon.srm") + assert result == [] + + @pytest.mark.asyncio + async def test_happy_path_excludes_tracked(self, tmp_path): + """Returns older versions, excluding the currently-tracked save.""" + svc, fake = make_service(tmp_path) + fake._supports_device_sync = True + # tracked save + fake.saves[100] = _server_save(save_id=100, rom_id=42, slot="default", updated_at="2026-03-10T10:00:00Z") + # older version + fake.saves[50] = _server_save(save_id=50, rom_id=42, slot="default", updated_at="2026-03-01T10:00:00Z") + # different base name — should not appear + fake.saves[60] = { + "id": 60, + "rom_id": 42, + "file_name": "other.srm", + "file_name_no_tags": "other", + "updated_at": "2026-03-05T10:00:00Z", + "file_size_bytes": 512, + "slot": "default", + "download_path": "/saves/other.srm", + } + self._setup_state(svc, tracked_id=100) + + result = await svc.list_file_versions(42, "default", "pokemon.srm") + + assert len(result) == 1 + assert result[0]["id"] == 50 + assert result[0]["updated_at"] == "2026-03-01T10:00:00Z" + + @pytest.mark.asyncio + async def test_matches_by_file_name_no_tags(self, tmp_path): + """Saves with different file_name but same file_name_no_tags are included.""" + svc, fake = make_service(tmp_path) + fake._supports_device_sync = True + # tracked: timestamped filename from initial POST + fake.saves[100] = _server_save( + save_id=100, + rom_id=42, + slot="default", + updated_at="2026-03-10T10:00:00Z", + filename="pokemon [2026-03-10_10-00-00].srm", + file_name_no_tags="pokemon", + ) + # another device's upload: different filename, same base + fake.saves[50] = _server_save( + save_id=50, + rom_id=42, + slot="default", + updated_at="2026-03-01T10:00:00Z", + filename="pokemon [2026-03-01_10-00-00].srm", + file_name_no_tags="pokemon", + ) + self._setup_state(svc, tracked_id=100) + + result = await svc.list_file_versions(42, "default", "pokemon.srm") + + assert len(result) == 1 + assert result[0]["id"] == 50 + + @pytest.mark.asyncio + async def test_sorted_newest_first(self, tmp_path): + """Versions are sorted by updated_at descending.""" + svc, fake = make_service(tmp_path) + fake._supports_device_sync = True + fake.saves[100] = _server_save(save_id=100, rom_id=42, slot="default", updated_at="2026-03-10T10:00:00Z") + fake.saves[30] = _server_save(save_id=30, rom_id=42, slot="default", updated_at="2026-02-01T10:00:00Z") + fake.saves[50] = _server_save(save_id=50, rom_id=42, slot="default", updated_at="2026-03-01T10:00:00Z") + self._setup_state(svc, tracked_id=100) + + result = await svc.list_file_versions(42, "default", "pokemon.srm") + + assert len(result) == 2 + assert result[0]["id"] == 50 # newer of the two old versions first + assert result[1]["id"] == 30 + + @pytest.mark.asyncio + async def test_empty_when_no_older_versions(self, tmp_path): + """Returns empty list when there are no versions other than the tracked one.""" + svc, fake = make_service(tmp_path) + fake._supports_device_sync = True + fake.saves[100] = _server_save(save_id=100, rom_id=42, slot="default", updated_at="2026-03-10T10:00:00Z") + self._setup_state(svc, tracked_id=100) + + result = await svc.list_file_versions(42, "default", "pokemon.srm") + + assert result == [] + + @pytest.mark.asyncio + async def test_no_tracked_save_returns_empty(self, tmp_path): + """When no tracked_save_id in state, returns empty — can't determine base name.""" + svc, fake = make_service(tmp_path) + fake._supports_device_sync = True + fake.saves[100] = _server_save(save_id=100, rom_id=42, slot="default", updated_at="2026-03-10T10:00:00Z") + fake.saves[50] = _server_save(save_id=50, rom_id=42, slot="default", updated_at="2026-03-01T10:00:00Z") + # No state at all (tracked_id is None) + + result = await svc.list_file_versions(42, "default", "pokemon.srm") + + assert result == [] + + @pytest.mark.asyncio + async def test_api_error_returns_empty(self, tmp_path): + """Returns empty list when the server call fails.""" + svc, fake = make_service(tmp_path) + fake._supports_device_sync = True + fake.fail_on_next(Exception("network error")) + + result = await svc.list_file_versions(42, "default", "pokemon.srm") + + assert result == [] + + @pytest.mark.asyncio + async def test_result_shape(self, tmp_path): + """Each entry contains the required fields: id, updated_at, file_size_bytes, device_syncs.""" + svc, fake = make_service(tmp_path) + fake._supports_device_sync = True + fake.saves[100] = _server_save(save_id=100, rom_id=42, slot="default", updated_at="2026-03-10T10:00:00Z") + fake.saves[50] = { + "id": 50, + "rom_id": 42, + "file_name": "pokemon [2026-03-01_10-00-00].srm", + "file_name_no_tags": "pokemon", + "emulator": "retroarch-mgba", + "updated_at": "2026-03-01T10:00:00Z", + "file_size_bytes": 2048, + "device_syncs": [ + {"device_id": "abc", "device_name": "steamdeck", "is_current": True, "last_synced_at": None} + ], + "slot": "default", + "download_path": "/saves/pokemon.srm", + } + self._setup_state(svc, tracked_id=100) + + result = await svc.list_file_versions(42, "default", "pokemon.srm") + + assert len(result) == 1 + entry = result[0] + assert entry["id"] == 50 + assert entry["file_name"] == "pokemon [2026-03-01_10-00-00].srm" + assert entry["emulator"] == "retroarch-mgba" + assert entry["updated_at"] == "2026-03-01T10:00:00Z" + assert entry["file_size_bytes"] == 2048 + assert len(entry["device_syncs"]) == 1 + assert entry["device_syncs"][0]["device_name"] == "steamdeck" + + +# --------------------------------------------------------------------------- +# TestRollbackToVersion +# --------------------------------------------------------------------------- + + +class TestRollbackToVersion: + """Tests for SaveService.rollback_to_version — the core rollback flow.""" + + def _setup_state(self, svc, tmp_path, tracked_id: int, last_sync_hash: str | None = None) -> None: + _install_rom(svc, tmp_path) + svc._save_sync_state["saves"]["42"] = { + "system": "gba", + "active_slot": "default", + "files": { + "pokemon.srm": { + "tracked_save_id": tracked_id, + "last_sync_hash": last_sync_hash, + }, + }, + } + + @pytest.mark.asyncio + async def test_returns_unsupported_on_v46(self, tmp_path): + """Returns unsupported when server does not support version history.""" + svc, _fake = make_service(tmp_path) + # v4.6: _supports_device_sync is False + result = await svc.rollback_to_version(42, "default", "pokemon.srm", 50) + assert result == {"status": "unsupported"} + + @pytest.mark.asyncio + async def test_returns_not_found_when_rom_not_installed(self, tmp_path): + """Returns not_found when the ROM is not in installed_roms.""" + svc, fake = make_service(tmp_path) + fake._supports_device_sync = True + # rom 999 is not installed + result = await svc.rollback_to_version(999, "default", "pokemon.srm", 50) + assert result == {"status": "not_found"} + + @pytest.mark.asyncio + async def test_returns_not_found_when_save_id_missing(self, tmp_path): + """Returns not_found when target save_id is not in the server response.""" + svc, fake = make_service(tmp_path) + fake._supports_device_sync = True + _install_rom(svc, tmp_path) + fake.saves[100] = _server_save(save_id=100, rom_id=42, slot="default") + # Request save_id=999, which doesn't exist + result = await svc.rollback_to_version(42, "default", "pokemon.srm", 999) + assert result == {"status": "not_found"} + + @pytest.mark.asyncio + async def test_proceeds_even_with_newer_foreign_save(self, tmp_path): + """Rollback is not blocked when a newer save exists in the slot. + + Gate E was removed — rollback is an explicit user action. The newer + save warning is surfaced through the separate newer-in-slot conflict + flow, not by blocking the rollback. + """ + svc, fake = make_service(tmp_path) + fake._supports_device_sync = True + self._setup_state(svc, tmp_path, tracked_id=100) + _create_save(tmp_path) + # tracked save + fake.saves[100] = _server_save(save_id=100, rom_id=42, slot="default") + # newer save from another device — should NOT block rollback + fake.saves[200] = _server_save(save_id=200, rom_id=42, slot="default", updated_at="2026-03-20T10:00:00Z") + fake.saves[50] = _server_save(save_id=50, rom_id=42, slot="default", updated_at="2026-02-01T10:00:00Z") + + result = await svc.rollback_to_version(42, "default", "pokemon.srm", 50) + assert result["status"] == "ok" + + @pytest.mark.asyncio + async def test_tracked_missing_blocked_without_force(self, tmp_path): + """Returns tracked_missing when the currently tracked save is gone from the server.""" + svc, fake = make_service(tmp_path) + fake._supports_device_sync = True + self._setup_state(svc, tmp_path, tracked_id=999) + _create_save(tmp_path) + # Tracked save 999 does NOT exist on server + fake.saves[50] = _server_save(save_id=50, rom_id=42, slot="default", updated_at="2026-02-01T10:00:00Z") + + result = await svc.rollback_to_version(42, "default", "pokemon.srm", 50) + assert result == {"status": "tracked_missing"} + + @pytest.mark.asyncio + async def test_tracked_missing_bypassed_by_force(self, tmp_path): + """force=True bypasses the tracked_missing guard.""" + svc, fake = make_service(tmp_path) + fake._supports_device_sync = True + self._setup_state(svc, tmp_path, tracked_id=999) + _create_save(tmp_path) + fake.saves[50] = _server_save(save_id=50, rom_id=42, slot="default", updated_at="2026-02-01T10:00:00Z") + + result = await svc.rollback_to_version(42, "default", "pokemon.srm", 50, force=True) + assert result["status"] == "ok" + + @pytest.mark.asyncio + async def test_unsynced_changes_blocked_without_force(self, tmp_path): + """Returns unsynced_changes when local file differs from last_sync_hash and force=False.""" + svc, fake = make_service(tmp_path) + fake._supports_device_sync = True + save_file = _create_save(tmp_path, content=b"\xff" * 1024) + local_hash = _file_md5(str(save_file)) + # Set a different hash as the "last synced" hash + self._setup_state(svc, tmp_path, tracked_id=100, last_sync_hash="aabbcc001122334455667788") + fake.saves[100] = _server_save(save_id=100, rom_id=42, slot="default") + fake.saves[50] = _server_save(save_id=50, rom_id=42, slot="default", updated_at="2026-02-01T10:00:00Z") + + result = await svc.rollback_to_version(42, "default", "pokemon.srm", 50) + + assert result["status"] == "unsynced_changes" + assert result["local_hash"] == local_hash + assert result["tracked_hash"] == "aabbcc001122334455667788" + + @pytest.mark.asyncio + async def test_force_overrides_unsynced_check(self, tmp_path): + """force=True skips the unsynced changes check and proceeds with download.""" + svc, fake = make_service(tmp_path) + fake._supports_device_sync = True + _create_save(tmp_path, content=b"\xff" * 1024) + self._setup_state(svc, tmp_path, tracked_id=100, last_sync_hash="aabbcc001122334455667788") + fake.saves[100] = _server_save(save_id=100, rom_id=42, slot="default") + fake.saves[50] = _server_save(save_id=50, rom_id=42, slot="default", updated_at="2026-02-01T10:00:00Z") + + result = await svc.rollback_to_version(42, "default", "pokemon.srm", 50, force=True) + + assert result["status"] == "ok" + download_calls = [c for c in fake.call_log if c[0] == "download_save"] + assert any(c[1][0] == 50 for c in download_calls) + + @pytest.mark.asyncio + async def test_happy_path_downloads_and_updates_state(self, tmp_path): + """Happy path: rollback downloads the target save and updates file sync state.""" + svc, fake = make_service(tmp_path) + fake._supports_device_sync = True + _create_save(tmp_path) + local_hash = _file_md5(str(tmp_path / "saves" / "gba" / "pokemon.srm")) + self._setup_state(svc, tmp_path, tracked_id=100, last_sync_hash=local_hash) + # tracked (no change since last sync) + fake.saves[100] = _server_save(save_id=100, rom_id=42, slot="default") + # older version to roll back to + fake.saves[50] = _server_save(save_id=50, rom_id=42, slot="default", updated_at="2026-02-01T10:00:00Z") + + result = await svc.rollback_to_version(42, "default", "pokemon.srm", 50) + + assert result["status"] == "ok" + # Verify download was called with save_id=50 + download_calls = [c for c in fake.call_log if c[0] == "download_save"] + assert any(c[1][0] == 50 for c in download_calls) + # State updated: tracked_save_id should now point to the rolled-back save + file_state = svc._save_sync_state["saves"]["42"]["files"]["pokemon.srm"] + assert file_state["tracked_save_id"] == 50 + + @pytest.mark.asyncio + async def test_no_local_file_no_unsynced_block(self, tmp_path): + """When local file doesn't exist, unsynced check is skipped.""" + svc, fake = make_service(tmp_path) + fake._supports_device_sync = True + # Set a last_sync_hash but no local file + self._setup_state(svc, tmp_path, tracked_id=100, last_sync_hash="somehash") + fake.saves[100] = _server_save(save_id=100, rom_id=42, slot="default") + fake.saves[50] = _server_save(save_id=50, rom_id=42, slot="default", updated_at="2026-02-01T10:00:00Z") + + result = await svc.rollback_to_version(42, "default", "pokemon.srm", 50) + + assert result["status"] == "ok" + + @pytest.mark.asyncio + async def test_no_state_entry_no_unsynced_block(self, tmp_path): + """When there's no last_sync_hash in state, unsynced check is skipped.""" + svc, fake = make_service(tmp_path) + fake._supports_device_sync = True + _create_save(tmp_path, content=b"\xff" * 1024) + # No last_sync_hash (first time syncing) + self._setup_state(svc, tmp_path, tracked_id=100, last_sync_hash=None) + fake.saves[100] = _server_save(save_id=100, rom_id=42, slot="default") + fake.saves[50] = _server_save(save_id=50, rom_id=42, slot="default", updated_at="2026-02-01T10:00:00Z") + + result = await svc.rollback_to_version(42, "default", "pokemon.srm", 50) + + assert result["status"] == "ok" + + @pytest.mark.asyncio + async def test_server_error_returns_not_found(self, tmp_path): + """Returns not_found when the server call fails.""" + svc, fake = make_service(tmp_path) + fake._supports_device_sync = True + _install_rom(svc, tmp_path) + fake.fail_on_next(Exception("network error")) + + result = await svc.rollback_to_version(42, "default", "pokemon.srm", 50) + + assert result["status"] == "not_found" diff --git a/tests/test_plugin_saves.py b/tests/test_plugin_saves.py index 8dbdef4..890bf92 100644 --- a/tests/test_plugin_saves.py +++ b/tests/test_plugin_saves.py @@ -830,3 +830,168 @@ async def test_delete_platform_saves(plugin, tmp_path): assert not srm2.exists() assert "10" not in plugin._save_sync_state["saves"] assert "20" not in plugin._save_sync_state["saves"] + + +# ============================================================================ +# Version History callables (plugin-level integration) +# ============================================================================ + + +class TestSavesVersionHistoryCallables: + """Integration tests for the three version history callables.""" + + @pytest.mark.asyncio + async def test_saves_supports_version_history_false(self, plugin): + """saves_supports_version_history returns False on v4.6 (FakeSaveApi default).""" + result = await plugin.saves_supports_version_history() + assert result is False + + @pytest.mark.asyncio + async def test_saves_supports_version_history_true(self, plugin): + """saves_supports_version_history returns True when adapter supports device sync.""" + plugin._fake_api._supports_device_sync = True + result = await plugin.saves_supports_version_history() + assert result is True + + @pytest.mark.asyncio + async def test_saves_list_file_versions_empty_on_v46(self, plugin, tmp_path): + """saves_list_file_versions returns empty list on v4.6.""" + plugin._fake_api.saves[100] = { + "id": 100, + "rom_id": 42, + "file_name": "pokemon.srm", + "updated_at": "2026-03-01T00:00:00Z", + "file_size_bytes": 1024, + "slot": "default", + "download_path": "/saves/pokemon.srm", + } + result = await plugin.saves_list_file_versions(42, "default", "pokemon.srm") + assert result == [] + + @pytest.mark.asyncio + async def test_saves_list_file_versions_happy_path(self, plugin, tmp_path): + """saves_list_file_versions returns filtered older versions on v4.7+.""" + plugin._fake_api._supports_device_sync = True + plugin._save_sync_state["saves"]["42"] = { + "system": "gba", + "active_slot": "default", + "files": {"pokemon.srm": {"tracked_save_id": 100}}, + } + plugin._fake_api.saves[100] = { + "id": 100, + "rom_id": 42, + "file_name": "pokemon.srm", + "updated_at": "2026-03-10T00:00:00Z", + "file_size_bytes": 1024, + "slot": "default", + "download_path": "/saves/pokemon.srm", + } + plugin._fake_api.saves[50] = { + "id": 50, + "rom_id": 42, + "file_name": "pokemon.srm", + "updated_at": "2026-03-01T00:00:00Z", + "file_size_bytes": 512, + "slot": "default", + "download_path": "/saves/pokemon.srm", + } + + result = await plugin.saves_list_file_versions(42, "default", "pokemon.srm") + + assert len(result) == 1 + assert result[0]["id"] == 50 + + @pytest.mark.asyncio + async def test_saves_rollback_to_version_unsupported_on_v46(self, plugin, tmp_path): + """saves_rollback_to_version returns unsupported on v4.6.""" + result = await plugin.saves_rollback_to_version(42, "default", "pokemon.srm", 50) + assert result == {"status": "unsupported"} + + @pytest.mark.asyncio + async def test_saves_rollback_to_version_happy_path(self, plugin, tmp_path): + """saves_rollback_to_version downloads the target save on success.""" + plugin._fake_api._supports_device_sync = True + _install_rom(plugin, tmp_path) + + # Create local save file with content matching last_sync_hash + saves_dir = tmp_path / "retrodeck" / "saves" / "gba" + saves_dir.mkdir(parents=True, exist_ok=True) + save_file = saves_dir / "pokemon.srm" + save_file.write_bytes(b"\x00" * 1024) + + import hashlib + + local_hash = hashlib.md5(b"\x00" * 1024).hexdigest() + + plugin._save_sync_state["saves"]["42"] = { + "system": "gba", + "active_slot": "default", + "files": {"pokemon.srm": {"tracked_save_id": 100, "last_sync_hash": local_hash}}, + } + plugin._fake_api.saves[100] = { + "id": 100, + "rom_id": 42, + "file_name": "pokemon.srm", + "updated_at": "2026-03-10T00:00:00Z", + "file_size_bytes": 1024, + "slot": "default", + "download_path": "/saves/pokemon.srm", + } + plugin._fake_api.saves[50] = { + "id": 50, + "rom_id": 42, + "file_name": "pokemon.srm", + "updated_at": "2026-03-01T00:00:00Z", + "file_size_bytes": 1024, + "slot": "default", + "download_path": "/saves/pokemon.srm", + } + + result = await plugin.saves_rollback_to_version(42, "default", "pokemon.srm", 50) + + assert result["status"] == "ok" + download_calls = [c for c in plugin._fake_api.call_log if c[0] == "download_save"] + assert any(c[1][0] == 50 for c in download_calls) + + @pytest.mark.asyncio + async def test_saves_rollback_to_version_force_param(self, plugin, tmp_path): + """saves_rollback_to_version passes force=True to service when specified.""" + plugin._fake_api._supports_device_sync = True + _install_rom(plugin, tmp_path) + + saves_dir = tmp_path / "retrodeck" / "saves" / "gba" + saves_dir.mkdir(parents=True, exist_ok=True) + save_file = saves_dir / "pokemon.srm" + save_file.write_bytes(b"\xff" * 1024) # different from last_sync_hash + + plugin._save_sync_state["saves"]["42"] = { + "system": "gba", + "active_slot": "default", + "files": {"pokemon.srm": {"tracked_save_id": 100, "last_sync_hash": "aabbcc"}}, + } + plugin._fake_api.saves[100] = { + "id": 100, + "rom_id": 42, + "file_name": "pokemon.srm", + "updated_at": "2026-03-10T00:00:00Z", + "file_size_bytes": 1024, + "slot": "default", + "download_path": "/saves/pokemon.srm", + } + plugin._fake_api.saves[50] = { + "id": 50, + "rom_id": 42, + "file_name": "pokemon.srm", + "updated_at": "2026-03-01T00:00:00Z", + "file_size_bytes": 1024, + "slot": "default", + "download_path": "/saves/pokemon.srm", + } + + # Without force=True, should return unsynced_changes + result_no_force = await plugin.saves_rollback_to_version(42, "default", "pokemon.srm", 50, False) + assert result_no_force["status"] == "unsynced_changes" + + # With force=True, should succeed + result_force = await plugin.saves_rollback_to_version(42, "default", "pokemon.srm", 50, True) + assert result_force["status"] == "ok"