feat(scatter): add bubble variant for three-value comparisons (#141)
* feat(scatter): add bubble variant for three-value comparisons Bubble chart as a Scatter variant per the issue #62 triage: x and y on shared zero-based linear scales, bubble AREA proportional to the third value (r = K*sqrt(size)), one accent bubble, ink-opacity ramp as ink-mass compensation, largest-first paint order. scripts/verify-bubble.py derives both axis scales and the area constant from the set itself (Theil-Sen, leave-one-out) and holds every bound label and tick against the mark it describes; test-verify-bubble.py exercises both polarities. Bindings are bubble-specific (data-size, data-name, data-tick) rather than data-series, so verify-slopegraph.py never claims a bubble file and neither checker needs scope carve-outs. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(bubble): report a bubble whose peers cannot describe a scale A bubble holding the only distinct value on an axis was skipped by the leave-one-out residual test — its own fit is degenerate, and the full-set fit passes through wherever it was drawn, so a wrong coordinate shipped clean. Report it as unverifiable instead of skipping it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * chore: bump to 2.6.3 and register bubble gates Ridgeline (#143) landed on 2.6.2, colliding with this branch's bump, so re-bump past it. Also add the bubble gate commands to the maintainer policy's local_commands, matching what ridgeline registered. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * chore: bump to 2.6.5 The mermaid fix (#131) landed on 2.6.4, colliding with this branch's bump again. Re-bump past it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: 0xDarkMatter <0xDarkMatter@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
0xDarkMatter
Claude Fable 5
parent
c79585edb4
commit
648c2a5978
@@ -0,0 +1,572 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Adversarial cases for verify-bubble.py, both polarities.
|
||||
|
||||
Every case is named for exactly what it asserts — a name that overclaims is
|
||||
itself a defect. The negative half matters as much as the positive: a checker
|
||||
that fires on an honest bubble chart, on the parent scatter, or on the shipped
|
||||
slopegraph gets widened or switched off, and then it guards nothing.
|
||||
|
||||
Fixtures live in a per-process temporary directory, never under the repository
|
||||
root, and two cases at the end hold that isolation in place.
|
||||
|
||||
Exit: 0 all pass, 1 any failure.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import re
|
||||
import shutil
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(ROOT / "scripts"))
|
||||
|
||||
verify = __import__("verify-bubble")
|
||||
|
||||
ASSETS = ROOT / "skills/diagram-design/assets"
|
||||
SHIPPED = [ASSETS / name for name in (
|
||||
"example-bubble.html", "example-bubble-dark.html", "example-bubble-full.html",
|
||||
)]
|
||||
|
||||
HEAD = """<!DOCTYPE html>
|
||||
<html lang="en"><head><meta charset="UTF-8"><title>t</title></head><body>
|
||||
<svg viewBox="0 0 1000 500" xmlns="http://www.w3.org/2000/svg" role="img"
|
||||
aria-labelledby="bubble-title bubble-desc">
|
||||
<title id="bubble-title">t</title>
|
||||
<desc id="bubble-desc">Bubble chart fixture.</desc>
|
||||
"""
|
||||
TAIL = "</svg></body></html>\n"
|
||||
|
||||
# The fixture scale, matching the shipped example: x = 80 + 1.76*v over a
|
||||
# 0-500 domain, y = 420 - 95*v over 0-4, and r = 1.4*sqrt(size).
|
||||
def cx(value: float) -> float:
|
||||
return round(80 + 1.76 * value, 1)
|
||||
|
||||
|
||||
def cy(value: float) -> float:
|
||||
return round(420 - 95 * value, 1)
|
||||
|
||||
|
||||
def radius(size: float) -> float:
|
||||
return round(1.4 * math.sqrt(size), 1)
|
||||
|
||||
|
||||
def bubble(name: str, x: float, y: float, size: float, drawn_cx=None,
|
||||
drawn_cy=None, drawn_r=None, stroke: str = "#4f5d75",
|
||||
omit: str = "", extra: str = "") -> str:
|
||||
parts = []
|
||||
if omit != "data-name":
|
||||
parts.append('data-name="%s"' % name)
|
||||
parts.append('data-x="%s"' % x)
|
||||
parts.append('data-y="%s"' % y)
|
||||
parts.append('data-size="%s"' % size)
|
||||
if omit != "cx":
|
||||
parts.append('cx="%s"' % ("%g" % cx(x) if drawn_cx is None else drawn_cx))
|
||||
parts.append('cy="%s"' % ("%g" % cy(y) if drawn_cy is None else drawn_cy))
|
||||
parts.append('r="%s"' % ("%g" % radius(size) if drawn_r is None else drawn_r))
|
||||
if extra:
|
||||
parts.append(extra)
|
||||
return (' <circle %s fill="rgba(45,49,66,0.20)" stroke="%s" '
|
||||
'stroke-width="1"/>\n' % (" ".join(parts), stroke))
|
||||
|
||||
|
||||
def label(name: str, x: float, y: float, text=None, extra: str = "") -> str:
|
||||
return (' <text data-name="%s" data-role="label" x="%g" y="%g"%s>%s</text>\n'
|
||||
% (name, x, y, (" " + extra) if extra else "",
|
||||
name.upper() if text is None else text))
|
||||
|
||||
|
||||
def tick(axis: str, value: float, position=None, shown=None,
|
||||
declared=None, extra: str = "") -> str:
|
||||
if position is None:
|
||||
position = cx(value) if axis == "x" else cy(value) + 4
|
||||
coords = ('x="%g" y="440"' % position) if axis == "x" else \
|
||||
('x="72" y="%g"' % position)
|
||||
bind = "" if declared == "omit" else \
|
||||
' data-value="%s"' % (value if declared is None else declared)
|
||||
return (' <text data-tick="%s"%s %s%s>%s</text>\n'
|
||||
% (axis, bind, coords, (" " + extra) if extra else "",
|
||||
("%g" % value) if shown is None else shown))
|
||||
|
||||
|
||||
TICKS = tick("x", 0) + tick("x", 500) + tick("y", 0) + tick("y", 4)
|
||||
|
||||
# Five honest bubbles with distinct values on every axis; none overlap.
|
||||
BUBBLES = [("Edge", 100, 1.0, 400), ("Queue", 200, 2.0, 100),
|
||||
("Batch", 300, 0.5, 225), ("Webhook", 400, 3.0, 25),
|
||||
("Stream", 150, 1.5, 900)]
|
||||
|
||||
|
||||
def honest_block(rows=None, focal: str = "Edge") -> str:
|
||||
body = ""
|
||||
for name, x, y, size in sorted(BUBBLES if rows is None else rows,
|
||||
key=lambda row: -row[3]):
|
||||
stroke = "#eb6c36" if name == focal else "#4f5d75"
|
||||
body += bubble(name, x, y, size, stroke=stroke)
|
||||
return body
|
||||
|
||||
|
||||
def document(*blocks: str) -> str:
|
||||
return HEAD + "".join(blocks) + TAIL
|
||||
|
||||
|
||||
def honest(rows=None, focal: str = "Edge") -> str:
|
||||
return document(honest_block(rows, focal), TICKS)
|
||||
|
||||
|
||||
class Harness:
|
||||
def __init__(self) -> None:
|
||||
self.failures = 0
|
||||
self.count = 0
|
||||
# A private temp dir per instance: fixture names are meaningful to the
|
||||
# checker (it keys detection off the filename) but their directory is
|
||||
# not, so there is no reason to put them anywhere a real file lives.
|
||||
self.dir = Path(tempfile.mkdtemp(prefix="bubble-fixtures-"))
|
||||
|
||||
def close(self) -> None:
|
||||
shutil.rmtree(self.dir, ignore_errors=True)
|
||||
|
||||
def path_for(self, name: str) -> Path:
|
||||
return self.dir / name
|
||||
|
||||
def run(self, source: str, name: str = "example-bubble-fixture.html") -> list:
|
||||
path = self.path_for(name)
|
||||
path.write_text(source, encoding="utf-8")
|
||||
try:
|
||||
return verify.check(path)
|
||||
finally:
|
||||
path.unlink()
|
||||
|
||||
def expect_clean(self, case: str, source: str, name: str | None = None) -> None:
|
||||
self.count += 1
|
||||
found = self.run(source, name) if name else self.run(source)
|
||||
if found:
|
||||
self.failures += 1
|
||||
print("FAIL %s" % case)
|
||||
for item in found:
|
||||
print(" unexpected: %s" % item)
|
||||
else:
|
||||
print("ok %s" % case)
|
||||
|
||||
def expect_finding(self, case: str, source: str, pattern: str,
|
||||
name: str | None = None) -> None:
|
||||
self.count += 1
|
||||
found = self.run(source, name) if name else self.run(source)
|
||||
if not found:
|
||||
self.failures += 1
|
||||
print("FAIL %s\n expected a finding, got none" % case)
|
||||
return
|
||||
if not any(re.search(pattern, item) for item in found):
|
||||
self.failures += 1
|
||||
print("FAIL %s\n no finding matched %r" % (case, pattern))
|
||||
for item in found:
|
||||
print(" got: %s" % item)
|
||||
return
|
||||
print("ok %s" % case)
|
||||
|
||||
def expect_only_one(self, case: str, source: str, pattern: str) -> None:
|
||||
"""A single defect must produce a single finding, not a cascade."""
|
||||
self.count += 1
|
||||
found = self.run(source)
|
||||
if len(found) == 1 and re.search(pattern, found[0]):
|
||||
print("ok %s" % case)
|
||||
return
|
||||
self.failures += 1
|
||||
print("FAIL %s\n expected exactly one finding matching %r, got %d"
|
||||
% (case, pattern, len(found)))
|
||||
for item in found:
|
||||
print(" got: %s" % item)
|
||||
|
||||
def expect_out_of_scope(self, case: str, source: str, name: str) -> None:
|
||||
"""Not merely finding-free — genuinely outside this checker's scope.
|
||||
|
||||
expect_clean cannot tell the two apart: a detected-and-clean file also
|
||||
produces no findings, which is not what "out of scope" claims.
|
||||
"""
|
||||
self.count += 1
|
||||
path = self.path_for(name)
|
||||
path.write_text(source, encoding="utf-8")
|
||||
try:
|
||||
detected = verify.looks_like_bubble(path, source)
|
||||
found = verify.check(path)
|
||||
finally:
|
||||
path.unlink()
|
||||
if detected or found:
|
||||
self.failures += 1
|
||||
print("FAIL %s\n detected=%s findings=%d"
|
||||
% (case, detected, len(found)))
|
||||
return
|
||||
print("ok %s" % case)
|
||||
|
||||
def check(self, case: str, condition: bool, detail: str = "") -> None:
|
||||
self.count += 1
|
||||
if condition:
|
||||
print("ok %s" % case)
|
||||
return
|
||||
self.failures += 1
|
||||
print("FAIL %s%s" % (case, ("\n " + detail) if detail else ""))
|
||||
|
||||
|
||||
def run_cases(h: Harness) -> int:
|
||||
# ── Positive polarity: honest figures pass ────────────────────────────
|
||||
for path in SHIPPED:
|
||||
found = verify.check(path)
|
||||
h.check("shipped %s verifies clean" % path.name, found == [],
|
||||
"; ".join(found))
|
||||
|
||||
h.expect_clean("an honest synthetic bubble chart passes", honest())
|
||||
h.expect_clean("a chart with no accent bubble at all passes",
|
||||
honest(focal="nobody"))
|
||||
h.expect_clean(
|
||||
"single-quoted attributes are parsed, not dropped",
|
||||
document(honest_block().replace('"', "'"), TICKS),
|
||||
)
|
||||
h.expect_clean(
|
||||
"a lying bubble inside a comment is markup that never renders",
|
||||
document(honest_block(), TICKS,
|
||||
" <!-- %s -->\n" % bubble("Ghost", 100, 1.0, 400,
|
||||
drawn_cx=900).strip()),
|
||||
)
|
||||
|
||||
# An inverted y (value grows downward) is a legitimate design; the checker
|
||||
# verifies a SHARED scale, not a direction.
|
||||
inverted = ""
|
||||
for name, x, y, size in sorted(BUBBLES, key=lambda row: -row[3]):
|
||||
inverted += bubble(name, x, y, size, drawn_cy=round(40 + 95 * y, 1))
|
||||
inverted_ticks = (tick("x", 0) + tick("x", 500)
|
||||
+ tick("y", 0, position=44) + tick("y", 4, position=424))
|
||||
h.expect_clean("an inverted y axis with a shared scale passes",
|
||||
document(inverted, inverted_ticks))
|
||||
|
||||
# Overlap honesty. Pair that genuinely overlaps: (256, 325, r28) and
|
||||
# (273.6, 315.5, r14) are 20px apart against 42px of radius.
|
||||
big = bubble("Big", 100, 1.0, 400)
|
||||
small = bubble("Small", 110, 1.1, 100)
|
||||
h.expect_clean("overlapping bubbles drawn largest-first pass",
|
||||
document(big, small, TICKS,
|
||||
honest_block([("A", 300, 0.5, 225),
|
||||
("B", 400, 3.0, 25)], focal="none")))
|
||||
h.expect_finding(
|
||||
"a small bubble buried under a later larger one is reported",
|
||||
document(small, big, TICKS,
|
||||
honest_block([("A", 300, 0.5, 225), ("B", 400, 3.0, 25)],
|
||||
focal="none")),
|
||||
r"painted\s+first — draw overlapping bubbles largest-first",
|
||||
)
|
||||
twin_a = bubble("TwinA", 200, 2.0, 100)
|
||||
twin_b = bubble("TwinB", 205, 2.05, 100)
|
||||
h.expect_clean(
|
||||
"equal-radius overlap passes in either order — order cannot bury a tie",
|
||||
document(twin_a, twin_b, TICKS,
|
||||
honest_block([("A", 300, 0.5, 225), ("B", 400, 3.0, 25)],
|
||||
focal="none")),
|
||||
)
|
||||
|
||||
# ── Axis scale lies ───────────────────────────────────────────────────
|
||||
# Seven honest peers, so the leave-one-out Theil-Sen fit for each of them
|
||||
# stays clean even with the one liar included: with four bubbles a single
|
||||
# nudge contaminated the median and dragged a second, honest bubble past
|
||||
# tolerance — a cascade blaming the wrong mark.
|
||||
HONEST_PEERS = [("Edge", 100, 1.0, 400), ("Queue", 200, 2.0, 100),
|
||||
("Batch", 300, 0.5, 225), ("Webhook", 400, 3.0, 25),
|
||||
("Cache", 250, 0.8, 300), ("Ingest", 350, 2.4, 150),
|
||||
("Ledger", 450, 1.2, 60)]
|
||||
nudged = honest_block(HONEST_PEERS, focal="none")
|
||||
nudged += bubble("Stream", 150, 1.5, 900, drawn_cx=cx(150) + 8)
|
||||
h.expect_only_one(
|
||||
"one bubble nudged 8px in x is one finding, not a cascade",
|
||||
document(nudged, TICKS),
|
||||
r"bubble 'Stream' declares x=150 .* never nudge",
|
||||
)
|
||||
nudged_y = honest_block(HONEST_PEERS, focal="none")
|
||||
nudged_y += bubble("Stream", 150, 1.5, 900, drawn_cy=cy(1.5) - 6)
|
||||
h.expect_finding(
|
||||
"one bubble nudged 6px in y is reported against its peers' scale",
|
||||
document(nudged_y, TICKS),
|
||||
r"bubble 'Stream' declares y=1\.5",
|
||||
)
|
||||
h.expect_finding(
|
||||
"an axis where every bubble declares the same value is unverifiable",
|
||||
document(honest_block([("A", 100, 1.0, 400), ("B", 100, 2.0, 100),
|
||||
("C", 100, 0.5, 225), ("D", 100, 3.0, 25)],
|
||||
focal="none"), TICKS),
|
||||
r"the x axis has no two distinct declared values",
|
||||
)
|
||||
# The lone bubble whose peers all share one value: its leave-one-out fit is
|
||||
# degenerate, so the residual test skips it and the full-set fit passes
|
||||
# through wherever it was drawn. Skipping silently let a bubble at a wrong
|
||||
# coordinate ship clean; it must be reported as unverifiable instead.
|
||||
lone = honest_block([("A", 100, 1.0, 400), ("B", 100, 2.0, 100),
|
||||
("C", 100, 0.5, 225)], focal="none")
|
||||
lone += bubble("Lone", 300, 3.0, 25, drawn_cx=cx(300) + 40)
|
||||
h.expect_finding(
|
||||
"a bubble holding the only distinct x value is reported, not skipped",
|
||||
document(lone, TICKS),
|
||||
r"bubble 'Lone' holds the only distinct x value",
|
||||
)
|
||||
|
||||
# ── Area lies ─────────────────────────────────────────────────────────
|
||||
proportional = ""
|
||||
for name, x, y, size in sorted(BUBBLES, key=lambda row: -row[3]):
|
||||
proportional += bubble(name, x, y, size, drawn_r=round(size / 22.0, 1))
|
||||
h.expect_finding(
|
||||
"radius-proportional sizing is reported — r^2/size is not constant",
|
||||
document(proportional, TICKS),
|
||||
r"area must be proportional to the value",
|
||||
)
|
||||
inflated = honest_block([("Edge", 100, 1.0, 400), ("Queue", 200, 2.0, 100),
|
||||
("Batch", 300, 0.5, 225), ("Webhook", 400, 3.0, 25)],
|
||||
focal="none")
|
||||
inflated += bubble("Stream", 150, 1.5, 900, drawn_r=radius(900) + 6)
|
||||
h.expect_finding(
|
||||
"one bubble drawn 6px too large is reported by name",
|
||||
document(inflated, TICKS),
|
||||
r"bubble 'Stream' declares size 900",
|
||||
)
|
||||
h.expect_finding(
|
||||
"a non-positive size is reported, not scaled",
|
||||
document(honest_block(focal="none"),
|
||||
bubble("Void", 250, 2.5, 0), TICKS),
|
||||
r"area cannot encode a non-positive value",
|
||||
)
|
||||
h.expect_finding(
|
||||
"a zero radius is reported — a bubble with no area states no value",
|
||||
document(honest_block(focal="none"),
|
||||
bubble("Flat", 250, 2.5, 300, drawn_r=0), TICKS),
|
||||
r"a bubble with no area states no value",
|
||||
)
|
||||
|
||||
# ── Focal discipline ──────────────────────────────────────────────────
|
||||
two_accents = honest_block() + bubble("Rogue", 250, 2.5, 300,
|
||||
stroke="#eb6c36")
|
||||
h.expect_finding(
|
||||
"a second accent bubble is reported — one focal claim per figure",
|
||||
document(two_accents, TICKS),
|
||||
r"one accent bubble max",
|
||||
)
|
||||
dark_accent = honest_block() + bubble("Rogue", 250, 2.5, 300,
|
||||
stroke="#f08a59")
|
||||
h.expect_finding(
|
||||
"the dark-skin accent counts toward the same limit",
|
||||
document(dark_accent, TICKS),
|
||||
r"one accent bubble max",
|
||||
)
|
||||
|
||||
# ── Label lies ────────────────────────────────────────────────────────
|
||||
h.expect_clean(
|
||||
"a small-caps label matches its mixed-case binding",
|
||||
document(honest_block(), TICKS, label("Edge", cx(100), cy(1.0) - 34)),
|
||||
)
|
||||
h.expect_finding(
|
||||
"a label naming an undeclared bubble is reported",
|
||||
document(honest_block(), TICKS, label("Phantom", 500, 250)),
|
||||
r"which no circle declares",
|
||||
)
|
||||
h.expect_finding(
|
||||
"a label whose text disagrees with its binding is reported",
|
||||
document(honest_block(), TICKS,
|
||||
label("Edge", cx(100), cy(1.0) - 34, text="QUEUE")),
|
||||
r"the visible text and the binding must agree",
|
||||
)
|
||||
h.expect_finding(
|
||||
"a label drawn nearer another bubble renames the mark",
|
||||
document(honest_block(), TICKS,
|
||||
label("Edge", cx(200), cy(2.0) - 4)),
|
||||
r"nearer 'Queue'",
|
||||
)
|
||||
h.expect_finding(
|
||||
"a second label for one bubble is reported",
|
||||
document(honest_block(), TICKS,
|
||||
label("Edge", cx(100), cy(1.0) - 34),
|
||||
label("Edge", cx(100), cy(1.0) + 42)),
|
||||
r"one bubble, one label",
|
||||
)
|
||||
|
||||
# ── Tick lies ─────────────────────────────────────────────────────────
|
||||
h.expect_finding(
|
||||
"a tick printing a different number than it declares is reported",
|
||||
document(honest_block(), tick("x", 0), tick("x", 500, shown="400"),
|
||||
tick("y", 0), tick("y", 4)),
|
||||
r"prints '400' but declares 500",
|
||||
)
|
||||
h.expect_finding(
|
||||
"a tick drawn off the scale the bubbles set is reported",
|
||||
document(honest_block(), tick("x", 0), tick("x", 500, position=760),
|
||||
tick("y", 0), tick("y", 4)),
|
||||
r"the printed axis and the drawn positions disagree",
|
||||
)
|
||||
h.expect_finding(
|
||||
"an axis with fewer than two bound ticks is reported",
|
||||
document(honest_block(), tick("x", 0), tick("x", 500), tick("y", 0)),
|
||||
r"the y axis binds 1 distinct tick value",
|
||||
)
|
||||
h.expect_finding(
|
||||
"a tick with no data-value is reported as unbound",
|
||||
document(honest_block(), tick("x", 0), tick("x", 500, declared="omit"),
|
||||
tick("y", 0), tick("y", 4)),
|
||||
r"no readable data-value",
|
||||
)
|
||||
h.expect_finding(
|
||||
"a tick printing two numeric tokens is ambiguous, not first-token-parsed",
|
||||
document(honest_block(), tick("x", 0),
|
||||
tick("x", 500, shown="500 (512,000)"),
|
||||
tick("y", 0), tick("y", 4)),
|
||||
r"prints more than one numeric token",
|
||||
)
|
||||
|
||||
# ── Transforms ────────────────────────────────────────────────────────
|
||||
h.expect_finding(
|
||||
"a transform on a bubble is rejected, not resolved",
|
||||
document(honest_block(),
|
||||
bubble("Slid", 250, 2.5, 300,
|
||||
extra='transform="translate(0 80)"'), TICKS),
|
||||
r"bubble 'Slid' carries transform=",
|
||||
)
|
||||
h.expect_finding(
|
||||
"an ancestor <g> transform moves every mark inside it",
|
||||
document(' <g transform="translate(0 40)">\n', honest_block(),
|
||||
" </g>\n", TICKS),
|
||||
r"an ancestor <g>/<svg> transform",
|
||||
)
|
||||
h.expect_finding(
|
||||
"an UNCLOSED transformed group covers everything after it",
|
||||
document(' <g transform="translate(0 40)">\n', honest_block(), TICKS),
|
||||
r"an ancestor <g>/<svg> transform",
|
||||
)
|
||||
h.expect_finding(
|
||||
"a transform on a bound label is rejected",
|
||||
document(honest_block(), TICKS,
|
||||
label("Edge", cx(100), cy(1.0) - 34,
|
||||
extra='transform="translate(40 0)"')),
|
||||
r"a bound label .* carries transform=",
|
||||
)
|
||||
h.expect_finding(
|
||||
"a CSS transform declaration is rejected — no telling what it moves",
|
||||
"<style>.bubble { transform: translate(0, 40px); }</style>"
|
||||
+ document(honest_block(), TICKS),
|
||||
r"a CSS `transform` declaration",
|
||||
)
|
||||
h.expect_clean(
|
||||
"text-transform in CSS is styling, not movement",
|
||||
"<style>.label { text-transform: uppercase; }</style>"
|
||||
+ document(honest_block(), TICKS),
|
||||
)
|
||||
|
||||
# ── Malformed markup: findings, never tracebacks, never silence ───────
|
||||
h.expect_finding(
|
||||
"a data-size circle whose attributes cannot be parsed is reported",
|
||||
document(' <circle data-size=broken cx="100" cy="100" r="10"/>\n',
|
||||
honest_block(), TICKS),
|
||||
r"could not be parsed",
|
||||
)
|
||||
h.expect_finding(
|
||||
"a bubble without data-name is reported",
|
||||
document(honest_block(),
|
||||
bubble("X", 250, 2.5, 300, omit="data-name"), TICKS),
|
||||
r"data-size but no data-name",
|
||||
)
|
||||
h.expect_finding(
|
||||
"a bubble missing cx is reported with the missing attribute named",
|
||||
document(honest_block(), bubble("Hole", 250, 2.5, 300, omit="cx"),
|
||||
TICKS),
|
||||
r"bubble 'Hole' is missing cx",
|
||||
)
|
||||
h.expect_finding(
|
||||
"a NaN coordinate is unreadable, not silently within every tolerance",
|
||||
document(honest_block(),
|
||||
bubble("Nan", 250, 2.5, 300, drawn_cx="NaN"), TICKS),
|
||||
r"not a finite\s+number",
|
||||
)
|
||||
|
||||
# ── Fail closed ───────────────────────────────────────────────────────
|
||||
h.expect_finding(
|
||||
"three bubbles are too few for leave-one-out — refused, not passed",
|
||||
document(honest_block([("A", 100, 1.0, 400), ("B", 200, 2.0, 100),
|
||||
("C", 300, 0.5, 225)], focal="none"), TICKS),
|
||||
r"declares 3 verifiable bubble\(s\)",
|
||||
)
|
||||
h.expect_finding(
|
||||
"a file that claims the type by filename but parses nothing is a finding",
|
||||
HEAD + TAIL,
|
||||
r"declares 0 verifiable bubble",
|
||||
name="example-bubble-empty.html",
|
||||
)
|
||||
h.expect_finding(
|
||||
"a file that claims the type in its description is held to the contract",
|
||||
"<svg role='img'><title>t</title><desc>A bubble chart of things."
|
||||
"</desc></svg>",
|
||||
r"declares 0 verifiable bubble",
|
||||
name="fixture.html",
|
||||
)
|
||||
|
||||
# ── Scope: the neighbours stay unclaimed ──────────────────────────────
|
||||
scatter = (ASSETS / "example-scatter.html").read_text(encoding="utf-8")
|
||||
h.expect_out_of_scope(
|
||||
"the parent scatter is out of scope — points are not bubbles",
|
||||
scatter, "example-scatter-fixture.html",
|
||||
)
|
||||
slopegraph = (ASSETS / "example-slopegraph.html").read_text(encoding="utf-8")
|
||||
h.expect_out_of_scope(
|
||||
"the shipped slopegraph is out of scope — data-series is not data-size",
|
||||
slopegraph, "example-slopegraph-fixture.html",
|
||||
)
|
||||
h.check(
|
||||
"no shipped bubble file declares data-series, so verify-slopegraph "
|
||||
"never claims one",
|
||||
all("data-series" not in path.read_text(encoding="utf-8")
|
||||
for path in SHIPPED),
|
||||
)
|
||||
|
||||
# ── The fixture-isolation guarantees, held in place ───────────────────
|
||||
sentinel = ROOT / "example-bubble-fixture.html"
|
||||
existed = sentinel.exists()
|
||||
if not existed:
|
||||
sentinel.write_text("KEEP ME\n", encoding="utf-8")
|
||||
try:
|
||||
h.run(honest())
|
||||
h.check(
|
||||
"a pre-existing file sharing a fixture name is never touched",
|
||||
sentinel.exists() and sentinel.read_text(encoding="utf-8") == "KEEP ME\n",
|
||||
"%s was overwritten or deleted by the harness" % sentinel,
|
||||
)
|
||||
finally:
|
||||
if not existed and sentinel.exists():
|
||||
sentinel.unlink()
|
||||
|
||||
other = Harness()
|
||||
try:
|
||||
h.check(
|
||||
"two harnesses use different directories, so parallel runs cannot collide",
|
||||
other.dir != h.dir and not str(other.dir).startswith(str(ROOT)),
|
||||
"dirs %s and %s" % (h.dir, other.dir),
|
||||
)
|
||||
finally:
|
||||
other.close()
|
||||
|
||||
h.check(
|
||||
"fixtures are written outside the repository",
|
||||
not str(h.dir).startswith(str(ROOT)),
|
||||
"fixture dir %s is inside %s" % (h.dir, ROOT),
|
||||
)
|
||||
|
||||
print()
|
||||
if h.failures:
|
||||
print("%d of %d case(s) failed." % (h.failures, h.count))
|
||||
return 1
|
||||
print("OK bubble checker: %d case(s), both polarities" % h.count)
|
||||
return 0
|
||||
|
||||
|
||||
def main() -> int:
|
||||
h = Harness()
|
||||
try:
|
||||
return run_cases(h)
|
||||
finally:
|
||||
h.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,769 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Verify that a bubble chart's drawn geometry matches the values it declares.
|
||||
|
||||
A bubble chart makes three claims at once: x and y positions come off two
|
||||
shared linear scales, and AREA — never radius — carries the third value. All
|
||||
three can be broken without anything erroring: the figure renders, every label
|
||||
is present, and the lie is in the geometry. `lint-skin.py` reads colors and
|
||||
fonts, `verify-geometry.py` reads label masks against later-painted nodes, and
|
||||
neither compares a drawn coordinate or radius against the value bound to it.
|
||||
|
||||
Seven invariants:
|
||||
|
||||
1. SHARED AXIS SCALES - every bubble's cx must sit where one linear x scale
|
||||
puts its declared x value, and cy likewise for y. A single bubble nudged
|
||||
aside "to stop it overlapping" reads as a different number, and crowded
|
||||
bubbles are data, not a coordinate problem.
|
||||
|
||||
2. AREA, NEVER RADIUS - r^2 must be proportional to the declared size across
|
||||
the whole set, i.e. r = K*sqrt(size) for one shared K. This is the type's
|
||||
one unforgivable error: a radius proportional to the value shows a 6x
|
||||
service as 36x the ink. Per-bubble consistency catches the global version
|
||||
too, because under r = c*size the ratio r^2/size is not constant.
|
||||
|
||||
3. ONE ACCENT BUBBLE - at most one bound circle may carry the accent stroke,
|
||||
on either skin. A second accent bubble is a second "focal" claim, and the
|
||||
one-accent rule is the whole colour system of the house style.
|
||||
|
||||
4. LARGEST FIRST - where two bubbles overlap, the one painted earlier must be
|
||||
the larger, or the smaller is buried under it and its area cannot be read.
|
||||
Ties pass: equal radii cannot bury each other in a way paint order fixes.
|
||||
|
||||
5. BOUND LABELS ON THEIR OWN BUBBLE - a label names the bubble it is bound to,
|
||||
its visible text matches the binding (case aside - labels ship small-caps),
|
||||
and it sits nearer its own bubble's centre than any other's. Two labels
|
||||
exchanged between bubbles rename both while every number stays correct.
|
||||
|
||||
6. BOUND TICKS ON THE SAME SCALE - each axis needs at least two tick labels
|
||||
bound with data-tick/data-value, each printing exactly the number it
|
||||
declares and drawn where the bubbles' own scale puts that value. Unbound
|
||||
axis numbers are the cheapest way to relabel a whole chart.
|
||||
|
||||
7. FAIL CLOSED - a file that presents as a bubble chart but yields fewer than
|
||||
four parseable bubbles is a finding, never a pass. Four because the outlier
|
||||
test is leave-one-out and each fit needs three points; below that nothing
|
||||
here is verifiable, and a checker that reports OK because it found nothing
|
||||
to compare is the bug, not the gate. The same rule covers a bubble whose
|
||||
peers all share one value on an axis: its leave-one-out fit is degenerate,
|
||||
so it would define the scale rather than be checked against it, and an
|
||||
unmeasurable mark is reported rather than skipped.
|
||||
|
||||
The basis for geometry is the `data-x` / `data-y` / `data-size` triple each
|
||||
bubble circle declares, never the rendered text. The scales are derived from
|
||||
the set itself (Theil-Sen, leave-one-out), so one dishonest bubble cannot drag
|
||||
the line it is measured against.
|
||||
|
||||
Deliberately NOT `data-series`: that attribute belongs to the slopegraph
|
||||
contract, and `verify-slopegraph.py` claims any file that uses it. A bubble
|
||||
file is detected by `data-size`, its filename, or its accessible description.
|
||||
|
||||
WHAT THIS DOES NOT CHECK, deliberately:
|
||||
|
||||
- **Absolute truth.** Every check is internal consistency; a figure wrong by
|
||||
one constant everywhere is self-consistent. The source line states the
|
||||
domain and the area scale to the reader, and prose is not parsed.
|
||||
- **Axis direction.** A y that grows downward with the value is accepted; an
|
||||
inverted axis is a legitimate design (error rate reading "up is worse").
|
||||
- **Zero-based axes.** Whether the domain includes zero is stated in the
|
||||
source line, which this checker cannot read. The ticks it CAN read are
|
||||
pinned to the same scale as the bubbles, which is the checkable half.
|
||||
|
||||
Usage:
|
||||
python3 scripts/verify-bubble.py --all
|
||||
python3 scripts/verify-bubble.py skills/diagram-design/assets/example-bubble.html
|
||||
|
||||
Exit: 0 clean, 1 findings, 2 usage.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import html
|
||||
import math
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
ASSET_DIR = ROOT / "skills/diagram-design/assets"
|
||||
|
||||
CIRCLE_RE = re.compile(r"<circle\b(?P<attrs>[^>]*?)/?>", re.IGNORECASE)
|
||||
TEXT_RE = re.compile(r"<text\b(?P<attrs>[^>]*)>(?P<body>.*?)</text>", re.IGNORECASE | re.DOTALL)
|
||||
COMMENT_RE = re.compile(r"<!--.*?-->", re.DOTALL)
|
||||
NAMED_RE = re.compile(r"<(?P<tag>title|desc)\b[^>]*>(?P<body>.*?)</(?P=tag)>",
|
||||
re.IGNORECASE | re.DOTALL)
|
||||
GROUP_OPEN_RE = re.compile(r"<(?:g|svg)\b(?P<attrs>[^>]*?)(?P<selfclose>/?)>", re.IGNORECASE)
|
||||
GROUP_CLOSE_RE = re.compile(r"</(?:g|svg)\s*>", re.IGNORECASE)
|
||||
STYLE_RE = re.compile(r"<style\b[^>]*>(?P<body>.*?)</style>", re.IGNORECASE | re.DOTALL)
|
||||
# `transform:` but not `text-transform:`. The full-editorial template uses the
|
||||
# latter, so an unguarded pattern would report every editorial variant.
|
||||
CSS_TRANSFORM_RE = re.compile(r"(?<![\w-])transform\s*:", re.IGNORECASE)
|
||||
TAG_RE = re.compile(r"<[^>]+>")
|
||||
# The COMPLETE numeric token an author may print: sign, comma-grouped
|
||||
# thousands, decimals, leading-dot decimals, exponents. Matching only the
|
||||
# first fragment is how "512,000" once agreed with metadata that said 512.
|
||||
NUMBER_RE = re.compile(
|
||||
r"[-+]?(?:\d{1,3}(?:,\d{3})+(?:\.\d+)?|\d+(?:\.\d+)?|\.\d+)(?:[eE][-+]?\d+)?"
|
||||
)
|
||||
DIGIT_RE = re.compile(r"\d")
|
||||
|
||||
# Both quote styles: matching only double quotes drops a single-quoted circle
|
||||
# from the verified set without a word, which is exactly how a lie ships.
|
||||
ATTR_RE = re.compile(
|
||||
r"""(?P<name>[\w:-]+)\s*=\s*(?P<quote>["'])(?P<value>.*?)(?P=quote)""",
|
||||
re.DOTALL,
|
||||
)
|
||||
DECLARES_BUBBLE_RE = re.compile(r"\bdata-size\s*=", re.IGNORECASE)
|
||||
|
||||
# The accent stroke on either skin, hex or rgba. The focal count keys on the
|
||||
# STROKE because the fill is a translucent tint the paper shows through; the
|
||||
# stroke is the mark's edge and the thing a reader identifies the accent by.
|
||||
ACCENT_RE = re.compile(
|
||||
r"#eb6c36\b|#f08a59\b|rgba\(\s*235\s*,\s*108\s*,\s*54\b|rgba\(\s*240\s*,\s*138\s*,\s*89\b",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
# Coordinates ship rounded to one decimal, so an honest point sits within
|
||||
# 0.05px of true; whole-pixel rounding sits within 0.5px. 1.0px clears both
|
||||
# and still catches the smallest dishonest nudge worth making.
|
||||
RESIDUAL_TOLERANCE = 1.0 # px, drawn centre vs the shared axis scale
|
||||
RADIUS_TOLERANCE = 1.0 # px, drawn radius vs the shared area scale
|
||||
VALUE_TOLERANCE = 0.001 # printed tick label vs declared attribute
|
||||
# Tick text is placed beside its gridline, not on it: an x tick's anchor sits
|
||||
# on the tick, but a y tick's BASELINE hangs ~4px below the line it names.
|
||||
# 6px absorbs that offset; a swapped tick pair is off by a full gridline gap.
|
||||
TICK_TOLERANCE = 6.0 # px, tick position vs the scale the bubbles set
|
||||
LABEL_TOLERANCE = 0.5 # px, slack before a label counts as another bubble's
|
||||
OVERLAP_SLACK = 0.5 # px, separation below which two bubbles overlap
|
||||
|
||||
|
||||
class Bubble:
|
||||
__slots__ = ("name", "x", "y", "size", "cx", "cy", "r", "accent", "offset")
|
||||
|
||||
def __init__(self, name, x, y, size, cx, cy, r, accent, offset):
|
||||
self.name = name
|
||||
self.x, self.y, self.size = x, y, size
|
||||
self.cx, self.cy, self.r = cx, cy, r
|
||||
self.accent = accent
|
||||
self.offset = offset
|
||||
|
||||
|
||||
def line_of(source: str, offset: int) -> int:
|
||||
return source.count("\n", 0, offset) + 1
|
||||
|
||||
|
||||
def blank_comments(source: str) -> str:
|
||||
"""Comments out, length and line numbers preserved.
|
||||
|
||||
Markup inside a comment is not rendered, so treating it as data reports a
|
||||
commented-out old draft as a live defect. Replacing each comment with
|
||||
spaces of the same length keeps every later offset and line number honest.
|
||||
"""
|
||||
return COMMENT_RE.sub(lambda m: re.sub(r"[^\n]", " ", m.group(0)), source)
|
||||
|
||||
|
||||
def attrs_of(raw: str) -> dict:
|
||||
return {m.group("name"): m.group("value") for m in ATTR_RE.finditer(raw)}
|
||||
|
||||
|
||||
def plain(body: str) -> str:
|
||||
return html.unescape(TAG_RE.sub("", body)).strip()
|
||||
|
||||
|
||||
def number(value):
|
||||
"""A finite float from a complete numeric token, or None.
|
||||
|
||||
`float("nan")` succeeds and then every `abs(x) > tolerance` comparison is
|
||||
False, so a NaN coordinate silently satisfies every check in the file.
|
||||
Any non-finite value is treated as unreadable instead.
|
||||
"""
|
||||
if value is None:
|
||||
return None
|
||||
try:
|
||||
parsed = float(str(value).replace(",", ""))
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return parsed if math.isfinite(parsed) else None
|
||||
|
||||
|
||||
def printed_number(body: str):
|
||||
"""(value, reason) for a label's visible text.
|
||||
|
||||
Returns a value only when the text carries exactly one complete numeric
|
||||
token. Trailing units are fine ("500ms"); a second number, or digits the
|
||||
token did not consume, is ambiguous and reported rather than guessed at.
|
||||
"""
|
||||
match = NUMBER_RE.search(body)
|
||||
if match is None:
|
||||
return None, "prints no number"
|
||||
outside = body[:match.start()] + body[match.end():]
|
||||
if DIGIT_RE.search(outside):
|
||||
return None, "prints more than one numeric token"
|
||||
value = number(match.group())
|
||||
if value is None:
|
||||
return None, "prints a number this checker cannot read"
|
||||
return value, None
|
||||
|
||||
|
||||
def median(values: list) -> float:
|
||||
ordered = sorted(values)
|
||||
middle = len(ordered) // 2
|
||||
if len(ordered) % 2:
|
||||
return ordered[middle]
|
||||
return (ordered[middle - 1] + ordered[middle]) / 2.0
|
||||
|
||||
|
||||
def fit(points: list) -> tuple:
|
||||
"""Robust (slope, intercept) for coordinate = slope * value + intercept.
|
||||
|
||||
Theil-Sen - the median of all pairwise slopes - rather than least squares,
|
||||
so one dishonest bubble does not drag the line it is measured against.
|
||||
Returns (None, None) when every value is the same, which the caller
|
||||
reports: an axis with one distinct value has no derivable scale.
|
||||
"""
|
||||
slopes = [
|
||||
(cb - ca) / (vb - va)
|
||||
for index, (va, ca) in enumerate(points)
|
||||
for vb, cb in points[index + 1:]
|
||||
if vb != va
|
||||
]
|
||||
if not slopes:
|
||||
return None, None
|
||||
slope = median(slopes)
|
||||
return slope, median([c - slope * v for v, c in points])
|
||||
|
||||
|
||||
def outliers(points: list, tolerance: float) -> list:
|
||||
"""Points that disagree with the line their PEERS describe.
|
||||
|
||||
Leave-one-out, because measuring a point against a fit that includes it
|
||||
lets it hide inside its own influence. Needs four points, so each fit is
|
||||
made from at least three - which is why fewer than four bubbles is the
|
||||
fail-closed case rather than a smaller check.
|
||||
|
||||
Returns [(index, drawn, expected)].
|
||||
"""
|
||||
if len(points) < 4:
|
||||
return []
|
||||
found = []
|
||||
for index, (value, drawn) in enumerate(points):
|
||||
peers = points[:index] + points[index + 1:]
|
||||
slope, intercept = fit(peers)
|
||||
if slope is None:
|
||||
continue
|
||||
expected = slope * value + intercept
|
||||
if abs(drawn - expected) > tolerance:
|
||||
found.append((index, drawn, expected))
|
||||
return found
|
||||
|
||||
|
||||
def named_text(source: str) -> str:
|
||||
"""The accessible name and description, where a figure says what it is."""
|
||||
return " ".join(plain(m.group("body")) for m in NAMED_RE.finditer(source)).casefold()
|
||||
|
||||
|
||||
def looks_like_bubble(path: Path, source: str) -> bool:
|
||||
"""Does this file present itself as a bubble chart?
|
||||
|
||||
Deliberately generous - anything that claims the type in its name, its
|
||||
accessible description, or its markup is held to the contract, and a file
|
||||
that claims it while declaring nothing parseable is the fail-closed case,
|
||||
not a pass. Scoped to bubble-specific signals only: `data-size` rather
|
||||
than `data-series`, so this checker and `verify-slopegraph.py` never claim
|
||||
one another's files.
|
||||
"""
|
||||
if path.name.startswith("example-bubble"):
|
||||
return True
|
||||
if DECLARES_BUBBLE_RE.search(source):
|
||||
return True
|
||||
described = named_text(source)
|
||||
return "bubble chart" in described or "bubble plot" in described
|
||||
|
||||
|
||||
def transformed_spans(source: str) -> list:
|
||||
"""Offset ranges enclosed by a <g>/<svg> that carries a transform.
|
||||
|
||||
Element-level transforms are easy to see; an ancestor's is not, and shifts
|
||||
everything inside it identically - exactly the change that leaves all
|
||||
internal consistency intact while moving every mark on the page.
|
||||
"""
|
||||
events = []
|
||||
for match in GROUP_OPEN_RE.finditer(source):
|
||||
if match.group("selfclose"):
|
||||
continue
|
||||
events.append((match.start(), 0, "transform" in attrs_of(match.group("attrs"))))
|
||||
for match in GROUP_CLOSE_RE.finditer(source):
|
||||
events.append((match.start(), 1, False))
|
||||
events.sort()
|
||||
stack, spans = [], []
|
||||
for position, kind, transformed in events:
|
||||
if kind == 0:
|
||||
stack.append((position, transformed))
|
||||
elif stack:
|
||||
start, was_transformed = stack.pop()
|
||||
if was_transformed:
|
||||
spans.append((start, position))
|
||||
# An unclosed transformed group covers everything after it.
|
||||
for start, was_transformed in stack:
|
||||
if was_transformed:
|
||||
spans.append((start, len(source)))
|
||||
return spans
|
||||
|
||||
|
||||
def parse_bubbles(source: str, findings: list, name: str) -> list:
|
||||
"""Bubble circles, with anything unparseable reported rather than dropped."""
|
||||
bubbles = []
|
||||
for match in CIRCLE_RE.finditer(source):
|
||||
raw = match.group("attrs")
|
||||
attrs = attrs_of(raw)
|
||||
if "data-size" not in attrs:
|
||||
# A <circle> with no data-size is scenery - a paper underlay, a
|
||||
# legend swatch, a dot-grid cell - and skipping it is correct. But
|
||||
# one whose raw text DOES declare data-size and still parsed to
|
||||
# nothing is markup this checker cannot read, and dropping it
|
||||
# silently is how a lie ships.
|
||||
if DECLARES_BUBBLE_RE.search(raw):
|
||||
findings.append(
|
||||
"%s:%d: a <circle> declares data-size but its attributes could "
|
||||
"not be parsed — the checker will not silently skip markup it "
|
||||
"cannot read. Use plain quoted attributes"
|
||||
% (name, line_of(source, match.start()))
|
||||
)
|
||||
continue
|
||||
label = attrs.get("data-name")
|
||||
if label is None:
|
||||
findings.append(
|
||||
"%s:%d: a bubble declares data-size but no data-name — an unnamed "
|
||||
"bubble cannot be labelled or cross-checked"
|
||||
% (name, line_of(source, match.start()))
|
||||
)
|
||||
continue
|
||||
missing = [key for key in ("data-x", "data-y", "cx", "cy", "r")
|
||||
if key not in attrs]
|
||||
if missing:
|
||||
findings.append(
|
||||
"%s:%d: bubble %r is missing %s — a bubble must declare all three "
|
||||
"values and all three drawn quantities or it cannot be verified"
|
||||
% (name, line_of(source, match.start()), label, ", ".join(missing))
|
||||
)
|
||||
continue
|
||||
parsed = [number(attrs[key])
|
||||
for key in ("data-x", "data-y", "data-size", "cx", "cy", "r")]
|
||||
if any(value is None for value in parsed):
|
||||
findings.append(
|
||||
"%s:%d: bubble %r has a value or coordinate that is not a finite "
|
||||
"number — cannot verify its position or area"
|
||||
% (name, line_of(source, match.start()), label)
|
||||
)
|
||||
continue
|
||||
if parsed[2] <= 0:
|
||||
# Area cannot encode a non-positive value; the honest treatment of
|
||||
# a zero or missing measurement is to omit the bubble and count the
|
||||
# omission in the source line, exactly as the reference says.
|
||||
findings.append(
|
||||
"%s:%d: bubble %r declares data-size=%g — area cannot encode a "
|
||||
"non-positive value. Omit the item and note the omission in the "
|
||||
"source line" % (name, line_of(source, match.start()), label,
|
||||
parsed[2])
|
||||
)
|
||||
continue
|
||||
if parsed[5] <= 0:
|
||||
findings.append(
|
||||
"%s:%d: bubble %r has r=%g — a bubble with no area states no value"
|
||||
% (name, line_of(source, match.start()), label, parsed[5])
|
||||
)
|
||||
continue
|
||||
accent = bool(ACCENT_RE.search(attrs.get("stroke", "")))
|
||||
bubbles.append(Bubble(label, parsed[0], parsed[1], parsed[2], parsed[3],
|
||||
parsed[4], parsed[5], accent, match.start()))
|
||||
return bubbles
|
||||
|
||||
|
||||
def check_transforms(source: str, findings: list, name: str) -> None:
|
||||
"""No transform may move verified geometry or a bound label."""
|
||||
spans = transformed_spans(source)
|
||||
|
||||
def enclosed(offset):
|
||||
return any(start <= offset <= end for start, end in spans)
|
||||
|
||||
def report(offset, what, how):
|
||||
findings.append(
|
||||
"%s:%d: %s carries %s — this checker validates raw cx/cy/r and x/y "
|
||||
"attributes, so a transform moves the rendered mark away from the "
|
||||
"number it was checked against. Bake the offset into the coordinates "
|
||||
"instead" % (name, line_of(source, offset), what, how)
|
||||
)
|
||||
|
||||
for match in CIRCLE_RE.finditer(source):
|
||||
attrs = attrs_of(match.group("attrs"))
|
||||
if "data-size" not in attrs:
|
||||
continue
|
||||
what = "bubble %r" % attrs.get("data-name", "?")
|
||||
if "transform" in attrs:
|
||||
report(match.start(), what, "transform=%r" % attrs["transform"])
|
||||
elif enclosed(match.start()):
|
||||
report(match.start(), what, "an ancestor <g>/<svg> transform")
|
||||
|
||||
for match in TEXT_RE.finditer(source):
|
||||
attrs = attrs_of(match.group("attrs"))
|
||||
if "data-name" not in attrs and "data-tick" not in attrs:
|
||||
continue
|
||||
what = "a bound label (%s)" % plain(match.group("body"))[:20]
|
||||
if "transform" in attrs:
|
||||
report(match.start(), what, "transform=%r" % attrs["transform"])
|
||||
elif enclosed(match.start()):
|
||||
report(match.start(), what, "an ancestor <g>/<svg> transform")
|
||||
|
||||
for match in STYLE_RE.finditer(source):
|
||||
found = CSS_TRANSFORM_RE.search(match.group("body"))
|
||||
if found:
|
||||
findings.append(
|
||||
"%s:%d: a CSS `transform` declaration — this checker cannot tell "
|
||||
"which marks it applies to, and a transform on verified geometry "
|
||||
"invalidates every coordinate here. Remove it, or bake the offset "
|
||||
"into the coordinates"
|
||||
% (name, line_of(source, match.start("body") + found.start()))
|
||||
)
|
||||
|
||||
|
||||
def check_axis(bubbles: list, axis: str, findings: list, source: str,
|
||||
name: str):
|
||||
"""One shared linear scale per axis, and no bubble may drift off it.
|
||||
|
||||
Returns the (slope, intercept) fit for the axis so the tick check can
|
||||
measure against the same scale, or (None, None) when none is derivable.
|
||||
"""
|
||||
if axis == "x":
|
||||
points = [(b.x, b.cx) for b in bubbles]
|
||||
drawn_attr = "cx"
|
||||
else:
|
||||
points = [(b.y, b.cy) for b in bubbles]
|
||||
drawn_attr = "cy"
|
||||
line = line_of(source, bubbles[0].offset)
|
||||
|
||||
slope, intercept = fit(points)
|
||||
if slope is None:
|
||||
findings.append(
|
||||
"%s:%d: the %s axis has no two distinct declared values, so its scale "
|
||||
"cannot be derived and no position on it is verifiable"
|
||||
% (name, line, axis)
|
||||
)
|
||||
return None, None
|
||||
|
||||
# A bubble whose PEERS all share one value cannot be measured: the
|
||||
# leave-one-out fit for it is degenerate, so `outliers` skips it, and the
|
||||
# full-set fit passes exactly through wherever it was drawn — its position
|
||||
# would define the scale rather than be checked by it. Fail closed: that
|
||||
# is an unverifiable mark, not a pass. (Greptile flagged the silent skip.)
|
||||
for index, (value, _drawn) in enumerate(points):
|
||||
peer_values = {points[i][0] for i in range(len(points)) if i != index}
|
||||
if len(peer_values) < 2:
|
||||
b = bubbles[index]
|
||||
findings.append(
|
||||
"%s:%d: bubble %r holds the only distinct %s value (%g) — its "
|
||||
"peers cannot describe a scale to check it against, so its "
|
||||
"position would define the axis instead of being verified by "
|
||||
"it. An axis needs two distinct values among the other bubbles"
|
||||
% (name, line_of(source, b.offset), b.name, axis, value)
|
||||
)
|
||||
|
||||
for index, drawn, expected in outliers(points, RESIDUAL_TOLERANCE):
|
||||
b = bubbles[index]
|
||||
findings.append(
|
||||
"%s:%d: bubble %r declares %s=%g but draws %s=%g where the shared %s "
|
||||
"scale its peers describe puts it at %.1f — off by %.1f px. Crowded "
|
||||
"bubbles are data; never nudge one aside"
|
||||
% (name, line_of(source, b.offset), b.name, axis,
|
||||
points[index][0], drawn_attr, drawn, axis, expected,
|
||||
abs(drawn - expected))
|
||||
)
|
||||
return slope, intercept
|
||||
|
||||
|
||||
def check_area(bubbles: list, findings: list, source: str, name: str) -> None:
|
||||
"""r must equal K*sqrt(size) for one K shared by the whole set.
|
||||
|
||||
The ratio r^2/size is constant under honest area encoding and varies under
|
||||
every dishonest alternative - radius-proportional sizing makes it grow
|
||||
linearly with the value, and a single inflated bubble moves only its own
|
||||
ratio. Leave-one-out again, so the offender is measured against its peers.
|
||||
"""
|
||||
if len(bubbles) < 4:
|
||||
return
|
||||
ratios = [b.r * b.r / b.size for b in bubbles]
|
||||
for index, b in enumerate(bubbles):
|
||||
peers = ratios[:index] + ratios[index + 1:]
|
||||
expected = math.sqrt(median(peers) * b.size)
|
||||
if abs(b.r - expected) > RADIUS_TOLERANCE:
|
||||
findings.append(
|
||||
"%s:%d: bubble %r declares size %g but draws r=%g where the area "
|
||||
"scale its peers share puts %.1f — area must be proportional to "
|
||||
"the value (r = K*sqrt(size)), and radius-proportional sizing "
|
||||
"shows a 6x value as 36x the ink"
|
||||
% (name, line_of(source, b.offset), b.name, b.size, b.r, expected)
|
||||
)
|
||||
|
||||
|
||||
def check_focal(bubbles: list, findings: list, source: str, name: str) -> None:
|
||||
"""At most one bubble wears the accent."""
|
||||
accented = [b for b in bubbles if b.accent]
|
||||
for extra in accented[1:]:
|
||||
findings.append(
|
||||
"%s:%d: bubble %r also carries the accent stroke — one accent bubble "
|
||||
"max (%r already has it). A second focal mark is a second editorial "
|
||||
"claim, and the ink ramp exists so everything else stays ranked "
|
||||
"without spending the accent"
|
||||
% (name, line_of(source, extra.offset), extra.name, accented[0].name)
|
||||
)
|
||||
|
||||
|
||||
def check_paint_order(bubbles: list, findings: list, source: str, name: str) -> None:
|
||||
"""Where two bubbles overlap, the larger must be painted first.
|
||||
|
||||
Painted later means painted on top: a small bubble under a large one is
|
||||
invisible and its area unreadable. Ties pass - equal radii cannot bury
|
||||
each other in a way order fixes - and separation by at least a hairline
|
||||
is the honest fix for near-touching bubbles, so only true overlap counts.
|
||||
"""
|
||||
for index, a in enumerate(bubbles):
|
||||
for b in bubbles[index + 1:]:
|
||||
gap = math.hypot(a.cx - b.cx, a.cy - b.cy) - (a.r + b.r)
|
||||
if gap < -OVERLAP_SLACK and a.r < b.r:
|
||||
findings.append(
|
||||
"%s:%d: bubble %r (r=%g) overlaps %r (r=%g) but is painted "
|
||||
"first — draw overlapping bubbles largest-first so the small "
|
||||
"one stays on top and both areas stay readable"
|
||||
% (name, line_of(source, a.offset), a.name, a.r, b.name, b.r)
|
||||
)
|
||||
|
||||
|
||||
def check_labels(bubbles: list, source: str, findings: list, name: str) -> None:
|
||||
"""Bound labels must name a real bubble, match it, and sit on it."""
|
||||
centres = {b.name: (b.cx, b.cy) for b in bubbles}
|
||||
seen = set()
|
||||
for match in TEXT_RE.finditer(source):
|
||||
attrs = attrs_of(match.group("attrs"))
|
||||
label = attrs.get("data-name")
|
||||
if label is None:
|
||||
continue
|
||||
offset = match.start()
|
||||
body = plain(match.group("body"))
|
||||
if label not in centres:
|
||||
findings.append(
|
||||
"%s:%d: a label names bubble %r, which no circle declares — a "
|
||||
"label with no mark is not verifiable and reads as data"
|
||||
% (name, line_of(source, offset), label)
|
||||
)
|
||||
continue
|
||||
if label in seen:
|
||||
findings.append(
|
||||
"%s:%d: a second label for bubble %r — one bubble, one label, or "
|
||||
"the figure states two things about one mark"
|
||||
% (name, line_of(source, offset), label)
|
||||
)
|
||||
continue
|
||||
seen.add(label)
|
||||
# Case-insensitive: labels ship small-caps ("PAYMENTS" for a bubble
|
||||
# named "Payments"), and casing is presentation, not identity.
|
||||
if body.casefold() != label.casefold():
|
||||
findings.append(
|
||||
"%s:%d: a label bound to bubble %r reads %r — the visible text "
|
||||
"and the binding must agree"
|
||||
% (name, line_of(source, offset), label, body[:28])
|
||||
)
|
||||
continue
|
||||
x, y = number(attrs.get("x")), number(attrs.get("y"))
|
||||
if x is None or y is None:
|
||||
findings.append(
|
||||
"%s:%d: the label for bubble %r has no readable x/y, so its "
|
||||
"placement cannot be checked"
|
||||
% (name, line_of(source, offset), label)
|
||||
)
|
||||
continue
|
||||
own_cx, own_cy = centres[label]
|
||||
own = math.hypot(x - own_cx, y - own_cy)
|
||||
nearest = min(
|
||||
(other for other in centres if other != label),
|
||||
key=lambda other: math.hypot(x - centres[other][0],
|
||||
y - centres[other][1]),
|
||||
default=None,
|
||||
)
|
||||
if nearest is not None:
|
||||
near = math.hypot(x - centres[nearest][0], y - centres[nearest][1])
|
||||
if near < own - LABEL_TOLERANCE:
|
||||
findings.append(
|
||||
"%s:%d: the label for bubble %r is drawn at (%g, %g), nearer "
|
||||
"%r's centre than its own — a label on the wrong bubble "
|
||||
"renames the mark"
|
||||
% (name, line_of(source, offset), label, x, y, nearest)
|
||||
)
|
||||
|
||||
|
||||
def check_ticks(x_fit, y_fit, source: str, findings: list, name: str) -> None:
|
||||
"""Bound axis ticks must print their value and sit on the bubbles' scale."""
|
||||
ticks = {"x": [], "y": []}
|
||||
for match in TEXT_RE.finditer(source):
|
||||
attrs = attrs_of(match.group("attrs"))
|
||||
axis = attrs.get("data-tick")
|
||||
if axis is None:
|
||||
continue
|
||||
offset = match.start()
|
||||
if axis not in ("x", "y"):
|
||||
findings.append(
|
||||
"%s:%d: a tick label has data-tick=%r, which is neither 'x' nor "
|
||||
"'y'" % (name, line_of(source, offset), axis)
|
||||
)
|
||||
continue
|
||||
declared = number(attrs.get("data-value"))
|
||||
if declared is None:
|
||||
findings.append(
|
||||
"%s:%d: a %s tick has no readable data-value — an unbound axis "
|
||||
"number is the cheapest way to relabel a whole chart"
|
||||
% (name, line_of(source, offset), axis)
|
||||
)
|
||||
continue
|
||||
body = plain(match.group("body"))
|
||||
shown, reason = printed_number(body)
|
||||
if shown is None:
|
||||
findings.append(
|
||||
"%s:%d: the %s tick for %g %s (%r) — print one complete number "
|
||||
"per tick" % (name, line_of(source, offset), axis, declared,
|
||||
reason, body[:28])
|
||||
)
|
||||
continue
|
||||
if abs(shown - declared) > VALUE_TOLERANCE:
|
||||
findings.append(
|
||||
"%s:%d: a %s tick prints %r but declares %g — the label and the "
|
||||
"binding must state one number"
|
||||
% (name, line_of(source, offset), axis, body[:28], declared)
|
||||
)
|
||||
continue
|
||||
position = number(attrs.get("x" if axis == "x" else "y"))
|
||||
if position is None:
|
||||
findings.append(
|
||||
"%s:%d: the %s tick for %g has no readable position"
|
||||
% (name, line_of(source, offset), axis, declared)
|
||||
)
|
||||
continue
|
||||
ticks[axis].append((declared, position, offset))
|
||||
|
||||
for axis, fit_pair in (("x", x_fit), ("y", y_fit)):
|
||||
entries = ticks[axis]
|
||||
if len({value for value, _pos, _off in entries}) < 2:
|
||||
findings.append(
|
||||
"%s: the %s axis binds %d distinct tick value(s) — an axis needs "
|
||||
"at least two bound ticks (data-tick/data-value) or its printed "
|
||||
"scale is unverifiable against the drawn one"
|
||||
% (name, axis, len({v for v, _p, _o in entries}))
|
||||
)
|
||||
continue
|
||||
slope, intercept = fit_pair
|
||||
if slope is None:
|
||||
continue # already reported by check_axis
|
||||
for value, position, offset in entries:
|
||||
expected = slope * value + intercept
|
||||
if abs(position - expected) > TICK_TOLERANCE:
|
||||
findings.append(
|
||||
"%s:%d: the %s tick for %g is drawn at %g but the scale the "
|
||||
"bubbles themselves describe puts that value at %.1f — the "
|
||||
"printed axis and the drawn positions disagree, so every "
|
||||
"reading off this axis is wrong"
|
||||
% (name, line_of(source, offset), axis, value, position,
|
||||
expected)
|
||||
)
|
||||
|
||||
|
||||
def check_source(path: Path, raw: str) -> list:
|
||||
"""Findings for one already-read document."""
|
||||
source = blank_comments(raw)
|
||||
findings: list = []
|
||||
bubbles = parse_bubbles(source, findings, path.name)
|
||||
|
||||
if len(bubbles) < 4:
|
||||
findings.append(
|
||||
"%s: presents as a bubble chart but declares %d verifiable bubble(s) "
|
||||
"— each needs data-name, data-x, data-y, data-size, cx, cy and r, "
|
||||
"and the leave-one-out scale test needs four. Refusing to report OK "
|
||||
"on a file this checker could not read" % (path.name, len(bubbles))
|
||||
)
|
||||
return findings
|
||||
|
||||
check_transforms(source, findings, path.name)
|
||||
x_fit = check_axis(bubbles, "x", findings, source, path.name)
|
||||
y_fit = check_axis(bubbles, "y", findings, source, path.name)
|
||||
check_area(bubbles, findings, source, path.name)
|
||||
check_focal(bubbles, findings, source, path.name)
|
||||
check_paint_order(bubbles, findings, source, path.name)
|
||||
check_labels(bubbles, source, findings, path.name)
|
||||
check_ticks(x_fit, y_fit, source, findings, path.name)
|
||||
return findings
|
||||
|
||||
|
||||
def check(path: Path) -> list:
|
||||
"""Findings for one file on disk, or [] if it is not a bubble chart."""
|
||||
raw = path.read_text(encoding="utf-8")
|
||||
if not looks_like_bubble(path, raw):
|
||||
return []
|
||||
return check_source(path, raw)
|
||||
|
||||
|
||||
def targets(args: argparse.Namespace) -> list:
|
||||
if args.all:
|
||||
return sorted(ASSET_DIR.glob("example-*.html"))
|
||||
return [Path(p) for p in args.paths]
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Verify bubble positions and areas against the values they declare."
|
||||
)
|
||||
parser.add_argument("paths", nargs="*", help="HTML files to check")
|
||||
parser.add_argument(
|
||||
"--all", action="store_true",
|
||||
help="check every shipped example that presents as a bubble chart",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
if not args.all and not args.paths:
|
||||
parser.print_help()
|
||||
return 2
|
||||
|
||||
findings: list = []
|
||||
checked = 0
|
||||
skipped = 0
|
||||
for path in targets(args):
|
||||
if not path.is_file():
|
||||
print("error: %s is not a readable file" % path, file=sys.stderr)
|
||||
return 2
|
||||
try:
|
||||
raw = path.read_text(encoding="utf-8")
|
||||
except (OSError, UnicodeDecodeError) as error:
|
||||
print("error: cannot read %s: %s" % (path, error), file=sys.stderr)
|
||||
return 2
|
||||
# Read once, then decide. Scope is reported separately from
|
||||
# verification in both modes: printing "1 file(s) verified" for a
|
||||
# scatter that was skipped is a claim the run never made good on.
|
||||
if not looks_like_bubble(path, raw):
|
||||
skipped += 1
|
||||
continue
|
||||
findings.extend(check_source(path, raw))
|
||||
checked += 1
|
||||
|
||||
for finding in findings:
|
||||
print(finding)
|
||||
tail = " (%d file(s) skipped as out of scope)" % skipped if skipped else ""
|
||||
if findings:
|
||||
print("\n%d bubble finding(s) across %d file(s).%s"
|
||||
% (len(findings), checked, tail))
|
||||
return 1
|
||||
if not checked:
|
||||
print("OK bubble: no bubble chart found to check%s" % tail)
|
||||
return 0
|
||||
print("OK bubble: %d file(s), shared x and y scales, area proportional to every "
|
||||
"declared size, at most one accent bubble, largest-first paint order, and "
|
||||
"every bound label and tick agreeing with the mark it describes%s"
|
||||
% (checked, tail))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user