Initial release with Gitea updater

This commit is contained in:
Davito committed 2026-09-23 19:02:23 +02:00
commit 46c2474985
36 files changed
+3674

No files matched your search

+30
View File
@@ -0,0 +1,30 @@
from pathlib import Path
from syncvito.config_store import ConfigStore
from syncvito.models import AppConfig, AppSettings, SyncDirection, SyncTask
def test_config_roundtrip(tmp_path: Path) -> None:
path = tmp_path / "config.json"
config = AppConfig(
tasks=[
SyncTask(
name="Archiv",
source=str(tmp_path / "quelle"),
target=str(tmp_path / "ziel"),
direction=SyncDirection.BIDIRECTIONAL,
selected_paths=["Dokumente"],
)
],
settings=AppSettings(font_size=12, ui_scale=110, check_updates_on_start=False),
)
ConfigStore(path).save(config)
loaded = ConfigStore(path).load()
assert loaded.settings.font_size == 12
assert loaded.settings.ui_scale == 110
assert loaded.settings.check_updates_on_start is False
assert loaded.tasks[0].name == "Archiv"
assert loaded.tasks[0].direction is SyncDirection.BIDIRECTIONAL
assert loaded.tasks[0].selected_paths == ["Dokumente"]
+26
View File
@@ -0,0 +1,26 @@
from syncvito.coordinator import derive_overall_status
from syncvito.models import TaskRuntimeState
def test_no_active_task_is_red() -> None:
assert derive_overall_status([], globally_paused=False) == ("paused", "Inaktiv")
assert derive_overall_status([TaskRuntimeState.PAUSED], globally_paused=False) == ("paused", "Inaktiv")
def test_healthy_active_tasks_are_green() -> None:
states = [TaskRuntimeState.ACTIVE, TaskRuntimeState.PAUSED]
assert derive_overall_status(states, globally_paused=False) == ("active", "Bereit")
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")
def test_error_has_highest_priority() -> None:
states = [TaskRuntimeState.WAITING_FOR_RELEASE, TaskRuntimeState.ERROR]
assert derive_overall_status(states, globally_paused=False) == ("error", "Fehler")
def test_global_pause_is_red_without_errors() -> None:
assert derive_overall_status([TaskRuntimeState.ACTIVE], globally_paused=True) == ("paused", "Pausiert")
+126
View File
@@ -0,0 +1,126 @@
import os
from pathlib import Path
from syncvito.models import ConflictMode, DeleteMode, SyncDirection, SyncTask
from syncvito.state_store import StateStore
from syncvito.sync_engine import SyncEngine
def _engine(tmp_path: Path) -> SyncEngine:
return SyncEngine(StateStore(tmp_path / "state.json"))
def test_one_way_copy_and_mirror_delete(tmp_path: Path) -> None:
source = tmp_path / "source"
target = tmp_path / "target"
source.mkdir()
target.mkdir()
(source / "neu.txt").write_text("aktuell", encoding="utf-8")
(target / "alt.txt").write_text("veraltet", encoding="utf-8")
task = SyncTask(
name="Einweg",
source=str(source),
target=str(target),
delete_mode=DeleteMode.MIRROR,
)
result = _engine(tmp_path).synchronize(task)
assert result.successful
assert (target / "neu.txt").read_text(encoding="utf-8") == "aktuell"
assert not (target / "alt.txt").exists()
def test_selection_limits_synced_files(tmp_path: Path) -> None:
source = tmp_path / "source"
target = tmp_path / "target"
(source / "auswahl").mkdir(parents=True)
target.mkdir()
(source / "auswahl" / "ja.txt").write_text("ja", encoding="utf-8")
(source / "nein.txt").write_text("nein", encoding="utf-8")
task = SyncTask(
name="Auswahl",
source=str(source),
target=str(target),
selected_paths=["auswahl"],
)
_engine(tmp_path).synchronize(task)
assert (target / "auswahl" / "ja.txt").exists()
assert not (target / "nein.txt").exists()
def test_bidirectional_newer_file_wins(tmp_path: Path) -> None:
source = tmp_path / "source"
target = tmp_path / "target"
source.mkdir()
target.mkdir()
left = source / "datei.txt"
right = target / "datei.txt"
left.write_text("links", encoding="utf-8")
right.write_text("rechts", encoding="utf-8")
os.utime(left, ns=(2_000_000_000, 2_000_000_000))
os.utime(right, ns=(1_000_000_000, 1_000_000_000))
task = SyncTask(
name="Beide",
source=str(source),
target=str(target),
direction=SyncDirection.BIDIRECTIONAL,
)
result = _engine(tmp_path).synchronize(task)
assert result.successful
assert right.read_text(encoding="utf-8") == "links"
def test_conflict_copy_preserves_replaced_version(tmp_path: Path) -> None:
source = tmp_path / "source"
target = tmp_path / "target"
source.mkdir()
target.mkdir()
left = source / "plan.txt"
right = target / "plan.txt"
left.write_text("neu", encoding="utf-8")
right.write_text("alt", encoding="utf-8")
os.utime(left, ns=(3_000_000_000, 3_000_000_000))
os.utime(right, ns=(1_000_000_000, 1_000_000_000))
task = SyncTask(
name="Konflikt",
source=str(source),
target=str(target),
direction=SyncDirection.BIDIRECTIONAL,
conflict_mode=ConflictMode.CONFLICT_COPY,
)
result = _engine(tmp_path).synchronize(task)
conflict_files = list(target.glob("plan_conflict_*.txt"))
assert result.successful
assert result.conflicts == 1
assert right.read_text(encoding="utf-8") == "neu"
assert conflict_files[0].read_text(encoding="utf-8") == "alt"
def test_bidirectional_mirrors_known_deletion(tmp_path: Path) -> None:
source = tmp_path / "source"
target = tmp_path / "target"
source.mkdir()
target.mkdir()
(source / "entfernen.txt").write_text("inhalt", encoding="utf-8")
task = SyncTask(
name="Löschung",
source=str(source),
target=str(target),
direction=SyncDirection.BIDIRECTIONAL,
delete_mode=DeleteMode.MIRROR,
)
engine = _engine(tmp_path)
engine.synchronize(task)
(target / "entfernen.txt").unlink()
result = engine.synchronize(task)
assert result.successful
assert not (source / "entfernen.txt").exists()
+67
View File
@@ -0,0 +1,67 @@
from pathlib import Path
import pytest
from syncvito.updater import GiteaUpdateService, UpdateFormatError, parse_version
def _release(version: str, *, draft: bool = False, prerelease: bool = False) -> dict[str, object]:
return {
"tag_name": f"v{version}",
"name": f"SyncVito {version}",
"body": "Änderungen",
"html_url": f"https://gitea.example/releases/v{version}",
"draft": draft,
"prerelease": prerelease,
"assets": [
{
"name": "SyncVito.exe",
"browser_download_url": "https://gitea.example/download/SyncVito.exe",
"size": 1234,
},
{
"name": "SyncVito.exe.sha256",
"browser_download_url": "https://gitea.example/download/SyncVito.exe.sha256",
"size": 80,
},
],
}
def test_parse_version_accepts_stable_tags() -> None:
assert parse_version("v1.2.3") == (1, 2, 3)
assert parse_version("10.20.30") == (10, 20, 30)
def test_parse_version_rejects_prerelease() -> None:
with pytest.raises(ValueError):
parse_version("1.2.3-beta")
def test_check_selects_newest_stable_release(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
service = GiteaUpdateService("1.0.0", base_url="https://gitea.example", update_directory=tmp_path)
releases = [_release("1.1.0"), _release("2.0.0", prerelease=True), _release("1.3.0")]
monkeypatch.setattr(service, "_read_json", lambda _url: releases)
update = service.check()
assert update is not None
assert update.version == "1.3.0"
assert update.download_url.endswith("SyncVito.exe")
def test_check_returns_none_for_current_version(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
service = GiteaUpdateService("1.3.0", base_url="https://gitea.example", update_directory=tmp_path)
monkeypatch.setattr(service, "_read_json", lambda _url: [_release("1.3.0"), _release("1.2.0")])
assert service.check() is None
def test_release_requires_checksum(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
service = GiteaUpdateService("1.0.0", base_url="https://gitea.example", update_directory=tmp_path)
release = _release("1.1.0")
release["assets"] = [release["assets"][0]] # type: ignore[index]
monkeypatch.setattr(service, "_read_json", lambda _url: [release])
with pytest.raises(UpdateFormatError, match="Prüfsumme|SyncVito.exe.sha256"):
service.check()