Release SyncVito 1.0.7

This commit is contained in:
Davito committed 2026-09-24 16:04:51 +02:00
1 parent 3d6ab87ce3
commit a04db4ee96
10 files changed
+278 -28

No files matched your search

+75
View File
@@ -0,0 +1,75 @@
from pathlib import Path
import pytest
from PyQt6.QtWidgets import QApplication
from syncvito.config_store import ConfigStore
from syncvito.coordinator import SyncCoordinator
from syncvito.models import AppConfig, SyncTask, TaskRuntimeState
from syncvito.sync_engine import BlockedFile, SyncResult
@pytest.fixture
def coordinator(tmp_path: Path):
application = QApplication.instance() or QApplication([])
task = SyncTask(name="AppData", source=str(tmp_path / "source"), target=str(tmp_path / "target"))
coordinator = SyncCoordinator(AppConfig(tasks=[task]), ConfigStore(tmp_path / "config.json"))
yield coordinator
coordinator._schedule_timer.stop()
coordinator._retry_timer.stop()
coordinator.stop()
assert application is not None
def test_many_locks_have_compact_banner_and_full_paths_in_log(coordinator: SyncCoordinator) -> None:
banners = []
logs = []
coordinator.lock_changed.connect(banners.append)
coordinator.log_created.connect(lambda *entry: logs.append(entry))
blocked = [BlockedFile(Path(f"AppData/Programm{index}/Cookies")) for index in range(300)]
task = coordinator.config.tasks[0]
coordinator._on_finished(task.id, SyncResult(copied=123, blocked=blocked))
assert len(banners[-1]) < 400
assert "123 kopiert" in banners[-1]
assert "300 Dateien gesperrt" in banners[-1]
assert "unvollständig" in banners[-1]
assert len(logs) == 301
assert str(blocked[-1].path) in logs[-1][2]
assert coordinator.task_state(task.id) is TaskRuntimeState.WAITING_FOR_RELEASE
coordinator._on_finished(task.id, SyncResult(copied=300))
assert banners[-1] == ""
assert task.id not in coordinator._retry_tasks
assert coordinator.task_state(task.id) is TaskRuntimeState.ACTIVE
def test_errors_are_visible_even_when_other_files_are_locked(coordinator: SyncCoordinator) -> None:
logs = []
coordinator.log_created.connect(lambda *entry: logs.append(entry))
task = coordinator.config.tasks[0]
coordinator._on_finished(task.id, SyncResult(blocked=[BlockedFile(Path("Cookies"))], errors=["Zugriff verweigert"]))
assert coordinator.task_state(task.id) is TaskRuntimeState.ERROR
assert any(level == "Fehler" and detail == "Zugriff verweigert" for _, level, detail in logs)
@pytest.mark.parametrize("all_locked", [False, True])
def test_retry_can_run_after_partial_release(
coordinator: SyncCoordinator, monkeypatch: pytest.MonkeyPatch, all_locked: bool
) -> None:
task = coordinator.config.tasks[0]
coordinator._retry_tasks[task.id] = [Path("dauerhaft"), Path("freigegeben")]
queued = []
monkeypatch.setattr(coordinator, "enqueue", queued.append)
monkeypatch.setattr("syncvito.coordinator.is_file_locked", lambda path: all_locked or path.name == "dauerhaft")
coordinator._check_retries()
assert queued == ([] if all_locked else [task.id])
def test_retry_queues_all_ready_tasks(coordinator: SyncCoordinator, monkeypatch: pytest.MonkeyPatch) -> None:
coordinator._retry_tasks = {"first": [], "second": []}
queued = []
monkeypatch.setattr(coordinator, "enqueue", queued.append)
coordinator._check_retries()
assert queued == ["first", "second"]
+74
View File
@@ -0,0 +1,74 @@
import ctypes
import os
from contextlib import contextmanager
from ctypes import wintypes
from pathlib import Path
import pytest
from syncvito.file_locks import is_file_locked
from syncvito.models import SyncTask
from syncvito.state_store import StateStore
from syncvito.sync_engine import SyncEngine
pytestmark = pytest.mark.skipif(os.name != "nt", reason="Windows-Dateisperren")
@contextmanager
def exclusive_lock(path: Path):
kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
kernel32.CreateFileW.argtypes = [
wintypes.LPCWSTR,
wintypes.DWORD,
wintypes.DWORD,
ctypes.c_void_p,
wintypes.DWORD,
wintypes.DWORD,
wintypes.HANDLE,
]
kernel32.CreateFileW.restype = wintypes.HANDLE
kernel32.CloseHandle.argtypes = [wintypes.HANDLE]
kernel32.CloseHandle.restype = wintypes.BOOL
handle = kernel32.CreateFileW(str(path), 0x80000000, 0, None, 3, 0x80, None)
assert handle != ctypes.c_void_p(-1).value, ctypes.get_last_error()
try:
yield
finally:
assert kernel32.CloseHandle(handle)
def test_detects_real_lock_and_release(tmp_path: Path) -> None:
path = tmp_path / "gesperrt.txt"
path.write_text("Inhalt")
assert not is_file_locked(path)
with exclusive_lock(path):
assert is_file_locked(path)
assert not is_file_locked(path)
assert not is_file_locked(tmp_path / "fehlt.txt")
@pytest.mark.parametrize("locked_side", ["source", "target"])
def test_sync_preserves_backup_and_continues_past_lock(tmp_path: Path, locked_side: str) -> None:
source = tmp_path / "source"
target = tmp_path / "target"
source.mkdir()
target.mkdir()
(source / "a.txt").write_text("neue Version")
(target / "a.txt").write_text("alt")
(source / "b.txt").write_text("lesbar")
locked_path = tmp_path / locked_side / "a.txt"
task = SyncTask(name="AppData", source=str(source), target=str(target))
engine = SyncEngine(StateStore(tmp_path / "state.json"))
with exclusive_lock(locked_path):
result = engine.synchronize(task)
assert not result.successful
assert [item.path for item in result.blocked] == [locked_path]
assert not result.errors
assert result.copied == 1
assert (target / "b.txt").read_text() == "lesbar"
assert (target / "a.txt").read_text() == "alt"
assert not list(target.glob("*.tmp"))
assert engine.synchronize(task).successful
assert (target / "a.txt").read_text() == "neue Version"
+8 -1
View File
@@ -14,7 +14,14 @@ def test_healthy_active_tasks_are_green() -> None:
def test_waiting_lock_has_priority_over_active_task() -> None:
states = [TaskRuntimeState.ACTIVE, TaskRuntimeState.WAITING_FOR_RELEASE]
assert derive_overall_status(states, globally_paused=False) == ("error", "Wartet auf Dateifreigabe")
assert derive_overall_status(states, globally_paused=False) == ("error", "Unvollständig – Dateien gesperrt")
def test_waiting_task_does_not_hide_running_sync() -> None:
assert derive_overall_status([TaskRuntimeState.WAITING_FOR_RELEASE], False, running=True) == (
"error",
"Synchronisiert",
)
def test_error_has_highest_priority() -> None:
+33
View File
@@ -1,6 +1,8 @@
import os
from pathlib import Path
import pytest
from syncvito.models import ConflictMode, DeleteMode, SyncDirection, SyncTask
from syncvito.state_store import StateStore
from syncvito.sync_engine import SyncEngine
@@ -10,6 +12,37 @@ def _engine(tmp_path: Path) -> SyncEngine:
return SyncEngine(StateStore(tmp_path / "state.json"))
@pytest.mark.parametrize("winerror", [None, 5])
def test_access_denied_is_not_reported_as_file_lock(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, winerror: int | None
) -> None:
source = tmp_path / "source"
target = tmp_path / "target"
source.mkdir()
(source / "geschuetzt.txt").write_text("geschützt")
(source / "lesbar.txt").write_text("lesbar")
from syncvito import sync_engine
copy = sync_engine._atomic_copy
def copy_with_denied_file(source_path: Path, target_path: Path) -> None:
if source_path.name == "geschuetzt.txt":
error = PermissionError(13, "Zugriff verweigert", str(source_path))
if winerror is not None:
error.winerror = winerror
raise error
copy(source_path, target_path)
monkeypatch.setattr(sync_engine, "_atomic_copy", copy_with_denied_file)
result = _engine(tmp_path).synchronize(SyncTask(name="AppData", source=str(source), target=str(target)))
assert not result.successful
assert not result.blocked
assert len(result.errors) == 1
assert str(source / "geschuetzt.txt") in result.errors[0]
assert (target / "lesbar.txt").read_text() == "lesbar"
def test_one_way_copy_and_mirror_delete(tmp_path: Path) -> None:
source = tmp_path / "source"
target = tmp_path / "target"