310 lines
12 KiB
Python
310 lines
12 KiB
Python
"""Tests for EvoScientist.paths — set_workspace_root and ensure_dirs."""
|
|
|
|
import os
|
|
from unittest import mock
|
|
|
|
import pytest
|
|
|
|
from EvoScientist import paths
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _restore_paths():
|
|
"""Snapshot module-level path globals and restore after each test."""
|
|
orig = {
|
|
"WORKSPACE_ROOT": paths.WORKSPACE_ROOT,
|
|
"RUNS_DIR": paths.RUNS_DIR,
|
|
"DATA_DIR": paths.DATA_DIR,
|
|
"MEMORIES_DIR": paths.MEMORIES_DIR,
|
|
"MEMORY_DIR": paths.MEMORY_DIR,
|
|
"GLOBAL_SKILLS_DIR": paths.GLOBAL_SKILLS_DIR,
|
|
"GLOBAL_MEMORIES_DIR": paths.GLOBAL_MEMORIES_DIR,
|
|
"USER_SKILLS_DIR": paths.USER_SKILLS_DIR,
|
|
"_active_workspace": paths._active_workspace,
|
|
"_EVOSCIENTIST_DATA_ROOT": paths._EVOSCIENTIST_DATA_ROOT,
|
|
}
|
|
yield
|
|
paths.WORKSPACE_ROOT = orig["WORKSPACE_ROOT"]
|
|
paths.RUNS_DIR = orig["RUNS_DIR"]
|
|
paths.DATA_DIR = orig["DATA_DIR"]
|
|
paths.MEMORIES_DIR = orig["MEMORIES_DIR"]
|
|
paths.MEMORY_DIR = orig["MEMORY_DIR"]
|
|
paths.GLOBAL_SKILLS_DIR = orig["GLOBAL_SKILLS_DIR"]
|
|
paths.GLOBAL_MEMORIES_DIR = orig["GLOBAL_MEMORIES_DIR"]
|
|
paths.USER_SKILLS_DIR = orig["USER_SKILLS_DIR"]
|
|
paths._active_workspace = orig["_active_workspace"]
|
|
paths._EVOSCIENTIST_DATA_ROOT = orig["_EVOSCIENTIST_DATA_ROOT"]
|
|
|
|
|
|
class TestSetWorkspaceRoot:
|
|
"""Tests for set_workspace_root()."""
|
|
|
|
def test_updates_derived_dirs(self, tmp_path, monkeypatch):
|
|
"""set_workspace_root should update WORKSPACE_ROOT and all derived dirs."""
|
|
monkeypatch.delenv("EVOSCIENTIST_MEMORIES_DIR", raising=False)
|
|
monkeypatch.delenv("EVOSCIENTIST_MEMORY_DIR", raising=False)
|
|
monkeypatch.delenv("EVOSCIENTIST_RUNS_DIR", raising=False)
|
|
monkeypatch.delenv("EVOSCIENTIST_SKILLS_DIR", raising=False)
|
|
|
|
new_root = tmp_path / "my_workspace"
|
|
new_root.mkdir()
|
|
|
|
paths.set_workspace_root(new_root)
|
|
|
|
assert paths.WORKSPACE_ROOT == new_root.resolve()
|
|
assert paths.RUNS_DIR == new_root.resolve() / "runs"
|
|
# MEMORIES_DIR is global — not derived from workspace root
|
|
assert paths.MEMORIES_DIR == paths.GLOBAL_MEMORIES_DIR
|
|
assert paths.USER_SKILLS_DIR == new_root.resolve() / "skills"
|
|
|
|
def test_resets_active_workspace(self, tmp_path):
|
|
"""set_workspace_root should reset _active_workspace to new root."""
|
|
new_root = tmp_path / "ws"
|
|
new_root.mkdir()
|
|
|
|
# Set active workspace to something different first
|
|
paths._active_workspace = tmp_path / "other"
|
|
|
|
paths.set_workspace_root(new_root)
|
|
|
|
assert paths._active_workspace == new_root.resolve()
|
|
|
|
def test_preserves_env_overrides(self, tmp_path):
|
|
"""Dirs set via env vars should NOT be overwritten by set_workspace_root."""
|
|
custom_mem = tmp_path / "custom_memory"
|
|
custom_skills = tmp_path / "custom_skills"
|
|
custom_runs = tmp_path / "custom_runs"
|
|
|
|
env = {
|
|
"EVOSCIENTIST_MEMORIES_DIR": str(custom_mem),
|
|
"EVOSCIENTIST_SKILLS_DIR": str(custom_skills),
|
|
"EVOSCIENTIST_RUNS_DIR": str(custom_runs),
|
|
}
|
|
|
|
new_root = tmp_path / "ws"
|
|
new_root.mkdir()
|
|
|
|
with mock.patch.dict(os.environ, env):
|
|
paths.set_workspace_root(new_root)
|
|
|
|
# WORKSPACE_ROOT and _active_workspace should still update
|
|
assert paths.WORKSPACE_ROOT == new_root.resolve()
|
|
assert paths._active_workspace == new_root.resolve()
|
|
|
|
# MEMORIES_DIR respects env override
|
|
assert paths.MEMORIES_DIR == custom_mem.expanduser()
|
|
assert paths.USER_SKILLS_DIR == custom_skills.expanduser()
|
|
assert paths.RUNS_DIR == custom_runs.expanduser()
|
|
|
|
def test_accepts_string_path(self, tmp_path):
|
|
"""set_workspace_root should accept str as well as Path."""
|
|
new_root = tmp_path / "str_ws"
|
|
new_root.mkdir()
|
|
|
|
paths.set_workspace_root(str(new_root))
|
|
|
|
assert paths.WORKSPACE_ROOT == new_root.resolve()
|
|
|
|
|
|
class TestEnsureDirsUsesUpdatedPaths:
|
|
"""ensure_dirs should create dirs at the currently set paths."""
|
|
|
|
def test_ensure_dirs_uses_updated_paths(self, tmp_path):
|
|
"""ensure_dirs creates the global memories dir (not workspace-local)."""
|
|
new_root = tmp_path / "workspace"
|
|
new_root.mkdir()
|
|
|
|
custom_mem = tmp_path / "memories"
|
|
|
|
with mock.patch.dict(
|
|
os.environ, {"EVOSCIENTIST_MEMORIES_DIR": str(custom_mem)}
|
|
):
|
|
paths.set_workspace_root(new_root)
|
|
paths.ensure_dirs()
|
|
|
|
assert custom_mem.is_dir()
|
|
assert not (new_root / "memory").exists() # no longer workspace-local
|
|
assert not (new_root / "memories").exists()
|
|
assert not (
|
|
new_root / "skills"
|
|
).exists() # skills created on demand by install_skill()
|
|
|
|
|
|
class TestDataDir:
|
|
"""Tests for DATA_DIR and global data-dir helpers."""
|
|
|
|
def test_global_skills_dir_under_data_dir(self):
|
|
"""GLOBAL_SKILLS_DIR must live under DATA_DIR."""
|
|
assert paths.GLOBAL_SKILLS_DIR == paths.DATA_DIR / "skills"
|
|
|
|
def test_global_memories_dir_under_data_dir(self):
|
|
"""GLOBAL_MEMORIES_DIR must live under DATA_DIR."""
|
|
assert paths.GLOBAL_MEMORIES_DIR == paths.DATA_DIR / "memories"
|
|
|
|
|
|
class TestGatewayDataDirs:
|
|
def test_evoscientist_root_prefers_home_override(self, tmp_path, monkeypatch):
|
|
home = tmp_path / "runtime-home"
|
|
monkeypatch.setenv("EVOSCIENTIST_HOME", str(home))
|
|
|
|
assert paths.evoscientist_root() == home.resolve()
|
|
|
|
def test_evoscientist_root_falls_back_to_data_dir(self, tmp_path, monkeypatch):
|
|
data_dir = tmp_path / "app-data"
|
|
monkeypatch.delenv("EVOSCIENTIST_HOME", raising=False)
|
|
monkeypatch.setattr(paths, "DATA_DIR", data_dir)
|
|
|
|
assert paths.evoscientist_root() == data_dir.resolve()
|
|
|
|
def test_data_root_respects_environment_override(self, tmp_path, monkeypatch):
|
|
data_root = tmp_path / "web-data"
|
|
monkeypatch.setenv("EVOSCIENTIST_DATA_ROOT", str(data_root))
|
|
paths._EVOSCIENTIST_DATA_ROOT = None
|
|
|
|
assert paths._data_root() == data_root.resolve()
|
|
|
|
def test_user_thread_and_global_dirs_are_created(self, tmp_path, monkeypatch):
|
|
data_root = tmp_path / "web-data"
|
|
monkeypatch.setenv("EVOSCIENTIST_DATA_ROOT", str(data_root))
|
|
paths._EVOSCIENTIST_DATA_ROOT = None
|
|
|
|
user_dir = paths.user_data_dir("user-a")
|
|
thread_dir = paths.thread_data_dir("user-a", "thread-1")
|
|
shared_dir = paths.global_data_dir("user-a")
|
|
|
|
assert user_dir == data_root / "user-a"
|
|
assert thread_dir == user_dir / "thread-1"
|
|
assert shared_dir == user_dir / "__global__"
|
|
assert user_dir.is_dir()
|
|
assert thread_dir.is_dir()
|
|
assert shared_dir.is_dir()
|
|
|
|
def test_iter_user_data_dirs_yields_directories_only(self, tmp_path, monkeypatch):
|
|
data_root = tmp_path / "web-data"
|
|
monkeypatch.setenv("EVOSCIENTIST_DATA_ROOT", str(data_root))
|
|
paths._EVOSCIENTIST_DATA_ROOT = None
|
|
paths.user_data_dir("user-a")
|
|
paths.user_data_dir("user-b")
|
|
(data_root / "metadata.json").write_text("{}", encoding="utf-8")
|
|
|
|
assert {path.name for path in paths.iter_user_data_dirs()} == {
|
|
"user-a",
|
|
"user-b",
|
|
}
|
|
|
|
def test_uploads_dir_uses_evoscientist_root(self, tmp_path, monkeypatch):
|
|
home = tmp_path / "runtime-home"
|
|
monkeypatch.setenv("EVOSCIENTIST_HOME", str(home))
|
|
|
|
assert paths.uploads_dir() == home.resolve() / "uploads"
|
|
|
|
|
|
class TestLegacySessionsDbMigration:
|
|
"""Tests for migrate_legacy_sessions_db() — transitional helper.
|
|
|
|
Tests redirect ``DATA_DIR`` and ``Path.home()`` so the real user home
|
|
is never touched.
|
|
"""
|
|
|
|
def _setup(self, tmp_path, monkeypatch):
|
|
"""Redirect data dir to tmp_path/new_data and Path.home() to
|
|
tmp_path/fake_home so legacy resolves to tmp_path/fake_home/.config/evoscientist.
|
|
|
|
Clears XDG_CONFIG_HOME so the legacy resolver deterministically uses
|
|
the Path.home() fallback. Tests that want the XDG branch set the
|
|
env var explicitly.
|
|
"""
|
|
monkeypatch.delenv("XDG_CONFIG_HOME", raising=False)
|
|
data_dir = tmp_path / "new_data"
|
|
fake_home = tmp_path / "fake_home"
|
|
fake_home.mkdir()
|
|
legacy_dir = fake_home / ".config" / "evoscientist"
|
|
monkeypatch.setattr(paths, "DATA_DIR", data_dir)
|
|
monkeypatch.setattr(paths.Path, "home", classmethod(lambda cls: fake_home))
|
|
return data_dir, legacy_dir
|
|
|
|
def test_copies_sqlite_trio_when_legacy_exists(self, tmp_path, monkeypatch):
|
|
"""All three SQLite files should be copied to the new location."""
|
|
data_dir, legacy_dir = self._setup(tmp_path, monkeypatch)
|
|
legacy_dir.mkdir(parents=True)
|
|
for name in ("sessions.db", "sessions.db-wal", "sessions.db-shm"):
|
|
(legacy_dir / name).write_bytes(b"stub-" + name.encode())
|
|
|
|
paths.migrate_legacy_sessions_db()
|
|
|
|
for name in ("sessions.db", "sessions.db-wal", "sessions.db-shm"):
|
|
assert (data_dir / name).read_bytes() == b"stub-" + name.encode()
|
|
# Legacy files must remain (copy, not move)
|
|
assert (legacy_dir / name).exists()
|
|
assert (data_dir / ".migrated").exists()
|
|
|
|
def test_idempotent_via_marker(self, tmp_path, monkeypatch):
|
|
"""Once .migrated exists, migration should be a no-op."""
|
|
data_dir, legacy_dir = self._setup(tmp_path, monkeypatch)
|
|
data_dir.mkdir()
|
|
(data_dir / ".migrated").touch()
|
|
legacy_dir.mkdir(parents=True)
|
|
(legacy_dir / "sessions.db").write_bytes(b"should-not-copy")
|
|
|
|
paths.migrate_legacy_sessions_db()
|
|
|
|
assert not (data_dir / "sessions.db").exists()
|
|
|
|
def test_no_legacy_just_creates_marker(self, tmp_path, monkeypatch):
|
|
"""When legacy dir doesn't exist, only the marker is created."""
|
|
data_dir, _ = self._setup(tmp_path, monkeypatch)
|
|
|
|
paths.migrate_legacy_sessions_db()
|
|
|
|
assert data_dir.is_dir()
|
|
assert (data_dir / ".migrated").exists()
|
|
assert not (data_dir / "sessions.db").exists()
|
|
|
|
def test_does_not_overwrite_existing_files(self, tmp_path, monkeypatch):
|
|
"""If new location already has a file, don't overwrite it."""
|
|
data_dir, legacy_dir = self._setup(tmp_path, monkeypatch)
|
|
data_dir.mkdir()
|
|
(data_dir / "sessions.db").write_bytes(b"new-content-keep")
|
|
legacy_dir.mkdir(parents=True)
|
|
(legacy_dir / "sessions.db").write_bytes(b"legacy-content")
|
|
|
|
paths.migrate_legacy_sessions_db()
|
|
|
|
assert (data_dir / "sessions.db").read_bytes() == b"new-content-keep"
|
|
|
|
def test_respects_xdg_config_home(self, tmp_path, monkeypatch):
|
|
"""Legacy source must honor XDG_CONFIG_HOME so users who customize it
|
|
don't silently get skipped by the migration."""
|
|
data_dir = tmp_path / "new_data"
|
|
xdg = tmp_path / "xdg"
|
|
legacy_dir = xdg / "evoscientist"
|
|
legacy_dir.mkdir(parents=True)
|
|
(legacy_dir / "sessions.db").write_bytes(b"xdg-db")
|
|
|
|
monkeypatch.setattr(paths, "DATA_DIR", data_dir)
|
|
monkeypatch.setenv("XDG_CONFIG_HOME", str(xdg))
|
|
|
|
paths.migrate_legacy_sessions_db()
|
|
|
|
assert (data_dir / "sessions.db").read_bytes() == b"xdg-db"
|
|
|
|
def test_marker_not_written_on_partial_failure(self, tmp_path, monkeypatch):
|
|
"""If any copy fails, the .migrated marker must not be written."""
|
|
data_dir, legacy_dir = self._setup(tmp_path, monkeypatch)
|
|
legacy_dir.mkdir(parents=True)
|
|
for name in ("sessions.db", "sessions.db-wal"):
|
|
(legacy_dir / name).write_bytes(b"ok")
|
|
|
|
real_copy2 = paths.shutil.copy2
|
|
|
|
def flaky_copy2(src, dst, *args, **kwargs):
|
|
if str(src).endswith("sessions.db-wal"):
|
|
raise OSError("simulated I/O failure")
|
|
return real_copy2(src, dst, *args, **kwargs)
|
|
|
|
with mock.patch.object(paths.shutil, "copy2", side_effect=flaky_copy2):
|
|
paths.migrate_legacy_sessions_db()
|
|
|
|
assert (data_dir / "sessions.db").exists() # main db copied
|
|
assert not (data_dir / ".migrated").exists() # retry allowed
|