#!/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">(.*?)', 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"(.*?)", 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"(?= 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)