import contextlib import io import json import os import subprocess import sys import tempfile import unittest from datetime import date from pathlib import Path from unittest.mock import patch sys.path.insert(0, str(Path(__file__).resolve().parents[1])) import progress import progress_history OPCODES = Path("core/shader/recompiler/RdnaDecoder/include/RdnaDecoder/RdnaOpcode.hpp") def commit(root, day, message, body, time="12:00:00Z"): library = root / "core/libs/prx/libSceExample" library.mkdir(parents=True, exist_ok=True) (library / "Export.cpp").write_text(body, encoding="utf-8") (root / OPCODES).parent.mkdir(parents=True, exist_ok=True) (root / OPCODES).write_text("enum class RdnaOpcode : int {\n SEndpgm,\n};\n", encoding="utf-8") env = {**os.environ, "GIT_AUTHOR_DATE": f"{day}T{time}", "GIT_COMMITTER_DATE": f"{day}T{time}"} subprocess.run(["git", "-C", str(root), "add", "-A"], check=True) subprocess.run(["git", "-C", str(root), "-c", "user.name=t", "-c", "user.email=t@t", "commit", "-q", "-m", message], check=True, env=env) class HistoryReportOutputTests(unittest.TestCase): def test_report_output_errors_are_usage_errors_and_allow_retry(self): with tempfile.TemporaryDirectory() as directory: root = Path(directory) subprocess.run(["git", "-C", directory, "init", "-q"], check=True) commit(root, "2026-01-01", "fixture", "int APS5_VABI A() { return 0; }\n") with patch.multiple(progress, ROOT=root, PRX=root / "core/libs/prx", OPCODES=root / OPCODES): for index, name in enumerate((None, "history.json", "history.svg", "history.html")): with self.subTest(name=name): output = root / f"reports-{index}" if name is None: output.write_bytes(b"preserve original") blocked = output else: output.mkdir() blocked = output / name blocked.mkdir() stdout, stderr = io.StringIO(), io.StringIO() with contextlib.redirect_stdout(stdout), contextlib.redirect_stderr(stderr), self.assertRaises(SystemExit) as raised: progress_history.main([str(output), "--cache", str(root / "counts.json")]) self.assertEqual(raised.exception.code, 2) self.assertIn(str(blocked), stderr.getvalue()) self.assertEqual(stdout.getvalue(), "") self.assertNotIn("Traceback", stderr.getvalue()) if name is None: self.assertEqual(blocked.read_bytes(), b"preserve original") blocked.unlink() else: blocked.rmdir() with contextlib.redirect_stdout(stdout): progress_history.main([str(output), "--cache", str(root / "counts.json")]) data = json.loads((output / "history.json").read_text(encoding="utf-8")) self.assertEqual(data[0]["libraries"]["done"], 1) for filename in ("history.json", "history.svg", "history.html"): self.assertGreater((output / filename).stat().st_size, 0) class ProgressHistoryTests(unittest.TestCase): def test_cache_reuses_immutable_commits_and_invalidates_changed_rules(self): with tempfile.TemporaryDirectory() as directory: root = Path(directory) subprocess.run(["git", "-C", directory, "init", "-q"], check=True) commit(root, "2026-01-01", "first", "int APS5_VABI A() { return 0; }\n") cache = root / "cache" / "history.json" isa = root / "isa.txt" isa.write_bytes(progress.ISA.read_bytes()) with patch.multiple(progress, ROOT=root, PRX=root / "core/libs/prx", OPCODES=root / OPCODES, ISA=isa): with patch.object(progress_history, "measure", wraps=progress_history.measure) as measure: first = progress_history.collect("HEAD", cache) self.assertEqual(measure.call_count, 1) before = cache.read_bytes() measure.reset_mock() self.assertEqual(progress_history.collect("HEAD", cache), first) measure.assert_not_called() self.assertEqual(cache.read_bytes(), before) commit(root, "2026-01-02", "second", "int APS5_VABI A() { return 0; }\nint APS5_VABI B() { return 0; }\n") measure.reset_mock() second = progress_history.collect("HEAD", cache) self.assertEqual(measure.call_count, 1) self.assertEqual(second[0], first[0]) isa.write_bytes(isa.read_bytes() + b"\n# changed rules input\n") measure.reset_mock() self.assertEqual(progress_history.collect("HEAD", cache), second) self.assertEqual(measure.call_count, 2) uncached = progress_history.collect("HEAD") self.assertEqual(uncached, second) self.assertEqual(progress_history.page(uncached), progress_history.page(second)) self.assertFalse(any(path.name.startswith(".history-") for path in cache.parent.iterdir())) def test_invalid_cache_is_not_overwritten(self): import json with tempfile.TemporaryDirectory() as directory: cache = Path(directory) / "history.json" for contents in (b"{", b"[]", json.dumps({"schema_version": 1, "rules": progress_history.cache_signature(), "commits": {"a" * 40: {"libraries": {"done": True, "total": 1, "percent": 100}, "shaders": {"done": 0, "total": 0, "percent": 0}}}}).encode()): with self.subTest(contents=contents): cache.write_bytes(contents) with patch.object(progress_history, "measure", side_effect=AssertionError("measurement started")), \ self.assertRaises(ValueError): progress_history.collect("HEAD", cache) self.assertEqual(cache.read_bytes(), contents) def test_failed_cache_publication_preserves_the_old_file(self): with tempfile.TemporaryDirectory() as directory: cache = Path(directory) / "history.json" cache.write_bytes(b"previous cache") with patch.object(progress_history.os, "replace", side_effect=OSError("cannot publish")), \ self.assertRaisesRegex(OSError, "cannot publish"): progress_history.save_cache(cache, "rules", {}) self.assertEqual(cache.read_bytes(), b"previous cache") self.assertEqual(list(cache.parent.iterdir()), [cache]) def test_one_point_per_day_at_the_last_commit(self): stub = "static void Stub() { NotImplemented_nid_no_patch(__func__); }\n" with tempfile.TemporaryDirectory() as directory: root = Path(directory) subprocess.run(["git", "-C", directory, "init", "-q"], check=True) commit(root, "2026-01-01", "first", "int APS5_VABI A() { Stub(); return 0; }\n" + stub) commit(root, "2026-01-02", "second", "int APS5_VABI A() { return 0; }\nint APS5_VABI B() { return 0; }\n") commit(root, "2026-01-02", "third", "int APS5_VABI A() { return 0; }\nint APS5_VABI B() { Stub(); return 0; }\n" + stub) with patch.multiple(progress, ROOT=root, PRX=root / "core/libs/prx", OPCODES=root / OPCODES): points = progress_history.collect("HEAD") html = progress_history.page(points) self.assertEqual([p["date"] for p in points], ["2026-01-01", "2026-01-02"]) self.assertEqual([(p["libraries"]["done"], p["libraries"]["total"]) for p in points], [(0, 1), (1, 2)]) self.assertIn("{label}<", svg) def test_main_writes_utf8_output_files(self): point = {"date": "2026-01-01", "commit": "abc1234", "libraries": {"done": 1, "total": 2, "percent": 50.0}, "shaders": {"done": 3, "total": 4, "percent": 75.0}} with tempfile.TemporaryDirectory() as directory: output = Path(directory) / "site" with patch.object(progress_history, "collect", return_value=[point]): progress_history.main([str(output)]) svg = (output / "history.svg").read_text(encoding="utf-8") self.assertIn("\u25cf", svg) html = (output / "history.html").read_text(encoding="utf-8") self.assertIn("", html) data = json.loads((output / "history.json").read_text(encoding="utf-8")) self.assertEqual(data, [point]) def test_main_rejects_aliased_output_reports(self): point = {"date": "2026-01-01", "commit": "abc1234", "libraries": {"done": 1, "total": 2, "percent": 50.0}, "shaders": {"done": 3, "total": 4, "percent": 75.0}} with tempfile.TemporaryDirectory() as directory: output = Path(directory) / "site" output.mkdir(parents=True) report = output / "history.json" alias = output / "history.html" report.write_text("existing json", encoding="utf-8") try: os.link(report, alias) except OSError: self.skipTest("hard links unavailable") errors = io.StringIO() with patch.object(progress_history, "collect", return_value=[point]): with contextlib.redirect_stderr(errors), self.assertRaises(SystemExit) as error: progress_history.main([str(output)]) self.assertEqual(error.exception.code, 2) self.assertEqual(report.read_text(encoding="utf-8"), "existing json") self.assertEqual(alias.read_text(encoding="utf-8"), "existing json") if __name__ == "__main__": unittest.main()