mirror of
https://github.com/MobileGL-Dev/MobileGL
synced 2026-11-07 01:53:24 +09:00
407 lines
20 KiB
Python
407 lines
20 KiB
Python
#!/usr/bin/env python3
|
|
# MobileGL - tools/trace_replay/test_compare_actuals.py
|
|
# Copyright (c) 2025-2026 MobileGL-Dev
|
|
# Licensed under the GNU Lesser General Public License v3.0:
|
|
# https://www.gnu.org/licenses/gpl-3.0.txt
|
|
# https://www.gnu.org/licenses/lgpl-3.0.txt
|
|
# SPDX-License-Identifier: LGPL-3.0-only
|
|
# End of Source File Header
|
|
|
|
"""Synthetic-only regressions for the actual-vs-actual SSIM and the archive summary.
|
|
|
|
No device, no PNG the retrace produced and no golden: every raster here is written by the test
|
|
so that each expected SSIM is a closed form (see compare_actuals.self_test for the derivations).
|
|
The summary half runs on a fabricated --archive-dir tree, because the failure it has to catch -
|
|
"three repeats were called identical while one of them had no actual image at all" - is a shape
|
|
of the DIRECTORY, not of the images.
|
|
"""
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import unittest
|
|
from unittest import mock
|
|
|
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
import compare_actuals as ca
|
|
import run_android_retrace_local as retrace
|
|
import trace_cases
|
|
|
|
|
|
# The two closed forms the self-test derives. Written out here as literals rather than
|
|
# recomputed from C1/C2, so a typo in the constants cannot agree with itself.
|
|
BLACK_VS_WHITE = 9.999000099990002e-05
|
|
INVERTED_HALVES = -0.9964064683569576
|
|
|
|
|
|
def solid(width, height, value, alpha=255):
|
|
return bytes([value, value, value, alpha] * (width * height))
|
|
|
|
|
|
def write_png(path, width, height, rgba):
|
|
Path(path).write_bytes(ca._png_bytes(width, height, rgba))
|
|
|
|
|
|
class Ssim(unittest.TestCase):
|
|
def setUp(self):
|
|
self.temp = tempfile.TemporaryDirectory(prefix="compare-actuals-")
|
|
self.root = Path(self.temp.name)
|
|
|
|
def tearDown(self):
|
|
self.temp.cleanup()
|
|
|
|
def test_known_pairs(self):
|
|
black, white = self.root / "a.png", self.root / "b.png"
|
|
write_png(black, 8, 4, solid(8, 4, 0))
|
|
write_png(white, 8, 4, solid(8, 4, 255))
|
|
self.assertEqual(ca.compare_files(black, black)["ssim"], 1.0)
|
|
self.assertAlmostEqual(ca.compare_files(black, white)["ssim"], BLACK_VS_WHITE, places=15)
|
|
self.assertEqual(ca.compare_files(black, white)["mismatch_pixels"], 32)
|
|
|
|
def test_alpha_is_ignored_and_rgb_is_averaged(self):
|
|
"""Alpha must not enter the score: the gate averages three channels, not four."""
|
|
opaque, transparent = self.root / "o.png", self.root / "t.png"
|
|
write_png(opaque, 4, 4, solid(4, 4, 200, alpha=255))
|
|
write_png(transparent, 4, 4, solid(4, 4, 200, alpha=0))
|
|
self.assertEqual(ca.compare_files(opaque, transparent)["ssim"], 1.0)
|
|
self.assertEqual(ca.compare_files(opaque, transparent)["mismatch_pixels"], 0)
|
|
|
|
def test_crop_selects_the_window_and_a_size_mismatch_is_refused(self):
|
|
halves, inverse = self.root / "h.png", self.root / "i.png"
|
|
left, right = bytearray(), bytearray()
|
|
for _ in range(4):
|
|
for x in range(8):
|
|
value = 0 if x < 4 else 255
|
|
left += bytes([value, value, value, 255])
|
|
right += bytes([255 - value] * 3 + [255])
|
|
write_png(halves, 8, 4, bytes(left))
|
|
write_png(inverse, 8, 4, bytes(right))
|
|
self.assertAlmostEqual(ca.compare_files(halves, inverse)["ssim"], INVERTED_HALVES, places=15)
|
|
self.assertAlmostEqual(ca.compare_files(halves, inverse, 0, 0, 4, 4)["ssim"],
|
|
BLACK_VS_WHITE, places=15)
|
|
small = self.root / "s.png"
|
|
write_png(small, 4, 4, solid(4, 4, 0))
|
|
with self.assertRaises(ca.ImageError):
|
|
ca.compare_files(halves, small)
|
|
|
|
def test_eight_and_sixteen_bit_and_rgb_decode_to_the_same_raster(self):
|
|
"""The three encodings the fixtures and the device actually produce agree."""
|
|
def chunk(kind, body):
|
|
import zlib
|
|
return (len(body).to_bytes(4, "big") + kind + body
|
|
+ zlib.crc32(kind + body).to_bytes(4, "big"))
|
|
|
|
def encode(path, width, height, depth, color_type, samples):
|
|
import zlib
|
|
raw = bytearray()
|
|
per_row = len(samples) // height
|
|
for y in range(height):
|
|
raw.append(0)
|
|
raw += samples[y * per_row:(y + 1) * per_row]
|
|
Path(path).write_bytes(
|
|
ca._PNG_MAGIC
|
|
+ chunk(b"IHDR", width.to_bytes(4, "big") + height.to_bytes(4, "big")
|
|
+ bytes([depth, color_type, 0, 0, 0]))
|
|
+ chunk(b"IDAT", zlib.compress(bytes(raw)))
|
|
+ chunk(b"IEND", b""))
|
|
|
|
rgba = self.root / "rgba.png"
|
|
rgb = self.root / "rgb.png"
|
|
rgba16 = self.root / "rgba16.png"
|
|
write_png(rgba, 2, 2, solid(2, 2, 96))
|
|
encode(rgb, 2, 2, 8, 2, bytes([96, 96, 96] * 4))
|
|
# 16-bit big-endian samples; png_set_strip_16 keeps the high byte, so RGB reads 96
|
|
# and alpha reads 255 - the same raster the 8-bit files above carry.
|
|
encode(rgba16, 2, 2, 16, 6, bytes([96, 0, 96, 0, 96, 0, 255, 0] * 4))
|
|
first = ca.read_png_rgba(rgba)
|
|
for other in (rgb, rgba16):
|
|
decoded = ca.read_png_rgba(other)
|
|
self.assertEqual((decoded.width, decoded.height), (first.width, first.height))
|
|
self.assertEqual(decoded.pixels, first.pixels, other.name)
|
|
|
|
|
|
class Summary(unittest.TestCase):
|
|
def setUp(self):
|
|
self.temp = tempfile.TemporaryDirectory(prefix="compare-actuals-summary-")
|
|
self.root = Path(self.temp.name)
|
|
|
|
def tearDown(self):
|
|
self.temp.cleanup()
|
|
|
|
def repeat(self, arm, index, rgba=None, ssim=1.0, passed=True, crop=(0, 0, 0, 0),
|
|
write_result=True, write_actual=True):
|
|
directory = self.root / arm / ("repeat-%02d" % index)
|
|
directory.mkdir(parents=True)
|
|
if write_actual:
|
|
write_png(directory / (arm + "-actual.png"), 8, 4,
|
|
rgba if rgba is not None else solid(8, 4, 64))
|
|
if write_result:
|
|
(directory / "result.json").write_text(json.dumps({
|
|
"passed": passed, "statusCode": 0 if passed else 1, "backend": "DirectVulkan",
|
|
"ssim": ssim, "ssimThreshold": 0.99, "matchedGoldenPath": "/x/y-golden.png",
|
|
"mismatchPixels": 0, "cropX": crop[0], "cropY": crop[1],
|
|
"cropWidth": crop[2], "cropHeight": crop[3]}), encoding="utf-8")
|
|
return directory
|
|
|
|
def test_identical_repeats_are_reported_bitwise_identical(self):
|
|
arm = "Example-DirectVulkan"
|
|
for index in (1, 2, 3):
|
|
self.repeat(arm, index)
|
|
report = ca.summarize(self.root)
|
|
self.assertEqual([row["repeat"] for row in report["rows"]], [1, 2, 3])
|
|
self.assertTrue(all(row["identical_to_first"] for row in report["rows"]))
|
|
self.assertTrue(all(row["ssim_vs_first"] == 1.0 for row in report["rows"]))
|
|
self.assertEqual(report["arms"][0]["all_bitwise_identical"], True)
|
|
self.assertEqual(report["arms"][0]["min_ssim_vs_golden"], 1.0)
|
|
|
|
def test_a_diverging_repeat_is_not_hidden_by_an_equal_ssim_against_the_golden(self):
|
|
"""The whole point of the column: same score, different picture.
|
|
|
|
Both repeats can score the same SSIM against the golden while differing from each
|
|
other, and result.json alone can never say so.
|
|
"""
|
|
arm = "Example-DirectVulkan"
|
|
self.repeat(arm, 1, rgba=solid(8, 4, 0), ssim=0.98)
|
|
self.repeat(arm, 2, rgba=solid(8, 4, 255), ssim=0.98)
|
|
report = ca.summarize(self.root)
|
|
second = report["rows"][1]
|
|
self.assertFalse(second["identical_to_first"])
|
|
self.assertAlmostEqual(second["ssim_vs_first"], BLACK_VS_WHITE, places=15)
|
|
self.assertEqual(second["mismatch_pixels_vs_first"], 32)
|
|
self.assertFalse(report["arms"][0]["all_bitwise_identical"])
|
|
self.assertAlmostEqual(report["arms"][0]["min_ssim_vs_first"], BLACK_VS_WHITE, places=15)
|
|
|
|
def test_the_result_json_crop_is_the_window_used(self):
|
|
arm = "Example-DirectVulkan"
|
|
left, right = bytearray(), bytearray()
|
|
for _ in range(4):
|
|
for x in range(8):
|
|
value = 0 if x < 4 else 255
|
|
left += bytes([value, value, value, 255])
|
|
right += bytes([255 - value] * 3 + [255])
|
|
self.repeat(arm, 1, rgba=bytes(left), crop=(0, 0, 4, 4))
|
|
self.repeat(arm, 2, rgba=bytes(right), crop=(0, 0, 4, 4))
|
|
report = ca.summarize(self.root)
|
|
self.assertEqual(report["rows"][1]["crop"], {"x": 0, "y": 0, "width": 4, "height": 4})
|
|
self.assertAlmostEqual(report["rows"][1]["ssim_vs_first"], BLACK_VS_WHITE, places=15)
|
|
|
|
def test_a_missing_actual_or_result_is_an_error_row_not_a_silent_pass(self):
|
|
arm = "Example-DirectVulkan"
|
|
self.repeat(arm, 1)
|
|
self.repeat(arm, 2, write_actual=False)
|
|
self.repeat(arm, 3, write_result=False)
|
|
report = ca.summarize(self.root)
|
|
self.assertEqual(report["rows"][1]["error"], "no *-actual.png")
|
|
self.assertEqual(report["rows"][2]["error"], "no result.json")
|
|
self.assertIsNone(report["rows"][2].get("ssim_vs_golden"))
|
|
self.assertEqual(report["arms"][0]["errors"], 2)
|
|
self.assertFalse(report["arms"][0]["all_bitwise_identical"])
|
|
# The renderer must survive a row with no numbers at all.
|
|
import io
|
|
buffer = io.StringIO()
|
|
ca.render_summary(report, buffer)
|
|
self.assertIn("no *-actual.png", buffer.getvalue())
|
|
|
|
def test_a_failed_repeat_is_carried_into_the_rollup(self):
|
|
arm = "Example-DirectVulkan"
|
|
self.repeat(arm, 1, ssim=1.0)
|
|
self.repeat(arm, 2, ssim=0.97, passed=False)
|
|
report = ca.summarize(self.root)
|
|
self.assertFalse(report["arms"][0]["passed_all"])
|
|
self.assertEqual(report["arms"][0]["min_ssim_vs_golden"], 0.97)
|
|
|
|
def test_self_test_of_the_transcribed_ssim_passes(self):
|
|
self.assertEqual(ca.self_test(), 0)
|
|
|
|
|
|
class Archive(unittest.TestCase):
|
|
"""The other half: run_android_retrace_local writes the tree summarize() reads.
|
|
|
|
Tested together because the failure this pair exists to prevent spans both - the result
|
|
root is keyed by case and backend and by NOTHING else, so a second repeat overwrites the
|
|
first, and the archive is the only thing standing between "three repeats" and "one
|
|
surviving picture". A test of either half alone would not see a key mismatch between them.
|
|
"""
|
|
|
|
def setUp(self):
|
|
self.temp = tempfile.TemporaryDirectory(prefix="retrace-archive-")
|
|
self.root = Path(self.temp.name)
|
|
self.previous_result_root = retrace.RESULT_ROOT
|
|
retrace.RESULT_ROOT = self.root / "result-root"
|
|
self.case = {"name": "Example/case:1", "backend_overrides": None}
|
|
self.arm = retrace.safe_case(self.case["name"]) + "-DirectVulkan"
|
|
self.result_dir = retrace.RESULT_ROOT / self.arm
|
|
self.result_dir.mkdir(parents=True)
|
|
|
|
def tearDown(self):
|
|
retrace.RESULT_ROOT = self.previous_result_root
|
|
self.temp.cleanup()
|
|
|
|
def write_run(self, value, ssim):
|
|
"""Overwrite the result root the way a real repeat does."""
|
|
write_png(self.result_dir / (self.arm + "-actual.png"), 8, 4, solid(8, 4, value))
|
|
(self.result_dir / "result.json").write_text(json.dumps(
|
|
{"passed": True, "statusCode": 0, "backend": "DirectVulkan", "ssim": ssim,
|
|
"ssimThreshold": 0.99, "matchedGoldenPath": "/x/g.png", "mismatchPixels": 0,
|
|
"cropX": 0, "cropY": 0, "cropWidth": 0, "cropHeight": 0}), encoding="utf-8")
|
|
(self.result_dir / "mobilegl.client.log").write_text("run-ahead ARMED\n", encoding="utf-8")
|
|
(self.result_dir / "transport-proof.json").write_text('{"passed": true}', encoding="utf-8")
|
|
write_png(self.result_dir / (self.arm + "-golden.png"), 8, 4, solid(8, 4, 1))
|
|
|
|
def test_each_repeat_survives_the_next_one_and_summarizes(self):
|
|
archive = self.root / "archive"
|
|
for index, (value, ssim) in enumerate(((10, 1.0), (10, 1.0), (200, 0.97)), start=1):
|
|
self.write_run(value, ssim)
|
|
kept = retrace.archive_repeat(archive, self.case, "DirectVulkan", index)
|
|
self.assertTrue((kept / "result.json").is_file())
|
|
self.assertTrue((kept / (self.arm + "-actual.png")).is_file())
|
|
self.assertTrue((kept / "mobilegl.client.log").is_file())
|
|
self.assertTrue((kept / "transport-proof.json").is_file())
|
|
# The golden is copied ONCE per arm, beside the repeats rather than inside each.
|
|
self.assertTrue((archive / self.arm / (self.arm + "-golden.png")).is_file())
|
|
self.assertFalse((archive / self.arm / "repeat-01" / (self.arm + "-golden.png")).is_file())
|
|
report = ca.summarize(archive)
|
|
self.assertEqual([row["repeat"] for row in report["rows"]], [1, 2, 3])
|
|
self.assertTrue(report["rows"][1]["identical_to_first"])
|
|
self.assertFalse(report["rows"][2]["identical_to_first"])
|
|
self.assertEqual(report["rows"][2]["ssim_vs_golden"], 0.97)
|
|
self.assertLess(report["rows"][2]["ssim_vs_first"], 1.0)
|
|
# The directory name the archive chose is the one the summary reports.
|
|
self.assertEqual({row["arm"] for row in report["rows"]}, {self.arm})
|
|
|
|
def test_a_repeat_that_produced_nothing_leaves_an_empty_directory_not_the_previous_one(self):
|
|
"""A failed repeat must not inherit the previous repeat's artifacts.
|
|
|
|
This is the concrete shape tools/device_bench/p6/README.md records for benchmark.json:
|
|
the result root is not cleared between runs, so a run that produced nothing leaves the
|
|
LAST one's files in place. Archiving copies whatever is there, so the guarantee this
|
|
test pins is the weaker, honest one - the archive reports what the result root held -
|
|
and the summary must still be able to say the two repeats are not the same evidence.
|
|
"""
|
|
archive = self.root / "archive"
|
|
self.write_run(10, 1.0)
|
|
retrace.archive_repeat(archive, self.case, "DirectVulkan", 1)
|
|
(self.result_dir / "result.json").unlink()
|
|
(self.result_dir / (self.arm + "-actual.png")).unlink()
|
|
kept = retrace.archive_repeat(archive, self.case, "DirectVulkan", 2)
|
|
self.assertFalse((kept / "result.json").exists())
|
|
report = ca.summarize(archive)
|
|
# Both artifacts are gone; the first missing one is the one named.
|
|
self.assertEqual(report["rows"][1]["error"], "no result.json")
|
|
self.assertNotIn("ssim_vs_first", report["rows"][1])
|
|
self.assertNotIn("actual_sha256", report["rows"][1])
|
|
self.assertEqual(report["arms"][0]["errors"], 1)
|
|
self.assertFalse(report["arms"][0]["all_bitwise_identical"])
|
|
|
|
|
|
class RepeatIsolation(unittest.TestCase):
|
|
"""The repeat loop itself: a repeat that rendered nothing must archive nothing.
|
|
|
|
The Archive class above pins what archive_repeat copies. This pins what main() leaves in the
|
|
result root for it to copy: trace-replay-ci.sh writes result.json and the actual PNG only
|
|
when the app produced them, so without a clear between repeats the second repeat of a case
|
|
whose app died (bsl-esc-menu's scudo abort) archives the FIRST repeat's picture, and the
|
|
summary calls it a passing, bit-identical repeat - the false green gate 3 reads.
|
|
"""
|
|
|
|
def setUp(self):
|
|
self.temp = tempfile.TemporaryDirectory(prefix="retrace-repeat-")
|
|
self.root = Path(self.temp.name)
|
|
apk = self.root / "pinned.apk"
|
|
apk.write_bytes(b"not really an apk")
|
|
self.patches = [
|
|
mock.patch.object(retrace, "RESULT_ROOT", self.root / "result-root"),
|
|
mock.patch.object(retrace, "render_summary", lambda: None),
|
|
mock.patch.dict(os.environ, {"MOBILEGL_TRACE_APK": str(apk)}),
|
|
]
|
|
for patch in self.patches:
|
|
patch.start()
|
|
|
|
def tearDown(self):
|
|
for patch in reversed(self.patches):
|
|
patch.stop()
|
|
self.temp.cleanup()
|
|
|
|
def test_a_repeat_whose_app_died_archives_no_stale_picture(self):
|
|
calls = []
|
|
|
|
def fake_run_case(case, backend, extra_args=None, timeout_seconds=None,
|
|
env_overrides=None, use_pbuffer=False):
|
|
calls.append(list(extra_args or []))
|
|
arm = retrace.RESULT_ROOT / (retrace.safe_case(case["name"]) + "-" + backend)
|
|
arm.mkdir(parents=True, exist_ok=True)
|
|
# trace-replay-ci.sh refreshes the logs on every run, success or not ...
|
|
(arm / "logcat.txt").write_text("run %d\n" % len(calls), encoding="utf-8")
|
|
if len(calls) == 1:
|
|
# ... but only a run whose app lived writes result.json and the actual.
|
|
write_png(arm / (arm.name + "-actual.png"), 8, 4, solid(8, 4, 10))
|
|
(arm / "result.json").write_text(json.dumps(
|
|
{"passed": True, "statusCode": 0, "backend": backend, "ssim": 1.0,
|
|
"ssimThreshold": 0.99, "matchedGoldenPath": "/x/g.png",
|
|
"mismatchPixels": 0, "cropX": 0, "cropY": 0, "cropWidth": 0,
|
|
"cropHeight": 0}), encoding="utf-8")
|
|
return 0
|
|
return 1
|
|
|
|
archive = self.root / "archive"
|
|
argv = ["run_android_retrace_local.py", "--case", "OpenRA", "--backend", "DirectVulkan",
|
|
"--repeat", "2", "--archive-dir", str(archive)]
|
|
with mock.patch.object(retrace, "run_case", fake_run_case), \
|
|
mock.patch.object(sys, "argv", argv):
|
|
self.assertEqual(retrace.main(), 1)
|
|
self.assertEqual(len(calls), 2)
|
|
arm = archive / "OpenRA-DirectVulkan"
|
|
self.assertTrue((arm / "repeat-01" / "result.json").is_file())
|
|
self.assertTrue((arm / "repeat-01" / "OpenRA-DirectVulkan-actual.png").is_file())
|
|
self.assertFalse((arm / "repeat-02" / "result.json").exists())
|
|
self.assertFalse((arm / "repeat-02" / "OpenRA-DirectVulkan-actual.png").exists())
|
|
report = ca.summarize(archive)
|
|
self.assertEqual(report["rows"][1]["error"], "no result.json")
|
|
self.assertFalse(report["arms"][0]["all_bitwise_identical"])
|
|
self.assertFalse(report["arms"][0]["passed_all"])
|
|
|
|
|
|
@unittest.skipIf(os.name == "nt", "the Git Bash spelling is the Windows branch of the same code")
|
|
class PosixHost(unittest.TestCase):
|
|
"""The device window runs detached from WSL, so the runner has to start on a POSIX host.
|
|
|
|
It used to hardcode Git Bash's `C:/Program Files/Git/bin/bash.exe` and spell every host path
|
|
/c/...: on Linux every case died with FileNotFoundError before its first adb call.
|
|
"""
|
|
|
|
def test_run_case_starts_a_posix_bash_with_native_paths(self):
|
|
with tempfile.TemporaryDirectory(prefix="retrace-posix-") as directory:
|
|
root = Path(directory)
|
|
case = retrace.case_with_defaults(trace_cases.find_trace_case("OpenRA"))
|
|
fixtures = root / "fixtures"
|
|
fixtures.mkdir()
|
|
(fixtures / case["trace_archive"]).write_bytes(b"a real archive, not an LFS pointer")
|
|
write_png(fixtures / case["golden"], 8, 4, solid(8, 4, 0))
|
|
apk = root / "pinned.apk"
|
|
apk.write_bytes(b"apk")
|
|
captured = {}
|
|
|
|
def fake_run(command, cwd=None, env=None, **_):
|
|
captured.update(command=command, env=env)
|
|
return subprocess.CompletedProcess(command, 0)
|
|
|
|
with mock.patch.object(retrace, "FIXTURES", fixtures), \
|
|
mock.patch.object(retrace, "RESULT_ROOT", root / "result-root"), \
|
|
mock.patch.object(retrace, "FIXTURE_ROOT", root / "fixture-root"), \
|
|
mock.patch.dict(os.environ, {"MOBILEGL_TRACE_APK": str(apk)}), \
|
|
mock.patch.object(retrace.subprocess, "run", fake_run):
|
|
self.assertEqual(retrace.run_case(case, "DirectVulkan"), 0)
|
|
command = captured["command"]
|
|
self.assertNotIn("Program Files", command[0])
|
|
self.assertTrue(Path(command[0]).is_file(), command[0])
|
|
self.assertEqual(command[command.index("--apk-file") + 1], str(apk.resolve()))
|
|
self.assertEqual(command[command.index("--golden") + 1],
|
|
str((fixtures / case["golden"]).resolve()))
|
|
self.assertEqual(captured["env"]["PYTHON"], sys.executable)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main(verbosity=2)
|