#!/usr/bin/env python3 # -*- coding: utf-8 -*- """test_ladder_writer.py โ€” the ONLY ladder writer (`upgrade-test.py --write-ladder`) writes what the gate accepts, and refuses what it must. No network (the digest resolver is replaced), no Docker. python3 scripts/test_ladder_writer.py """ import importlib.util import io import json import os import shutil import sys import tempfile import unittest from contextlib import redirect_stdout HERE = os.path.dirname(os.path.abspath(__file__)) ROOT = os.path.dirname(HERE) sys.path.insert(0, HERE) import image_digest # noqa: E402 import ladder # noqa: E402 spec = importlib.util.spec_from_file_location("upgrade_test_mod", os.path.join(HERE, "upgrade-test.py")) ut = importlib.util.module_from_spec(spec) spec.loader.exec_module(ut) D = "sha256:" + "c" * 64 # READ, never typed (R-663): navidrome moved 0.64.0 -> 0.64.1 on 2026-09-23 night and a literal here # broke two of these tests without the writer changing. LIVE = ladder.images_in(open(os.path.join(ROOT, "templates", "navidrome", "docker-compose.yml")).read())["navidrome"].split(":")[1] NEXT = LIVE + "-next" AFTER = LIVE + "-after" def bench(verdict="proven", peak=0.41, marks=(), frm=LIVE, to=NEXT): return {"harness_version": 3, "app": "navidrome", "verdict": verdict, "from": {"navidrome": "deluan/navidrome:" + frm}, "to": {"navidrome": "deluan/navidrome:" + to}, "measured_at": "2026-09-23T22:00:00Z", "marks": list(marks), "memory": {"containers": {"navidrome": {"peak_pct": peak}}}} class WriterTest(unittest.TestCase): def setUp(self): self.tmp = tempfile.mkdtemp(prefix="ladder-writer-") shutil.copytree(os.path.join(ROOT, "templates", "navidrome"), os.path.join(self.tmp, "templates", "navidrome")) self.fy = os.path.join(self.tmp, "templates", "navidrome", ".felhom.yml") # start from a template WITHOUT a ladder, at the live pin text = open(self.fy).read() if "update_ladder:" in text: text = text[:text.index("\n# update_ladder")] + "\n" open(self.fy, "w").write(text) shutil.rmtree(os.path.join(self.tmp, "templates", "navidrome", "steps"), ignore_errors=True) self._orig = image_digest.resolve image_digest.resolve = lambda ref: (D, None) def tearDown(self): image_digest.resolve = self._orig shutil.rmtree(self.tmp, ignore_errors=True) def run_writer(self, b, box_verdict="proven"): bp, xp = os.path.join(self.tmp, "b.json"), os.path.join(self.tmp, "x.json") json.dump(b, open(bp, "w")) json.dump({"verdict": box_verdict, "to": b["to"]}, open(xp, "w")) buf = io.StringIO() with redirect_stdout(buf): rc = ut.write_ladder([bp, "--box", xp, "--catalog", self.tmp, "--evidence", "ev/x/"]) return rc, buf.getvalue() def test_writes_what_the_gate_accepts(self): rc, out = self.run_writer(bench()) self.assertEqual(rc, 0, out) entries, _, errs = ladder.parse(open(self.fy).read()) self.assertEqual(errs, []) self.assertEqual(entries[-1]["to"], {"navidrome": "deluan/navidrome:" + NEXT}) self.assertEqual(entries[-1]["memory_peak_pct"], 41.0) # a PERCENT, from the watch's fraction self.assertEqual(entries[-1]["digest"], {"navidrome": D}) comp = open(os.path.join(self.tmp, "templates", "navidrome", "docker-compose.yml")).read() self.assertEqual(ladder.images_in(comp), {"navidrome": "deluan/navidrome:" + NEXT}) import subprocess r = subprocess.run([sys.executable, os.path.join(HERE, "check-test-record.py"), "--root", self.tmp, "navidrome"], capture_output=True, text=True) self.assertEqual(r.returncode, 0, r.stdout) def test_refuses_a_failed_bench(self): rc, out = self.run_writer(bench(verdict="failed")) self.assertEqual(rc, 1) self.assertNotIn("update_ladder:", open(self.fy).read()) def test_refuses_a_failed_box(self): rc, out = self.run_writer(bench(), box_verdict="inconclusive") self.assertEqual(rc, 1) self.assertNotIn("update_ladder:", open(self.fy).read()) def test_refuses_when_the_template_is_not_at_from(self): rc, out = self.run_writer(bench(frm="0.0.1")) self.assertEqual(rc, 1) self.assertIn("the template is at", out) def test_marks_follow_the_measurement(self): rc, out = self.run_writer(bench(peak=0.86, marks=["memory_tight", "files_may_change"])) self.assertEqual(rc, 0, out) e = ladder.parse(open(self.fy).read())[0][-1] self.assertEqual(e["marks"], {"files_may_change": True, "needs_person": None, "memory_tight": True}) def test_a_second_step_keeps_the_first_steps_definition(self): """`09` ยง6.4 part 5: writing step 2 turns step 1 into an intermediate step โ€” its OWN definition (the compose as it stood, fixes included) is kept at steps/.yml, and the gate accepts the two-step ladder. RED-PROOF (REPORT.md): drop the STEP block in write_ladder โ€” the gate refuses at "has no definition".""" rc, out = self.run_writer(bench()) self.assertEqual(rc, 0, out) comp_p = os.path.join(self.tmp, "templates", "navidrome", "docker-compose.yml") # a fix flows into the step-1 definition after it was published (the romm f4eb94f shape) open(comp_p, "a").write("# a fix that flowed after step 1\n") at_step1 = open(comp_p).read() rc, out = self.run_writer(bench(frm=NEXT, to=AFTER)) self.assertEqual(rc, 0, out) sp = os.path.join(self.tmp, "templates", "navidrome", ladder.step_file({"navidrome": "deluan/navidrome:" + NEXT})) self.assertTrue(os.path.isfile(sp), out) self.assertEqual(open(sp).read(), at_step1, "the step file must be the definition AS SERVED, fixes included") smp = os.path.join(self.tmp, "templates", "navidrome", ladder.step_meta_file({"navidrome": "deluan/navidrome:" + NEXT})) self.assertTrue(os.path.isfile(smp), "the step's own .felhom.yml (R-664) was not written") self.assertNotIn("update_ladder:", open(smp).read()) import subprocess r = subprocess.run([sys.executable, os.path.join(HERE, "check-test-record.py"), "--root", self.tmp, "navidrome"], capture_output=True, text=True) self.assertEqual(r.returncode, 0, r.stdout) if __name__ == "__main__": unittest.main(verbosity=2)