#!/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 def bench(verdict="proven", peak=0.41, marks=(), frm="0.64.0", to="0.64.1"): 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 0.64.0 (the live pin tonight) text = open(self.fy).read() if "update_ladder:" in text: text = text[:text.index("\n# update_ladder")] + "\n" open(self.fy, "w").write(text) 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:0.64.1"}) 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:0.64.1"}) 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.63.2")) 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}) if __name__ == "__main__": unittest.main(verbosity=2)