#!/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 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 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([guest_mib(self.d, gb) for gb in (8, 16, 32)], [4096, 11676, 28060]) 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): cases = { "household": (HOUSEHOLD, ["no", "well", "well"]), "household without immich": ([s for s in HOUSEHOLD if s != "immich"], ["well", "well", "well"]), "immich alone": (["immich"], ["tight", "well", "well"]), "all 62": (self.all, ["no", "no", "tight"]), "nothing": ([], ["well", "well", "well"]), } for name, (sel, want) in cases.items(): self.assertEqual(estimate(self.d, sel)[1], want, name) 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 len(cells) == 5 and cells[0] not in ("Selection", "---"): 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") self.assertEqual(rows[name][2:], [WORDS[v] for v in verdicts], 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"(?