Files
felhom.eu/scripts/test_build_sizing_web.py
T
admin 16dca64834
gates / gates (push) Successful in 6m31s
Website: machine estimator v2 — fold-out categories and the memory bar (still unlisted, R-997)
Each category is a native <details>, closed at first, with an 'n selected' count;
a preset opens what it ticks. A sticky memory bar (0-64 GB, marks 8/16/24/32/48/64+):
solid fill = smallest box memory where the selection fits well, thin mark = where it
fits, but tight; one line names the box to buy. minBoxGb() inverts guestMib() by
bisection on it, so the rule is still written once. Box sizes 12/24/48/64 added;
tests cover seven sizes and the inverse (red-proofed); SIZING.md table gains 24 GB.
2026-10-13 15:38:45 +02:00

159 lines
7.9 KiB
Python

#!/usr/bin/env python3
"""Tests for the machine estimator's data and rule (website/assets/sizing-data.json, sizing.js).
No JavaScript runtime exists on DooPlex or in CI (python3 + git only), so the rule is checked here in Python, on
the SAME data file the page reads, and the JavaScript is held to having no numbers of its own:
1. the committed data file is exactly what scripts/build_sizing_web.py builds
2. the four cases of SIZING.md §5 give the expected verdicts by the rule (estimate() below mirrors sizing.js)
3. the verdict table in SIZING.md §5 says the same as the rule computes
4. sizing.js carries the rule's shape and none of its constants (they all come from the data file)
Run: python3 -I scripts/test_build_sizing_web.py
"""
import io, json, os, re, sys, unittest
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, os.path.join(ROOT, "scripts"))
import build_sizing_web as B
DATA = os.path.join(ROOT, "website", "assets", "sizing-data.json")
JS = os.path.join(ROOT, "website", "assets", "sizing.js")
SIZING_MD = os.path.join(ROOT, "documentation", "audits", "sizing-2026-10-13", "SIZING.md")
HOUSEHOLD = ["immich", "jellyfin", "nextcloud", "home-assistant", "vaultwarden", "paperless-ngx"]
WORDS = {"well": "fits well", "tight": "fits, but tight", "no": "does not fit"}
def guest_mib(d, gb): # mirrors sizing.js guestMib()
r = d["guest_rule"]
total = gb * 1024 - r["memtotal_shortfall_mib"]
mem = total - r["reserve_mib"]
if mem > total - r["max_gap_mib"]:
mem = total - r["max_gap_mib"]
if mem < r["min_mib"]:
mem = r["min_mib"]
if mem > total - r["ceiling_gap_mib"]:
mem = total - r["ceiling_gap_mib"]
return mem
def min_box_gb(d, need): # mirrors sizing.js minBoxGb(): a bisection on guest_mib, never a 2nd rule
lo, hi = 0.0, 1.0
while guest_mib(d, hi) < need:
hi *= 2
while hi - lo > 1 / 1024:
mid = (lo + hi) / 2
if guest_mib(d, mid) >= need:
hi = mid
else:
lo = mid
return hi
def estimate(d, slugs): # mirrors sizing.js estimate()
by = {a["slug"]: a for a in d["apps"]}
peak = d["platform_mb"] + sum(by[s]["peak_mb"] for s in slugs if not by[s].get("builtin"))
out = []
for gb in d["boxes_gb"]:
g = guest_mib(d, gb)
out.append("well" if peak * d["headroom"] <= g else ("tight" if peak <= g else "no"))
return peak, out
def first(d, verdicts, want): # mirrors recommend_gb / tight_box_gb in sizing.js
return next((gb for gb, v in zip(d["boxes_gb"], verdicts) if v == want), None)
class SizingWeb(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.d = json.load(io.open(DATA, encoding="utf-8"))
cls.all = [a["slug"] for a in cls.d["apps"] if not a.get("builtin")]
def test_committed_file_is_a_fresh_build(self):
self.assertEqual(io.open(DATA, encoding="utf-8").read(), B.render(B.build()),
"sizing-data.json differs from a fresh build — run scripts/build_sizing_web.py")
def test_offers_the_62_listed_apps_plus_builtin(self):
self.assertEqual(len(self.all), 62)
self.assertEqual([a["slug"] for a in self.d["apps"] if a.get("builtin")], ["filebrowser"])
def test_guest_sizes_follow_the_installer(self):
self.assertEqual(self.d["boxes_gb"], [8, 12, 16, 24, 32, 48, 64])
self.assertEqual([guest_mib(self.d, gb) for gb in self.d["boxes_gb"]],
[4096, 7580, 11676, 19868, 28060, 44444, 60828])
self.assertEqual(guest_mib(self.d, 4), 4 * 1024 - 612 - 1024) # a 4 GB host: the ceiling, not 4 GB
def test_the_four_cases(self):
# columns: 8, 12, 16, 24, 32, 48, 64 GB; then the recommended ("fits well") and the smallest tight size
W, T, N = "well", "tight", "no"
cases = {
"household": (HOUSEHOLD, [N, T, W, W, W, W, W], 16, 12),
"household without immich": ([s for s in HOUSEHOLD if s != "immich"], [W, W, W, W, W, W, W], 8, None),
"immich alone": (["immich"], [T, W, W, W, W, W, W], 12, 8),
"all 62": (self.all, [N, N, N, N, T, W, W], 48, 32), # 24 GB: guest 19,868 < peak 21,783
"nothing": ([], [W] * 7, 8, None),
}
for name, (sel, want, rec, tight) in cases.items():
peak, verdicts = estimate(self.d, sel)
self.assertEqual(verdicts, want, name)
self.assertEqual(first(self.d, verdicts, "well"), rec, name + " recommended")
self.assertEqual(first(self.d, verdicts, "tight"), tight, name + " smallest tight")
def test_inverse_agrees_with_the_forward_rule(self):
cases = [HOUSEHOLD, [s for s in HOUSEHOLD if s != "immich"], ["immich"], self.all, []]
for sel in cases:
peak, _ = estimate(self.d, sel)
for need in (peak * self.d["headroom"], peak):
gb = min_box_gb(self.d, need)
self.assertGreaterEqual(guest_mib(self.d, gb), need) # it fits at the answer
self.assertLess(guest_mib(self.d, gb - 2 / 1024), need) # and not one step smaller
# the recommendation is the first listed size at or above the fill
rec = next((b for b in self.d["boxes_gb"] if guest_mib(self.d, b) >= need), None)
if rec is not None:
self.assertGreaterEqual(rec, gb - 1 / 1024)
def test_sizing_md_table_agrees_with_the_rule(self):
with io.open(SIZING_MD, encoding="utf-8") as f:
s = f.read()
block = re.search(r"<!-- sizing-verdicts:.*?-->(.*?)<!-- /sizing-verdicts -->", s, re.S)
self.assertIsNotNone(block, "SIZING.md has no sizing-verdicts table")
rows = {}
for line in block.group(1).splitlines():
cells = [c.strip() for c in line.strip().strip("|").split("|")]
if cells and cells[0] == "Selection":
cols = [int(c.split()[0]) for c in cells[2:]]
elif len(cells) > 2 and not cells[0].startswith("---"):
rows[cells[0]] = cells
sel = {"Household set": HOUSEHOLD, "Household set without Immich": [s for s in HOUSEHOLD if s != "immich"],
"Immich alone": ["immich"], "All 62 apps": self.all}
self.assertEqual(set(rows), set(sel))
for name, slugs in sel.items():
peak, verdicts = estimate(self.d, slugs)
self.assertEqual(rows[name][1], "%.1f GB" % (peak / 1024), name + " peak")
by = dict(zip(self.d["boxes_gb"], verdicts))
self.assertIn(24, cols, "SIZING.md's table lacks the 24 GB column")
self.assertEqual(rows[name][2:], [WORDS[by[c]] for c in cols], name)
def test_js_has_the_rule_and_none_of_its_numbers(self):
js = io.open(JS, encoding="utf-8").read()
for need in ("data.platform_mb", "data.headroom", "r.reserve_mib", "r.max_gap_mib", "r.min_mib",
"r.ceiling_gap_mib", "r.memtotal_shortfall_mib", "data.boxes_gb",
"peak * data.headroom <= guest", "peak <= guest"):
self.assertIn(need, js)
code = re.sub(r"/\*.*?\*/", "", js, flags=re.S)
code = re.sub(r"(?m)//.*$", "", code)
code = re.sub(r"'(?:[^'\\]|\\.)*'", "''", code)
nums = set(re.findall(r"(?<![\w.])\d+(?:\.\d+)?", code))
allowed = {"0", "1", "2", "100", "1024"} # 100 = percent, 1024 = MiB per GiB; no rule constant
self.assertTrue(nums <= allowed, "sizing.js holds its own numbers: %s" % sorted(nums - allowed))
for need in ("data.bar_max_gb", "data.bar_marks_gb", "function minBoxGb", "guestMib(data, mid) >= needMib"):
self.assertIn(need, js)
def test_data_has_no_peak_below_typical(self):
for a in self.d["apps"]:
self.assertGreaterEqual(a["peak_mb"], a["typical_mb"], a["slug"])
if __name__ == "__main__":
unittest.main(verbosity=2)