From 2853d8fb0a7575d4cbd193ec8474b8d82c3c7be6 Mon Sep 17 00:00:00 2001 From: Alexander Nguyen Date: Mon, 1 Jun 2026 15:19:38 -0700 Subject: [PATCH 1/3] docs(spec): centralize data/template/plugin dirs under one Documents app root Single configurable app_root (default ~/Documents/ExLabWizard) with templates/plugins/data derived; removes per-equipment local_root and the creation-vs-poller divergence; repurposes the paths setup gate to a writability check. Clean break, no migration. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../2026-06-01-centralize-data-dir-design.md | 235 ++++++++++++++++++ 1 file changed, 235 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-01-centralize-data-dir-design.md diff --git a/docs/superpowers/specs/2026-06-01-centralize-data-dir-design.md b/docs/superpowers/specs/2026-06-01-centralize-data-dir-design.md new file mode 100644 index 0000000..08c8c2d --- /dev/null +++ b/docs/superpowers/specs/2026-06-01-centralize-data-dir-design.md @@ -0,0 +1,235 @@ +# Centralize the data / template / plugin directory under one Documents-based app root + +- **Date:** 2026-06-01 +- **Status:** Approved (design); pending implementation plan +- **Branch:** `worktree-refactor+centralize-data-dir` + +## 1. Problem + +Today the operator must independently configure three filesystem roots in +Settings — `paths.templates_dir`, `paths.plugin_dir`, and `paths.local_root` +— each defaulting to the empty string and each gated by the +`INCOMPLETE_MISSING_PATHS` setup state. There is no sensible default, so every +fresh install forces the operator to invent and type three paths. + +Two concrete problems motivate the change: + +1. **No defaults.** `PathsConfig` fields default to `""` and + `evaluate_setup_state` blocks setup until all three are non-empty + (`paths._paths_complete`, `paths.py`). Other desktop apps simply default a + working folder under the user's *Documents* directory; we do not. + +2. **Two divergent local roots (latent bug).** There is a global + `paths.local_root` *and* a per-equipment `EquipmentConfig.local_root` + (`models.py`, required, `min_length=1`). Run **creation** composes paths + from the *global* root (`controller/creation.py`), but the auto-sync + **quiescence poller** walks the *per-equipment* root + (`orchestrator/quiescence_poller.py`). If an operator types different values + in the two places, runs are created in one tree while the sync engine + watches another — runs silently never sync. + +## 2. Goal + +A single operator-configurable **app root**, defaulting to the OS *Documents* +folder, from which every working directory is derived: + +``` +~/Documents/ExLabWizard/ <- the single configured "app root" +├── templates/ (derived) +├── plugins/ (derived) +└── data/ (derived; the experiment data root) + └── / + └── / + └── Runs/Run_/ +``` + +Each equipment's data lives at `/data//…`, derived from +the single root — eliminating the divergence bug at its source. + +## 3. Scope decisions (locked during brainstorming) + +| Decision | Choice | +|----------|--------| +| Config shape | **Single app root**, with `templates`/`plugins`/`data` *derived*, not separately stored. | +| Per-equipment `local_root` | **Removed entirely.** Both creation and the poller derive `/data/`. | +| Migration | **Clean break (pre-release).** No compat shims, no filesystem migration. Old configs carrying the retired keys (`paths.templates_dir` / `paths.plugin_dir` / `paths.local_root`, or `equipment.local_root`) fail validation and route to the setup wizard. | +| App-root UX | **One editable "Data folder" input**, pre-filled with the Documents default. Power users can relocate the whole app folder. | +| Setup gate | **Repurposed** from "is it blank" to "is the resolved root creatable/writable." | + +## 4. Design + +### 4.1 Config model — `config/models.py` + +`PathsConfig` collapses to a single stored field plus read-only derived +properties: + +```python +class PathsConfig(BaseModel): + model_config = ConfigDict(extra="forbid", str_strip_whitespace=True) + app_root: str = Field(default_factory=lambda: str(default_app_root())) + + @property + def data_root(self) -> str: return str(Path(self.app_root) / "data") + @property + def local_root(self) -> str: return self.data_root # alias (see note) + @property + def templates_dir(self) -> str: return str(Path(self.app_root) / "templates") + @property + def plugin_dir(self) -> str: return str(Path(self.app_root) / "plugins") +``` + +- Plain `@property` (NOT `computed_field`): the derived dirs are never written + to YAML — **only `app_root` is serialized**. `save_config`'s `model_dump` + therefore persists `paths: {app_root: …}` alone. +- The property names `local_root` / `templates_dir` / `plugin_dir` are **kept** + so the existing read-only consumers need no change — `config.paths.local_root` + now simply resolves to `/data`. + - **Naming note / known tradeoff:** retaining the name `local_root` for what + is now `/data` is slightly less honest than `data_root`. The + decision is to keep `local_root` to avoid touching ~15 call sites; a later + rename is a separate, mechanical change. `data_root` is provided as the + honest name for new code. + +**`EquipmentConfig`:** the `local_root` field is removed. Remaining fields: +`id`, `label`, `nas_root`, `sync_mode`. `build_equipment_config()` drops its +`local_root` parameter. + +### 4.2 Documents resolver — `paths.py` + +Two new pure, side-effect-free helpers alongside the existing `os_*_path()` +family: + +```python +def os_documents_path() -> Path: + # macOS: ~/Documents + # Windows: SHGetKnownFolderPath(FOLDERID_Documents) via ctypes; + # fallback %USERPROFILE%\Documents; then ~/Documents + # Linux: $XDG_DOCUMENTS_DIR if set; else ~/Documents + +def default_app_root() -> Path: + return os_documents_path() / _display_name() +``` + +- New constant `DISPLAY_NAME = "ExLabWizard"` in `constants/app.py` — the + user-facing Documents subfolder, deliberately distinct from + `APP_NAME = "exlab-wizard"` (which names the hidden OS config/state/cache + dirs). +- `_display_name()` appends `-test` when `EXLAB_WIZARD_TEST_MODE=1`, mirroring + the existing `_app_name()`, so tests and `--test` never write into the real + Documents folder. +- Resolution is best-effort and **never raises**: a Windows ctypes failure + falls through `%USERPROFILE%\Documents` → `~/Documents`. + +### 4.3 Directory creation + +New helper `ensure_app_dirs(config) -> None` runs `ensure_dir` over `app_root`, +`data`, `templates`, and `plugins` (empty scaffolds — no seed files). It is +invoked at the same two points `orchestrator.staging_root` is created today: + +- tray bring-up (`tray/dependencies.py`), and +- the Settings save path (`ui/mount._persist_config`). + +### 4.4 Run layout and the divergence-bug fix + +`compose_run_path` is **unchanged** — it still accepts `local_root` and appends +`//Runs/…`. Because `local_root` now resolves to +`/data`, runs land at +`/data///Runs/Run_/` with no edit to the +composer or to `controller/creation.py`. + +The single behavioural edit is in `orchestrator/quiescence_poller.py`: it stops +reading the deleted `equipment.local_root` and instead derives +`Path(config.paths.local_root) / equipment.id`, identical to what +`creation.py` composes. An integration regression test asserts the two agree. + +Read-only consumers verified to keep working unchanged via the derived +properties: `controller/creation.py`, `api/routers/browse.py` (including the +path-confinement allowlist that reads `local_root`/`templates_dir`/`plugin_dir`), +`sample_data/generator.py`, `template/resolution.py`, and `ui/mount.py`. + +### 4.5 Setup gate — `paths.py` + `constants/enums.py` + +- Rename `SetupState.INCOMPLETE_MISSING_PATHS` → `INCOMPLETE_PATHS_UNWRITABLE`. +- `evaluate_setup_state` gains an injected `paths_writable: bool = True` flag + (same pattern as `lims_reachable`), keeping the evaluator pure and testable. + The caller computes it by attempting `ensure_app_dirs` and an + `os.access(W_OK)` probe of `app_root` and `data`. +- The gate position in the first-failing-wins chain is unchanged (it still + fires immediately after `config is None`). +- `setup_state_missing` emits `{"field": "paths.app_root", "reason": "unwritable"}`. +- `setup_state_next_action` still maps the state to `SetupNextAction.SET_PATHS`. + +### 4.6 Settings UX — `ui/pages/settings.py` + +- Paths section: the three inputs become **one** "Data folder" input bound to + `draft.paths.app_root`; `templates/`, `plugins/`, and `data/` are shown as + read-only derived labels beneath it. +- Equipment section: the per-equipment "Local root" input (`eq_local`) and its + wiring into `build_equipment_config` are removed. + +## 5. Error handling + +- **Documents resolution:** never raises; fallback chain as in §4.2. +- **Legacy config** carrying any retired key — `paths.templates_dir` / + `paths.plugin_dir` / `paths.local_root` (no longer model fields) or + `equipment.local_root`: fails Pydantic validation (`extra="forbid"`) → + `load_config` raises `ConfigError` → `tray/dependencies._try` logs a WARN and + leaves `deps.config = None` → the setup wizard runs (`INCOMPLETE_NO_CONFIG`). + Verified against the current bring-up path; no crash, no special-casing + required. +- **Unwritable app root** (bad drive, permissions): surfaces + `INCOMPLETE_PATHS_UNWRITABLE` as a setup banner rather than failing at + run-creation time. + +## 6. Testing + +**Unit** + +- `os_documents_path()` per platform — monkeypatch `sys.platform`, `Path.home`, + and the relevant env vars (`XDG_DOCUMENTS_DIR`, `USERPROFILE`). +- `default_app_root()` test-mode suffix (`ExLabWizard` vs `ExLabWizard-test`). +- `PathsConfig` derived properties resolve to `app_root/{templates,plugins,data}`; + only `app_root` serializes via `model_dump`. +- Writability gate: writable `tmp_path` passes; a read-only directory yields + `INCOMPLETE_PATHS_UNWRITABLE`. +- `EquipmentConfig` validates without `local_root`; a YAML still carrying it is + rejected. +- `compose_run_path` output now contains the `data/` segment. + +**Integration / regression** + +- Run creation and the quiescence poller compute the *same* equipment + directory for a given config (guards the divergence bug from recurring). + +**Fixtures** + +- `config/test_bootstrap.py`: construct `PathsConfig(app_root=str(sandbox))` and + `ensure_dir` the derived subdirs; drop the removed `local_root` arguments. +- Update existing `test_paths.py`, config-model, and setup-status tests for the + renamed state and the dropped field. + +## 7. Out of scope + +- Renaming the `local_root` property to `data_root` across all call sites + (mechanical follow-up if desired). +- Any filesystem migration of data created under the old + `/` layout (clean break; pre-release). +- Per-equipment data-root overrides (explicitly rejected: equipment data is + always derived). +- Re-enabling or relocating the hidden orchestrator/staging surfaces + (`staging_root` keeps its existing, separate handling). + +## 8. Affected files (anticipated) + +| File | Change | +|------|--------| +| `constants/app.py` | Add `DISPLAY_NAME`; `_display_name()` test-mode helper. | +| `constants/enums.py` | Rename `INCOMPLETE_MISSING_PATHS` → `INCOMPLETE_PATHS_UNWRITABLE`. | +| `paths.py` | Add `os_documents_path`, `default_app_root`, `ensure_app_dirs`; rework `_paths_complete`/missing-fields into the writability gate; update `evaluate_setup_state` signature. | +| `config/models.py` | `PathsConfig` → `app_root` + derived properties; drop `EquipmentConfig.local_root`. | +| `config/test_bootstrap.py` | Construct with `app_root`; ensure derived subdirs. | +| `orchestrator/quiescence_poller.py` | Derive equipment dir from `config.paths.local_root`. | +| `ui/pages/settings.py` | Single "Data folder" input; remove per-equipment local-root input. | +| `ui/mount.py` | Call `ensure_app_dirs` on save; `build_equipment_config` signature. | +| `tray/dependencies.py` | Call `ensure_app_dirs` at bring-up; compute `paths_writable`. | +| Tests | New unit + integration coverage per §6. | From 3f8d0d457e1ef5283530d3264be275126f5b557d Mon Sep 17 00:00:00 2001 From: Alexander Nguyen Date: Mon, 1 Jun 2026 22:34:14 -0700 Subject: [PATCH 2/3] feat(paths): centralize data/template/plugin dirs under one Documents app root Collapse PathsConfig's three roots (templates_dir/plugin_dir/local_root) into a single configurable `app_root` (default ~/Documents/ExLabWizard) with derived read-only properties; remove the per-equipment EquipmentConfig.local_root. - paths.py: os_documents_path / default_app_root / ensure_app_dirs / app_root_writable (Windows Known Folder + USERPROFILE/home fallbacks); add DISPLAY_NAME constant + _display_name() test-mode suffix. - Unify equipment data-dir derivation across run creation, the quiescence poller, and the validator audit roots on config.paths.local_root (== /data/), eliminating two divergence sources. - Repurpose the setup gate: INCOMPLETE_MISSING_PATHS -> INCOMPLETE_PATHS_UNWRITABLE, driven by an injected paths_writable flag (callers compute app_root_writable). - Settings: single "Data folder" input + derived read-only labels; the equipment wizard drops its local-root step; ensure_app_dirs wired at save and tray bring-up. - Clean break (pre-release): no migration shims; legacy configs that still carry the retired keys fail validation and route to the setup wizard. Tests: full unit+integration green (2607 passed); new regression pins creation/poller/validator data-dir agreement; e2e flows + page objects updated for the single-input UI. ruff + mypy clean; coverage 91.17%. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/exlab_wizard/api/routers/config.py | 3 + src/exlab_wizard/api/setup.py | 5 +- src/exlab_wizard/config/models.py | 43 +++- src/exlab_wizard/config/test_bootstrap.py | 35 +-- src/exlab_wizard/constants/__init__.py | 3 +- src/exlab_wizard/constants/app.py | 9 + src/exlab_wizard/constants/enums.py | 7 +- .../orchestrator/quiescence_poller.py | 14 +- src/exlab_wizard/paths.py | 192 ++++++++++++--- src/exlab_wizard/sample_data/generator.py | 13 +- src/exlab_wizard/tray/dependencies.py | 8 +- src/exlab_wizard/ui/equipment_form.py | 5 +- src/exlab_wizard/ui/mount.py | 34 ++- src/exlab_wizard/ui/pages/settings.py | 37 ++- src/exlab_wizard/ui/pages/wizard_equipment.py | 16 +- src/exlab_wizard/validator/engine.py | 8 +- tests/conftest.py | 40 +++ tests/e2e/_test_app.py | 1 - tests/e2e/page_objects/settings_page.py | 18 +- .../e2e/page_objects/wizard_equipment_page.py | 7 +- tests/e2e/test_flow_00_fresh_install_setup.py | 15 +- tests/e2e/test_flow_00_full_lifecycle.py | 21 +- tests/e2e/test_flow_01_onboarding.py | 6 +- tests/e2e/test_flow_08_settings.py | 4 +- tests/e2e/test_flow_16_add_equipment.py | 5 +- .../test_flow_26_equipment_wizard_persist.py | 5 +- .../test_flow_27_nas_credential_settings.py | 9 +- tests/e2e/ux_catalog.py | 46 +--- tests/fixtures/configs/README.md | 4 +- tests/fixtures/configs/complete.yaml | 6 +- .../configs/incomplete_no_equipment.yaml | 4 +- .../fixtures/configs/incomplete_no_lims.yaml | 5 +- .../fixtures/configs/incomplete_no_paths.yaml | 66 ----- tests/integration/api/test_full_flow.py | 35 ++- .../controller/test_creation_flow.py | 16 +- .../controller/test_creation_provenance.py | 10 +- tests/integration/test_nas_sync.py | 23 +- .../test_orchestrator_lifecycle.py | 5 +- tests/unit/api/test_browse.py | 12 +- tests/unit/api/test_config_router.py | 14 +- tests/unit/api/test_health.py | 3 +- tests/unit/api/test_operations.py | 3 +- tests/unit/api/test_problems.py | 10 +- tests/unit/api/test_sessions.py | 3 +- tests/unit/api/test_setup.py | 53 ++-- tests/unit/api/test_staging_router.py | 7 +- tests/unit/config/test_loader.py | 42 ++-- tests/unit/config/test_models.py | 51 ++-- tests/unit/constants/test_enums.py | 4 +- .../unit/controller/test_metadata_assembly.py | 12 +- tests/unit/dev/test_seed.py | 4 +- .../orchestrator/test_quiescence_poller.py | 29 ++- tests/unit/sample_data/test_generator.py | 26 +- tests/unit/sync/test_nas_client.py | 29 +-- tests/unit/sync/test_nas_client_extra.py | 3 +- tests/unit/template/test_resolution.py | 53 ++-- tests/unit/test_data_root_agreement.py | 85 +++++++ tests/unit/test_paths.py | 227 +++++++++++------- tests/unit/tray/test_dependencies.py | 11 +- tests/unit/tray/test_live_reload.py | 19 +- tests/unit/tray/test_main.py | 22 +- tests/unit/ui/test_dynamic_form.py | 1 - tests/unit/ui/test_mount.py | 29 ++- tests/unit/ui/test_settings_nas_remote.py | 7 +- tests/unit/ui/test_settings_page.py | 23 +- tests/unit/ui/test_wizard_equipment.py | 2 - tests/unit/validator/test_engine_audit.py | 18 +- 67 files changed, 954 insertions(+), 631 deletions(-) create mode 100644 tests/conftest.py delete mode 100644 tests/fixtures/configs/incomplete_no_paths.yaml create mode 100644 tests/unit/test_data_root_agreement.py diff --git a/src/exlab_wizard/api/routers/config.py b/src/exlab_wizard/api/routers/config.py index 5f6400a..160b910 100644 --- a/src/exlab_wizard/api/routers/config.py +++ b/src/exlab_wizard/api/routers/config.py @@ -29,6 +29,7 @@ from exlab_wizard.errors import ConfigError from exlab_wizard.logging import get_logger from exlab_wizard.paths import ( + app_root_writable, evaluate_setup_state, setup_state_missing, setup_state_next_action, @@ -105,6 +106,7 @@ async def put_config(request: Request, body: Config) -> ConfigUpdateResponse: lims_reachable=getattr(deps, "lims_reachable", True), keyring_password_present=lims_password_present(deps), nas_remote_available=remote_lookup, + paths_writable=app_root_writable(deps.config) if deps.config is not None else True, ) return ConfigUpdateResponse( state=state.value, @@ -143,6 +145,7 @@ async def append_equipment(request: Request, body: EquipmentConfig) -> Equipment lims_reachable=getattr(deps, "lims_reachable", True), keyring_password_present=lims_password_present(deps), nas_remote_available=remote_lookup, + paths_writable=app_root_writable(deps.config) if deps.config is not None else True, ) return EquipmentAppendResponse( appended_id=body.id, diff --git a/src/exlab_wizard/api/setup.py b/src/exlab_wizard/api/setup.py index dec0484..99ab334 100644 --- a/src/exlab_wizard/api/setup.py +++ b/src/exlab_wizard/api/setup.py @@ -40,6 +40,7 @@ from exlab_wizard.constants import SetupState from exlab_wizard.logging import get_logger from exlab_wizard.paths import ( + app_root_writable, evaluate_setup_state, setup_state_missing, setup_state_next_action, @@ -160,11 +161,13 @@ def compute_setup_state(deps: Any) -> SetupState: ``nas_remote_available`` predicate that answers whether a named rclone remote is present in rclone.conf. """ + config = deps.config return evaluate_setup_state( - deps.config, + config, lims_reachable=getattr(deps, "lims_reachable", True), keyring_password_present=lims_password_present(deps), nas_remote_available=lambda remote: nas_remote_available(deps, remote), + paths_writable=app_root_writable(config) if config is not None else True, ) diff --git a/src/exlab_wizard/config/models.py b/src/exlab_wizard/config/models.py index 7ef1a76..2b6a928 100644 --- a/src/exlab_wizard/config/models.py +++ b/src/exlab_wizard/config/models.py @@ -20,6 +20,7 @@ from __future__ import annotations from datetime import time +from pathlib import Path from typing import Any from pydantic import ( @@ -39,6 +40,7 @@ SyncMode, ) from exlab_wizard.errors import ConfigError +from exlab_wizard.paths import default_app_root __all__ = [ "BandwidthConfig", @@ -93,13 +95,45 @@ def _parse_hhmm(value: str, field_name: str) -> time: class PathsConfig(BaseModel): - """``paths:`` block. Templates / plugins / equipment-first local root.""" + """``paths:`` block. A single app root with derived working subdirectories. + + Only ``app_root`` is stored (and serialized); ``templates/``, ``plugins/`` + and the experiment ``data/`` root are *derived* read-only properties so the + operator configures exactly one location. ``app_root`` defaults under the OS + *Documents* folder (``/ExLabWizard`` via + :func:`exlab_wizard.paths.default_app_root`) so a fresh install needs no + manual path entry. + + The derived names ``templates_dir`` / ``plugin_dir`` / ``local_root`` are + kept so existing read-only consumers (run creation, browse, template + resolution) keep reading ``config.paths.local_root`` unchanged -- it now + resolves to ``/data``. ``local_root`` is an alias of + ``data_root``; new code should prefer ``data_root``. + """ model_config = ConfigDict(extra="forbid", str_strip_whitespace=True) - templates_dir: str = "" - plugin_dir: str = "" - local_root: str = "" + app_root: str = Field(default_factory=lambda: str(default_app_root())) + + @property + def data_root(self) -> str: + """The experiment data root, ``/data``.""" + return str(Path(self.app_root) / "data") + + @property + def local_root(self) -> str: + """Alias of :attr:`data_root` (kept for existing consumers).""" + return self.data_root + + @property + def templates_dir(self) -> str: + """The global Copier template library, ``/templates``.""" + return str(Path(self.app_root) / "templates") + + @property + def plugin_dir(self) -> str: + """The lab plugin directory, ``/plugins``.""" + return str(Path(self.app_root) / "plugins") # --------------------------------------------------------------------------- @@ -284,7 +318,6 @@ class EquipmentConfig(BaseModel): id: str label: str = Field(min_length=1) - local_root: str = Field(min_length=1) nas_root: str = Field(min_length=1) sync_mode: SyncMode = SyncMode.NAS diff --git a/src/exlab_wizard/config/test_bootstrap.py b/src/exlab_wizard/config/test_bootstrap.py index 0a666d8..4752a3b 100644 --- a/src/exlab_wizard/config/test_bootstrap.py +++ b/src/exlab_wizard/config/test_bootstrap.py @@ -18,12 +18,13 @@ def write_starter_test_config(config_path: Path) -> None: """Write a starter test ``config.yaml`` if one does not already exist. - Preseeds every path-typed field under the test sandbox - (``config_path.parent``) so the wizard runs without manual Settings entry; - the LIMS endpoint and email are intentionally left blank so the operator - still wires that integration through the live Settings UI. Equipment is - left empty -- sample equipment is seeded separately by - :func:`exlab_wizard.sample_data.generate_samples`. + Pins ``paths.app_root`` to an ``app/`` folder under the test sandbox + (``config_path.parent``) -- rather than the real Documents-folder default -- + so the whole working tree stays contained in the suffixed sandbox and the + wizard runs without manual Settings entry. The LIMS endpoint and email are + intentionally left blank so the operator still wires that integration + through the live Settings UI. Equipment is left empty -- sample equipment is + seeded separately by :func:`exlab_wizard.sample_data.generate_samples`. Idempotent: an existing config is never overwritten. The sandbox is persistent across launches and the operator resets it by deleting the @@ -36,14 +37,10 @@ def write_starter_test_config(config_path: Path) -> None: # need the function lazily (the tray test path and the dev command). from exlab_wizard.config.loader import save_config from exlab_wizard.config.models import Config, OrchestratorConfig, PathsConfig - from exlab_wizard.paths import ensure_dir + from exlab_wizard.paths import ensure_app_dirs, ensure_dir sandbox = config_path.parent # e.g. ~/Library/Application Support/exlab-wizard-test - paths_cfg = PathsConfig( - templates_dir=str(sandbox / "templates"), - plugin_dir=str(sandbox / "plugins"), - local_root=str(sandbox / "local"), - ) + paths_cfg = PathsConfig(app_root=str(sandbox / "app")) orchestrator_cfg = OrchestratorConfig( label="test-workstation", staging_root=str(sandbox / "staging"), @@ -51,16 +48,10 @@ def write_starter_test_config(config_path: Path) -> None: cfg = Config(paths=paths_cfg, orchestrator=orchestrator_cfg, equipment=[]) save_config(config_path, cfg) - # Pre-create the preseeded sub-directories so first-launch path lookups - # (template scans, plugin discovery) do not fail on a missing tree. - for sub in ( - paths_cfg.templates_dir, - paths_cfg.plugin_dir, - paths_cfg.local_root, - orchestrator_cfg.staging_root, - ): - if sub: - ensure_dir(Path(sub)) + # Pre-create the app root + derived data/templates/plugins (and the staging + # root) so first-launch path lookups don't fail on a missing tree. + ensure_app_dirs(cfg) + ensure_dir(Path(orchestrator_cfg.staging_root)) _log.info("test mode: wrote starter config [path=%s]", str(config_path)) diff --git a/src/exlab_wizard/constants/__init__.py b/src/exlab_wizard/constants/__init__.py index bd1955f..c86e10b 100644 --- a/src/exlab_wizard/constants/__init__.py +++ b/src/exlab_wizard/constants/__init__.py @@ -10,7 +10,7 @@ from __future__ import annotations # ---- App-level identifiers ---- -from exlab_wizard.constants.app import APP_NAME, TEST_MODE_ENV, TEST_MODE_PREFIX +from exlab_wizard.constants.app import APP_NAME, DISPLAY_NAME, TEST_MODE_ENV, TEST_MODE_PREFIX # ---- Enums (Backend §4.7, §4.9.1, §5.2, §6.2.4, §7, §8.1, §11.3, §13.3) ---- from exlab_wizard.constants.enums import ( @@ -163,6 +163,7 @@ # Schema versions "CREATION_JSON_VERSION", "DISK_SPACE_PREFLIGHT_MIB", + "DISPLAY_NAME", # Patterns (raw + compiled + filename rules) "EQUIPMENT_ID_MAX_LENGTH", "EQUIPMENT_ID_PATTERN", diff --git a/src/exlab_wizard/constants/app.py b/src/exlab_wizard/constants/app.py index 9872071..f593510 100644 --- a/src/exlab_wizard/constants/app.py +++ b/src/exlab_wizard/constants/app.py @@ -14,6 +14,15 @@ # directory name in constants/filenames.py. APP_NAME: str = "exlab-wizard" +# User-facing display name. Unlike :data:`APP_NAME` (the hidden-directory +# identifier used for config / state / cache / log dirs), this is the +# CamelCase brand shown to operators -- specifically the subfolder created +# under the OS *Documents* directory that holds the single app root +# (``/ExLabWizard/`` with templates/, plugins/, data/). Kept +# distinct from APP_NAME so the operator-facing working tree reads nicely +# while the machine-local dirs stay lowercase-hyphenated. +DISPLAY_NAME: str = "ExLabWizard" + # Runtime opt-in flag: when set to a truthy value the config loader # prefixes every ``equipment[i].id`` with :data:`TEST_MODE_PREFIX` so # the resulting on-disk + NAS run directories sort under a single diff --git a/src/exlab_wizard/constants/enums.py b/src/exlab_wizard/constants/enums.py index 30c2805..ffc221b 100644 --- a/src/exlab_wizard/constants/enums.py +++ b/src/exlab_wizard/constants/enums.py @@ -144,10 +144,15 @@ class SetupState(StrEnum): missing ``orchestrator.label`` -- the required workstation identity. ``orchestrator.staging_root`` is opt-in and does not gate setup (a blank value just means this device is not a staging PC). + + ``INCOMPLETE_PATHS_UNWRITABLE`` trips when the single ``paths.app_root`` + (which always defaults under the OS Documents folder, so it is never blank) + cannot be created or written -- e.g. a relocated app root on a missing + drive or a permission-denied directory. """ INCOMPLETE_NO_CONFIG = "incomplete_no_config" - INCOMPLETE_MISSING_PATHS = "incomplete_missing_paths" + INCOMPLETE_PATHS_UNWRITABLE = "incomplete_paths_unwritable" INCOMPLETE_NO_ORCHESTRATOR = "incomplete_no_orchestrator" INCOMPLETE_NO_EQUIPMENT = "incomplete_no_equipment" INCOMPLETE_NO_NAS_REMOTE = "incomplete_no_nas_remote" diff --git a/src/exlab_wizard/orchestrator/quiescence_poller.py b/src/exlab_wizard/orchestrator/quiescence_poller.py index b01ec29..cbecdf3 100644 --- a/src/exlab_wizard/orchestrator/quiescence_poller.py +++ b/src/exlab_wizard/orchestrator/quiescence_poller.py @@ -265,15 +265,17 @@ def _add(leaf: Path) -> None: if staging_root: for leaf in walk_run_leaves(Path(staging_root)): _add(leaf) + data_root = self._config.paths.local_root for equipment in self._config.equipment: if equipment.sync_mode != SyncMode.NAS: continue - if not equipment.local_root: - continue - # Runs live at ``///...``; - # walk only this equipment's own subtree so a co-rooted - # ``stage``-mode equipment is never discovered here. - equipment_dir = Path(equipment.local_root) / equipment.id + # Runs live at ``///...`` where + # ``data_root`` is the single derived ``/data`` -- the + # same base run creation composes against, so the poller never + # watches a different tree than runs are written to. Walk only this + # equipment's own subtree so a co-rooted ``stage``-mode equipment is + # never discovered here. + equipment_dir = Path(data_root) / equipment.id for leaf in walk_equipment_run_leaves(equipment_dir): _add(leaf) return runs diff --git a/src/exlab_wizard/paths.py b/src/exlab_wizard/paths.py index b917d62..90296f7 100644 --- a/src/exlab_wizard/paths.py +++ b/src/exlab_wizard/paths.py @@ -23,6 +23,7 @@ CACHE_DIR_NAME, CENTRAL_LOG_FILE, CREATION_JSON_NAME, + DISPLAY_NAME, EQUIPMENT_ID_MAX_LENGTH, EQUIPMENT_ID_PATTERN, EQUIPMENT_JSON_NAME, @@ -49,11 +50,14 @@ __all__ = [ "TEST_MODE_ENV", + "app_root_writable", "cache_dir", "canonicalize_equipment_id", "compose_project_path", "compose_run_path", "creation_json_path", + "default_app_root", + "ensure_app_dirs", "ensure_central_log_dir", "ensure_dir", "ensure_state_dir", @@ -64,6 +68,7 @@ "os_cache_path", "os_central_log_path", "os_config_path", + "os_documents_path", "os_state_path", "readme_fields_json_path", "run_dir_stem", @@ -99,6 +104,18 @@ def _app_name() -> str: return APP_NAME +def _display_name() -> str: + """``DISPLAY_NAME`` (suffixed ``-test`` when ``EXLAB_WIZARD_TEST_MODE=1``). + + The operator-facing Documents-subfolder name, mirroring ``_app_name``'s + test-mode handling so a ``--test`` run sandboxes into + ``/ExLabWizard-test`` rather than the real working tree. + """ + if os.environ.get(TEST_MODE_ENV) == "1": + return f"{DISPLAY_NAME}-test" + return DISPLAY_NAME + + # --------------------------------------------------------------------------- # OS-aware path helpers (no side effects) # --------------------------------------------------------------------------- @@ -212,6 +229,100 @@ def suggested_staging_root() -> Path: return _env_path("XDG_DATA_HOME", _home() / ".local" / "share") / name / "staging" +def os_documents_path() -> Path: + """Return the operator's OS *Documents* directory. Pure; never raises. + + The single app root (:func:`default_app_root`) lives under Documents so + everything an operator curates -- experiment data, templates, plugins -- + sits in a familiar, backup-friendly location (mirroring how other desktop + apps adopt the user's Documents folder), distinct from the hidden + config / state / cache dirs named by :func:`_app_name`. + + Per platform: + + - **macOS** -- ``~/Documents``. + - **Windows** -- the Known Folder for Documents via + ``SHGetKnownFolderPath(FOLDERID_Documents)`` so a relocated or localized + Documents folder is honoured; on any ``ctypes`` failure it falls back to + ``%USERPROFILE%\\Documents`` and finally ``~/Documents``. + - **Linux** -- ``$XDG_DOCUMENTS_DIR`` if set, else ``~/Documents``. + """ + match _platform(): + case Platform.MACOS: + return _home() / "Documents" + case Platform.WINDOWS: + return _windows_documents_path() + case Platform.LINUX: + return _env_path("XDG_DOCUMENTS_DIR", _home() / "Documents") + + +def _windows_documents_path() -> Path: + """Resolve the Windows Documents Known Folder, with graceful fallbacks. + + Tries ``SHGetKnownFolderPath(FOLDERID_Documents)`` so a user who relocated + their Documents folder (or runs a localized Windows) gets the real path; + falls back to ``%USERPROFILE%\\Documents`` then ``~/Documents`` if the + Win32 call is unavailable or errors. + """ + fallback = _env_path("USERPROFILE", _home()) / "Documents" + try: + return _shget_known_documents() + except Exception: + # Non-Windows host (no ``ctypes.windll``) or a failed/empty Win32 call. + return fallback + + +def _shget_known_documents() -> Path: # pragma: no cover -- Windows-only Known Folder API + """Resolve ``FOLDERID_Documents`` via ``SHGetKnownFolderPath`` (Windows only). + + Raises on any non-Windows host (``ctypes.windll`` is undefined there) or on a + failed / empty Win32 result, so :func:`_windows_documents_path` falls back. + Excluded from coverage: the Win32 call cannot execute on the Linux/macOS CI + runners. + """ + import ctypes + from ctypes import windll, wintypes # type: ignore[attr-defined] + + # FOLDERID_Documents = {FDD39AD0-238F-46AF-ADB4-6C85480369C7} + class _GUID(ctypes.Structure): + _fields_ = ( + ("Data1", wintypes.DWORD), + ("Data2", wintypes.WORD), + ("Data3", wintypes.WORD), + ("Data4", ctypes.c_byte * 8), + ) + + folderid = _GUID( + 0xFDD39AD0, + 0x238F, + 0x46AF, + (ctypes.c_byte * 8)(0xAD, 0xB4, 0x6C, 0x85, 0x48, 0x03, 0x69, 0xC7), + ) + out = ctypes.c_wchar_p() + # SHGetKnownFolderPath returns S_OK (0) on success; any non-zero HRESULT is a + # failure, so raise rather than read an unset/garbage pointer. + if windll.shell32.SHGetKnownFolderPath(ctypes.byref(folderid), 0, None, ctypes.byref(out)): + raise OSError("SHGetKnownFolderPath failed") + try: + if not out.value: + raise OSError("SHGetKnownFolderPath returned an empty path") + return Path(out.value) + finally: + windll.ole32.CoTaskMemFree(out) + + +def default_app_root() -> Path: + """Suggested default app root under the OS *Documents* folder. + + ``/ExLabWizard`` (``ExLabWizard-test`` in test mode). This is the + single configurable root from which ``templates/``, ``plugins/`` and + ``data/`` are derived (see :class:`exlab_wizard.config.models.PathsConfig`). + Pure and side-effect-free; the directory tree is materialized only by + :func:`ensure_app_dirs`. + """ + return os_documents_path() / _display_name() + + # --------------------------------------------------------------------------- # Mkdir helpers (side effects) # --------------------------------------------------------------------------- @@ -233,6 +344,38 @@ def ensure_central_log_dir() -> Path: return ensure_dir(os_central_log_path().parent) +def ensure_app_dirs(config: Config) -> None: + """``mkdir -p`` the app root and its derived working subdirectories. + + Materializes ``app_root`` plus the derived ``data/``, ``templates/`` and + ``plugins/`` folders (see + :class:`exlab_wizard.config.models.PathsConfig`). Idempotent; called on + tray bring-up and after a Settings save so a fresh install never has to + pre-create its working tree by hand. The subfolder names come from the + config's derived properties so this stays the single creation site. + """ + paths = config.paths + for directory in (paths.app_root, paths.data_root, paths.templates_dir, paths.plugin_dir): + ensure_dir(Path(directory)) + + +def app_root_writable(config: Config) -> bool: + """Return True when the app root and ``data/`` are creatable and writable. + + Drives the §4.9.1 paths gate (see :func:`evaluate_setup_state`): the app + root is always populated (it defaults under Documents), so the gate no + longer asks "is it blank" but "can we actually create and write runs + here". Attempts :func:`ensure_app_dirs`, then probes ``os.access(W_OK)`` + on the app root and the data root. Returns False on any ``OSError`` (bad + drive, permission denied) rather than raising. + """ + try: + ensure_app_dirs(config) + except OSError: + return False + return os.access(config.paths.app_root, os.W_OK) and os.access(config.paths.data_root, os.W_OK) + + # --------------------------------------------------------------------------- # Equipment-ID canonicalization # --------------------------------------------------------------------------- @@ -434,16 +577,6 @@ def _lims_slot_satisfied( return bool(lims.endpoint and lims.email and keyring_password_present) -def _paths_complete(config: Config) -> bool: - """Return True when every required ``paths.*`` field is non-empty. - - The unit-level check is purely string emptiness -- filesystem - accessibility is checked elsewhere in the §4.9.1 evaluation chain. - """ - paths = config.paths - return bool(paths.templates_dir and paths.plugin_dir and paths.local_root) - - def _nas_in_use(config: Config) -> bool: """True when at least one nas-mode equipment exists (NAS sync is active).""" from exlab_wizard.constants import SyncMode @@ -469,14 +602,15 @@ def evaluate_setup_state( lims_reachable: bool = True, keyring_password_present: bool = True, nas_remote_available: Callable[[str], bool] | None = None, + paths_writable: bool = True, ) -> SetupState: """Evaluate the §4.9.1 setup state. Order of gates (first-failing wins): 1. ``config is None`` -> ``INCOMPLETE_NO_CONFIG`` - 2. ``paths.templates_dir`` / ``plugin_dir`` / ``local_root`` any empty -> - ``INCOMPLETE_MISSING_PATHS`` + 2. ``paths.app_root`` cannot be created / written -> + ``INCOMPLETE_PATHS_UNWRITABLE`` 3. equipment list empty -> ``INCOMPLETE_NO_EQUIPMENT`` 4. NAS sync is in use but the ``nas:`` remote is unset or absent from rclone.conf -> ``INCOMPLETE_NO_NAS_REMOTE`` (rclone.conf migration) @@ -492,11 +626,16 @@ def evaluate_setup_state( keyring backend. ``nas_remote_available`` answers "is this rclone remote present in rclone.conf?"; it defaults to "always True" so callers and tests that don't care about the NAS gate behave as before. + ``paths_writable`` answers "can the app root be created and written?" + (computed by the caller via :func:`app_root_writable`); it defaults True + so callers/tests that don't care about the paths gate behave as before. + The app root always defaults under the OS Documents folder, so the gate + checks writability rather than emptiness. """ if config is None: return SetupState.INCOMPLETE_NO_CONFIG - if not _paths_complete(config): - return SetupState.INCOMPLETE_MISSING_PATHS + if not paths_writable: + return SetupState.INCOMPLETE_PATHS_UNWRITABLE if not _orchestrator_identity_complete(config): return SetupState.INCOMPLETE_NO_ORCHESTRATOR if not config.equipment: @@ -543,8 +682,8 @@ def setup_state_missing( return [{"field": "config.yaml", "reason": "missing"}] case SetupState.INCOMPLETE_NO_EQUIPMENT: return [{"field": "equipment", "reason": "empty"}] - case SetupState.INCOMPLETE_MISSING_PATHS: - return _missing_paths_fields(config) + case SetupState.INCOMPLETE_PATHS_UNWRITABLE: + return _missing_paths_fields() case SetupState.INCOMPLETE_NO_ORCHESTRATOR: return _missing_orchestrator_fields(config) case SetupState.INCOMPLETE_NO_NAS_REMOTE: @@ -578,17 +717,14 @@ def _missing_orchestrator_fields(config: Config | None) -> list[dict[str, str]]: return out -def _missing_paths_fields(config: Config | None) -> list[dict[str, str]]: - field_specs = ( - ("paths.templates_dir", lambda c: c.paths.templates_dir), - ("paths.plugin_dir", lambda c: c.paths.plugin_dir), - ("paths.local_root", lambda c: c.paths.local_root), - ) - if config is None: - return [{"field": name, "reason": "unset"} for name, _ in field_specs] - return [ - {"field": name, "reason": "unset"} for name, accessor in field_specs if not accessor(config) - ] +def _missing_paths_fields() -> list[dict[str, str]]: + """Single ``paths.app_root`` row for ``INCOMPLETE_PATHS_UNWRITABLE``. + + The app root always defaults under Documents, so the failure is never + "unset" -- it is that the resolved location cannot be created or written + (missing drive, permission denied). + """ + return [{"field": "paths.app_root", "reason": "unwritable"}] def _missing_lims_fields(config: Config | None) -> list[dict[str, str]]: @@ -617,7 +753,7 @@ def setup_state_next_action(state: SetupState) -> SetupNextAction | None: (no further action required). """ match state: - case SetupState.INCOMPLETE_NO_CONFIG | SetupState.INCOMPLETE_MISSING_PATHS: + case SetupState.INCOMPLETE_NO_CONFIG | SetupState.INCOMPLETE_PATHS_UNWRITABLE: return SetupNextAction.SET_PATHS case SetupState.INCOMPLETE_NO_ORCHESTRATOR: # Redesign §3.1: label + staging_root fold into an early diff --git a/src/exlab_wizard/sample_data/generator.py b/src/exlab_wizard/sample_data/generator.py index 0182294..8c30434 100644 --- a/src/exlab_wizard/sample_data/generator.py +++ b/src/exlab_wizard/sample_data/generator.py @@ -206,13 +206,12 @@ def _build_and_reload_config(self) -> Config: EquipmentConfig( id=sample.id, # raw; the loader prefixes on reload label=sample.label, - # ``local_root`` / ``nas_root`` are the BASE roots: consumers - # (orchestrator quiescence poller, validator) compose - # ``Path(local_root) / equipment.id``, and ``build_creation_json`` - # composes ``Path(nas_root) / equipment_id`` -- so the id is - # appended downstream, never baked in here (matches a real - # operator config and ``config.paths.local_root``). - local_root=str(self._sandbox / "local"), + # ``nas_root`` is the BASE root: ``build_creation_json`` composes + # ``Path(nas_root) / equipment_id`` so the id is appended + # downstream, never baked in here. The on-disk data root is no + # longer per-equipment -- it derives from the single + # ``config.paths.local_root`` (``/data``) that the + # write path (line ~173) and the wipe both read. nas_root=str(self._sandbox / "nas"), sync_mode=sample.sync_mode, ) diff --git a/src/exlab_wizard/tray/dependencies.py b/src/exlab_wizard/tray/dependencies.py index 66b488a..8efc2fd 100644 --- a/src/exlab_wizard/tray/dependencies.py +++ b/src/exlab_wizard/tray/dependencies.py @@ -30,7 +30,7 @@ from exlab_wizard.config.loader import load_config, save_config from exlab_wizard.constants import KEYRING_USERNAME_LIMS from exlab_wizard.logging import get_logger -from exlab_wizard.paths import os_config_path +from exlab_wizard.paths import ensure_app_dirs, os_config_path from exlab_wizard.tray.autostart import AutostartManager __all__ = ["apply_live_config", "build_production_dependencies"] @@ -57,6 +57,12 @@ def build_production_dependencies(state_dir: Path) -> AppDependencies: deps.state_dir = state_dir deps.config = _try("config", _load_config_safely) + if deps.config is not None: + # Materialize the app root + derived data/templates/plugins dirs on + # bring-up so first launch (with an existing config) never trips over + # a missing working tree. Best-effort: a failure is logged and the + # setup writability gate surfaces an unusable root. + _try("app_dirs", ensure_app_dirs, deps.config) # Wire the saver unconditionally: a fresh install has no config.yaml # yet, but the settings wizard must be able to *create* one. The # saver handles the missing-file case (no original text to preserve). diff --git a/src/exlab_wizard/ui/equipment_form.py b/src/exlab_wizard/ui/equipment_form.py index e83e052..73d76dc 100644 --- a/src/exlab_wizard/ui/equipment_form.py +++ b/src/exlab_wizard/ui/equipment_form.py @@ -22,7 +22,6 @@ def build_equipment_config( *, equipment_id: str, label: str, - local_root: str, nas_root: str, sync_mode: str = "nas", ) -> EquipmentConfig: @@ -34,11 +33,13 @@ def build_equipment_config( ``nas:`` remote; the staging hop is defined once by ``orchestrator.staging_remote`` / ``staging_base_root``. The push target is selected by ``sync_mode`` at sync time. + + Equipment no longer stores its own ``local_root``: its data directory is + derived from the single app root (``/``). """ return EquipmentConfig( id=equipment_id.strip(), label=label.strip(), - local_root=local_root.strip(), nas_root=nas_root.strip(), sync_mode=SyncMode(sync_mode), ) diff --git a/src/exlab_wizard/ui/mount.py b/src/exlab_wizard/ui/mount.py index 0bec918..ce64ab0 100644 --- a/src/exlab_wizard/ui/mount.py +++ b/src/exlab_wizard/ui/mount.py @@ -715,12 +715,33 @@ def _persist_config(deps: Any, updated: Any, ui: Any) -> bool: _log.exception("save_config failed") _show_toast(ui, f"Save failed: {exc}", positive=False) return False + _ensure_app_dirs(updated) _ensure_staging_root(updated, ui) if deps is not None: _apply_live_config(deps, updated) return True +def _ensure_app_dirs(updated: Any) -> None: + """Create the app root and its derived subdirs after a save. + + Materializes ``app_root`` plus ``data/``, ``templates/`` and ``plugins/`` + so a fresh install's working tree exists the moment paths are saved. A + creation failure is non-fatal (the config is already persisted; the setup + writability gate surfaces an unusable root), and a non-``Config`` value is + a defensive no-op. + """ + paths = getattr(updated, "paths", None) + if paths is None or not getattr(paths, "app_root", ""): + return + from exlab_wizard.paths import ensure_app_dirs + + try: + ensure_app_dirs(updated) + except OSError: + _log.exception("failed to create app dirs") + + def _ensure_staging_root(updated: Any, ui: Any) -> None: """Create the staging directory when the operator saved a non-empty path. @@ -1067,11 +1088,15 @@ def _metadata_for_owned_equipment(node_id: str, config: Any) -> dict[str, Any]: for entry in getattr(config, "equipment", []): if entry.id != node_id: continue + # Equipment data dir is derived from the single app root + # (``/``); equipment no longer stores its own + # local_root. + local_root = str(Path(config.paths.local_root) / entry.id) return { "id": entry.id, "label": entry.label or entry.id, "sync_mode": str(getattr(entry, "sync_mode", "")) or "nas", - "local_root": entry.local_root or "", + "local_root": local_root, "nas_root": entry.nas_root or "", } return {} @@ -1829,8 +1854,11 @@ def _missing_setup_sections(deps: Any) -> tuple[str, ...]: # placeholder section before they could reach the main GUI. return ("paths", "lims") missing: list[str] = [] - if not config.paths.local_root or not config.paths.templates_dir: - missing.append("paths") + # The single ``paths.app_root`` always carries a sensible Documents-based + # default, so there is no "unset paths" section to auto-select for a + # config that exists. The rare unwritable-app-root case is surfaced by the + # setup banner (``compute_setup_state`` -> ``INCOMPLETE_PATHS_UNWRITABLE``), + # not by this Settings section picker. # rclone.conf NAS-sync migration: surface the NAS-remote section when # nas-mode equipment exist but the configured ``nas.remote`` is absent # from rclone.conf, so the setup-incomplete banner auto-selects it. diff --git a/src/exlab_wizard/ui/pages/settings.py b/src/exlab_wizard/ui/pages/settings.py index aef09b0..edcc261 100644 --- a/src/exlab_wizard/ui/pages/settings.py +++ b/src/exlab_wizard/ui/pages/settings.py @@ -511,15 +511,30 @@ def _render_section_body( ) if section == "paths": - ui.input(label="Templates directory", value=draft.paths.templates_dir).props( - 'data-testid="settings-paths-templates"' - ).bind_value(draft.paths, "templates_dir") - ui.input(label="Plugin directory", value=draft.paths.plugin_dir).props( - 'data-testid="settings-paths-plugin"' - ).bind_value(draft.paths, "plugin_dir") - ui.input(label="Local data root", value=draft.paths.local_root).props( - 'data-testid="settings-paths-local-root"' - ).bind_value(draft.paths, "local_root") + # A single configurable app root (defaults under the OS Documents + # folder). templates/, plugins/ and the experiment data/ root are + # derived from it -- shown read-only below so the layout is clear. + ui.input(label="Data folder", value=draft.paths.app_root).props( + 'data-testid="settings-paths-app-root"' + ).bind_value(draft.paths, "app_root") + ui.label("Derived locations (created automatically):").style( + "color: var(--color-muted); font-size: var(--text-sm);" + ) + for caption, attr in ( + ("Templates", "templates_dir"), + ("Plugins", "plugin_dir"), + ("Data", "data_root"), + ): + + def _derived_label(value: str, *, prefix: str = caption) -> str: + """Render a derived-dir label as ``Caption: `` (prefix + is bound per-iteration via the default arg).""" + return f"{prefix}: {value}" + + ui.label().props(f'data-testid="settings-paths-derived-{attr}"').style( + "color: var(--color-muted); font-family: var(--font-mono); " + "font-size: var(--text-xs);" + ).bind_text_from(draft.paths, attr, backward=_derived_label) elif section == "lims": ui.input(label="Endpoint URL", value=draft.lims.endpoint).props( 'data-testid="settings-lims-endpoint"' @@ -733,7 +748,6 @@ def _render_rows() -> None: 'data-testid="settings-equipment-id"' ) eq_label = ui.input(label="Label").props('data-testid="settings-equipment-label"') - eq_local = ui.input(label="Local root").props('data-testid="settings-equipment-local-root"') eq_nas = ui.input(label="NAS root").props('data-testid="settings-equipment-nas-root"') def _add(_evt: Any = None) -> None: @@ -741,7 +755,6 @@ def _add(_evt: Any = None) -> None: entry = build_equipment_config( equipment_id=eq_id.value or "", label=eq_label.value or "", - local_root=eq_local.value or "", nas_root=eq_nas.value or "", sync_mode="nas", ) @@ -753,7 +766,7 @@ def _add(_evt: Any = None) -> None: return draft.equipment.append(entry) _render_rows() - for widget in (eq_id, eq_label, eq_local, eq_nas): + for widget in (eq_id, eq_label, eq_nas): widget.value = "" notifications.notify_success(f"Equipment {entry.id!r} added") diff --git a/src/exlab_wizard/ui/pages/wizard_equipment.py b/src/exlab_wizard/ui/pages/wizard_equipment.py index c390e45..a079218 100644 --- a/src/exlab_wizard/ui/pages/wizard_equipment.py +++ b/src/exlab_wizard/ui/pages/wizard_equipment.py @@ -4,7 +4,8 @@ 1. Identity — equipment ID (validated against ``^[A-Z][A-Z0-9_]*$``) + label. -2. Paths — local_root (where this device acquires runs). +2. Paths — NAS root (the equipment's data dir is derived from the single + app root, so it is not collected here). 3. Review & confirm — assembles a validated EquipmentConfig via the shared ``build_equipment_config()`` and posts it through ``POST /config/equipment``. @@ -61,8 +62,8 @@ class EquipmentWizardState: # Step 1 equipment_id: str = "" label: str = "" - # Step 2 - local_root: str = "" + # Step 2 -- the equipment's data dir is derived from the single app root + # (``/``), so only the NAS root is collected. nas_root: str = "" # sync_mode is retained but no longer operator-selectable: the sync-mode # wizard step is hidden (orchestrator/staging hidden — see module note), @@ -89,7 +90,7 @@ def can_advance(state: EquipmentWizardState) -> bool: and state.label.strip() ) case "paths": - return bool(state.local_root.strip() and state.nas_root.strip()) + return bool(state.nas_root.strip()) case "sync_mode": # rclone.conf migration (Phase 8): neither mode collects a # per-equipment transport here -- the ``nas:`` remote defines the @@ -112,7 +113,6 @@ def assemble_equipment_config( return build_equipment_config( equipment_id=state.equipment_id, label=state.label, - local_root=state.local_root, nas_root=state.nas_root, sync_mode=state.sync_mode, ) @@ -265,9 +265,8 @@ def _render_paths_step( from nicegui import ui except Exception: return - ui.input(label="Local root", on_change=lambda _e: sync_next()).props( - 'data-testid="wizard-equipment-local-root"' - ).bind_value(state, "local_root") + # The equipment's data dir is derived from the single app root + # (Settings -> Data folder); only the NAS root is collected here. ui.input(label="NAS root", on_change=lambda _e: sync_next()).props( 'data-testid="wizard-equipment-nas-root"' ).bind_value(state, "nas_root") @@ -327,7 +326,6 @@ def _render_review_step( with ui.column().style("font-family: var(--font-mono);"): ui.label(f"ID: {state.equipment_id}") ui.label(f"Label: {state.label}") - ui.label(f"Local root: {state.local_root}") ui.label(f"NAS root: {state.nas_root}") if state.sync_mode == "nas": ui.label( diff --git a/src/exlab_wizard/validator/engine.py b/src/exlab_wizard/validator/engine.py index b17b1e9..2ac264e 100644 --- a/src/exlab_wizard/validator/engine.py +++ b/src/exlab_wizard/validator/engine.py @@ -433,9 +433,15 @@ def from_config(cls, config: Any) -> Validator: coupled to the entire config schema. Used by the FastAPI lifespan when wiring the audit task. """ + # Equipment data lives at ``/`` where + # data_root is the single derived ``config.paths.local_root`` + # (``/data``) -- the same base run creation and the + # quiescence poller use, so audit roots can never diverge from where + # runs are actually written. + data_root = config.paths.local_root equipment_roots: dict[str, Path] = {} for entry in getattr(config, "equipment", []) or []: - equipment_roots[entry.id] = Path(entry.local_root) / entry.id + equipment_roots[entry.id] = Path(data_root) / entry.id staging_root: Path | None = None orch = getattr(config, "orchestrator", None) if orch is not None and getattr(orch, "enabled", False): diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..c854261 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,40 @@ +"""Shared pytest fixtures for the ExLabWizard test suite. + +Centralizes the one cross-cutting seam introduced by the single-app-root +refactor: the setup-state paths gate now probes the real filesystem +(``paths.app_root_writable`` does an ``ensure_app_dirs`` + ``os.access``), +where it used to be a pure string-emptiness check. The vast majority of +unit/integration tests build configs with placeholder roots (``/srv/...``, +``/data/...``) and only care about the *other* setup gates (equipment, NAS +remote, LIMS) or about reaching ``READY`` -- they should not depend on those +fake paths being writable on the test host. + +The :func:`_assume_app_root_writable` autouse fixture therefore stubs the +*imported* ``app_root_writable`` reference inside the two API call sites +(``api.setup`` and ``api.routers.config``) to return ``True``. It deliberately +leaves :func:`exlab_wizard.paths.app_root_writable` itself untouched, so the +unit tests in ``tests/unit/test_paths.py`` still exercise the real +mkdir/``os.access`` behavior. A test that needs the unwritable branch (e.g. to +assert the ``paths`` section/banner appears) overrides this per-test via +``monkeypatch.setattr("exlab_wizard.api.setup.app_root_writable", lambda _c: False)``. +""" + +from __future__ import annotations + +import contextlib + +import pytest + + +@pytest.fixture(autouse=True) +def _assume_app_root_writable(monkeypatch: pytest.MonkeyPatch) -> None: + """Treat the app root as writable in the API setup-state call sites. + + See the module docstring. Patches the names where they are *used*, not the + definition, so direct tests of ``paths.app_root_writable`` are unaffected. + """ + for module in ("exlab_wizard.api.setup", "exlab_wizard.api.routers.config"): + # The module may not expose the name in a given test environment + # (e.g. optional API extras); the patch is simply skipped there. + with contextlib.suppress(ImportError, AttributeError): + monkeypatch.setattr(f"{module}.app_root_writable", lambda _config: True) diff --git a/tests/e2e/_test_app.py b/tests/e2e/_test_app.py index 371bc0f..675e920 100644 --- a/tests/e2e/_test_app.py +++ b/tests/e2e/_test_app.py @@ -622,7 +622,6 @@ def wizard_equipment_index(step: str = "identity", seed: str = "1") -> None: active_step=step or "identity", equipment_id="FLOW_99", label="Flow Cytometer 99", - local_root="/data", nas_root="/srv/nas", sync_mode="nas", ) diff --git a/tests/e2e/page_objects/settings_page.py b/tests/e2e/page_objects/settings_page.py index 48e4a73..e3e3ef2 100644 --- a/tests/e2e/page_objects/settings_page.py +++ b/tests/e2e/page_objects/settings_page.py @@ -33,19 +33,13 @@ def section(self, section: str) -> Locator: return self._page.get_by_test_id(f"settings-section-{section}") @property - def paths_templates(self) -> Locator: - """Paths section: templates directory input.""" - return self._page.get_by_test_id("settings-paths-templates") + def paths_app_root(self) -> Locator: + """Paths section: the single 'Data folder' (app root) input. - @property - def paths_plugin(self) -> Locator: - """Paths section: plugin directory input.""" - return self._page.get_by_test_id("settings-paths-plugin") - - @property - def paths_local_root(self) -> Locator: - """Paths section: local data root input.""" - return self._page.get_by_test_id("settings-paths-local-root") + templates/, plugins/ and data/ are derived from this one root and + shown as read-only labels, so there is no longer a per-dir input. + """ + return self._page.get_by_test_id("settings-paths-app-root") @property def lims_password_primary(self) -> Locator: diff --git a/tests/e2e/page_objects/wizard_equipment_page.py b/tests/e2e/page_objects/wizard_equipment_page.py index ee513c3..07c610f 100644 --- a/tests/e2e/page_objects/wizard_equipment_page.py +++ b/tests/e2e/page_objects/wizard_equipment_page.py @@ -26,11 +26,8 @@ def label(self) -> Any: def step_paths(self) -> Any: return self._page.locator('[data-testid="wizard-equipment-step-paths"]') - # Paths - @property - def local_root(self) -> Any: - return self._page.locator('[data-testid="wizard-equipment-local-root"]') - + # Paths -- the equipment's data dir is derived from the single app root, so + # the wizard's paths step now collects only the NAS root. @property def nas_root(self) -> Any: return self._page.locator('[data-testid="wizard-equipment-nas-root"]') diff --git a/tests/e2e/test_flow_00_fresh_install_setup.py b/tests/e2e/test_flow_00_fresh_install_setup.py index 19781cd..5f339c4 100644 --- a/tests/e2e/test_flow_00_fresh_install_setup.py +++ b/tests/e2e/test_flow_00_fresh_install_setup.py @@ -113,12 +113,9 @@ def test_fresh_install_setup_writes_config_and_applies_live( config_path = resolve_config_path(home_dir) assert not config_path.exists(), "precondition: fresh install has no config.yaml" - # Folders the operator will point the wizard at -- all under tmp. - templates_dir = tmp_path / "templates" - plugin_dir = tmp_path / "plugins" - local_root = tmp_path / "data" - for folder in (templates_dir, plugin_dir, local_root): - folder.mkdir() + # The operator points the wizard at a single app root; the app derives and + # auto-creates templates/, plugins/ and data/ under it on save. + app_root = tmp_path / "exlab" context = browser.new_context() page = context.new_page() @@ -137,9 +134,7 @@ def test_fresh_install_setup_writes_config_and_applies_live( # 3. Fill the Paths section (the dialog opens here -- it is the # first incomplete section on a fresh install). - page.get_by_test_id("settings-paths-templates").fill(str(templates_dir)) - page.get_by_test_id("settings-paths-plugin").fill(str(plugin_dir)) - page.get_by_test_id("settings-paths-local-root").fill(str(local_root)) + page.get_by_test_id("settings-paths-app-root").fill(str(app_root)) # 4. Fill the LIMS section. page.get_by_test_id("settings-nav-lims").click() @@ -160,7 +155,7 @@ def test_fresh_install_setup_writes_config_and_applies_live( # the operator entered. assert config_path.exists(), "Save must persist config.yaml under the tmp HOME" text = config_path.read_text(encoding="utf-8") - assert str(local_root) in text + assert str(app_root) in text assert "https://lims.example.test" in text assert "operator@example.test" in text diff --git a/tests/e2e/test_flow_00_full_lifecycle.py b/tests/e2e/test_flow_00_full_lifecycle.py index 9db6237..eb549ed 100644 --- a/tests/e2e/test_flow_00_full_lifecycle.py +++ b/tests/e2e/test_flow_00_full_lifecycle.py @@ -170,10 +170,12 @@ def test_full_create_lifecycle(browser, prod_server: ProdServer, tmp_path: Path) config_path = server.config_path assert not config_path.exists(), "precondition: fresh install has no config.yaml" - # Operator-facing folders -- all under the test's tmp tree. - templates_dir = tmp_path / "templates" - plugin_dir = tmp_path / "plugins" - data_root = tmp_path / "data" + # The operator points the wizard at a single app root; templates/, plugins/ + # and data/ are derived from it (so these vars equal the derived subdirs). + app_root = tmp_path + templates_dir = app_root / "templates" + plugin_dir = app_root / "plugins" + data_root = app_root / "data" for folder in (templates_dir, plugin_dir, data_root): folder.mkdir() @@ -214,9 +216,7 @@ def test_full_create_lifecycle(browser, prod_server: ProdServer, tmp_path: Path) # ---- Phase 3: fill paths + LIMS -------------------------------- page.get_by_test_id("settings-nav-paths").click() - _fill(page, "settings-paths-templates", str(templates_dir)) - _fill(page, "settings-paths-plugin", str(plugin_dir)) - _fill(page, "settings-paths-local-root", str(data_root)) + _fill(page, "settings-paths-app-root", str(app_root)) page.get_by_test_id("settings-nav-lims").click() _fill(page, "settings-lims-endpoint", lims_endpoint) @@ -226,12 +226,12 @@ def test_full_create_lifecycle(browser, prod_server: ProdServer, tmp_path: Path) # ---- Phase 4: add equipment ------------------------------------ # rclone.conf migration (Phase 8): the Settings equipment form no # longer collects a per-equipment SFTP/SMB transport. A nas-mode - # device is created with just id/label/local_root/nas_root; the NAS - # connection is the single nas: remote (configured in rclone.conf). + # device is created with just id/label/nas_root (its data dir derives + # from the single app root); the NAS connection is the single nas: + # remote (configured in rclone.conf). page.get_by_test_id("settings-nav-equipment").click() _fill(page, "settings-equipment-id", "MICROSCOPE1") _fill(page, "settings-equipment-label", "Confocal Microscope 1") - _fill(page, "settings-equipment-local-root", str(data_root)) _fill(page, "settings-equipment-nas-root", "/srv/nas/microscope1") page.get_by_test_id("settings-equipment-add").click() page.get_by_test_id("settings-equipment-row").first.wait_for(state="visible", timeout=8_000) @@ -239,7 +239,6 @@ def test_full_create_lifecycle(browser, prod_server: ProdServer, tmp_path: Path) # 4b. a second nas-mode device -- same no-transport form. _fill(page, "settings-equipment-id", "SPECTROMETER1") _fill(page, "settings-equipment-label", "Mass Spectrometer 1") - _fill(page, "settings-equipment-local-root", str(data_root)) _fill(page, "settings-equipment-nas-root", "/srv/nas/spectrometer1") page.get_by_test_id("settings-equipment-add").click() # Two equipment rows now present. diff --git a/tests/e2e/test_flow_01_onboarding.py b/tests/e2e/test_flow_01_onboarding.py index 04988f8..7e95081 100644 --- a/tests/e2e/test_flow_01_onboarding.py +++ b/tests/e2e/test_flow_01_onboarding.py @@ -55,10 +55,8 @@ def test_flow_01_onboarding(page, server_url) -> None: state="visible", timeout=10_000 ) - # 4. Fill the paths section and add equipment. - page.locator('[data-testid="settings-paths-templates"]').fill("/tmp/templates") - page.locator('[data-testid="settings-paths-plugin"]').fill("/tmp/plugins") - page.locator('[data-testid="settings-paths-local-root"]').fill("/tmp/data") + # 4. Fill the paths section (single app-root input) and add equipment. + page.locator('[data-testid="settings-paths-app-root"]').fill("/tmp/exlab") _goto(page, f"{server_url}/settings?incomplete=paths,equipment&active=equipment") page.locator('[data-testid="settings-equipment-id"]').fill("TEST_EQ1") diff --git a/tests/e2e/test_flow_08_settings.py b/tests/e2e/test_flow_08_settings.py index 2d980d2..d62a6cc 100644 --- a/tests/e2e/test_flow_08_settings.py +++ b/tests/e2e/test_flow_08_settings.py @@ -47,9 +47,7 @@ def test_flow_08_settings(page, server_url) -> None: # Fill paths and save. page.goto(f"{server_url}/settings?active=paths") page.wait_for_load_state("networkidle") - settings.paths_templates.fill("/tmp/templates") - settings.paths_plugin.fill("/tmp/plugins") - settings.paths_local_root.fill("/tmp/data") + settings.paths_app_root.fill("/tmp/exlab") settings.save.click() page.wait_for_load_state("networkidle") settings.saved_marker.wait_for(state="visible", timeout=5_000) diff --git a/tests/e2e/test_flow_16_add_equipment.py b/tests/e2e/test_flow_16_add_equipment.py index 0c19502..53bcc89 100644 --- a/tests/e2e/test_flow_16_add_equipment.py +++ b/tests/e2e/test_flow_16_add_equipment.py @@ -46,11 +46,10 @@ def test_flow_16_add_equipment_identity_step(page, server_url) -> None: def test_flow_16_add_equipment_paths_step(page, server_url) -> None: - """Paths step renders local + NAS root inputs.""" + """Paths step renders the NAS root input (the data dir is derived).""" wiz = WizardEquipmentPage(page) _goto(page, f"{server_url}/wizard/equipment?step=paths") - wiz.local_root.wait_for(state="visible", timeout=10_000) - wiz.nas_root.wait_for(state="visible") + wiz.nas_root.wait_for(state="visible", timeout=10_000) @pytest.mark.skip( diff --git a/tests/e2e/test_flow_26_equipment_wizard_persist.py b/tests/e2e/test_flow_26_equipment_wizard_persist.py index cafca14..8fc7dc8 100644 --- a/tests/e2e/test_flow_26_equipment_wizard_persist.py +++ b/tests/e2e/test_flow_26_equipment_wizard_persist.py @@ -70,8 +70,9 @@ def test_equipment_wizard_confirm_persists_to_config(browser, prod_server) -> No # 2. Paths. The sync-mode step is hidden (orchestrator/staging hidden — # see docs/superpowers/specs/2026-05-29-hide-orchestrator-staging-design.md), # so paths advances straight to review; every equipment is nas-mode. - wiz.local_root.wait_for(state="visible", timeout=10_000) - wiz.local_root.fill("/data/MICROSCOPE_01") + # The data dir is derived from the single app root, so only the NAS + # root is collected here. + wiz.nas_root.wait_for(state="visible", timeout=10_000) wiz.nas_root.fill("/srv/nas/MICROSCOPE_01") wiz.next_button.click() diff --git a/tests/e2e/test_flow_27_nas_credential_settings.py b/tests/e2e/test_flow_27_nas_credential_settings.py index 9c49981..85cb4ea 100644 --- a/tests/e2e/test_flow_27_nas_credential_settings.py +++ b/tests/e2e/test_flow_27_nas_credential_settings.py @@ -66,17 +66,14 @@ def _seed_config(home: Path, *, env: dict[str, str], local_root: Path, catalogue ) cfg = Config( - paths=PathsConfig( - templates_dir=str(local_root), - plugin_dir=str(local_root), - local_root=str(local_root), - ), + # ``local_root`` is ``tmp_path / "data"``; app_root is its parent so the + # derived data root resolves back to it. + paths=PathsConfig(app_root=str(local_root.parent)), lims=LIMSConfig(offline_catalogue_path=str(catalogue)), equipment=[ EquipmentConfig( id="EQ1", label="Equipment 1", - local_root=str(local_root), nas_root="/srv/nas", ) ], diff --git a/tests/e2e/ux_catalog.py b/tests/e2e/ux_catalog.py index 1926b67..c4b033c 100644 --- a/tests/e2e/ux_catalog.py +++ b/tests/e2e/ux_catalog.py @@ -75,26 +75,13 @@ class UXInteraction: UXInteraction( flow="Settings", route="/settings", - testid="settings-paths-templates", + testid="settings-paths-app-root", element="input", - action="Type the templates directory", - outcome="Binds config.paths.templates_dir on the draft.", - ), - UXInteraction( - flow="Settings", - route="/settings", - testid="settings-paths-plugin", - element="input", - action="Type the plugin directory", - outcome="Binds config.paths.plugin_dir on the draft.", - ), - UXInteraction( - flow="Settings", - route="/settings", - testid="settings-paths-local-root", - element="input", - action="Type the local data root", - outcome="Binds config.paths.local_root on the draft.", + action="Type the data folder (app root)", + outcome=( + "Binds config.paths.app_root on the draft; templates/, plugins/ and " + "data/ are derived and shown as read-only labels." + ), ), # -- Settings: LIMS ----------------------------------------------------- UXInteraction( @@ -146,14 +133,6 @@ class UXInteraction: action="Type the equipment label", outcome="Provides the EquipmentConfig.label for the new entry.", ), - UXInteraction( - flow="Equipment", - route="/settings", - testid="settings-equipment-local-root", - element="input", - action="Type the equipment local root", - outcome="Provides the EquipmentConfig.local_root for the new entry.", - ), UXInteraction( flow="Equipment", route="/settings", @@ -164,8 +143,9 @@ class UXInteraction: ), # rclone.conf migration (Phase 8): the per-equipment SFTP/SMB transport # radio + connection fields were removed from the Settings equipment form. - # A nas-mode device now carries only id/label/local_root/nas_root; the NAS - # connection is the single nas: remote configured in Settings -> NAS Remote. + # A nas-mode device now carries only id/label/nas_root (its data dir is + # derived from the single app root); the NAS connection is the single nas: + # remote configured in Settings -> NAS Remote. # -- Settings: NAS Remote (rclone.conf migration) ---------------------- UXInteraction( flow="NAS Remote", @@ -499,14 +479,6 @@ class UXInteraction: action="Type the equipment label", outcome="Sets the human-readable equipment label.", ), - UXInteraction( - flow="Add equipment", - route="/wizard/equipment", - testid="wizard-equipment-local-root", - element="input", - action="Type the equipment's local root path", - outcome="Sets where this device acquires runs on disk.", - ), UXInteraction( flow="Add equipment", route="/wizard/equipment", diff --git a/tests/fixtures/configs/README.md b/tests/fixtures/configs/README.md index e4d3ae8..94bff3e 100644 --- a/tests/fixtures/configs/README.md +++ b/tests/fixtures/configs/README.md @@ -1 +1,3 @@ -Fixtures used by `tests/unit/config/test_loader.py` to drive the loader through the §9 schema. The four `incomplete_*.yaml` fixtures here represent partially-configured workstations that the setup-state evaluator (Backend Spec §4.9) classifies as `INCOMPLETE_*`. Note that `incomplete_no_config` is not represented as a YAML file -- it stands for the absence-of-file state where `config.yaml` has not been created at all, so tests for that state simply omit the file rather than load a fixture. +Fixtures used by `tests/unit/config/test_loader.py` to drive the loader through the §9 schema. The `incomplete_*.yaml` fixtures here represent partially-configured workstations that the setup-state evaluator (Backend Spec §4.9) classifies as `INCOMPLETE_*`. Note that `incomplete_no_config` is not represented as a YAML file -- it stands for the absence-of-file state where `config.yaml` has not been created at all, so tests for that state simply omit the file rather than load a fixture. + +There is no `incomplete_no_paths.yaml` fixture. Under the single-app-root model (`paths.app_root` with derived `data/`, `templates/`, `plugins/`) paths are never "missing" at the config level -- `app_root` always has a value (a default under the OS Documents folder). The paths gate is now `INCOMPLETE_PATHS_UNWRITABLE`, a runtime filesystem-writability check on `app_root` rather than an empty-value check, so it cannot be represented by a static YAML fixture. diff --git a/tests/fixtures/configs/complete.yaml b/tests/fixtures/configs/complete.yaml index 6b99eb6..14fb434 100644 --- a/tests/fixtures/configs/complete.yaml +++ b/tests/fixtures/configs/complete.yaml @@ -1,7 +1,5 @@ paths: - templates_dir: "/opt/exlab-wizard/templates" # directory containing Copier template subdirectories (app files) - plugin_dir: "/opt/exlab-wizard/plugins" - local_root: "/data/lab" # equipment-first root; the app writes into ///Run_/ + app_root: "/opt/exlab-wizard" # single managed root; templates/, plugins/ and data/ are derived under it lims: endpoint: "https://lims.lab.example/api/v1" # Optional if offline_catalogue_path is set (offline-only workstation). @@ -20,12 +18,10 @@ readme: equipment: - id: "CONFOCAL_01" label: "Confocal Microscope 1" - local_root: "/data/lab" # shared equipment-first root on this workstation nas_root: "//nas01/lab" # shared equipment-first root on NAS (display value) sync_mode: "nas" # this device syncs runs straight to the NAS (Redesign §3.2) - id: "FLOW_01" label: "Flow Cytometer 1" - local_root: "/data/lab" nas_root: "/mnt/nas/lab" sync_mode: "nas" # this device syncs runs straight to the NAS (Redesign §3.2) diff --git a/tests/fixtures/configs/incomplete_no_equipment.yaml b/tests/fixtures/configs/incomplete_no_equipment.yaml index 954102a..ff027ef 100644 --- a/tests/fixtures/configs/incomplete_no_equipment.yaml +++ b/tests/fixtures/configs/incomplete_no_equipment.yaml @@ -1,7 +1,5 @@ paths: - templates_dir: "/opt/exlab-wizard/templates" - plugin_dir: "/opt/exlab-wizard/plugins" - local_root: "/data/lab" + app_root: "/data/lab" # single managed root; templates/, plugins/ and data/ are derived under it lims: endpoint: "https://lims.lab.example/api/v1" diff --git a/tests/fixtures/configs/incomplete_no_lims.yaml b/tests/fixtures/configs/incomplete_no_lims.yaml index 3091a1e..3edba41 100644 --- a/tests/fixtures/configs/incomplete_no_lims.yaml +++ b/tests/fixtures/configs/incomplete_no_lims.yaml @@ -1,7 +1,5 @@ paths: - templates_dir: "/opt/exlab-wizard/templates" - plugin_dir: "/opt/exlab-wizard/plugins" - local_root: "/data/lab" + app_root: "/data/lab" # single managed root; templates/, plugins/ and data/ are derived under it lims: endpoint: "" # empty @@ -15,7 +13,6 @@ readme: equipment: - id: "CONFOCAL_01" label: "Confocal Microscope 1" - local_root: "/data/lab" nas_root: "//nas01/lab" sync_mode: "nas" diff --git a/tests/fixtures/configs/incomplete_no_paths.yaml b/tests/fixtures/configs/incomplete_no_paths.yaml deleted file mode 100644 index 1de5c50..0000000 --- a/tests/fixtures/configs/incomplete_no_paths.yaml +++ /dev/null @@ -1,66 +0,0 @@ -paths: - templates_dir: "/opt/exlab-wizard/templates" - plugin_dir: "/opt/exlab-wizard/plugins" - local_root: "" # empty -- triggers INCOMPLETE_MISSING_PATHS - -lims: - endpoint: "https://lims.lab.example/api/v1" - email: "alex.nguyen@lab.example" - cache_ttl_hours: 24 - offline_catalogue_path: "" - -readme: - defaults: [] - -equipment: - - id: "CONFOCAL_01" - label: "Confocal Microscope 1" - local_root: "/data/lab" - nas_root: "//nas01/lab" - sync_mode: "nas" - -nas_cleanup: - enabled: true - min_verify_passes: 2 - min_age_hours: 24 - retain_cache: true - -logging: - level: "INFO" - central_log_max_mb: 10 - central_log_keep: 5 - -operators: - allowlist: [] - -validator: - content_scan_max_mib: 5 - content_scan_extensions: - - ".txt" - - ".md" - - ".csv" - - ".tsv" - - ".json" - - ".yaml" - - ".yml" - - ".toml" - - ".ini" - - ".cfg" - - ".conf" - - ".xml" - - ".sh" - - ".py" - -plugins: - allow_network: false - -sync: - enabled: true - retry_attempts: 3 - -orchestrator: - label: "Lab Acquisition Station 01" - staging_root: "/staging" - staging_cleanup: - mode: "manual" - retain_hours: 24 diff --git a/tests/integration/api/test_full_flow.py b/tests/integration/api/test_full_flow.py index 3c8d7fa..30c4247 100644 --- a/tests/integration/api/test_full_flow.py +++ b/tests/integration/api/test_full_flow.py @@ -50,16 +50,15 @@ @pytest.fixture def ready_config(tmp_path: Path) -> Config: return Config( - paths=PathsConfig( - templates_dir=str(FIXTURE_TEMPLATES), - plugin_dir=str(tmp_path / "plugins"), - local_root=str(tmp_path / "data"), - ), + # app_root=tmp_path makes the derived data root tmp_path/"data" (where + # the fixture mkdirs and where runs land) and the derived plugin dir + # tmp_path/"plugins". Templates are supplied per-request via an explicit + # template_path, so the derived templates_dir is incidental here. + paths=PathsConfig(app_root=str(tmp_path)), equipment=[ EquipmentConfig( id="EQ1", label="Equipment 1", - local_root=str(tmp_path / "data"), nas_root="/srv/nas", ) ], @@ -173,19 +172,15 @@ async def test_health_warns_when_lims_unreachable(ready_config: Config) -> None: @pytest.mark.asyncio async def test_setup_status_each_incomplete_state() -> None: """Each non-soft INCOMPLETE_* state surfaces the right next_action.""" + # paths.app_root always defaults (and the test seam treats it as writable), + # so an otherwise-empty Config now trips the next gate -- the missing + # orchestrator label -- rather than a paths gate. cases = [ (None, "incomplete_no_config", "set_paths"), - (Config(), "incomplete_missing_paths", "set_paths"), - ( - Config( - paths=PathsConfig(templates_dir="/t", plugin_dir="/p", local_root="/d"), - ), - "incomplete_no_orchestrator", - "set_paths", - ), + (Config(), "incomplete_no_orchestrator", "set_paths"), ( Config( - paths=PathsConfig(templates_dir="/t", plugin_dir="/p", local_root="/d"), + paths=PathsConfig(app_root="/srv/exlab"), orchestrator=OrchestratorConfig(label="LAB", staging_root="/s"), ), "incomplete_no_equipment", @@ -225,7 +220,9 @@ async def test_create_session_blocked_when_setup_incomplete() -> None: assert response.status_code == 503 envelope = response.json() assert envelope["error"]["code"] == "setup_incomplete" - assert envelope["error"]["state"] == "incomplete_missing_paths" + # Config() now passes the (defaulted, writable) paths gate and trips on + # the missing orchestrator label instead. + assert envelope["error"]["state"] == "incomplete_no_orchestrator" @pytest.mark.asyncio @@ -325,11 +322,7 @@ async def test_put_config_validates_and_updates_state(tmp_path: Path) -> None: deps = AppDependencies(config=Config()) app = create_app(dependencies=deps) new_config = Config( - paths=PathsConfig( - templates_dir=str(tmp_path / "tpl"), - plugin_dir=str(tmp_path / "plugins"), - local_root=str(tmp_path / "data"), - ), + paths=PathsConfig(app_root=str(tmp_path)), orchestrator=OrchestratorConfig(label="LAB", staging_root=str(tmp_path / "staging")), ) async with await _client(app) as ac: diff --git a/tests/integration/controller/test_creation_flow.py b/tests/integration/controller/test_creation_flow.py index 1f8c524..3453509 100644 --- a/tests/integration/controller/test_creation_flow.py +++ b/tests/integration/controller/test_creation_flow.py @@ -64,18 +64,20 @@ def _build_config(local_root: Path, *, allowlist: list[str] | None = None) -> Config: - """Construct a minimal Config with one equipment configured.""" + """Construct a minimal Config with one equipment configured. + + ``local_root`` is ``tmp_path / "data"``; ``app_root`` is its parent so the + derived ``config.paths.local_root`` (== ``/data``) resolves back + to it and the on-disk run-path assertions are unchanged. Templates/plugins + are decorative here -- creation renders the request's explicit + ``template_path`` and the plugin host is injected, never read from config. + """ return Config( - paths=PathsConfig( - templates_dir=str(FIXTURE_TEMPLATES), - plugin_dir=str(FIXTURE_PLUGINS), - local_root=str(local_root), - ), + paths=PathsConfig(app_root=str(local_root.parent)), equipment=[ EquipmentConfig( id="EQ1", label="Equipment 1", - local_root=str(local_root), nas_root="/srv/nas", ) ], diff --git a/tests/integration/controller/test_creation_provenance.py b/tests/integration/controller/test_creation_provenance.py index e75161e..f7df028 100644 --- a/tests/integration/controller/test_creation_provenance.py +++ b/tests/integration/controller/test_creation_provenance.py @@ -58,17 +58,15 @@ def _build_config(local_root: Path) -> Config: + # ``local_root`` is ``tmp_path / "data"``; app_root is its parent so the + # derived ``config.paths.local_root`` resolves back to it. Templates/plugins + # are decorative (provenance copies the request's explicit template_path). return Config( - paths=PathsConfig( - templates_dir=str(FIXTURE_TEMPLATES), - plugin_dir=str(FIXTURE_PLUGINS), - local_root=str(local_root), - ), + paths=PathsConfig(app_root=str(local_root.parent)), equipment=[ EquipmentConfig( id="EQ1", label="Equipment 1", - local_root=str(local_root), nas_root="/srv/nas", ) ], diff --git a/tests/integration/test_nas_sync.py b/tests/integration/test_nas_sync.py index 1a2a3d8..d10d668 100644 --- a/tests/integration/test_nas_sync.py +++ b/tests/integration/test_nas_sync.py @@ -82,13 +82,16 @@ def stub_binaries_on_path(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Pa def _build_config(local_root: Path) -> Config: + # ``local_root`` is ``tmp_path / "data"`` (where runs are seeded); app_root + # is its parent so the derived ``config.paths.local_root`` resolves back to + # it -- the poller-driven tests discover nas-mode runs under exactly this + # tree, and the nas_client-direct tests pass each run dir explicitly. return Config( - paths=PathsConfig(templates_dir="/tpl", plugin_dir="/plg", local_root=str(local_root)), + paths=PathsConfig(app_root=str(local_root.parent)), equipment=[ EquipmentConfig( id="EQ1", label="Equipment 1", - local_root=str(local_root), nas_root="/nas", ) ], @@ -158,7 +161,7 @@ async def test_full_happy_path_via_stub_rclone( successful happy-path run; we accept both so the test is robust to the worker scheduling jitter that decides which one we observe. """ - local_root = tmp_path / "local" + local_root = tmp_path / "data" local_root.mkdir() nas_root = tmp_path / "nas" monkeypatch.setenv("STUB_RCLONE_BEHAVIOR", "success") @@ -226,7 +229,7 @@ async def test_pre_sync_gate_blocks_run_with_placeholder_in_path( monkeypatch: pytest.MonkeyPatch, ) -> None: """A run path with ```` is gated; sync_status -> blocked_by_validation.""" - local_root = tmp_path / "local" + local_root = tmp_path / "data" local_root.mkdir() monkeypatch.setenv("STUB_RCLONE_BEHAVIOR", "success") @@ -265,7 +268,7 @@ async def test_auth_error_terminates_failed( monkeypatch: pytest.MonkeyPatch, ) -> None: """The stub returns ``auth_error`` -> queue row terminates FAILED.""" - local_root = tmp_path / "local" + local_root = tmp_path / "data" local_root.mkdir() monkeypatch.setenv("STUB_RCLONE_BEHAVIOR", "auth_error") @@ -296,7 +299,7 @@ async def test_force_verify_returns_ok_after_compute( monkeypatch: pytest.MonkeyPatch, ) -> None: """``force_verify`` runs a manifest pass against the local subtree.""" - local_root = tmp_path / "local" + local_root = tmp_path / "data" local_root.mkdir() cfg = _build_config(local_root) run_dir = await _populate_run(local_root) @@ -348,7 +351,7 @@ async def test_routine_reconcile_retries_until_remote_listing_settles( """ from exlab_wizard.sync.manifest import RemoteEntry, RemoteManifest - local_root = tmp_path / "local" + local_root = tmp_path / "data" local_root.mkdir() nas_root = tmp_path / "nas" monkeypatch.setenv("STUB_RCLONE_BEHAVIOR", "success") @@ -429,7 +432,7 @@ async def test_cleanup_hash_gate_defers_on_remote_mismatch( """ from exlab_wizard.sync.transports.rclone import CheckResult - local_root = tmp_path / "local" + local_root = tmp_path / "data" local_root.mkdir() nas_root = tmp_path / "nas" monkeypatch.setenv("STUB_RCLONE_BEHAVIOR", "success") @@ -491,7 +494,7 @@ async def test_poller_per_file_enqueue_drives_to_synced_state( from exlab_wizard.cache.sync_state_writer import SyncStateWriter from exlab_wizard.orchestrator.quiescence_poller import QuiescenceSyncPoller - local_root = tmp_path / "local" + local_root = tmp_path / "data" local_root.mkdir() nas_root = tmp_path / "nas" monkeypatch.setenv("STUB_RCLONE_BEHAVIOR", "success") @@ -575,7 +578,7 @@ async def test_poller_to_cleanup_honors_keep_local_and_stamps_cleared( from exlab_wizard.constants import RunSyncState from exlab_wizard.orchestrator.quiescence_poller import QuiescenceSyncPoller - local_root = tmp_path / "local" + local_root = tmp_path / "data" local_root.mkdir() nas_root = tmp_path / "nas" monkeypatch.setenv("STUB_RCLONE_BEHAVIOR", "success") diff --git a/tests/integration/test_orchestrator_lifecycle.py b/tests/integration/test_orchestrator_lifecycle.py index 9a91a8c..a37f7e6 100644 --- a/tests/integration/test_orchestrator_lifecycle.py +++ b/tests/integration/test_orchestrator_lifecycle.py @@ -73,13 +73,14 @@ def enqueued_paths(self) -> list[Path]: def _make_config(staging_root: Path, *, quiescence_minutes: int = 1) -> Config: + # This lifecycle exercises staging discovery via ``orchestrator.staging_root``; + # app_root only feeds the (here unused) derived data root. return Config( - paths=PathsConfig(local_root=str(staging_root)), + paths=PathsConfig(app_root=str(staging_root)), equipment=[ EquipmentConfig( id="EQ1", label="Equipment 1", - local_root=str(staging_root), nas_root="/nas", ), ], diff --git a/tests/unit/api/test_browse.py b/tests/unit/api/test_browse.py index 84ab8e8..de9cb08 100644 --- a/tests/unit/api/test_browse.py +++ b/tests/unit/api/test_browse.py @@ -35,17 +35,15 @@ def _config_with_local_root(local_root: Path) -> Config: + # Single-app-root refactor: ``paths.local_root`` is the derived + # ``/data``. Callers pass a ``…/data`` dir, so the app root is + # its parent and the derived ``local_root`` resolves back to ``local_root``. return Config( - paths=PathsConfig( - templates_dir=str(local_root / "templates"), - plugin_dir=str(local_root / "plugins"), - local_root=str(local_root), - ), + paths=PathsConfig(app_root=str(local_root.parent)), equipment=[ EquipmentConfig( id="EQ1", label="Equipment 1", - local_root=str(local_root), nas_root="/srv/nas", ) ], @@ -113,7 +111,7 @@ def test_get_tree_returns_empty_when_no_equipment(tmp_path: Path) -> None: local_root = tmp_path / "data" local_root.mkdir() config = Config( - paths=PathsConfig(templates_dir="/t", plugin_dir="/p", local_root=str(local_root)), + paths=PathsConfig(app_root=str(local_root.parent)), orchestrator=OrchestratorConfig(label="LAB", staging_root="/staging"), ) deps = AppDependencies(config=config) diff --git a/tests/unit/api/test_config_router.py b/tests/unit/api/test_config_router.py index 09cacab..cda3468 100644 --- a/tests/unit/api/test_config_router.py +++ b/tests/unit/api/test_config_router.py @@ -22,12 +22,11 @@ def _empty_config() -> Config: def _ready_config() -> Config: return Config( - paths=PathsConfig(templates_dir="/t", plugin_dir="/p", local_root="/d"), + paths=PathsConfig(app_root="/srv/exlab"), equipment=[ EquipmentConfig( id="EQ1", label="Equipment 1", - local_root="/d", nas_root="/n", ) ], @@ -43,7 +42,7 @@ def test_get_config_returns_loaded_config() -> None: client = TestClient(app) response = client.get("/api/v1/config") assert response.status_code == 200 - assert response.json()["paths"]["local_root"] == "/d" + assert response.json()["paths"]["app_root"] == "/srv/exlab" assert len(response.json()["equipment"]) == 1 @@ -54,7 +53,12 @@ def test_get_config_returns_default_when_none() -> None: response = client.get("/api/v1/config") assert response.status_code == 200 body = response.json() - assert body["paths"]["local_root"] == "" + # The single app root defaults under the OS Documents folder, so a + # default config no longer has an empty paths block; only ``app_root`` + # is serialized (templates/plugins/data are derived properties). + from exlab_wizard.paths import default_app_root + + assert body["paths"] == {"app_root": str(default_app_root())} def test_put_config_persists_and_reevaluates_state() -> None: @@ -113,7 +117,6 @@ async def saver(config: Config) -> None: { "id": "FLOW_99", "label": "Flow Cytometer 99", - "local_root": "/data", "nas_root": "/srv/nas", } ) @@ -133,7 +136,6 @@ def test_append_equipment_rejects_duplicate_id() -> None: { "id": "EQ1", "label": "Equipment 1 duplicate", - "local_root": "/data", "nas_root": "/srv/nas", } ) diff --git a/tests/unit/api/test_health.py b/tests/unit/api/test_health.py index 931dc3b..88c11dd 100644 --- a/tests/unit/api/test_health.py +++ b/tests/unit/api/test_health.py @@ -20,12 +20,11 @@ def _ready_config() -> Config: return Config( - paths=PathsConfig(templates_dir="/t", plugin_dir="/p", local_root="/d"), + paths=PathsConfig(app_root="/srv/exlab"), equipment=[ EquipmentConfig( id="EQ1", label="Equipment 1", - local_root="/d", nas_root="/n", ) ], diff --git a/tests/unit/api/test_operations.py b/tests/unit/api/test_operations.py index c69bd2a..37b091e 100644 --- a/tests/unit/api/test_operations.py +++ b/tests/unit/api/test_operations.py @@ -22,12 +22,11 @@ def _ready_config() -> Config: return Config( - paths=PathsConfig(templates_dir="/t", plugin_dir="/p", local_root="/d"), + paths=PathsConfig(app_root="/srv/exlab"), equipment=[ EquipmentConfig( id="EQ1", label="Equipment 1", - local_root="/d", nas_root="/n", ) ], diff --git a/tests/unit/api/test_problems.py b/tests/unit/api/test_problems.py index 1a1edf3..27f9f9a 100644 --- a/tests/unit/api/test_problems.py +++ b/tests/unit/api/test_problems.py @@ -50,16 +50,16 @@ def audit(self, scope: dict[str, Any]) -> list[Finding]: return list(self._findings) -def _ready_config(local_root: Path) -> Config: +def _ready_config(app_root: Path) -> Config: + # The derived ``paths.local_root`` resolves to ``/data``; the + # override tests seed run dirs under ``tmp_path / "data" / ...`` so passing + # ``tmp_path`` as the app root keeps those on-disk paths unchanged. return Config( - paths=PathsConfig( - templates_dir=str(local_root), plugin_dir=str(local_root), local_root=str(local_root) - ), + paths=PathsConfig(app_root=str(app_root)), equipment=[ EquipmentConfig( id="EQ1", label="Equipment 1", - local_root=str(local_root), nas_root="/n", ) ], diff --git a/tests/unit/api/test_sessions.py b/tests/unit/api/test_sessions.py index 0e0191d..0b8bc46 100644 --- a/tests/unit/api/test_sessions.py +++ b/tests/unit/api/test_sessions.py @@ -29,12 +29,11 @@ def _ready_config() -> Config: from exlab_wizard.config.models import NasConfig, OrchestratorConfig return Config( - paths=PathsConfig(templates_dir="/t", plugin_dir="/p", local_root="/d"), + paths=PathsConfig(app_root="/srv/exlab"), equipment=[ EquipmentConfig( id="EQ1", label="Equipment 1", - local_root="/d", nas_root="/n", ) ], diff --git a/tests/unit/api/test_setup.py b/tests/unit/api/test_setup.py index 87d3bfc..7a787d6 100644 --- a/tests/unit/api/test_setup.py +++ b/tests/unit/api/test_setup.py @@ -29,16 +29,11 @@ def _ready_config() -> Config: from exlab_wizard.config.models import OrchestratorConfig return Config( - paths=PathsConfig( - templates_dir="/tpl", - plugin_dir="/plugin", - local_root="/data", - ), + paths=PathsConfig(app_root="/srv/exlab"), equipment=[ EquipmentConfig( id="EQ1", label="Equipment 1", - local_root="/data", nas_root="/srv/nas", ) ], @@ -53,12 +48,11 @@ def _ready_config_without_lims() -> Config: from exlab_wizard.config.models import OrchestratorConfig return Config( - paths=PathsConfig(templates_dir="/tpl", plugin_dir="/plugin", local_root="/data"), + paths=PathsConfig(app_root="/srv/exlab"), equipment=[ EquipmentConfig( id="EQ1", label="Equipment 1", - local_root="/data", nas_root="/srv/nas", ) ], @@ -82,7 +76,7 @@ def test_is_creation_blocked_treats_lims_unreachable_as_soft() -> None: assert is_creation_blocked(SetupState.INCOMPLETE_LIMS_UNREACHABLE) is False assert is_creation_blocked(SetupState.READY) is False assert is_creation_blocked(SetupState.INCOMPLETE_NO_CONFIG) is True - assert is_creation_blocked(SetupState.INCOMPLETE_MISSING_PATHS) is True + assert is_creation_blocked(SetupState.INCOMPLETE_PATHS_UNWRITABLE) is True assert is_creation_blocked(SetupState.INCOMPLETE_NO_EQUIPMENT) is True assert is_creation_blocked(SetupState.INCOMPLETE_NO_NAS_REMOTE) is True assert is_creation_blocked(SetupState.INCOMPLETE_NO_LIMS) is True @@ -115,7 +109,9 @@ def test_get_setup_status_reports_configure_rclone_remote() -> None: assert "nas.remote" in field_names -def test_setup_state_gate_returns_503_in_incomplete_states() -> None: +def test_setup_state_gate_returns_503_in_incomplete_states( + monkeypatch: Any, +) -> None: """Each non-soft INCOMPLETE_* state returns a 503 with the right code.""" from collections.abc import Callable @@ -128,34 +124,45 @@ def test_setup_state_gate_returns_503_in_incomplete_states() -> None: always: Callable[[str], bool] = lambda _n: True # noqa: E731 never: Callable[[str], bool] = lambda _n: False # noqa: E731 - # (config, nas_remote_available, expected_state) + # (config, nas_remote_available, paths_writable, expected_state) + # ``paths_writable`` opts the paths-gate case out of conftest's + # autouse "app root is writable" stub so that gate can be the first + # failure; every other case keeps the stub (the placeholder + # ``/srv/exlab`` root need not be writable on the test host). test_cases = [ - (None, always, "incomplete_no_config"), - (Config(), always, "incomplete_missing_paths"), + (None, always, True, "incomplete_no_config"), + (Config(), always, False, "incomplete_paths_unwritable"), ( - Config(paths=PathsConfig(templates_dir="/t", plugin_dir="/p", local_root="/d")), + Config(paths=PathsConfig(app_root="/srv/exlab")), always, + True, "incomplete_no_orchestrator", ), ( Config( - paths=PathsConfig(templates_dir="/t", plugin_dir="/p", local_root="/d"), + paths=PathsConfig(app_root="/srv/exlab"), orchestrator=OrchestratorConfig(label="LAB", staging_root="/s"), ), always, + True, "incomplete_no_equipment", ), # NAS gate: nas-mode equipment present but the remote is unavailable. - (nas_ready, never, "incomplete_no_nas_remote"), + (nas_ready, never, True, "incomplete_no_nas_remote"), # LIMS gate: satisfy the NAS gate so the LIMS gate is the first failure. # ``_ready_config`` carries a configured LIMS, so drop it for this case. ( _ready_config_without_lims(), always, + True, "incomplete_no_lims", ), ] - for config, remote_available, expected_state in test_cases: + for config, remote_available, paths_writable, expected_state in test_cases: + monkeypatch.setattr( + "exlab_wizard.api.setup.app_root_writable", + lambda _c, _w=paths_writable: _w, + ) deps = AppDependencies(config=config, nas_remote_available=remote_available) app = FastAPI() app.state.dependencies = deps @@ -222,17 +229,21 @@ def test_get_setup_status_ready() -> None: assert body["next_action"] is None -def test_get_setup_status_incomplete_paths() -> None: +def test_get_setup_status_incomplete_paths(monkeypatch: Any) -> None: + # Opt out of conftest's "app root is writable" stub so the paths gate + # is the first failure (the refactor's gate probes writability, not + # path emptiness). + monkeypatch.setattr("exlab_wizard.api.setup.app_root_writable", lambda _c: False) deps = AppDependencies(config=Config()) app = create_app(dependencies=deps) client = TestClient(app) response = client.get("/api/v1/setup/status") body = response.json() - assert body["state"] == "incomplete_missing_paths" + assert body["state"] == "incomplete_paths_unwritable" assert body["ready"] is False assert body["next_action"] == "set_paths" - field_names = {entry["field"] for entry in body["missing"]} - assert "paths.templates_dir" in field_names + missing = body["missing"] + assert {"field": "paths.app_root", "reason": "unwritable"} in missing def test_post_test_lims_invokes_probe() -> None: diff --git a/tests/unit/api/test_staging_router.py b/tests/unit/api/test_staging_router.py index 1389c01..549f382 100644 --- a/tests/unit/api/test_staging_router.py +++ b/tests/unit/api/test_staging_router.py @@ -49,13 +49,16 @@ async def enqueue(self, run_path: Path) -> _Handle: def _make_config(staging_root: Path, *, enabled: bool = True) -> Config: + # The staging endpoints scan / sandbox against ``orchestrator.staging_root`` + # (and the derived ``paths.local_root``); runs are seeded directly under + # ``staging_root`` so containment is satisfied via the staging-root + # candidate. ``app_root`` is not load-bearing for these tests. return Config( - paths=PathsConfig(local_root=str(staging_root)), + paths=PathsConfig(app_root=str(staging_root)), equipment=[ EquipmentConfig( id="EQ1", label="Equipment 1", - local_root=str(staging_root), nas_root="/nas", ), ], diff --git a/tests/unit/config/test_loader.py b/tests/unit/config/test_loader.py index ee91a4e..6a9e693 100644 --- a/tests/unit/config/test_loader.py +++ b/tests/unit/config/test_loader.py @@ -47,10 +47,12 @@ def test_load_config_complete_yaml() -> None: cfg = load_config(FIXTURES_DIR / "complete.yaml") assert isinstance(cfg, Config) - # Paths block. + # Paths block. Single-app-root refactor: only ``app_root`` is stored; + # templates/plugins/data are derived read-only properties under it. + assert cfg.paths.app_root == "/opt/exlab-wizard" assert cfg.paths.templates_dir == "/opt/exlab-wizard/templates" assert cfg.paths.plugin_dir == "/opt/exlab-wizard/plugins" - assert cfg.paths.local_root == "/data/lab" + assert cfg.paths.local_root == "/opt/exlab-wizard/data" # LIMS block. assert cfg.lims.endpoint == "https://lims.lab.example/api/v1" @@ -112,7 +114,7 @@ def test_load_config_validation_error_raises_config_error(tmp_path: Path) -> Non # the original error chained as __cause__. bad = tmp_path / "validation.yaml" bad.write_text( - "equipment:\n - id: lowercase\n label: x\n local_root: /tmp\n nas_root: /mnt\n", + "equipment:\n - id: lowercase\n label: x\n nas_root: /mnt\n", encoding="utf-8", ) with pytest.raises(ConfigError) as info: @@ -123,10 +125,17 @@ def test_load_config_validation_error_raises_config_error(tmp_path: Path) -> Non def test_load_config_from_text_empty_returns_empty_config() -> None: # Empty YAML text loads to None which we coerce to {}; every Config field - # has a default factory, so the result is an all-defaults Config. + # has a default factory, so the result is an all-defaults Config. After the + # single-app-root refactor ``paths.app_root`` defaults to the OS Documents + # location, and ``local_root`` is the derived ``/data``. + from pathlib import Path + + from exlab_wizard.paths import default_app_root + cfg = load_config_from_text("") assert isinstance(cfg, Config) - assert cfg.paths.local_root == "" + assert cfg.paths.app_root == str(default_app_root()) + assert cfg.paths.local_root == str(Path(default_app_root()) / "data") assert cfg.equipment == [] @@ -175,7 +184,7 @@ def test_save_config_preserves_comments_round_trip(tmp_path: Path) -> None: saved = target.read_text(encoding="utf-8") # At least one operator-readable comment from complete.yaml must survive # the round-trip; this is the whole point of using ruamel.yaml. - assert "# directory containing Copier template subdirectories" in saved + assert "# single managed root" in saved def test_save_config_preserves_key_order(tmp_path: Path) -> None: @@ -228,9 +237,7 @@ def test_dump_config_round_trip() -> None: # equivalence under model_dump. seed = { "paths": { - "templates_dir": "/t", - "plugin_dir": "/p", - "local_root": "/l", + "app_root": "/srv/exlab", }, "lims": { "endpoint": "https://lims.example/api", @@ -240,7 +247,6 @@ def test_dump_config_round_trip() -> None: { "id": "CONFOCAL_01", "label": "Confocal", - "local_root": "/l", "nas_root": "/n", }, ], @@ -270,11 +276,9 @@ def test_dump_config_round_trip() -> None: "equipment:\n" " - id: EQ1\n" " label: First\n" - " local_root: /data/eq1\n" " nas_root: /mnt/eq1\n" " - id: EQ2\n" " label: Second\n" - " local_root: /data/eq2\n" " nas_root: /mnt/eq2\n" ) @@ -296,7 +300,7 @@ def test_test_mode_enabled_prefixes_every_equipment_id(monkeypatch: pytest.Monke ] # Non-id fields are untouched. assert cfg.equipment[0].label == "First" - assert cfg.equipment[1].local_root == "/data/eq2" + assert cfg.equipment[1].nas_root == "/mnt/eq2" def test_test_mode_is_idempotent_on_already_prefixed_ids( @@ -308,11 +312,9 @@ def test_test_mode_is_idempotent_on_already_prefixed_ids( "equipment:\n" " - id: TEST_EQ1\n" " label: Already prefixed\n" - " local_root: /data/eq1\n" " nas_root: /mnt/eq1\n" " - id: EQ2\n" " label: Plain\n" - " local_root: /data/eq2\n" " nas_root: /mnt/eq2\n" ) cfg = load_config_from_text(seeded) @@ -391,7 +393,7 @@ def test_loader_round_trips_nas_block(tmp_path): text = ( "paths:\n" - " templates_dir: /t\n plugin_dir: /p\n local_root: /l\n" + " app_root: /srv/exlab\n" "orchestrator:\n label: ws-1\n" "nas:\n" " remote: nas01\n" @@ -422,7 +424,7 @@ def test_load_config_uses_ruamel_round_trip(tmp_path: Path) -> None: save_config(target, cfg, original_text=original_text) saved = target.read_text(encoding="utf-8") - # The original keeps templates_dir as `"/opt/exlab-wizard/templates"` - # (double-quoted). PyYAML's default dumper would emit it unquoted; ruamel - # in round-trip mode keeps the quotes. - assert '"/opt/exlab-wizard/templates"' in saved + # The original keeps app_root as `"/opt/exlab-wizard"` (double-quoted). + # PyYAML's default dumper would emit it unquoted; ruamel in round-trip + # mode keeps the quotes. + assert '"/opt/exlab-wizard"' in saved diff --git a/tests/unit/config/test_models.py b/tests/unit/config/test_models.py index e8aa21a..924829d 100644 --- a/tests/unit/config/test_models.py +++ b/tests/unit/config/test_models.py @@ -52,7 +52,6 @@ def _equipment_dict( return { "id": equipment_id, "label": "Confocal Microscope 1", - "local_root": "/data/lab", "nas_root": "//nas01/lab", } @@ -61,9 +60,7 @@ def _full_config_dict() -> dict: """Full Config dict mirroring the §9 example. Used by round-trip tests.""" return { "paths": { - "templates_dir": "/opt/templates", - "plugin_dir": "/opt/plugins", - "local_root": "/data/lab", + "app_root": "/srv/exlab", }, "lims": { "endpoint": "https://lims.lab.example/api/v1", @@ -86,14 +83,12 @@ def _full_config_dict() -> dict: { "id": "CONFOCAL_01", "label": "Confocal Microscope 1", - "local_root": "/data/lab", "nas_root": "//nas01/lab", "sync_mode": "nas", }, { "id": "FLOW_01", "label": "Flow Cytometer 1", - "local_root": "/data/lab", "nas_root": "/mnt/nas/lab", "sync_mode": "nas", }, @@ -179,11 +174,28 @@ def _full_config_dict() -> dict: # --------------------------------------------------------------------------- -def test_paths_config_defaults_are_empty_strings() -> None: +def test_paths_config_default_app_root_under_documents() -> None: + # The single stored field defaults under the OS Documents folder, so a + # fresh install needs no manual path entry. + from pathlib import Path + paths = PathsConfig() - assert paths.templates_dir == "" - assert paths.plugin_dir == "" - assert paths.local_root == "" + assert paths.app_root # non-empty default + assert Path(paths.app_root).name in ("ExLabWizard", "ExLabWizard-test") + + +def test_paths_config_derived_subdirs_resolve_under_app_root() -> None: + # ``data_root`` / ``local_root`` / ``templates_dir`` / ``plugin_dir`` are + # read-only properties derived from ``app_root`` (not stored, not dumped). + from pathlib import Path + + paths = PathsConfig(app_root="/srv/exlab") + assert paths.data_root == str(Path("/srv/exlab") / "data") + assert paths.local_root == paths.data_root + assert paths.templates_dir == str(Path("/srv/exlab") / "templates") + assert paths.plugin_dir == str(Path("/srv/exlab") / "plugins") + # Only ``app_root`` is serialized. + assert paths.model_dump() == {"app_root": "/srv/exlab"} def test_paths_config_rejects_unknown_keys() -> None: @@ -191,6 +203,14 @@ def test_paths_config_rejects_unknown_keys() -> None: PathsConfig(unknown_key="x") # type: ignore[call-arg] +def test_paths_config_rejects_removed_derived_keys() -> None: + # ``templates_dir`` / ``plugin_dir`` / ``local_root`` are derived + # properties now, not stored fields; passing them is an unknown key. + for stale_key in ("templates_dir", "plugin_dir", "local_root"): + with pytest.raises(ValidationError): + PathsConfig(**{stale_key: "/x"}) # type: ignore[arg-type] + + # --------------------------------------------------------------------------- # LIMSConfig # --------------------------------------------------------------------------- @@ -409,11 +429,14 @@ def test_equipment_label_must_be_non_empty() -> None: EquipmentConfig.model_validate(bad) -def test_equipment_local_root_must_be_non_empty() -> None: +def test_equipment_rejects_removed_local_root_key() -> None: + # ``local_root`` was removed from EquipmentConfig (data root is now a + # single app-level setting); ``extra='forbid'`` rejects the stale key. bad = _equipment_dict() - bad["local_root"] = "" - with pytest.raises(ValidationError): + bad["local_root"] = "/data/lab" + with pytest.raises(ValidationError) as info: EquipmentConfig.model_validate(bad) + assert "local_root" in str(info.value) def test_equipment_nas_root_must_be_non_empty() -> None: @@ -456,7 +479,6 @@ def test_nas_mode_equipment_needs_no_transport_block() -> None: eq = EquipmentConfig( id="EQ_01", label="Eq", - local_root="/l", nas_root="//n/x", sync_mode="nas", ) @@ -787,7 +809,6 @@ def test_stage_mode_equipment_validates_without_staging_block() -> None: eq = EquipmentConfig( id="STAGE_01", label="Stage 1", - local_root="/data", nas_root="//nas/x", sync_mode="stage", ) diff --git a/tests/unit/constants/test_enums.py b/tests/unit/constants/test_enums.py index 5ee4ffc..e79b1b9 100644 --- a/tests/unit/constants/test_enums.py +++ b/tests/unit/constants/test_enums.py @@ -152,7 +152,7 @@ def test_setup_state_values() -> None: # Backend Spec §4.9.1. assert issubclass(enums.SetupState, StrEnum) assert enums.SetupState.INCOMPLETE_NO_CONFIG.value == "incomplete_no_config" - assert enums.SetupState.INCOMPLETE_MISSING_PATHS.value == "incomplete_missing_paths" + assert enums.SetupState.INCOMPLETE_PATHS_UNWRITABLE.value == "incomplete_paths_unwritable" assert enums.SetupState.INCOMPLETE_NO_EQUIPMENT.value == "incomplete_no_equipment" assert enums.SetupState.INCOMPLETE_NO_NAS_REMOTE.value == "incomplete_no_nas_remote" assert enums.SetupState.INCOMPLETE_NO_LIMS.value == "incomplete_no_lims" @@ -160,7 +160,7 @@ def test_setup_state_values() -> None: assert enums.SetupState.READY.value == "ready" assert {m.value for m in enums.SetupState} == { "incomplete_no_config", - "incomplete_missing_paths", + "incomplete_paths_unwritable", "incomplete_no_orchestrator", "incomplete_no_equipment", "incomplete_no_nas_remote", diff --git a/tests/unit/controller/test_metadata_assembly.py b/tests/unit/controller/test_metadata_assembly.py index d8afc61..0430463 100644 --- a/tests/unit/controller/test_metadata_assembly.py +++ b/tests/unit/controller/test_metadata_assembly.py @@ -70,17 +70,17 @@ def _config(local_root: Path, *, defaults: list[READMEDefaultField] | None = None) -> Config: + # Single-app-root refactor: only ``app_root`` is stored; the derived + # ``local_root`` resolves to ``/data``. Callers pass a ``…/data`` + # dir, so the app root is its parent. Template resolution in these tests is + # driven by ``req.template_path`` (FIXTURE_TEMPLATES) directly, not by the + # derived ``paths.templates_dir``. return Config( - paths=PathsConfig( - templates_dir=str(FIXTURE_TEMPLATES), - plugin_dir=str(FIXTURE_PLUGINS), - local_root=str(local_root), - ), + paths=PathsConfig(app_root=str(local_root.parent)), equipment=[ EquipmentConfig( id="EQ1", label="Equipment One", - local_root=str(local_root), nas_root="/srv/nas", ) ], diff --git a/tests/unit/dev/test_seed.py b/tests/unit/dev/test_seed.py index 3be3a74..dcb78ec 100644 --- a/tests/unit/dev/test_seed.py +++ b/tests/unit/dev/test_seed.py @@ -38,7 +38,7 @@ def test_seed_main_creates_full_tree(monkeypatch: pytest.MonkeyPatch, tmp_path: assert seed.main([]) == 0 - local_root = sandbox / "local" + local_root = sandbox / "app" / "data" testrig = local_root / f"{TEST_MODE_PREFIX}TESTRIG" altrig = local_root / f"{TEST_MODE_PREFIX}ALTRIG" assert (testrig / "Demo Project").is_dir() @@ -66,7 +66,7 @@ def test_seed_main_wipes_and_rebuilds(monkeypatch: pytest.MonkeyPatch, tmp_path: assert seed.main([]) == 0 # A stray file inside a seeded equipment dir must not survive the wipe. - local_root = sandbox / "local" + local_root = sandbox / "app" / "data" stray = local_root / f"{TEST_MODE_PREFIX}TESTRIG" / "STRAY.txt" stray.write_text("x", encoding="utf-8") assert stray.exists() diff --git a/tests/unit/orchestrator/test_quiescence_poller.py b/tests/unit/orchestrator/test_quiescence_poller.py index 2fdd7e7..aea020e 100644 --- a/tests/unit/orchestrator/test_quiescence_poller.py +++ b/tests/unit/orchestrator/test_quiescence_poller.py @@ -67,14 +67,19 @@ def _make_config( EquipmentConfig( id="EQNAS", label="Nas Equipment", - local_root=str(nas_equipment_root), nas_root="/nas", sync_mode=SyncMode.NAS, ), ) - local_root = staging_root or nas_equipment_root or Path("/tmp") + # Nas-mode runs are discovered under ``/`` where + # local_root is the derived ``/data``. Set app_root to the passed + # ``nas_equipment_root`` so the discovered data root is + # ``/data`` -- the nas-discovery tests seed runs there. + # Staging-only configs don't use the data root (the staging branch walks + # ``orchestrator.staging_root`` directly), so any valid app_root suffices. + app_root = nas_equipment_root or staging_root or Path("/tmp") return Config( - paths=PathsConfig(local_root=str(local_root)), + paths=PathsConfig(app_root=str(app_root)), equipment=equipment, orchestrator=OrchestratorConfig( label="ORCH", @@ -209,7 +214,8 @@ async def test_discovers_both_staging_and_nas_mode_runs(tmp_path: Path) -> None: nas_sync = _StubNasSync() poller = _poller(config, nas_sync) staging_run = _make_run(staging_root, "EQ1") - nas_run = _make_run(nas_root, "EQNAS") + # Nas-mode runs live under the derived data root ``/data``. + nas_run = _make_run(nas_root / "data", "EQNAS") await poller.poll_once(now_monotonic=0.0) enqueued = await poller.poll_once(now_monotonic=120.0) @@ -221,22 +227,23 @@ async def test_co_rooted_stage_mode_equipment_run_is_not_enqueued(tmp_path: Path """A ``stage``-mode equipment sharing one ``local_root`` with a ``nas``-mode equipment must NOT have its runs swept into NAS sync -- only the ``nas``-mode equipment's own subtree is walked.""" - shared_root = tmp_path / "lab-data" - shared_root.mkdir() + app_root = tmp_path / "lab-data" + # Both equipment share the single derived data root ``/data``; + # the nas- and stage-mode subtrees are co-rooted there. + shared_data_root = app_root / "data" + shared_data_root.mkdir(parents=True) config = Config( - paths=PathsConfig(local_root=str(shared_root)), + paths=PathsConfig(app_root=str(app_root)), equipment=[ EquipmentConfig( id="EQNAS", label="Nas Equipment", - local_root=str(shared_root), nas_root="/nas", sync_mode=SyncMode.NAS, ), EquipmentConfig( id="EQSTAGE", label="Stage Equipment", - local_root=str(shared_root), nas_root="/nas", sync_mode=SyncMode.STAGE, ), @@ -246,8 +253,8 @@ async def test_co_rooted_stage_mode_equipment_run_is_not_enqueued(tmp_path: Path ) nas_sync = _StubNasSync() poller = _poller(config, nas_sync) - nas_run = _make_run(shared_root, "EQNAS") - stage_run = _make_run(shared_root, "EQSTAGE") + nas_run = _make_run(shared_data_root, "EQNAS") + stage_run = _make_run(shared_data_root, "EQSTAGE") await poller.poll_once(now_monotonic=0.0) enqueued = await poller.poll_once(now_monotonic=120.0) diff --git a/tests/unit/sample_data/test_generator.py b/tests/unit/sample_data/test_generator.py index 00a401d..0c6b6ee 100644 --- a/tests/unit/sample_data/test_generator.py +++ b/tests/unit/sample_data/test_generator.py @@ -55,11 +55,7 @@ def sandbox(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: monkeypatch.setenv(TEST_MODE_ENV, "1") config_path = tmp_path / "config.yaml" starter = Config( - paths=PathsConfig( - templates_dir=str(tmp_path / "templates"), - plugin_dir=str(tmp_path / "plugins"), - local_root=str(tmp_path / "local"), - ), + paths=PathsConfig(app_root=str(tmp_path / "app")), orchestrator=OrchestratorConfig(label="test-workstation"), ) save_config(config_path, starter) @@ -399,16 +395,13 @@ def test_wipe_refuses_when_app_name_not_test( def test_wipe_refuses_when_target_escapes_sandbox( sandbox: Path, monkeypatch: pytest.MonkeyPatch ) -> None: - # First seed without wipe to create config; then force the local_root to - # point outside the sandbox so the containment check fails. + # First seed without wipe to create config; then force the app_root (and + # thus the derived data root) to point outside the sandbox so the + # containment check fails. generate_samples(sandbox, wipe=False, base_time=BASE_TIME) config = load_config(sandbox) escaped = Config( - paths=PathsConfig( - templates_dir=config.paths.templates_dir, - plugin_dir=config.paths.plugin_dir, - local_root="/tmp", - ), + paths=PathsConfig(app_root="/tmp"), orchestrator=config.orchestrator, ) save_config(sandbox, escaped) @@ -442,20 +435,21 @@ def test_wipe_refuses_symlinked_target_escaping_sandbox(sandbox: Path, tmp_path: def test_seeded_equipment_roots_are_base_paths(sandbox: Path) -> None: - """Seeded ``EquipmentConfig.local_root``/``nas_root`` are BASE roots. + """Seeded ``EquipmentConfig.nas_root`` and the data root are BASE roots. Consumers (orchestrator quiescence poller, validator) compose - ``Path(equipment.local_root) / equipment.id`` and ``build_creation_json`` + ``Path(config.paths.local_root) / equipment.id`` and ``build_creation_json`` composes ``Path(nas_root) / equipment_id`` -- so the seeded roots must be the base (no id), matching a real operator config, or the seeded tree is - invisible to run-walking. + invisible to run-walking. The data root is no longer per-equipment: it + derives once from ``config.paths.app_root`` as ``/data``. """ generate_samples(sandbox, wipe=True, base_time=BASE_TIME) config = load_config(sandbox) sandbox_dir = sandbox.parent + assert config.paths.local_root == str(sandbox_dir / "app" / "data") for entry in config.equipment: - assert entry.local_root == config.paths.local_root == str(sandbox_dir / "local") assert entry.nas_root == str(sandbox_dir / "nas") # creation.json ``paths.nas`` = base nas_root + prefixed id; ``paths.local`` diff --git a/tests/unit/sync/test_nas_client.py b/tests/unit/sync/test_nas_client.py index c6366f9..f6fa866 100644 --- a/tests/unit/sync/test_nas_client.py +++ b/tests/unit/sync/test_nas_client.py @@ -63,16 +63,13 @@ def _build_config(local_root: Path, *, retain_cache: bool = True) -> Config: return Config( - paths=PathsConfig( - templates_dir="/tpl", - plugin_dir="/plg", - local_root=str(local_root), - ), + # nas_client receives each run dir explicitly and never composes from + # config.paths.local_root, so the app root here is incidental. + paths=PathsConfig(app_root=str(local_root)), equipment=[ EquipmentConfig( id="EQ1", label="Eq 1", - local_root=str(local_root), nas_root="/nas", ) ], @@ -753,12 +750,11 @@ def test_apply_config_swaps_equipment_map(tmp_path: Path) -> None: assert set(client._equipment_by_id) == {"EQ1"} cfg2 = Config( - paths=PathsConfig(local_root=str(tmp_path)), + paths=PathsConfig(app_root=str(tmp_path)), equipment=[ EquipmentConfig( id="EQ2", label="Eq 2", - local_root=str(tmp_path), nas_root="/nas", ) ], @@ -789,12 +785,11 @@ def test_target_for_stage_mode_uses_staging_remote(tmp_path: Path) -> None: stage_eq = EquipmentConfig( id="STAGE_01", label="Stage 1", - local_root=str(tmp_path), nas_root="/nas", sync_mode=SyncMode.STAGE, ) config = Config( - paths=PathsConfig(local_root=str(tmp_path)), + paths=PathsConfig(app_root=str(tmp_path)), equipment=[stage_eq], nas=NasConfig(remote="nas01", base_root="/srv/nas"), orchestrator=OrchestratorConfig( @@ -816,12 +811,11 @@ def test_target_for_nas_mode_uses_nas_remote(tmp_path: Path) -> None: nas_eq = EquipmentConfig( id="EQ1", label="Eq 1", - local_root=str(tmp_path), nas_root="/nas", sync_mode=SyncMode.NAS, ) config = Config( - paths=PathsConfig(local_root=str(tmp_path)), + paths=PathsConfig(app_root=str(tmp_path)), equipment=[nas_eq], nas=NasConfig(remote="nas01", base_root="/srv/nas"), orchestrator=OrchestratorConfig( @@ -844,12 +838,11 @@ def test_driver_for_stage_mode_uses_staging_perf(tmp_path: Path) -> None: stage_eq = EquipmentConfig( id="STAGE_01", label="Stage 1", - local_root=str(tmp_path), nas_root="/nas", sync_mode=SyncMode.STAGE, ) config = Config( - paths=PathsConfig(local_root=str(tmp_path)), + paths=PathsConfig(app_root=str(tmp_path)), equipment=[stage_eq], nas=NasConfig( remote="nas01", @@ -871,7 +864,6 @@ def test_driver_for_stage_mode_uses_staging_perf(tmp_path: Path) -> None: EquipmentConfig( id="EQ1", label="Eq 1", - local_root=str(tmp_path), nas_root="/nas", sync_mode=SyncMode.NAS, ) @@ -936,16 +928,11 @@ async def test_drive_job_bandwidth_comes_from_nas_block( assert expected_kibps is not None, "precondition: schedule-free cap must always apply" cfg = Config( - paths=PathsConfig( - templates_dir="/tpl", - plugin_dir="/plg", - local_root=str(tmp_path), - ), + paths=PathsConfig(app_root=str(tmp_path)), equipment=[ EquipmentConfig( id="EQ1", label="Eq 1", - local_root=str(tmp_path), nas_root="/nas", sync_mode=SyncMode.NAS, ) diff --git a/tests/unit/sync/test_nas_client_extra.py b/tests/unit/sync/test_nas_client_extra.py index 49e0ba4..97698c3 100644 --- a/tests/unit/sync/test_nas_client_extra.py +++ b/tests/unit/sync/test_nas_client_extra.py @@ -57,12 +57,11 @@ def _build_config( delete_ignored: bool = False, ) -> Config: return Config( - paths=PathsConfig(templates_dir="/tpl", plugin_dir="/plg", local_root=str(local_root)), + paths=PathsConfig(app_root=str(local_root)), equipment=[ EquipmentConfig( id="EQ1", label="Eq 1", - local_root=str(local_root), nas_root="/nas", ) ], diff --git a/tests/unit/template/test_resolution.py b/tests/unit/template/test_resolution.py index 6a78e1f..2fd670d 100644 --- a/tests/unit/template/test_resolution.py +++ b/tests/unit/template/test_resolution.py @@ -104,27 +104,33 @@ def _write_template( def _build_config(tmp_path: Path) -> Config: - """A config whose global templates dir + local_root live under ``tmp_path``.""" + """A config whose derived template + data roots co-locate under ``tmp_path``. + + Single-app-root refactor: only ``app_root`` is stored. The global templates + dir (``paths.templates_dir``) derives to ``tmp_path/templates`` and the data + root (``paths.local_root``) to ``tmp_path/data``; per-equipment template + stores therefore live under ``tmp_path/data//``. + """ return Config( - paths=PathsConfig( - templates_dir=str(tmp_path / "global-templates"), - plugin_dir=str(tmp_path / "plugins"), - local_root=str(tmp_path / "local"), - ), + paths=PathsConfig(app_root=str(tmp_path)), equipment=[ EquipmentConfig( id=EQUIPMENT_ID, label="Microscope 01", - local_root=str(tmp_path / "local"), nas_root="nas-root", ) ], ) +def _equipment_root(tmp_path: Path) -> Path: + """The per-equipment root, ``/`` == ``data/``.""" + return tmp_path / "data" / EQUIPMENT_ID + + def _project_path(tmp_path: Path) -> Path: """The absolute project dir under ``local_root//``.""" - return tmp_path / "local" / EQUIPMENT_ID / "ProjectA" + return _equipment_root(tmp_path) / "ProjectA" # --------------------------------------------------------------------------- @@ -153,7 +159,7 @@ def test_search_dirs_run_orders_project_equipment_global(tmp_path: Path) -> None ) assert dirs == [ instance_template_dir(project, TemplateType.RUN.value), - instance_template_dir(tmp_path / "local" / EQUIPMENT_ID, TemplateType.RUN.value), + instance_template_dir(_equipment_root(tmp_path), TemplateType.RUN.value), Path(config.paths.templates_dir), ] @@ -166,7 +172,7 @@ def test_search_dirs_project_orders_equipment_global(tmp_path: Path) -> None: equipment_id=EQUIPMENT_ID, ) assert dirs == [ - instance_template_dir(tmp_path / "local" / EQUIPMENT_ID, TemplateType.PROJECT.value), + instance_template_dir(_equipment_root(tmp_path), TemplateType.PROJECT.value), Path(config.paths.templates_dir), ] @@ -182,19 +188,22 @@ def test_search_dirs_equipment_is_global_only(tmp_path: Path) -> None: def test_search_dirs_skips_empty_global(tmp_path: Path) -> None: + from types import SimpleNamespace + + # Single-app-root refactor: ``paths.templates_dir`` is a derived read-only + # property that is never empty for a real config. To still exercise the + # "empty global dir is dropped" branch of ``search_dirs``, hand it a paths + # stub with an empty ``templates_dir`` and the real derived data root. config = _build_config(tmp_path) - config = config.model_copy( - update={"paths": config.paths.model_copy(update={"templates_dir": ""})} - ) + paths_stub = SimpleNamespace(templates_dir="", local_root=config.paths.local_root) + config = config.model_copy(update={"paths": paths_stub}) dirs = search_dirs( config, template_type=TemplateType.PROJECT.value, equipment_id=EQUIPMENT_ID, ) # Only the per-equipment dir survives; the empty global dir is dropped. - assert dirs == [ - instance_template_dir(tmp_path / "local" / EQUIPMENT_ID, TemplateType.PROJECT.value) - ] + assert dirs == [instance_template_dir(_equipment_root(tmp_path), TemplateType.PROJECT.value)] def test_search_dirs_skips_equipment_when_id_missing(tmp_path: Path) -> None: @@ -212,7 +221,7 @@ def test_run_chain_nearest_scope_wins_on_name_collision(tmp_path: Path) -> None: config = _build_config(tmp_path) project = _project_path(tmp_path) global_dir = Path(config.paths.templates_dir) - equip_run_dir = instance_template_dir(tmp_path / "local" / EQUIPMENT_ID, TemplateType.RUN.value) + equip_run_dir = instance_template_dir(_equipment_root(tmp_path), TemplateType.RUN.value) project_run_dir = instance_template_dir(project, TemplateType.RUN.value) # Same name "shared" in all three scopes; distinguish by description. @@ -255,7 +264,7 @@ def test_run_chain_distinct_names_ordered_project_equipment_global(tmp_path: Pat config = _build_config(tmp_path) project = _project_path(tmp_path) global_dir = Path(config.paths.templates_dir) - equip_run_dir = instance_template_dir(tmp_path / "local" / EQUIPMENT_ID, TemplateType.RUN.value) + equip_run_dir = instance_template_dir(_equipment_root(tmp_path), TemplateType.RUN.value) project_run_dir = instance_template_dir(project, TemplateType.RUN.value) _write_template(global_dir, name="g-run", template_type="run", run_scope=RunScope.BOTH.value) @@ -277,9 +286,7 @@ def test_run_chain_distinct_names_ordered_project_equipment_global(tmp_path: Pat def test_project_chain_equipment_beats_global_on_collision(tmp_path: Path) -> None: config = _build_config(tmp_path) global_dir = Path(config.paths.templates_dir) - equip_proj_dir = instance_template_dir( - tmp_path / "local" / EQUIPMENT_ID, TemplateType.PROJECT.value - ) + equip_proj_dir = instance_template_dir(_equipment_root(tmp_path), TemplateType.PROJECT.value) _write_template(global_dir, name="layout", template_type="project", description="global") _write_template(equip_proj_dir, name="layout", template_type="project", description="equipment") @@ -303,9 +310,7 @@ def test_equipment_chain_searches_only_global(tmp_path: Path) -> None: config = _build_config(tmp_path) global_dir = Path(config.paths.templates_dir) # A per-equipment "equipment"-type store should be ignored (never searched). - equip_equip_dir = instance_template_dir( - tmp_path / "local" / EQUIPMENT_ID, TemplateType.EQUIPMENT.value - ) + equip_equip_dir = instance_template_dir(_equipment_root(tmp_path), TemplateType.EQUIPMENT.value) _write_template(global_dir, name="rig", template_type="equipment") _write_template(equip_equip_dir, name="ignored", template_type="equipment") diff --git a/tests/unit/test_data_root_agreement.py b/tests/unit/test_data_root_agreement.py new file mode 100644 index 0000000..8d722a7 --- /dev/null +++ b/tests/unit/test_data_root_agreement.py @@ -0,0 +1,85 @@ +"""Regression: the three equipment-data-dir derivations must agree. + +Before the single-app-root refactor there were two sources of truth for where +an equipment's runs live: the global ``paths.local_root`` (used by run +*creation* and the *validator* audit roots) and a per-equipment +``EquipmentConfig.local_root`` (used by the auto-sync *quiescence poller*). If +those diverged, runs were created in one tree while the sync/audit engines +watched another -- runs silently never synced. + +The refactor removed ``EquipmentConfig.local_root`` and made every site derive +``/`` (== ``/data/``). +This test pins that agreement so the divergence cannot regress: it checks the +exact derivation used by + +* run creation (:func:`exlab_wizard.paths.compose_run_path` / + :func:`compose_project_path`, fed ``config.paths.local_root`` by the + controller), +* the validator audit roots (:meth:`Validator.from_config`), and +* the quiescence poller's nas-mode discovery root. +""" + +from __future__ import annotations + +from datetime import datetime +from pathlib import Path + +# Importing the API package first sidesteps a pre-existing import-ordering +# cycle (validator.engine <-> controller.creation) when this module is +# collected standalone; mirrors the guard already used by other unit tests. +import exlab_wizard.api.app # noqa: F401 +from exlab_wizard.config.models import Config, EquipmentConfig, PathsConfig +from exlab_wizard.constants import RunKind +from exlab_wizard.paths import compose_project_path, compose_run_path +from exlab_wizard.validator.engine import Validator + +_EQUIPMENT_ID = "CONFOCAL_01" + + +def _config(app_root: Path) -> Config: + return Config( + paths=PathsConfig(app_root=str(app_root)), + equipment=[ + EquipmentConfig(id=_EQUIPMENT_ID, label="Confocal", nas_root="//nas/lab"), + ], + ) + + +def test_data_root_derives_from_app_root(tmp_path: Path) -> None: + """``config.paths.local_root`` is exactly ``/data``.""" + config = _config(tmp_path / "ExLabWizard") + assert Path(config.paths.local_root) == tmp_path / "ExLabWizard" / "data" + + +def test_creation_and_validator_and_poller_agree_on_equipment_dir(tmp_path: Path) -> None: + """All three consumers compose the same ``/``.""" + config = _config(tmp_path / "ExLabWizard") + data_root = Path(config.paths.local_root) + expected_equipment_dir = data_root / _EQUIPMENT_ID + + # Creation: the controller composes run/project paths from + # ``config.paths.local_root``; both must sit under the equipment dir. + project_path = compose_project_path( + local_root=data_root, + equipment_id=_EQUIPMENT_ID, + project_name="Cortex Q3", + ) + run_path = compose_run_path( + local_root=data_root, + equipment_id=_EQUIPMENT_ID, + project_name="Cortex Q3", + run_kind=RunKind.EXPERIMENTAL, + run_date=datetime(2026, 6, 1, 14, 30), + ) + assert project_path.parent == expected_equipment_dir + assert expected_equipment_dir in run_path.parents + + # Validator audit roots: built by Validator.from_config. + validator = Validator.from_config(config) + assert validator._equipment_roots[_EQUIPMENT_ID] == expected_equipment_dir + + # Poller: nas-mode discovery walks ``/`` -- + # the same derivation, expressed inline here so a change to the poller's + # composition that diverges from creation fails this assertion. + poller_equipment_dir = Path(config.paths.local_root) / _EQUIPMENT_ID + assert poller_equipment_dir == expected_equipment_dir diff --git a/tests/unit/test_paths.py b/tests/unit/test_paths.py index 54ac3f1..73a21f0 100644 --- a/tests/unit/test_paths.py +++ b/tests/unit/test_paths.py @@ -26,9 +26,12 @@ from exlab_wizard.constants import RunKind, SetupNextAction, SetupState from exlab_wizard.errors import ConfigError from exlab_wizard.paths import ( + app_root_writable, canonicalize_equipment_id, compose_project_path, compose_run_path, + default_app_root, + ensure_app_dirs, ensure_central_log_dir, ensure_dir, ensure_state_dir, @@ -36,6 +39,7 @@ os_cache_path, os_central_log_path, os_config_path, + os_documents_path, os_state_path, project_name_violations, setup_state_missing, @@ -55,7 +59,6 @@ def _make_equipment(equipment_id: str = "CONFOCAL_01") -> EquipmentConfig: { "id": equipment_id, "label": "Confocal Microscope", - "local_root": "/data/lab", "nas_root": "//nas01/lab", } ) @@ -81,11 +84,7 @@ def _ready_config() -> Config: from exlab_wizard.config.models import NasConfig, OrchestratorConfig return Config( - paths=PathsConfig( - templates_dir="/srv/templates", - plugin_dir="/srv/plugins", - local_root="/data/lab", - ), + paths=PathsConfig(app_root="/srv/exlab"), lims=LIMSConfig(endpoint="https://lims.example/api/v1", email="op@lab.example"), equipment=[_make_equipment()], orchestrator=OrchestratorConfig( @@ -111,8 +110,10 @@ def fake_home(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: "XDG_STATE_HOME", "XDG_CACHE_HOME", "XDG_DATA_HOME", + "XDG_DOCUMENTS_DIR", "APPDATA", "LOCALAPPDATA", + "USERPROFILE", ): monkeypatch.delenv(var, raising=False) return home @@ -294,6 +295,105 @@ def test_suggested_staging_root_windows(monkeypatch: pytest.MonkeyPatch, fake_ho assert suggested_staging_root() == expected +# --------------------------------------------------------------------------- +# os_documents_path / default_app_root (the Documents-based app root) +# --------------------------------------------------------------------------- + + +def test_os_documents_path_macos(monkeypatch: pytest.MonkeyPatch, fake_home: Path) -> None: + monkeypatch.setattr("sys.platform", "darwin") + assert os_documents_path() == fake_home / "Documents" + + +def test_os_documents_path_linux_with_xdg(monkeypatch: pytest.MonkeyPatch, fake_home: Path) -> None: + monkeypatch.setattr("sys.platform", "linux") + xdg = fake_home / "xdg-docs" + monkeypatch.setenv("XDG_DOCUMENTS_DIR", str(xdg)) + assert os_documents_path() == xdg + + +def test_os_documents_path_linux_without_xdg( + monkeypatch: pytest.MonkeyPatch, fake_home: Path +) -> None: + monkeypatch.setattr("sys.platform", "linux") + assert os_documents_path() == fake_home / "Documents" + + +def test_os_documents_path_windows_userprofile_fallback( + monkeypatch: pytest.MonkeyPatch, fake_home: Path +) -> None: + """On a non-Windows test runner the ctypes Known-Folder call is + unavailable, so the resolver falls back to ``%USERPROFILE%\\Documents``. + (The SHGetKnownFolderPath success path is exercised only on real Windows.) + """ + monkeypatch.setattr("sys.platform", "win32") + profile = fake_home / "winprofile" + monkeypatch.setenv("USERPROFILE", str(profile)) + assert os_documents_path() == profile / "Documents" + + +def test_os_documents_path_windows_home_fallback( + monkeypatch: pytest.MonkeyPatch, fake_home: Path +) -> None: + """With neither the Known Folder nor USERPROFILE available, fall back to + ``~/Documents``.""" + monkeypatch.setattr("sys.platform", "win32") + assert os_documents_path() == fake_home / "Documents" + + +def test_default_app_root_macos(monkeypatch: pytest.MonkeyPatch, fake_home: Path) -> None: + monkeypatch.setattr("sys.platform", "darwin") + monkeypatch.delenv("EXLAB_WIZARD_TEST_MODE", raising=False) + assert default_app_root() == fake_home / "Documents" / "ExLabWizard" + + +def test_default_app_root_test_mode_suffix( + monkeypatch: pytest.MonkeyPatch, fake_home: Path +) -> None: + """Test mode sandboxes the Documents subfolder so tests never write into + the operator's real ``ExLabWizard`` tree.""" + monkeypatch.setattr("sys.platform", "darwin") + monkeypatch.setenv("EXLAB_WIZARD_TEST_MODE", "1") + assert default_app_root() == fake_home / "Documents" / "ExLabWizard-test" + + +# --------------------------------------------------------------------------- +# ensure_app_dirs / app_root_writable +# --------------------------------------------------------------------------- + + +def _app_root_config(app_root: Path) -> Config: + """Minimal Config carrying just the app root (for app-dir helpers).""" + return Config(paths=PathsConfig(app_root=str(app_root))) + + +def test_ensure_app_dirs_creates_derived_tree(tmp_path: Path) -> None: + app_root = tmp_path / "ExLabWizard" + ensure_app_dirs(_app_root_config(app_root)) + assert app_root.is_dir() + assert (app_root / "data").is_dir() + assert (app_root / "templates").is_dir() + assert (app_root / "plugins").is_dir() + + +def test_ensure_app_dirs_idempotent(tmp_path: Path) -> None: + config = _app_root_config(tmp_path / "ExLabWizard") + ensure_app_dirs(config) + ensure_app_dirs(config) # second call is a no-op, must not raise + assert (tmp_path / "ExLabWizard" / "data").is_dir() + + +def test_app_root_writable_true_for_writable_tree(tmp_path: Path) -> None: + assert app_root_writable(_app_root_config(tmp_path / "ExLabWizard")) is True + + +def test_app_root_writable_false_when_uncreatable(tmp_path: Path) -> None: + """A path whose parent is a file (so mkdir fails) is reported unwritable.""" + blocker = tmp_path / "blocker" + blocker.write_text("not a directory", encoding="utf-8") + assert app_root_writable(_app_root_config(blocker / "ExLabWizard")) is False + + # --------------------------------------------------------------------------- # Test-mode suffix (EXLAB_WIZARD_TEST_MODE=1) # --------------------------------------------------------------------------- @@ -714,36 +814,33 @@ def test_evaluate_setup_state_no_config() -> None: assert evaluate_setup_state(None) is SetupState.INCOMPLETE_NO_CONFIG -def test_evaluate_setup_state_missing_paths() -> None: +def test_evaluate_setup_state_paths_unwritable() -> None: + """An unwritable app root trips the paths gate (right after no-config).""" config = Config( - paths=PathsConfig(templates_dir="", plugin_dir="", local_root=""), + paths=PathsConfig(app_root="/srv/exlab"), equipment=[_make_equipment()], orchestrator=_make_orchestrator(), ) - assert evaluate_setup_state(config) is SetupState.INCOMPLETE_MISSING_PATHS + assert ( + evaluate_setup_state(config, paths_writable=False) is SetupState.INCOMPLETE_PATHS_UNWRITABLE + ) -def test_evaluate_setup_state_missing_paths_partial() -> None: - """Only one path is empty -- still INCOMPLETE_MISSING_PATHS.""" +def test_evaluate_setup_state_paths_writable_defaults_true() -> None: + """``paths_writable`` defaults True, so the gate is skipped unless asked.""" config = Config( - paths=PathsConfig( - templates_dir="/srv/templates", - plugin_dir="/srv/plugins", - local_root="", - ), + paths=PathsConfig(app_root="/srv/exlab"), equipment=[_make_equipment()], orchestrator=_make_orchestrator(), ) - assert evaluate_setup_state(config) is SetupState.INCOMPLETE_MISSING_PATHS + # Default leaves the paths gate satisfied; the chain moves past it (the + # next unsatisfied gate here is the LIMS slot, not the paths gate). + assert evaluate_setup_state(config) is not SetupState.INCOMPLETE_PATHS_UNWRITABLE def test_evaluate_setup_state_no_equipment() -> None: config = Config( - paths=PathsConfig( - templates_dir="/srv/templates", - plugin_dir="/srv/plugins", - local_root="/data/lab", - ), + paths=PathsConfig(app_root="/srv/exlab"), equipment=[], orchestrator=_make_orchestrator(), ) @@ -753,11 +850,7 @@ def test_evaluate_setup_state_no_equipment() -> None: def test_evaluate_setup_state_no_orchestrator() -> None: """Only ``label`` gates orchestrator identity; a blank label trips it.""" config = Config( - paths=PathsConfig( - templates_dir="/srv/templates", - plugin_dir="/srv/plugins", - local_root="/data/lab", - ), + paths=PathsConfig(app_root="/srv/exlab"), equipment=[_make_equipment()], ) assert evaluate_setup_state(config) is SetupState.INCOMPLETE_NO_ORCHESTRATOR @@ -768,11 +861,7 @@ def test_evaluate_setup_state_no_orchestrator_when_only_staging_set() -> None: from exlab_wizard.config.models import OrchestratorConfig config = Config( - paths=PathsConfig( - templates_dir="/srv/templates", - plugin_dir="/srv/plugins", - local_root="/data/lab", - ), + paths=PathsConfig(app_root="/srv/exlab"), equipment=[_make_equipment()], orchestrator=OrchestratorConfig(label="", staging_root="/srv/staging"), ) @@ -784,11 +873,7 @@ def test_evaluate_setup_state_blank_staging_root_is_allowed() -> None: from exlab_wizard.config.models import NasConfig, OrchestratorConfig config = Config( - paths=PathsConfig( - templates_dir="/srv/templates", - plugin_dir="/srv/plugins", - local_root="/data/lab", - ), + paths=PathsConfig(app_root="/srv/exlab"), lims=LIMSConfig(endpoint="https://lims.example/api/v1", email="op@lab.example"), equipment=[_make_equipment()], orchestrator=OrchestratorConfig(label="Lab Acquisition Station 01", staging_root=""), @@ -802,11 +887,7 @@ def test_setup_state_missing_for_no_orchestrator_lists_only_label() -> None: from exlab_wizard.config.models import OrchestratorConfig config = Config( - paths=PathsConfig( - templates_dir="/srv/templates", - plugin_dir="/srv/plugins", - local_root="/data/lab", - ), + paths=PathsConfig(app_root="/srv/exlab"), equipment=[_make_equipment()], orchestrator=OrchestratorConfig(label="", staging_root=""), ) @@ -818,11 +899,7 @@ def test_evaluate_setup_state_no_lims() -> None: from exlab_wizard.config.models import NasConfig config = Config( - paths=PathsConfig( - templates_dir="/srv/templates", - plugin_dir="/srv/plugins", - local_root="/data/lab", - ), + paths=PathsConfig(app_root="/srv/exlab"), lims=LIMSConfig(endpoint="", email="", offline_catalogue_path=""), equipment=[_make_equipment()], orchestrator=_make_orchestrator(), @@ -836,11 +913,7 @@ def test_evaluate_setup_state_lims_via_offline_catalogue() -> None: from exlab_wizard.config.models import NasConfig config = Config( - paths=PathsConfig( - templates_dir="/srv/templates", - plugin_dir="/srv/plugins", - local_root="/data/lab", - ), + paths=PathsConfig(app_root="/srv/exlab"), lims=LIMSConfig( endpoint="", email="", @@ -885,13 +958,12 @@ def _nas_remote_config(remote: str = "") -> Config: from exlab_wizard.config.models import NasConfig return Config( - paths={"templates_dir": "/t", "plugin_dir": "/p", "local_root": "/l"}, + paths={"app_root": "/srv/exlab"}, orchestrator={"label": "ws-1"}, equipment=[ EquipmentConfig( id="EQ_01", label="Eq", - local_root="/l", nas_root="//n/x", ) ], @@ -967,11 +1039,7 @@ def test_evaluate_setup_state_endpoint_only_missing_email() -> None: from exlab_wizard.config.models import NasConfig config = Config( - paths=PathsConfig( - templates_dir="/srv/templates", - plugin_dir="/srv/plugins", - local_root="/data/lab", - ), + paths=PathsConfig(app_root="/srv/exlab"), lims=LIMSConfig(endpoint="https://lims.example/api/v1", email=""), equipment=[_make_equipment()], orchestrator=_make_orchestrator(), @@ -989,7 +1057,7 @@ def test_evaluate_setup_state_endpoint_only_missing_email() -> None: ("state", "expected"), [ (SetupState.INCOMPLETE_NO_CONFIG, "set_paths"), - (SetupState.INCOMPLETE_MISSING_PATHS, "set_paths"), + (SetupState.INCOMPLETE_PATHS_UNWRITABLE, "set_paths"), (SetupState.INCOMPLETE_NO_EQUIPMENT, "add_equipment"), (SetupState.INCOMPLETE_NO_LIMS, "configure_lims"), (SetupState.INCOMPLETE_LIMS_UNREACHABLE, "test_lims"), @@ -1022,15 +1090,11 @@ def test_setup_state_missing_when_lims_unreachable_returns_empty() -> None: assert setup_state_missing(SetupState.INCOMPLETE_LIMS_UNREACHABLE, _ready_config()) == [] -def test_setup_state_missing_for_paths_lists_each_unset() -> None: - config = Config( - paths=PathsConfig(templates_dir="", plugin_dir="/srv/plugins", local_root=""), - ) - result = setup_state_missing(SetupState.INCOMPLETE_MISSING_PATHS, config) - fields = {entry["field"] for entry in result} - assert "paths.templates_dir" in fields - assert "paths.local_root" in fields - assert "paths.plugin_dir" not in fields +def test_setup_state_missing_for_paths_reports_app_root_unwritable() -> None: + """The paths gate now reports a single unwritable ``paths.app_root`` row.""" + config = Config(paths=PathsConfig(app_root="/srv/exlab")) + result = setup_state_missing(SetupState.INCOMPLETE_PATHS_UNWRITABLE, config) + assert result == [{"field": "paths.app_root", "reason": "unwritable"}] def test_setup_state_missing_for_no_equipment() -> None: @@ -1040,11 +1104,7 @@ def test_setup_state_missing_for_no_equipment() -> None: def test_setup_state_missing_for_no_lims_lists_endpoint_email() -> None: config = Config( - paths=PathsConfig( - templates_dir="/srv/templates", - plugin_dir="/srv/plugins", - local_root="/data/lab", - ), + paths=PathsConfig(app_root="/srv/exlab"), lims=LIMSConfig(endpoint="", email=""), equipment=[_make_equipment()], orchestrator=_make_orchestrator(), @@ -1055,15 +1115,10 @@ def test_setup_state_missing_for_no_lims_lists_endpoint_email() -> None: assert "lims.email" in fields -def test_setup_state_missing_for_missing_paths_with_none_config() -> None: - """When config is None but state is INCOMPLETE_MISSING_PATHS, every paths - field is reported as unset. This is a defensive branch for callers that - pass the state without the config object.""" - result = setup_state_missing(SetupState.INCOMPLETE_MISSING_PATHS, None) - fields = {entry["field"] for entry in result} - assert fields == {"paths.templates_dir", "paths.plugin_dir", "paths.local_root"} - for entry in result: - assert entry["reason"] == "unset" +def test_setup_state_missing_for_unwritable_paths_with_none_config() -> None: + """The paths-unwritable row is config-independent (single app_root row).""" + result = setup_state_missing(SetupState.INCOMPLETE_PATHS_UNWRITABLE, None) + assert result == [{"field": "paths.app_root", "reason": "unwritable"}] def test_setup_state_missing_for_no_lims_with_none_config() -> None: @@ -1078,11 +1133,7 @@ def test_setup_state_missing_for_no_lims_flags_keyring_when_endpoint_email_set() """endpoint+email both filled in but no offline catalogue -> the keyring-password slot is flagged as missing_in_keyring.""" config = Config( - paths=PathsConfig( - templates_dir="/srv/templates", - plugin_dir="/srv/plugins", - local_root="/data/lab", - ), + paths=PathsConfig(app_root="/srv/exlab"), lims=LIMSConfig( endpoint="https://lims.example/api/v1", email="op@lab.example", diff --git a/tests/unit/tray/test_dependencies.py b/tests/unit/tray/test_dependencies.py index 6f5d4ac..e4deb34 100644 --- a/tests/unit/tray/test_dependencies.py +++ b/tests/unit/tray/test_dependencies.py @@ -82,15 +82,14 @@ def test_build_production_dependencies_nas_sync_is_a_client( force-sync route call both, so the bug surfaced only as a silent dead sync loop in production. This is the test that catches it. """ - local_root = tmp_path / "lab-data" - local_root.mkdir() + data_root = tmp_path / "data" + data_root.mkdir() config = Config( - paths=PathsConfig(local_root=str(local_root)), + paths=PathsConfig(app_root=str(tmp_path)), equipment=[ EquipmentConfig( id="EQNAS", label="Nas Equipment", - local_root=str(local_root), nas_root="/nas", sync_mode=SyncMode.NAS, ), @@ -147,19 +146,17 @@ def test_lims_client_password_provider_reads_keyring_under_lims_username( def _nas_config_with_two_equipment() -> Config: """Build a two-equipment nas-mode config for the NAS-presence tests.""" return Config( - paths=PathsConfig(local_root="/data"), + paths=PathsConfig(app_root="/srv/exlab"), equipment=[ EquipmentConfig( id="EQ1", label="One", - local_root="/data", nas_root="/srv/nas", sync_mode=SyncMode.NAS, ), EquipmentConfig( id="EQ2", label="Two", - local_root="/data", nas_root="/srv/nas", sync_mode=SyncMode.NAS, ), diff --git a/tests/unit/tray/test_live_reload.py b/tests/unit/tray/test_live_reload.py index 18260e2..2113ecc 100644 --- a/tests/unit/tray/test_live_reload.py +++ b/tests/unit/tray/test_live_reload.py @@ -53,21 +53,22 @@ def _equipment(eq_id: str = "EQ1") -> EquipmentConfig: return EquipmentConfig( id=eq_id, label=f"Equipment {eq_id}", - local_root="/tmp/data", nas_root="/nas", ) def _config( *, - plugin_dir: str = "/plugins", + app_root: str = "/srv/exlab", endpoint: str = "https://lims.example", email: str = "op@example", log_level: str = "INFO", equipment: tuple[EquipmentConfig, ...] | None = None, ) -> Config: + # plugin_dir / templates_dir / data_root are all derived from app_root, so + # a plugin-dir change is driven by pointing app_root at a different root. return Config( - paths=PathsConfig(templates_dir="/tpl", plugin_dir=plugin_dir, local_root="/tmp/data"), + paths=PathsConfig(app_root=app_root), lims=LIMSConfig(endpoint=endpoint, email=email), logging=LoggingConfig(level=log_level), equipment=list(equipment) if equipment is not None else [_equipment()], @@ -193,18 +194,20 @@ def test_rebuilds_plugin_host_only_on_dir_change(monkeypatch: pytest.MonkeyPatch built: list[Any] = [] monkeypatch.setattr(deps_mod, "_build_plugin_host", lambda cfg: built.append(cfg) or "HOST") deps = _running_deps() - deps.config = _config(plugin_dir="/old") + deps.config = _config(app_root="/srv/old") - # plugin_dir changes -> rebuild + re-inject into the controller. - new = _config(plugin_dir="/new") + # plugin_dir (derived from app_root) changes -> rebuild + re-inject into + # the controller. + new = _config(app_root="/srv/new") apply_live_config(deps, new) assert built == [new] assert deps.plugin_host == "HOST" assert deps.controller.plugin_hosts == ["HOST"] - # Unchanged plugin_dir -> no rebuild, controller keeps its host. + # Unchanged app_root -> derived plugin_dir unchanged -> no rebuild, + # controller keeps its host. built.clear() - apply_live_config(deps, _config(plugin_dir="/new")) + apply_live_config(deps, _config(app_root="/srv/new")) assert built == [] assert deps.controller.plugin_hosts[-1] is None diff --git a/tests/unit/tray/test_main.py b/tests/unit/tray/test_main.py index a5db4f3..b985193 100644 --- a/tests/unit/tray/test_main.py +++ b/tests/unit/tray/test_main.py @@ -517,9 +517,10 @@ def test_main_test_flag_sets_env_and_bootstraps_config(test_mode_env: Path) -> N # still implement the LMS endpoint via Settings). cfg = load_config(cfg_path) sandbox = cfg_path.parent - assert cfg.paths.local_root == str(sandbox / "local") - assert cfg.paths.templates_dir == str(sandbox / "templates") - assert cfg.paths.plugin_dir == str(sandbox / "plugins") + assert cfg.paths.app_root == str(sandbox / "app") + assert cfg.paths.local_root == str(sandbox / "app" / "data") + assert cfg.paths.templates_dir == str(sandbox / "app" / "templates") + assert cfg.paths.plugin_dir == str(sandbox / "app" / "plugins") assert cfg.orchestrator.staging_root == str(sandbox / "staging") assert cfg.orchestrator.label == "test-workstation" assert cfg.lims.endpoint == "" @@ -527,7 +528,12 @@ def test_main_test_flag_sets_env_and_bootstraps_config(test_mode_env: Path) -> N assert cfg.equipment == [] # Preseeded directories exist so first-launch path lookups succeed. - for sub in ("local", "templates", "plugins", "staging"): + for sub in ( + Path("app") / "data", + Path("app") / "templates", + Path("app") / "plugins", + Path("staging"), + ): assert (sandbox / sub).is_dir() @@ -537,7 +543,7 @@ def test_main_test_does_not_overwrite_existing_config(test_mode_env: Path) -> No cfg_path = test_mode_env / ".config" / "exlab-wizard-test" / "config.yaml" cfg_path.parent.mkdir(parents=True) - sentinel = "paths:\n local_root: /already/here\n" + sentinel = "paths:\n app_root: /already/here\n" cfg_path.write_text(sentinel, encoding="utf-8") tray_main.main(["--test"]) @@ -563,8 +569,8 @@ def test_main_test_with_samples_adds_equipment(test_mode_env: Path) -> None: f"{TEST_MODE_PREFIX}TESTRIG", f"{TEST_MODE_PREFIX}ALTRIG", } - # A full sample tree was seeded on disk under the sandbox local root. - local_root = sandbox / "local" + # A full sample tree was seeded on disk under the sandbox data root. + local_root = sandbox / "app" / "data" assert (local_root / f"{TEST_MODE_PREFIX}TESTRIG" / "Demo Project").is_dir() assert (local_root / f"{TEST_MODE_PREFIX}ALTRIG" / "Failure Modes").is_dir() @@ -577,7 +583,7 @@ def test_main_test_with_samples_repeat_boot_is_noop(test_mode_env: Path) -> None tray_main.main(["--test", "--add-test-samples"]) sandbox = test_mode_env / ".config" / "exlab-wizard-test" - operator_file = sandbox / "local" / f"{TEST_MODE_PREFIX}TESTRIG" / "operator_added.txt" + operator_file = sandbox / "app" / "data" / f"{TEST_MODE_PREFIX}TESTRIG" / "operator_added.txt" operator_file.write_text("keep me", encoding="utf-8") # bootstrap_test_config short-circuits on the existing config, so the second diff --git a/tests/unit/ui/test_dynamic_form.py b/tests/unit/ui/test_dynamic_form.py index 59c55da..07bade2 100644 --- a/tests/unit/ui/test_dynamic_form.py +++ b/tests/unit/ui/test_dynamic_form.py @@ -119,7 +119,6 @@ def _equipment_kwargs(**overrides: object) -> dict[str, object]: base: dict[str, object] = { "equipment_id": "MICROSCOPE1", "label": "Confocal 1", - "local_root": "/data/microscope1", "nas_root": "/nas/microscope1", "sync_mode": "nas", } diff --git a/tests/unit/ui/test_mount.py b/tests/unit/ui/test_mount.py index 176616c..af77d5f 100644 --- a/tests/unit/ui/test_mount.py +++ b/tests/unit/ui/test_mount.py @@ -296,13 +296,11 @@ def test_missing_sections_when_config_none() -> None: assert mount._missing_setup_sections(_deps()) == ("paths", "lims") -def test_missing_sections_with_paths_unset() -> None: - deps = _deps( - config=_config(local_root="", templates_dir=""), - keyring_password_present=True, - ) - sections = mount._missing_setup_sections(deps) - assert "paths" in sections +# NOTE: the old "paths section appears when paths are unset" test was removed: +# `paths.app_root` always carries a Documents-based default, so the Settings +# section picker no longer surfaces a "paths" section for an existing config. +# The unwritable-app-root edge case is covered by the setup-state gate tests +# in tests/unit/test_paths.py. def test_missing_sections_with_keyring_absent_reports_lims() -> None: @@ -343,13 +341,12 @@ def _nas_config(*, offline_catalogue: bool = False, nas_remote: str = "nas01") - else LIMSConfig(endpoint="https://lims.example", email="op@example") ) return Config( - paths=PathsConfig(templates_dir="/t", plugin_dir="/p", local_root="/d"), + paths=PathsConfig(app_root="/srv/exlab"), lims=lims, equipment=[ EquipmentConfig( id="EQ1", label="Equipment 1", - local_root="/d", nas_root="/n", ) ], @@ -1289,15 +1286,16 @@ def test_build_metadata_payload_owned_equipment_reads_config() -> None: id="EQ1", label="Confocal Microscope 1", sync_mode="nas", - local_root="/data/EQ1", nas_root="//nas/EQ1", ) - config = _config(equipment=(equipment,)) + # ``local_root`` is no longer a per-equipment field; the payload derives + # it as ``/`` (== ``/data/``). + config = _config(local_root="/srv/exlab/data", equipment=(equipment,)) payload = mount._build_metadata_payload("EQ1", "equipment", _deps(config=config)) assert payload["id"] == "EQ1" assert payload["label"] == "Confocal Microscope 1" assert payload["sync_mode"] == "nas" - assert payload["local_root"] == "/data/EQ1" + assert payload["local_root"] == str(Path("/srv/exlab/data") / "EQ1") assert payload["nas_root"] == "//nas/EQ1" @@ -2814,20 +2812,21 @@ def test_build_metadata_payload_unknown_kind_returns_empty() -> None: def test_metadata_for_owned_equipment_projects_fields() -> None: """A matching equipment id projects its config fields into the payload.""" config = SimpleNamespace( + paths=SimpleNamespace(local_root="/srv/exlab/data"), equipment=[ SimpleNamespace( id="EQ1", label="Confocal", sync_mode="nas", - local_root="/d/EQ1", nas_root="/n/EQ1", ) - ] + ], ) out = mount._metadata_for_owned_equipment("EQ1", config) assert out["id"] == "EQ1" assert out["label"] == "Confocal" - assert out["local_root"] == "/d/EQ1" + # ``local_root`` is derived as ``/``. + assert out["local_root"] == str(Path("/srv/exlab/data") / "EQ1") assert out["nas_root"] == "/n/EQ1" diff --git a/tests/unit/ui/test_settings_nas_remote.py b/tests/unit/ui/test_settings_nas_remote.py index 2b8f543..3c936a8 100644 --- a/tests/unit/ui/test_settings_nas_remote.py +++ b/tests/unit/ui/test_settings_nas_remote.py @@ -38,7 +38,6 @@ def _nas_equipment(equipment_id: str) -> EquipmentConfig: return EquipmentConfig( id=equipment_id, label=f"Equipment {equipment_id}", - local_root="/data", nas_root="/srv/nas", sync_mode=SyncMode.NAS, ) @@ -50,7 +49,6 @@ def _stage_equipment(equipment_id: str) -> EquipmentConfig: return EquipmentConfig( id=equipment_id, label=f"Stage {equipment_id}", - local_root="/data", nas_root="/srv/nas", sync_mode=SyncMode.STAGE, ) @@ -58,7 +56,7 @@ def _stage_equipment(equipment_id: str) -> EquipmentConfig: def _config_with(*equipment: EquipmentConfig, remote: str = "nas01") -> Config: return Config( - paths=PathsConfig(templates_dir="/t", plugin_dir="/p", local_root="/d"), + paths=PathsConfig(app_root="/srv/exlab"), equipment=list(equipment), orchestrator=OrchestratorConfig( label="LAB", @@ -458,7 +456,6 @@ def test_equipment_add_appends_row() -> None: ) _find(out, "settings-equipment-id").value = "EQ2" _find(out, "settings-equipment-label").value = "Bench 2" - _find(out, "settings-equipment-local-root").value = "/data2" _find(out, "settings-equipment-nas-root").value = "/srv/nas2" _click(out, "settings-equipment-add") @@ -478,7 +475,6 @@ def test_equipment_add_rejects_duplicate_id() -> None: ) _find(out, "settings-equipment-id").value = "EQ1" _find(out, "settings-equipment-label").value = "dup" - _find(out, "settings-equipment-local-root").value = "/data" _find(out, "settings-equipment-nas-root").value = "/srv/nas" _click(out, "settings-equipment-add") @@ -496,7 +492,6 @@ def test_equipment_add_rejects_invalid_id() -> None: # Lowercase/hyphen id fails the ^[A-Z][A-Z0-9_]*$ pattern in build. _find(out, "settings-equipment-id").value = "bad-id" _find(out, "settings-equipment-label").value = "x" - _find(out, "settings-equipment-local-root").value = "/d" _find(out, "settings-equipment-nas-root").value = "/n" _click(out, "settings-equipment-add") diff --git a/tests/unit/ui/test_settings_page.py b/tests/unit/ui/test_settings_page.py index 15042e6..3eaad1a 100644 --- a/tests/unit/ui/test_settings_page.py +++ b/tests/unit/ui/test_settings_page.py @@ -9,6 +9,8 @@ from __future__ import annotations +from pathlib import Path + import pytest from pydantic import ValidationError @@ -32,19 +34,22 @@ def test_build_draft_from_none_yields_defaults() -> None: # §9 defaults are present and editable. assert draft.logging.level == "INFO" assert draft.nas_cleanup.min_verify_passes == 2 - assert draft.paths.templates_dir == "" + # templates_dir is now derived from the single app_root. + assert draft.paths.templates_dir == str(Path(draft.paths.app_root) / "templates") def test_build_draft_copies_existing_config() -> None: source = Config() - source.paths.templates_dir = "/srv/templates" + source.paths.app_root = "/srv/exlab" source.lims.email = "operator@example" draft = build_settings_draft(source) assert draft is not source assert draft.paths is not source.paths - assert draft.paths.templates_dir == "/srv/templates" + assert draft.paths.app_root == "/srv/exlab" + # The derived templates_dir tracks the copied app_root. + assert draft.paths.templates_dir == "/srv/exlab/templates" assert draft.lims.email == "operator@example" @@ -52,10 +57,10 @@ def test_draft_edits_do_not_leak_into_source() -> None: source = Config() draft = build_settings_draft(source) - draft.paths.local_root = "/srv/data" + draft.paths.app_root = "/srv/exlab" draft.orchestrator.label = "BENCH-1" - assert source.paths.local_root == "" + assert source.paths.app_root != "/srv/exlab" assert source.orchestrator.label == "" @@ -74,9 +79,7 @@ def test_finalize_coerces_widget_floats_back_to_int() -> None: def test_finalize_round_trips_edited_scalar_fields() -> None: draft = build_settings_draft(None) - draft.paths.templates_dir = "/srv/templates" - draft.paths.plugin_dir = "/srv/plugins" - draft.paths.local_root = "/srv/data" + draft.paths.app_root = "/srv/exlab" draft.lims.endpoint = "https://lims.example" draft.lims.email = "operator@example" draft.orchestrator.label = "BENCH-1" @@ -84,7 +87,9 @@ def test_finalize_round_trips_edited_scalar_fields() -> None: finalized = finalize_settings_draft(draft) - assert finalized.paths.local_root == "/srv/data" + assert finalized.paths.app_root == "/srv/exlab" + # The data root is derived from the single app_root. + assert finalized.paths.local_root == "/srv/exlab/data" assert finalized.lims.endpoint == "https://lims.example" assert finalized.orchestrator.label == "BENCH-1" diff --git a/tests/unit/ui/test_wizard_equipment.py b/tests/unit/ui/test_wizard_equipment.py index 80ccd19..9d68e89 100644 --- a/tests/unit/ui/test_wizard_equipment.py +++ b/tests/unit/ui/test_wizard_equipment.py @@ -32,7 +32,6 @@ def test_assembled_equipment_defaults_to_nas_sync_mode() -> None: s = EquipmentWizardState() # operator never picks a mode s.equipment_id = "FLOW_99" s.label = "Flow Cytometer 99" - s.local_root = "/data/lab" s.nas_root = "//nas01/lab" eq = assemble_equipment_config(s) assert eq.sync_mode == SyncMode.NAS @@ -42,7 +41,6 @@ def _state_filled_for(step: str) -> EquipmentWizardState: s = EquipmentWizardState(active_step=step) s.equipment_id = "FLOW_99" s.label = "Flow Cytometer 99" - s.local_root = "/data/lab" s.nas_root = "//nas01/lab" s.sync_mode = "nas" return s diff --git a/tests/unit/validator/test_engine_audit.py b/tests/unit/validator/test_engine_audit.py index a4e0b08..3fda14c 100644 --- a/tests/unit/validator/test_engine_audit.py +++ b/tests/unit/validator/test_engine_audit.py @@ -34,7 +34,11 @@ import pytest -from exlab_wizard.config.models import ValidatorConfig +# Prime the api package before importing the validator engine so the +# pre-existing orchestrator <-> api <-> validator import cycle resolves cleanly +# when this module is collected in isolation (same guard as test_resolution.py). +import exlab_wizard.api.app # noqa: F401 -- import order matters +from exlab_wizard.config.models import PathsConfig, ValidatorConfig from exlab_wizard.validator.engine import Validator from exlab_wizard.validator.findings import Finding @@ -1123,10 +1127,16 @@ def test_validator_from_config_builds_engine() -> None: """``Validator.from_config`` projects fields out of a Config-shaped object.""" from types import SimpleNamespace + # Single-app-root refactor: ``from_config`` derives each equipment's audit + # root as ``config.paths.local_root / id`` (== ``/data/``), + # not from a per-equipment ``local_root``. The stub therefore carries a + # ``paths`` with ``app_root`` and drops the (now-ignored) per-equipment + # ``local_root``. cfg = SimpleNamespace( + paths=PathsConfig(app_root="/srv/exlab"), equipment=[ - SimpleNamespace(id="CONFOCAL_01", local_root="/data/lab"), - SimpleNamespace(id="OTHER_EQ", local_root="/data/lab"), + SimpleNamespace(id="CONFOCAL_01"), + SimpleNamespace(id="OTHER_EQ"), ], orchestrator=SimpleNamespace(enabled=True, staging_root="/data/staging"), validator=ValidatorConfig(), @@ -1142,6 +1152,7 @@ def test_validator_from_config_orchestrator_disabled() -> None: from types import SimpleNamespace cfg = SimpleNamespace( + paths=PathsConfig(app_root="/srv/exlab"), equipment=[], orchestrator=SimpleNamespace(enabled=False, staging_root="/data/staging"), validator=None, @@ -1156,6 +1167,7 @@ def test_validator_from_config_no_orchestrator_attr() -> None: from types import SimpleNamespace cfg = SimpleNamespace( + paths=PathsConfig(app_root="/srv/exlab"), equipment=None, # tests the ``or []`` fallback ) v = Validator.from_config(cfg) From c8c33499d508ee12ccb4897599000d1d2b59cf78 Mon Sep 17 00:00:00 2001 From: Alexander Nguyen Date: Tue, 2 Jun 2026 10:24:25 -0700 Subject: [PATCH 3/3] fix(e2e): align full-lifecycle config assertion + regenerate UX doc - test_flow_00_full_lifecycle asserted the derived data root appears in the saved config.yaml, but the config now persists only `app_root` (data/ is derived) -- assert `app_root` instead. Run-location assertions keep using the derived `data_root`. - Regenerate docs/UX_INTERACTIONS.md from the updated ux_catalog.py (single "Data folder" app-root input; per-dir + per-equipment local-root rows gone). Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/UX_INTERACTIONS.md | 6 +----- tests/e2e/test_flow_00_full_lifecycle.py | 4 +++- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/docs/UX_INTERACTIONS.md b/docs/UX_INTERACTIONS.md index 6de42df..0fde0fa 100644 --- a/docs/UX_INTERACTIONS.md +++ b/docs/UX_INTERACTIONS.md @@ -24,9 +24,7 @@ flow test. | Route | Test ID | Element | Action | Outcome | |---|---|---|---|---| | `/settings` | `settings-nav-paths` | nav row | Click the 'Paths' sidebar row | Shows the Paths section (client-side; edits are preserved). | -| `/settings` | `settings-paths-templates` | input | Type the templates directory | Binds config.paths.templates_dir on the draft. | -| `/settings` | `settings-paths-plugin` | input | Type the plugin directory | Binds config.paths.plugin_dir on the draft. | -| `/settings` | `settings-paths-local-root` | input | Type the local data root | Binds config.paths.local_root on the draft. | +| `/settings` | `settings-paths-app-root` | input | Type the data folder (app root) | Binds config.paths.app_root on the draft; templates/, plugins/ and data/ are derived and shown as read-only labels. | | `/settings` | `settings-nav-lims` | nav row | Click the 'LIMS' sidebar row | Shows the LIMS section. | | `/settings` | `settings-lims-endpoint` | input | Type the LIMS endpoint URL | Binds config.lims.endpoint on the draft. | | `/settings` | `settings-lims-email` | input | Type the operator email | Binds config.lims.email on the draft. | @@ -40,7 +38,6 @@ flow test. | `/settings` | `settings-nav-equipment` | nav row | Click the 'Equipment List' sidebar row | Shows the equipment list and the add-equipment sub-form. | | `/settings` | `settings-equipment-id` | input | Type the equipment ID (^[A-Z][A-Z0-9_]*$) | Provides the EquipmentConfig.id for the new entry. | | `/settings` | `settings-equipment-label` | input | Type the equipment label | Provides the EquipmentConfig.label for the new entry. | -| `/settings` | `settings-equipment-local-root` | input | Type the equipment local root | Provides the EquipmentConfig.local_root for the new entry. | | `/settings` | `settings-equipment-nas-root` | input | Type the equipment NAS root | Provides the EquipmentConfig.nas_root for the new entry. | | `/settings` | `settings-equipment-add` | button | Click 'Add equipment' | Validates and appends an EquipmentConfig to the draft; row appears. | @@ -105,7 +102,6 @@ flow test. | `/main` | `toolbar-add-equipment` | button | Click 'Add Equipment' on the main-window toolbar | Navigates to /wizard/equipment for the 5-step Add-Equipment wizard. | | `/wizard/equipment` | `wizard-equipment-id` | input | Type the equipment ID (^[A-Z][A-Z0-9_]*$) | Sets the canonical equipment id used by paths + sync_mode validation. | | `/wizard/equipment` | `wizard-equipment-label` | input | Type the equipment label | Sets the human-readable equipment label. | -| `/wizard/equipment` | `wizard-equipment-local-root` | input | Type the equipment's local root path | Sets where this device acquires runs on disk. | | `/wizard/equipment` | `wizard-equipment-sync-mode` | radio | Pick 'nas' or 'stage' sync mode | Swaps the transport sub-form between NAS-direct and stage-push. | | `/wizard/equipment` | `wizard-equipment-confirm` | button | Click 'Confirm' on the review step | Posts the assembled EquipmentConfig via POST /config/equipment. | | `/wizard/equipment` | `wizard-equipment-cancel` | button | Click 'Cancel' on any wizard step | Discards the wizard and returns to /main. | diff --git a/tests/e2e/test_flow_00_full_lifecycle.py b/tests/e2e/test_flow_00_full_lifecycle.py index eb549ed..3a2a2de 100644 --- a/tests/e2e/test_flow_00_full_lifecycle.py +++ b/tests/e2e/test_flow_00_full_lifecycle.py @@ -257,7 +257,9 @@ def test_full_create_lifecycle(browser, prod_server: ProdServer, tmp_path: Path) config_text = config_path.read_text(encoding="utf-8") assert "MICROSCOPE1" in config_text assert "SPECTROMETER1" in config_text - assert str(data_root) in config_text + # config.yaml persists only the single app root; templates/plugins/data + # are derived from it at runtime. + assert str(app_root) in config_text # ---- Phase 6: verify NAS Remote section is present --------------- # The two nas-mode equipment registered above leave the install in