"""Install layout: where the code lives versus where it may write. In a checkout these are one directory, which is exactly why the difference went unnoticed — every path resolved against the repo root. Installed, the code sits under Program Files and nothing may write there, so these tests simulate the frozen layout rather than trusting that it works. """ import os import sys from pathlib import Path import pytest from behavision import paths @pytest.fixture def frozen(monkeypatch): """Pretend to be a PyInstaller one-folder build on Windows.""" def _apply(exe_dir, programdata): monkeypatch.setattr(sys, "frozen", True, raising=False) monkeypatch.setattr(sys, "executable", str(Path(exe_dir) / "behavision.exe")) # Not `os.name`: pathlib dispatches on it and patching it makes # every Path() in the process raise. monkeypatch.setattr(paths, "_os_family", lambda: "windows") monkeypatch.setenv("PROGRAMDATA", str(programdata)) monkeypatch.delenv("BEHAVISION_DATA_DIR", raising=False) monkeypatch.delenv("BEHAVISION_CONFIG", raising=False) return _apply def test_checkout_keeps_everything_in_one_place(): """Development convenience must not regress.""" monkey = getattr(sys, "frozen", False) assert not monkey assert paths.state_root() == paths.install_root() def test_frozen_writes_to_programdata_not_program_files(tmp_path, frozen): exe = tmp_path / "Program Files" / "Behavision" data = tmp_path / "ProgramData" exe.mkdir(parents=True) frozen(exe, data) assert paths.install_root() == exe assert paths.state_root() == data / "Behavision" # The one that actually matters: nothing writable lands under the code. assert exe not in paths.state_root().parents assert paths.state_root() != paths.install_root() def test_install_root_is_the_exe_folder_not_meipass(tmp_path, frozen, monkeypatch): """_MEIPASS is a temp dir that vanishes between runs; resolving the install root to it would make every relative path unstable.""" exe = tmp_path / "app" exe.mkdir() frozen(exe, tmp_path / "pd") monkeypatch.setattr(sys, "_MEIPASS", str(tmp_path / "temp_meipass"), raising=False) assert paths.install_root() == exe def test_data_dir_env_overrides_everything(tmp_path, frozen, monkeypatch): frozen(tmp_path / "app", tmp_path / "pd") monkeypatch.setenv("BEHAVISION_DATA_DIR", str(tmp_path / "elsewhere")) assert paths.state_root() == (tmp_path / "elsewhere").resolve() def test_config_env_overrides_everything(tmp_path, monkeypatch): target = tmp_path / "custom.yaml" monkeypatch.setenv("BEHAVISION_CONFIG", str(target)) assert paths.config_path() == target.resolve() def test_editable_config_is_seeded_once_and_never_overwritten(tmp_path, frozen): """An upgrade must not silently revert an operator's thresholds.""" exe = tmp_path / "app" (exe / "config").mkdir(parents=True) (exe / "config" / "default.yaml").write_text("app:\n log_level: INFO\n") frozen(exe, tmp_path / "pd") first = paths.ensure_config() assert first == tmp_path / "pd" / "Behavision" / "config" / "default.yaml" assert first.is_file() first.write_text("app:\n log_level: DEBUG\n") # operator edits it assert paths.ensure_config() == first assert "DEBUG" in first.read_text() def test_local_config_wins_over_the_bundled_one(tmp_path, frozen): exe = tmp_path / "app" (exe / "config").mkdir(parents=True) (exe / "config" / "default.yaml").write_text("app: {}\n") pd = tmp_path / "pd" local = pd / "Behavision" / "config" / "default.yaml" local.parent.mkdir(parents=True) local.write_text("app: {}\n") frozen(exe, pd) assert paths.config_path() == local def test_env_file_prefers_the_writable_copy(tmp_path, frozen): exe = tmp_path / "app" exe.mkdir() pd = tmp_path / "pd" / "Behavision" pd.mkdir(parents=True) frozen(exe, tmp_path / "pd") assert paths.env_file() is None # optional, absent is fine (exe / ".env").write_text("A=1\n") assert paths.env_file() == exe / ".env" (pd / ".env").write_text("A=2\n") assert paths.env_file() == pd / ".env" def test_models_go_somewhere_writable(tmp_path, frozen): """Models are ~200 MB and downloaded on first run, so they cannot live next to the code under Program Files.""" exe = tmp_path / "app" exe.mkdir() frozen(exe, tmp_path / "pd") from behavision.config import Config cfg = Config() resolved = paths.state_root() / cfg.app.models_dir assert exe not in resolved.parents def test_describe_answers_where_is_my_database(tmp_path, frozen): frozen(tmp_path / "app", tmp_path / "pd") d = paths.describe() assert d["frozen"] is True assert set(d) == {"frozen", "install_root", "state_root", "config"} def test_a_checkout_does_not_copy_its_config_onto_itself(tmp_path, monkeypatch): """install_root == state_root in a checkout, so the seeding copy would be a file onto itself — which truncates it before the read on some platforms. """ root = tmp_path / "repo" (root / "config").mkdir(parents=True) original = "app:\n log_level: WARNING\n" (root / "config" / "default.yaml").write_text(original) monkeypatch.setattr(paths, "install_root", lambda: root) monkeypatch.setattr(paths, "state_root", lambda: root) monkeypatch.delenv("BEHAVISION_CONFIG", raising=False) assert paths.ensure_config() == root / "config" / "default.yaml" assert (root / "config" / "default.yaml").read_text() == original def test_missing_bundled_config_names_where_it_should_have_been(tmp_path, frozen): exe = tmp_path / "app" exe.mkdir() frozen(exe, tmp_path / "pd") assert paths.ensure_config() == exe / "config" / "default.yaml" def test_spec_ships_the_files_the_app_reads_at_runtime(): """The dashboard and the default config are read from disk, so they must travel with the code. A rename here fails in the bundle only — which is the one place nothing is tested.""" import re spec = (Path(__file__).parent.parent / "behavision.spec").read_text() datas = re.search(r"^datas = \[(.*?)^\]", spec, re.S | re.M).group(1) sources = re.findall(r'\("([^"]+)",\s*"[^"]+"\)', datas) assert sources, "no data files declared" root = Path(__file__).parent.parent missing = [src for src in sources if not (root / src).exists()] assert not missing, f"spec ships files that do not exist: {missing}"