#!/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 = """
t
\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 (' \n' % (" ".join(parts), stroke))
def label(name: str, x: float, y: float, text=None, extra: str = "") -> str:
return (' %s\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 (' %s\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,
" \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 transform moves every mark inside it",
document(' \n', honest_block(),
" \n", TICKS),
r"an ancestor /