Files
AnyPS5/tools/tests/test_progress_history.py
T
Dean 31a50b73d6 Merge pull request #11527 from StoneDC/fix/history-report-output-errors
fix(tools): report history output failures as usage errors
2026-10-27 19:04:07 +13:00

214 lines
12 KiB
Python

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("<svg", html)
self.assertIn("2026-01-02", html)
def test_unknown_ref_is_reported_with_git_reason(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")
with patch.multiple(progress, ROOT=root, PRX=root / "core/libs/prx", OPCODES=root / OPCODES), \
patch.dict(os.environ, {"LC_ALL": "C"}):
with self.assertRaises(ValueError) as raised:
progress_history.daily_commits("no-such-ref")
self.assertIn("no-such-ref", str(raised.exception))
self.assertIn("unknown revision", str(raised.exception))
def test_days_are_utc_whatever_the_committer_offset(self):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
subprocess.run(["git", "-C", directory, "init", "-q"], check=True)
commit(root, "2026-01-02", "older", "int APS5_VABI A() { return 0; }\n", "08:00:00+13:00")
commit(root, "2026-01-01", "newer", "int APS5_VABI A() { return 0; }\nint APS5_VABI B() { return 0; }\n",
"21:30:00+02:00")
with patch.multiple(progress, ROOT=root, PRX=root / "core/libs/prx", OPCODES=root / OPCODES):
points = progress_history.collect("HEAD")
self.assertEqual([(p["date"], p["libraries"]["total"]) for p in points], [("2026-01-01", 2)])
def test_long_series_keeps_the_chart_readable(self):
def series(count):
start = date(2026, 1, 1).toordinal()
return [{"date": date.fromordinal(start + i).isoformat(),
**{name: {"done": i, "total": count, "percent": 100 * i / count} for name in ("libraries", "shaders")}}
for i in range(count)]
short, medium, long = (progress_history.chart(series(count)) for count in (20, 100, 400))
week = progress_history.chart(series(10))
self.assertEqual((week.count('class="x"'), week.count("<circle")), (10, 20))
self.assertEqual((short.count('class="x"'), short.count("<circle")), (3, 40))
self.assertEqual((medium.count('class="x"'), medium.count("<circle")), (14, 0))
self.assertEqual((long.count('class="x"'), long.count("<circle")), (14, 0))
def test_each_panel_shows_its_declared_total(self):
point = {"date": "2026-01-01", "libraries": {"done": 3292, "total": 3706, "percent": 88.83},
"shaders": {"done": 1166, "total": 1166, "percent": 100.0}}
svg = progress_history.chart([point, {**point, "date": "2026-01-02"}])
self.assertIn("Declared 3,706", svg)
self.assertIn("Declared 1,166", svg)
self.assertIn("Implemented 1,166 (100%)", svg)
def test_axis_labels_fall_on_calendar_days_without_commits(self):
start = date(2026, 8, 3).toordinal()
skipped = {date(2026, 8, 10), date(2026, 8, 17), date(2026, 8, 31)}
points = [{"date": day.isoformat(),
**{name: {"done": i, "total": 70, "percent": 100 * i / 70} for name in ("libraries", "shaders")}}
for i, day in enumerate(date.fromordinal(start + i) for i in range(70)) if day not in skipped]
svg = progress_history.chart(points)
for label in ("Aug 3", "Aug 10", "Aug 17", "Aug 24", "Aug 31"):
self.assertIn(f">{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("<!DOCTYPE html>", html)
data = json.loads((output / "history.json").read_text(encoding="utf-8"))
self.assertEqual(data, [point])
if __name__ == "__main__":
unittest.main()