|
| 1 | +"""batteryUISysdiagnose: sysdiagnose logs/BatteryUIPlist property lists, SYNTHETIC input only.""" |
| 2 | +import importlib.util |
| 3 | +import plistlib |
| 4 | +import tempfile |
| 5 | +import unittest |
| 6 | +from datetime import datetime, timezone |
| 7 | +from pathlib import Path |
| 8 | +from types import SimpleNamespace |
| 9 | + |
| 10 | +from scripts.artifacts import batteryUISysdiagnose as module |
| 11 | + |
| 12 | +HERE = Path(__file__).resolve().parent |
| 13 | +SPEC = importlib.util.spec_from_file_location('synthetic_battery_ui', HERE / 'synthetic_battery_ui.py') |
| 14 | +SYNTH = importlib.util.module_from_spec(SPEC) |
| 15 | +SPEC.loader.exec_module(SYNTH) |
| 16 | + |
| 17 | +PREFIX = 'PLBatteryUIQueryRange' |
| 18 | + |
| 19 | + |
| 20 | +def _context(files): |
| 21 | + return SimpleNamespace(get_files_found=lambda: [str(path) for path in files], |
| 22 | + get_relative_path=lambda value: Path(value.split(' >> ')[0]).name) |
| 23 | + |
| 24 | + |
| 25 | +def _run(function, files): |
| 26 | + return function.__wrapped__(_context(files)) |
| 27 | + |
| 28 | + |
| 29 | +def _write_tree(folder, files): |
| 30 | + base = Path(folder) / 'sysdiagnose_SYNTHETIC_iPhone-OS_iPhone_00A000' / 'logs' / 'BatteryUIPlist' |
| 31 | + out = [] |
| 32 | + for name, data in files.items(): |
| 33 | + path = base / name |
| 34 | + path.parent.mkdir(parents=True, exist_ok=True) |
| 35 | + path.write_bytes(data) |
| 36 | + out.append(path) |
| 37 | + return out |
| 38 | + |
| 39 | + |
| 40 | +def independent_counts(files): |
| 41 | + """(buckets, app entries) counted straight from the property lists.""" |
| 42 | + buckets = apps = 0 |
| 43 | + for data in files.values(): |
| 44 | + breakdown = plistlib.loads(data)['Breakdown'] |
| 45 | + for key, value in breakdown.items(): |
| 46 | + if not key.startswith(PREFIX): |
| 47 | + continue |
| 48 | + for bucket in value if isinstance(value, list) else [value]: |
| 49 | + buckets += 1 |
| 50 | + apps += len([a for a in bucket['PLBatteryUIAppArrayKey'] if isinstance(a, dict)]) |
| 51 | + return buckets, apps |
| 52 | + |
| 53 | + |
| 54 | +def utc(value): |
| 55 | + return datetime.fromtimestamp(value, tz=timezone.utc) |
| 56 | + |
| 57 | + |
| 58 | +class BatteryUISysdiagnoseTest(unittest.TestCase): |
| 59 | + def test_row_counts_match_an_independent_count_in_both_shapes(self): |
| 60 | + for variant in ('hourly', 'stretched'): |
| 61 | + files = SYNTH.build_files(variant) |
| 62 | + expected_buckets, expected_apps = independent_counts(files) |
| 63 | + with tempfile.TemporaryDirectory() as folder: |
| 64 | + paths = _write_tree(folder, files) |
| 65 | + a_headers, apps, _ = _run(module.batteryUISysdiagnoseApps, paths) |
| 66 | + b_headers, buckets, _ = _run(module.batteryUISysdiagnoseBuckets, paths) |
| 67 | + s_headers, snapshots, _ = _run(module.batteryUISysdiagnoseSnapshots, paths) |
| 68 | + self.assertEqual((len(buckets), len(apps)), (expected_buckets, expected_apps), variant) |
| 69 | + self.assertEqual(len(snapshots), len(files)) |
| 70 | + for headers, rows in ((a_headers, apps), (b_headers, buckets), (s_headers, snapshots)): |
| 71 | + self.assertTrue(rows) |
| 72 | + self.assertTrue(all(len(row) == len(headers) for row in rows)) |
| 73 | + |
| 74 | + def test_bucket_times_are_utc_datetimes_and_seconds_are_end_minus_start(self): |
| 75 | + with tempfile.TemporaryDirectory() as folder: |
| 76 | + headers, rows, _ = _run(module.batteryUISysdiagnoseApps, _write_tree(folder, SYNTH.build_files('hourly', False))) |
| 77 | + self.assertEqual(headers[0], ('Bucket Start (UTC)', 'datetime')) |
| 78 | + self.assertEqual(headers[1], ('Bucket End (UTC)', 'datetime')) |
| 79 | + self.assertTrue(all(row[0].tzinfo is not None and row[1].tzinfo is not None for row in rows)) |
| 80 | + self.assertTrue(all((row[1] - row[0]).total_seconds() == row[2] for row in rows)) |
| 81 | + hourly = {row[2] for row in rows if row[3] == 'DayTapKey'} |
| 82 | + self.assertEqual(hourly, {3600.0}) |
| 83 | + self.assertEqual({row[2] for row in rows if row[3] == 'WeekKey'}, {864000.0}) |
| 84 | + |
| 85 | + def test_the_stretched_shape_keeps_the_stored_bucket_width(self): |
| 86 | + with tempfile.TemporaryDirectory() as folder: |
| 87 | + _, rows, _ = _run(module.batteryUISysdiagnoseApps, _write_tree(folder, SYNTH.build_files('stretched', False))) |
| 88 | + self.assertEqual({row[2] for row in rows if row[3] == 'DayTapKey'}, {86400.0}) |
| 89 | + self.assertEqual({row[2] for row in rows if row[3] == 'WeekKey'}, {86400.0}) |
| 90 | + |
| 91 | + def test_runtime_columns_are_the_stored_values_and_stay_within_a_tap_bucket(self): |
| 92 | + with tempfile.TemporaryDirectory() as folder: |
| 93 | + headers, rows, _ = _run(module.batteryUISysdiagnoseApps, _write_tree(folder, SYNTH.build_files('hourly', False))) |
| 94 | + fg, bg = headers.index('Foreground Runtime (s)'), headers.index('Background Runtime (s)') |
| 95 | + for row in rows: |
| 96 | + if row[3] in ('DayTapKey', 'WeekTapKey'): |
| 97 | + self.assertLessEqual(row[fg], row[2]) |
| 98 | + self.assertLessEqual(row[bg], row[2]) |
| 99 | + beta = next(r for r in rows if r[3] == 'DayKey' and r[5] == 'com.example.beta') |
| 100 | + self.assertEqual(beta[fg], round(86400.0 * 0.05 * 2 / 4, 3)) |
| 101 | + self.assertEqual(beta[headers.index('Qualifiers (as stored)')], '2, 7') |
| 102 | + |
| 103 | + def test_missing_keys_stay_blank_and_components_are_listed(self): |
| 104 | + with tempfile.TemporaryDirectory() as folder: |
| 105 | + headers, rows, _ = _run(module.batteryUISysdiagnoseApps, _write_tree(folder, SYNTH.build_files('hourly', False))) |
| 106 | + flash = next(r for r in rows if r[5] == 'Flashlight') |
| 107 | + for name in ('Foreground Plugged-In Runtime (as stored)', 'Background Plugged-In Runtime (as stored)', |
| 108 | + 'Call Off-Screen Time (as stored)'): |
| 109 | + self.assertIsNone(flash[headers.index(name)]) |
| 110 | + self.assertEqual(flash[headers.index('App Type (as stored)')], 1) |
| 111 | + gamma = next(r for r in rows if r[5] == 'com.example.gamma') |
| 112 | + self.assertEqual(gamma[headers.index('Energy Components (as stored)')], |
| 113 | + 'SynthCPU=12.75; SynthDRAM=4.5; SynthWidgetCounts={"a": 1, "b": 2}') |
| 114 | + |
| 115 | + def test_bucket_totals_equal_the_sum_of_the_app_values_and_empty_buckets_are_listed(self): |
| 116 | + with tempfile.TemporaryDirectory() as folder: |
| 117 | + paths = _write_tree(folder, SYNTH.build_files('hourly', False)) |
| 118 | + _, apps, _ = _run(module.batteryUISysdiagnoseApps, paths) |
| 119 | + _, buckets, _ = _run(module.batteryUISysdiagnoseBuckets, paths) |
| 120 | + for bucket in buckets: |
| 121 | + members = [a for a in apps if a[:5] == bucket[:5]] |
| 122 | + self.assertEqual(bucket[12], len(members)) |
| 123 | + self.assertAlmostEqual(bucket[5], sum(a[8] for a in members), places=3) |
| 124 | + empty = [b for b in buckets if b[12] == 0] |
| 125 | + self.assertTrue(empty) |
| 126 | + self.assertEqual({b[11] for b in empty}, {1}) |
| 127 | + self.assertEqual({b[11] for b in buckets if b[12]}, {0}) |
| 128 | + |
| 129 | + def test_upgrade_file_gives_the_builds_from_its_name_and_a_marker_row(self): |
| 130 | + with tempfile.TemporaryDirectory() as folder: |
| 131 | + paths = _write_tree(folder, SYNTH.build_files('stretched')) |
| 132 | + _, rows, source = _run(module.batteryUISysdiagnoseSnapshots, paths) |
| 133 | + _, buckets, _ = _run(module.batteryUISysdiagnoseBuckets, paths) |
| 134 | + self.assertEqual(len(rows), 2) |
| 135 | + by_kind = {row[1]: row for row in rows} |
| 136 | + upgrade, main = by_kind['Upgrade file'], by_kind['Main file'] |
| 137 | + self.assertEqual((upgrade[2], upgrade[3]), ('00A001', '00B002')) |
| 138 | + self.assertEqual((main[2], main[3]), ('', '')) |
| 139 | + self.assertEqual(main[0], utc(SYNTH.QUERY_TIME)) |
| 140 | + self.assertEqual(main[4], utc(SYNTH.QUERY_TIME - 33000.5)) |
| 141 | + self.assertEqual(main[5:10], (77, 601, 87, 1036800.0, 1.0)) |
| 142 | + self.assertEqual(main[10], 'DayKey, DayTapKey, WeekKey, WeekTapKey') |
| 143 | + self.assertIn('BatteryUIUpgrade_00A001_00B002.plist', source) |
| 144 | + upgrade_buckets = [b for b in buckets if b[13] == 'Upgrade file'] |
| 145 | + self.assertTrue(upgrade_buckets) |
| 146 | + self.assertTrue(all(b[10] == 29285029.5 for b in upgrade_buckets if b[3] in ('DayKey', 'DayTapKey', 'WeekKey'))) |
| 147 | + |
| 148 | + def test_damaged_values_become_blanks_and_do_not_stop_the_file(self): |
| 149 | + files = {'BatteryUISysdiagnose.plist': plistlib.dumps(SYNTH.edge_main())} |
| 150 | + with tempfile.TemporaryDirectory() as folder: |
| 151 | + paths = _write_tree(folder, files) |
| 152 | + headers, apps, _ = _run(module.batteryUISysdiagnoseApps, paths) |
| 153 | + _, buckets, _ = _run(module.batteryUISysdiagnoseBuckets, paths) |
| 154 | + expected_buckets, expected_apps = independent_counts(files) |
| 155 | + self.assertEqual((len(buckets), len(apps)), (expected_buckets, expected_apps)) |
| 156 | + alpha = next(r for r in apps if r[3] == 'DayKey' and r[5] == 'com.example.alpha') |
| 157 | + self.assertIsNone(alpha[headers.index('Foreground Runtime (s)')]) |
| 158 | + beta = next(r for r in apps if r[3] == 'DayKey' and r[5] == 'com.example.beta') |
| 159 | + self.assertIsNone(beta[headers.index('Energy Used (as stored)')]) |
| 160 | + first_tap = next(b for b in buckets if b[3] == 'DayTapKey' and b[4] == 1) |
| 161 | + self.assertEqual((first_tap[0], first_tap[2]), (None, None)) |
| 162 | + |
| 163 | + def test_unreadable_file_gives_a_snapshot_row_with_a_note_and_no_other_rows(self): |
| 164 | + with tempfile.TemporaryDirectory() as folder: |
| 165 | + paths = _write_tree(folder, {'BatteryUISysdiagnose.plist': b'not a property list'}) |
| 166 | + _, apps, _ = _run(module.batteryUISysdiagnoseApps, paths) |
| 167 | + _, buckets, _ = _run(module.batteryUISysdiagnoseBuckets, paths) |
| 168 | + headers, snapshots, _ = _run(module.batteryUISysdiagnoseSnapshots, paths) |
| 169 | + self.assertEqual((apps, buckets), ([], [])) |
| 170 | + self.assertEqual(len(snapshots), 1) |
| 171 | + self.assertEqual(snapshots[0][headers.index('Notes')], 'The file could not be read as a property list.') |
| 172 | + |
| 173 | + def test_sysdiagnose_archive_and_extracted_folder_give_the_same_rows(self): |
| 174 | + with tempfile.TemporaryDirectory() as folder: |
| 175 | + archive = Path(folder) / 'sysdiagnose_2026.09.29_19-00-00-0400_iPhone-OS_iPhone_24A437.tar.gz' |
| 176 | + SYNTH.write_archive(archive, 'stretched') |
| 177 | + paths = _write_tree(Path(folder) / 'tree', SYNTH.build_files('stretched')) |
| 178 | + for function in (module.batteryUISysdiagnoseApps, module.batteryUISysdiagnoseBuckets, |
| 179 | + module.batteryUISysdiagnoseSnapshots): |
| 180 | + _, from_archive, source = _run(function, [archive]) |
| 181 | + _, from_folder, _ = _run(function, paths) |
| 182 | + self.assertEqual(sorted(repr(r[:-1]) for r in from_archive), sorted(repr(r[:-1]) for r in from_folder)) |
| 183 | + self.assertIn(' >> sysdiagnose_SYNTHETIC_iPhone-OS_iPhone_24A437/logs/BatteryUIPlist/', source) |
| 184 | + self.assertNotIn('other/', source) |
| 185 | + |
| 186 | + def test_empty_input_gives_no_rows(self): |
| 187 | + for function in (module.batteryUISysdiagnoseApps, module.batteryUISysdiagnoseBuckets, |
| 188 | + module.batteryUISysdiagnoseSnapshots): |
| 189 | + headers, rows, source = _run(function, []) |
| 190 | + self.assertEqual((rows, source), ([], '')) |
| 191 | + self.assertTrue(headers) |
| 192 | + |
| 193 | + |
| 194 | +if __name__ == '__main__': |
| 195 | + unittest.main() |
0 commit comments