diff --git a/tests/test_icon_design_layers.py b/tests/test_icon_design_layers.py new file mode 100644 index 00000000..83bf8261 --- /dev/null +++ b/tests/test_icon_design_layers.py @@ -0,0 +1,837 @@ +"""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.** 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, 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. + +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. +""" + +from __future__ import annotations + +import re +import xml.etree.ElementTree as ET +from collections import Counter +from pathlib import Path + +REPO = Path(__file__).resolve().parent.parent +SVG = REPO / "packaging" / "assets" / "icon.svg" + +NS = "{http://www.w3.org/2000/svg}" + +#: How many branches the fan has, and therefore how many nodes. +BRANCHES = 4 + +#: 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"} + + +#: 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.""" + 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. + + That is where the branch lands, which is where its node is. + """ + return _path_ends(element.get("d") or "")[1] + + +def _centre(element: ET.Element) -> tuple[float, float] | None: + """A shape's centre, or `None` when it carries no placeable `cx`/`cy`.""" + try: + return (float(element.get("cx")), float(element.get("cy"))) + except (TypeError, ValueError): + 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 _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. + + 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] = [] + + # 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") + ] + # 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 centre is not None + and elements[i].tag == NS + "path" + 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 + if centre is not None + 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 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 (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)) + 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) leaving the hub lit by a " + f"gradient, expected {BRANCHES}" + ) + 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( + "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: + 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 " + "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" + ) + + # 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. + 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( + "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" + ) + # 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 + # 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 " + 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 = [ + wanted + for i in branches + if (wanted := _gradient_id(elements[i].get("stroke"))) is not None + ] + 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"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"] + 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" + ) + + +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(" 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) + ) + + +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_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. + + 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 at any size, since four are the design. + + 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) + )