test_skill_update.py 5.1 KB
"""U-018 tests: c_skill_update / c_skill_rollback, real git in a throwaway
temp repo (never graphlemon's own -- see skill_update.py's module docstring
on why repo_path has no default). Sqlite + local git are fast/free, no
mocking needed.

Run: python3 -m unittest src.test_skill_update -v
"""

import subprocess
import tempfile
import unittest

from src import bug_record, schema, skill_update


def _init_git_repo(path):
    subprocess.run(["git", "init"], cwd=path, check=True, capture_output=True)


class SkillUpdateTestBase(unittest.TestCase):
    def setUp(self):
        self.tmp = tempfile.TemporaryDirectory()
        self.addCleanup(self.tmp.cleanup)
        _init_git_repo(self.tmp.name)

        self.conn = schema.connect(":memory:")
        schema.create_schema(self.conn)
        self.conn.execute(
            "INSERT INTO skill (skill_id, owner_org_node, file_path, current_git_commit_sha, source, status) "
            "VALUES ('write-brd', 'planner', 'skills/write-brd', 'unknown', 'migrated_from_hermes', 'active')"
        )
        self.conn.execute(
            "INSERT INTO bug_record (bug_id, source, description, root_cause_category, root_cause_detail, "
            "suggested_route, status) VALUES ('bug-1', 'validator', 'skill produces bad BRDs', 'skill_defect', "
            "'systematic gap', 'fast_lane', 'routed')"
        )
        self.conn.commit()

        skill_update._write_and_commit(self.tmp.name, "skills/write-brd/SKILL.md", "v1 content", "seed skill")

    def tearDown(self):
        self.conn.close()


class UpdateSkillTest(SkillUpdateTestBase):
    def test_happy_path_creates_iteration_and_commits(self):
        result = skill_update.update_skill(
            self.conn, "write-brd", "bug-1", "v2 content", "fix the systematic gap", self.tmp.name
        )
        self.assertTrue(result["iteration_id"])
        self.assertTrue(result["after_git_commit_sha"])

        with open(self.tmp.name + "/skills/write-brd/SKILL.md") as f:
            self.assertEqual(f.read(), "v2 content")

        row = self.conn.execute(
            "SELECT skill_id, triggered_by_bug_id, change_summary FROM skill_iteration WHERE iteration_id = ?",
            (result["iteration_id"],),
        ).fetchone()
        self.assertEqual(row, ("write-brd", "bug-1", "fix the systematic gap"))

        current_sha = self.conn.execute("SELECT current_git_commit_sha FROM skill WHERE skill_id = 'write-brd'").fetchone()[0]
        self.assertEqual(current_sha, result["after_git_commit_sha"])

    def test_fills_in_bug_records_skill_iteration_suggestion_id(self):
        # this is the loop U-015 deferred: skill_defect bug_records start
        # with skill_iteration_suggestion_id=null, filled in once the
        # iteration this bug triggered actually exists
        result = skill_update.update_skill(
            self.conn, "write-brd", "bug-1", "v2 content", "fix it", self.tmp.name
        )
        suggestion_id = self.conn.execute(
            "SELECT skill_iteration_suggestion_id FROM bug_record WHERE bug_id = 'bug-1'"
        ).fetchone()[0]
        self.assertEqual(suggestion_id, result["iteration_id"])

    def test_missing_trigger_ref_rejected(self):
        with self.assertRaises(skill_update.MissingTriggerRefError):
            skill_update.update_skill(self.conn, "write-brd", None, "v2 content", "x", self.tmp.name)
        count = self.conn.execute("SELECT COUNT(*) FROM skill_iteration").fetchone()[0]
        self.assertEqual(count, 0)

    def test_each_iteration_produces_its_own_commit(self):
        skill_update.update_skill(self.conn, "write-brd", "bug-1", "v2", "first fix", self.tmp.name)
        skill_update.update_skill(self.conn, "write-brd", "bug-1", "v3", "second fix", self.tmp.name)
        log = subprocess.run(["git", "log", "--oneline"], cwd=self.tmp.name, capture_output=True, text=True)
        self.assertEqual(len(log.stdout.strip().splitlines()), 3)  # seed + 2 iterations


class RollbackSkillTest(SkillUpdateTestBase):
    def test_rollback_restores_prior_content_as_new_iteration(self):
        first = skill_update.update_skill(self.conn, "write-brd", "bug-1", "v2 broken", "bad fix", self.tmp.name)

        result = skill_update.rollback_skill(self.conn, first["iteration_id"], self.tmp.name)
        self.assertTrue(result["new_iteration_id"])
        self.assertNotEqual(result["new_iteration_id"], first["iteration_id"])

        with open(self.tmp.name + "/skills/write-brd/SKILL.md") as f:
            self.assertEqual(f.read(), "v1 content")

        row = self.conn.execute(
            "SELECT rollback_of_iteration_id FROM skill_iteration WHERE iteration_id = ?",
            (result["new_iteration_id"],),
        ).fetchone()
        self.assertEqual(row[0], first["iteration_id"])

        count = self.conn.execute("SELECT COUNT(*) FROM skill_iteration").fetchone()[0]
        self.assertEqual(count, 2)  # rollback is a new record, not a history rewrite

    def test_nonexistent_iteration_raises_not_found(self):
        with self.assertRaises(skill_update.IterationNotFoundError):
            skill_update.rollback_skill(self.conn, "no-such-iteration", self.tmp.name)


if __name__ == "__main__":
    unittest.main()