feat(line): add slopegraph variant for change between two states (#106)
This commit is contained in:
@@ -0,0 +1,673 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Adversarial cases for verify-slopegraph.py, both polarities.
|
||||
|
||||
Every case is named for exactly what it asserts. A case called "unlabelled
|
||||
sliver does not disable the check" once proved only that the *labelled* elements
|
||||
stayed checked, which made a real gap look covered for days - so a name here
|
||||
that overclaims is itself a defect.
|
||||
|
||||
The negative half matters as much as the positive: a checker that fires on an
|
||||
honest indexed slopegraph, or on the other shipped example types, gets widened
|
||||
or switched off, and then it guards nothing.
|
||||
|
||||
Fixtures live in a per-process temporary directory. They used to be written to
|
||||
fixed paths under the repository root and unconditionally unlinked, which would
|
||||
overwrite and then delete a real file that happened to share a fixture name, and
|
||||
raced when two runs overlapped. Two cases at the end hold that fix in place.
|
||||
|
||||
Exit: 0 all pass, 1 any failure.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
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-slopegraph")
|
||||
|
||||
SHIPPED = ROOT / "skills/diagram-design/assets/example-slopegraph.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="slopegraph-title slopegraph-desc">
|
||||
<title id="slopegraph-title">t</title>
|
||||
<desc id="slopegraph-desc">Slopegraph fixture.</desc>
|
||||
"""
|
||||
TAIL = "</svg></body></html>\n"
|
||||
|
||||
# The bound state captions every slopegraph must carry.
|
||||
CAPTIONS = (
|
||||
' <text data-axis="from" data-state="BEFORE" x="320" y="440">BEFORE</text>\n'
|
||||
' <text data-axis="to" data-state="AFTER" x="680" y="440">AFTER</text>\n'
|
||||
)
|
||||
|
||||
# y = 420 - (v - 100) * 380/450, the shared scale the shipped example uses:
|
||||
# p95 milliseconds over a 100-550 domain.
|
||||
PX = 380.0 / 450.0
|
||||
|
||||
|
||||
def y(value: float) -> float:
|
||||
return round(420 - (value - 100) * PX, 1)
|
||||
|
||||
|
||||
def series(name: str, frm: float, to: float, y1=None, y2=None,
|
||||
x1: float = 320, x2: float = 680, omit: str = "") -> str:
|
||||
parts = ['data-series="%s"' % name]
|
||||
if omit != "data-from":
|
||||
parts.append('data-from="%s"' % frm)
|
||||
if omit != "data-to":
|
||||
parts.append('data-to="%s"' % to)
|
||||
parts.append('x1="%g"' % x1)
|
||||
if omit != "y1":
|
||||
parts.append('y1="%g"' % (y(frm) if y1 is None else y1))
|
||||
parts.append('x2="%g"' % x2)
|
||||
if omit != "y2":
|
||||
parts.append('y2="%g"' % (y(to) if y2 is None else y2))
|
||||
return " <line %s stroke=\"#2d3142\" stroke-width=\"1.2\"/>\n" % " ".join(parts)
|
||||
|
||||
|
||||
def labels(name: str, frm: float, to: float, shown_from=None, shown_to=None,
|
||||
skip: str = "", skip_name: str = "", y_from=None, y_to=None) -> str:
|
||||
"""The value and name label pair for each end of one series.
|
||||
|
||||
y_from / y_to place the labels independently of the value scale, so a
|
||||
fixture that moves the geometry can move its labels with it. A fixture
|
||||
whose labels stay behind reports a real row defect that has nothing to do
|
||||
with what the case is testing.
|
||||
"""
|
||||
out = ""
|
||||
for end, value, shown, row in (("from", frm, shown_from, y_from),
|
||||
("to", to, shown_to, y_to)):
|
||||
if skip == end:
|
||||
continue
|
||||
text = value if shown is None else shown
|
||||
baseline = (y(value) if row is None else row) + 3.5
|
||||
out += (' <text data-series="%s" data-end="%s" x="%d" y="%g">%s</text>\n'
|
||||
% (name, end, 304 if end == "from" else 696, baseline, text))
|
||||
if skip_name != end:
|
||||
out += (' <text data-series="%s" data-end="%s" data-role="name" '
|
||||
'x="%d" y="%g">%s</text>\n'
|
||||
% (name, end, 272 if end == "from" else 728, baseline, name))
|
||||
return out
|
||||
|
||||
|
||||
def document(*blocks: str) -> str:
|
||||
return HEAD + CAPTIONS + "".join(blocks) + TAIL
|
||||
|
||||
|
||||
def honest(rows) -> str:
|
||||
return document(honest_rows_block(rows))
|
||||
|
||||
|
||||
def honest_rows_block(rows=None) -> str:
|
||||
"""The series+labels block for a correct figure, without the wrapper."""
|
||||
body = ""
|
||||
for name, frm, to in (ROWS if rows is None else rows):
|
||||
body += series(name, frm, to) + labels(name, frm, to)
|
||||
return body
|
||||
|
||||
|
||||
ROWS = [("Search", 512, 208), ("Catalog", 376, 164), ("Checkout", 291, 143),
|
||||
("Auth", 154, 121), ("Recommender", 238, 431)]
|
||||
|
||||
|
||||
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="slopegraph-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-slopegraph-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, label: 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" % label)
|
||||
for item in found:
|
||||
print(" unexpected: %s" % item)
|
||||
else:
|
||||
print("ok %s" % label)
|
||||
|
||||
def expect_finding(self, label: 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" % label)
|
||||
return
|
||||
if not any(re.search(pattern, item) for item in found):
|
||||
self.failures += 1
|
||||
print("FAIL %s\n no finding matched %r" % (label, pattern))
|
||||
for item in found:
|
||||
print(" got: %s" % item)
|
||||
return
|
||||
print("ok %s" % label)
|
||||
|
||||
def expect_only_one(self, label: 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" % label)
|
||||
return
|
||||
self.failures += 1
|
||||
print("FAIL %s\n expected exactly one finding matching %r, got %d"
|
||||
% (label, pattern, len(found)))
|
||||
for item in found:
|
||||
print(" got: %s" % item)
|
||||
|
||||
def expect_out_of_scope(self, label: str, source: str, name: str) -> None:
|
||||
"""Not merely finding-free - genuinely outside this checker's scope.
|
||||
|
||||
expect_clean cannot tell the two apart, so four cases named "out of
|
||||
scope" were only ever proving "no findings", which a detected-and-clean
|
||||
file also satisfies.
|
||||
"""
|
||||
self.count += 1
|
||||
path = self.path_for(name)
|
||||
path.write_text(source, encoding="utf-8")
|
||||
try:
|
||||
detected = verify.looks_like_slopegraph(path, source)
|
||||
found = verify.check(path)
|
||||
finally:
|
||||
path.unlink()
|
||||
if detected or found:
|
||||
self.failures += 1
|
||||
print("FAIL %s\n detected=%s findings=%d"
|
||||
% (label, detected, len(found)))
|
||||
return
|
||||
print("ok %s" % label)
|
||||
|
||||
def check(self, label: str, condition: bool, detail: str = "") -> None:
|
||||
self.count += 1
|
||||
if condition:
|
||||
print("ok %s" % label)
|
||||
return
|
||||
self.failures += 1
|
||||
print("FAIL %s%s" % (label, ("\n " + detail) if detail else ""))
|
||||
|
||||
|
||||
def main() -> int:
|
||||
h = Harness()
|
||||
try:
|
||||
return run_cases(h)
|
||||
finally:
|
||||
h.close()
|
||||
|
||||
|
||||
def run_cases(h: Harness) -> int:
|
||||
# ── Negative half: honest figures must stay silent ────────────────────
|
||||
h.expect_clean("an honest slopegraph reports nothing", honest(ROWS))
|
||||
|
||||
h.expect_clean(
|
||||
"the shipped light example reports nothing",
|
||||
SHIPPED.read_text(encoding="utf-8"), name="example-slopegraph.html",
|
||||
)
|
||||
|
||||
# Two series holding identical values must land on identical y. This is the
|
||||
# case the draft got wrong, and the honest rendering of it must pass.
|
||||
h.expect_clean(
|
||||
"two series with identical values drawn at identical y is legal",
|
||||
honest([("Search", 512, 208), ("Mirror", 512, 208), ("Auth", 154, 121)]),
|
||||
)
|
||||
|
||||
h.expect_clean(
|
||||
"a correct indexed slopegraph (every series starts at the index base) "
|
||||
"is not rejected",
|
||||
honest([("api", 100, 143), ("web", 100, 118), ("worker", 100, 196)]),
|
||||
)
|
||||
|
||||
# Coordinates ship rounded to 0.1px, and an author may reasonably round to
|
||||
# whole pixels instead. Neither may read as a defect.
|
||||
for label, quantum in (("one decimal", 1), ("whole pixels", 0)):
|
||||
rounded = ""
|
||||
for name, frm, to in ROWS:
|
||||
rounded += series(name, frm, to, y1=round(y(frm), quantum),
|
||||
y2=round(y(to), quantum)) + labels(name, frm, to)
|
||||
h.expect_clean("coordinates rounded to %s stay within tolerance" % label,
|
||||
document(rounded))
|
||||
|
||||
# The rotated value-axis caption carries a transform and must not be caught:
|
||||
# it is not verified geometry and not a bound label.
|
||||
h.expect_clean(
|
||||
"the rotated value-axis caption's transform is not reported",
|
||||
document(' <text transform="rotate(-90 24 230)" x="24" y="230">'
|
||||
"P95 RESPONSE TIME, MS</text>\n" + honest_rows_block()),
|
||||
)
|
||||
|
||||
# `text-transform` is not `transform`.
|
||||
h.expect_clean(
|
||||
"a CSS text-transform declaration is not read as a transform",
|
||||
HEAD + "<style>.eyebrow { text-transform: uppercase; }</style>\n"
|
||||
+ CAPTIONS + honest_rows_block() + TAIL,
|
||||
)
|
||||
|
||||
# The detector must not drag the other example types into scope.
|
||||
for other in ("example-line.html", "example-bar.html", "example-treemap.html",
|
||||
"example-scatter.html"):
|
||||
path = ROOT / "skills/diagram-design/assets" / other
|
||||
if path.exists():
|
||||
h.expect_out_of_scope(
|
||||
"%s is not detected as a slopegraph at all" % other,
|
||||
path.read_text(encoding="utf-8"), other)
|
||||
|
||||
# ── Positive half: each lie must be named as itself ───────────────────
|
||||
|
||||
# 1. Jitter. One point moved to free up label space.
|
||||
jittered = ""
|
||||
for name, frm, to in ROWS:
|
||||
shift = 3.0 if name == "Search" else 0.0
|
||||
jittered += series(name, frm, to, y2=y(to) - shift) + labels(name, frm, to)
|
||||
h.expect_only_one(
|
||||
"a single endpoint nudged 3px reports as one point, not as a bad axis",
|
||||
document(jittered), r"'Search' draws its to endpoint.*off by 3\.0 px",
|
||||
)
|
||||
|
||||
# The specific shape of the draft's bug: two series at identical values
|
||||
# pulled apart so their labels clear each other.
|
||||
collided = ""
|
||||
for name, frm, to in [("Search", 512, 208), ("Mirror", 512, 208),
|
||||
("Catalog", 376, 164), ("Checkout", 291, 143),
|
||||
("Auth", 154, 121)]:
|
||||
if name == "Search":
|
||||
collided += series(name, frm, to, y1=y(frm) - 3, y2=y(to) - 3)
|
||||
else:
|
||||
collided += series(name, frm, to)
|
||||
collided += labels(name, frm, to)
|
||||
h.expect_finding(
|
||||
"an equal-valued series pulled 3px clear of its twin's labels is reported",
|
||||
document(collided), r"'Search' draws its (from|to) endpoint",
|
||||
)
|
||||
|
||||
# An indexed slopegraph with one endpoint wrong. A whole-set fit called this
|
||||
# clean: with three points and one 3px error Theil-Sen has no outlier budget,
|
||||
# and the contaminated slope passed within 1px of every point.
|
||||
indexed_lie = (series("api", 100, 118) + labels("api", 100, 118)
|
||||
+ series("web", 100, 143) + labels("web", 100, 143)
|
||||
+ series("worker", 100, 196, y2=y(196) + 3)
|
||||
+ labels("worker", 100, 196))
|
||||
h.expect_finding(
|
||||
"an indexed slopegraph with one endpoint 3px wrong is reported",
|
||||
document(indexed_lie), r"'worker' draws its to endpoint",
|
||||
)
|
||||
|
||||
# 2. Dual scale, and a shared scale with a shifted origin.
|
||||
dual = ""
|
||||
for name, frm, to in ROWS:
|
||||
dual += series(name, frm, to, y2=round(420 - (420 - y(to)) * 0.8, 1)) \
|
||||
+ labels(name, frm, to)
|
||||
h.expect_only_one(
|
||||
"a right axis compressed to 80% reports as a scale mismatch and stops there",
|
||||
document(dual), r"the two axes are not on the same scale",
|
||||
)
|
||||
|
||||
shifted = ""
|
||||
for name, frm, to in ROWS:
|
||||
shifted += (series(name, frm, to, y2=y(to) - 12)
|
||||
+ labels(name, frm, to, y_to=y(to) - 12))
|
||||
h.expect_only_one(
|
||||
"a right axis shifted 12px at an identical slope reports as an origin offset",
|
||||
document(shifted), r"share a scale but not an origin",
|
||||
)
|
||||
|
||||
# 3. Transforms move the rendered mark away from the checked coordinates.
|
||||
h.expect_finding(
|
||||
"a transform on a series line is reported",
|
||||
document(series("Search", 512, 208).replace(
|
||||
" <line ", ' <line transform="translate(0 80)" ', 1)
|
||||
+ labels("Search", 512, 208)
|
||||
+ honest_rows_block([r for r in ROWS if r[0] != "Search"])),
|
||||
r"series 'Search' carries transform",
|
||||
)
|
||||
|
||||
h.expect_finding(
|
||||
"a transform on a bound label is reported",
|
||||
document(honest_rows_block().replace(
|
||||
' <text data-series="Search" data-end="from"',
|
||||
' <text transform="translate(0 40)" data-series="Search" data-end="from"',
|
||||
1)),
|
||||
r"bound label.*carries transform",
|
||||
)
|
||||
|
||||
h.expect_finding(
|
||||
"an ancestor <g> transform over the series is reported",
|
||||
document(' <g transform="translate(0 60)">\n' + honest_rows_block()
|
||||
+ " </g>\n"),
|
||||
r"carries an ancestor <g>/<svg> transform",
|
||||
)
|
||||
|
||||
h.expect_finding(
|
||||
"a CSS transform declaration is reported",
|
||||
HEAD + "<style>line { transform: translateY(40px); }</style>\n"
|
||||
+ CAPTIONS + honest_rows_block() + TAIL,
|
||||
r"a CSS `transform` declaration",
|
||||
)
|
||||
|
||||
# 4. The printed value must be one complete token that matches metadata.
|
||||
h.expect_finding(
|
||||
"a printed value that contradicts the declared one is reported",
|
||||
document(series("Search", 512, 208)
|
||||
+ labels("Search", 512, 208, shown_to="180")
|
||||
+ honest_rows_block([r for r in ROWS if r[0] != "Search"])),
|
||||
r"'Search' prints '180' at its to endpoint but declares 208",
|
||||
)
|
||||
|
||||
h.expect_finding(
|
||||
"a thousands separator that changes the value is reported",
|
||||
document(series("Search", 512, 208)
|
||||
+ labels("Search", 512, 208, shown_from="512,000")
|
||||
+ honest_rows_block([r for r in ROWS if r[0] != "Search"])),
|
||||
r"prints '512,000' at its from endpoint but declares 512",
|
||||
)
|
||||
|
||||
h.expect_finding(
|
||||
"a label carrying two numeric tokens is reported, not half-read",
|
||||
document(series("Search", 512, 208)
|
||||
+ labels("Search", 512, 208, shown_from="512 was 600")
|
||||
+ honest_rows_block([r for r in ROWS if r[0] != "Search"])),
|
||||
r"prints more than one numeric token",
|
||||
)
|
||||
|
||||
h.expect_clean(
|
||||
"a unit suffix on a printed value is accepted",
|
||||
document("".join(series(n, f, t)
|
||||
+ labels(n, f, t, shown_from="%dms" % f,
|
||||
shown_to="%dms" % t)
|
||||
for n, f, t in ROWS)),
|
||||
)
|
||||
|
||||
h.expect_finding(
|
||||
"a series missing one endpoint value label is reported",
|
||||
document(series("Search", 512, 208)
|
||||
+ labels("Search", 512, 208, skip="to")
|
||||
+ honest_rows_block([r for r in ROWS if r[0] != "Search"])),
|
||||
r"'Search' prints no to endpoint value",
|
||||
)
|
||||
|
||||
# 5. Captions and names must be bound to what they describe.
|
||||
swapped_text = document(honest_rows_block()).replace(
|
||||
">BEFORE</text>", ">@@T@@</text>", 1).replace(
|
||||
">AFTER</text>", ">BEFORE</text>", 1).replace(
|
||||
">@@T@@</text>", ">AFTER</text>", 1)
|
||||
h.expect_finding(
|
||||
"swapping only the visible caption text is reported",
|
||||
swapped_text, r"state caption reads 'AFTER' but declares data-state='BEFORE'",
|
||||
)
|
||||
|
||||
swapped_x = document(honest_rows_block()).replace(
|
||||
'data-state="BEFORE" x="320"', 'data-state="BEFORE" x="@@"', 1).replace(
|
||||
'data-state="AFTER" x="680"', 'data-state="AFTER" x="320"', 1).replace(
|
||||
'x="@@"', 'x="680"', 1)
|
||||
h.expect_finding(
|
||||
"swapping the caption x values is reported",
|
||||
swapped_x, r"state caption \('BEFORE'\) is drawn at x=680",
|
||||
)
|
||||
|
||||
h.expect_finding(
|
||||
"a caption with no data-state is reported",
|
||||
document(honest_rows_block()).replace(' data-state="BEFORE"', "", 1),
|
||||
r"has no data-state",
|
||||
)
|
||||
|
||||
h.expect_finding(
|
||||
"a missing state caption is reported",
|
||||
HEAD + ' <text data-axis="from" data-state="BEFORE" x="320" y="440">'
|
||||
"BEFORE</text>\n" + honest_rows_block() + TAIL,
|
||||
r"no 'to' state caption",
|
||||
)
|
||||
|
||||
h.expect_finding(
|
||||
"two captions reading the same thing is reported",
|
||||
document(honest_rows_block()).replace(
|
||||
'data-state="AFTER" x="680" y="440">AFTER<',
|
||||
'data-state="BEFORE" x="680" y="440">BEFORE<', 1),
|
||||
r"both state captions read 'BEFORE'",
|
||||
)
|
||||
|
||||
h.expect_finding(
|
||||
"two series names swapped between rows is reported",
|
||||
document(honest_rows_block()).replace(
|
||||
'data-role="name" x="272" y="%g">Search<' % (y(512) + 3.5),
|
||||
'data-role="name" x="272" y="%g">Catalog<' % (y(512) + 3.5), 1),
|
||||
r"name label bound to series 'Search' reads 'Catalog'",
|
||||
)
|
||||
|
||||
h.expect_finding(
|
||||
"a name label drawn on another series' row is reported",
|
||||
document(honest_rows_block()).replace(
|
||||
'data-series="Auth" data-end="from" data-role="name" x="272" y="%g"'
|
||||
% (y(154) + 3.5),
|
||||
'data-series="Auth" data-end="from" data-role="name" x="272" y="%g"'
|
||||
% (y(512) + 3.5), 1),
|
||||
r"name label for series 'Auth' is drawn at y=.*nearer 'Search'",
|
||||
)
|
||||
|
||||
h.expect_finding(
|
||||
"a series with no name label is reported",
|
||||
document(series("Search", 512, 208)
|
||||
+ labels("Search", 512, 208, skip_name="from")
|
||||
+ honest_rows_block([r for r in ROWS if r[0] != "Search"])),
|
||||
r"'Search' has no from name label",
|
||||
)
|
||||
|
||||
h.expect_finding(
|
||||
"a second label for the same endpoint is reported",
|
||||
document(honest_rows_block()
|
||||
+ ' <text data-series="Search" data-end="from" x="304" y="80">999'
|
||||
"</text>\n"),
|
||||
r"a second from value label for series 'Search'",
|
||||
)
|
||||
|
||||
h.expect_finding(
|
||||
"an endpoint label for a series with no line is reported",
|
||||
document(honest_rows_block()
|
||||
+ ' <text data-series="Ghost" data-end="from" x="304" y="80">300'
|
||||
"</text>\n"),
|
||||
r"names series 'Ghost', which no line declares",
|
||||
)
|
||||
|
||||
# 6. Fail closed, and read what is actually there.
|
||||
h.expect_finding(
|
||||
"a file named example-slopegraph with no series is reported, not passed",
|
||||
document(' <line x1="320" y1="40" x2="320" y2="420" stroke="#2d3142"/>\n'),
|
||||
r"presents as a slopegraph but declares 0 verifiable series",
|
||||
name="example-slopegraph-empty.html",
|
||||
)
|
||||
|
||||
h.expect_finding(
|
||||
"a file whose desc says slopegraph but declares no series is reported",
|
||||
document(' <line x1="320" y1="40" x2="320" y2="420" stroke="#2d3142"/>\n'),
|
||||
r"presents as a slopegraph but declares 0 verifiable series",
|
||||
name="figure.html",
|
||||
)
|
||||
|
||||
h.expect_finding(
|
||||
"a slopegraph declared only in a desc past 4000 chars is still detected",
|
||||
"<!doctype html><style>/*" + ("x" * 4200) + "*/</style>"
|
||||
+ '<svg role="img"><title>t</title>'
|
||||
+ "<desc>Slopegraph of latency with no declared series.</desc>"
|
||||
+ '<line x1="1" y1="1" x2="2" y2="2"/></svg>',
|
||||
r"presents as a slopegraph but declares 0 verifiable series",
|
||||
name="figure.html",
|
||||
)
|
||||
|
||||
for omitted in ("data-from", "data-to", "y1", "y2"):
|
||||
h.expect_finding(
|
||||
"a series line missing %s is reported rather than dropped" % omitted,
|
||||
document(series("Search", 512, 208, omit=omitted)
|
||||
+ labels("Search", 512, 208)
|
||||
+ honest_rows_block([r for r in ROWS if r[0] != "Search"])),
|
||||
r"missing %s" % omitted,
|
||||
)
|
||||
|
||||
h.expect_finding(
|
||||
"a declared value that is not a finite number is reported rather than dropped",
|
||||
document(' <line data-series="Search" data-from="half a second" '
|
||||
'data-to="208" x1="320" y1="72.1" x2="680" y2="328.8" '
|
||||
'stroke="#2d3142"/>\n'
|
||||
+ honest_rows_block([r for r in ROWS if r[0] != "Search"])),
|
||||
r"not a finite number",
|
||||
)
|
||||
|
||||
h.expect_finding(
|
||||
"a NaN coordinate is reported, not silently accepted",
|
||||
document(' <line data-series="a" data-from="nan" data-to="nan" x1="320" '
|
||||
'y1="nan" x2="680" y2="nan" stroke="#2d3142"/>\n'
|
||||
+ honest_rows_block()),
|
||||
r"not a finite number",
|
||||
)
|
||||
|
||||
h.expect_clean(
|
||||
"a commented-out series line is ignored, not read as data",
|
||||
document(honest_rows_block()
|
||||
+ ' <!-- old draft: <line data-series="ghost" data-from="999" '
|
||||
'data-to="1" x1="320" y1="10" x2="680" y2="410"/> -->\n'),
|
||||
)
|
||||
|
||||
h.expect_clean(
|
||||
"an honest slopegraph written with single-quoted attributes is parsed",
|
||||
HEAD + CAPTIONS + "".join(
|
||||
" <line data-series='%s' data-from='%s' data-to='%s' x1='320' "
|
||||
"y1='%g' x2='680' y2='%g' stroke='#2d3142'/>\n"
|
||||
% (n, f, t, y(f), y(t)) + labels(n, f, t) for n, f, t in ROWS) + TAIL,
|
||||
)
|
||||
|
||||
h.expect_finding(
|
||||
"a single-quoted series line with a nudged endpoint is reported",
|
||||
HEAD + CAPTIONS + "".join(
|
||||
" <line data-series='%s' data-from='%s' data-to='%s' x1='320' "
|
||||
"y1='%g' x2='680' y2='%g' stroke='#2d3142'/>\n"
|
||||
% (n, f, t, y(f), y(t) - (3.0 if n == "Search" else 0.0))
|
||||
+ labels(n, f, t) for n, f, t in ROWS) + TAIL,
|
||||
r"'Search' draws its to endpoint",
|
||||
)
|
||||
|
||||
h.expect_finding(
|
||||
"a <line> declaring data-series whose attributes cannot be parsed is reported",
|
||||
document(honest_rows_block()
|
||||
+ " <line data-series=unquoted data-from=1 data-to=2 "
|
||||
"x1=320 y1=40 x2=680 y2=420 stroke='#2d3142'/>\n"),
|
||||
r"declares data-series but its attributes could not be parsed",
|
||||
)
|
||||
|
||||
h.expect_clean(
|
||||
"axis rules and legend swatches without data-series are skipped silently",
|
||||
document(honest_rows_block()
|
||||
+ ' <line x1="320" y1="40" x2="320" y2="420" stroke="#2d3142"/>\n'
|
||||
' <line x1="40" y1="492" x2="64" y2="492" stroke="#eb6c36"/>\n'),
|
||||
)
|
||||
|
||||
for label in ("left", "right"):
|
||||
rows = ""
|
||||
for name, frm, to in ROWS:
|
||||
stray = name == "Search"
|
||||
rows += series(
|
||||
name, frm, to,
|
||||
x1=300 if (stray and label == "left") else 320,
|
||||
x2=640 if (stray and label == "right") else 680,
|
||||
) + labels(name, frm, to)
|
||||
h.expect_finding(
|
||||
"a series that does not share the %s axis position is reported" % label,
|
||||
document(rows), r"series do not share one x[12]",
|
||||
)
|
||||
|
||||
h.expect_finding(
|
||||
"a figure where every series holds one value on each axis is reported "
|
||||
"as unverifiable",
|
||||
honest([("api", 100, 120), ("web", 100, 120)]),
|
||||
r"neither axis has two distinct values",
|
||||
)
|
||||
|
||||
sci = ""
|
||||
for n, f, t in (("a", 512, 208), ("b", 376, 164), ("c", 291, 143),
|
||||
("d", 154, 121)):
|
||||
sci += (series(n, "%ge0" % f, "%ge0" % t, y1=y(f), y2=y(t))
|
||||
+ labels(n, f, t, shown_from="%ge0" % f, shown_to="%ge0" % t))
|
||||
h.expect_clean(
|
||||
"scientific-notation labels are parsed, not truncated to the mantissa",
|
||||
document(sci),
|
||||
)
|
||||
|
||||
# Real geometry, values offset by a billion: the two axes share one
|
||||
# transform, and only an intercept compared at value zero would call them
|
||||
# shifted. Mapping the raw billions through the fixture scale instead put
|
||||
# every point at y = -8.4e8, where the fit is degenerate and the case
|
||||
# passed without testing anything.
|
||||
base = 1000000000
|
||||
billions = ""
|
||||
for n, f, t in ROWS:
|
||||
billions += (series(n, base + f, base + t, y1=y(f), y2=y(t))
|
||||
+ labels(n, base + f, base + t, y_from=y(f), y_to=y(t)))
|
||||
h.expect_clean(
|
||||
"a shared scale on billion-magnitude values is not reported as shifted",
|
||||
document(billions),
|
||||
)
|
||||
|
||||
# ── The fixture-isolation fix, held in place ──────────────────────────
|
||||
sentinel = ROOT / "example-slopegraph-fixture.html"
|
||||
existed = sentinel.exists()
|
||||
if not existed:
|
||||
sentinel.write_text("KEEP ME\n", encoding="utf-8")
|
||||
try:
|
||||
h.run(honest(ROWS))
|
||||
h.run(honest(ROWS), name="example-slopegraph-fixture.html")
|
||||
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 slopegraph checker: %d case(s), both polarities" % h.count)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,754 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Verify that a slopegraph's drawn slopes match the values it prints.
|
||||
|
||||
A slopegraph makes exactly one claim: **both axes carry the same scale**, so the
|
||||
angle of a line is a rate of change and two angles are comparable. Break that
|
||||
claim and nothing errors - 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
|
||||
endpoint against the number sitting beside it.
|
||||
|
||||
Six invariants, each of which has shipped broken in a draft of this type:
|
||||
|
||||
1. SHARED SCALE - the left and right axes must map value to y through the same
|
||||
linear transform. Both halves are checked, slope *and* origin: a second axis
|
||||
that is rescaled makes every slope wrong by a factor, and one that is merely
|
||||
shifted makes every slope wrong by a constant. Checking the slope alone would
|
||||
pass the shifted case, which is the easier mistake to make.
|
||||
|
||||
2. NO JITTER - each declared value must sit where the shared scale puts it. The
|
||||
draft this checker was written against nudged two series apart by 3px because
|
||||
their labels collided at identical values; both then read as different
|
||||
numbers. Crowded endpoint labels are data, not a coordinate problem.
|
||||
|
||||
3. UNTRANSFORMED GEOMETRY - the coordinates read here are raw attributes, so any
|
||||
`transform` on verified geometry, on its labels, or on an ancestor moves the
|
||||
rendered mark away from the number this checker validated. A single
|
||||
`transform="translate(0 80)"` on one series line slid its endpoint 80px and
|
||||
every check still passed. Transforms are rejected rather than resolved: a
|
||||
partial implementation of the SVG transform stack is worse than an honest
|
||||
refusal, because it looks like coverage.
|
||||
|
||||
4. COMPLETE PRINTED VALUES - the whole visible numeric token must match the
|
||||
declared one. Matching only the first fragment read the label "512,000" as
|
||||
512 and agreed with metadata that said 512.
|
||||
|
||||
5. LABELS BOUND TO MEANING - state captions belong to a named axis, and each
|
||||
series name belongs to a series and an end. Unbound, the two captions can be
|
||||
swapped - reversing the reading of the entire figure - or two names exchanged
|
||||
between rows, with every geometric check still green.
|
||||
|
||||
6. FAIL CLOSED - a file that presents as a slopegraph but yields nothing
|
||||
parseable is a finding, never a pass. A checker that reports OK because it
|
||||
found nothing to compare is the bug, not the gate. `verify-treemap.py`
|
||||
returned early on `len(cells) < 3` and that is precisely how an undersized
|
||||
cell went unverified.
|
||||
|
||||
The basis for geometry is the `data-from` / `data-to` pair each series line
|
||||
declares, never the rendered text. Deriving the basis from text is what let a
|
||||
treemap cell with no label drop silently out of the checked set; here a series
|
||||
whose label is missing or restyled stays in the set and its missing label is
|
||||
itself reported.
|
||||
|
||||
WHAT THIS DOES NOT CHECK, deliberately:
|
||||
|
||||
- **Direction.** An axis whose y grows with value is accepted. That is correct
|
||||
for a rank slopegraph, where rank 1 belongs at the top, and this checker has no
|
||||
way to tell an intended inversion from a mistaken one.
|
||||
- **Absolute truth.** Every check here is internal consistency. If every label
|
||||
and every declared value is wrong by the same constant, the figure is
|
||||
self-consistent and passes; nothing in the file could reveal otherwise. The
|
||||
source line is where the domain is stated to a reader, and prose is not parsed.
|
||||
- **Curvature below tolerance.** A value-to-y map that bends by less than
|
||||
RESIDUAL_TOLERANCE at every point is indistinguishable from a straight one.
|
||||
|
||||
Usage:
|
||||
python3 scripts/verify-slopegraph.py --all
|
||||
python3 scripts/verify-slopegraph.py skills/diagram-design/assets/example-slopegraph.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"
|
||||
|
||||
LINE_RE = re.compile(r"<line\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 `-?\d+(\.\d+)?` read
|
||||
# "1e3" as 1 and "512,000" as 512, so a mangled label agreed with its metadata.
|
||||
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 made a single-quoted series
|
||||
# line invisible: the detector below sees `data-series` in the raw source either
|
||||
# way, but the attribute parser returned nothing, so the line was dropped from
|
||||
# the verified set without a word - a file carrying a 400px lie reported clean.
|
||||
ATTR_RE = re.compile(
|
||||
r"""(?P<name>[\w:-]+)\s*=\s*(?P<quote>["'])(?P<value>.*?)(?P=quote)""",
|
||||
re.DOTALL,
|
||||
)
|
||||
DECLARES_SERIES_RE = re.compile(r"\bdata-series\s*=", re.IGNORECASE)
|
||||
|
||||
# Coordinates ship rounded to one decimal, so a point can sit 0.05px from its
|
||||
# true position honestly; an author rounding to whole pixels can sit 0.5px off.
|
||||
# 1.0px clears both and still catches the smallest dishonest nudge worth making
|
||||
# (the draft's was 3px). In data units at the shipped 0.844 px-per-ms scale that
|
||||
# is 1.2ms of slack on values running 121-512 - about a quarter of one percent,
|
||||
# below the precision the labels themselves claim.
|
||||
RESIDUAL_TOLERANCE = 1.0 # px, per endpoint, against the shared scale
|
||||
ORIGIN_TOLERANCE = 0.5 # px, between the two axes at mid-domain
|
||||
SCALE_TOLERANCE = 1.0 # px, worst-case slope disagreement across the range
|
||||
VALUE_TOLERANCE = 0.001 # printed label vs declared attribute
|
||||
CAPTION_TOLERANCE = 0.5 # px, state caption x vs the axis it names
|
||||
ROW_TOLERANCE = 0.5 # px, slack before a label counts as another row
|
||||
|
||||
|
||||
class Series:
|
||||
__slots__ = ("name", "frm", "to", "x1", "y1", "x2", "y2", "offset")
|
||||
|
||||
def __init__(self, name, frm, to, x1, y1, x2, y2, offset):
|
||||
self.name = name
|
||||
self.frm, self.to = frm, to
|
||||
self.x1, self.y1 = x1, y1
|
||||
self.x2, self.y2 = x2, y2
|
||||
self.offset = offset
|
||||
|
||||
def value(self, end):
|
||||
return self.frm if end == "from" else self.to
|
||||
|
||||
def endpoint_y(self, end):
|
||||
return self.y1 if end == "from" else self.y2
|
||||
|
||||
def axis_x(self, end):
|
||||
return self.x1 if end == "from" else self.x2
|
||||
|
||||
|
||||
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 satisfied 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 ("512ms"); 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 y = slope * value + intercept.
|
||||
|
||||
Theil-Sen - the median of all pairwise slopes - rather than least squares, so
|
||||
that one dishonest point does not drag the line it is being measured against.
|
||||
Returns (None, None) when every value is the same, which is legitimate on an
|
||||
indexed axis and handled by the caller.
|
||||
"""
|
||||
slopes = [
|
||||
(yb - ya) / (vb - va)
|
||||
for index, (va, ya) in enumerate(points)
|
||||
for vb, yb in points[index + 1:]
|
||||
if vb != va
|
||||
]
|
||||
if not slopes:
|
||||
return None, None
|
||||
slope = median(slopes)
|
||||
return slope, median([y - slope * v for v, y 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. Theil-Sen's breakdown point is around 29%,
|
||||
which is zero outliers on a three-point axis: an indexed three-series figure
|
||||
with one endpoint 3px wrong produced a whole-set fit that called all three
|
||||
points clean, because the contaminated slope passed within 1px of every one
|
||||
of them. Excluding the point under test removes that escape.
|
||||
|
||||
Returns [(index, drawn, expected)]. Needs four points, so each fit is made
|
||||
from at least three.
|
||||
"""
|
||||
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_slopegraph(path: Path, source: str) -> bool:
|
||||
"""Does this file present itself as a slopegraph?
|
||||
|
||||
Deliberately generous. Anything that claims the type in its name, its
|
||||
accessible description, or its markup is held to the contract even if it
|
||||
declares no parseable series - that combination is the fail-closed case, not
|
||||
a pass. The whole document is searched: a 4000-character window missed a file
|
||||
whose only declaration sat behind a long stylesheet.
|
||||
"""
|
||||
if path.name.startswith("example-slopegraph"):
|
||||
return True
|
||||
if DECLARES_SERIES_RE.search(source):
|
||||
return True
|
||||
described = named_text(source)
|
||||
return "slopegraph" in described or "slope graph" 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 - which is 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_series(source: str, findings: list, name: str) -> list:
|
||||
"""Series lines, with anything unparseable reported rather than dropped."""
|
||||
series = []
|
||||
for match in LINE_RE.finditer(source):
|
||||
raw = match.group("attrs")
|
||||
attrs = attrs_of(raw)
|
||||
label = attrs.get("data-series")
|
||||
if label is None:
|
||||
# A <line> with no data-series is scenery - an axis rule, a legend
|
||||
# swatch - and skipping it is correct. But one whose raw text DOES
|
||||
# declare data-series and still parsed to nothing is markup this
|
||||
# checker cannot read, and dropping it silently is how a lie ships.
|
||||
if DECLARES_SERIES_RE.search(raw):
|
||||
findings.append(
|
||||
"%s:%d: a <line> declares data-series but its attributes could "
|
||||
"not be parsed — the checker will not silently skip markup it "
|
||||
"cannot read. Use plain double-quoted attributes"
|
||||
% (name, line_of(source, match.start()))
|
||||
)
|
||||
continue
|
||||
missing = [key for key in ("data-from", "data-to", "x1", "y1", "x2", "y2")
|
||||
if key not in attrs]
|
||||
if missing:
|
||||
findings.append(
|
||||
"%s:%d: series %r declares data-series but is missing %s — a series "
|
||||
"line must state both values and both endpoints or it cannot be verified"
|
||||
% (name, line_of(source, match.start()), label, ", ".join(missing))
|
||||
)
|
||||
continue
|
||||
parsed = [number(attrs[key])
|
||||
for key in ("data-from", "data-to", "x1", "y1", "x2", "y2")]
|
||||
if any(value is None for value in parsed):
|
||||
findings.append(
|
||||
"%s:%d: series %r has a value or coordinate that is not a finite "
|
||||
"number — cannot verify its slope"
|
||||
% (name, line_of(source, match.start()), label)
|
||||
)
|
||||
continue
|
||||
series.append(Series(label, parsed[0], parsed[1], parsed[2], parsed[3],
|
||||
parsed[4], parsed[5], match.start()))
|
||||
return series
|
||||
|
||||
|
||||
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 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 LINE_RE.finditer(source):
|
||||
attrs = attrs_of(match.group("attrs"))
|
||||
if "data-series" not in attrs:
|
||||
continue
|
||||
what = "series %r" % attrs["data-series"]
|
||||
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-series" not in attrs and "data-axis" 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_axes(series: list, findings: list, source: str, name: str) -> None:
|
||||
"""Every series must span the same two axis positions."""
|
||||
for attr, end in (("x1", "from"), ("x2", "to")):
|
||||
positions = sorted({round(s.axis_x(end), 3) for s in series})
|
||||
if len(positions) > 1:
|
||||
offenders = ", ".join("%s at %g" % (s.name, s.axis_x(end)) for s in series)
|
||||
findings.append(
|
||||
"%s:%d: series do not share one %s — found %s (%s). Every line must "
|
||||
"run between the same two axes or the slopes are not comparable"
|
||||
% (name, line_of(source, series[0].offset), attr,
|
||||
"/".join("%g" % p for p in positions), offenders)
|
||||
)
|
||||
|
||||
|
||||
def check_scale(series: list, findings: list, source: str, name: str) -> None:
|
||||
"""The two axes must share one linear value-to-y map, and no point may drift."""
|
||||
left = [(s.frm, s.y1) for s in series]
|
||||
right = [(s.to, s.y2) for s in series]
|
||||
combined = left + right
|
||||
values = [v for v, _ in combined]
|
||||
span = max(values) - min(values)
|
||||
mid = (max(values) + min(values)) / 2.0
|
||||
line = line_of(source, series[0].offset)
|
||||
|
||||
left_fit = fit(left)
|
||||
right_fit = fit(right)
|
||||
|
||||
if left_fit[0] is not None and right_fit[0] is not None:
|
||||
slope_drift = abs(left_fit[0] - right_fit[0]) * span
|
||||
if slope_drift > SCALE_TOLERANCE:
|
||||
findings.append(
|
||||
"%s:%d: the two axes are not on the same scale — %.4g px/unit on the "
|
||||
"left against %.4g on the right, which puts the same value up to "
|
||||
"%.1f px apart across the plotted range. Both axes must use one scale "
|
||||
"or every slope is a lie"
|
||||
% (name, line, left_fit[0], right_fit[0], slope_drift)
|
||||
)
|
||||
return
|
||||
# Compared at mid-domain, not at value zero. Raw intercepts are the
|
||||
# predicted y AT zero, so on values in the billions two axes sharing one
|
||||
# transform disagreed by hundreds of pixels there through nothing but
|
||||
# float cancellation, and an honest figure was reported as shifted.
|
||||
origin_drift = abs((left_fit[0] * mid + left_fit[1])
|
||||
- (right_fit[0] * mid + right_fit[1]))
|
||||
if origin_drift > ORIGIN_TOLERANCE:
|
||||
findings.append(
|
||||
"%s:%d: the two axes share a scale but not an origin — offset by "
|
||||
"%.1f px at mid-domain. A shifted axis tilts every slope by the same "
|
||||
"amount, so the comparison between series survives and every rate is "
|
||||
"wrong" % (name, line, origin_drift)
|
||||
)
|
||||
return
|
||||
# Both returns above are deliberate. Per-point residuals are measured
|
||||
# against "the" shared scale, so once the two axes disagree about what
|
||||
# that scale is there is nothing meaningful to measure against - every
|
||||
# point reports an error and the one finding that names the cause is
|
||||
# buried in its own consequences.
|
||||
elif left_fit[0] is None and right_fit[0] is None:
|
||||
findings.append(
|
||||
"%s:%d: neither axis has two distinct values, so the scale cannot be "
|
||||
"derived and no slope here is verifiable" % (name, line)
|
||||
)
|
||||
return
|
||||
|
||||
ends = ["from"] * len(left) + ["to"] * len(right)
|
||||
for index, drawn, expected in outliers(combined, RESIDUAL_TOLERANCE):
|
||||
s = series[index % len(series)]
|
||||
value = combined[index][0]
|
||||
findings.append(
|
||||
"%s:%d: series %r draws its %s endpoint (%g) at y=%g where the shared "
|
||||
"scale its peers describe puts %g at y=%.1f — off by %.1f px. Do not move "
|
||||
"a point to make room for its label"
|
||||
% (name, line_of(source, s.offset), s.name, ends[index], value, drawn,
|
||||
value, expected, abs(drawn - expected))
|
||||
)
|
||||
|
||||
|
||||
def misplaced_row(series: list, owner: str, end: str, y: float):
|
||||
"""A series whose endpoint is strictly nearer this label than its own owner's.
|
||||
|
||||
Nearer, not nearest. Two series holding the same value at an end sit at the
|
||||
same y by necessity - that is the honest rendering of equal values, and it is
|
||||
also what an indexed slopegraph does at every from-endpoint - so a tie must
|
||||
pass. Only a label that has drifted onto a row genuinely closer to someone
|
||||
else has been mislabelled.
|
||||
"""
|
||||
own = min(abs(y - s.endpoint_y(end)) for s in series if s.name == owner)
|
||||
for other in series:
|
||||
if other.name != owner and abs(y - other.endpoint_y(end)) < own - ROW_TOLERANCE:
|
||||
return other.name
|
||||
return None
|
||||
|
||||
|
||||
def collect_bound_labels(source: str, findings: list, name: str) -> dict:
|
||||
"""Bound labels keyed by (series, end, role), with duplicates reported."""
|
||||
found = {}
|
||||
for match in TEXT_RE.finditer(source):
|
||||
attrs = attrs_of(match.group("attrs"))
|
||||
label, end = attrs.get("data-series"), attrs.get("data-end")
|
||||
if label is None or end is None:
|
||||
continue
|
||||
role = attrs.get("data-role", "value")
|
||||
key = (label, end, role)
|
||||
if key in found:
|
||||
# A second label for one endpoint used to overwrite the first, so a
|
||||
# figure could print two contradictory numbers for one point and be
|
||||
# judged on whichever came last.
|
||||
findings.append(
|
||||
"%s:%d: a second %s %s label for series %r — one endpoint, one "
|
||||
"label, or the figure states two things about one point"
|
||||
% (name, line_of(source, match.start()), end, role, label)
|
||||
)
|
||||
continue
|
||||
found[key] = (plain(match.group("body")), number(attrs.get("y")),
|
||||
match.start())
|
||||
return found
|
||||
|
||||
|
||||
def check_labels(series: list, source: str, findings: list, name: str) -> None:
|
||||
"""Printed values and names must exist, be unique, match, and sit on their row."""
|
||||
bound = collect_bound_labels(source, findings, name)
|
||||
declared = {s.name for s in series}
|
||||
|
||||
for (label, end, role), (body, y, offset) in sorted(
|
||||
bound.items(), key=lambda item: item[1][2]
|
||||
):
|
||||
if label not in declared:
|
||||
findings.append(
|
||||
"%s:%d: a %s label names series %r, which no line declares — a label "
|
||||
"with no mark is not verifiable and reads as data"
|
||||
% (name, line_of(source, offset), role, label)
|
||||
)
|
||||
continue
|
||||
if end not in ("from", "to"):
|
||||
findings.append(
|
||||
"%s:%d: label for series %r has data-end=%r, which is neither "
|
||||
"'from' nor 'to'" % (name, line_of(source, offset), label, end)
|
||||
)
|
||||
continue
|
||||
# Row placement. A label bound to one series but drawn beside another's
|
||||
# endpoint mislabels the row, and no value check would notice: two names
|
||||
# exchanged between rows leaves every number correct in isolation.
|
||||
if y is not None:
|
||||
sits_by = misplaced_row(series, label, end, y)
|
||||
if sits_by is not None:
|
||||
findings.append(
|
||||
"%s:%d: the %s %s label for series %r is drawn at y=%g, nearer "
|
||||
"%r's endpoint than its own — a label on the wrong row renames "
|
||||
"the line"
|
||||
% (name, line_of(source, offset), end, role, label, y, sits_by)
|
||||
)
|
||||
|
||||
for s in series:
|
||||
for end in ("from", "to"):
|
||||
value_entry = bound.get((s.name, end, "value"))
|
||||
if value_entry is None:
|
||||
findings.append(
|
||||
"%s:%d: series %r prints no %s endpoint value — a slopegraph must "
|
||||
"label both ends, or the reader has a slope and no magnitude"
|
||||
% (name, line_of(source, s.offset), s.name, end)
|
||||
)
|
||||
else:
|
||||
body, _y, offset = value_entry
|
||||
shown, reason = printed_number(body)
|
||||
if shown is None:
|
||||
findings.append(
|
||||
"%s:%d: the %s endpoint label for %r %s (%r) — label both "
|
||||
"endpoints with one complete value each"
|
||||
% (name, line_of(source, offset), end, s.name, reason,
|
||||
body[:28])
|
||||
)
|
||||
elif abs(shown - s.value(end)) > VALUE_TOLERANCE:
|
||||
findings.append(
|
||||
"%s:%d: series %r prints %r at its %s endpoint but declares "
|
||||
"%g — the label and the geometry must state one number"
|
||||
% (name, line_of(source, offset), s.name, body, end,
|
||||
s.value(end))
|
||||
)
|
||||
name_entry = bound.get((s.name, end, "name"))
|
||||
if name_entry is None:
|
||||
findings.append(
|
||||
"%s:%d: series %r has no %s name label (data-role=\"name\") — a "
|
||||
"slopegraph names every series at both ends, and an unbound name "
|
||||
"can be swapped with another row undetected"
|
||||
% (name, line_of(source, s.offset), s.name, end)
|
||||
)
|
||||
continue
|
||||
body, _y, offset = name_entry
|
||||
if body != s.name:
|
||||
findings.append(
|
||||
"%s:%d: a name label bound to series %r reads %r — the visible "
|
||||
"name and the binding must agree"
|
||||
% (name, line_of(source, offset), s.name, body[:28])
|
||||
)
|
||||
|
||||
|
||||
def check_captions(series: list, source: str, findings: list, name: str) -> None:
|
||||
"""Each state caption must name the axis it is drawn against."""
|
||||
seen = {}
|
||||
for match in TEXT_RE.finditer(source):
|
||||
attrs = attrs_of(match.group("attrs"))
|
||||
end = attrs.get("data-axis")
|
||||
if end is None:
|
||||
continue
|
||||
if end not in ("from", "to"):
|
||||
findings.append(
|
||||
"%s:%d: a state caption has data-axis=%r, which is neither 'from' "
|
||||
"nor 'to'" % (name, line_of(source, match.start()), end)
|
||||
)
|
||||
continue
|
||||
if end in seen:
|
||||
findings.append(
|
||||
"%s:%d: a second %r state caption — one axis, one caption"
|
||||
% (name, line_of(source, match.start()), end)
|
||||
)
|
||||
continue
|
||||
seen[end] = (number(attrs.get("x")), plain(match.group("body")),
|
||||
attrs.get("data-state"), match.start())
|
||||
|
||||
for end in ("from", "to"):
|
||||
if end not in seen:
|
||||
findings.append(
|
||||
"%s: no %r state caption (data-axis=%r) — an unbound caption pair can "
|
||||
"be swapped, which reverses the reading of the whole figure"
|
||||
% (name, end, end)
|
||||
)
|
||||
continue
|
||||
x, body, declared, offset = seen[end]
|
||||
expected = series[0].axis_x(end)
|
||||
if x is None or abs(x - expected) > CAPTION_TOLERANCE:
|
||||
findings.append(
|
||||
"%s:%d: the %r state caption (%r) is drawn at x=%s but its axis is at "
|
||||
"x=%g — the captions are swapped or misplaced, which reverses the "
|
||||
"direction every slope is read in"
|
||||
% (name, line_of(source, offset), end, body[:20],
|
||||
"%g" % x if x is not None else "?", expected)
|
||||
)
|
||||
# Position alone is not enough: swapping just the two visible strings
|
||||
# leaves both captions where they were and reverses the figure anyway.
|
||||
# data-state binds the text, exactly as data-series binds a series name.
|
||||
if declared is None:
|
||||
findings.append(
|
||||
"%s:%d: the %r state caption (%r) has no data-state — its visible "
|
||||
"text is unbound, so it can be exchanged with the other caption and "
|
||||
"nothing here would notice"
|
||||
% (name, line_of(source, offset), end, body[:20])
|
||||
)
|
||||
elif body != declared:
|
||||
findings.append(
|
||||
"%s:%d: the %r state caption reads %r but declares data-state=%r — the "
|
||||
"visible caption and its binding must agree"
|
||||
% (name, line_of(source, offset), end, body[:20], declared)
|
||||
)
|
||||
|
||||
if len(seen) == 2:
|
||||
first, second = seen["from"], seen["to"]
|
||||
if first[1] and first[1] == second[1]:
|
||||
findings.append(
|
||||
"%s:%d: both state captions read %r — two states the reader cannot "
|
||||
"tell apart is not a comparison"
|
||||
% (name, line_of(source, second[3]), first[1])
|
||||
)
|
||||
|
||||
|
||||
def check_source(path: Path, raw: str) -> list:
|
||||
"""Findings for one already-read document."""
|
||||
source = blank_comments(raw)
|
||||
findings: list = []
|
||||
series = parse_series(source, findings, path.name)
|
||||
|
||||
if len(series) < 2:
|
||||
findings.append(
|
||||
"%s: presents as a slopegraph but declares %d verifiable series — every "
|
||||
"line needs data-series with data-from and data-to. Refusing to report "
|
||||
"OK on a file this checker could not read" % (path.name, len(series))
|
||||
)
|
||||
return findings
|
||||
|
||||
check_transforms(source, findings, path.name)
|
||||
check_axes(series, findings, source, path.name)
|
||||
check_scale(series, findings, source, path.name)
|
||||
check_labels(series, source, findings, path.name)
|
||||
check_captions(series, source, findings, path.name)
|
||||
return findings
|
||||
|
||||
|
||||
def check(path: Path) -> list:
|
||||
"""Findings for one file on disk, or [] if it is not a slopegraph."""
|
||||
raw = path.read_text(encoding="utf-8")
|
||||
if not looks_like_slopegraph(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 slopegraph slopes against the values they are labelled with."
|
||||
)
|
||||
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 slopegraph",
|
||||
)
|
||||
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 bar chart that was
|
||||
# skipped is a claim the run never made good on.
|
||||
if not looks_like_slopegraph(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 slopegraph finding(s) across %d file(s).%s"
|
||||
% (len(findings), checked, tail))
|
||||
return 1
|
||||
if not checked:
|
||||
print("OK slopegraph: no slopegraph found to check%s" % tail)
|
||||
return 0
|
||||
print("OK slopegraph: %d file(s), one shared scale on both axes, no transforms on "
|
||||
"verified geometry, and every printed value, name and caption bound to what "
|
||||
"it describes%s" % (checked, tail))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user