Website: machine estimator v2 — fold-out categories and the memory bar (still unlisted, R-997)
gates / gates (push) Successful in 6m31s

Each category is a native <details>, closed at first, with an 'n selected' count;
a preset opens what it ticks. A sticky memory bar (0-64 GB, marks 8/16/24/32/48/64+):
solid fill = smallest box memory where the selection fits well, thin mark = where it
fits, but tight; one line names the box to buy. minBoxGb() inverts guestMib() by
bisection on it, so the rule is still written once. Box sizes 12/24/48/64 added;
tests cover seven sizes and the inverse (red-proofed); SIZING.md table gains 24 GB.
This commit is contained in:
2026-10-13 15:38:45 +02:00
parent d5ef93b13b
commit 16dca64834
11 changed files with 277 additions and 110 deletions
+55 -11
View File
@@ -36,6 +36,19 @@ def guest_mib(d, gb): # mirrors sizing.js guestMib()
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"))
@@ -46,6 +59,10 @@ def estimate(d, slugs): # mirrors sizing.js estimate()
return peak, out
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):
@@ -61,19 +78,39 @@ class SizingWeb(unittest.TestCase):
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(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, ["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"]),
"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) in cases.items():
self.assertEqual(estimate(self.d, sel)[1], want, name)
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_sizing_md_table_agrees_with_the_rule(self):
with io.open(SIZING_MD, encoding="utf-8") as f:
@@ -83,7 +120,9 @@ class SizingWeb(unittest.TestCase):
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", "---"):
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}
@@ -91,7 +130,9 @@ class SizingWeb(unittest.TestCase):
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)
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()
@@ -103,7 +144,10 @@ class SizingWeb(unittest.TestCase):
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"}))
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"):
self.assertIn(need, js)
def test_data_has_no_peak_below_typical(self):
for a in self.d["apps"]: