"""remote/files.py の仕様ベーステスト(push で送るファイルの選定).
仕様出典:
- docs/02_ENV/ENV_04_開発コマンド.md「研究室サーバでの実行」
- docs/02_ENV/ENV_02_環境構築手順.md「コードの転送」(送らないもの / 送るもの)
- 実装サマリー 仕様 1(除外規則・output/ の拒否・Windows 絶対パスの警告・--prune の対象)
テスト方針:
- tmp_path に小さな疑似プロジェクトを実ファイルで作る(ファイル I/O はモックしない)
"""
from __future__ import annotations
import os
from pathlib import Path
import pytest
from sm_tias_lightsim.remote import files
# 疑似プロジェクトのファイル構成(arcname → 内容)
_PROJECT_FILES: dict[str, str] = {
"pyproject.toml": "[project]\nname = 'x'\n",
"src/sm_tias_lightsim/__init__.py": "",
"src/sm_tias_lightsim/remote/commands.py": "x = 1\n",
"src/sm_tias_lightsim/__pycache__/__init__.cpython-312.pyc": "pyc",
"src/sm_tias_lightsim/output/keep.py": "# 直下以外の output は送る\n",
"tests/test_a.py": "def test_a(): pass\n",
"tests/.pytest_cache/v/cache": "c",
"scripts/run.py": "print(1)\n",
".venv/Scripts/python.exe": "exe",
".git/HEAD": "ref: refs/heads/main\n",
".ruff_cache/0.1/x": "r",
".pytest_cache/README.md": "p",
"output/offaxis/luminance.npy": "npy",
"output/_remote_logs/a.log": "log",
"configs/scene.yaml": "name: a\n",
"configs/meshes/前側.stl": "solid 前側\n",
"configs/_tmp_offaxis/scene_cube_L120.yaml": "name: b\n",
"configs/_tmp_offaxis/meshes/立方体_L120.stl": "solid\n",
"configs/_tmp_offaxis/__pycache__/z.pyc": "pyc",
"configs/_tmp_fn_full_base.yaml": "name: c\n",
"lightsim/luminance_maps/a.npy": "npy",
"reference_measurement/r.png": "png",
"REFERENCE/paper.pdf": "pdf",
"docs/a.md": "# a\n",
}
# 既定の push で送られるべきもの(ENV_02「送らないもの」を除いた全体)
_EXPECTED_BASE = {
"pyproject.toml",
"src/sm_tias_lightsim/__init__.py",
"src/sm_tias_lightsim/remote/commands.py",
"src/sm_tias_lightsim/output/keep.py",
"tests/test_a.py",
"scripts/run.py",
"configs/scene.yaml",
"configs/meshes/前側.stl",
"lightsim/luminance_maps/a.npy",
"reference_measurement/r.png",
"REFERENCE/paper.pdf",
"docs/a.md",
}
@pytest.fixture
def project(tmp_path: Path) -> Path:
root = tmp_path / "proj"
for rel, content in _PROJECT_FILES.items():
path = root / rel
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(content, encoding="utf-8")
return root
def _arcnames(entries) -> list[str]:
return [e.arcname for e in entries]
# ---------------------------------------------------------------------------
# is_excluded
# ---------------------------------------------------------------------------
@pytest.mark.parametrize(
"rel",
[
".venv",
".venv/Scripts/python.exe",
".git/HEAD",
"__pycache__/x.pyc",
"src/sm_tias_lightsim/__pycache__/a.pyc",
"tests/.pytest_cache/v",
"scripts/.ruff_cache/x",
"scripts/sub/.venv/lib/x.py", # どの階層でも除外
"src/a/.git/config",
"output",
"output/offaxis/luminance.npy",
"configs/_tmp_offaxis",
"configs/_tmp_offaxis/scene.yaml",
"configs/_tmp_offaxis/meshes/x.stl",
"configs/_tmp_fn_full_base.yaml", # ファイルでも除外
"configs/_tmp_",
],
)
def test_is_excluded_true(rel):
assert files.is_excluded(rel) is True
@pytest.mark.parametrize(
"rel",
[
"",
"pyproject.toml",
"src/sm_tias_lightsim/cli/remote.py",
"src/sm_tias_lightsim/output/keep.py", # 直下以外の output は送る
"tests/output/x.py",
"outputs/x",
"output_old/x",
"configs/scene.yaml",
"configs/meshes/前側.stl",
"configs/sub/_tmp_x.yaml", # configs 直下の _tmp_* だけが対象
"configs/tmp_x.yaml",
"lightsim/luminance_maps/a.npy",
"reference_measurement/r.png",
"REFERENCE/paper.pdf",
".gitignore",
".github/workflows/ci.yml",
"venv/x",
],
)
def test_is_excluded_false(rel):
assert files.is_excluded(rel) is False
# ---------------------------------------------------------------------------
# collect_push_entries
# ---------------------------------------------------------------------------
class TestCollectPushEntries:
def test_base_push_matches_spec(self, project):
"""既定の push は .venv/.git/キャッシュ/output/configs/_tmp_* を除く全体を送ること."""
entries = files.collect_push_entries(project, include_base=True)
assert set(_arcnames(entries)) == _EXPECTED_BASE
def test_entries_are_sorted_posix_and_sized(self, project):
entries = files.collect_push_entries(project, include_base=True)
names = _arcnames(entries)
assert names == sorted(names)
for entry in entries:
assert "\\" not in entry.arcname
assert entry.path.is_file()
assert entry.path == (project / entry.arcname).resolve()
assert entry.size == entry.path.stat().st_size
def test_japanese_file_names_are_kept(self, project):
entries = files.collect_push_entries(project, include_base=True)
assert "configs/meshes/前側.stl" in _arcnames(entries)
def test_include_adds_excluded_tmp_dir(self, project):
"""--include で configs/_tmp_* を一緒に送れること(キャッシュ類は除く)."""
entries = files.collect_push_entries(
project, include_base=True, extra_paths=["configs/_tmp_offaxis"]
)
names = set(_arcnames(entries))
assert names == _EXPECTED_BASE | {
"configs/_tmp_offaxis/scene_cube_L120.yaml",
"configs/_tmp_offaxis/meshes/立方体_L120.stl",
}
def test_include_single_file(self, project):
entries = files.collect_push_entries(
project, include_base=True, extra_paths=["configs/_tmp_fn_full_base.yaml"]
)
assert "configs/_tmp_fn_full_base.yaml" in _arcnames(entries)
def test_only_sends_just_the_given_paths(self, project):
"""--only は指定したパスだけを送る(コード本体は送らない)."""
entries = files.collect_push_entries(
project, include_base=False, extra_paths=["configs/_tmp_offaxis"]
)
assert _arcnames(entries) == [
"configs/_tmp_offaxis/meshes/立方体_L120.stl",
"configs/_tmp_offaxis/scene_cube_L120.yaml",
]
def test_only_with_nothing_returns_empty(self, project):
assert files.collect_push_entries(project, include_base=False) == []
def test_overlapping_paths_are_deduplicated(self, project):
entries = files.collect_push_entries(
project, include_base=True, extra_paths=["configs", "configs/scene.yaml"]
)
names = _arcnames(entries)
assert len(names) == len(set(names))
assert names.count("configs/scene.yaml") == 1
def test_absolute_path_inside_project_is_accepted(self, project):
entries = files.collect_push_entries(
project,
include_base=False,
extra_paths=[str(project / "configs" / "_tmp_fn_full_base.yaml")],
)
assert _arcnames(entries) == ["configs/_tmp_fn_full_base.yaml"]
@pytest.mark.skipif(os.name != "nt", reason="Windows の区切り文字の扱い")
def test_windows_backslash_path_is_accepted(self, project):
entries = files.collect_push_entries(
project, include_base=False, extra_paths=["configs\\_tmp_offaxis"]
)
assert "configs/_tmp_offaxis/scene_cube_L120.yaml" in _arcnames(entries)
@pytest.mark.parametrize(
"extra",
[
"output/offaxis", # output/ 配下は送信拒否
"output",
"output/offaxis/luminance.npy",
"configs/../output/offaxis",
"../outside", # プロジェクト外
".", # プロジェクト全体
"",
"configs/_tmp_does_not_exist", # 存在しない
],
)
def test_invalid_extra_paths_raise(self, project, extra):
(project.parent / "outside").mkdir(exist_ok=True)
with pytest.raises(ValueError):
files.collect_push_entries(project, include_base=True, extra_paths=[extra])
@pytest.mark.skipif(os.name != "nt", reason="Windows の区切り文字の扱い")
def test_windows_backslash_output_is_rejected(self, project):
with pytest.raises(ValueError):
files.collect_push_entries(project, include_base=False, extra_paths=["output\\offaxis"])
def test_absolute_path_outside_project_is_rejected(self, project):
outside = project.parent / "elsewhere.yaml"
outside.write_text("x", encoding="utf-8")
with pytest.raises(ValueError):
files.collect_push_entries(project, include_base=False, extra_paths=[str(outside)])
# ---------------------------------------------------------------------------
# find_project_root
# ---------------------------------------------------------------------------
class TestFindProjectRoot:
def test_finds_root_from_nested_dir(self, project):
start = project / "src" / "sm_tias_lightsim" / "remote"
assert files.find_project_root(start) == project.resolve()
def test_finds_root_from_root_itself(self, project):
assert files.find_project_root(project) == project.resolve()
def test_raises_outside_project(self, tmp_path):
lonely = tmp_path / "lonely"
lonely.mkdir()
with pytest.raises(ValueError):
files.find_project_root(lonely)
def test_pyproject_alone_is_not_enough(self, tmp_path):
other = tmp_path / "other"
other.mkdir()
(other / "pyproject.toml").write_text("", encoding="utf-8")
with pytest.raises(ValueError):
files.find_project_root(other)
# ---------------------------------------------------------------------------
# find_yaml_with_windows_paths(送る YAML に Windows 絶対パスがあれば警告)
# ---------------------------------------------------------------------------
class TestFindYamlWithWindowsPaths:
def _entry(self, root: Path, rel: str, content: str | bytes) -> files.PushEntry:
path = root / rel
path.parent.mkdir(parents=True, exist_ok=True)
if isinstance(content, bytes):
path.write_bytes(content)
else:
path.write_text(content, encoding="utf-8")
return files.PushEntry(path=path, arcname=rel, size=path.stat().st_size)
@pytest.mark.parametrize(
"content",
[
"geometry:\n meshes:\n - id: a\n path: C:\\Users\\r\\meshes\\前側.stl\n",
"geometry:\n meshes:\n - path: 'D:/data/a.stl'\n",
'geometry:\n meshes:\n - id: a\n path: "c:\\\\x\\\\a.stl"\n',
],
)
def test_detects_windows_absolute_paths(self, tmp_path, content):
entry = self._entry(tmp_path, "configs/_tmp_x/scene.yaml", content)
assert files.find_yaml_with_windows_paths([entry]) == ["configs/_tmp_x/scene.yaml"]
@pytest.mark.parametrize(
"content",
[
"geometry:\n meshes:\n - id: a\n path: meshes/前側.stl\n",
"geometry:\n meshes:\n - id: a\n path: ../meshes/前側.stl\n",
"geometry:\n meshes:\n - id: a\n path: ../../../../home/x/configs/meshes/a.stl\n",
"geometry:\n meshes:\n - id: a\n path: /home/hasegawa/a.stl\n",
"description: 'C:\\Users は path ではない'\n",
],
)
def test_ignores_portable_paths(self, tmp_path, content):
entry = self._entry(tmp_path, "configs/scene.yaml", content)
assert files.find_yaml_with_windows_paths([entry]) == []
def test_yml_extension_is_checked(self, tmp_path):
entry = self._entry(tmp_path, "configs/a.yml", "path: C:\\a.stl\n")
assert files.find_yaml_with_windows_paths([entry]) == ["configs/a.yml"]
def test_non_yaml_files_are_ignored(self, tmp_path):
entry = self._entry(tmp_path, "docs/a.md", "path: C:\\a.stl\n")
assert files.find_yaml_with_windows_paths([entry]) == []
def test_undecodable_yaml_is_skipped_without_error(self, tmp_path):
entry = self._entry(tmp_path, "configs/bin.yaml", b"\xff\xfe\x00path: C:\\a\x80")
assert files.find_yaml_with_windows_paths([entry]) == []
def test_returns_only_offending_files(self, tmp_path):
good = self._entry(tmp_path, "configs/good.yaml", "path: meshes/a.stl\n")
bad = self._entry(tmp_path, "configs/_tmp_a/bad.yaml", " - path: C:\\a.stl\n")
assert files.find_yaml_with_windows_paths([good, bad]) == ["configs/_tmp_a/bad.yaml"]
# ---------------------------------------------------------------------------
# compute_prune_targets(--prune: src/ tests/ scripts/ だけ.output/・configs/ は消さない)
# ---------------------------------------------------------------------------
class TestComputePruneTargets:
def test_prune_dirs_are_code_only(self):
assert tuple(files.PRUNE_DIRS) == ("src", "tests", "scripts")
def test_returns_remote_files_missing_locally(self):
remote = [
"src/sm_tias_lightsim/a.py",
"src/sm_tias_lightsim/old.py",
"tests/test_old.py",
"scripts/gone.py",
"scripts/run.py",
]
local = {"src/sm_tias_lightsim/a.py", "scripts/run.py", "configs/scene.yaml"}
assert files.compute_prune_targets(remote, local) == [
"scripts/gone.py",
"src/sm_tias_lightsim/old.py",
"tests/test_old.py",
]
def test_cache_files_are_not_targeted(self):
remote = [
"src/sm_tias_lightsim/__pycache__/a.cpython-312.pyc",
"tests/.pytest_cache/v/x",
"scripts/.ruff_cache/y",
]
assert files.compute_prune_targets(remote, set()) == []
def test_nothing_to_prune(self):
remote = ["src/a.py"]
assert files.compute_prune_targets(remote, {"src/a.py"}) == []
@pytest.mark.parametrize(
"bad",
[
"output/offaxis/luminance.npy",
"configs/_tmp_offaxis/scene.yaml",
"configs/scene.yaml",
"/etc/passwd",
"src/../output/x",
"../src/x",
"src", # ディレクトリそのもの
"lightsim/a.npy",
],
)
def test_never_targets_outside_prune_dirs(self, bad):
"""output/・configs/ 等のパスを受け取っても削除対象にせず中断すること."""
with pytest.raises(RuntimeError):
files.compute_prune_targets(["src/a.py", bad], set())