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.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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" }
|
||||
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user