Website: the machine estimator (/gepigeny, /en/sizing), live but unlisted; SIZING.md §5 in three verdicts; R-997 filed
gates / gates (push) Successful in 6m42s

A household ticks apps and sees, for an 8, 16 and 32 GB box: fits well / fits, but
tight / does not fit, the totals, and the apps that burst on CPU. One rule in
assets/sizing.js, every number from assets/sizing-data.json (built by
scripts/build_sizing_web.py from the 2026-10-13 N100 measurement; test: 7 cases,
red-proofed). Unlisted: noindex, robots Disallow, not in the sitemap, linked from
nowhere — site_gates.py gate 25 enforces it (UNLISTED twin pairs keep every twin
check). SIZING.md §5 corrected: the 8 GB line contradicted its own x1.5 rule; guest
sizes 4.0/11.4/27.4 GB with the measured 612 MiB MemTotal shortfall. R-993 gains the
fact that the installer accepts a host below any minimum.
This commit is contained in:
2026-10-13 14:31:44 +02:00
parent fdca4971af
commit d5ef93b13b
14 changed files with 1813 additions and 15 deletions
+114
View File
@@ -0,0 +1,114 @@
#!/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"<!-- 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 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"(?<![\w.])\d+(?:\.\d+)?", code))
self.assertTrue(nums <= {"0", "1", "2", "1024"}, "sizing.js holds its own numbers: %s" % sorted(nums - {"0", "1", "2", "1024"}))
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)