Files
felhom.eu/scripts/test_build_sizing_web.py
T
admin 92e9d45cf5
gates / gates (push) Successful in 6m29s
Website: machine estimator v3 — the bar shows what the box's memory is used for (still unlisted, R-997)
The bar keeps its length (the fits-well box memory) and is split into base system
(box - guest), Felhom's own services, each app's peak (largest first, the dashboard's
palette minus amber and its first blue; beyond 8 apps one group) and spare room
(guest - peak) — all from the one rule; a test proves the parts sum to the bar within
1 MB. A legend under it (not sticky) and one sentence explain the recommended size.
Typical figure moved into the method fold-out. English: 'an 8 GB machine'.
2026-10-13 16:08:00 +02:00

196 lines
10 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 segments(d, slugs): # mirrors estimate().segments in sizing.js (v3)
by = {a["slug"]: a for a in d["apps"]}
apps = sorted((by[s] for s in slugs if not by[s].get("builtin")), key=lambda a: -a["peak_mb"])
peak = d["platform_mb"] + sum(a["peak_mb"] for a in apps)
well = min_box_gb(d, peak * d["headroom"])
box, guest = well * 1024, guest_mib(d, well)
return well, [("base", box - guest), ("platform", d["platform_mb"])] + \
[(a["slug"], a["peak_mb"]) for a in apps] + [("spare", guest - peak)]
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_segments_add_up_to_the_bar(self):
for name, sel in {"household": HOUSEHOLD, "household without immich": [x for x in HOUSEHOLD if x != "immich"],
"immich alone": ["immich"], "all 62": self.all, "nothing": []}.items():
well, segs = segments(self.d, sel)
self.assertAlmostEqual(sum(mb for _, mb in segs), well * 1024, delta=1, msg=name)
self.assertGreaterEqual(dict(segs)["base"], 0, name + ": base system negative")
self.assertGreaterEqual(dict(segs)["spare"], 0, name + ": spare negative")
# the household preset, by hand: peak 5,515 MiB; the bar holds 5,515 * 1.5 = 8,272.5 in the guest
well, segs = segments(self.d, HOUSEHOLD)
self.assertAlmostEqual(dict(segs)["spare"], 5515 * 0.5, delta=1)
self.assertAlmostEqual(dict(segs)["base"], 4708, delta=1) # 4,096 for Proxmox + 612 MemTotal shortfall
def test_english_article(self):
with io.open(os.path.join(ROOT, "website", "en", "sizing.html"), encoding="utf-8-sig") as f:
t = json.loads(re.search(r'id="sizer-strings">(.*?)</script>', f.read(), re.S).group(1))
def say(tpl, gb): # mirrors fill() in sizing.js
art = t["an"] if str(gb) in t["an_before"] else t["a"]
return tpl.replace("{a}", art).replace("{gb}", str(gb))
got = {gb: say(t["recommend"], gb) for gb in self.d["boxes_gb"]}
self.assertEqual(got[8], "Recommended: an 8 GB machine.")
for gb in self.d["boxes_gb"]:
if gb != 8:
self.assertEqual(got[gb], "Recommended: a %d GB machine." % gb)
for k in ("tight_on", "only_tight", "none_fits"):
self.assertNotRegex(say(t[k], 8), r"\ba 8\b", k)
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",
"data.bar_app_colours", "boxMb - guestMb", "guestMb - peak"):
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)