92e9d45cf5
gates / gates (push) Successful in 6m29s
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'.
196 lines
10 KiB
Python
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)
|