|
| 1 | +# ----------------------------------------------------------------------------- |
| 2 | +# Predbat Home Battery System |
| 3 | +# Copyright Trefor Southwell 2026 - All Rights Reserved |
| 4 | +# This application maybe used for personal use only and not for commercial use |
| 5 | +# ----------------------------------------------------------------------------- |
| 6 | +# fmt off |
| 7 | +# pylint: disable=consider-using-f-string |
| 8 | +# pylint: disable=line-too-long |
| 9 | +# pylint: disable=attribute-defined-outside-init |
| 10 | + |
| 11 | +""" |
| 12 | +Unit tests for configurable log rotation (#5076) - the log_count apps.yaml setting, the |
| 13 | +two-digit predbat.NN.log naming, and the migration path that reads the single-digit names an |
| 14 | +older Predbat wrote. |
| 15 | +""" |
| 16 | + |
| 17 | +import os |
| 18 | +import tempfile |
| 19 | + |
| 20 | +from utils import ( |
| 21 | + PREDBAT_LOG_COUNT_DEFAULT, |
| 22 | + PREDBAT_LOG_COUNT_MAX, |
| 23 | + PREDBAT_LOG_COUNT_MIN, |
| 24 | + predbat_log_count, |
| 25 | + predbat_log_file_prev, |
| 26 | + predbat_log_name, |
| 27 | + predbat_log_name_legacy, |
| 28 | + read_predbat_log, |
| 29 | + rotate_predbat_logs, |
| 30 | +) |
| 31 | + |
| 32 | + |
| 33 | +def _touch(name, content=""): |
| 34 | + """Create a file with known content, for asserting which file ended up where.""" |
| 35 | + with open(name, "w", encoding="utf-8") as handle: |
| 36 | + handle.write(content) |
| 37 | + |
| 38 | + |
| 39 | +def _rotate(args): |
| 40 | + """ |
| 41 | + Run the rotation exactly as Hass.log() does, against the current working directory. |
| 42 | +
|
| 43 | + Calls the same rotate_predbat_logs() Hass.log() calls, rather than a hand-copy of its loop - |
| 44 | + a copy can diverge from the code it is meant to be testing and still pass, which is exactly |
| 45 | + what let the off-by-one Copilot found on #5076 ship with a green test suite (the copy had the |
| 46 | + same bug, so it agreed with itself). Only the parts of Hass.log() this file does not otherwise |
| 47 | + exercise - closing/reopening the live predbat.log - are still done directly here. |
| 48 | + """ |
| 49 | + max_logs = predbat_log_count(args) - 1 |
| 50 | + rotate_predbat_logs(max_logs) |
| 51 | + os.rename("predbat.log", predbat_log_name(1)) |
| 52 | + _touch("predbat.log", "live") |
| 53 | + |
| 54 | + |
| 55 | +def run_log_rotation_tests(my_predbat): |
| 56 | + """ |
| 57 | + Test configurable log rotation, two-digit naming and single-digit migration (#5076). |
| 58 | + """ |
| 59 | + failed = False |
| 60 | + print("**** Running log_rotation tests ****") |
| 61 | + |
| 62 | + saved_cwd = os.getcwd() |
| 63 | + try: |
| 64 | + with tempfile.TemporaryDirectory() as tmpdir: |
| 65 | + os.chdir(tmpdir) |
| 66 | + |
| 67 | + print("Test 1: log_count defaults and clamps") |
| 68 | + for args, expected, why in ( |
| 69 | + ({}, PREDBAT_LOG_COUNT_DEFAULT, "absent"), |
| 70 | + ({"log_count": 25}, 25, "in range"), |
| 71 | + ({"log_count": 1}, PREDBAT_LOG_COUNT_MIN, "below minimum"), |
| 72 | + ({"log_count": 500}, PREDBAT_LOG_COUNT_MAX, "above maximum"), |
| 73 | + ({"log_count": "30"}, 30, "numeric string"), |
| 74 | + ({"log_count": "banana"}, PREDBAT_LOG_COUNT_DEFAULT, "non-numeric"), |
| 75 | + ({"log_count": None}, PREDBAT_LOG_COUNT_DEFAULT, "null"), |
| 76 | + # YAML accepts non-finite numeric scalars (.inf/-.inf), which parse as a float |
| 77 | + # int() cannot convert - OverflowError, not caught by the (TypeError, ValueError) |
| 78 | + # this already guarded against (Copilot review on #5076). |
| 79 | + ({"log_count": float("inf")}, PREDBAT_LOG_COUNT_DEFAULT, "positive infinity"), |
| 80 | + ({"log_count": float("-inf")}, PREDBAT_LOG_COUNT_DEFAULT, "negative infinity"), |
| 81 | + ): |
| 82 | + actual = predbat_log_count(args) |
| 83 | + if actual != expected: |
| 84 | + print(" ERROR: log_count {} ({}) should give {}, got {}".format(args, why, expected, actual)) |
| 85 | + failed = True |
| 86 | + |
| 87 | + print("Test 2: names are zero-padded to two digits") |
| 88 | + if predbat_log_name(1) != "predbat.01.log" or predbat_log_name(99) != "predbat.99.log": |
| 89 | + print(" ERROR: unexpected padded names: {!r} {!r}".format(predbat_log_name(1), predbat_log_name(99))) |
| 90 | + failed = True |
| 91 | + if predbat_log_name_legacy(1) != "predbat.1.log": |
| 92 | + print(" ERROR: unexpected legacy name: {!r}".format(predbat_log_name_legacy(1))) |
| 93 | + failed = True |
| 94 | + |
| 95 | + print("Test 3: a rotation shifts every log up one slot") |
| 96 | + _touch("predbat.log", "current") |
| 97 | + _touch(predbat_log_name(1), "one") |
| 98 | + _touch(predbat_log_name(2), "two") |
| 99 | + _rotate({"log_count": 10}) |
| 100 | + for name, expected in ((predbat_log_name(1), "current"), (predbat_log_name(2), "one"), (predbat_log_name(3), "two")): |
| 101 | + if not os.path.isfile(name): |
| 102 | + print(" ERROR: expected {} to exist after rotation".format(name)) |
| 103 | + failed = True |
| 104 | + elif open(name, encoding="utf-8").read() != expected: |
| 105 | + print(" ERROR: {} should hold {!r}, got {!r}".format(name, expected, open(name, encoding="utf-8").read())) |
| 106 | + failed = True |
| 107 | + |
| 108 | + print("Test 4: single-digit logs from an older Predbat migrate to two-digit") |
| 109 | + for name in os.listdir("."): |
| 110 | + os.remove(name) |
| 111 | + _touch("predbat.log", "current") |
| 112 | + _touch(predbat_log_name_legacy(1), "old-one") |
| 113 | + _touch(predbat_log_name_legacy(2), "old-two") |
| 114 | + _rotate({"log_count": 10}) |
| 115 | + if os.path.isfile(predbat_log_name_legacy(2)) or os.path.isfile(predbat_log_name_legacy(3)): |
| 116 | + print(" ERROR: single-digit files should have been renamed, found {}".format(sorted(os.listdir(".")))) |
| 117 | + failed = True |
| 118 | + for name, expected in ((predbat_log_name(2), "old-one"), (predbat_log_name(3), "old-two")): |
| 119 | + if not os.path.isfile(name) or open(name, encoding="utf-8").read() != expected: |
| 120 | + print(" ERROR: expected {} to hold {!r}, listing is {}".format(name, expected, sorted(os.listdir(".")))) |
| 121 | + failed = True |
| 122 | + |
| 123 | + print("Test 4b: a legacy single-digit log in the OLDEST kept slot is migrated, not stranded") |
| 124 | + # #5076 Copilot review: the shift previously only walked slots 1..max_logs-1, so a |
| 125 | + # legacy predbat.9.log sitting in the oldest kept slot (max_logs=9 for the default |
| 126 | + # log_count=10) was never a rename source - it does not collide with anything landing |
| 127 | + # on slot 9 from below, since that only writes the two-digit name. Left on disk under |
| 128 | + # the old name forever, invisible to predbat_log_file_prev()'s two-digit-first lookup. |
| 129 | + for name in os.listdir("."): |
| 130 | + os.remove(name) |
| 131 | + _touch("predbat.log", "current") |
| 132 | + _touch(predbat_log_name_legacy(9), "oldest-legacy") |
| 133 | + _rotate({"log_count": 10}) # max_logs = 9 |
| 134 | + if os.path.isfile(predbat_log_name_legacy(9)): |
| 135 | + print(" ERROR: the legacy file in the oldest kept slot should not survive under its old name, found {}".format(sorted(os.listdir(".")))) |
| 136 | + failed = True |
| 137 | + if os.path.isfile(predbat_log_name(10)) or os.path.isfile(predbat_log_name_legacy(10)): |
| 138 | + print(" ERROR: log_count=10 keeps only slots 1-9 plus the live log, found {}".format(sorted(os.listdir(".")))) |
| 139 | + failed = True |
| 140 | + |
| 141 | + print("Test 5: logs beyond the configured count are removed") |
| 142 | + for name in os.listdir("."): |
| 143 | + os.remove(name) |
| 144 | + _touch("predbat.log", "current") |
| 145 | + for num in range(1, 6): |
| 146 | + _touch(predbat_log_name(num), "log{}".format(num)) |
| 147 | + _rotate({"log_count": 3}) |
| 148 | + # Keeping 3 files total means the live log plus slots 01 and 02. |
| 149 | + if os.path.isfile(predbat_log_name(3)): |
| 150 | + print(" ERROR: slot 03 should have been removed with log_count 3, listing is {}".format(sorted(os.listdir(".")))) |
| 151 | + failed = True |
| 152 | + remaining = sorted(name for name in os.listdir(".") if name != "predbat.log") |
| 153 | + if remaining != [predbat_log_name(1), predbat_log_name(2)]: |
| 154 | + print(" ERROR: expected only slots 01 and 02 to remain, got {}".format(remaining)) |
| 155 | + failed = True |
| 156 | + |
| 157 | + print("Test 6: lowering log_count clears files stranded above the new count") |
| 158 | + for name in os.listdir("."): |
| 159 | + os.remove(name) |
| 160 | + _touch("predbat.log", "current") |
| 161 | + for num in range(1, 21): |
| 162 | + _touch(predbat_log_name(num), "log{}".format(num)) |
| 163 | + _rotate({"log_count": 4}) |
| 164 | + stranded = [name for name in os.listdir(".") if name not in ("predbat.log", predbat_log_name(1), predbat_log_name(2), predbat_log_name(3))] |
| 165 | + if stranded: |
| 166 | + print(" ERROR: files above the new count should be removed, found {}".format(sorted(stranded))) |
| 167 | + failed = True |
| 168 | + |
| 169 | + print("Test 7: the viewer prefers the two-digit previous log") |
| 170 | + for name in os.listdir("."): |
| 171 | + os.remove(name) |
| 172 | + _touch("predbat.log", "live-line") |
| 173 | + _touch(predbat_log_name(1), "padded-prev") |
| 174 | + if predbat_log_file_prev() != predbat_log_name(1): |
| 175 | + print(" ERROR: expected the padded name to win, got {!r}".format(predbat_log_file_prev())) |
| 176 | + failed = True |
| 177 | + data = read_predbat_log() |
| 178 | + if "padded-prev" not in data or "live-line" not in data: |
| 179 | + print(" ERROR: the viewer should show both logs, got {!r}".format(data)) |
| 180 | + failed = True |
| 181 | + if data.index("padded-prev") > data.index("live-line"): |
| 182 | + print(" ERROR: the previous log should be prefixed, not appended, got {!r}".format(data)) |
| 183 | + failed = True |
| 184 | + |
| 185 | + print("Test 8: the viewer falls back to a single-digit previous log after an upgrade") |
| 186 | + for name in os.listdir("."): |
| 187 | + os.remove(name) |
| 188 | + _touch("predbat.log", "live-line") |
| 189 | + _touch(predbat_log_name_legacy(1), "legacy-prev") |
| 190 | + if predbat_log_file_prev() != predbat_log_name_legacy(1): |
| 191 | + print(" ERROR: expected the legacy name as fallback, got {!r}".format(predbat_log_file_prev())) |
| 192 | + failed = True |
| 193 | + data = read_predbat_log() |
| 194 | + if "legacy-prev" not in data: |
| 195 | + print(" ERROR: an upgrade must not lose the previous log, got {!r}".format(data)) |
| 196 | + failed = True |
| 197 | + |
| 198 | + print("Test 9: no previous log at all is not an error") |
| 199 | + for name in os.listdir("."): |
| 200 | + os.remove(name) |
| 201 | + _touch("predbat.log", "only-line") |
| 202 | + if predbat_log_file_prev() is not None: |
| 203 | + print(" ERROR: expected no previous log, got {!r}".format(predbat_log_file_prev())) |
| 204 | + failed = True |
| 205 | + if read_predbat_log().strip() != "only-line": |
| 206 | + print(" ERROR: expected just the live log, got {!r}".format(read_predbat_log())) |
| 207 | + failed = True |
| 208 | + finally: |
| 209 | + os.chdir(saved_cwd) |
| 210 | + |
| 211 | + if failed: |
| 212 | + print("\n**** log_rotation tests: FAILED ****") |
| 213 | + else: |
| 214 | + print("\n**** log_rotation tests: PASSED ****") |
| 215 | + |
| 216 | + return failed |
0 commit comments