From 7e2baf8349345551c6b6ea6f1ed2fd408ff98e5f Mon Sep 17 00:00:00 2001 From: EMRG Evolution Date: Tue, 27 Oct 2026 22:29:37 +0800 Subject: [PATCH 1/5] 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/5] 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/5] 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) + ) From 9ebc8880b62946679894018f9ab6edf870253f5b Mon Sep 17 00:00:00 2001 From: EMRG Evolution Date: Wed, 28 Oct 2026 12:54:20 +0800 Subject: [PATCH 4/5] emrg: the icon guard reads a place, not a size MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `faults()` promises an empty list for any document that keeps the four properties, and four documents that keep all four were refused: the hub's halo shrunk to the nodes' radius, one branch drawn with a relative curve, a fan written entirely with `m 512 650`, and a stroke spelled `url( #branchGrad )`. Two more went the other way — a fifth node at `r="90"` and one painted as a body only — and were accepted. Behind all six is one habit: an identity built from a size, or from the spelling of a coordinate rather than the place it names. - `_start_point`/`_end_point` become one command walker. The numbers in a `d` are places only through the command that owns them, so a relative `c` carries offsets and `m` offsets from `(0, 0)`. The last pair anywhere in the string read this icon's own branch as arriving at `-196, -142`, and an `M`-only start rule made a fan written `m 512 650` state no origin at all — leaving `hub_centre` to answer the stem's foot. - A branch is selected and its light read by one shared `url()` parse. Two readers of one `stroke` let a document fall out of the branch set while its gradient read as nothing; loosening either alone would have left the other still declining it. - The stray-circle test asks whether a circle belongs to the hub or to a node — at an anchor, or leaning off one — instead of whether its radius equals the four nodes'. A size is what those four happen to measure, not what makes a circle a node, so keying it there read the hub's own halo as a fifth node and missed a fifth node of any other size. The four wrongly-refused documents join the accept arms and the fifth-node arm is widened to three radii. Each accept arm is checked against the previous guard as well: it refused all four, so none of them is vacuous. --- tests/test_icon_design_layers.py | 277 ++++++++++++++++++++++++++----- 1 file changed, 235 insertions(+), 42 deletions(-) diff --git a/tests/test_icon_design_layers.py b/tests/test_icon_design_layers.py index 90051f64..9c1199e2 100644 --- a/tests/test_icon_design_layers.py +++ b/tests/test_icon_design_layers.py @@ -61,24 +61,86 @@ BODY = {"x": "56", "y": "56", "width": "912", "height": "912", "rx": "206"} +#: A path's command letters and its numbers, as the document writes them. +_PATH_TOKEN = re.compile(r"[MmZzLlHhVvCcSsQqTtAa]|-?(?:\d+\.\d*|\.\d+|\d+)(?:[eE][-+]?\d+)?") + +#: How many numbers a command consumes before it states its own end point. An arc's +#: seven are `rx ry rot large-arc sweep x y`, so only its last two are a place. +_ARITY = {"M": 2, "L": 2, "T": 2, "H": 1, "V": 1, "Q": 4, "S": 4, "C": 6, "A": 7} + +#: A paint-server reference: `url(#id)`, with the whitespace CSS allows inside it. +_URL_REFERENCE = re.compile(r"\s*url\(\s*#([^)\s]+)\s*\)\s*") + + +def _gradient_id(stroke: str | None) -> str | None: + """The gradient a `stroke` references, or `None` when it references none. + + Deliberately one reading of one attribute. This file selected a branch with + `stroke.startswith("url(#")` and read that same stroke's light with a second, + different regex, so `stroke="url( #branchGrad )"` — which CSS strips to the same + reference and every renderer draws identically — left the fan with no branch to + select and no light to read. Two parsers of one attribute get one parser. + """ + match = _URL_REFERENCE.fullmatch(stroke or "") + return match.group(1) if match else None + + +def _path_ends(d: str) -> tuple[tuple[float, float] | None, tuple[float, float] | None]: + """Where a path begins and where it arrives, walking each command's arithmetic. + + The numbers in `d` are places only through the command that owns them: a relative + `c` carries offsets, and an `m` at the head of a path offsets from `(0, 0)`. + Reading the last pair anywhere in the string answered `(-196, -142)` for this + icon's own branch curve written relative — a point nothing is painted at — and an + `M`-only reader made a fan written `m 512 650` state no origin at all, so the hub + was derived from the stem's foot. A guard that answers a different question than + the one it states is not a stricter guard. + """ + tokens = _PATH_TOKEN.findall(d or "") + current = (0.0, 0.0) + start: tuple[float, float] | None = None + command = "" + index = 0 + while index < len(tokens): + token = tokens[index] + if token.isalpha(): + command = token + index += 1 + continue + if not command: + return None, None + upper = command.upper() + need = _ARITY.get(upper) + if need is None or index + need > len(tokens): + break + values = [float(value) for value in tokens[index : index + need]] + index += need + if upper == "H": + current = (current[0] + values[0] if command != upper else values[0], current[1]) + elif upper == "V": + current = (current[0], current[1] + values[0] if command != upper else values[0]) + else: + x, y = values[-2], values[-1] + current = (x + current[0], y + current[1]) if command != upper else (x, y) + if start is None: + start = current + if upper == "M": + # Every pair after the first under one `M` is an implicit line to. + command = "l" if command != upper else "L" + return start, current + + 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))) + return _path_ends(element.get("d") or "")[0] 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. + That 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)) + return _path_ends(element.get("d") or "")[1] def _centre(element: ET.Element) -> tuple[float, float] | None: @@ -147,7 +209,7 @@ def faults(svg_text: str) -> list[str]: 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(#") + and _gradient_id(elements[i].get("stroke")) is not None ] concentric = [ i @@ -261,29 +323,47 @@ def faults(svg_text: str) -> list[str]: 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 + # Every circle in the design belongs either to the hub or to one of its nodes, + # and both give their layers the same shape: two rings concentric on a centre + # and one bright core leaning off it. So a circle is accounted for when it + # sits at an anchor — the hub, or a place a branch arrives — or when it is the + # one circle leaning off that anchor. No radius takes part. A size is what the + # four nodes happen to measure, not what makes a circle a node, and keying the + # test to it read the hub's own resized halo as a fifth node while a fifth + # node of any other size went unseen. + circles = [ + element + for element in elements + if element.tag == NS + "circle" and _centre(element) is not None + ] + anchors = ([] if centre is None else [centre]) + list(outermost) + accounted = set(anchors) + for anchor in anchors: + leaning = [ + point + for point in (_centre(element) for element in circles) + if point != anchor ] - 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" - ) + if leaning: + accounted.add(min(leaning, key=lambda point: _distance(point, anchor))) + stray = [ + point + for point in (_centre(element) for element in circles) + if point not in accounted + ] + if stray: + problems.append( + f"a circle sits at {stray[0][0]:g}, {stray[0][1]:g} where no branch " + f"ends and no hub layer stands: {len(stray)} of them belong to neither " + "the hub nor a node, so a fifth is painted beside the four" + ) # 2. One light for the fan: the gradient its branches reference, in user space, # centred on the hub they leave. referenced = [ - match.group(1) + wanted for i in branches - if (match := re.fullmatch(r"\s*url\(#([^)]+)\)\s*", elements[i].get("stroke") or "")) + if (wanted := _gradient_id(elements[i].get("stroke"))) is not None ] if branches and len(set(referenced)) != 1: problems.append( @@ -537,6 +617,110 @@ def test_the_check_accepts_a_fan_whose_node_layer_was_renamed() -> None: ) +def test_the_check_accepts_a_hub_halo_as_small_as_the_nodes() -> None: + """The hub's halo is a halo, not a node: resized to 88 it is still three layers. + + Read as "any circle of the nodes' size at no arrival", the hub's own halo became a + fifth node the moment a designer shrank it to the radius the four nodes carry — a + document with the layering, the light, the four nodes and the body rect all intact, + refused for a size. Which layer belongs to whom is a place, not a measurement. + """ + text = SVG.read_text(encoding="utf-8") + resized = text.replace('cx="512" cy="650" r="150"', 'cx="512" cy="650" r="88"', 1) + assert resized != text, "the substitution did not apply, so this arm measures nothing" + assert resized.count('cx="512" cy="650" r="88"') == 1, ( + "exactly the hub's halo must carry the new radius, found " + f"{resized.count('cx=\"512\" cy=\"650\" r=\"88\"')}" + ) + + problems = faults(resized) + assert problems == [], ( + "the check refuses a hub whose halo was resized to the nodes' radius, though " + "every property holds:\n" + "\n".join(f" - {problem}" for problem in problems) + ) + + +def test_the_check_accepts_a_branch_written_with_a_relative_curve() -> None: + """`c` states offsets from where it stands; where it lands is still where it lands. + + The shipped curve written relatively — `c -76 -32 -150 -82 -196 -142` from + `M 512 650` — arrives at the same `316, 508`. Read as "the last pair in the string", + it arrived at `-196, -142`, which reported the branch as both bare and a fifth node + at a point no renderer draws at. + """ + text = SVG.read_text(encoding="utf-8") + absolute = "M 512 650 C 436 618 362 568 316 508" + relative = "M 512 650 c -76 -32 -150 -82 -196 -142" + curved = text.replace(absolute, relative) + assert curved != text, "the substitution did not apply, so this arm measures nothing" + assert curved.count(relative) == 2, ( + "a branch is drawn twice, crisp and blurred, and both must move to the relative " + f"spelling; found {curved.count(relative)} of 2" + ) + + problems = faults(curved) + assert problems == [], ( + "the check refuses a branch drawn with a relative curve, though it arrives " + "where the absolute one does:\n" + + "\n".join(f" - {problem}" for problem in problems) + ) + + +def test_the_check_accepts_a_fan_written_entirely_with_relative_moves() -> None: + """`m 512 650` at the head of a path is the same origin as `M 512 650`. + + A reader that accepts only an absolute moveto made a fan written this way state no + start at all, so the hub was derived from whatever starts were left — the stem's + foot at `470, 950` — and the fan was then refused for being lit from a gradient the + stem's hue carries. That is not a refusal of the design. + """ + text = SVG.read_text(encoding="utf-8") + rewritten = text + for absolute, relative in ( + ("M 512 650 C 436 618 362 568 316 508", "m 512 650 c -76 -32 -150 -82 -196 -142"), + ("M 512 650 C 470 576 448 492 444 414", "m 512 650 c -42 -74 -64 -158 -68 -236"), + ("M 512 650 C 554 576 576 492 580 414", "m 512 650 c 42 -74 64 -158 68 -236"), + ("M 512 650 C 588 618 662 568 708 508", "m 512 650 c 76 -32 150 -82 196 -142"), + ): + rewritten = rewritten.replace(absolute, relative) + + assert "M 512 650" not in rewritten, "a branch still states its origin absolutely" + assert rewritten.count("m 512 650") == 2 * BRANCHES, ( + "every branch, crisp and blurred, must carry the relative moveto; found " + f"{rewritten.count('m 512 650')} of {2 * BRANCHES}" + ) + + problems = faults(rewritten) + assert problems == [], ( + "the check refuses a fan written with relative moves, though its origin, its " + "light and its nodes are unchanged:\n" + + "\n".join(f" - {problem}" for problem in problems) + ) + + +def test_the_check_accepts_whitespace_inside_the_stroke_reference() -> None: + """`url( #branchGrad )` and `url(#branchGrad)` are the same paint server. + + CSS strips the space, so both render identically. Two readers of one `stroke` — a + prefix test selecting branches and a stricter regex reading their light — left this + document with no branches at all, and loosening either one alone would have left the + other declining the same string. + """ + text = SVG.read_text(encoding="utf-8") + spaced = text.replace('stroke="url(#branchGrad)"', 'stroke="url( #branchGrad )"') + assert spaced != text, "the substitution did not apply, so this arm measures nothing" + assert spaced.count("url( #branchGrad )") == BRANCHES, ( + "every branch must carry the spaced reference, found " + f"{spaced.count('url( #branchGrad )')} of {BRANCHES}" + ) + + problems = faults(spaced) + assert problems == [], ( + "the check refuses a fan whose stroke reference carries whitespace, though it " + "names the same gradient:\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. @@ -559,18 +743,27 @@ def test_the_check_refuses_a_node_missing_its_outer_layer() -> None: 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" + """A fifth node where no branch ends: refused at any size, since four are the design. - 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) - ) + Keyed to the radius the four nodes measure, this arm accepted a fifth node of `90` + and one painted as a body only at `42` — both sitting beside the fan with nothing + arriving at either. What makes a node is a branch ending at it, so the test is a + circle at no arrival and on no hub ring, whatever its radius reads. + """ + text = SVG.read_text(encoding="utf-8") + anchor = ' ' + assert anchor in text, "the anchor this arm appends after is gone, so it measures nothing" + + for shape in ( + '', + '', + '', + ): + added = text.replace(anchor, anchor + "\n " + shape, 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), ( + f"the check accepted a fifth node drawn as {shape}; it reports:\n" + + "\n".join(f" - {problem}" for problem in problems) + ) From 10134d07979321cbec512009c260779b1e69c7a6 Mon Sep 17 00:00:00 2001 From: EMRG Evolution Date: Wed, 28 Oct 2026 13:20:22 +0800 Subject: [PATCH 5/5] emrg: the hub's layers are owned, not nearest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The check claimed a hub with no layer beside its centre, and did not make that refusal: with the hub's specular highlight deleted it returned no faults at all. The layer was read as the nearest circle to the centre of any owner, so a node's outer ring 242 units away stood in for the deleted highlight — and since a node's core leans the same way a hub's does, it satisfied the lean as well. Ownership now comes first. A circle belongs to the anchor it sits on — the hub's centre or a place a branch arrives — and an anchor owns one circle leaning off it, the nearest no other anchor is nearer to. Only a circle the hub owns can be its highlight, so a node's ring is never the hub's, and a hub that owns nothing beside itself is refused in the missing layer's own words. The node pass reads the same ownership instead of recomputing a second one that could disagree with it. Fifteen arms hold it, measured both ways: the shipped icon and seven redesigns it must accept, and seven documents it must refuse — the new one being the hub with its highlight deleted, reported as one fault so the arm can attribute it. --- tests/test_icon_design_layers.py | 140 +++++++++++++++++++++++-------- 1 file changed, 104 insertions(+), 36 deletions(-) diff --git a/tests/test_icon_design_layers.py b/tests/test_icon_design_layers.py index 9c1199e2..83bf8261 100644 --- a/tests/test_icon_design_layers.py +++ b/tests/test_icon_design_layers.py @@ -179,6 +179,39 @@ def hub_centre(elements: list[ET.Element]) -> tuple[float, float] | None: return counted.most_common(1)[0][0] +def _ownership( + centres: list[tuple[float, float]], anchors: list[tuple[float, float]] +) -> tuple[dict[tuple[float, float], tuple[float, float]], set[tuple[float, float]]]: + """Which anchor owns each circle centre, and which centres no anchor owns. + + An anchor is the hub's centre or a place a branch arrives. A circle sitting on an + anchor belongs to it, and an anchor also owns one circle leaning off it — the + nearest centre no other anchor is nearer to, which is its bright core. One apiece, + so a second circle beside an anchor is a fifth node however it is drawn. + + The two-way nearest test is what a missing layer needs. A node's ring is nearer to + its own arrival than to the hub, so it can never be passed off as the hub's + highlight; and a hub whose highlight is gone owns nothing beside itself, which is + the fault rather than a stand-in that satisfies the lean by accident. + """ + seated = set(anchors) + owned: dict[tuple[float, float], tuple[float, float]] = { + point: point for point in centres if point in seated + } + if anchors: + leaning: dict[tuple[float, float], tuple[float, float]] = {} + for point in centres: + if point in seated: + continue + mine = min(anchors, key=lambda anchor: _distance(point, anchor)) + best = leaning.get(mine) + if best is None or _distance(point, mine) < _distance(best, mine): + leaning[mine] = point + owned.update({point: anchor for anchor, point in leaning.items()}) + unclaimed = {point for point in centres if point not in owned} + return owned, unclaimed + + def faults(svg_text: str) -> list[str]: """Every way `svg_text` breaks the design, in the design's own terms. @@ -211,6 +244,16 @@ def faults(svg_text: str) -> list[str]: and _start_point(elements[i]) == centre and _gradient_id(elements[i].get("stroke")) is not None ] + # Where the branches arrive is where the nodes are, and it is read before the hub's + # own layers are chosen: a circle is a node's when it sits where a branch ends, or is + # the core leaning off that place, and only what no node owns can be a hub layer. + # Choosing "the nearest circle beside the centre" instead let a node's outer ring 242 + # units away stand in for the hub's highlight once that highlight was deleted — it + # leans up and left like a highlight, so the sphere read as lit and the missing layer + # went unreported. Ownership is a place, so it is decided by place. + arrivals = sorted( + {point for i in branches if (point := _end_point(elements[i])) is not None} + ) concentric = [ i for i in painted @@ -218,18 +261,30 @@ def faults(svg_text: str) -> list[str]: and elements[i].tag == NS + "circle" and _centre(elements[i]) == centre ] + circle_centres = [ + point + for point in ( + _centre(element) for element in elements if element.tag == NS + "circle" + ) + if point is not None + ] + anchors = ([] if centre is None else [centre]) + list(arrivals) + owned, unclaimed = _ownership(circle_centres, anchors) # 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. + # the nearest circle the *hub* owns beside its centre — the lean is the property, and + # how far it leans is the design's to choose, which a maximum distance would take + # away. A hub that owns none has no highlight, however many node cores lean the same + # way, and that is the fault below. beside = [ i for i in painted if centre is not None and elements[i].tag == NS + "circle" - and _centre(elements[i]) is not None - and _centre(elements[i]) != centre + and (point := _centre(elements[i])) is not None + and point != centre + and owned.get(point) == centre ] highlight = ( min(beside, key=lambda i: _distance(_centre(elements[i]), centre)) @@ -281,13 +336,6 @@ def faults(svg_text: str) -> list[str]: # 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 = [ @@ -326,31 +374,14 @@ def faults(svg_text: str) -> list[str]: # Every circle in the design belongs either to the hub or to one of its nodes, # and both give their layers the same shape: two rings concentric on a centre # and one bright core leaning off it. So a circle is accounted for when it - # sits at an anchor — the hub, or a place a branch arrives — or when it is the - # one circle leaning off that anchor. No radius takes part. A size is what the - # four nodes happen to measure, not what makes a circle a node, and keying the - # test to it read the hub's own resized halo as a fifth node while a fifth - # node of any other size went unseen. - circles = [ - element - for element in elements - if element.tag == NS + "circle" and _centre(element) is not None - ] - anchors = ([] if centre is None else [centre]) + list(outermost) - accounted = set(anchors) - for anchor in anchors: - leaning = [ - point - for point in (_centre(element) for element in circles) - if point != anchor - ] - if leaning: - accounted.add(min(leaning, key=lambda point: _distance(point, anchor))) - stray = [ - point - for point in (_centre(element) for element in circles) - if point not in accounted - ] + # sits at an anchor — the hub, or a place a branch arrives — or when it is + # that anchor's own leaning core, the one circle nearest it that no closer + # anchor claims. No radius takes part. A size is what the four nodes happen to + # measure, not what makes a circle a node, and keying the test to it read the + # hub's own resized halo as a fifth node while a fifth node of any other size + # went unseen. Ownership was decided above, before the hub's layers were read, + # so the hub and the nodes cannot disagree about who owns a circle. + stray = sorted(unclaimed) if stray: problems.append( f"a circle sits at {stray[0][0]:g}, {stray[0][1]:g} where no branch " @@ -515,6 +546,43 @@ def test_the_check_refuses_a_highlight_that_leans_the_wrong_way() -> None: ) +def test_the_check_refuses_a_hub_with_no_layer_beside_its_centre() -> None: + """A hub lit by nothing of its own: refused, in the missing layer's own words. + + Deleting the hub's specular highlight leaves two rings and no bright core, and this + check passed it. The layer beside the centre was read as the nearest circle of any + owner, so a node's outer ring 242 units away — up and left, the way a highlight + leans — stood in for the deleted one, and it satisfied the lean as well. A sphere + with no highlight of its own reads as a flat disc whichever way a node's core leans, + so the refusal has to be made. Which layer belongs to whom is a place, and a node's + ring is never the hub's. + + One fault, not two: the arm is about the missing layer, and a second finding would + mean the deletion broke something else and this arm could not attribute it. + """ + text = SVG.read_text(encoding="utf-8") + highlight = ' \n' + assert highlight in text, ( + "the hub's highlight is not written the way this arm removes it, so it measures " + "nothing" + ) + dropped = text.replace(highlight, "", 1) + assert 'cx="505" cy="642"' not in dropped, "the highlight is still painted" + assert dropped.count("