diff --git a/wizard-work/spark/CHECK b/wizard-work/spark/CHECK index 74ef389..86e2339 100755 --- a/wizard-work/spark/CHECK +++ b/wizard-work/spark/CHECK @@ -27,5 +27,13 @@ python3 --version 2>&1 | grep -q "Python" || exit 10 # Check git commit pushed git -C /root/troupe-standup rev-parse ed959f0 >/dev/null 2>&1 || exit 11 +# Q81: the profile-health suite. One cell per graded behaviour (9 cells at 2026-10-26). +# Resolves its own directory from $0, so this leg grades THIS checkout, not a host path. +SPARK_DIR="$(cd "$(dirname "$0")" && pwd)" +suite_out="$(python3 -m unittest discover -s "$SPARK_DIR" -p 'test_hermes_profile_health.py' 2>&1)" +suite_rc=$? +echo "$suite_out" | tail -3 +[ "$suite_rc" -eq 0 ] || exit 12 + echo "VERIFIED" exit 0 \ No newline at end of file diff --git a/wizard-work/spark/test_hermes_profile_health.py b/wizard-work/spark/test_hermes_profile_health.py new file mode 100644 index 0000000..9f14c29 --- /dev/null +++ b/wizard-work/spark/test_hermes_profile_health.py @@ -0,0 +1,142 @@ +#!/usr/bin/env python3 +"""Cells for wizard-work/spark/hermes_profile_health.py (Q81). + +Every cell drives the tool as a subprocess and grades its exit code, so the thing +under test is the CLI contract (0 clean / 1 named faults / 2 ungradable), not an +internal function. Fixtures are built in a fresh temp dir per cell; nothing here +reads or writes a real profile home, and no host path is referenced: the tool is +located from this file's own directory. + +Run: python3 -m unittest discover -s wizard-work/spark -p test_hermes_profile_health.py +""" +import os +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + +TOOL = Path(os.path.dirname(os.path.abspath(__file__))) / "hermes_profile_health.py" +DIRS = ("skills", "plugins", "cron", "memories") +GOOD_CONFIG = "model: deepseek-v4.1-flash\nprovider: deepseek\n" + + +class ProfileHealthTest(unittest.TestCase): + """One cell per graded behaviour; each names the mutant that kills it.""" + + def setUp(self): + self._tmp = tempfile.TemporaryDirectory(prefix="q81cell-") + self.base = Path(self._tmp.name) + self.addCleanup(self._tmp.cleanup) + + # ---- fixture ----------------------------------------------------------- + def make_home(self, name="home", *, dirs=DIRS, dirs_as_files=(), config=GOOD_CONFIG, + config_bytes=None): + home = self.base / name + home.mkdir(parents=True, exist_ok=True) + if config_bytes is not None: + (home / "config.yaml").write_bytes(config_bytes) + elif config is not None: + (home / "config.yaml").write_text(config, encoding="utf-8") + for d in dirs: + if d in dirs_as_files: + (home / d).write_text("", encoding="utf-8") + else: + (home / d).mkdir(exist_ok=True) + return home + + def run_tool(self, *args, env_extra=None, drop_hermes_home=True): + env = dict(os.environ) + env.pop("HERMES_PROFILES_DIR", None) + if drop_hermes_home: + env.pop("HERMES_HOME", None) + env.update(env_extra or {}) + return subprocess.run( + [sys.executable, str(TOOL), *args], + cwd=str(self.base), + env=env, + capture_output=True, + text=True, + ) + + # ---- cells ------------------------------------------------------------- + def test_healthy_profile_exits_0(self): + """Killed by M5 (an extra expected directory nobody has) and M3 (rc 2/1 swap).""" + home = self.make_home() + r = self.run_tool("--profile", str(home)) + self.assertEqual(r.returncode, 0, r.stdout + r.stderr) + self.assertIn("Profile is healthy", r.stdout) + self.assertNotIn("Issues", r.stdout) + + def test_missing_directory_exits_1(self): + """Killed by M4 (the missing-directory branch suppressed).""" + home = self.make_home(dirs=("skills", "plugins", "cron")) + r = self.run_tool("--profile", str(home)) + self.assertEqual(r.returncode, 1, r.stdout + r.stderr) + self.assertIn("memories/ missing", r.stdout) + self.assertNotIn("Profile is healthy", r.stdout) + + def test_absent_profile_dir_exits_2(self): + """Killed by M3 (absent home graded as a fault, rc 1, instead of ungradable).""" + r = self.run_tool("--profile", str(self.base / "does-not-exist")) + self.assertEqual(r.returncode, 2, r.stdout + r.stderr) + self.assertIn("does not exist", r.stderr) + self.assertEqual(r.stdout.strip(), "", "rc 2 must not print a verdict") + + def test_skills_as_regular_file_exits_1(self): + """Killed by M1 (is_dir() weakened back to exists()).""" + home = self.make_home(dirs_as_files=("skills",)) + r = self.run_tool("--profile", str(home)) + self.assertEqual(r.returncode, 1, r.stdout + r.stderr) + self.assertIn("skills exists but is not a directory", r.stdout) + + def test_empty_config_exits_1(self): + """Killed by M2 (the empty-config grade removed).""" + home = self.make_home(config="") + r = self.run_tool("--profile", str(home)) + self.assertEqual(r.returncode, 1, r.stdout + r.stderr) + self.assertIn("config.yaml is empty", r.stdout) + + def test_invalid_yaml_exits_1(self): + """Killed by M2 (empty-only) and M6 (config graded by exists() alone).""" + home = self.make_home(config="model: [unclosed\n - :\n\t:bad\n") + r = self.run_tool("--profile", str(home)) + self.assertEqual(r.returncode, 1, r.stdout + r.stderr) + self.assertIn("not valid YAML", r.stdout) + + def test_config_not_a_mapping_exits_1(self): + """Killed by M6 (no parse, so a list config reads healthy).""" + home = self.make_home(config="- just\n- a\n- list\n") + r = self.run_tool("--profile", str(home)) + self.assertEqual(r.returncode, 1, r.stdout + r.stderr) + self.assertIn("not a mapping", r.stdout) + + def test_no_profile_names_nothing_exits_2(self): + """Killed by M7 (an implicit default home invented instead of refusing).""" + r = self.run_tool() + self.assertEqual(r.returncode, 2, r.stdout + r.stderr) + self.assertIn("ungradable", r.stderr) + # the refusal must name the missing input, not a missing directory: an invented + # default home also exits 2 on this host, and that mutant must not read clean here. + self.assertIn("no profile named", r.stderr) + self.assertEqual(r.stdout.strip(), "", "rc 2 must not print a verdict") + + def test_profile_name_resolves_under_root(self): + """Killed by M8 (a bare NAME taken as a relative path).""" + root = self.base / "profiles" + home = root / "volt" + home.mkdir(parents=True) + (home / "config.yaml").write_text(GOOD_CONFIG, encoding="utf-8") + for d in DIRS: + (home / d).mkdir() + r = self.run_tool("--profile", "volt", "--profiles-root", str(root)) + self.assertEqual(r.returncode, 0, r.stdout + r.stderr) + self.assertIn(str(home), r.stdout) + # and HERMES_HOME alone, with no --profile, reaches the same home + r2 = self.run_tool(env_extra={"HERMES_HOME": str(home)}, drop_hermes_home=False) + self.assertEqual(r2.returncode, 0, r2.stdout + r2.stderr) + self.assertIn(str(home), r2.stdout) + + +if __name__ == "__main__": + unittest.main(verbosity=2)