Initial SyncGames tree: agent, Android, deploy, docs.
Session-gated MinIO save sync with AppImage GUI, CLI edit/session flow, and Gitea release helper. Co-authored-by: Cursor <[email protected]>
This commit is contained in:
@@ -0,0 +1,24 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from syncgames.config import AgentConfig, save_agent_config, try_load_agent_config
|
||||
|
||||
|
||||
def test_save_and_reload_agent(tmp_path: Path, monkeypatch):
|
||||
monkeypatch.setenv("SYNCGAMES_CONFIG", str(tmp_path))
|
||||
cfg = AgentConfig(
|
||||
device_id="laptop",
|
||||
endpoint_url="https://syncgames-s3.example.com",
|
||||
bucket="syncgames",
|
||||
access_key="ak",
|
||||
secret_key="sk",
|
||||
config_root=tmp_path,
|
||||
)
|
||||
path = save_agent_config(cfg)
|
||||
assert path.exists()
|
||||
loaded = try_load_agent_config(tmp_path)
|
||||
assert loaded is not None
|
||||
assert loaded.device_id == "laptop"
|
||||
assert loaded.endpoint_url.endswith("example.com")
|
||||
assert loaded.secret_key == "sk"
|
||||
@@ -0,0 +1,71 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from syncgames.config import AgentConfig, GameConfig
|
||||
from syncgames.errors import StaleWipError
|
||||
from syncgames.session import end_session, start_session
|
||||
from syncgames.store import FilesystemStore
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def env(tmp_path: Path):
|
||||
cfg_root = tmp_path / "cfg"
|
||||
games = cfg_root / "games"
|
||||
games.mkdir(parents=True)
|
||||
native = tmp_path / "native" / "saves"
|
||||
native.mkdir(parents=True)
|
||||
(native / "slot1.sav").write_bytes(b"v1")
|
||||
|
||||
cfg = AgentConfig(
|
||||
device_id="pc-test",
|
||||
endpoint_url="",
|
||||
store="filesystem",
|
||||
ssot_root=tmp_path / "ssot",
|
||||
config_root=cfg_root,
|
||||
state_root=tmp_path / "state",
|
||||
)
|
||||
game = GameConfig(
|
||||
id="demo",
|
||||
name="Demo",
|
||||
platform="other",
|
||||
paths=[str(native)],
|
||||
versions_to_keep=3,
|
||||
)
|
||||
# seed remote empty then first end will push after start with empty live
|
||||
store = FilesystemStore(cfg.ssot_root)
|
||||
return cfg, store, game, native
|
||||
|
||||
|
||||
def test_start_end_roundtrip(env):
|
||||
cfg, store, game, native = env
|
||||
start_session(cfg, store, game)
|
||||
(native / "slot1.sav").write_bytes(b"v2-progress")
|
||||
meta = end_session(cfg, store, game)
|
||||
assert meta.live_hash
|
||||
assert store.list_live("demo")
|
||||
|
||||
|
||||
def test_hash_gate_blocks_stale(env):
|
||||
cfg, store, game, native = env
|
||||
start_session(cfg, store, game)
|
||||
(native / "slot1.sav").write_bytes(b"A")
|
||||
end_session(cfg, store, game)
|
||||
|
||||
# Simulate stale session with old parent after remote advanced elsewhere
|
||||
start_session(cfg, store, game)
|
||||
# Mutate remote live out from under us
|
||||
lives = list((Path(cfg.ssot_root) / "games" / "demo" / "live").rglob("slot1.sav"))
|
||||
assert lives
|
||||
lives[0].write_bytes(b"REMOTE-NEWER")
|
||||
# Update meta live_hash to mismatch parent
|
||||
meta = store.get_meta("demo")
|
||||
assert meta
|
||||
meta.raw["live_hash"] = "sha256:deadbeef"
|
||||
store.put_meta("demo", meta)
|
||||
|
||||
(native / "slot1.sav").write_bytes(b"local-stale-wip")
|
||||
with pytest.raises(StaleWipError):
|
||||
end_session(cfg, store, game)
|
||||
@@ -0,0 +1,56 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from syncgames.hashutil import tree_hash
|
||||
from syncgames.store import FilesystemStore, empty_meta
|
||||
|
||||
|
||||
def test_tree_hash_stable():
|
||||
a = {"b.bin": "sha256:aa", "a.bin": "sha256:bb"}
|
||||
b = {"a.bin": "sha256:bb", "b.bin": "sha256:aa"}
|
||||
assert tree_hash(a) == tree_hash(b)
|
||||
|
||||
|
||||
def test_filesystem_roundtrip(tmp_path: Path):
|
||||
store = FilesystemStore(tmp_path / "ssot")
|
||||
game = "test-game"
|
||||
meta = empty_meta(game, 3)
|
||||
store.put_meta(game, meta)
|
||||
assert store.get_meta(game) is not None
|
||||
|
||||
local = tmp_path / "wip"
|
||||
local.mkdir()
|
||||
(local / "save.dat").write_bytes(b"hello-save")
|
||||
checksums = store.upload_prefix(game, "live", "", local)
|
||||
assert "save.dat" in checksums
|
||||
|
||||
dest = tmp_path / "down"
|
||||
got = store.download_live(game, dest)
|
||||
assert got["save.dat"].startswith("sha256:")
|
||||
assert (dest / "save.dat").read_bytes() == b"hello-save"
|
||||
|
||||
store.upload_prefix(game, "history", "phone/20260101T000000Z", local)
|
||||
hist = store.list_history(game)
|
||||
assert any(h.startswith("phone/") for h in hist)
|
||||
|
||||
probe = store.probe()
|
||||
assert probe.startswith("sha256:")
|
||||
|
||||
|
||||
def test_meta_json_roundtrip(tmp_path: Path):
|
||||
store = FilesystemStore(tmp_path)
|
||||
m = empty_meta("g1")
|
||||
m.raw["lease"] = {
|
||||
"holder": "pc",
|
||||
"expires_at": "2099-01-01T00:00:00Z",
|
||||
"session_id": "x",
|
||||
}
|
||||
store.put_meta("g1", m)
|
||||
again = store.get_meta("g1")
|
||||
assert again is not None
|
||||
assert again.lease["holder"] == "pc"
|
||||
# ensure valid json file
|
||||
text = (tmp_path / "games" / "g1" / "meta.json").read_text()
|
||||
json.loads(text)
|
||||
Reference in New Issue
Block a user