From 7e2baf8349345551c6b6ea6f1ed2fd408ff98e5f Mon Sep 17 00:00:00 2001 From: EMRG Evolution Date: Tue, 27 Oct 2026 22:29:37 +0800 Subject: [PATCH 1/3] emrg: pin the app icon's design source with a guard --- tests/test_icon_design_layers.py | 286 +++++++++++++++++++++++++++++++ 1 file changed, 286 insertions(+) create mode 100644 tests/test_icon_design_layers.py diff --git a/tests/test_icon_design_layers.py b/tests/test_icon_design_layers.py new file mode 100644 index 00000000..cc83d68e --- /dev/null +++ b/tests/test_icon_design_layers.py @@ -0,0 +1,286 @@ +"""The icon's design source must keep the properties nothing renders for us. + +`tests/test_gen_assets_keeps_the_icon_transparent.py` guards the *products* the +generator emits, and reads the generator's script; its only statement about the +design source is `MARGIN = 56`. So every property `packaging/assets/icon.svg` +carries on its own went unmeasured, and a revert of any of them passes every leg. + +Four properties, each one a defect this project has already shipped or nearly +shipped: + +1. **Layer order at the hub.** The hub's circles are painted *after* the + branches. Painted before them, the branch caps converge on the hub's face and + each branch's own gradient starts at its darkest stop there, which reads as a + dark blotch on the sphere. +2. **One light for the fan.** `branchGrad` is a `userSpaceOnUse` radial gradient + centred on the hub. Under the default `objectBoundingBox` units each branch + resolves the gradient against its own bounding box, and the illumination + points five different ways. +3. **Four nodes.** A fifth branch close to its neighbours shrinks to a blob + below 128 px. +4. **The body rectangle.** 56 inset, 912 square, `rx` 206 on a 1024 canvas — the + shape every platform clips an app icon into. + +The guard is a function over SVG **text**, so the refuse direction costs a +literal rather than a filesystem: the shipped file must come back faultless, and +a document with the previous layering must be refused. An assertion that can only +ever see the good file proves nothing about the bad one. + +It reads no generated product: those are gitignored build output, and producing +them needs a renderer the test runners cannot be assumed to have. +""" + +from __future__ import annotations + +import re +import xml.etree.ElementTree as ET +from pathlib import Path + +REPO = Path(__file__).resolve().parent.parent +SVG = REPO / "packaging" / "assets" / "icon.svg" + +NS = "{http://www.w3.org/2000/svg}" + +#: Every branch's stroke is this gradient; the count is the fan's branch count. +BRANCH_STROKE = "url(#branchGrad)" +BRANCHES = 4 + +#: A node's body radius. The card is the two other layers of each node (glow halo, +#: bright core), so counting the bodies counts the nodes. +NODE_BODY_R = "88" + +#: The hub is centred here; its glow halo and body share that centre exactly. +HUB = ("512", "650") + +#: The hub's third layer is its specular highlight, and it is deliberately **not** +#: concentric: it sits up and left of the centre, the direction every node's core +#: leans (a node's body is at `(x, y)` and its core at `(x-6, y-8)`). So "a layer +#: at the hub" means within this many user units of the centre, not on it — on the +#: shipped file the highlight is 10.6 units out. +HUB_HIGHLIGHT_OFFSET_MAX = 16.0 + +#: The app-icon body: 1024 canvas, inset 56, so a 912 square with a 206 radius. +BODY = {"x": "56", "y": "56", "width": "912", "height": "912", "rx": "206"} + + +def distance_from_hub(element: ET.Element) -> float: + """How far a shape's centre sits from the hub's, in user units. + + `nan` for anything whose `cx`/`cy` is not a number, which every comparison + rejects: a circle that cannot be placed is not one of the hub's layers. + """ + try: + dx = float(element.get("cx", "nan")) - float(HUB[0]) + dy = float(element.get("cy", "nan")) - float(HUB[1]) + except (TypeError, ValueError): + return float("nan") + return (dx * dx + dy * dy) ** 0.5 + + +def faults(svg_text: str) -> list[str]: + """Every way `svg_text` breaks the design, in the design's own terms. + + Returns an empty list for a document that keeps all four properties. Parsing + is the caller's problem to see: an unreadable document raises, because "could + not measure" is not "passed". + """ + root = ET.fromstring(svg_text) + elements = list(root.iter()) + + problems: list[str] = [] + + # 1 + 3. The branches, in document order, and the hub's place among them. + painted = [ + i + for i, element in enumerate(elements) + if element.tag in (NS + "path", NS + "circle", NS + "rect", NS + "ellipse") + ] + branches = [ + i + for i in painted + if elements[i].tag == NS + "path" and elements[i].get("stroke") == BRANCH_STROKE + ] + hub = [ + i + for i in painted + if elements[i].tag == NS + "circle" + and distance_from_hub(elements[i]) <= HUB_HIGHLIGHT_OFFSET_MAX + ] + + if len(branches) != BRANCHES: + problems.append( + f"the fan has {len(branches)} branch(es) stroked with {BRANCH_STROKE}, " + f"expected {BRANCHES}" + ) + if len(hub) != 3: + problems.append( + f"the hub has {len(hub)} layer(s) within {HUB_HIGHLIGHT_OFFSET_MAX:g} units " + f"of cx={HUB[0]} cy={HUB[1]}, expected 3 (glow halo, body, bright core)" + ) + else: + # Two of the three sit on the centre; the third is the highlight, and it + # must lean up and left like every node's, or the hub reads as a flat disc. + concentric = [i for i in hub if distance_from_hub(elements[i]) == 0.0] + offset = [i for i in hub if i not in concentric] + if len(concentric) != 2 or len(offset) != 1: + problems.append( + f"the hub has {len(concentric)} concentric layer(s) and " + f"{len(offset)} offset one(s), expected 2 and 1: a halo and a body on " + "the centre, and one specular highlight beside it" + ) + else: + core = elements[offset[0]] + dx = float(core.get("cx")) - float(HUB[0]) + dy = float(core.get("cy")) - float(HUB[1]) + if not (dx < 0 and dy < 0): + problems.append( + f"the hub's bright core is offset ({dx:+g}, {dy:+g}) from the " + "centre; it must sit up and left of it, the direction every " + "node's core leans, or the sphere reads as a flat disc" + ) + if not branches or not hub: + problems.append( + "no branch and hub to order: the layer-order property cannot be read " + "without both, so this document is not the icon this test knows" + ) + elif min(hub) < max(branches): + problems.append( + "the hub is painted before the branches: its circles must come after " + f"every branch (first hub layer at position {min(hub)}, last branch at " + f"{max(branches)}), or the branch caps smear across its face" + ) + + nodes = [ + element + for element in elements + if element.tag == NS + "circle" and element.get("r") == NODE_BODY_R + ] + if len(nodes) != BRANCHES: + problems.append( + f"the fan has {len(nodes)} node(s) of radius {NODE_BODY_R}, expected {BRANCHES}" + ) + + # 2. One light, in user space, centred on the hub. + match = re.search(r"]*\bid=\"branchGrad\"[^>]*>", svg_text) + if match is None: + problems.append("no radialGradient declares id=\"branchGrad\"") + else: + head = match.group(0) + if "gradientUnits=\"userSpaceOnUse\"" not in head: + problems.append( + "branchGrad is not in userSpaceOnUse units: under the default " + "objectBoundingBox each branch resolves the light against its own " + "box, so the fan is lit from a different direction per branch" + ) + if f"cx=\"{HUB[0]}\"" not in head or f"cy=\"{HUB[1]}\"" not in head: + problems.append( + f"branchGrad is not centred on the hub (cx={HUB[0]} cy={HUB[1]})" + ) + + # 4. The app-icon body every platform clips against. + rects = [element for element in elements if element.tag == NS + "rect"] + if len(rects) != 1: + problems.append(f"expected exactly one body rect, found {len(rects)}") + else: + for name, value in BODY.items(): + if rects[0].get(name) != value: + problems.append( + f"the body rect's {name} is {rects[0].get(name)!r}, expected {value!r}" + ) + + return problems + + +def test_the_shipped_icon_keeps_every_designed_property() -> None: + """The file that ships is the file these four properties describe.""" + problems = faults(SVG.read_text(encoding="utf-8")) + assert problems == [], "the shipped icon.svg breaks its own design:\n" + "\n".join( + f" - {problem}" for problem in problems + ) + + +def test_the_check_refuses_the_layering_it_replaced() -> None: + """Below the branches, and five of them: refused, and refused for both reasons. + + The document is synthetic on purpose. The previous design is not in this tree + any more, so a refusal proved against a missing file would measure nothing; + this one carries the two defects in the smallest document that can hold them. + """ + old_layering = """ + + + + + + + + + + + + + + + + + +""" + + problems = faults(old_layering) + + assert any("painted before the branches" in problem for problem in problems), ( + "the check accepted the hub painted under the branches; it reports:\n" + + "\n".join(f" - {problem}" for problem in problems) + ) + assert any("5 branch(es)" in problem for problem in problems), ( + "the check accepted the five-branch fan; it reports:\n" + + "\n".join(f" - {problem}" for problem in problems) + ) + assert any("userSpaceOnUse" in problem for problem in problems), ( + "the check accepted a branchGrad in objectBoundingBox units; it reports:\n" + + "\n".join(f" - {problem}" for problem in problems) + ) + + +def test_the_check_refuses_a_body_rect_that_moved() -> None: + """A whole-file replacement is where the 56/912/206 body quietly moves. + + Every product is clipped to this rectangle, so a shifted square is invisible + to the transparency guards and visible to every user's Dock. + """ + moved = SVG.read_text(encoding="utf-8").replace( + 'x="56" y="56" width="912" height="912" rx="206"', + 'x="64" y="56" width="896" height="912" rx="206"', + 1, + ) + assert moved != SVG.read_text(encoding="utf-8"), ( + "the substitution did not apply, so this arm measures nothing" + ) + + problems = faults(moved) + assert any("x is '64'" in problem for problem in problems), ( + "the check accepted a body rect that moved; it reports:\n" + + "\n".join(f" - {problem}" for problem in problems) + ) + + +def test_the_check_refuses_a_highlight_that_leans_the_wrong_way() -> None: + """The hub's bright core is where the light comes from, and it leans up-left. + + Mirroring it across the centre says the light comes from below right, and the + sphere then reads as a flat disc lit from nowhere the branches agree with. The + shipped file carries the one substitution, so a fault reported here can only + be the lean. + """ + mirrored = SVG.read_text(encoding="utf-8") + bent = mirrored.replace('cx="505" cy="642"', 'cx="519" cy="658"', 1) + assert bent != mirrored, "the substitution did not apply, so this arm measures nothing" + + problems = faults(bent) + assert len(problems) == 1, ( + "the check reports more than the lean, so this arm cannot attribute it:\n" + + "\n".join(f" - {problem}" for problem in problems) + ) + assert "up and left" in problems[0], ( + f"the check reported {problems[0]!r}, which does not name the lean" + ) From d76ef9caaea1ba108b4f086debfde9dc4663b75e Mon Sep 17 00:00:00 2001 From: argszero Date: Wed, 28 Oct 2026 00:59:46 +0800 Subject: [PATCH 2/3] emrg: the icon guard reads the design, not the shipped coordinates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The veto on this head named four literals standing in for properties, each of which refused a redesign that keeps all four properties: - a node was counted by `r="88"`, which paints the glow halo (`nodeGlow`), not the body (`r="42"`) — so a fan whose haloes were resized to 92 was reported as having no nodes at all, and the message named a radius instead of the design - the hub was pinned at 512,650, so moving the hub, its highlight, its light and the branch origins together was refused three times - the gradient was pinned by id, so renaming it lost the fan - a 16-unit box stood in for the highlight's lean, though nothing fixes how far it leans Now the hub is derived from the point the branches leave, a branch is a path that leaves it stroked with a gradient (the id is resolved from those strokes), a node is counted by the layer that exists once per node, and the highlight is the nearest layer beside the centre — its lean is checked, its distance is the design's. Three arms hold the accept direction: haloes resized, the hub moved with its light, and the gradient renamed. All three were refused by the version this replaces, and the two refusals that matter — the layer order and the lean — are unchanged, reason text included. Full suite on this tree: 5957 passed, 37 skipped; the three added arms are the difference from the 5954 measured on the previous head. --- tests/test_icon_design_layers.py | 313 +++++++++++++++++++++++-------- 1 file changed, 237 insertions(+), 76 deletions(-) diff --git a/tests/test_icon_design_layers.py b/tests/test_icon_design_layers.py index cc83d68e..02128ecf 100644 --- a/tests/test_icon_design_layers.py +++ b/tests/test_icon_design_layers.py @@ -12,19 +12,22 @@ branches. Painted before them, the branch caps converge on the hub's face and each branch's own gradient starts at its darkest stop there, which reads as a dark blotch on the sphere. -2. **One light for the fan.** `branchGrad` is a `userSpaceOnUse` radial gradient - centred on the hub. Under the default `objectBoundingBox` units each branch - resolves the gradient against its own bounding box, and the illumination - points five different ways. +2. **One light for the fan.** The branches are all stroked with a single radial + gradient in `userSpaceOnUse` units centred on the hub — `branchGrad` on the + shipped file. Under the default `objectBoundingBox` units each branch resolves + the gradient against its own bounding box, and the illumination points a + different way per branch. 3. **Four nodes.** A fifth branch close to its neighbours shrinks to a blob below 128 px. 4. **The body rectangle.** 56 inset, 912 square, `rx` 206 on a 1024 canvas — the shape every platform clips an app icon into. -The guard is a function over SVG **text**, so the refuse direction costs a -literal rather than a filesystem: the shipped file must come back faultless, and -a document with the previous layering must be refused. An assertion that can only -ever see the good file proves nothing about the bad one. +The guard is a function over SVG **text**, so both directions cost a literal +rather than a filesystem: the shipped file must come back faultless, a document +with the previous layering must be refused, and a redesign that keeps the four +properties must come back faultless too. An assertion that can only ever see the +good file proves nothing about the bad one, and one that pins the shipped +coordinates rather than the design reports every redesign as a broken design. It reads no generated product: those are gitignored build output, and producing them needs a renderer the test runners cannot be assumed to have. @@ -34,6 +37,7 @@ import re import xml.etree.ElementTree as ET +from collections import Counter from pathlib import Path REPO = Path(__file__).resolve().parent.parent @@ -41,51 +45,76 @@ NS = "{http://www.w3.org/2000/svg}" -#: Every branch's stroke is this gradient; the count is the fan's branch count. -BRANCH_STROKE = "url(#branchGrad)" +#: How many branches the fan has, and therefore how many nodes. BRANCHES = 4 -#: A node's body radius. The card is the two other layers of each node (glow halo, -#: bright core), so counting the bodies counts the nodes. -NODE_BODY_R = "88" - -#: The hub is centred here; its glow halo and body share that centre exactly. -HUB = ("512", "650") - -#: The hub's third layer is its specular highlight, and it is deliberately **not** -#: concentric: it sits up and left of the centre, the direction every node's core -#: leans (a node's body is at `(x, y)` and its core at `(x-6, y-8)`). So "a layer -#: at the hub" means within this many user units of the centre, not on it — on the -#: shipped file the highlight is 10.6 units out. -HUB_HIGHLIGHT_OFFSET_MAX = 16.0 +#: A node's **glow halo** — the one layer of a node's three that exists exactly once +#: per node, and the only one named by what it is rather than by a size. On the +#: shipped file the halo is `r="88"`, the body `r="42"` and the highlight `r="15"`, +#: so counting by radius counted the halo while calling it the body, and resizing a +#: node — a redesign that keeps four nodes — read as the fan losing all of them. +#: The bodies cannot serve this purpose: the hub has one, and the hub is not a node. +NODE_HALO_FILL = "url(#nodeGlow)" #: The app-icon body: 1024 canvas, inset 56, so a 912 square with a 206 radius. BODY = {"x": "56", "y": "56", "width": "912", "height": "912", "rx": "206"} -def distance_from_hub(element: ET.Element) -> float: - """How far a shape's centre sits from the hub's, in user units. +def _start_point(element: ET.Element) -> tuple[float, float] | None: + """Where a path begins, in user units; `None` when its `d` does not say.""" + match = re.match(r"\s*M\s*(-?[\d.]+)[\s,]+(-?[\d.]+)", element.get("d") or "") + if match is None: + return None + return (float(match.group(1)), float(match.group(2))) - `nan` for anything whose `cx`/`cy` is not a number, which every comparison - rejects: a circle that cannot be placed is not one of the hub's layers. - """ + +def _centre(element: ET.Element) -> tuple[float, float] | None: + """A shape's centre, or `None` when it carries no placeable `cx`/`cy`.""" try: - dx = float(element.get("cx", "nan")) - float(HUB[0]) - dy = float(element.get("cy", "nan")) - float(HUB[1]) + return (float(element.get("cx")), float(element.get("cy"))) except (TypeError, ValueError): - return float("nan") - return (dx * dx + dy * dy) ** 0.5 + return None + + +def _distance(a: tuple[float, float], b: tuple[float, float]) -> float: + return ((a[0] - b[0]) ** 2 + (a[1] - b[1]) ** 2) ** 0.5 + + +def hub_centre(elements: list[ET.Element]) -> tuple[float, float] | None: + """The point the fan emanates from, read off the paths instead of pinned. + + The branches begin at the hub, so the start point the most stroked paths share + *is* the hub; a blurred copy of a branch starts where its crisp original does, + which only makes that point more common. `None` when no path declares a start, + which the caller reports rather than supplying a centre for. + + Deriving it is the difference between pinning the design and pinning a pair of + coordinates: a redesign that moves the hub, its highlight, its light and the + branch origins together is still the same design, and a literal at the top of + this file cannot tell that from a fan detached from its hub. + """ + starts = [ + _start_point(element) + for element in elements + if element.tag == NS + "path" and element.get("stroke") + ] + counted = Counter(point for point in starts if point is not None) + if not counted: + return None + return counted.most_common(1)[0][0] def faults(svg_text: str) -> list[str]: """Every way `svg_text` breaks the design, in the design's own terms. - Returns an empty list for a document that keeps all four properties. Parsing - is the caller's problem to see: an unreadable document raises, because "could - not measure" is not "passed". + Returns an empty list for a document that keeps all four properties — the + shipped one, and any redesign that keeps them, which the accept arms at the end + of this file hold it to. Parsing is the caller's problem to see: an unreadable + document raises, because "could not measure" is not "passed". """ root = ET.fromstring(svg_text) elements = list(root.iter()) + centre = hub_centre(elements) problems: list[str] = [] @@ -95,43 +124,64 @@ def faults(svg_text: str) -> list[str]: for i, element in enumerate(elements) if element.tag in (NS + "path", NS + "circle", NS + "rect", NS + "ellipse") ] + # A branch is a path that leaves the hub lit by a gradient. Neither the gradient's + # id nor the hub's coordinates are named here: an id is a name and a coordinate is + # where the last designer put it, while the property is where the light resolves to + # and which point the fan leaves from. branches = [ i for i in painted - if elements[i].tag == NS + "path" and elements[i].get("stroke") == BRANCH_STROKE + if centre is not None + and elements[i].tag == NS + "path" + and _start_point(elements[i]) == centre + and (elements[i].get("stroke") or "").startswith("url(#") + ] + concentric = [ + i + for i in painted + if centre is not None + and elements[i].tag == NS + "circle" + and _centre(elements[i]) == centre ] - hub = [ + # The hub's third layer is its specular highlight, and it is deliberately not + # concentric: it sits up and left of the centre, the direction every node's core + # leans (a node's body is at `(x, y)`, its core at `(x-6, y-8)`). So it is read as + # the nearest circle beside the centre — the lean is the property, and how far it + # leans is the design's to choose, which a maximum distance would take away. + beside = [ i for i in painted - if elements[i].tag == NS + "circle" - and distance_from_hub(elements[i]) <= HUB_HIGHLIGHT_OFFSET_MAX + if centre is not None + and elements[i].tag == NS + "circle" + and _centre(elements[i]) is not None + and _centre(elements[i]) != centre ] + highlight = ( + min(beside, key=lambda i: _distance(_centre(elements[i]), centre)) + if beside and centre is not None + else None + ) + hub = concentric + ([] if highlight is None else [highlight]) if len(branches) != BRANCHES: problems.append( - f"the fan has {len(branches)} branch(es) stroked with {BRANCH_STROKE}, " - f"expected {BRANCHES}" - ) - if len(hub) != 3: - problems.append( - f"the hub has {len(hub)} layer(s) within {HUB_HIGHLIGHT_OFFSET_MAX:g} units " - f"of cx={HUB[0]} cy={HUB[1]}, expected 3 (glow halo, body, bright core)" + f"the fan has {len(branches)} branch(es) leaving the hub lit by a " + f"gradient, expected {BRANCHES}" ) - else: - # Two of the three sit on the centre; the third is the highlight, and it - # must lean up and left like every node's, or the hub reads as a flat disc. - concentric = [i for i in hub if distance_from_hub(elements[i]) == 0.0] - offset = [i for i in hub if i not in concentric] - if len(concentric) != 2 or len(offset) != 1: + if centre is not None: + if len(concentric) != 2: + problems.append( + f"the hub has {len(concentric)} layer(s) centred on the point its " + "branches leave, expected 2: a glow halo and a body" + ) + if highlight is None: problems.append( - f"the hub has {len(concentric)} concentric layer(s) and " - f"{len(offset)} offset one(s), expected 2 and 1: a halo and a body on " - "the centre, and one specular highlight beside it" + "the hub has no layer beside its centre, so nothing on it says which " + "way the light comes from and the sphere reads as a flat disc" ) else: - core = elements[offset[0]] - dx = float(core.get("cx")) - float(HUB[0]) - dy = float(core.get("cy")) - float(HUB[1]) + hx, hy = _centre(elements[highlight]) + dx, dy = hx - centre[0], hy - centre[1] if not (dx < 0 and dy < 0): problems.append( f"the hub's bright core is offset ({dx:+g}, {dy:+g}) from the " @@ -150,32 +200,67 @@ def faults(svg_text: str) -> list[str]: f"{max(branches)}), or the branch caps smear across its face" ) - nodes = [ + # 3. One node per branch. Counted by the layer that exists once per node, so a + # node resized is still a node; a node the fan does not have still reads as one + # the fan is missing. + haloes = [ element for element in elements - if element.tag == NS + "circle" and element.get("r") == NODE_BODY_R + if element.tag == NS + "circle" and element.get("fill") == NODE_HALO_FILL ] - if len(nodes) != BRANCHES: + if len(haloes) != BRANCHES: problems.append( - f"the fan has {len(nodes)} node(s) of radius {NODE_BODY_R}, expected {BRANCHES}" + f"the fan has {len(haloes)} node halo(s) painted with {NODE_HALO_FILL}, " + f"expected {BRANCHES}" ) - # 2. One light, in user space, centred on the hub. - match = re.search(r"]*\bid=\"branchGrad\"[^>]*>", svg_text) - if match is None: - problems.append("no radialGradient declares id=\"branchGrad\"") - else: - head = match.group(0) - if "gradientUnits=\"userSpaceOnUse\"" not in head: - problems.append( - "branchGrad is not in userSpaceOnUse units: under the default " - "objectBoundingBox each branch resolves the light against its own " - "box, so the fan is lit from a different direction per branch" - ) - if f"cx=\"{HUB[0]}\"" not in head or f"cy=\"{HUB[1]}\"" not in head: + # 2. One light for the fan: the gradient its branches reference, in user space, + # centred on the hub they leave. + referenced = [ + match.group(1) + for i in branches + if (match := re.fullmatch(r"\s*url\(#([^)]+)\)\s*", elements[i].get("stroke") or "")) + ] + if branches and len(set(referenced)) != 1: + problems.append( + f"the fan's {len(branches)} branch(es) reference {len(set(referenced))} " + f"different gradient(s) ({', '.join(sorted(set(referenced)))}): one light " + "for the fan means one gradient" + ) + elif referenced: + wanted = referenced[0] + gradient = next( + ( + element + for element in elements + if element.tag == NS + "radialGradient" and element.get("id") == wanted + ), + None, + ) + if gradient is None: problems.append( - f"branchGrad is not centred on the hub (cx={HUB[0]} cy={HUB[1]})" + f"the branches are stroked with url(#{wanted}) and no radialGradient " + "declares that id, so the light they carry cannot be read" ) + else: + if gradient.get("gradientUnits") != "userSpaceOnUse": + problems.append( + f"{wanted} is not in userSpaceOnUse units: under the default " + "objectBoundingBox each branch resolves the light against its own " + "box, so the fan is lit from a different direction per branch" + ) + lit = _centre(gradient) + if lit is None: + problems.append( + f"{wanted} has no cx/cy that read as a centre, so where the fan " + "is lit from cannot be measured" + ) + elif centre is not None and lit != centre: + problems.append( + f"{wanted} is centred on ({lit[0]:g}, {lit[1]:g}) and the branches " + f"leave the hub at ({centre[0]:g}, {centre[1]:g}): the light must " + "come from the hub, or the fan is lit from beside it" + ) # 4. The app-icon body every platform clips against. rects = [element for element in elements if element.tag == NS + "rect"] @@ -284,3 +369,79 @@ def test_the_check_refuses_a_highlight_that_leans_the_wrong_way() -> None: assert "up and left" in problems[0], ( f"the check reported {problems[0]!r}, which does not name the lean" ) + + +# --- the accept direction: a redesign that keeps the properties -------------------------------- +# +# The three arms above prove the guard refuses what it means to refuse. They cannot prove it +# accepts what it means to accept, and each of these three was refused by an earlier version of +# this file: it counted a radius, it pinned a hub's coordinates, and it named the gradient. A +# guard that reports every redesign as a broken design is measuring the shipped file, not the +# design, and it will be answered by loosening the guard until it measures nothing. + + +def test_the_check_accepts_a_fan_whose_nodes_were_resized() -> None: + """A halo's size is a size, not the node: four haloes stay four nodes at 92. + + Counting `r="88"` refused a fan with four branches, four nodes, one light and an + untouched body rect, and its message named the radius instead of the design. What + makes four nodes four is that four of them are painted at all. + """ + text = SVG.read_text(encoding="utf-8") + resized = text.replace('r="88"', 'r="92"') + assert resized != text, "the substitution did not apply, so this arm measures nothing" + found = resized.count('r="92"') + assert found == BRANCHES, f"expected {BRANCHES} resized haloes, found {found}" + + problems = faults(resized) + assert problems == [], ( + "the check refuses a fan whose nodes were resized, though every property holds:\n" + + "\n".join(f" - {problem}" for problem in problems) + ) + + +def test_the_check_accepts_a_hub_that_moved_with_its_light() -> None: + """The hub, its highlight, its light and the branch origins moved together: one design. + + Moved down 40 units as a set, the sphere is the same sphere in a different place. + Four literals had to agree for the shipped file to pass, so a coherent move broke all + four at once while breaking nothing a reader of the icon could see. + """ + text = SVG.read_text(encoding="utf-8") + moved = text.replace("M 512 650 ", "M 512 690 ") + moved = moved.replace('cx="512" cy="650"', 'cx="512" cy="690"') + moved = moved.replace('cx="505" cy="642"', 'cx="505" cy="682"') + + assert moved != text, "the substitution did not apply, so this arm measures nothing" + at_new_centre = moved.count('cy="690"') + assert at_new_centre == 3, ( + "the hub, its body and the gradient must all sit at the new centre, found " + f"{at_new_centre} of 3" + ) + assert "M 512 650 " not in moved, "a branch still leaves the old centre" + + problems = faults(moved) + assert problems == [], ( + "the check refuses a hub that moved together with its light:\n" + + "\n".join(f" - {problem}" for problem in problems) + ) + + +def test_the_check_accepts_a_fan_lit_by_a_renamed_gradient() -> None: + """`branchGrad` is a name; the property is user-space units centred on the hub. + + Renamed everywhere it appears — the gradient and every stroke that references it — the + light is the same light. Pinning the id made the name the requirement. + """ + text = SVG.read_text(encoding="utf-8") + renamed = text.replace("branchGrad", "fanLight") + assert renamed != text and "branchGrad" not in renamed, ( + "the substitution did not apply, so this arm measures nothing" + ) + assert renamed.count("url(#fanLight)") == BRANCHES + + problems = faults(renamed) + assert problems == [], ( + "the check refuses a fan whose one gradient was renamed:\n" + + "\n".join(f" - {problem}" for problem in problems) + ) From c65e8bb3b330ecfcf68e33da50b2954eb14444c2 Mon Sep 17 00:00:00 2001 From: argszero Date: Wed, 28 Oct 2026 05:18:42 +0800 Subject: [PATCH 3/3] emrg: a node is where a branch arrives, not the name of its layer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The veto on this head named the last literal standing in for a property: `NODE_HALO_FILL = "url(#nodeGlow)"` counted nodes by an id. A document that renamed the per-node layer coherently — the gradient and every `fill` that uses it, nothing else touched — was refused as having no nodes at all, which is the same fault this file had already removed from the other three properties, and the opposite of what its own renamed-gradient arm says about ids being names. The count is now read from the document. A branch's `d` ends where its node is, so the arrivals are the node centres; the largest circle at each is that node's outer layer; and the property is that the fan's nodes are shaped alike and that no node of that size sits where no branch ends. No id and no size is named. Both directions, measured against the guard this replaces (`d76ef9ca`): - per-node layer renamed `nodeGlow` -> `nodeHalo`: previously refused ("the fan has 0 node halo(s) painted with url(#nodeGlow)"), now faultless - one node's outer layer dropped: still refused, now as "the fan's nodes are not shaped alike: their outermost layers measure 42, 88" - a node added where no branch ends: still refused, now as "a node layer of 88 sits at 820, 650 where no branch ends" - a node body resized, and the shipped file: accepted by both One correction to the review's recipe, said plainly rather than quietly: counting the branch endpoints alone answers 4 on all four of those documents, because removing a halo leaves the branch arriving at the same point. What makes the dropped and the added node readable without a literal is the shape check — the four nodes' outermost layers must agree, and that size must not appear elsewhere — so that is what the count rests on. Three arms hold it: the renamed layer (which would have caught this), the dropped outer layer, and the node beside the fan. Full suite on this tree: 5960 passed, 37 skipped; the three arms are the difference from 5957. --- tests/test_icon_design_layers.py | 169 +++++++++++++++++++++++++++---- 1 file changed, 149 insertions(+), 20 deletions(-) diff --git a/tests/test_icon_design_layers.py b/tests/test_icon_design_layers.py index 02128ecf..90051f64 100644 --- a/tests/test_icon_design_layers.py +++ b/tests/test_icon_design_layers.py @@ -17,8 +17,10 @@ shipped file. Under the default `objectBoundingBox` units each branch resolves the gradient against its own bounding box, and the illumination points a different way per branch. -3. **Four nodes.** A fifth branch close to its neighbours shrinks to a blob - below 128 px. +3. **Four nodes, one per branch.** A fifth branch close to its neighbours shrinks + to a blob below 128 px; a branch that arrives bare, or a node beside the fan, + breaks the same count. Which layer is a node's is read from the document — the + largest circle where a branch arrives — so no id and no size is fixed here. 4. **The body rectangle.** 56 inset, 912 square, `rx` 206 on a 1024 canvas — the shape every platform clips an app icon into. @@ -48,13 +50,12 @@ #: How many branches the fan has, and therefore how many nodes. BRANCHES = 4 -#: A node's **glow halo** — the one layer of a node's three that exists exactly once -#: per node, and the only one named by what it is rather than by a size. On the -#: shipped file the halo is `r="88"`, the body `r="42"` and the highlight `r="15"`, -#: so counting by radius counted the halo while calling it the body, and resizing a -#: node — a redesign that keeps four nodes — read as the fan losing all of them. -#: The bodies cannot serve this purpose: the hub has one, and the hub is not a node. -NODE_HALO_FILL = "url(#nodeGlow)" +#: No constant names a node, because a node has no name in the design: it is a place +#: a branch arrives. Counting one named its layer's gradient id, so a fan whose +#: per-node layer was renamed coherently — the gradient and every `fill` that uses +#: it, nothing else touched — was refused as having no nodes at all. That is the +#: same fault as counting a radius, one constant over: an id is a name, and the +#: property is that each branch ends in a node shaped like the others. #: The app-icon body: 1024 canvas, inset 56, so a 912 square with a 206 radius. BODY = {"x": "56", "y": "56", "width": "912", "height": "912", "rx": "206"} @@ -68,6 +69,18 @@ def _start_point(element: ET.Element) -> tuple[float, float] | None: return (float(match.group(1)), float(match.group(2))) +def _end_point(element: ET.Element) -> tuple[float, float] | None: + """Where a path arrives, in user units; `None` when its `d` does not say. + + The last coordinate pair is where the branch lands, which is where its node is. + """ + pairs = re.findall(r"(-?\d+(?:\.\d+)?)[\s,]+(-?\d+(?:\.\d+)?)", element.get("d") or "") + if not pairs: + return None + x, y = pairs[-1] + return (float(x), float(y)) + + def _centre(element: ET.Element) -> tuple[float, float] | None: """A shape's centre, or `None` when it carries no placeable `cx`/`cy`.""" try: @@ -200,19 +213,70 @@ def faults(svg_text: str) -> list[str]: f"{max(branches)}), or the branch caps smear across its face" ) - # 3. One node per branch. Counted by the layer that exists once per node, so a - # node resized is still a node; a node the fan does not have still reads as one - # the fan is missing. - haloes = [ - element - for element in elements - if element.tag == NS + "circle" and element.get("fill") == NODE_HALO_FILL - ] - if len(haloes) != BRANCHES: + # 3. One node per branch, read from where the branches arrive rather than from a + # name or a size. Nothing here says what a node's layer is called or how big it + # is: a node is a place a branch ends, its outermost layer is whatever is largest + # there, and the property is that the fan's nodes are shaped alike. A renamed or + # resized node therefore passes, while a node missing from one branch, an extra + # one where no branch ends, or a fan of uneven nodes is refused. + arrivals = sorted( + { + point + for i in branches + if (point := _end_point(elements[i])) is not None + } + ) + outermost: dict[tuple[float, float], float | None] = {} + for point in arrivals: + at_node = [ + float(element.get("r")) + for element in elements + if element.tag == NS + "circle" + and _centre(element) == point + and element.get("r") is not None + ] + outermost[point] = max(at_node) if at_node else None + + if len(arrivals) != BRANCHES: + problems.append( + f"the fan's branches arrive at {len(arrivals)} point(s), expected " + f"{BRANCHES}, so it cannot carry one node per branch" + ) + if not outermost: problems.append( - f"the fan has {len(haloes)} node halo(s) painted with {NODE_HALO_FILL}, " - f"expected {BRANCHES}" + "no node can be read: no branch's path says where it arrives, so the " + "fan's node count cannot be measured from this document" ) + else: + bare = [point for point, size in outermost.items() if size is None] + if bare: + problems.append( + f"a branch arrives at {bare[0][0]:g}, {bare[0][1]:g} where no node is " + "painted, so that branch ends bare" + ) + sizes = sorted({size for size in outermost.values() if size is not None}) + if len(sizes) > 1: + problems.append( + f"the fan's nodes are not shaped alike: their outermost layers measure " + f"{', '.join(f'{size:g}' for size in sizes)}, so at least one node is " + "missing its outer layer or carries a second one" + ) + if sizes: + stray = [ + _centre(element) + for element in elements + if element.tag == NS + "circle" + and element.get("r") is not None + and float(element.get("r")) == sizes[-1] + and _centre(element) not in outermost + ] + if stray: + problems.append( + f"a node layer of {sizes[-1]:g} sits at " + f"{stray[0][0]:g}, {stray[0][1]:g} where no branch ends: the fan " + f"has {len(arrivals)} node(s) for {len(arrivals) + len(stray)} " + "painted, so a fifth sits beside the four" + ) # 2. One light for the fan: the gradient its branches reference, in user space, # centred on the hub they leave. @@ -445,3 +509,68 @@ def test_the_check_accepts_a_fan_lit_by_a_renamed_gradient() -> None: "the check refuses a fan whose one gradient was renamed:\n" + "\n".join(f" - {problem}" for problem in problems) ) + + +def test_the_check_accepts_a_fan_whose_node_layer_was_renamed() -> None: + """A node's layer is named too, and its name is no more a property than the + gradient's: renamed at the gradient and at every `fill` that uses it, the fan is + the same fan with four nodes. + + This is the arm that was missing when the count was pinned to `url(#nodeGlow)`. + The file already held that an id is a name for the light, and broke the rule one + constant over for the nodes — a rename is a rename whichever layer carries it. + """ + text = SVG.read_text(encoding="utf-8") + renamed = text.replace("nodeGlow", "nodeHalo") + assert renamed != text and "nodeGlow" not in renamed, ( + "the substitution did not apply, so this arm measures nothing" + ) + assert renamed.count("url(#nodeHalo)") == text.count("url(#nodeGlow)"), ( + "the rename must reach the gradient and every fill that uses it, found " + f"{renamed.count('url(#nodeHalo)')} of {text.count('url(#nodeGlow)')}" + ) + + problems = faults(renamed) + assert problems == [], ( + "the check refuses a fan whose per-node layer was renamed, though every " + "property holds:\n" + "\n".join(f" - {problem}" for problem in problems) + ) + + +def test_the_check_refuses_a_node_missing_its_outer_layer() -> None: + """One branch arriving at a node that is not shaped like the rest: refused. + + The rename arm above must not be bought by making the count vacuous. A node whose + outermost layer is gone still has a body and a highlight where its branch lands, + so a count of places would call it a node; what marks it is that the four are no + longer alike. + """ + text = SVG.read_text(encoding="utf-8") + dropped = text.replace( + ' \n', "", 1 + ) + assert dropped != text, "the substitution did not apply, so this arm measures nothing" + + problems = faults(dropped) + assert any("not shaped alike" in problem for problem in problems), ( + "the check accepted a node that lost its outer layer; it reports:\n" + + "\n".join(f" - {problem}" for problem in problems) + ) + + +def test_the_check_refuses_a_node_beside_the_fan() -> None: + """A fifth node where no branch ends: refused, since four nodes are the design.""" + text = SVG.read_text(encoding="utf-8") + added = text.replace( + ' ', + ' \n' + ' ', + 1, + ) + assert added != text, "the substitution did not apply, so this arm measures nothing" + + problems = faults(added) + assert any("where no branch ends" in problem for problem in problems), ( + "the check accepted a node beside the fan; it reports:\n" + + "\n".join(f" - {problem}" for problem in problems) + )