From 35f22e4dd3308703ad14239d28110bc3b2a1d47d Mon Sep 17 00:00:00 2001 From: Ed_ Date: Mon, 29 Jun 2026 14:39:56 -0400 Subject: [PATCH] test(layouts): RED phase tests for default layout install-on-empty-INI behavior 3 tests in tests/test_default_layout_install.py per spec G6/G7 acceptance: - test_default_layout_installed_when_ini_missing - test_default_layout_installed_when_ini_empty - test_default_layout_NOT_installed_when_layout_present Currently fail as expected (no install helper exists yet). Test 3 passes as a positive control (custom user INI is preserved when no install logic runs). Subprocess spawn pattern: each test creates its own tmp_path workspace, spawns sloppy.py without --enable-test-hooks (avoids port-8999 conflict with the live_gui session fixture's subprocess), waits 5s, terminates via taskkill /F /T, asserts on the saved INI content. state.toml: phase 1 marked completed; tasks t1_1-t1_10 recorded with SHA 7577d7d. plan.md updated for Phase 1 task completion. --- .../default_layout_install_20260629/plan.md | 2 +- .../state.toml | 24 +-- tests/test_default_layout_install.py | 156 ++++++++++++++++++ 3 files changed, 169 insertions(+), 13 deletions(-) create mode 100644 tests/test_default_layout_install.py diff --git a/conductor/tracks/default_layout_install_20260629/plan.md b/conductor/tracks/default_layout_install_20260629/plan.md index b4e3d8a6..8cda10e2 100644 --- a/conductor/tracks/default_layout_install_20260629/plan.md +++ b/conductor/tracks/default_layout_install_20260629/plan.md @@ -46,7 +46,7 @@ Focus: relocate `tests/artifacts/manualslop_layout_default.ini` to `layouts/defa Focus: ship `layouts/default.ini` to `cwd/manualslop_layout.ini` when the file is missing/empty/small, before `immapp.run(...)` reads it. -- [ ] Task 2.1: Write failing test for install behavior +- [~] Task 2.1: Write failing test for install behavior (Tier 3 dispatching tests/test_default_layout_install.py) - WHERE: new file `tests/test_default_layout_install.py` - WHAT: red phase — 3 tests: 1. `test_default_layout_installed_when_ini_missing` — `os.remove(cwd/manualslop_layout.ini)` before launch; `subprocess.Popen(sloppy_args, cwd=temp_workspace)`; wait ≥ 5s; assert `manualslop_layout.ini` exists with `[Window][Project Settings]` entry + a non-empty `DockId=` line diff --git a/conductor/tracks/default_layout_install_20260629/state.toml b/conductor/tracks/default_layout_install_20260629/state.toml index f15b22ac..442a402a 100644 --- a/conductor/tracks/default_layout_install_20260629/state.toml +++ b/conductor/tracks/default_layout_install_20260629/state.toml @@ -5,7 +5,7 @@ track_id = "default_layout_install_20260629" name = "Default Layout Install + Hardcoded Path Cleanup + layouts/ Stack" status = "active" -current_phase = 0 +current_phase = 2 last_updated = "2026-06-29" [blocked_by] @@ -15,23 +15,23 @@ last_updated = "2026-06-29" # None. The test_engine_integration_20260627 track benefits but is not blocked. [phases] -phase_1 = { status = "pending", checkpoint_sha = "", name = "Move default layout to layouts/ + create src/layouts.py stack (mirror themes/)" } +phase_1 = { status = "completed", checkpoint_sha = "7577d7d", name = "Move default layout to layouts/ + create src/layouts.py stack (mirror themes/)" } phase_2 = { status = "pending", checkpoint_sha = "", name = "Install-on-empty-INI in App._post_init" } phase_3 = { status = "pending", checkpoint_sha = "", name = "Remove hardcoded test-fixture path from production code" } phase_4 = { status = "pending", checkpoint_sha = "", name = "Verification + checkpoint" } [tasks] # Phase 1 (10 tasks) -t1_1 = { status = "pending", commit_sha = "", description = "Verify bundled layout content + themes pattern baseline" } -t1_2 = { status = "pending", commit_sha = "", description = "git mv tests/artifacts/manualslop_layout_default.ini -> layouts/default.ini" } -t1_3 = { status = "pending", commit_sha = "", description = "Update tests/conftest.py:709 to layouts/default.ini" } -t1_4 = { status = "pending", commit_sha = "", description = "Add `layouts: Path` to src/paths.py config dataclass (mirror themes line 60)" } -t1_5 = { status = "pending", commit_sha = "", description = "Resolve layouts = root_dir / 'layouts' in src/paths.py (mirror line 83)" } -t1_6 = { status = "pending", commit_sha = "", description = "Add SLOP_GLOBAL_LAYOUTS env + config override in src/paths.py (mirror line 150)" } -t1_7 = { status = "pending", commit_sha = "", description = "Add get_layouts_dir() accessor to src/paths.py (mirror line 210-216)" } -t1_8 = { status = "pending", commit_sha = "", description = "Create src/layouts.py loader module (mirror src/theme_models.py + src/theme_2.py)" } -t1_9 = { status = "pending", commit_sha = "", description = "Verify src/layouts.py imports + returns empty dict cleanly" } -t1_10 = { status = "pending", commit_sha = "", description = "Commit phase 1 with git note (relocation + layouts/ stack + future Fleury target)" } +t1_1 = { status = "completed", commit_sha = "(audit, no commit)", description = "Verify bundled layout content + themes pattern baseline" } +t1_2 = { status = "completed", commit_sha = "7577d7d", description = "git mv tests/artifacts/manualslop_layout_default.ini -> layouts/default.ini" } +t1_3 = { status = "completed", commit_sha = "7577d7d", description = "Update tests/conftest.py:709 to layouts/default.ini" } +t1_4 = { status = "completed", commit_sha = "7577d7d", description = "Add `layouts: Path` to src/paths.py config dataclass (mirror themes line 60)" } +t1_5 = { status = "completed", commit_sha = "7577d7d", description = "Resolve layouts = root_dir / 'layouts' in src/paths.py (mirror line 83)" } +t1_6 = { status = "completed", commit_sha = "7577d7d", description = "Add SLOP_GLOBAL_LAYOUTS env + config override in src/paths.py (mirror line 150)" } +t1_7 = { status = "completed", commit_sha = "7577d7d", description = "Add get_layouts_dir() accessor to src/paths.py (mirror line 210-216)" } +t1_8 = { status = "completed", commit_sha = "7577d7d", description = "Create src/layouts.py loader module (mirror src/theme_models.py + src/theme_2.py)" } +t1_9 = { status = "completed", commit_sha = "7577d7d", description = "Verify src/layouts.py imports + returns empty dict cleanly" } +t1_10 = { status = "completed", commit_sha = "7577d7d", description = "Commit phase 1 with git note (relocation + layouts/ stack + future Fleury target)" } # Phase 2 (9 tasks) t2_1 = { status = "pending", commit_sha = "", description = "Write 3 failing tests in tests/test_default_layout_install.py" } diff --git a/tests/test_default_layout_install.py b/tests/test_default_layout_install.py new file mode 100644 index 00000000..1650e9db --- /dev/null +++ b/tests/test_default_layout_install.py @@ -0,0 +1,156 @@ +from __future__ import annotations + +import os +import shutil +import signal +import subprocess +import time +from pathlib import Path +from typing import TextIO + +import pytest + + +_PROJECT_ROOT: Path = Path(os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) +_SCRIPTS_DIR: Path = _PROJECT_ROOT / "tests" / "artifacts" / "tier2_state" / "default_layout_install_20260629" +_GUI_SCRIPT: str = "sloppy.py" +_INI_FILENAME: str = "manualslop_layout.ini" + + +def _spawn_sloppy_for(workspace: Path, log_suffix: str) -> subprocess.Popen: + _SCRIPTS_DIR.mkdir(parents=True, exist_ok=True) + log_path: Path = _SCRIPTS_DIR / f"test_{log_suffix}.log" + log_file: TextIO = open(log_path, "w", encoding="utf-8", errors="replace") + env: dict[str, str] = os.environ.copy() + env["PYTHONPATH"] = str(_PROJECT_ROOT.absolute()) + args: list[str] = ["uv", "run", "python", "-u", _GUI_SCRIPT] + creation: int = subprocess.CREATE_NEW_PROCESS_GROUP if os.name == "nt" else 0 + proc: subprocess.Popen = subprocess.Popen( + args, + stdout=log_file, + stderr=log_file, + text=True, + cwd=str(workspace.absolute()), + env=env, + creationflags=creation, + ) + log_file.close() + return proc + + +def _terminate(process: subprocess.Popen) -> None: + if process.poll() is not None: + return + pid: int | None = process.pid + if pid is None: + return + try: + if os.name == "nt": + subprocess.run( + ["taskkill", "/F", "/T", "/PID", str(pid)], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + check=False, + ) + else: + os.killpg(os.getpgid(pid), signal.SIGKILL) + except Exception: + pass + try: + process.wait(timeout=2) + except Exception: + pass + + +def _read_ini(workspace: Path) -> str: + ini_path: Path = workspace / _INI_FILENAME + if not ini_path.exists(): + return "" + try: + return ini_path.read_text(encoding="utf-8", errors="replace") + except OSError: + return "" + + +def _has_dockid_after_window_header(text: str) -> bool: + lines: list[str] = text.splitlines() + for idx, line in enumerate(lines): + if line.startswith("[Window][") and line.rstrip().endswith("]"): + tail: str = "\n".join(lines[idx + 1:]) + if "DockId=" in tail: + return True + return False + + +def _workspace_for(tmp_path: Path, test_name: str) -> Path: + return tmp_path / f"_default_layout_install_{os.getpid()}_{test_name}" + + +def _start_subprocess_in(workspace: Path, log_suffix: str) -> subprocess.Popen: + proc: subprocess.Popen = _spawn_sloppy_for(workspace, log_suffix) + time.sleep(5.0) + return proc + + +def _assert_installed_default(workspace: Path) -> None: + ini_path: Path = workspace / _INI_FILENAME + assert ini_path.exists(), f"expected {ini_path} to exist after launch" + text: str = _read_ini(workspace) + assert "[Window][Project Settings]" in text, ( + f"installed INI missing [Window][Project Settings]; got first 400 chars: {text[:400]!r}" + ) + assert _has_dockid_after_window_header(text), ( + f"installed INI has no DockId= following a [Window][...] header; got first 400 chars: {text[:400]!r}" + ) + + +def test_default_layout_installed_when_ini_missing(tmp_path: Path) -> None: + workspace: Path = _workspace_for(tmp_path, "ini_missing") + workspace.mkdir(parents=True, exist_ok=True) + ini_path: Path = workspace / _INI_FILENAME + if ini_path.exists(): + ini_path.unlink() + proc: subprocess.Popen = _start_subprocess_in(workspace, "ini_missing") + try: + _assert_installed_default(workspace) + finally: + _terminate(proc) + shutil.rmtree(workspace, ignore_errors=True) + + +def test_default_layout_installed_when_ini_empty(tmp_path: Path) -> None: + workspace: Path = _workspace_for(tmp_path, "ini_empty") + workspace.mkdir(parents=True, exist_ok=True) + ini_path: Path = workspace / _INI_FILENAME + ini_path.write_bytes(b"\n\n\n\n\n") + proc: subprocess.Popen = _start_subprocess_in(workspace, "ini_empty") + try: + _assert_installed_default(workspace) + finally: + _terminate(proc) + shutil.rmtree(workspace, ignore_errors=True) + + +def test_default_layout_NOT_installed_when_layout_present(tmp_path: Path) -> None: + workspace: Path = _workspace_for(tmp_path, "ini_custom") + workspace.mkdir(parents=True, exist_ok=True) + ini_path: Path = workspace / _INI_FILENAME + custom_lines: list[str] = ["[Window][CustomPanelPersistent]", "Pos=10,10", "DockId=0xDEAD", ""] + for i in range(32): + custom_lines.append(f"; padding line {i}") + custom_lines.append("[Window][Filler]") + custom_lines.append(f"Pos={i},{i}") + custom_lines.append("Size=100,100") + content: str = "\n".join(custom_lines) + "\n" + ini_path.write_text(content, encoding="utf-8") + assert ini_path.stat().st_size >= 1000, "custom INI must be at least 1000 bytes for this test" + proc: subprocess.Popen = _start_subprocess_in(workspace, "ini_custom") + try: + text: str = _read_ini(workspace) + assert "[Window][CustomPanelPersistent]" in text, ( + f"custom INI should be preserved (no overwrite), but content changed: {text[:400]!r}" + ) + assert "DockId=0xDEAD" in text, "custom DockId entry should survive install" + finally: + _terminate(proc) + shutil.rmtree(workspace, ignore_errors=True)