|
| 1 | +#!/usr/bin/env python3 |
| 2 | +# spell-checker:ignore newbin oldbin |
| 3 | +"""Unit tests for the per-binary size comparison script.""" |
| 4 | + |
| 5 | +import json |
| 6 | +import os |
| 7 | +import sys |
| 8 | +import tempfile |
| 9 | +import unittest |
| 10 | + |
| 11 | +from util.compare_size_results import compare, format_report, human_kb, load_sizes, main |
| 12 | + |
| 13 | + |
| 14 | +def _write_json(data): |
| 15 | + fd, path = tempfile.mkstemp(suffix=".json") |
| 16 | + with os.fdopen(fd, "w") as f: |
| 17 | + json.dump(data, f) |
| 18 | + return path |
| 19 | + |
| 20 | + |
| 21 | +class TestCompareSizeResults(unittest.TestCase): |
| 22 | + def test_human_kb(self): |
| 23 | + self.assertEqual(human_kb(512), "512 KB") |
| 24 | + self.assertEqual(human_kb(1536), "1.50 MB") |
| 25 | + self.assertEqual(human_kb(1024 * 1024), "1.00 GB") |
| 26 | + self.assertEqual(human_kb(-2048), "-2.00 MB") |
| 27 | + |
| 28 | + def test_load_sizes_date_keyed(self): |
| 29 | + path = _write_json( |
| 30 | + { |
| 31 | + "Mon, 01 Jan 2024 00:00:00 +0000": { |
| 32 | + "sha": "old", |
| 33 | + "sizes": {"ls": 1000}, |
| 34 | + }, |
| 35 | + "Tue, 02 Jan 2024 00:00:00 +0000": { |
| 36 | + "sha": "new", |
| 37 | + "sizes": {"ls": 1100}, |
| 38 | + }, |
| 39 | + } |
| 40 | + ) |
| 41 | + try: |
| 42 | + sha, sizes = load_sizes(path) |
| 43 | + self.assertEqual(sha, "new") |
| 44 | + self.assertEqual(sizes, {"ls": 1100}) |
| 45 | + finally: |
| 46 | + os.unlink(path) |
| 47 | + |
| 48 | + def test_load_sizes_flat_fallback(self): |
| 49 | + path = _write_json({"ls": "1064"}) |
| 50 | + try: |
| 51 | + sha, sizes = load_sizes(path) |
| 52 | + self.assertIsNone(sha) |
| 53 | + self.assertEqual(sizes, {"ls": 1064}) |
| 54 | + finally: |
| 55 | + os.unlink(path) |
| 56 | + |
| 57 | + def test_compare_thresholds(self): |
| 58 | + # Both thresholds met -> significant (growth and shrinkage). |
| 59 | + sig, *_ = compare({"ls": 1100}, {"ls": 1000}, 0.05, 4) |
| 60 | + self.assertEqual(len(sig), 1) |
| 61 | + self.assertEqual(sig[0]["delta"], 100) |
| 62 | + |
| 63 | + sig, *_ = compare({"ls": 900}, {"ls": 1000}, 0.05, 4) |
| 64 | + self.assertEqual(sig[0]["delta"], -100) |
| 65 | + |
| 66 | + # Only relative met (10% but 2 KB) -> not significant. |
| 67 | + sig, *_ = compare({"t": 22}, {"t": 20}, 0.05, 4) |
| 68 | + self.assertEqual(sig, []) |
| 69 | + |
| 70 | + # Only absolute met (10 KB but 0.01%) -> not significant. |
| 71 | + sig, *_ = compare({"b": 100010}, {"b": 100000}, 0.05, 4) |
| 72 | + self.assertEqual(sig, []) |
| 73 | + |
| 74 | + def test_compare_threshold_boundaries(self): |
| 75 | + # Exactly at the threshold (4 KB AND 5%) -> significant: the script |
| 76 | + # uses >= on both sides. |
| 77 | + sig, *_ = compare({"ls": 84}, {"ls": 80}, 0.05, 4) |
| 78 | + self.assertEqual(len(sig), 1) |
| 79 | + self.assertEqual(sig[0]["delta"], 4) |
| 80 | + self.assertAlmostEqual(sig[0]["rel"], 0.05) |
| 81 | + |
| 82 | + # Just below absolute floor: 3 KB / 3.75% -> not significant. |
| 83 | + sig, *_ = compare({"ls": 83}, {"ls": 80}, 0.05, 4) |
| 84 | + self.assertEqual(sig, []) |
| 85 | + |
| 86 | + # Absolute floor met exactly (4 KB) but relative just below (4%). |
| 87 | + sig, *_ = compare({"ls": 104}, {"ls": 100}, 0.05, 4) |
| 88 | + self.assertEqual(sig, []) |
| 89 | + |
| 90 | + # Relative just below (4.99%) with comfortable absolute -> rejected. |
| 91 | + sig, *_ = compare({"ls": 10499}, {"ls": 10000}, 0.05, 4) |
| 92 | + self.assertEqual(sig, []) |
| 93 | + |
| 94 | + # Symmetric shrinkage at the boundary -> still significant. |
| 95 | + sig, *_ = compare({"ls": 76}, {"ls": 80}, 0.05, 4) |
| 96 | + self.assertEqual(len(sig), 1) |
| 97 | + self.assertEqual(sig[0]["delta"], -4) |
| 98 | + |
| 99 | + def test_compare_added_removed_and_totals(self): |
| 100 | + sig, added, removed, totals = compare( |
| 101 | + {"ls": 1000, "newbin": 500}, {"ls": 1000, "oldbin": 800}, 0.05, 4 |
| 102 | + ) |
| 103 | + self.assertEqual(sig, []) |
| 104 | + self.assertEqual(added, [("newbin", 500)]) |
| 105 | + self.assertEqual(removed, [("oldbin", 800)]) |
| 106 | + # Totals must only consider binaries present in both runs. |
| 107 | + self.assertEqual(totals, {"current": 1000, "reference": 1000}) |
| 108 | + |
| 109 | + def test_compare_sort_and_zero_reference(self): |
| 110 | + sig, *_ = compare({"a": 1100, "b": 2000}, {"a": 1000, "b": 1000}, 0.05, 4) |
| 111 | + self.assertEqual([c["name"] for c in sig], ["b", "a"]) |
| 112 | + # Zero reference must not crash. |
| 113 | + sig, *_ = compare({"ls": 1000}, {"ls": 0}, 0.05, 4) |
| 114 | + self.assertEqual(len(sig), 1) |
| 115 | + |
| 116 | + def test_format_report_renders_changes(self): |
| 117 | + sig = [{"name": "ls", "old": 1000, "new": 1100, "delta": 100, "rel": 0.10}] |
| 118 | + report = format_report( |
| 119 | + sig, |
| 120 | + [("new", 5)], |
| 121 | + [("old", 8)], |
| 122 | + {"current": 1100, "reference": 1000}, |
| 123 | + 0.05, |
| 124 | + 4, |
| 125 | + ) |
| 126 | + for s in ("ls", "+10.00%", "New binaries", "new", "Removed binaries", "old"): |
| 127 | + self.assertIn(s, report) |
| 128 | + |
| 129 | + def _run_main(self, argv): |
| 130 | + old = sys.argv |
| 131 | + sys.argv = argv |
| 132 | + try: |
| 133 | + return main() |
| 134 | + finally: |
| 135 | + sys.argv = old |
| 136 | + |
| 137 | + def test_main_writes_only_when_significant(self): |
| 138 | + cur = _write_json({"d": {"sha": "n", "sizes": {"ls": 1100}}}) |
| 139 | + ref = _write_json({"d": {"sha": "o", "sizes": {"ls": 1000}}}) |
| 140 | + same = _write_json({"d": {"sha": "n", "sizes": {"ls": 1000}}}) |
| 141 | + out_sig = tempfile.mktemp(suffix=".txt") |
| 142 | + out_none = tempfile.mktemp(suffix=".txt") |
| 143 | + try: |
| 144 | + self.assertEqual(self._run_main(["x", cur, ref, "--output", out_sig]), 0) |
| 145 | + self.assertTrue(os.path.exists(out_sig)) |
| 146 | + with open(out_sig) as f: |
| 147 | + self.assertIn("+10.00%", f.read()) |
| 148 | + |
| 149 | + self.assertEqual(self._run_main(["x", same, ref, "--output", out_none]), 0) |
| 150 | + self.assertFalse(os.path.exists(out_none)) |
| 151 | + finally: |
| 152 | + for p in (cur, ref, same, out_sig, out_none): |
| 153 | + if os.path.exists(p): |
| 154 | + os.unlink(p) |
| 155 | + |
| 156 | + def test_main_missing_reference_is_not_fatal(self): |
| 157 | + cur = _write_json({"d": {"sha": "n", "sizes": {"ls": 1000}}}) |
| 158 | + try: |
| 159 | + self.assertEqual(self._run_main(["x", cur, "/nonexistent.json"]), 0) |
| 160 | + finally: |
| 161 | + os.unlink(cur) |
| 162 | + |
| 163 | + |
| 164 | +if __name__ == "__main__": |
| 165 | + unittest.main() |
0 commit comments